mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(agent-presets): answer health from the walk alone, and keep the reason reachable
`import.meta.resolve`'s `parentURL` argument takes effect only under `--experimental-import-meta-resolve`, which no launch passes, so the fallback resolved from this module rather than from the harness — the one question it existed to answer. The disk walk is the whole answer now, and the refusal memo it needed goes with it. A `file:` URL joins the file branch rather than the package one, where a resolver would only normalize it and report a missing target as present, and a row is skipped on the Loader's own `Boolean(disabled)` so `disabled: 0` is checked like the Loader checks it. A broken card says so through `aria-disabled` rather than `disabled`, and refuses the pick in its own handler. `disabled` took it out of the tab order, which with the reason moved onto the badge left it unreachable without a pointer — reachable before this change, so hiding it was a regression rather than a path that never existed. Both notes this decision partly supersedes are updated in place and cross-linked, one README pair loses an editing residue that repeated a sentence, and the single-row diagnostic no longer reads "row row 1".
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md
|
||||
2026-08-26-preset-health-resolves-rows.md: 1615b2b86bee27a282357e1d5f023797672cb9b5
|
||||
2026-08-26-preset-health-resolves-rows.zh.md: ea287ae8204aa1730d80199b0f4e86e3cf7b42a3
|
||||
2026-08-26-preset-health-resolves-rows.md: 909423e373bc2368b5fb1e81b1172275920282aa
|
||||
2026-08-26-preset-health-resolves-rows.zh.md: d2cffae66f7bd7fb78e068bd4242d009b95d3c5c
|
||||
|
||||
@@ -8,6 +8,8 @@ English | [中文](2026-08-26-preset-health-resolves-rows.zh.md)
|
||||
|
||||
A preset the roster listed as healthy could still be impossible to compose. Discovery's health check proved the composition parsed in the loader dialect and held named rows, and deliberately stopped there — it resolved no plugin name and applied no config.
|
||||
|
||||
This note partly supersedes [broken presets are roster rows](../bug-fix/2026-08-09-broken-preset-roster-rows.md), whose rejected "validating deep" alternative is what shipped here, and it moved the reason off the card face; it also relaxes the shipped-roster assertion [plugin-owned shipped preset root](../bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md) records. Both are updated in place.
|
||||
|
||||
`broken` is load-bearing, though, not a card decoration. `presetOptions` drops a broken row from the session pickers so a chooser never defers the discovery to a failed session start, and `resolveMountable` refuses one before spending a mount. Everything downstream therefore reads "not broken" as "will compose".
|
||||
|
||||
The gap surfaced when the [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) renamed packages under the pre-release stance. In-repo references moved with it; a preset authored under `<dshHome>/.agent-presets` did not, and one naming `@deepseek-ai/dsh-workspace-context` kept its healthy card, kept its place in the picker, and failed only when a person switched to it. A row naming a package a later release renamed or uninstalled is how an authored preset actually rots, and it was exactly the class the check excluded.
|
||||
@@ -16,11 +18,11 @@ The failure it did produce named less than it knew. The Loader's per-row wrapper
|
||||
|
||||
## Decision
|
||||
|
||||
**Discovery resolves each row it can prove will start, and imports nothing.** The resolve pass runs after the shape check in `packages/preset/agent-presets/src/discovery.ts`, so a malformed composition still answers with the shape reason. A package name is looked up on disk — Node's own upward `node_modules` walk, stopping at `<package>/package.json` — and only a name that finds nothing there is confirmed through `import.meta.resolve`, whose refusals are then remembered for the process. A preset-relative or absolute specifier is statted instead, because `import.meta.resolve` only joins URLs for those and a preset shipping a file that was deleted would otherwise pass. Nothing is evaluated either way.
|
||||
**Discovery resolves each row it can prove will start, and imports nothing.** The resolve pass runs after the shape check in `packages/preset/agent-presets/src/discovery.ts`, so a malformed composition still answers with the shape reason. A package name is looked up on disk — Node's own upward `node_modules` walk, stopping at `<package>/package.json`. A preset-relative or absolute specifier is statted instead, because `import.meta.resolve` only joins URLs for those and a preset shipping a file that was deleted would otherwise pass. Nothing is evaluated either way.
|
||||
|
||||
The disk lookup is the fast path because the resolver is not one: a registered ESM loader hook turns every `import.meta.resolve` call into a synchronous round-trip to the hooks thread. Under the `tsx` hook the source launch installs, that measured 2ms for a hit and 5ms for a miss against 0.055ms and 0.032ms on bare Node, which put 238ms of resolver time into each roster read. The walk answers the same 135 rows in 0.7ms. Keeping the resolver for names the walk cannot find leaves a read paying for the failures it reports rather than for every row it clears, and keeps a package only a loader can resolve — through tsconfig paths, or an import map — from being called broken. A Node builtin short-circuits ahead of both.
|
||||
The disk lookup, not `import.meta.resolve`, for two reasons. It is the cheap one: a registered ESM loader hook turns every resolver call into a synchronous round-trip to the hooks thread, measured under the `tsx` hook the source launch installs at 2ms for a hit and 5ms for a miss against 0.055ms and 0.032ms on bare Node — 238ms of resolver time per roster read, where the walk answers the same 135 rows in 0.7ms. It is also the only one that can be asked about the harness at all: `import.meta.resolve`'s `parentURL` argument takes effect only under `--experimental-import-meta-resolve`, which no launch passes, so it resolves from the calling module and would answer about this package rather than about the deployment. The resolver that does honour an explicit parent is the Loader's internal one, whose `resolveSync` signature differs between Node 22 and 24. A Node builtin short-circuits ahead of the walk.
|
||||
|
||||
The refusal memo sits behind the walk, never in front of it, so a package installed since is found on disk and a recorded refusal cannot go stale in the direction that matters.
|
||||
What the walk gives up: a package resolvable only through a loader hook — an import map, or a tree with no `node_modules` — is reported broken. No supported install produces one, because `dsh plugin install` puts every plugin beside the roster.
|
||||
|
||||
**One classifier decides where a row resolves.** `src/specifier.ts` owns the split — `cordis:` builtin, preset-relative, absolute file, package name — and both the mount's import override and discovery's check read it. A row discovery resolved from one base while the mount imported it from another would be reported healthy and then fail to load.
|
||||
|
||||
@@ -30,7 +32,7 @@ The refusal memo sits behind the walk, never in front of it, so a package instal
|
||||
|
||||
**The mount diagnostic follows a cause that carries more than its message.** `mountDetail` reads branches from `AggregateError.errors`, or from `error.cause.errors` when the cause is an `AggregateError`; a plain cause chain is already flattened into the message and is not followed, which would print every line twice. Nested branches indent under the row that owns them.
|
||||
|
||||
**The client puts the reason on the badge.** The card face keeps the preset's own description, because a package specifier tells a chooser nothing they can act on there. The host's reason is the badge's tooltip for a pointer, and a visually hidden `role="alert"` node carries it to assistive technology — the card body is disabled when a preset is broken, so it leaves the tab order and the tooltip has no keyboard path.
|
||||
**The client puts the reason on the badge.** The card face keeps the preset's own description, because a package specifier tells a chooser nothing they can act on there. The host's reason is revealed by hovering the badge or focusing the card, and a visually hidden `role="alert"` node announces it. A broken card says so through `aria-disabled` rather than `disabled` and refuses the pick in its own handler: `disabled` would take it out of the tab order, and with the reason off the face that would leave anyone without a pointer unable to reach it at all.
|
||||
|
||||
**A refused switch says why, where it was refused.** The chip's own label reverts to the preset the session still runs, so without a word the pick simply appears not to have happened. It announces through the shared `Toast`, over the composer column, the way the model picker beside it already reports a rejected selection. Only a pick a person just made is announced — the applier also runs when a session becomes current, and a banner over that would report a refusal nobody asked for. The banner holds for eight seconds rather than the primitive's three, because it carries a cause that names packages and rows; `Toast` gained a `holdMs` for that, which also retired the hazard of a hold constant the stylesheet had to be kept in step with by hand.
|
||||
|
||||
@@ -58,7 +60,7 @@ The wire already separated the two texts this needs: `message` wraps the cause i
|
||||
|
||||
**Reuse the icon row's `data-tip` pseudo-element for the tooltip.** Rejected once measured: generated content joins an element's accessible text, so the card's aria snapshot grew a second verbatim copy of a reason the alert already carried. A real `aria-hidden` element keeps exactly one accessible copy — and the existing tooltip is one `nowrap` line sized for an icon label, while this one names package specifiers one per line.
|
||||
|
||||
**Make the badge a focusable control so the tooltip has a keyboard path.** Rejected for now. The badge sits inside the card's own `<button>`, so a focusable trigger means restructuring the card head; the visually hidden alert already reaches assistive technology, and no keyboard-only path regressed — the disabled card body was never in the tab order.
|
||||
**Make the badge itself the focusable control.** Rejected: the badge sits inside the card's own `<button>`, so a focusable trigger there means restructuring the card head. Keeping the card focusable through `aria-disabled` reveals the same tooltip from the same key press and changes no layout. Leaving the reason reachable by pointer alone was rejected too — it was visible without any interaction before this change, so hiding it behind hover is a regression for anyone reading by keyboard rather than a path that never existed.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ Status: implemented
|
||||
|
||||
名单列为健康的 preset,仍可能根本无法组装。发现过程的健康检查只证明组装能以加载器方言解析、由具名行组成,并刻意止步于此——它不解析任何插件名,也不应用任何配置。
|
||||
|
||||
本 note 部分取代了[损坏的 preset 是名单行](../bug-fix/2026-08-09-broken-preset-roster-rows.zh.md):那份 note 在 Alternatives 中否决的「深度校验」正是这里落地的做法,而且原因也已移出卡片正面;它同时放宽了[插件自带内置 preset 根](../bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md)记录的随附名单断言。两份都已就地更新。
|
||||
|
||||
但 `broken` 是承重的,不是卡片上的装饰。`presetOptions` 会把损坏的行从会话选择器里滤掉,好让选择的人不必等到会话启动失败才发现;`resolveMountable` 会在花费一次挂载之前拒绝它。因此下游一切都把「不是 broken」读作「能组装」。
|
||||
|
||||
这个缺口在[仓库命名契约](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)按预发布立场重命名包时暴露出来。仓库内的引用随之更新;写在 `<dshHome>/.agent-presets` 下的 preset 没有,于是引用 `@deepseek-ai/dsh-workspace-context` 的那一个保住了健康的卡片、保住了在选择器里的位置,直到有人切换过去才失败。引用了被后续版本改名或卸载的包,正是手写 preset 真正的腐化方式,而它恰好是这项检查排除掉的那一类。
|
||||
@@ -16,11 +18,11 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
**发现过程解析每一行它能证明会启动的行,且不 import 任何东西。** 解析这一趟跑在 `packages/preset/agent-presets/src/discovery.ts` 的形状检查之后,因此格式错误的组装仍然回答形状原因。包名先在磁盘上查——就是 Node 自己那套向上走 `node_modules`、停在 `<包>/package.json` 的做法——只有在磁盘上查不到的名字才交给 `import.meta.resolve` 确认,而它的拒绝会在本进程内记下来。preset 相对路径与绝对路径改用 stat,因为对这两类 `import.meta.resolve` 只做 URL 拼接,否则一个丢失了自带文件的 preset 会蒙混过关。两条路都不求值。
|
||||
**发现过程解析每一行它能证明会启动的行,且不 import 任何东西。** 解析这一趟跑在 `packages/preset/agent-presets/src/discovery.ts` 的形状检查之后,因此格式错误的组装仍然回答形状原因。包名先在磁盘上查——就是 Node 自己那套向上走 `node_modules`、停在 `<包>/package.json` 的做法。preset 相对路径与绝对路径改用 stat,因为对这两类 `import.meta.resolve` 只做 URL 拼接,否则一个丢失了自带文件的 preset 会蒙混过关。两条路都不求值。
|
||||
|
||||
磁盘查找之所以是快路径,是因为解析器并非一件均质的事:只要注册了 ESM loader hook,每一次 `import.meta.resolve` 调用就变成一次到 hooks 线程的同步往返。在源码启动所用的 `tsx` hook 下,实测命中 2ms、未命中 5ms,而裸 Node 分别是 0.055ms 与 0.032ms——这让每次名单读取背上 238ms 的解析器时间。同样这 135 行,磁盘走法只要 0.7ms。把解析器留给磁盘找不到的名字,等于让一次读取只为它报出的失败付费,而不为它放行的每一行付费,同时也避免把只有 loader 能解析的包——经由 tsconfig paths 或 import map——判成损坏。Node 内建模块在两者之前直接短路。
|
||||
用磁盘查找而不是 `import.meta.resolve`,有两个理由。它便宜:只要注册了 ESM loader hook,每一次解析器调用就变成一次到 hooks 线程的同步往返,在源码启动所用的 `tsx` hook 下实测命中 2ms、未命中 5ms,而裸 Node 分别是 0.055ms 与 0.032ms——每次名单读取要背上 238ms 的解析器时间,而同样这 135 行磁盘走法只要 0.7ms。它也是唯一问得到「宿主」的:`import.meta.resolve` 的 `parentURL` 参数只在 `--experimental-import-meta-resolve` 下生效,而没有任何启动方式传它,因此它是相对调用方模块解析的,回答的是关于本包而不是关于部署的问题。真正认显式 parent 的是 Loader 的内部解析器,而它的 `resolveSync` 在 Node 22 与 24 上签名不同。Node 内建模块在磁盘查找之前直接短路。
|
||||
|
||||
拒绝记录位于磁盘查找之后而非之前,因此后来装上的包会先在磁盘上被找到,已记下的拒绝不会在要紧的方向上过期。
|
||||
磁盘走法放弃了什么:只有经由 loader hook 才能解析的包——import map,或根本没有 `node_modules` 的目录树——会被报为损坏。任何受支持的安装都不会产出这种情况,因为 `dsh plugin install` 会把每个插件装在名单旁边。
|
||||
|
||||
**只有一个分类器决定一行在哪里解析。** `src/specifier.ts` 拥有这个划分——`cordis:` 内建、preset 相对、绝对文件、包名——挂载的 import 覆写与发现过程的检查都读它。若发现过程按一个基准解析、而挂载按另一个基准 import,那一行会被报告为健康,然后加载失败。
|
||||
|
||||
@@ -30,7 +32,7 @@ Status: implemented
|
||||
|
||||
**挂载诊断跟随携带信息多于自身 message 的 cause。** `mountDetail` 从 `AggregateError.errors` 取分支,或在 cause 是 `AggregateError` 时从 `error.cause.errors` 取;普通的 cause 链已被展平进 message,不再跟随,否则每一行都会打印两遍。嵌套分支在拥有它的那一行下缩进。
|
||||
|
||||
**客户端把原因放到徽标上。** 卡片正面保留 preset 自己的描述,因为在那里一个包说明符不足以让选择的人采取行动。宿主给出的原因作为徽标的提示条服务于指针,另有一个视觉隐藏的 `role="alert"` 节点把它送达辅助技术——preset 损坏时卡片主体被禁用,因而离开 tab 序列,提示条也就没有键盘路径。
|
||||
**客户端把原因放到徽标上。** 卡片正面保留 preset 自己的描述,因为在那里一个包说明符不足以让选择的人采取行动。宿主给出的原因在悬停徽标或聚焦卡片时展开,另有一个视觉隐藏的 `role="alert"` 节点负责朗读。损坏的卡片用 `aria-disabled` 而非 `disabled` 表达这件事,并在自己的处理函数里拒绝这次选择:`disabled` 会把它移出 tab 序列,而原因已不在正面,那等于让不用指针的人完全够不到它。
|
||||
|
||||
**被拒绝的切换要在被拒绝的地方说明原因。** chip 的标签会弹回会话仍在运行的那个 preset,因此不说话的话,这次选择看起来就像根本没发生。它经由共享的 `Toast` 在 composer 列上方自报,与旁边的模型选择器报告被拒绝的选择方式一致。只有人刚做出的选择会被自报——应用器在会话成为当前会话时也会运行,为那种情况弹横幅等于报告一个没人问过的拒绝。横幅停留八秒而非 primitive 默认的三秒,因为它承载的原因要点名包与行;`Toast` 为此获得了 `holdMs`,顺带也消除了「停留常量必须由人手与样式表保持同步」这一隐患。
|
||||
|
||||
@@ -58,7 +60,7 @@ Status: implemented
|
||||
|
||||
**复用图标行的 `data-tip` 伪元素来做提示条。** 实测后否决:生成内容会并入元素的可访问文本,因此卡片的 aria 快照多出一份 alert 已经携带的原因的逐字副本。改用真实的 `aria-hidden` 元素后,可访问副本恰好只有一份——而且既有那条提示条是为图标标签准备的单行 `nowrap`,这一条要逐行列出包说明符。
|
||||
|
||||
**把徽标做成真正可聚焦的控件,让提示条有键盘路径。** 暂不采用。徽标位于卡片自身的 `<button>` 内部,可聚焦的触发器意味着改造卡片头部结构;视觉隐藏的 alert 已经触达辅助技术,且没有任何键盘路径退化——被禁用的卡片主体从来就不在 tab 序列里。
|
||||
**把徽标本身做成可聚焦控件。** 否决:徽标位于卡片自身的 `<button>` 内部,在那里放可聚焦触发器意味着改造卡片头部结构。用 `aria-disabled` 让卡片保持可聚焦,同一次按键就能展开同一条提示条,且不改动任何布局。「原因只用指针可达」同样被否决——这次改动之前它无需任何交互就可见,因此把它藏到悬停之后,对用键盘阅读的人是退化,而不是一条本就不存在的路径。
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md
|
||||
2026-08-09-broken-preset-roster-rows.md: 069585957d4d99598cc38e4a7c6bc8c8d82490ca
|
||||
2026-08-09-broken-preset-roster-rows.zh.md: 3bd60403edcce8ba7668c66561ce24c4d08b57e8
|
||||
2026-08-09-broken-preset-roster-rows.md: 4ae92f0b12f2ebede925d6047b358b8cafb10149
|
||||
2026-08-09-broken-preset-roster-rows.zh.md: 60d1ff533e3e968e04dad8526042fe4579752569
|
||||
|
||||
@@ -12,7 +12,7 @@ With files as the only composition editor, hand-edit damage had two failure shap
|
||||
|
||||
Discovery owns health, and a damaged directory is a **roster row carrying a `broken` reason**, never a gap. `scanRoot` treats every directory whose name is a usable preset id as a preset slot: composition missing → broken ("still occupies the id; delete it or restore the file"), composition unreadable/unparsable/not-a-list-of-named-rows → broken with the parser's first line. The shape check parses with the loader's own `entryListSchema` (the `!!js` dialect), so health can never call broken what the loader would accept; directories whose names fail `PRESET_ID` are skipped outright, because no copy could ever collide with them. `broken` rides `AgentPreset`, the `agentPreset.list` wire entry, and the UI row. Mounting paths (`mount`/`recompose`/`standingKeyFor`) refuse a broken preset up front via `resolveMountable` with the discovery-reported reason; `resolve` still answers (delete/read/report need the row), and `copy`'s roster check now sees ghosts, which turns the "already exists" refusal actionable — the broken card to delete is on the same page.
|
||||
|
||||
Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure.
|
||||
Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too; the reason itself has since moved off the card face onto the badge, see the note above), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -30,4 +30,4 @@ Surfaces split by their job: the management section renders broken rows as marke
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment.
|
||||
Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Partly superseded — [preset health resolves the rows it can prove will start](../architecture/2026-08-26-preset-health-resolves-rows.md) now RESOLVES each such row without importing it, which is cheap enough to run on every read; the mount still owns the failures resolution cannot see. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment.
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
发现过程负责健康,受损目录是**携带 `broken` 原因的名单行**,绝不是空缺。`scanRoot` 把名字是可用 preset id 的每个目录都当作一个 preset 槽位:组装缺失 → broken(「仍占着该 id;删除目录或恢复文件」),组装不可读/解析失败/不是具名行列表 → broken 并携带解析器的首行。形状检查用加载器自己的 `entryListSchema`(含 `!!js` 的方言)解析,因此健康检查绝不会把加载器接受的组装叫作损坏;名字不符合 `PRESET_ID` 的目录直接跳过,因为复制永远不可能与之相撞。`broken` 依次落在 `AgentPreset`、`agentPreset.list` 的线上条目和 UI 行上。挂载路径(`mount`/`recompose`/`standingKeyFor`)经 `resolveMountable` 用发现时记下的原因在前置拒绝;`resolve` 照样应答(删除/读取/上报都需要这一行),而 `copy` 的名单检查现在看得见幽灵,让「已存在」的拒绝变得可操作——要删的损坏卡片就在同一页上。
|
||||
|
||||
界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。
|
||||
界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给;原因本身此后已从卡片正面移到徽标上,见上述 note),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。
|
||||
|
||||
## 后果
|
||||
|
||||
@@ -30,4 +30,4 @@ Status: implemented
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。
|
||||
隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。已被部分取代——[预设健康解析它能证明会启动的行](../architecture/2026-08-26-preset-health-resolves-rows.zh.md)现在会**解析**每一个这样的行而不 import 它,便宜到足以每次读取都跑;挂载仍然拥有解析看不见的那些失败。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md
|
||||
2026-08-20-plugin-owned-shipped-preset-root.md: 43bcc685c2edfa5d125139b998d75ce8b308f60d
|
||||
2026-08-20-plugin-owned-shipped-preset-root.zh.md: c2cc586a7a17d7cdb818523a74320fae106eb773
|
||||
2026-08-20-plugin-owned-shipped-preset-root.md: 5af1d213f62acfc92ba053e7246adf55e32b4c34
|
||||
2026-08-20-plugin-owned-shipped-preset-root.zh.md: e5f2e08e221258899433f363109fb385b7472dc6
|
||||
|
||||
@@ -18,7 +18,7 @@ This completes the [per-session preset roster](../architecture/2026-08-03-per-se
|
||||
|
||||
## Testing
|
||||
|
||||
`shipped-root.spec.ts` covers the plugin ownership directly: a bare roster lists the four shipped presets healthy and `system`-trusted (proving the moved files resolve from the package), the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and `includeShippedRoot: false` mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built `lib/` verifies the bundled layout resolves the directory too. Gate scripts (`verify-cordis-config`, `verify-runtime-closure`) scan the new location.
|
||||
`shipped-root.spec.ts` covers the plugin ownership directly: a bare roster lists the four shipped presets `system`-trusted and carrying no reason other than unresolved rows (proving the moved files resolve from the package). Health has since grown a module-resolution pass — [preset health resolves the rows it can prove will start](../architecture/2026-08-26-preset-health-resolves-rows.md) — and a fixture base is not the install a shipped row's packages sit in, so the assertion names the reason it tolerates rather than requiring none, the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and `includeShippedRoot: false` mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built `lib/` verifies the bundled layout resolves the directory too. Gate scripts (`verify-cordis-config`, `verify-runtime-closure`) scan the new location.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
## 测试
|
||||
|
||||
`shipped-root.spec.ts` 直接覆盖插件所有权:裸 roster 列出四套内置 preset 且健康、`system` 信任(证明搬移后的文件能从包内解析);内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;`includeShippedRoot: false` 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built `lib/` 运行验证打包布局同样解析得到目录。门禁脚本(`verify-cordis-config`、`verify-runtime-closure`)扫描新位置。
|
||||
`shipped-root.spec.ts` 直接覆盖插件所有权:裸 roster 列出四套内置 preset 为 `system` 信任、且除未解析行外不携带其他原因(证明搬移后的文件能从包内解析)。健康检查此后新增了一趟模块解析——见[预设健康解析它能证明会启动的行](../architecture/2026-08-26-preset-health-resolves-rows.zh.md)——而 fixture 基准并不是内置行所引用的包所在的那个安装,因此该断言点名它容忍的原因,而不是要求一个都没有;内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;`includeShippedRoot: false` 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built `lib/` 运行验证打包布局同样解析得到目录。门禁脚本(`verify-cordis-config`、`verify-runtime-closure`)扫描新位置。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
}, 60_000)
|
||||
|
||||
it('re-reads the slash catalog through the composition the switch installed', async () => {
|
||||
// Continues the previous case: the chip has already applied `minimal` to
|
||||
// Continues 'applies the staged pick': the chip has already applied `minimal` to
|
||||
// the blank session, and this one reads the menu that switch left behind.
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog'))
|
||||
const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
|
||||
README.md: 20d8df62361e683ac302b40138984bc4ab7aad67
|
||||
README.zh.md: a99695909cabdd871d87e6cd75bd92f0d22329e5
|
||||
README.md: 1f07b034839dafcb0794d1a2e41b5015f5a55df0
|
||||
README.zh.md: 2aa5420f2092d974e293ddce97d34517c63e37b8
|
||||
|
||||
@@ -43,7 +43,7 @@ When the roster carries the self-referential `cordis` preset, a dashed add-card
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
Options and the current default both come from one `agentPreset.list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to MOUNT is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to MOUNT is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPreset.read`, `copy`, `openDocument`, `remove`, `list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, remove, and openDocument manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/changed`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
|
||||
Options and the current default both come from one `agentPreset.list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPreset.read`, `copy`, `openDocument`, `remove`, `list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, remove, and openDocument manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/changed`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
选项与当前默认值都来自同一次 `agentPreset.list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的字段。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被宿主拒绝**挂载**的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPreset.read`、`copy`、`openDocument`、`remove`、`list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、remove 与 openDocument 管理名单并驱动宿主桌面。分区在自身操作、`settings/changed` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。
|
||||
选项与当前默认值都来自同一次 `agentPreset.list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的字段。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPreset.read`、`copy`、`openDocument`、`remove`、`list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、remove 与 openDocument 管理名单并驱动宿主桌面。分区在自身操作、`settings/changed` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
|
||||
const [open, setOpen] = useState(false)
|
||||
// The seq keys the banner, so picking the same broken preset twice replays
|
||||
// it rather than leaving the first one silently in place.
|
||||
const seatRef = useRef<HTMLButtonElement>(null)
|
||||
const toastSeq = useRef(0)
|
||||
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
||||
|
||||
@@ -184,7 +183,6 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
ref={seatRef}
|
||||
type="button"
|
||||
className={css.seat}
|
||||
aria-haspopup="menu"
|
||||
@@ -206,13 +204,10 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
|
||||
icon={<IconWarningOutline16 />}
|
||||
holdMs={REFUSAL_HOLD_MS}
|
||||
// The composer card, which is the content column this chip sits
|
||||
// above — not an ancestor of it, so the lookup is a page query
|
||||
// rather than `closest`. Absent, the banner centers on the window,
|
||||
// which is off-center whenever the sidebar is open.
|
||||
anchor={
|
||||
seatRef.current?.closest<HTMLElement>('[data-composer-card]')
|
||||
?? document.querySelector<HTMLElement>('[data-composer-card]')
|
||||
}
|
||||
// above rather than inside — hence a page query, not `closest`.
|
||||
// Absent, the banner centers on the window, which is off-center
|
||||
// whenever the sidebar is open.
|
||||
anchor={document.querySelector<HTMLElement>('[data-composer-card]')}
|
||||
onDone={() => { setToast(null) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -121,15 +121,28 @@
|
||||
transition: opacity .12s;
|
||||
}
|
||||
|
||||
.brokenBadge:hover .brokenTip {
|
||||
/* Focusing the card reveals it too: a broken card keeps its place in the tab
|
||||
order (`aria-disabled`, not `disabled`), so this is the keyboard path to a
|
||||
reason the card face no longer shows. */
|
||||
.brokenBadge:hover .brokenTip,
|
||||
.cardMain:focus-visible .brokenTip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The same reason, for assistive technology only. The card body is disabled
|
||||
when a preset is broken, so it leaves the tab order and the badge's tooltip
|
||||
has no keyboard or screen-reader path; this node is that path. Sighted
|
||||
pointer users read the badge instead, which keeps a picker card showing the
|
||||
preset's own description rather than a package specifier. */
|
||||
.cardMain[aria-disabled='true'] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cardMain:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* The same reason, for a screen reader. A pointer reveals the badge's tooltip
|
||||
and a keyboard reveals it by focusing the card, but neither announces it;
|
||||
this node is what does. Keeping it out of the card face is what lets a
|
||||
picker card show the preset's own description rather than a package
|
||||
specifier. */
|
||||
.cardBrokenReason {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -256,14 +256,20 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
common act, so it should not hide behind a small button.
|
||||
The action row sits outside it — nesting buttons is
|
||||
invalid, and these act on the card rather than select it.
|
||||
A broken preset cannot compose a session, so its body is
|
||||
disabled; the reason rides the badge rather than the card
|
||||
face, which stays the preset's own description. */}
|
||||
A broken preset cannot compose a session, so its body
|
||||
refuses the pick; the reason rides the badge rather than
|
||||
the card face, which stays the preset's own
|
||||
description. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={row.isDefault}
|
||||
disabled={row.isDefault || row.broken !== undefined}
|
||||
// Broken says so through `aria-disabled` rather than
|
||||
// `disabled`, which would take the card out of the tab
|
||||
// order. With the reason moved onto the badge, that is
|
||||
// the only way anyone without a pointer reaches it.
|
||||
disabled={row.isDefault}
|
||||
aria-disabled={row.broken !== undefined}
|
||||
// Without this the name is the whole card read aloud —
|
||||
// title, badge, description, id.
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
|
||||
@@ -271,7 +277,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
// tooltips over one target would race, and the card's
|
||||
// own label answers what clicking it would do.
|
||||
title={row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
onClick={() => {
|
||||
if (row.broken !== undefined) return
|
||||
void props.makeDefault(row.id)
|
||||
}}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{text.name}</span>
|
||||
|
||||
@@ -194,8 +194,12 @@ describe('the preset list', () => {
|
||||
// Reachable without a pointer: the disabled body leaves the tab order, so
|
||||
// this node is the only reading assistive technology gets.
|
||||
expect(within(ghost).getByRole('alert').textContent).toContain('is missing')
|
||||
// `aria-disabled`, not `disabled`: the card stays in the tab order so a
|
||||
// keyboard reaches the reason the face no longer shows, and refuses the
|
||||
// pick itself rather than by being unreachable.
|
||||
const body = within(ghost).getByRole('button', { name: `${en.brokenBadge}: 幽灵预设` })
|
||||
expect(body).toHaveProperty('disabled', true)
|
||||
expect(body).toHaveProperty('disabled', false)
|
||||
expect(body.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(body)
|
||||
expect(actions.makeDefault).not.toHaveBeenCalled()
|
||||
// Copying a broken preset would only mint another broken one; deleting
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: bd3aa5292c189a0d1b62f6d5a20314f0be5e986c
|
||||
README.zh.md: 55b808aa73ff9cafad98d2dfca877263f0ca8723
|
||||
README.md: c88f101eee2e6988d06ee75f564ca4aeffd5efe6
|
||||
README.zh.md: 0af43e4831fc7ca90f113705dc33d5e3411929b6
|
||||
|
||||
@@ -29,7 +29,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con
|
||||
|
||||
### Controls and icons
|
||||
|
||||
`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionBanner`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and the `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree.
|
||||
`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionBanner`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and the `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree.
|
||||
|
||||
### Rendering agent output
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-library"
|
||||
|
||||
### 控件与图标
|
||||
|
||||
`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionBanner`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。
|
||||
`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionBanner`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。
|
||||
|
||||
### 渲染 agent 输出
|
||||
|
||||
|
||||
@@ -123,36 +123,30 @@ function packageInstalled(name: string, base: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Package specifiers a resolver has already refused, keyed by base and name.
|
||||
*
|
||||
* Consulted only after {@link packageInstalled} finds nothing, which is what
|
||||
* keeps a recorded refusal from going stale in the direction that matters: a
|
||||
* package installed since is found on disk and never reaches this set, so
|
||||
* fixing an install still takes effect on the next roster read. What it
|
||||
* removes is the repeat cost of confirming the same absent name on every
|
||||
* read, which a preset carrying several stale rows would otherwise pay
|
||||
* forever.
|
||||
*/
|
||||
const refusedPackages = new Set<string>()
|
||||
|
||||
/**
|
||||
* Whether one classified row names a module that exists, importing nothing.
|
||||
*
|
||||
* Each kind is checked by what actually answers it. A package name is looked
|
||||
* up on disk, then — only when that finds nothing — confirmed through
|
||||
* `import.meta.resolve`, which is authoritative but expensive: a registered
|
||||
* ESM loader hook turns each call into a synchronous round-trip to the hooks
|
||||
* thread, measured at 2ms for a hit and 5ms for a miss under the `tsx` hook
|
||||
* the source launch installs. Keeping it to the rows that look absent leaves
|
||||
* a roster read paying for the failures it reports rather than for every row
|
||||
* it clears, and keeps a package that only a loader can resolve — through
|
||||
* tsconfig paths, or an import map — from being called broken.
|
||||
* up on disk — the same upward walk Node's own resolver starts with — and a
|
||||
* relative or `file:` specifier is statted, because both name one file.
|
||||
* Nothing is evaluated either way, so a row is judged without its plugin
|
||||
* observing that discovery looked.
|
||||
*
|
||||
* A relative or `file:` specifier is statted, because `import.meta.resolve`
|
||||
* only joins URLs for those: a preset shipping a file that was deleted would
|
||||
* otherwise pass. Nothing is evaluated either way, so a row is judged without
|
||||
* its plugin observing that discovery looked.
|
||||
* `import.meta.resolve` is deliberately not the fallback for a name the disk
|
||||
* lookup misses. Its `parentURL` argument only takes effect under
|
||||
* `--experimental-import-meta-resolve`, which no launch passes, so it would
|
||||
* resolve from THIS module rather than from the harness — reporting a
|
||||
* dependency visible only to this package as healthy, and a plugin the mount
|
||||
* can import as broken. The resolver that does honour an explicit parent is
|
||||
* the Loader's internal one, whose `resolveSync` signature differs between
|
||||
* Node 22 and 24 (`ModuleLoader.fromInternal` tags the raw object rather than
|
||||
* normalising it); reaching into that for a case the walk already covers buys
|
||||
* nothing a supported deployment needs, because every plugin a preset names
|
||||
* is installed beside the roster.
|
||||
*
|
||||
* What that gives up: a package resolvable ONLY through a loader hook — an
|
||||
* import map, or a tree with no `node_modules` at all — is reported broken.
|
||||
* No supported install produces one.
|
||||
* @param row - the classified specifier, from {@link classifyRowSpecifier}.
|
||||
* @param presetBase - directory URL a preset-relative specifier resolves against.
|
||||
* @param harnessBase - base URL a package name resolves against.
|
||||
@@ -160,29 +154,14 @@ const refusedPackages = new Set<string>()
|
||||
*/
|
||||
async function rowResolves(row: RowSpecifier, presetBase: string, harnessBase: string): Promise<boolean> {
|
||||
if (row.kind === 'builtin') return true
|
||||
if (row.kind === 'package') {
|
||||
if (isBuiltin(row.specifier)) return true
|
||||
if (packageInstalled(row.specifier, harnessBase)) return true
|
||||
const refusal = `${harnessBase}\u0000${row.specifier}`
|
||||
if (refusedPackages.has(refusal)) return false
|
||||
try {
|
||||
import.meta.resolve(row.specifier, harnessBase)
|
||||
return true
|
||||
} catch {
|
||||
// Absent package, dangling install link, missing subpath export: every
|
||||
// resolution failure is the same answer to the only question asked here,
|
||||
// and the reason names the row rather than repeating the resolver's text.
|
||||
refusedPackages.add(refusal)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (row.kind === 'package') return isBuiltin(row.specifier) || packageInstalled(row.specifier, harnessBase)
|
||||
const url = row.kind === 'file' ? new URL(row.specifier) : new URL(row.specifier, presetBase)
|
||||
return await isFile(fileURLToPath(url))
|
||||
}
|
||||
|
||||
/** One row that names a module no resolver can find. */
|
||||
interface UnresolvableRow {
|
||||
/** The row's own id in quotes, or its position when it declares none. */
|
||||
/** `row "id"`, or the row's position when it declares none. */
|
||||
readonly label: string
|
||||
/** The specifier exactly as the row wrote it. */
|
||||
readonly name: string
|
||||
@@ -191,13 +170,14 @@ interface UnresolvableRow {
|
||||
/**
|
||||
* Rows whose module cannot be resolved.
|
||||
*
|
||||
* Only rows that will certainly be started are checked. `disabled` is the one
|
||||
* entry field the Loader interpolates — a `!!js` expression evaluates against
|
||||
* the loader context at mount time — so a row carrying anything but an absent,
|
||||
* null, or `false` value cannot be proven to load from a file alone. Skipping
|
||||
* those trades a missed name for the failure that matters more: calling a
|
||||
* usable preset broken makes it unselectable and uncopyable, which is worse
|
||||
* than reporting the same stale row at mount time as before.
|
||||
* Only rows that will certainly be started are checked, and the test is the
|
||||
* Loader's own: it starts a row when `Boolean(options.disabled)` is false, so
|
||||
* `disabled: 0` names a row that DOES start and must be checked. A `!!js`
|
||||
* expression is an object and therefore truthy, which skips exactly the rows
|
||||
* whose value only the loader context can decide. Skipping those trades a
|
||||
* missed name for the failure that matters more: calling a usable preset
|
||||
* broken makes it unselectable and uncopyable, which is worse than reporting
|
||||
* the same stale row at mount time as before.
|
||||
*
|
||||
* Shape is the caller's precondition: {@link entryListProblem} has already
|
||||
* proven every row is a map carrying a `name` string, and groups recurse the
|
||||
@@ -217,15 +197,14 @@ async function unresolvableRows(
|
||||
const found: UnresolvableRow[] = []
|
||||
for (const [index, entry] of rows.entries()) {
|
||||
const row = entry as { id?: unknown; name: string; group?: unknown; config?: unknown; disabled?: unknown }
|
||||
const { disabled } = row
|
||||
if (disabled !== undefined && disabled !== null && disabled !== false) continue
|
||||
if (Boolean(row.disabled)) continue
|
||||
const positional = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}`
|
||||
if (row.group === true) {
|
||||
found.push(...await unresolvableRows(row.config as readonly unknown[], presetBase, harnessBase, positional))
|
||||
continue
|
||||
}
|
||||
if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase)) continue
|
||||
const label = typeof row.id === 'string' && row.id !== '' ? `"${row.id}"` : positional
|
||||
const label = typeof row.id === 'string' && row.id !== '' ? `row "${row.id}"` : positional
|
||||
found.push({ label, name: row.name })
|
||||
}
|
||||
return found
|
||||
@@ -268,7 +247,7 @@ async function compositionProblem(path: string, harnessBase: string): Promise<st
|
||||
const [first] = unresolvable
|
||||
if (first === undefined) return undefined
|
||||
if (unresolvable.length === 1) {
|
||||
return `row ${first.label} names a plugin that cannot be resolved: ${first.name}`
|
||||
return `${first.label} names a plugin that cannot be resolved: ${first.name}`
|
||||
}
|
||||
return `${String(unresolvable.length)} rows name plugins that cannot be resolved:\n`
|
||||
+ unresolvable.map(row => `- ${row.label}: ${row.name}`).join('\n')
|
||||
|
||||
@@ -22,7 +22,7 @@ export type RowSpecifier =
|
||||
| { readonly kind: 'builtin'; readonly specifier: string }
|
||||
/** A path relative to the preset's own directory; the preset ships the file. */
|
||||
| { readonly kind: 'preset'; readonly specifier: string }
|
||||
/** An absolute path, already a file URL; it names one file and no base. */
|
||||
/** An absolute path or `file:` URL; it names one file and no base. */
|
||||
| { readonly kind: 'file'; readonly specifier: string }
|
||||
/** A package name resolved from the installed harness. */
|
||||
| { readonly kind: 'package'; readonly specifier: string }
|
||||
@@ -32,14 +32,18 @@ export type RowSpecifier =
|
||||
*
|
||||
* An absolute filesystem path becomes a file URL here rather than at each
|
||||
* call site, because Node's ESM resolver rejects a bare drive-letter path on
|
||||
* Windows. The `specifier` a caller receives is always the string to hand a
|
||||
* resolver; only `kind` decides which base it goes with.
|
||||
* Windows. A `file:` URL is already one and joins it: the Loader accepts both
|
||||
* spellings for the same thing, and treating the URL as a package name would
|
||||
* hand it to a resolver that only normalizes it, reporting a file that is not
|
||||
* there as present. The `specifier` a caller receives is always the string to
|
||||
* hand a resolver; only `kind` decides which base it goes with.
|
||||
* @param name - the module specifier exactly as the row wrote it.
|
||||
* @returns the classification, carrying the specifier to resolve.
|
||||
*/
|
||||
export function classifyRowSpecifier(name: string): RowSpecifier {
|
||||
if (name.startsWith('cordis:')) return { kind: 'builtin', specifier: name }
|
||||
if (name.startsWith('.')) return { kind: 'preset', specifier: name }
|
||||
if (name.startsWith('file:')) return { kind: 'file', specifier: name }
|
||||
if (isAbsolute(name)) return { kind: 'file', specifier: pathToFileURL(name).href }
|
||||
return { kind: 'package', specifier: name }
|
||||
}
|
||||
|
||||
@@ -255,12 +255,13 @@ describe('rows naming a plugin that cannot be resolved', () => {
|
||||
const composition = '- id: a\n name: no-such-a\n- id: b\n name: no-such-b\n'
|
||||
|
||||
expect(await scanned(composition)).toBe(
|
||||
'2 rows name plugins that cannot be resolved:\n- "a": no-such-a\n- "b": no-such-b')
|
||||
'2 rows name plugins that cannot be resolved:\n- row "a": no-such-a\n- row "b": no-such-b')
|
||||
})
|
||||
|
||||
it('falls back to the row position when a row declares no id', async () => {
|
||||
// One `row` prefix, not two: the label carries it either way.
|
||||
expect(await scanned('- name: no-such-plugin\n'))
|
||||
.toMatch(/^row row 1 names a plugin/)
|
||||
.toBe('row 1 names a plugin that cannot be resolved: no-such-plugin')
|
||||
})
|
||||
|
||||
it('descends into a group and keeps the group in the label', async () => {
|
||||
@@ -292,11 +293,20 @@ describe('rows naming a plugin that cannot be resolved', () => {
|
||||
expect(await scanned(composition)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('checks a row that says it is enabled', async () => {
|
||||
expect(await scanned('- id: on\n name: no-such-plugin\n disabled: false\n'))
|
||||
it.each(['false', '0', "''"])('checks a row the loader would start (disabled: %s)', async (value) => {
|
||||
// The Loader starts a row when `Boolean(options.disabled)` is false, so a
|
||||
// falsy-but-present value names a row that does run.
|
||||
expect(await scanned(`- id: on\n name: no-such-plugin\n disabled: ${value}\n`))
|
||||
.toMatch(/cannot be resolved/)
|
||||
})
|
||||
|
||||
it('reports a file: URL whose target is not there', async () => {
|
||||
// The Loader accepts a `file:` URL for the same thing an absolute path
|
||||
// names; a resolver handed one only normalizes it and never looks.
|
||||
const missing = pathToFileURL(join(tmpdir(), 'dsh-presets-absent', 'nope.mjs')).href
|
||||
expect(await scanned(`- id: url\n name: '${missing}'\n`)).toMatch(/cannot be resolved/)
|
||||
})
|
||||
|
||||
it('reads an installed package off disk without asking the resolver', async () => {
|
||||
// The fast path, and the one that has to answer alone: this package has a
|
||||
// directory and nothing to import, so a resolver would reject it.
|
||||
|
||||
Reference in New Issue
Block a user