mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge branch 'master' into worktree/composer-editable-gate
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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: 909423e373bc2368b5fb1e81b1172275920282aa
|
||||
2026-08-26-preset-health-resolves-rows.zh.md: d2cffae66f7bd7fb78e068bd4242d009b95d3c5c
|
||||
@@ -0,0 +1,75 @@
|
||||
# Agent Note: Preset health resolves the rows it can prove will start
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-26-preset-health-resolves-rows.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
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.
|
||||
|
||||
The failure it did produce named less than it knew. The Loader's per-row wrapper builds a plain `Error` whose message ends with `cause.message` and keeps the cause only as `error.cause`. A group that fails on two rows therefore arrives as one wrapped row whose message is `failed to apply loader entry <group> (cordis:group): loader entries failed to apply`, with the two real reasons reachable through `cause.errors` alone. The mount diagnostic flattened `AggregateError.errors` and never followed `cause`, so it ended at that line and named neither row.
|
||||
|
||||
## 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`. 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, 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.
|
||||
|
||||
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.
|
||||
|
||||
**A row that may never start is skipped.** `disabled` is the one entry field the [Loader interpolates](2026-08-11-loader-entry-disabled-interpolation.md): a `!!js` expression evaluates against the loader context at mount time, which discovery cannot do from a file. A row carrying anything but an absent, null, or `false` value is left unchecked, and a disabled group takes its children with it. Every shipped preset gates its shell rows this way, so this is the common shape, not a corner.
|
||||
|
||||
**The harness base is a required argument.** `discoverPresets(roots, harnessBase)` and `scanRoot(root, harnessBase)` take it; `AgentPresets` reads `ctx.baseUrl` once in its constructor and throws when it is absent. The base is what makes the question answerable at all — the same package name fails from a preset's own directory and resolves from the installed harness — so an optional one would silently restore the state this check exists to end.
|
||||
|
||||
**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 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.
|
||||
|
||||
The wire already separated the two texts this needs: `message` wraps the cause in the roster's own "preset X failed to mount" frame, while `details.reason` holds the cause alone. A surface that names the preset itself takes the second, or it says the preset twice.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Check when a preset is selected rather than when the roster is listed.** Rejected. The pickers filter on `broken` before anyone selects, so a preset only checked at selection is still offered, and the reported failure still arrives after the click — the original complaint, relocated. The roster row is where every consumer already reads the verdict.
|
||||
|
||||
**Keep the base optional and skip the check without one.** Rejected. Its failure mode is precisely the bug being fixed, delivered with no signal: healthy cards for presets that cannot compose. `ctx.baseUrl` is set on the root before any scoped context derives from it, so the throw is an assertion about something that does not happen rather than a branch with runtime cost.
|
||||
|
||||
**Import each row instead of resolving it.** Rejected. Importing runs module top-level code on every roster read, which is a side effect a picker must not have, and it is the mount's job — a plugin that throws on apply or waits forever for a service still fails at the first session, by design.
|
||||
|
||||
**Resolve every row through `import.meta.resolve`.** Shipped first and reverted on measurement: correct, and 445ms per roster read, which the client's three concurrent reads turned into 2.45 seconds apiece — the settings section visibly stalled. The resolver is the authority on what imports, but asking it about rows that are plainly installed pays a hooks-thread round-trip for each one.
|
||||
|
||||
**Cache the whole of `compositionProblem` on the existing `CompositionStamp`.** Rejected as the answer to the cost: it would have made repeat reads free while leaving the first read of every edited composition at full price, and it keys resolution on the composition file, which does not change when an install does. The walk removed the cost instead, so nothing needs the stamp.
|
||||
|
||||
**Send the switch failure to the roster card instead of a banner.** Rejected: the card is exactly where the failures that reach a mount are invisible. A composition whose rows all resolve is reported healthy, so "see the settings page for the reason" points at a card that says the preset is fine.
|
||||
|
||||
**Report only the first unresolvable row, matching the shape check.** Rejected. A parse failure can cascade, so naming one is honest there; unresolvable names are independent facts all knowable at once, and reporting them one reload at a time is the avoidable part.
|
||||
|
||||
**Follow `error.cause` unconditionally in `mountDetail`.** Rejected. The Loader's wrapper already appends `cause.message` to the message it builds, so a plain chain would render every line twice. An `AggregateError` cause is the one shape whose detail the message drops.
|
||||
|
||||
**Keep rendering the reason on the card face.** Rejected. The reason names package specifiers and paths, and a picker card that shows them in place of the preset's description trades what a chooser needs for what a fixer needs — while the fixer's copy is one hover away either way.
|
||||
|
||||
**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 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
|
||||
|
||||
A preset naming a package that a rename or an uninstall took away is marked on the roster, refused before a mount is spent, and dropped from the pickers — the same treatment a ghost directory already got. The reason names each row at fault, and a failure that survives to mount names every row inside a group rather than the group alone.
|
||||
|
||||
Health answers from what is installed, not from what would import: a package present but exporting a file that is missing still reports healthy and still fails at mount. That is the safe direction — under-reporting returns the previous behavior, while a false broken makes a usable preset unselectable — and it keeps the answer out of the build state of any one package. A source checkout is still not an installed host, though, because a shipped row names a package the deployment installs beside the roster: `shipped-root.spec.ts` asserts the shipped presets carry no reason other than unresolved rows rather than no reason at all. The mount fixtures name a module that loads and then refuses, since a fixture naming a file that does not exist can no longer reach the mount.
|
||||
|
||||
A mount failure is now legible where it happens, which matters most for the failures health can never catch: a row that resolves and then refuses is reported healthy on the roster forever, so the banner is not a convenience over the card — it is the only account of that failure anywhere.
|
||||
|
||||
Measured in the web app on a roster of eleven presets, `agentPreset.list` answers in 14ms cold and 6-8ms after, and the three concurrent reads the client opens with settle in 9ms of wall clock. The same reads took 2.45 seconds each while every row went through the resolver.
|
||||
|
||||
`@deepseek-ai/cordis-plugin-group` is a devDependency of `dsh-agent-presets`: the mount fixtures now compose through `cordis:group` the way real presets do, and a preset outside the workspace cannot resolve that package by name, so the app registers it as a builtin and the fixture harness does the same.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Agent Note: Preset health resolves the rows it can prove will start
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-26-preset-health-resolves-rows.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
名单列为健康的 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 真正的腐化方式,而它恰好是这项检查排除掉的那一类。
|
||||
|
||||
而它真正产出的失败,说得比它知道的还少。加载器的逐行包装构造一个普通 `Error`,其 message 以 `cause.message` 结尾,cause 只留在 `error.cause` 上。于是一个有两行失败的 group,抵达时是一行被包装的行,message 为 `failed to apply loader entry <group> (cordis:group): loader entries failed to apply`,两条真正的原因只能经由 `cause.errors` 取得。挂载诊断只展平 `AggregateError.errors`,从不跟随 `cause`,因此它停在那一行,一行都没点名。
|
||||
|
||||
## Decision
|
||||
|
||||
**发现过程解析每一行它能证明会启动的行,且不 import 任何东西。** 解析这一趟跑在 `packages/preset/agent-presets/src/discovery.ts` 的形状检查之后,因此格式错误的组装仍然回答形状原因。包名先在磁盘上查——就是 Node 自己那套向上走 `node_modules`、停在 `<包>/package.json` 的做法。preset 相对路径与绝对路径改用 stat,因为对这两类 `import.meta.resolve` 只做 URL 拼接,否则一个丢失了自带文件的 preset 会蒙混过关。两条路都不求值。
|
||||
|
||||
用磁盘查找而不是 `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,那一行会被报告为健康,然后加载失败。
|
||||
|
||||
**可能永远不会启动的行被跳过。** `disabled` 是[加载器唯一会插值](2026-08-11-loader-entry-disabled-interpolation.zh.md)的条目字段:`!!js` 表达式在挂载时对加载器上下文求值,而发现过程无法仅凭文件做到。凡该字段不是缺失、null 或 `false` 的行都不做检查,被禁用的 group 连同其子行一起跳过。每个随附 preset 都用这种方式为 shell 行设门,所以这是常见形状,不是边角。
|
||||
|
||||
**harness base 是必填参数。** `discoverPresets(roots, harnessBase)` 与 `scanRoot(root, harnessBase)` 都接收它;`AgentPresets` 在构造函数里读一次 `ctx.baseUrl`,缺失就抛。基准正是让这个问题可回答的前提——同一个包名从 preset 自己的目录解析会失败、从已安装的 harness 解析会成功——所以做成可选就等于悄悄恢复这项检查要终结的那个状态。
|
||||
|
||||
**挂载诊断跟随携带信息多于自身 message 的 cause。** `mountDetail` 从 `AggregateError.errors` 取分支,或在 cause 是 `AggregateError` 时从 `error.cause.errors` 取;普通的 cause 链已被展平进 message,不再跟随,否则每一行都会打印两遍。嵌套分支在拥有它的那一行下缩进。
|
||||
|
||||
**客户端把原因放到徽标上。** 卡片正面保留 preset 自己的描述,因为在那里一个包说明符不足以让选择的人采取行动。宿主给出的原因在悬停徽标或聚焦卡片时展开,另有一个视觉隐藏的 `role="alert"` 节点负责朗读。损坏的卡片用 `aria-disabled` 而非 `disabled` 表达这件事,并在自己的处理函数里拒绝这次选择:`disabled` 会把它移出 tab 序列,而原因已不在正面,那等于让不用指针的人完全够不到它。
|
||||
|
||||
**被拒绝的切换要在被拒绝的地方说明原因。** chip 的标签会弹回会话仍在运行的那个 preset,因此不说话的话,这次选择看起来就像根本没发生。它经由共享的 `Toast` 在 composer 列上方自报,与旁边的模型选择器报告被拒绝的选择方式一致。只有人刚做出的选择会被自报——应用器在会话成为当前会话时也会运行,为那种情况弹横幅等于报告一个没人问过的拒绝。横幅停留八秒而非 primitive 默认的三秒,因为它承载的原因要点名包与行;`Toast` 为此获得了 `holdMs`,顺带也消除了「停留常量必须由人手与样式表保持同步」这一隐患。
|
||||
|
||||
线上本来就把这需要的两段文本分开了:`message` 把原因裹进名单自己的「preset X failed to mount」框架,而 `details.reason` 只保留原因本身。自己会点名 preset 的表面取后者,否则会把 preset 说两遍。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在选中 preset 时检查,而不是在列出名单时。** 否决。选择器在任何人选中之前就按 `broken` 过滤,因此只在选中时检查的 preset 仍会被摆出来,报出的失败仍在点击之后到达——原本的抱怨只是换了个位置。名单行才是每个消费者已经在读判定的地方。
|
||||
|
||||
**让 base 可选,没有它就跳过检查。** 否决。它的失败模式正是要修的这个 bug,而且不带任何信号:无法组装的 preset 顶着健康卡片。`ctx.baseUrl` 在任何作用域上下文派生之前就设在根上,因此这个抛出是对「不会发生的事」的断言,而不是一条有运行时代价的分支。
|
||||
|
||||
**import 每一行而不是解析它。** 否决。import 会在每次读取名单时执行模块顶层代码,这是选择器不该有的副作用,而且那是挂载的职责——在 apply 时抛错、或永远等待某个服务的插件仍按设计在第一个会话处失败。
|
||||
|
||||
**每一行都交给 `import.meta.resolve` 解析。** 先这样发出去,实测后回退:它是对的,但每次名单读取要 445ms,而客户端并发的三次读取把它放大成每次 2.45 秒——设置分区肉眼可见地卡住。解析器确实是「什么能 import」的权威,但为明摆着装好的行去问它,等于为每一行付一次 hooks 线程往返。
|
||||
|
||||
**把整个 `compositionProblem` 缓存在已有的 `CompositionStamp` 上。** 作为省开销的手段被否决:它只能让重复读取免费,每个被编辑过的组装的第一次读取仍是全价;而且它把解析结果挂在组装文件上,可安装状态变化时组装文件并不会变。改用磁盘查找直接消掉了开销,于是没有什么还需要这个 stamp。
|
||||
|
||||
**把切换失败送到名单卡片上,而不是弹横幅。** 否决:卡片恰恰是那些能走到挂载的失败看不见的地方。所有行都能解析的组装会被报告为健康,于是「去设置页看原因」指向的是一张写着「这个 preset 没问题」的卡片。
|
||||
|
||||
**只报第一个无法解析的行,与形状检查保持一致。** 否决。解析失败会连锁,所以在那里只点名一个是诚实的;无法解析的名字是彼此独立、一次即可全部知晓的事实,而一次重载只修一个才是可以避免的部分。
|
||||
|
||||
**在 `mountDetail` 里无条件跟随 `error.cause`。** 否决。加载器的包装已经把 `cause.message` 追加进它构造的 message,因此普通链会把每一行渲染两遍。`AggregateError` 类型的 cause 是唯一被 message 丢掉细节的形状。
|
||||
|
||||
**继续把原因渲染在卡片正面。** 否决。原因里是包说明符和路径,把它们摆在 preset 描述的位置,等于用选择者需要的东西换取修复者需要的东西——而修复者需要的那份,无论如何都只隔一次悬停。
|
||||
|
||||
**复用图标行的 `data-tip` 伪元素来做提示条。** 实测后否决:生成内容会并入元素的可访问文本,因此卡片的 aria 快照多出一份 alert 已经携带的原因的逐字副本。改用真实的 `aria-hidden` 元素后,可访问副本恰好只有一份——而且既有那条提示条是为图标标签准备的单行 `nowrap`,这一条要逐行列出包说明符。
|
||||
|
||||
**把徽标本身做成可聚焦控件。** 否决:徽标位于卡片自身的 `<button>` 内部,在那里放可聚焦触发器意味着改造卡片头部结构。用 `aria-disabled` 让卡片保持可聚焦,同一次按键就能展开同一条提示条,且不改动任何布局。「原因只用指针可达」同样被否决——这次改动之前它无需任何交互就可见,因此把它藏到悬停之后,对用键盘阅读的人是退化,而不是一条本就不存在的路径。
|
||||
|
||||
## Consequences
|
||||
|
||||
引用了被改名或卸载掉的包的 preset,会在名单上被标出、在花费挂载之前被拒绝、并从选择器里剔除——与幽灵目录早已得到的待遇一致。原因会点名每一个出问题的行;而活到挂载阶段的失败,会点名 group 内的每一行,而不只是 group 本身。
|
||||
|
||||
健康依据的是「装没装」,而不是「能不能 import」:包在、但导出指向的文件不在,仍会报告健康并仍在挂载时失败。这是安全的方向——漏报只是退回先前的行为,而误报会让一个可用的 preset 变得不可选——而且它让答案不依赖于任何单个包的构建状态。不过源码检出仍不是已安装宿主,因为随附行引用的是部署与名单装在一起的包:`shipped-root.spec.ts` 断言随附 preset 除未解析行之外不携带其他原因,而不是不携带任何原因。挂载夹具改为引用一个能加载、随后拒绝的模块,因为引用不存在文件的夹具已经到不了挂载。
|
||||
|
||||
挂载失败现在在它发生的地方就可读,这对健康永远抓不到的那类失败最要紧:能解析、随后拒绝的行在名单上永远显示健康,因此这条横幅不是卡片之外的一份便利——它是那类失败在任何地方唯一的交代。
|
||||
|
||||
在 web 应用中对十一个 preset 的名单实测:`agentPreset.list` 冷启动 14ms、之后 6–8ms,客户端开场并发的三次读取合计 9ms wall。在每一行都过解析器的版本里,同样这三次读取各要 2.45 秒。
|
||||
|
||||
`@deepseek-ai/cordis-plugin-group` 成为 `dsh-agent-presets` 的 devDependency:挂载夹具现在像真实 preset 那样经由 `cordis:group` 组装,而工作区之外的 preset 无法按名解析该包,所以应用把它注册为内建,夹具 harness 也照做。
|
||||
@@ -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`)扫描新位置。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -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/process/2026-08-20-doc-site-raw-markdown-twins.md
|
||||
2026-08-20-doc-site-raw-markdown-twins.md: 5b45657d13d02bc7211e47cad2143afd6890d4e2
|
||||
2026-08-20-doc-site-raw-markdown-twins.zh.md: 1730c9e23ee7abc72350943370a0a346ddc4e7f4
|
||||
2026-08-20-doc-site-raw-markdown-twins.md: 7f557d1327ce4d981e37c1281a8d58526303f815
|
||||
2026-08-20-doc-site-raw-markdown-twins.zh.md: 0fd7e90cf7119b49576ea68e89e7f73ccecfe4a5
|
||||
|
||||
@@ -14,7 +14,9 @@ The documentation site serves rendered HTML only, so an agent reading the docs h
|
||||
|
||||
One projection serves both trees because its site-internal links are relative. `./sibling.md` renders as a clean URL on the HTML site and resolves file-to-file in the raw tree, so the twins need no second link-rewriting mode. Every route is emitted, including the frontmatter-only locale homes, because published pages link to them and the raw tree must stay link-closed; a spec walks every emitted relative link to pin that closure.
|
||||
|
||||
An index route renders as a directory URL, so "append `.md`" lands on `<dir>.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`. A twin or image may never overwrite a file the build already carries, such as a `public/` copy; a name collision fails the emission.
|
||||
An index route renders as a directory URL, so "append `.md`" lands on `<dir>.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`.
|
||||
|
||||
Each production entry point resolves the VitePress configuration, requires `outDir` to be a proper child of the site root, and requires its nearest existing parent to resolve inside the real site root. It unlinks a link-shaped output without traversing its target and removes a real output directory before bundling. This project-owned preparation covers MPA builds, which do not empty their final output directory, and prevents removed routes or assets from surviving a rebuild. The later raw-twin pass treats files created by the current VitePress build as occupied: a twin or image may never overwrite one, such as a `public/` copy, and a name collision fails the emission.
|
||||
|
||||
`llms.txt` is generated from the publication manifest at the site root: both locale trees in sidebar order, one `- [label](<base><route>): <section>` row per page, links site-absolute under the deploy-time `DOCS_BASE`. Locale homes stay out — the file itself is the agent entry point.
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ Status: implemented
|
||||
|
||||
一份投影同时服务两棵树,因为站内链接是相对路径。`./sibling.md` 在 HTML 站渲染为 clean URL,在原始树中按文件对文件解析,孪生页不需要第二套链接改写模式。所有路由都被发射,包括仅有 frontmatter 的 locale 首页:已发布页面链接到它们,原始树必须保持链接封闭;一个 spec 遍历发射树中的每条相对链接来钉住这条闭合性。
|
||||
|
||||
index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `<dir>.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。孪生页与图片一律不得覆盖构建目录中已存在的文件(例如 `public/` 副本);同名冲突使发射失败。
|
||||
index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `<dir>.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。
|
||||
|
||||
每个生产构建入口都会解析 VitePress 配置,要求 `outDir` 必须是站点根目录的严格子目录,并要求它最近的现有父目录必须解析到站点根目录的真实路径以内。构建会直接解除链接型输出而不遍历其目标,并在打包前删除真实输出目录。由项目负责的这一步覆盖了不会清空最终输出目录的 MPA 构建,并避免被移除的路由或资产在重新构建后残留。随后的原始孪生页发射会把当前 VitePress 构建创建的文件视为已占用:孪生页或图片一律不得覆盖这类文件(例如 `public/` 副本),同名冲突会使发射失败。
|
||||
|
||||
`llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label](<base><route>): <section>`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
|| 'dsh-ubuntu-24-04-16core' }}
|
||||
name: node 24 / snapshots and artifacts
|
||||
env:
|
||||
DSH_GATE_CONCURRENCY: '8'
|
||||
DSH_GATE_CONCURRENCY: '10'
|
||||
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
|
||||
DSH_OXLINT_THREADS: '8'
|
||||
DSH_PUBLINT_CONCURRENCY: '8'
|
||||
@@ -421,6 +421,13 @@ jobs:
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
# Best-effort: a torn-down job on this self-hosted pool can leave a
|
||||
# locked @reflink native module under the action's install destination,
|
||||
# and pnpm/action-setup's self-installer then fails its unlink with
|
||||
# EPERM. Clearing the destination gives every attempt fresh state.
|
||||
- name: Clear stale pnpm setup state
|
||||
shell: pwsh
|
||||
run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js
|
||||
@@ -445,7 +452,7 @@ jobs:
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: '6'
|
||||
DSH_COVERAGE_PARTITIONS: '4'
|
||||
DSH_COVERAGE_PARTITIONS: '6'
|
||||
DSH_COVERAGE_TEST_TIMEOUT_MS: '30000'
|
||||
DSH_GATE_CONCURRENCY: '3'
|
||||
steps:
|
||||
@@ -457,6 +464,10 @@ jobs:
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
# Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy.
|
||||
- name: Clear stale pnpm setup state
|
||||
shell: pwsh
|
||||
run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js
|
||||
@@ -491,6 +502,10 @@ jobs:
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
# Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy.
|
||||
- name: Clear stale pnpm setup state
|
||||
shell: pwsh
|
||||
run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js
|
||||
@@ -533,6 +548,10 @@ jobs:
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
# Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy.
|
||||
- name: Clear stale pnpm setup state
|
||||
shell: pwsh
|
||||
run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js
|
||||
|
||||
@@ -71,8 +71,10 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm run build:lib:host
|
||||
|
||||
# Concurrency here is rehearsal-only: the credentialed publish workflows
|
||||
# invoke release:pack without the flag and keep the strictly serial path.
|
||||
- name: Pack release tarballs
|
||||
run: pnpm run release:pack --family vendor --out dist/npm-vendor
|
||||
run: pnpm run release:pack --family vendor --out dist/npm-vendor --concurrency 8
|
||||
|
||||
- name: Verify packed install
|
||||
run: pnpm run release:verify-packed-install --family vendor --from dist/npm-vendor
|
||||
|
||||
@@ -68,8 +68,10 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm run build:official
|
||||
|
||||
# Concurrency here is rehearsal-only: the credentialed publish workflows
|
||||
# invoke release:pack without the flag and keep the strictly serial path.
|
||||
- name: Pack release tarballs
|
||||
run: pnpm run release:pack --family dsh --out dist/npm
|
||||
run: pnpm run release:pack --family dsh --out dist/npm --concurrency 8
|
||||
|
||||
# The harness packages declare the vendored framework as a peer, and this
|
||||
# verification must not depend on the registry already carrying matching
|
||||
@@ -77,7 +79,7 @@ jobs:
|
||||
# publishes — so it installs that family's pack output too. Only dist/npm
|
||||
# is published.
|
||||
- name: Pack the vendored framework for verification
|
||||
run: pnpm run release:pack --family vendor --out dist/npm-vendor
|
||||
run: pnpm run release:pack --family vendor --out dist/npm-vendor --concurrency 8
|
||||
|
||||
# dsh-sandbox-local declares the Landlock entry as a runtime dependency, so
|
||||
# the verification needs its tarball. Its platform packages stay out: they
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//
|
||||
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
@@ -32,6 +33,26 @@ const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'agent-preset-selection-web-e2e'
|
||||
/** A project skill only a preset that mounts `skill-filesystem` can discover. */
|
||||
const SKILL_NAME = 'preset-catalog-demo'
|
||||
/** The preset whose rows resolve and then refuse to start. */
|
||||
const REFUSING_ID = 'zz-refusing'
|
||||
|
||||
/**
|
||||
* Seed a preset discovery reports healthy and the mount refuses.
|
||||
*
|
||||
* Every row resolves — the module is right there beside the composition — so
|
||||
* health has nothing to report and the chip offers the preset like any other.
|
||||
* Only starting it finds out, which is the case the chip's banner exists for.
|
||||
* @param root - the lane's writable preset root.
|
||||
*/
|
||||
async function seedRefusingPreset(root: string): Promise<void> {
|
||||
const directory = join(root, REFUSING_ID)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'refuses.mjs'),
|
||||
'export const name = \'refuses\'\nexport function apply() { throw new Error(\'this row refuses to start\') }\n')
|
||||
await writeFile(join(directory, 'agent.cordis.yml'), '- id: refuses\n name: ./refuses.mjs\n')
|
||||
await writeFile(join(directory, 'preset.yml'),
|
||||
'name: Refusing mode\ndescription: Resolves, then refuses to start.\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed one project skill under the connected workspace.
|
||||
@@ -176,11 +197,16 @@ describe('web e2e: agent-preset selection', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let presetRoot: string
|
||||
|
||||
beforeAll(async () => {
|
||||
// The scaffold's default roster pin is exactly this scenario's shape: the
|
||||
// plugin's shipped presets, default `standard`.
|
||||
scaffold = await launchWebScaffold({})
|
||||
// The shipped presets, plus one lane-owned preset that mounts and refuses:
|
||||
// the chip's own failure path needs a preset the roster offers.
|
||||
presetRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-refusing-')))
|
||||
await seedRefusingPreset(presetRoot)
|
||||
scaffold = await launchWebScaffold({
|
||||
agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: 'standard' },
|
||||
})
|
||||
// A resumed session runs what it was created with; seeding one that
|
||||
// records `minimal` is what makes the header label a claim about the
|
||||
// session rather than an echo of the current default.
|
||||
@@ -237,8 +263,23 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
|
||||
})
|
||||
|
||||
it('says why a switch was refused instead of letting the chip revert in silence', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-refused'))
|
||||
await page.getByRole('button', { name: 'Minimal mode' }).click()
|
||||
await page.getByRole('menuitem', { name: /Refusing mode/ }).click()
|
||||
|
||||
// Health cleared every row, so nothing on the settings page says this
|
||||
// preset is unusable — the banner is where the host's reason lands, and
|
||||
// without it the chip just snaps back to the preset it already ran.
|
||||
const banner = page.getByRole('alert').filter({ hasText: 'Refusing mode' })
|
||||
await banner.waitFor({ timeout: 15_000 })
|
||||
expect(await banner.textContent()).toContain('this row refuses to start')
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
|
||||
await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 })
|
||||
}, 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()
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
- menuitem "PTC mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program."
|
||||
- menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor."
|
||||
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
|
||||
- menuitem "Refusing mode Resolves, then refuses to start."
|
||||
|
||||
+2
-2
@@ -94,8 +94,8 @@
|
||||
"gen-translation-brief": "tsx scripts/gen-translation-brief.ts",
|
||||
"verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
|
||||
"docs:dev": "pnpm --filter @deepseek-ai/website run dev",
|
||||
"docs:build": "pnpm --filter @deepseek-ai/website run build && pnpm run verify-doc-site-fragments",
|
||||
"docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa && pnpm run verify-doc-site-fragments",
|
||||
"docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments",
|
||||
"docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments",
|
||||
"docs:preview": "pnpm --filter @deepseek-ai/website run preview",
|
||||
"docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts && pnpm run docs:build",
|
||||
"website:dev": "pnpm run docs:dev",
|
||||
|
||||
@@ -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: d36588fedc0a86b62acec05b523ec410f36d089b
|
||||
README.zh.md: e1364214af04c511c0b42fba46ac73e22edd8c41
|
||||
README.md: 1f07b034839dafcb0794d1a2e41b5015f5a55df0
|
||||
README.zh.md: 2aa5420f2092d974e293ddce97d34517c63e37b8
|
||||
|
||||
@@ -29,7 +29,7 @@ Mount this plugin alongside the settings and conversation packages; the preset s
|
||||
|
||||
### Managing the roster
|
||||
|
||||
The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The default is set from any surface; deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared.
|
||||
The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The default is set from any surface; deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared. The card face still shows the preset's own description — a chooser cannot act on a package specifier there — and the host's reason rides the badge as a tooltip, plus a visually hidden alert that carries it to assistive technology, which a disabled card body cannot.
|
||||
|
||||
### The conversational entry
|
||||
|
||||
@@ -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. [`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>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 管理名单
|
||||
|
||||
设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。默认值可在任一表面设置;删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。
|
||||
设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。默认值可在任一表面设置;删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。卡片正面仍显示 preset 自己的描述——在选择器里,一个包说明符不足以让人采取行动——宿主给出的原因作为提示条挂在徽标上,另有一个视觉隐藏的 alert 把它送达辅助技术,而被禁用的卡片主体做不到这一点。
|
||||
|
||||
### 对话式入口
|
||||
|
||||
@@ -43,7 +43,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
选项与当前默认值都来自同一次 `agentPreset.list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的字段。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。[`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>
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
* Picking stages; the choice reaches a session when one becomes current.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
IconAgentPresetOutline16, IconChevronDownOutline14, IconWarningOutline16, Menu, Toast,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the hero seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSeatState } from './seat-store.ts'
|
||||
@@ -30,8 +32,8 @@ export interface AgentPresetSeatInjected {
|
||||
}
|
||||
/** Read the roster when the chip first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Stage one preset for the next session. */
|
||||
select: (id: string) => Promise<void>
|
||||
/** Stage one preset for the next session; resolves to a refusal, or undefined. */
|
||||
select: (id: string) => Promise<string | undefined>
|
||||
/** Clear the one-shot introduce cue once the chip has played it. */
|
||||
introduced: () => void
|
||||
}
|
||||
@@ -47,6 +49,18 @@ const INTRO_CHAR_STAGGER_MS = 40
|
||||
const INTRO_TEXT_REVEAL_MS = 200
|
||||
const INTRO_CHAR_FADE_MS = 400
|
||||
|
||||
/**
|
||||
* How long a refused switch holds before fading.
|
||||
*
|
||||
* Longer than the primitive's default because this banner is the only place
|
||||
* the refusal appears. The chip's label has already snapped back to the
|
||||
* preset the session still runs, and a preset the host refuses to MOUNT is
|
||||
* one discovery reported healthy — its row on the settings page carries no
|
||||
* reason to go back and read, because there was nothing to see until the
|
||||
* rows actually ran.
|
||||
*/
|
||||
const REFUSAL_HOLD_MS = 8000
|
||||
|
||||
/**
|
||||
* Per-character start offset for the introduce reveal.
|
||||
* @param count - character count of the shown preset name.
|
||||
@@ -71,6 +85,10 @@ export type AgentPresetSeatProps =
|
||||
export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) {
|
||||
const state = useAgentPresetSeat(snapshot => snapshot)
|
||||
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 toastSeq = useRef(0)
|
||||
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
@@ -126,45 +144,73 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
|
||||
: label
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={state.options.map((option) => {
|
||||
const text = presetDisplayText(option, t)
|
||||
return {
|
||||
id: option.id,
|
||||
// Name and description together: the id alone never says what a
|
||||
// preset does, which is why the roster carries display copy.
|
||||
label: (
|
||||
<span className={css.item}>
|
||||
<span className={css.itemName}>{text.name}</span>
|
||||
<span className={css.itemDesc}>{text.description ?? t('noDescription')}</span>
|
||||
</span>
|
||||
),
|
||||
}
|
||||
})}
|
||||
selectedId={state.current}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
void select(id)
|
||||
}}
|
||||
align="start"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.seat}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={state.error ?? t('seatHint')}
|
||||
disabled={state.busy}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
|
||||
{shownLabel}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
<>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={state.options.map((option) => {
|
||||
const text = presetDisplayText(option, t)
|
||||
return {
|
||||
id: option.id,
|
||||
// Name and description together: the id alone never says what a
|
||||
// preset does, which is why the roster carries display copy.
|
||||
label: (
|
||||
<span className={css.item}>
|
||||
<span className={css.itemName}>{text.name}</span>
|
||||
<span className={css.itemDesc}>{text.description ?? t('noDescription')}</span>
|
||||
</span>
|
||||
),
|
||||
}
|
||||
})}
|
||||
selectedId={state.current}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
const picked = state.options.find(option => option.id === id)
|
||||
// The fallback is for the row shape `find` cannot promise; the menu's
|
||||
// items ARE `state.options`, so an emitted id is always one of them.
|
||||
/* v8 ignore next */
|
||||
const name = picked === undefined ? id : presetDisplayText(picked, t).name
|
||||
void select(id).then((refusal) => {
|
||||
// Announced only for a pick a person just made: `apply()` also runs
|
||||
// when a session becomes current, and a banner over that would
|
||||
// report a refusal nobody asked for.
|
||||
if (refusal === undefined) return
|
||||
toastSeq.current += 1
|
||||
setToast({ seq: toastSeq.current, text: t('switchRefused', { name, reason: refusal }) })
|
||||
})
|
||||
}}
|
||||
align="start"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.seat}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={state.error ?? t('seatHint')}
|
||||
disabled={state.busy}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
|
||||
{shownLabel}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
{toast !== null && (
|
||||
<Toast
|
||||
key={toast.seq}
|
||||
text={toast.text}
|
||||
icon={<IconWarningOutline16 />}
|
||||
holdMs={REFUSAL_HOLD_MS}
|
||||
// The composer card, which is the content column this chip sits
|
||||
// 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) }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,12 +94,62 @@
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
/* The discovery-reported reason, verbatim: it names the file and the fix. */
|
||||
.cardBrokenReason {
|
||||
font-size: 12px;
|
||||
/* The discovery-reported reason, verbatim: it names the rows and the fix.
|
||||
A real element rather than the icon row's `data-tip` pseudo-element, for two
|
||||
reasons: generated content joins the card's accessible text, where this would
|
||||
repeat what the alert already carries, and that tooltip is one `nowrap` line
|
||||
for an icon label while this names package specifiers one per line. */
|
||||
.brokenTip {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
text-align: left;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .12s;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The card body is the control that picks the preset. */
|
||||
@@ -132,6 +182,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* Anchors the broken badge's tooltip: the badge itself stays unpositioned so
|
||||
its `::after` resolves against the card's own width instead of against a
|
||||
badge that sits partway across it. A tooltip grown from the badge would
|
||||
run past the card, and past the section for a card in the last column. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cardName {
|
||||
|
||||
@@ -256,23 +256,45 @@ 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 and the card says why instead of offering it. */}
|
||||
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}`}
|
||||
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
// The reason rides the badge, not the whole card: two
|
||||
// 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={() => {
|
||||
if (row.broken !== undefined) return
|
||||
void props.makeDefault(row.id)
|
||||
}}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{text.name}</span>
|
||||
{row.broken !== undefined
|
||||
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
|
||||
? (
|
||||
<span className={css.brokenBadge}>
|
||||
{t('brokenBadge')}
|
||||
{/* Pointer-only, hence `aria-hidden`: the same
|
||||
reason reaches assistive technology through the
|
||||
alert below, and a second copy inside the card's
|
||||
own text would be read out twice. */}
|
||||
<span className={css.brokenTip} aria-hidden="true">{row.broken}</span>
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
<span className={css.badge}>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
@@ -280,6 +302,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
<CardDescription text={text.description ?? t('noDescription')} />
|
||||
{/* Visually hidden, deliberately: the pointer path is the
|
||||
badge's tooltip, and a disabled card body is out of the
|
||||
tab order, so this is the only reading a screen reader
|
||||
or a keyboard-only user gets. */}
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
|
||||
@@ -11,7 +11,7 @@ export type AgentPresetSettingsKey =
|
||||
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder'
|
||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
||||
| 'brokenBadge' | 'brokenNoCopy'
|
||||
| 'brokenBadge' | 'brokenNoCopy' | 'switchRefused'
|
||||
| 'composition' | 'cancel' | 'close' | 'retry'
|
||||
| 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft'
|
||||
| 'openLocation' | 'showLocation' | 'revealedPathLabel'
|
||||
@@ -59,6 +59,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
noDescription: 'No description.',
|
||||
brokenBadge: 'Failed to load',
|
||||
brokenNoCopy: 'A preset that failed to load cannot be duplicated',
|
||||
switchRefused: 'Could not switch to {name}: {reason}',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (agent.cordis.yml)',
|
||||
cancel: 'Cancel',
|
||||
@@ -119,6 +120,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
noDescription: '暂无描述。',
|
||||
brokenBadge: '加载失败',
|
||||
brokenNoCopy: '预设加载失败,不能复制',
|
||||
switchRefused: '无法切换到「{name}」:{reason}',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(agent.cordis.yml)',
|
||||
cancel: '取消',
|
||||
|
||||
@@ -94,13 +94,20 @@ export class AgentPresetSeatController {
|
||||
/**
|
||||
* Stage one preset for the next session, applying it immediately when a
|
||||
* blank session is already current.
|
||||
*
|
||||
* The refusal is returned as well as stored, because the two readers need
|
||||
* different things from it: the chip's own label carries the standing state,
|
||||
* while the caller that made this pick is the one that has to say why the
|
||||
* label came back — and only it knows the pick was a person's, not the
|
||||
* applier catching up with a session that just became current.
|
||||
* @param id - the preset to stage.
|
||||
* @returns once the stage settled, and the apply too when one happened.
|
||||
* @returns the refusal text, or undefined once the pick settled.
|
||||
*/
|
||||
async select(id: string): Promise<void> {
|
||||
if (this.store.getSnapshot().busy) return
|
||||
async select(id: string): Promise<string | undefined> {
|
||||
if (this.store.getSnapshot().busy) return undefined
|
||||
this.stage(id)
|
||||
await this.apply()
|
||||
return this.store.getSnapshot().error ?? undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,9 +159,17 @@ export class AgentPresetSeatController {
|
||||
const result = await this.remote.agentPresets.select(session.id, staged)
|
||||
this.staged = undefined
|
||||
if (!result.ok) {
|
||||
const { error } = result
|
||||
this.set({
|
||||
busy: false,
|
||||
error: result.error.message,
|
||||
// A refusal carries its cause twice: `message` wraps it in the
|
||||
// roster's own frame, which names the preset the surface reporting
|
||||
// this already names, and a `reason` detail holds the same cause
|
||||
// without it. Read by the detail rather than by the code, because
|
||||
// every refusal that has a cause to give names it the same way.
|
||||
error: 'reason' in error.details && typeof error.details.reason === 'string'
|
||||
? error.details.reason
|
||||
: error.message,
|
||||
current: presetOf(session) ?? '',
|
||||
})
|
||||
return
|
||||
|
||||
@@ -623,4 +623,25 @@ describe('AgentPresetSeatController reconciliation', () => {
|
||||
busy: false, current: '', error: message,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the bare cause of a mount failure, not the frame that names the preset again', async () => {
|
||||
const reason = 'failed to import loader entry ctx (@deepseek-ai/dsh-gone): Cannot find package'
|
||||
const controller = new AgentPresetSeatController({
|
||||
agentPresets: {
|
||||
select: () => Promise.resolve({
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: 'agent-preset-invalid',
|
||||
message: `agent-presets: preset "broken" failed to mount: ${reason}`,
|
||||
details: { agentPreset: 'broken', reason },
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as never, () => ({ id: SessionId('uncomposed'), blank: true }))
|
||||
|
||||
// The surface reporting this names the preset itself, so carrying the
|
||||
// roster's own "preset X failed to mount" frame would say it twice.
|
||||
expect(await controller.select('broken')).toBe(reason)
|
||||
expect(controller.store.getSnapshot().error).toBe(reason)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,17 +55,24 @@ function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
|
||||
return actions
|
||||
}
|
||||
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
|
||||
/** The runtime's own `{name}` substitution, so a test reads the shown text. */
|
||||
function translate(key: keyof typeof en, params?: Record<string, unknown>): string {
|
||||
const template = en[key]
|
||||
return params === undefined
|
||||
? template
|
||||
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
|
||||
}
|
||||
|
||||
function renderSeat(
|
||||
state: Partial<AgentPresetSeatState> = {},
|
||||
select: () => Promise<string | undefined> = () => Promise.resolve(undefined),
|
||||
) {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
|
||||
const actions = {
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
introduced: vi.fn(),
|
||||
}
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(select), introduced: vi.fn() }
|
||||
render(<AgentPresetSeat {...({
|
||||
...actions,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
t: translate,
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
return actions
|
||||
}
|
||||
@@ -277,6 +284,45 @@ describe('the new-session chip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('a refused switch', () => {
|
||||
it('announces the reason instead of letting the label snap back in silence', async () => {
|
||||
// The banner's own timer has to be a fake one from the start, or the
|
||||
// lifetime assertion below would wait out its real nine seconds.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
try {
|
||||
const reason = 'failed to import loader entry live-on-mac (@deepseek-ai/dsh-also-gone)'
|
||||
renderSeat({}, () => Promise.resolve(reason))
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /mine/ }))
|
||||
|
||||
// The host refuses a mount discovery reported healthy, so this banner is
|
||||
// the only place the cause appears — the chip has already reverted and
|
||||
// the settings row shows the preset as fine.
|
||||
const banner = await screen.findByRole('alert')
|
||||
expect(banner.textContent).toContain(reason)
|
||||
expect(banner.textContent).toContain('mine')
|
||||
|
||||
// Transient by design: it holds long enough to read a cause that names
|
||||
// packages, then leaves rather than sitting over the screen.
|
||||
act(() => { vi.advanceTimersByTime(9001) })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('says nothing when the switch lands', async () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /mine/ }))
|
||||
|
||||
await waitFor(() => { expect(actions.select).toHaveBeenCalledWith('mine') })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the chip introduce cue', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
|
||||
@@ -176,16 +176,30 @@ describe('the preset list', () => {
|
||||
const actions = renderSection({
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'ghost', trust: 'user', isDefault: false, name: '幽灵预设', broken: 'the composition file agent.cordis.yml is missing' },
|
||||
{
|
||||
id: 'ghost', trust: 'user', isDefault: false, name: '幽灵预设', description: '我自己写的',
|
||||
broken: 'the composition file agent.cordis.yml is missing',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const ghost = rowFor('ghost')
|
||||
// The reason is on the card, and the body cannot pick what cannot mount.
|
||||
expect(within(ghost).getByText(en.brokenBadge)).toBeTruthy()
|
||||
// The badge carries the reason for a pointer, and the body cannot pick
|
||||
// what cannot mount.
|
||||
expect(within(ghost).getByText(en.brokenBadge).textContent)
|
||||
.toBe(`${en.brokenBadge}the composition file agent.cordis.yml is missing`)
|
||||
// A picker card keeps showing what the preset is; a package specifier in
|
||||
// its place would tell a chooser nothing they can act on there.
|
||||
expect(within(ghost).getByText('我自己写的')).toBeTruthy()
|
||||
// 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: 4ca3d89dfcb5d169c274092ed9b14ab888bcafff
|
||||
README.zh.md: 0be30193cffe463f2ff05588c2ae1ded0746fec3
|
||||
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.
|
||||
`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 提供复制按钮。
|
||||
`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 输出
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* Keep the fade delay and duration synchronized with HOLD_MS and FADE_MS in
|
||||
Toast.tsx so the toast unmounts after the fade. */
|
||||
/* The fade delay comes from the component as `--dsh-toast-hold`, so one value
|
||||
drives both the unmount timer and this animation; the fallback matches the
|
||||
component's own default. The fade DURATION still has to agree with FADE_MS
|
||||
in Toast.tsx, which no owner varies. */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
@@ -24,7 +26,7 @@
|
||||
transform: translateX(-50%);
|
||||
animation:
|
||||
dsh-toast-in 160ms ease-out,
|
||||
dsh-toast-fade 1000ms ease 3000ms forwards;
|
||||
dsh-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -60,6 +62,6 @@
|
||||
not movement) still ends the banner before the timed unmount. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation: dsh-toast-fade 1000ms ease 3000ms forwards;
|
||||
animation: dsh-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import css from './Toast.module.css'
|
||||
|
||||
/** Full-opacity hold before the fade starts. Must agree with the stylesheet's
|
||||
* toast-fade delay (Toast.module.css) or the banner unmounts mid-fade. */
|
||||
/** Full-opacity hold before the fade starts, when the owner names none. */
|
||||
const HOLD_MS = 3000
|
||||
/** Fade duration. Must agree with the stylesheet's toast-fade duration. */
|
||||
const FADE_MS = 1000
|
||||
@@ -17,24 +16,32 @@ const FADE_MS = 1000
|
||||
* transformed or filtered ancestor cannot trap the fixed banner in that
|
||||
* ancestor's box.
|
||||
*
|
||||
* The hold is the owner's to set, because how long a banner has to stay
|
||||
* depends on how much there is to read: a one-line limit lands in the default
|
||||
* window, while a failure that names what broke does not. One value drives
|
||||
* both the unmount timer and the stylesheet's fade delay — the stylesheet
|
||||
* reads it as a custom property — so the two can no longer disagree and leave
|
||||
* the banner unmounting mid-fade.
|
||||
* @param props.text - resolved banner copy; the owner passes localized text.
|
||||
* @param props.icon - optional leading glyph (e.g. a warning icon).
|
||||
* @param props.holdMs - full-opacity hold before the fade; defaults to 3000.
|
||||
* @param props.anchor - optional element whose horizontal center the banner
|
||||
* follows (e.g. the composer card, so the banner centers over the chat column
|
||||
* rather than the whole window); omitted, it centers on the viewport.
|
||||
* @param props.onDone - called once the fade completes; unmount the toast here.
|
||||
* @returns the floating banner.
|
||||
*/
|
||||
export function Toast({ text, icon, anchor, onDone }: {
|
||||
export function Toast({ text, icon, anchor, holdMs = HOLD_MS, onDone }: {
|
||||
text: string
|
||||
icon?: ReactNode
|
||||
anchor?: HTMLElement | null
|
||||
holdMs?: number
|
||||
onDone: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDone, HOLD_MS + FADE_MS)
|
||||
const timer = setTimeout(onDone, holdMs + FADE_MS)
|
||||
return () => { clearTimeout(timer) }
|
||||
}, [onDone])
|
||||
}, [holdMs, onDone])
|
||||
// Anchor-centered placement re-measures on window resizes; the banner lives
|
||||
// four seconds, so sub-window layout drift within that span stays out of
|
||||
// scope.
|
||||
@@ -50,7 +57,14 @@ export function Toast({ text, icon, anchor, onDone }: {
|
||||
return () => { window.removeEventListener('resize', measure) }
|
||||
}, [anchor])
|
||||
return createPortal(
|
||||
<div className={css.toast} role="alert" style={left === null ? undefined : { left }}>
|
||||
<div
|
||||
className={css.toast}
|
||||
role="alert"
|
||||
style={{
|
||||
...left === null ? {} : { left },
|
||||
'--dsh-toast-hold': `${String(holdMs)}ms`,
|
||||
} as CSSProperties}
|
||||
>
|
||||
{icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
|
||||
<span className={css.text}>{text}</span>
|
||||
</div>,
|
||||
|
||||
@@ -24,6 +24,24 @@ describe('Toast', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('holds for the owner\'s window and hands the stylesheet the same value', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onDone = vi.fn()
|
||||
const view = render(<Toast text="切换失败" holdMs={6000} onDone={onDone} />)
|
||||
// One value drives both, so a banner can never unmount mid-fade: the
|
||||
// timer waits the hold plus the fade, and the stylesheet delays the
|
||||
// fade by the same hold.
|
||||
expect(view.getByRole('alert').style.getPropertyValue('--dsh-toast-hold')).toBe('6000ms')
|
||||
vi.advanceTimersByTime(6999)
|
||||
expect(onDone).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('centers over its anchor and re-measures on window resize', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
/**
|
||||
* Full-corpus regression for the worker module transform: every built bundle in
|
||||
* the workspace is transformed, executed through the real wrapper contract, and
|
||||
* its export shape compared against what Node's own ESM loader produces for the
|
||||
* same file.
|
||||
* Full-corpus import gate: every built bundle in the workspace —
|
||||
* `packages/<group>/<package>/lib/index.js` and `vendor/<package>/lib/index.js`
|
||||
* — must be importable by Node's ESM loader. A bundle that stops importing (a
|
||||
* stray `.css` import, an emitted module Node cannot parse, a dependency that
|
||||
* throws at module scope) is reported by name.
|
||||
*
|
||||
* This is the harness that answers "does the transform hold on real output",
|
||||
* which no hand-written case can: the corpus is whatever the build currently
|
||||
* emits, so a rolldown upgrade that starts emitting an unseen module form shows
|
||||
* up here first.
|
||||
* Baseline exemptions are a pinned list, not a count: an unlisted import
|
||||
* failure is a real finding (a bundle that stopped being importable), and it
|
||||
* must not hide inside a total. A listed file that becomes importable also
|
||||
* fails, so the list cannot rot.
|
||||
*
|
||||
* Module-syntax statistics are counted from the acorn AST, so the check has no
|
||||
* separate lexer dependency. Baseline exemptions are a pinned list, not a count:
|
||||
* four files cannot be imported by Node in this repository for reasons unrelated
|
||||
* to the transform, and an unexpected member fails the run.
|
||||
*
|
||||
* Cost: this walks the whole build output and imports every bundle, so it takes
|
||||
* tens of seconds and needs `pnpm run build:lib:host` to have run. It is a
|
||||
* heavyweight suite, not part of a default aggregator run.
|
||||
* Cost: this walks the whole build output and imports every bundle serially in
|
||||
* one process, so it takes minutes on loaded runners and needs
|
||||
* `pnpm run build:lib:host` to have run. It is a heavyweight suite, not part
|
||||
* of a default aggregator run.
|
||||
*
|
||||
* Run: tsx tests/compile/transform-corpus-check.ts [files...]
|
||||
* With no arguments it discovers the corpus itself.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { parse } from 'acorn'
|
||||
import { createAlsRuntime } from '../../src/polyfill/async-context/als-runtime.ts'
|
||||
import { lowerModuleSource } from '../../src/compile/transform.ts'
|
||||
import { WRAPPER_PARAMS } from '../../src/image-layout.ts'
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url))
|
||||
|
||||
/**
|
||||
* Files Node's ESM loader cannot import in this repository, so no baseline
|
||||
* export shape exists to compare against. None is a transform failure: each is
|
||||
* checked to still TRANSFORM cleanly, only the comparison is skipped.
|
||||
*
|
||||
* Named rather than counted: an unlisted baseline failure is a real finding
|
||||
* (a bundle that stopped being importable), and it must not hide inside a total.
|
||||
* A listed file that becomes importable also fails, so the list cannot rot.
|
||||
* Files Node's ESM loader cannot import in this repository. None is a finding:
|
||||
* each is listed with the reason the import fails, and the run refuses a
|
||||
* listed file that imports cleanly so the list stays current in both
|
||||
* directions. The koffi entry depends on corpus order: sandbox-windows-acl
|
||||
* imports the win32-process package earlier in the serial sweep (a distinct
|
||||
* module instance under its node_modules URL), so win32-process's own file-URL
|
||||
* import re-registers koffi's type names and fails as the second load.
|
||||
*/
|
||||
const BASELINE_EXEMPT: ReadonlyMap<string, string> = new Map([
|
||||
['packages/client/ui-primitives/lib/index.js', 'imports .css, which bare Node cannot load'],
|
||||
@@ -48,25 +40,6 @@ const BASELINE_EXEMPT: ReadonlyMap<string, string> = new Map([
|
||||
['packages/test-support/client-runtime/lib/index.js', "needs vitest's internal state"],
|
||||
])
|
||||
|
||||
/**
|
||||
* Bundles whose own SOURCE contains the double-lowering sentinels, so the
|
||||
* transform's guard refuses them by design.
|
||||
*
|
||||
* This package is the only such case and the refusal is correct: its bundle
|
||||
* carries `transform.ts`'s own template literals (`` `__als$${n}` `` from
|
||||
* `alsTemp`, and the `${ALS}.pause(` fragments), which is exactly the text the
|
||||
* guard looks for. A self-referential false positive is the right trade: the
|
||||
* guard exists because a mis-wired image manifest would otherwise show up only
|
||||
* as "slower", and no roster row transforms this package.
|
||||
*
|
||||
* Listed rather than skipped silently, and asserted to keep refusing: if the
|
||||
* guard stopped tripping here, either the guard or this bundle's contents
|
||||
* changed, and both are worth knowing about.
|
||||
*/
|
||||
const DOUBLE_LOWERING_SENTINEL: ReadonlySet<string> = new Set([
|
||||
'packages/experimental/webworker-runtime/lib/index.js',
|
||||
])
|
||||
|
||||
let failures = 0
|
||||
const report: string[] = []
|
||||
const log = (line: string): void => {
|
||||
@@ -114,188 +87,11 @@ function discover(): string[] {
|
||||
|
||||
/**
|
||||
* @returns Path relative to the repository root, for stable diagnostics.
|
||||
* Always POSIX-separated: the exemption table and the recorded findings key
|
||||
* on one form, and a win32 walk would otherwise miss every entry.
|
||||
* Always POSIX-separated: the exemption table keys on one form, and a win32
|
||||
* walk would otherwise miss every entry.
|
||||
*/
|
||||
const relative = (path: string): string => path.slice(repositoryRoot.length).replaceAll('\\', '/')
|
||||
|
||||
/**
|
||||
* Present a Node ESM namespace the way the worker loader hands one over, so a
|
||||
* real dependency and a transformed one look the same to the module body.
|
||||
* @param value - A module namespace, or whatever `require` returned.
|
||||
* @returns The value, or an `__esModule`-marked projection of a Module namespace.
|
||||
*/
|
||||
function asLoaderExports(value: unknown): unknown {
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
if ((value as { [Symbol.toStringTag]?: string })[Symbol.toStringTag] !== 'Module') return value
|
||||
const out: Record<string, unknown> = {}
|
||||
Object.defineProperty(out, '__esModule', { value: true })
|
||||
for (const key of Object.keys(value)) {
|
||||
Object.defineProperty(out, key, { enumerable: true, get: () => (value as Record<string, unknown>)[key] })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifiers a transformed body will request, read straight out of the emitted
|
||||
* code. The transform emits every static import as `require(<string literal>)`
|
||||
* (`transform.ts` builds them with `JSON.stringify`), so a literal scan finds
|
||||
* exactly the set that must be resolvable before the body runs. A dynamic
|
||||
* `import(expr)` is not found and does not need to be: it resolves lazily,
|
||||
* after the body has already produced its exports.
|
||||
* @param code - Emitted CommonJS body.
|
||||
* @returns The requested specifiers, deduplicated.
|
||||
*/
|
||||
function requestedSpecifiers(code: string): string[] {
|
||||
const found = new Set<string>()
|
||||
for (const match of code.matchAll(/require\("((?:[^"\\]|\\.)*)"\)/g)) {
|
||||
const raw = match[1]
|
||||
if (raw !== undefined) found.add(JSON.parse(`"${raw}"`) as string)
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a dependency through the same loader that produces this check's baseline.
|
||||
*
|
||||
* This matters more than it looks. The baseline every file is compared against is
|
||||
* `await import(file)` — Node's ESM loader. A dependency fetched with
|
||||
* `createRequire` instead goes through the CommonJS resolver, which selects the
|
||||
* `require` condition of a package's `exports` map: for a dual-build package that
|
||||
* is a DIFFERENT ARTIFACT with a different interop shape. `@deepseek-ai/schemastery`
|
||||
* is the case that exposed it — `require` yields `lib/index.cjs`, whose
|
||||
* `module.exports` is the `Schema` function with no `default` and no `__esModule`,
|
||||
* while `import` yields `lib/index.mjs`, a namespace with `default`. A body
|
||||
* written against the second shape misbehaves when handed the first.
|
||||
*
|
||||
* That divergence also made the whole check runner-dependent: under the `tsx` CLI
|
||||
* `require` was patched to return the ESM view and all 228 passed, while under
|
||||
* `node --import tsx/esm` three files failed. A gate whose verdict depends on how
|
||||
* it was launched is not a gate, so dependencies now come from `import()` and the
|
||||
* CommonJS path is only a fallback.
|
||||
* @param specifier - Module specifier as the transformed body requests it.
|
||||
* @param path - Absolute path of the importing bundle.
|
||||
* @returns The dependency in loader-facing form, or undefined when neither loader can supply it.
|
||||
*/
|
||||
async function loadDependency(specifier: string, path: string): Promise<unknown> {
|
||||
const real = createRequire(pathToFileURL(path))
|
||||
try {
|
||||
// Resolve through the importer so relative and bare specifiers both work, then
|
||||
// import the resolved file: resolution is CommonJS's, delivery is ESM's.
|
||||
const resolved = specifier.startsWith('node:') ? specifier : pathToFileURL(real.resolve(specifier)).href
|
||||
return asLoaderExports(await import(resolved))
|
||||
} catch {
|
||||
// Not importable as ESM (a genuine CommonJS-only dependency, or unresolvable).
|
||||
try {
|
||||
return asLoaderExports(real(specifier))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A stand-in for a dependency Node cannot load here: every access answers something callable. */
|
||||
function fakeModule(): unknown {
|
||||
const target: Record<string, unknown> = {}
|
||||
return new Proxy(target, {
|
||||
get: (holder, key) => {
|
||||
if (key === '__esModule') return true
|
||||
if (key === 'default') return function fakeDefault() {}
|
||||
if (typeof key === 'symbol') return undefined
|
||||
if (!(key in holder)) holder[key] = function fakeNamed() {}
|
||||
return holder[key]
|
||||
},
|
||||
has: () => true,
|
||||
})
|
||||
}
|
||||
|
||||
const als = createAlsRuntime()
|
||||
|
||||
/**
|
||||
* Execute a transformed body under the real wrapper contract.
|
||||
*
|
||||
* Dependencies are loaded BEFORE the body runs, because the body's `require` is
|
||||
* synchronous while faithful delivery ({@link loadDependency}) is not. A
|
||||
* dependency neither loader can supply falls back to a permissive stand-in: the
|
||||
* subject under test is this file's own export shape, not its dependencies'.
|
||||
* @param code - Emitted CommonJS body.
|
||||
* @param path - Absolute path of the bundle, used for resolution and diagnostics.
|
||||
* @returns The populated `exports` object.
|
||||
*/
|
||||
async function runTransformed(code: string, path: string): Promise<Record<string, unknown>> {
|
||||
const exports: Record<string, unknown> = {}
|
||||
const module = { exports }
|
||||
const loaded = new Map<string, unknown>()
|
||||
await Promise.all(requestedSpecifiers(code).map(async (specifier) => {
|
||||
const delivered = await loadDependency(specifier, path)
|
||||
if (delivered !== undefined) loaded.set(specifier, delivered)
|
||||
}))
|
||||
const fakes = new Map<string, unknown>()
|
||||
const require = (specifier: string): unknown => {
|
||||
const delivered = loaded.get(specifier)
|
||||
if (delivered !== undefined) return delivered
|
||||
if (!fakes.has(specifier)) fakes.set(specifier, fakeModule())
|
||||
return fakes.get(specifier)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- the wrapper contract under test is a `new Function` body
|
||||
const factory = new Function(...WRAPPER_PARAMS, code) as (...args: unknown[]) => void
|
||||
const metaRequire = createRequire(pathToFileURL(path))
|
||||
factory(exports, require, module, path, path.replace(/\/[^/]*$/, ''), {
|
||||
url: pathToFileURL(path).href,
|
||||
// Path-anchored like the worker loader; an import-only export face falls
|
||||
// back to this check file's own resolver.
|
||||
resolve: (specifier: string) => {
|
||||
try {
|
||||
return pathToFileURL(metaRequire.resolve(specifier)).href
|
||||
} catch {
|
||||
return import.meta.resolve(specifier)
|
||||
}
|
||||
},
|
||||
}, als)
|
||||
return exports
|
||||
}
|
||||
|
||||
/** Module-syntax counts read from the AST. */
|
||||
interface Counts {
|
||||
staticImports: number
|
||||
dynamicImports: number
|
||||
importMeta: number
|
||||
awaitExpressions: number
|
||||
}
|
||||
|
||||
/** @returns Occurrence counts of the forms the transform rewrites. */
|
||||
function countForms(source: string, _path: string): Counts {
|
||||
const counts: Counts = { staticImports: 0, dynamicImports: 0, importMeta: 0, awaitExpressions: 0 }
|
||||
let program: unknown
|
||||
try {
|
||||
program = parse(source, { ecmaVersion: 'latest', sourceType: 'module', allowAwaitOutsideFunction: true })
|
||||
} catch {
|
||||
// Counting is reporting only; a parse failure is the transform's to report.
|
||||
return counts
|
||||
}
|
||||
const walk = (node: unknown): void => {
|
||||
if (node === null || typeof node !== 'object') return
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) walk(child)
|
||||
return
|
||||
}
|
||||
const record = node as Record<string, unknown>
|
||||
if (typeof record.type !== 'string') return
|
||||
if (record.type === 'ImportDeclaration') counts.staticImports += 1
|
||||
if (record.type === 'ImportExpression') counts.dynamicImports += 1
|
||||
if (record.type === 'AwaitExpression') counts.awaitExpressions += 1
|
||||
if (record.type === 'MetaProperty' && (record.meta as { name?: string } | undefined)?.name === 'import') {
|
||||
counts.importMeta += 1
|
||||
}
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key === 'type' || key === 'start' || key === 'end') continue
|
||||
walk(value)
|
||||
}
|
||||
}
|
||||
walk(program)
|
||||
return counts
|
||||
}
|
||||
|
||||
const files = process.argv.slice(2).length > 0
|
||||
? process.argv.slice(2).map(path => (path.startsWith('/') ? path : join(process.cwd(), path)))
|
||||
: discover()
|
||||
@@ -304,67 +100,17 @@ if (files.length === 0) {
|
||||
process.stdout.write('transform-corpus-check: no built bundles found; run `pnpm run build:lib:host` first\n')
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
const verdicts = {
|
||||
ok: 0, mismatch: 0, transformFailed: 0, execFailed: 0, exempt: 0, unexpectedBaseline: 0, sentinelRefused: 0,
|
||||
}
|
||||
const totals = { bytesIn: 0, bytesOut: 0, lowered: 0, unchanged: 0, lineDrift: 0 }
|
||||
const counts: Counts = { staticImports: 0, dynamicImports: 0, importMeta: 0, awaitExpressions: 0 }
|
||||
const verdicts = { ok: 0, exempt: 0, unexpectedBaseline: 0 }
|
||||
|
||||
for (const file of files) {
|
||||
const key = relative(file)
|
||||
const source = readFileSync(file, 'utf8')
|
||||
const observed = countForms(source, file)
|
||||
counts.staticImports += observed.staticImports
|
||||
counts.dynamicImports += observed.dynamicImports
|
||||
counts.importMeta += observed.importMeta
|
||||
counts.awaitExpressions += observed.awaitExpressions
|
||||
totals.bytesIn += source.length
|
||||
|
||||
let code: string
|
||||
try {
|
||||
code = lowerModuleSource({ filename: file, source }).code
|
||||
} catch (reason) {
|
||||
const message = (reason as Error).message
|
||||
if (DOUBLE_LOWERING_SENTINEL.has(key)) {
|
||||
// Expected: this bundle's own text contains the sentinels the guard
|
||||
// matches. Assert it is really the guard talking, not some other refusal.
|
||||
if (message.includes('already lowered')) {
|
||||
verdicts.sentinelRefused += 1
|
||||
} else {
|
||||
fail(`- WRONG REFUSAL ${key}: expected the double-lowering guard, got: ${message}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fail(`- TRANSFORM FAILED ${key}: ${message}`)
|
||||
verdicts.transformFailed += 1
|
||||
continue
|
||||
}
|
||||
if (DOUBLE_LOWERING_SENTINEL.has(key)) {
|
||||
fail(`- STALE SENTINEL ${key}: the double-lowering guard no longer refuses it; `
|
||||
+ 'remove it from DOUBLE_LOWERING_SENTINEL or check whether the guard still works')
|
||||
}
|
||||
totals.bytesOut += code.length
|
||||
if (code === source) totals.unchanged += 1
|
||||
else totals.lowered += 1
|
||||
|
||||
// The debugging contract, over the whole corpus: a transformed body has the
|
||||
// same line count as its source, so a stack frame still points at the right
|
||||
// line.
|
||||
const sourceLines = source.split('\n').length
|
||||
const codeLines = code.split('\n').length
|
||||
if (sourceLines !== codeLines) {
|
||||
fail(`- LINE DRIFT ${key}: source ${String(sourceLines)} lines, transformed ${String(codeLines)}`)
|
||||
totals.lineDrift += 1
|
||||
}
|
||||
|
||||
const exemption = BASELINE_EXEMPT.get(key)
|
||||
let expected: string[]
|
||||
try {
|
||||
expected = Object.keys(await import(pathToFileURL(file).href) as object).sort()
|
||||
await import(pathToFileURL(file).href)
|
||||
} catch (reason) {
|
||||
if (exemption === undefined) {
|
||||
// A bundle that stopped being importable is a real finding, so it fails
|
||||
// rather than joining a tolerated total.
|
||||
// A bundle that stopped being importable is a real finding, so it
|
||||
// fails rather than joining a tolerated total.
|
||||
fail(`- UNEXPECTED BASELINE FAILURE ${key}: ${(reason as Error).message.split('\n')[0]}`)
|
||||
verdicts.unexpectedBaseline += 1
|
||||
} else {
|
||||
@@ -376,42 +122,18 @@ if (files.length === 0) {
|
||||
// The exemption list must stay honest in the other direction too: a file
|
||||
// that became importable should leave the list.
|
||||
fail(`- STALE EXEMPTION ${key}: imports fine now (${exemption}); remove it from BASELINE_EXEMPT`)
|
||||
}
|
||||
|
||||
let actual: string[]
|
||||
try {
|
||||
actual = Object.keys(await runTransformed(code, file)).sort()
|
||||
} catch (reason) {
|
||||
fail(`- EXEC FAILED ${key}: ${(reason as Error).message.split('\n')[0]}`)
|
||||
verdicts.execFailed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const missing = expected.filter(name => !actual.includes(name))
|
||||
const extra = actual.filter(name => !expected.includes(name))
|
||||
if (missing.length === 0 && extra.length === 0) {
|
||||
verdicts.ok += 1
|
||||
continue
|
||||
}
|
||||
fail(`- EXPORT MISMATCH ${key}: missing=[${missing.join(',')}] extra=[${extra.join(',')}]`)
|
||||
verdicts.mismatch += 1
|
||||
verdicts.ok += 1
|
||||
}
|
||||
|
||||
const growth = totals.bytesIn === 0 ? 0 : ((totals.bytesOut - totals.bytesIn) / totals.bytesIn) * 100
|
||||
log('')
|
||||
log(`files=${String(files.length)} ok=${String(verdicts.ok)} exportMismatch=${String(verdicts.mismatch)} `
|
||||
+ `transformFailed=${String(verdicts.transformFailed)} execFailed=${String(verdicts.execFailed)} `
|
||||
+ `lineDrift=${String(totals.lineDrift)} baselineExempt=${String(verdicts.exempt)} `
|
||||
+ `sentinelRefused=${String(verdicts.sentinelRefused)} `
|
||||
log(`files=${String(files.length)} ok=${String(verdicts.ok)} baselineExempt=${String(verdicts.exempt)} `
|
||||
+ `unexpectedBaselineFailure=${String(verdicts.unexpectedBaseline)}`)
|
||||
log(`lowered=${String(totals.lowered)} packedAsIs=${String(totals.unchanged)} `
|
||||
+ `bytes ${String(totals.bytesIn)} -> ${String(totals.bytesOut)} (${growth.toFixed(1)}%)`)
|
||||
log(`forms: staticImport=${String(counts.staticImports)} dynamicImport=${String(counts.dynamicImports)} `
|
||||
+ `importMeta=${String(counts.importMeta)} await=${String(counts.awaitExpressions)}`)
|
||||
|
||||
process.stdout.write(failures === 0
|
||||
? `\ntransform-corpus-check: ${String(verdicts.ok)} bundles match their ESM baseline, `
|
||||
+ `${String(verdicts.exempt)} exempt, ${String(verdicts.sentinelRefused)} sentinel-refused, no drift\n`
|
||||
? `\ntransform-corpus-check: ${String(verdicts.ok)} bundles import under Node, `
|
||||
+ `${String(verdicts.exempt)} exempt\n`
|
||||
: `\ntransform-corpus-check: ${String(failures)} finding(s)\n`)
|
||||
process.exitCode = failures === 0 ? 0 : 1
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* Runs the full-corpus transform gate (`transform-corpus-check.ts`) in the
|
||||
* Runs the full-corpus import gate (`transform-corpus-check.ts`) in the
|
||||
* launcher it is written for, and reports its findings as this suite's failure.
|
||||
*
|
||||
* Spawned rather than imported, because the gate's oracle is NODE's ESM loader:
|
||||
* every built bundle's transformed export shape is compared against what
|
||||
* `await import(file)` produces there. Vitest replaces that loader with vite's
|
||||
* module runner, which imports files Node cannot — a `.css` import resolves, and
|
||||
* koffi loads a second time — so an in-process corpus run measures the transform
|
||||
* against a different loader and reports three of the four pinned baseline
|
||||
* exemptions as stale. The gate's own note applies to itself: a gate whose
|
||||
* verdict depends on how it was launched is not a gate.
|
||||
* whether a built bundle imports is judged by `await import(file)` there.
|
||||
* Vitest replaces that loader with vite's module runner, which imports files
|
||||
* Node cannot — a `.css` import resolves, and koffi loads a second time — so an
|
||||
* in-process run measures a different loader and reports the pinned baseline
|
||||
* exemptions as stale. A gate whose verdict depends on how it was launched is
|
||||
* not a gate.
|
||||
*
|
||||
* The corpus is the build output, so this skips on a tree that has none.
|
||||
*/
|
||||
@@ -19,7 +18,7 @@ import { expect, test } from 'vitest'
|
||||
|
||||
const runner = fileURLToPath(new URL('./transform-corpus-check.ts', import.meta.url))
|
||||
|
||||
test('every built bundle transforms to the export shape Node loads', (context) => {
|
||||
test('every built bundle imports under Node', (context) => {
|
||||
const finished = spawnSync(process.execPath, ['--import', 'tsx/esm', runner], { encoding: 'utf8' })
|
||||
const output = `${finished.stdout}${finished.stderr}`
|
||||
if (output.includes('no built bundles found')) {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* are rewritten, that line numbers survive, which forms are refused, and that
|
||||
* every covered trap form stays fixed.
|
||||
*
|
||||
* Scope boundary: this file checks the transform itself; the image collector's
|
||||
* loop around it is covered by the packer's `transform-image.spec.ts`.
|
||||
* Scope boundary: this file checks the transform itself; the pack-time loop
|
||||
* around it is covered end-to-end by the packer's `image-loadable.spec.ts`.
|
||||
* Emitted-code assertions are deliberately written against substrings
|
||||
* of the real output rather than whole-file goldens: a golden would fail on every
|
||||
* helper reordering, which is not the contract. The contract is the observable
|
||||
|
||||
@@ -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/preset/agent-presets/README.md
|
||||
README.md: e2e002da72069a0ad0edd5cc8e2692784dcbd8bd
|
||||
README.zh.md: 4f0e11413edaa8f461681d2e1a1e5b23b2421b49
|
||||
README.md: 5b83ed85a6691dabb6ec340b6462f17d5281efdd
|
||||
README.zh.md: f55f1e383d11b28fa1d8ff52aec1e94a351914f7
|
||||
|
||||
@@ -80,7 +80,7 @@ A session can switch to a different preset only while it has produced nothing
|
||||
|
||||
### Failures and recovery
|
||||
|
||||
A preset whose composition is missing, unparsable, or not a list of named plugin rows is listed as broken with a one-line reason; composing such a preset is refused up front, so a session never starts half-composed. A composition that parses but names a missing package, or waits for a service the composition never supplies, fails at session creation, which rolls the creation back. Fix the preset's file or delete it, then retry.
|
||||
A preset whose composition is missing, unparsable, not a list of named plugin rows, or naming a module that cannot be resolved is listed as broken with a reason naming the rows at fault; composing such a preset is refused up front, so a session never starts half-composed. What survives to session creation is a row whose module loads and then refuses — a plugin that throws, or one waiting for a service the composition never supplies — which fails the creation and rolls it back, naming every failed row including those inside a group. Fix the preset's file or delete it, then retry.
|
||||
|
||||
-----
|
||||
|
||||
@@ -169,7 +169,7 @@ These limits define when the roster is a poor fit or needs special operational c
|
||||
- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts.
|
||||
- **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-filesystem` watches its roots by default, so each edit-then-create cycle adds a live watcher set.
|
||||
- **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start.
|
||||
- **Health is a shape check, not a mount** — discovery proves the composition parses in the loader dialect and holds named rows, not that every row's module resolves or activates; a row naming an absent package still fails at the first session, which rolls the creation back.
|
||||
- **Health asks what is installed, not what would import** — discovery proves the composition parses in the loader dialect, holds named rows, and that each row it can prove will start names a package present above the harness base or a file that exists; it never imports one, so a package whose own entry file is missing, a plugin that throws on apply, and one waiting forever for a service all still fail at the first session. `disabled` is the one entry field the Loader interpolates, so a row carrying an expression there is left unchecked rather than judged from the file.
|
||||
- **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change"; the shipped set itself accepts the same cost — `cordis` and `code` each duplicate `standard`'s full assembly and then edit it — so the whole assembly stays readable in one file.
|
||||
- **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`.
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ agent-presets:
|
||||
|
||||
### 失败与恢复
|
||||
|
||||
组装缺失、无法解析或不是具名插件行列表的 preset 会被列为 broken,并附带一行原因;组装此类 preset 会被提前拒绝,因此会话绝不会以半组装状态启动。能解析但引用了缺失包、或等待组装从未提供的服务的组装,会在会话创建时失败并回滚创建。修复 preset 的文件或删除它,然后重试。
|
||||
组装缺失、无法解析、不是具名插件行列表,或者引用了无法解析的模块的 preset 会被列为 broken,原因会指名出问题的行;组装此类 preset 会被提前拒绝,因此会话绝不会以半组装状态启动。能活到会话创建的,是模块能加载但随后拒绝的行——抛错的插件,或等待组装从未提供的服务的插件——它会让创建失败并回滚,且会指名每一个失败的行,包括组内的行。修复 preset 的文件或删除它,然后重试。
|
||||
|
||||
-----
|
||||
|
||||
@@ -169,7 +169,7 @@ agent-presets:
|
||||
- **代际只以组装文件为键**——stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。
|
||||
- **被替代的代际永不回收**——已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-filesystem` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。
|
||||
- **副本从不被实际挂载以校验**——它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。
|
||||
- **健康是形状检查,不是挂载**——发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。
|
||||
- **健康问的是「装没装」,不是「能不能 import」**——发现过程证明组装能以加载器方言解析、由具名行组成,且每一行它能证明会启动的行所引用的包装在 harness 基准之上、或所引用的文件确实存在;它从不 import 任何一个,因此入口文件缺失的包、在 apply 时抛错的插件、以及永远等待某个服务的插件,都仍在第一个会话处失败。`disabled` 是加载器唯一会插值的条目字段,因此在该字段写了表达式的行会被跳过,而不是仅凭文件下判断。
|
||||
- **副本是会漂移的快照**——升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义;随附集合自己也接受同样的代价——`cordis` 与 `code` 都复制了 `standard` 的完整组装并在此基础上编辑——换来整份组装在一个文件里可读。
|
||||
- **根目录扫描不做监听**——每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-group": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
|
||||
@@ -11,17 +11,27 @@
|
||||
* path refuses the name while no surface shows anything to delete — and a
|
||||
* malformed composition would otherwise read as an ordinary preset until the
|
||||
* first session fails to mount it.
|
||||
*
|
||||
* Health is what every consumer reads before offering a preset — the pickers
|
||||
* drop a broken row rather than defer the discovery to a failed session
|
||||
* start — so it covers the way an authored preset actually rots: a row naming
|
||||
* a package that was renamed or uninstalled. Resolving those names is a
|
||||
* separate pass from the shape check and stops short of importing anything,
|
||||
* so a composition is judged without running a line of plugin code.
|
||||
* @module @deepseek-ai/dsh-agent-presets/discovery
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { isBuiltin } from 'node:module'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { load } from 'js-yaml'
|
||||
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-home-paths'
|
||||
import { readPresetMetadata } from './metadata.ts'
|
||||
import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts'
|
||||
import { classifyRowSpecifier, type RowSpecifier } from './specifier.ts'
|
||||
|
||||
/** The composition file that makes a directory a preset. */
|
||||
export const COMPOSITION_FILE = 'agent.cordis.yml'
|
||||
@@ -84,15 +94,132 @@ function entryListProblem(rows: unknown, at = ''): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a package name is installed anywhere above `base`.
|
||||
*
|
||||
* Node's own upward `node_modules` walk, stopping at the package directory:
|
||||
* the question is whether the package is there at all, which is what a row
|
||||
* naming a package a rename or an uninstall took away gets wrong. A pnpm
|
||||
* store link answers through the symlink, and a link left dangling by a
|
||||
* deleted checkout answers false — the shape a stale profile install leaves.
|
||||
*
|
||||
* `existsSync` rather than the async `stat`: the walk is a handful of lookups
|
||||
* per package and runs on every roster read, where 150 promise round-trips
|
||||
* cost more than the lookups they wrap.
|
||||
* @param name - the package specifier, possibly carrying a subpath.
|
||||
* @param base - the URL to walk up from.
|
||||
* @returns true when the package directory is installed above `base`.
|
||||
*/
|
||||
function packageInstalled(name: string, base: string): boolean {
|
||||
// A scoped name spends two segments on the package; anything after either
|
||||
// form is a subpath export, which lives inside the package directory.
|
||||
const pkg = name.split('/').slice(0, name.startsWith('@') ? 2 : 1).join('/')
|
||||
let dir = fileURLToPath(base)
|
||||
for (;;) {
|
||||
if (existsSync(join(dir, 'node_modules', pkg, 'package.json'))) return true
|
||||
const parent = dirname(dir)
|
||||
if (parent === dir) return false
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 — 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.
|
||||
*
|
||||
* `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.
|
||||
* @returns true when the row names something that can be imported.
|
||||
*/
|
||||
async function rowResolves(row: RowSpecifier, presetBase: string, harnessBase: string): Promise<boolean> {
|
||||
if (row.kind === 'builtin') return true
|
||||
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 {
|
||||
/** `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
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows whose module cannot be resolved.
|
||||
*
|
||||
* 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
|
||||
* same way it does.
|
||||
* @param rows - the parsed composition rows.
|
||||
* @param presetBase - directory URL a preset-relative specifier resolves against.
|
||||
* @param harnessBase - base URL a package name resolves against.
|
||||
* @param at - row-path prefix for nested diagnostics, empty at the top level.
|
||||
* @returns one entry per unresolvable row, in composition order.
|
||||
*/
|
||||
async function unresolvableRows(
|
||||
rows: readonly unknown[],
|
||||
presetBase: string,
|
||||
harnessBase: string,
|
||||
at = '',
|
||||
): Promise<UnresolvableRow[]> {
|
||||
const found: UnresolvableRow[] = []
|
||||
for (const [index, entry] of rows.entries()) {
|
||||
const row = entry as { id?: unknown; name: string; group?: unknown; config?: unknown; disabled?: unknown }
|
||||
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 "${row.id}"` : positional
|
||||
found.push({ label, name: row.name })
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the composition at `path` cannot mount, or undefined when it looks
|
||||
* loadable. Parsed with the loader's own YAML dialect ({@link entryListSchema},
|
||||
* the one carrying `!!js`), so health can never call a composition broken
|
||||
* that the loader would accept.
|
||||
* @param path - absolute path of the composition file.
|
||||
* @param harnessBase - base URL a row's package name resolves against.
|
||||
* @returns one human-readable reason, or undefined when the file is loadable.
|
||||
*/
|
||||
async function compositionProblem(path: string): Promise<string | undefined> {
|
||||
async function compositionProblem(path: string, harnessBase: string): Promise<string | undefined> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(path, 'utf8')
|
||||
@@ -111,7 +238,19 @@ async function compositionProblem(path: string): Promise<string | undefined> {
|
||||
// the reason is displayed on a roster card, not in a terminal.
|
||||
return `the composition is not valid YAML: ${full.replace(/\n[\s\S]*$/, '')}`
|
||||
}
|
||||
return entryListProblem(rows)
|
||||
const shape = entryListProblem(rows)
|
||||
if (shape !== undefined) return shape
|
||||
// The composition's own directory, exactly as `Include` derives it, so a
|
||||
// row naming a file the preset ships resolves the way the mount will.
|
||||
const presetBase = new URL('.', pathToFileURL(path)).href
|
||||
const unresolvable = await unresolvableRows(rows as readonly unknown[], presetBase, harnessBase)
|
||||
const [first] = unresolvable
|
||||
if (first === undefined) return undefined
|
||||
if (unresolvable.length === 1) {
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,9 +282,11 @@ async function isFile(path: string): Promise<boolean> {
|
||||
* so it blocks nothing, and reporting `.DS_Store`-grade residue as broken
|
||||
* presets would teach users to ignore the marker.
|
||||
* @param root - the directory and the trust its presets inherit.
|
||||
* @param harnessBase - base URL a row's package name resolves against; the
|
||||
* caller's own `ctx.baseUrl`, which is where the installed harness lives.
|
||||
* @returns the root's presets ordered by id.
|
||||
*/
|
||||
export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
|
||||
export async function scanRoot(root: PresetRoot, harnessBase: string): Promise<AgentPreset[]> {
|
||||
const dir = resolve(expandHomePath(root.path))
|
||||
let children
|
||||
try {
|
||||
@@ -160,7 +301,7 @@ export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
|
||||
const directory = join(dir, child.name)
|
||||
const path = join(directory, COMPOSITION_FILE)
|
||||
const broken = await isFile(path)
|
||||
? await compositionProblem(path)
|
||||
? await compositionProblem(path, harnessBase)
|
||||
: `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file`
|
||||
// Display text only, and never fatal: a preset with unreadable metadata
|
||||
// still mounts, it just shows its id.
|
||||
@@ -181,12 +322,16 @@ export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
|
||||
/**
|
||||
* Scan every root in precedence order.
|
||||
* @param roots - roots in precedence order; an earlier root wins a duplicate id.
|
||||
* @param harnessBase - base URL a row's package name resolves against.
|
||||
* @returns every discovered preset, first-root-wins per id.
|
||||
*/
|
||||
export async function discoverPresets(roots: readonly PresetRoot[]): Promise<AgentPreset[]> {
|
||||
export async function discoverPresets(
|
||||
roots: readonly PresetRoot[],
|
||||
harnessBase: string,
|
||||
): Promise<AgentPreset[]> {
|
||||
const byId = new Map<string, AgentPreset>()
|
||||
for (const root of roots) {
|
||||
for (const preset of await scanRoot(root)) {
|
||||
for (const preset of await scanRoot(root, harnessBase)) {
|
||||
if (byId.has(preset.id)) continue
|
||||
byId.set(preset.id, preset)
|
||||
}
|
||||
|
||||
@@ -180,6 +180,18 @@ export class AgentPresets extends TypertRemoteService {
|
||||
*/
|
||||
private readonly resolvedRoots: readonly PresetRoot[]
|
||||
|
||||
/**
|
||||
* Where a row's package name resolves from: the base URL of the composition
|
||||
* this roster was loaded by, which is inside the installed harness.
|
||||
*
|
||||
* Discovery needs it because a preset's own directory is the wrong base for
|
||||
* a package name — a locally authored preset lives under the user's home,
|
||||
* where Node's upward `node_modules` walk never reaches the harness's
|
||||
* dependencies. The mount already resolves rows this way; holding the same
|
||||
* base here is what lets health answer the question before a session does.
|
||||
*/
|
||||
private readonly harnessBase: string
|
||||
|
||||
/**
|
||||
* The user layer over `config.default`, present only while a settings
|
||||
* provider is composed. Held rather than snapshotted so a hot-reloaded
|
||||
@@ -206,6 +218,18 @@ export class AgentPresets extends TypertRemoteService {
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentPresets')
|
||||
this.selfCtx = ctx
|
||||
const { baseUrl } = ctx
|
||||
if (baseUrl === undefined) {
|
||||
// Self-contained misconfiguration, so it fails at load: without a base
|
||||
// the roster can neither resolve a row nor tell a healthy preset from
|
||||
// one naming a package that is gone, and the silent alternative is the
|
||||
// exact failure this check exists to report.
|
||||
throw new Error(
|
||||
'agent-presets: the roster needs `ctx.baseUrl` to resolve the plugins a composition names; '
|
||||
+ 'compose it under a Loader, or set the base on the context this plugin is applied to',
|
||||
)
|
||||
}
|
||||
this.harnessBase = baseUrl
|
||||
this.resolvedRoots = [
|
||||
...config.includeShippedRoot ? [{ path: SHIPPED_PRESET_ROOT, trust: 'system' } satisfies PresetRoot] : [],
|
||||
...config.roots,
|
||||
@@ -279,7 +303,7 @@ export class AgentPresets extends TypertRemoteService {
|
||||
* @returns the presets, first-root-wins per id.
|
||||
*/
|
||||
async list(): Promise<AgentPreset[]> {
|
||||
return await discoverPresets(this.resolvedRoots)
|
||||
return await discoverPresets(this.resolvedRoots, this.harnessBase)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
* @module @deepseek-ai/dsh-agent-presets/mount
|
||||
*/
|
||||
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { Include } from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { EntryTree } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { PresetMountError, type AgentPreset } from './preset.ts'
|
||||
import { classifyRowSpecifier } from './specifier.ts'
|
||||
|
||||
/** What one mounted subtree publishes about itself for the audit to read. */
|
||||
interface MountedTree {
|
||||
@@ -74,21 +74,24 @@ class PresetTree extends Include {
|
||||
* filesystem path names neither base and becomes a file URL before Node's
|
||||
* ESM loader receives it, which is required for drive-letter paths on
|
||||
* Windows.
|
||||
*
|
||||
* {@link classifyRowSpecifier} makes that split, so discovery's health check
|
||||
* resolves every row from the same base this import uses.
|
||||
* @param name - the module specifier from the row.
|
||||
* @param getOuterStack - the loader's stack composer for import diagnostics.
|
||||
* @returns the imported module, or the `cordis:` builtin.
|
||||
*/
|
||||
override import(name: string, getOuterStack?: () => string[]): unknown {
|
||||
const specifier = isAbsolute(name) ? pathToFileURL(name).href : name
|
||||
const row = classifyRowSpecifier(name)
|
||||
const base = harnessBase.get(this.config)
|
||||
/* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */
|
||||
if (base === undefined) return super.import(specifier, getOuterStack)
|
||||
if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack)
|
||||
if (base === undefined) return super.import(row.specifier, getOuterStack)
|
||||
if (row.kind === 'builtin' || row.kind === 'preset') return super.import(row.specifier, getOuterStack)
|
||||
const internal = this.ctx.loader.internal
|
||||
/* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a
|
||||
hypothetical embedder from losing the row's name in a resolution error. */
|
||||
if (internal === undefined) return super.import(specifier, getOuterStack)
|
||||
return internal.import(specifier, base, {})
|
||||
if (internal === undefined) return super.import(row.specifier, getOuterStack)
|
||||
return internal.import(row.specifier, base, {})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,13 +305,34 @@ export function inactiveRows(tree: EntryTree): string[] {
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The causes of `error` whose detail its own message does not already carry.
|
||||
*
|
||||
* `AggregateError` names none of its causes in its own message, so its
|
||||
* `errors` are the branches. The Loader's per-row wrapper takes the opposite
|
||||
* approach: it appends `cause.message` to the message it builds and keeps the
|
||||
* cause only as `error.cause`, so following a plain chain would print every
|
||||
* line twice. That leaves exactly one lossy shape — a wrapped row whose cause
|
||||
* is an `AggregateError`. Its message ends with the aggregate's own line and
|
||||
* drops the `errors` behind it, which is how a failed group reports as
|
||||
* "loader entries failed to apply" and names none of the rows that failed.
|
||||
* @param error - the failure to read branches from.
|
||||
* @returns the branches to render beneath `error.message`, possibly empty.
|
||||
*/
|
||||
function detailBranches(error: Error): readonly unknown[] {
|
||||
if (error instanceof AggregateError) return error.errors
|
||||
return error.cause instanceof AggregateError ? error.cause.errors : []
|
||||
}
|
||||
|
||||
/**
|
||||
* The reportable text of a mount failure.
|
||||
*
|
||||
* The loader reports several failed rows as one `AggregateError`, whose own
|
||||
* message names none of them; without flattening, a composition that fails on
|
||||
* two rows says only "loader entries failed to apply" and the operator has
|
||||
* nothing to act on.
|
||||
* nothing to act on. Nested groups indent under the row that owns them, so a
|
||||
* composition failing inside a group still names the rows rather than the
|
||||
* group alone.
|
||||
* @param error - the value the mount rejected with.
|
||||
* @returns a single-line-per-cause description.
|
||||
*/
|
||||
@@ -317,8 +341,12 @@ function mountDetail(error: unknown): string {
|
||||
wraps a row's thrown value before it propagates, and this module's own
|
||||
rejections are Errors. The fallback keeps a hostile value readable. */
|
||||
if (!(error instanceof Error)) return String(error)
|
||||
if (!(error instanceof AggregateError)) return error.message
|
||||
return [error.message, ...error.errors.map(cause => `- ${mountDetail(cause)}`)].join('\n')
|
||||
const branches = detailBranches(error)
|
||||
if (branches.length === 0) return error.message
|
||||
return [
|
||||
error.message,
|
||||
...branches.map(branch => `- ${mountDetail(branch).replaceAll('\n', '\n ')}`),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* How one composition row's `name` reaches a module.
|
||||
*
|
||||
* A preset composition is read by `Include`, which rewrites its context's
|
||||
* `baseUrl` to the composition's own directory. That is right for a row
|
||||
* naming a file the preset ships and wrong for a row naming a package: a
|
||||
* locally authored preset lives under the user's home, where Node's upward
|
||||
* `node_modules` walk never reaches the harness's own dependencies. Both the
|
||||
* mount's import override and discovery's health check therefore have to
|
||||
* classify a row's name before they can act on it, and they must classify it
|
||||
* the same way — a row discovery resolves from one base and the mount imports
|
||||
* from another would be reported healthy and then fail to load.
|
||||
* @module @deepseek-ai/dsh-agent-presets/specifier
|
||||
*/
|
||||
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
/** One composition row's module specifier, classified by where it resolves. */
|
||||
export type RowSpecifier =
|
||||
/** A `cordis:` builtin the Loader supplies; nothing is resolved. */
|
||||
| { 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 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 }
|
||||
|
||||
/**
|
||||
* Classify one row's `name`.
|
||||
*
|
||||
* 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. 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 }
|
||||
}
|
||||
@@ -303,6 +303,11 @@ describe('a ghost directory under the user root', () => {
|
||||
await ctx.agentPresets.remove('ghost')
|
||||
expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
|
||||
await ctx.agentPresets.copy('standard', 'ghost')
|
||||
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'ghost')?.broken).toBeUndefined()
|
||||
expect((await ctx.agentPresets.list()).map(preset => preset.id)).toContain('ghost')
|
||||
// A copy carries the whole preset directory, so a preset's own files
|
||||
// travel with it. This fixture's rows reach OUTSIDE that directory, which
|
||||
// no real preset does and which no copy can carry — so the claim here is
|
||||
// the reclaimed id and the restored composition, not the rows' targets.
|
||||
expect(existsSync(join(userRoot, 'ghost', COMPOSITION_FILE))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
@@ -25,6 +25,9 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
})
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
// Standing in for the installed harness: a row's package name resolves from
|
||||
// here, and this directory's upward `node_modules` walk reaches the workspace.
|
||||
const HARNESS = new URL('.', import.meta.url).href
|
||||
const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
|
||||
const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const }
|
||||
|
||||
@@ -45,7 +48,7 @@ describe('display order', () => {
|
||||
await writeFile(join(root, id, COMPOSITION_FILE), '[]\n')
|
||||
}
|
||||
|
||||
const found = await scanRoot({ path: root, trust: 'system' })
|
||||
const found = await scanRoot({ path: root, trust: 'system' }, HARNESS)
|
||||
|
||||
// The shipped set reads by capability; presets that declare nothing stay
|
||||
// alphabetical behind them rather than interleaving unpredictably.
|
||||
@@ -60,7 +63,7 @@ describe('display order', () => {
|
||||
await writeFile(join(root, id, 'preset.yml'), 'order: 1\n')
|
||||
}
|
||||
|
||||
const found = await scanRoot({ path: root, trust: 'system' })
|
||||
const found = await scanRoot({ path: root, trust: 'system' }, HARNESS)
|
||||
|
||||
// Two presets claiming the same slot must still list in a stable order:
|
||||
// a directory-scan order would reshuffle the picker between reads.
|
||||
@@ -70,7 +73,7 @@ describe('display order', () => {
|
||||
|
||||
describe('preset discovery', () => {
|
||||
it('reports one preset per directory holding a composition, ordered by id', async () => {
|
||||
const found = await scanRoot(SYSTEM)
|
||||
const found = await scanRoot(SYSTEM, HARNESS)
|
||||
|
||||
expect(found.map(preset => preset.id)).toEqual(['minimal', 'standard'])
|
||||
expect(found[0]).toEqual({
|
||||
@@ -81,7 +84,7 @@ describe('preset discovery', () => {
|
||||
})
|
||||
|
||||
it('reports a directory with no composition as a broken preset slot', async () => {
|
||||
const found = await scanRoot(USER)
|
||||
const found = await scanRoot(USER, HARNESS)
|
||||
|
||||
// The directory still occupies its id — a copy to that name is refused —
|
||||
// so hiding it would leave nothing to see or delete. It surfaces broken.
|
||||
@@ -96,7 +99,7 @@ describe('preset discovery', () => {
|
||||
await mkdir(join(root, 'usable'))
|
||||
await writeFile(join(root, 'usable', COMPOSITION_FILE), '[]\n')
|
||||
|
||||
const found = await scanRoot({ path: root, trust: 'user' })
|
||||
const found = await scanRoot({ path: root, trust: 'user' }, HARNESS)
|
||||
|
||||
// `.hidden` and `Has_Caps` cannot collide with any copy target, so
|
||||
// reporting tool residue as broken presets would only train users to
|
||||
@@ -105,13 +108,13 @@ describe('preset discovery', () => {
|
||||
})
|
||||
|
||||
it('records the root trust on every preset it discovers', async () => {
|
||||
const found = await scanRoot(USER)
|
||||
const found = await scanRoot(USER, HARNESS)
|
||||
|
||||
expect(found.every(preset => preset.trust === 'user')).toBe(true)
|
||||
})
|
||||
|
||||
it('lets the earlier root win a duplicate id', async () => {
|
||||
const found = await discoverPresets([SYSTEM, USER])
|
||||
const found = await discoverPresets([SYSTEM, USER], HARNESS)
|
||||
|
||||
const standard = found.filter(preset => preset.id === 'standard')
|
||||
expect(standard).toHaveLength(1)
|
||||
@@ -119,7 +122,7 @@ describe('preset discovery', () => {
|
||||
})
|
||||
|
||||
it('treats an absent root as supplying no presets', async () => {
|
||||
const found = await scanRoot({ path: join(FIXTURES, 'no-such-root'), trust: 'user' })
|
||||
const found = await scanRoot({ path: join(FIXTURES, 'no-such-root'), trust: 'user' }, HARNESS)
|
||||
|
||||
expect(found).toEqual([])
|
||||
})
|
||||
@@ -130,7 +133,7 @@ describe('preset discovery', () => {
|
||||
await mkdir(join(root, 'real'))
|
||||
await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n')
|
||||
|
||||
const found = await scanRoot({ path: root, trust: 'user' })
|
||||
const found = await scanRoot({ path: root, trust: 'user' }, HARNESS)
|
||||
|
||||
expect(found.map(preset => preset.id)).toEqual(['real'])
|
||||
})
|
||||
@@ -140,26 +143,34 @@ describe('preset discovery', () => {
|
||||
const notADirectory = join(root, 'file-as-root')
|
||||
await writeFile(notADirectory, 'not a directory\n')
|
||||
|
||||
await expect(scanRoot({ path: notADirectory, trust: 'user' }))
|
||||
await expect(scanRoot({ path: notADirectory, trust: 'user' }, HARNESS))
|
||||
.rejects.toThrow(/cannot read preset root/)
|
||||
})
|
||||
|
||||
it('expands a leading tilde in a root path', async () => {
|
||||
// `~` alone resolves to the home directory, which exists but holds no
|
||||
// preset directories; the point is that it did not throw on a literal `~`.
|
||||
const found = await scanRoot({ path: '~/.dsh-agent-presets-absent', trust: 'user' })
|
||||
const found = await scanRoot({ path: '~/.dsh-agent-presets-absent', trust: 'user' }, HARNESS)
|
||||
|
||||
expect(found).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('composition health', () => {
|
||||
/** One directory under a fresh root holding `composition`, scanned. */
|
||||
/**
|
||||
* One directory under a fresh root holding `composition`, scanned.
|
||||
*
|
||||
* Rows that exist only to carry a shape name `js-yaml`, a package the
|
||||
* harness base really resolves: health resolves every enabled row's module,
|
||||
* so an invented name would answer the wrong check.
|
||||
* @param composition - the composition file's contents.
|
||||
* @returns the reported reason, or undefined when the composition is healthy.
|
||||
*/
|
||||
async function scanned(composition: string): Promise<string | undefined> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-'))
|
||||
await mkdir(join(root, 'probe'))
|
||||
await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' })
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS)
|
||||
return preset?.broken
|
||||
}
|
||||
|
||||
@@ -172,7 +183,7 @@ describe('composition health', () => {
|
||||
})
|
||||
|
||||
it('reports the first row that names no plugin, by position', async () => {
|
||||
expect(await scanned('- id: ok\n name: some-plugin\n- id: broken\n'))
|
||||
expect(await scanned('- id: ok\n name: js-yaml\n- id: broken\n'))
|
||||
.toMatch(/row 2 names no plugin/)
|
||||
})
|
||||
|
||||
@@ -191,7 +202,7 @@ describe('composition health', () => {
|
||||
})
|
||||
|
||||
it('accepts a group whose own list is healthy', async () => {
|
||||
const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n name: some-plugin\n'
|
||||
const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n name: js-yaml\n'
|
||||
expect(await scanned(composition)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -202,7 +213,7 @@ describe('composition health', () => {
|
||||
await writeFile(path, '[]\n')
|
||||
fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' })
|
||||
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' })
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS)
|
||||
|
||||
expect(fsHarness.nextReadError).toBeUndefined()
|
||||
expect(preset?.broken).toMatch(/cannot be read/)
|
||||
@@ -211,7 +222,7 @@ describe('composition health', () => {
|
||||
it('accepts the loader dialect, !!js scalars included', async () => {
|
||||
// Health must never call a composition broken that the loader accepts:
|
||||
// `!!js` is the loader's own extension, so it parses here too.
|
||||
const composition = '- id: x\n name: some-plugin\n config:\n value: !!js "1 + 1"\n'
|
||||
const composition = '- id: x\n name: js-yaml\n config:\n value: !!js "1 + 1"\n'
|
||||
expect(await scanned(composition)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -219,3 +230,125 @@ describe('composition health', () => {
|
||||
expect(await scanned('[]\n')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rows naming a plugin that cannot be resolved', () => {
|
||||
/** One directory under a fresh root holding `composition`, scanned. */
|
||||
async function scanned(composition: string): Promise<string | undefined> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-resolve-'))
|
||||
await mkdir(join(root, 'probe'))
|
||||
await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS)
|
||||
return preset?.broken
|
||||
}
|
||||
|
||||
it('reports a package the harness cannot resolve, with the row and the name', async () => {
|
||||
// The way an authored preset actually rots: it named a package that a
|
||||
// later release renamed, so the composition still parses and still cannot
|
||||
// compose a session.
|
||||
expect(await scanned('- id: stale\n name: \'@deepseek-ai/dsh-no-such-package\'\n'))
|
||||
.toBe('row "stale" names a plugin that cannot be resolved: @deepseek-ai/dsh-no-such-package')
|
||||
})
|
||||
|
||||
it('names every unresolvable row rather than only the first', async () => {
|
||||
// Unlike a parse failure, one unresolvable name tells you nothing about
|
||||
// the next: fixing them one reload at a time is the avoidable part.
|
||||
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- 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'))
|
||||
.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 () => {
|
||||
const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - name: no-such-plugin\n'
|
||||
|
||||
expect(await scanned(composition)).toMatch(/row 1 row 1 names a plugin that cannot be resolved/)
|
||||
})
|
||||
|
||||
it('resolves a preset-relative row against the preset\'s own directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-relative-'))
|
||||
await mkdir(join(root, 'probe'))
|
||||
await writeFile(join(root, 'probe', 'own-plugin.mjs'), 'export function apply() {}\n')
|
||||
await writeFile(join(root, 'probe', COMPOSITION_FILE), '- id: own\n name: ./own-plugin.mjs\n- id: gone\n name: ./deleted.mjs\n')
|
||||
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS)
|
||||
|
||||
// A file the preset ships resolves beside its composition, not from the
|
||||
// harness — the same split the mount's import override makes.
|
||||
expect(preset?.broken).toBe('row "gone" names a plugin that cannot be resolved: ./deleted.mjs')
|
||||
})
|
||||
|
||||
it('skips a row the loader may never start', async () => {
|
||||
// `disabled` is the one entry field the Loader interpolates, so a `!!js`
|
||||
// row cannot be judged from the file; calling a usable preset broken is
|
||||
// worse than leaving a switched-off row to fail at mount as before.
|
||||
const composition = '- id: off\n name: no-such-plugin\n disabled: true\n'
|
||||
+ '- id: maybe\n name: no-such-either\n disabled: !!js process.platform === \'win32\'\n'
|
||||
|
||||
expect(await scanned(composition)).toBeUndefined()
|
||||
})
|
||||
|
||||
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.
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-presets-installed-'))
|
||||
await mkdir(join(home, 'node_modules', '@scope', 'pkg'), { recursive: true })
|
||||
await writeFile(join(home, 'node_modules', '@scope', 'pkg', 'package.json'), '{"name":"@scope/pkg"}\n')
|
||||
await mkdir(join(home, 'presets', 'probe'), { recursive: true })
|
||||
await writeFile(join(home, 'presets', 'probe', COMPOSITION_FILE), "- id: p\n name: '@scope/pkg'\n")
|
||||
|
||||
const [preset] = await scanRoot(
|
||||
{ path: join(home, 'presets'), trust: 'user' }, pathToFileURL(join(home, 'app/')).href)
|
||||
|
||||
expect(preset?.broken).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a package whose install link dangles', async () => {
|
||||
// What a stale profile install leaves behind: the name is still in
|
||||
// `node_modules`, pointing at a checkout that is gone.
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-presets-dangling-'))
|
||||
await mkdir(join(home, 'node_modules', '@scope'), { recursive: true })
|
||||
await symlink(join(home, 'deleted-checkout'), join(home, 'node_modules', '@scope', 'pkg'))
|
||||
await mkdir(join(home, 'presets', 'probe'), { recursive: true })
|
||||
await writeFile(join(home, 'presets', 'probe', COMPOSITION_FILE), "- id: p\n name: '@scope/pkg'\n")
|
||||
|
||||
const [preset] = await scanRoot(
|
||||
{ path: join(home, 'presets'), trust: 'user' }, pathToFileURL(join(home, 'app/')).href)
|
||||
|
||||
expect(preset?.broken).toBe('row "p" names a plugin that cannot be resolved: @scope/pkg')
|
||||
})
|
||||
|
||||
it('leaves a node builtin alone', async () => {
|
||||
// Nothing installs `node:fs`, so the disk walk finds nothing; calling a
|
||||
// composition broken over a name Node always supplies would be a false
|
||||
// report, which costs more than the row it would have caught.
|
||||
expect(await scanned('- id: b\n name: node:fs\n')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a cordis builtin alone', async () => {
|
||||
// A `cordis:` name is supplied by the Loader itself, so there is nothing
|
||||
// to resolve — as a row of its own, and as the group it recurses into.
|
||||
expect(await scanned('- id: inc\n name: cordis:include\n config:\n path: ./nested.cordis.yml\n'))
|
||||
.toBeUndefined()
|
||||
expect(await scanned('- id: grp\n name: cordis:group\n group: true\n config: []\n')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// A row that resolves and then refuses to apply. Discovery's health check
|
||||
// resolves every row's module without importing it, so a fixture naming a file
|
||||
// that does not exist can no longer reach the mount; a module that loads and
|
||||
// throws is what still exercises the loader's own failure reporting.
|
||||
export const name = 'throws'
|
||||
|
||||
export function apply(_ctx, config) {
|
||||
throw new Error(config.message)
|
||||
}
|
||||
@@ -2,5 +2,7 @@
|
||||
name: ../../plugins/contribute.js
|
||||
config:
|
||||
tool: ok
|
||||
- id: missing
|
||||
name: ../../plugins/does-not-exist.js
|
||||
- id: refuses
|
||||
name: ../../plugins/throws.js
|
||||
config:
|
||||
message: this row refuses to apply
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Two failing rows inside a group. The Loader wraps the group's own
|
||||
# AggregateError into a plain Error carrying only its message, so without
|
||||
# following `cause` the diagnostic ends at "loader entries failed to apply" and
|
||||
# names neither row.
|
||||
- id: outer
|
||||
name: cordis:group
|
||||
group: true
|
||||
config:
|
||||
- id: inner-first
|
||||
name: ../../plugins/throws.js
|
||||
config:
|
||||
message: inner first refuses
|
||||
- id: inner-second
|
||||
name: ../../plugins/throws.js
|
||||
config:
|
||||
message: inner second refuses
|
||||
+9
-5
@@ -1,7 +1,11 @@
|
||||
# Two rows that cannot load: the Loader reports several failed entries as one
|
||||
# Two rows that cannot apply: the Loader reports several failed entries as one
|
||||
# AggregateError whose own message names none of them, so this fixture is what
|
||||
# proves the mount diagnostic flattens the causes.
|
||||
- id: first-missing
|
||||
name: ../../plugins/does-not-exist.js
|
||||
- id: second-missing
|
||||
name: ../../plugins/also-missing.js
|
||||
- id: first-refuses
|
||||
name: ../../plugins/throws.js
|
||||
config:
|
||||
message: first row refuses
|
||||
- id: second-refuses
|
||||
name: ../../plugins/throws.js
|
||||
config:
|
||||
message: second row refuses
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import Group from '@deepseek-ai/cordis-plugin-group'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -43,6 +44,10 @@ async function harness(roster: Config = { default: 'standard', roots: ROOTS, inc
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
// A preset outside this workspace cannot resolve `cordis-plugin-group` by
|
||||
// name, so the app registers it as a builtin; the fixtures compose the same
|
||||
// way real presets do, which needs it here too.
|
||||
ctx.loader.builtins.group = Group
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
@@ -244,7 +249,16 @@ describe('rejecting a composition that cannot be used', () => {
|
||||
// message names none of them; unflattened, the operator is told only that
|
||||
// "loader entries failed to apply" and has nothing to act on.
|
||||
await expect(agentOn(ctx, 'sess-two-broken', 'two-broken'))
|
||||
.rejects.toThrow(/first-missing[\s\S]*second-missing/)
|
||||
.rejects.toThrow(/first-refuses[\s\S]*second-refuses/)
|
||||
})
|
||||
|
||||
it('names the rows inside a failed group, not the group alone', async () => {
|
||||
// The Loader's per-row wrapper keeps only `cause.message`, so a group's
|
||||
// own AggregateError arrives with its `errors` reachable through `cause`
|
||||
// alone. Reading the message stops at "loader entries failed to apply"
|
||||
// and names neither row that actually refused.
|
||||
await expect(agentOn(ctx, 'sess-nested-broken', 'nested-broken'))
|
||||
.rejects.toThrow(/outer[\s\S]*inner-first[\s\S]*inner-second/)
|
||||
})
|
||||
|
||||
it('names the unresolved service when a row never activates', async () => {
|
||||
@@ -330,7 +344,7 @@ describe('the preset roster', () => {
|
||||
|
||||
// `not-a-preset` is the fixture ghost: no composition file, listed broken.
|
||||
expect(listed.map(preset => preset.id).sort())
|
||||
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'not-a-preset', 'pending', 'standard', 'two-broken'])
|
||||
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'nested-broken', 'not-a-preset', 'pending', 'standard', 'two-broken'])
|
||||
expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system')
|
||||
expect(listed.find(preset => preset.id === 'not-a-preset')?.broken).toMatch(/is missing/)
|
||||
})
|
||||
@@ -378,6 +392,7 @@ describe('composing from a broken preset', () => {
|
||||
describe('a roster with nothing in it', () => {
|
||||
it('says so instead of naming an empty list of candidates', async () => {
|
||||
const bare = new Context()
|
||||
bare.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await bare.plugin(Loader)
|
||||
await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false })
|
||||
|
||||
@@ -386,6 +401,21 @@ describe('a roster with nothing in it', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('a roster with no base to resolve from', () => {
|
||||
it('refuses at load rather than calling every preset broken', async () => {
|
||||
// Health answers "can this row be imported?", and the same package name
|
||||
// fails from a preset's own directory while resolving from the installed
|
||||
// harness. Without the base there is no answer, and the silent one is
|
||||
// exactly the failure the check exists to report.
|
||||
const baseless = new Context()
|
||||
await baseless.plugin(Loader)
|
||||
|
||||
await expect(baseless.plugin(AgentPresets, {
|
||||
default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false,
|
||||
})).rejects.toThrow(/needs `ctx\.baseUrl`/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the preset file is an input, never a persistence target', () => {
|
||||
it('survives a row that disposes itself, which makes the Loader persist a tree', async () => {
|
||||
// The preset lives in a temp root, not under `fixtures/`: without the
|
||||
@@ -411,6 +441,7 @@ describe('the preset file is an input, never a persistence target', () => {
|
||||
scoped.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await scoped.plugin(Loader)
|
||||
scoped.loader.builtins.include = Include
|
||||
scoped.loader.builtins.group = Group
|
||||
await scoped.plugin(LlmRuntime)
|
||||
await scoped.plugin(SessionStore)
|
||||
await scoped.plugin(SystemPrompt, { persona: '' })
|
||||
@@ -588,7 +619,7 @@ describe('replacing a composition', () => {
|
||||
// A preset root this test owns, so removing the composition mid-flight
|
||||
// cannot disturb the shipped fixtures.
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-'))
|
||||
const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', '- id: nope\n name: ./does-not-exist.js\n']]
|
||||
const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', `- id: nope\n name: ${join(FIXTURES, 'plugins', 'throws.js')}\n config:\n message: refuses\n`]]
|
||||
for (const [id, body] of seeded) {
|
||||
await mkdir(join(root, id))
|
||||
await writeFile(join(root, id, COMPOSITION_FILE), body)
|
||||
@@ -597,6 +628,7 @@ describe('replacing a composition', () => {
|
||||
scoped.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await scoped.plugin(Loader)
|
||||
scoped.loader.builtins.include = Include
|
||||
scoped.loader.builtins.group = Group
|
||||
await scoped.plugin(LlmRuntime)
|
||||
await scoped.plugin(SessionStore)
|
||||
await scoped.plugin(SystemPrompt, { persona: '' })
|
||||
|
||||
@@ -28,7 +28,10 @@ const ROOTS = [
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
|
||||
]
|
||||
const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n'
|
||||
// A row naming a package, the way an authored preset's rows do. Health
|
||||
// resolves every row it can prove will start, so a path reaching outside the
|
||||
// temp preset directory these tests seed would report the composition broken.
|
||||
const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
|
||||
@@ -58,7 +58,13 @@ describe('the shipped preset root', () => {
|
||||
const listed = await ctx.agentPresets.list()
|
||||
expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard'])
|
||||
expect(listed.every(preset => preset.trust === 'system')).toBe(true)
|
||||
expect(listed.every(preset => preset.broken === undefined)).toBe(true)
|
||||
// Not `broken === undefined`: health asks whether each row's package is
|
||||
// installed above the base, and the shipped rows name packages the
|
||||
// deployment installs beside the roster. This fixture base is not that
|
||||
// install, so unresolved rows are the only reason it can report here —
|
||||
// malformed would be a different one, and this asserts there is none.
|
||||
expect(listed.map(preset => preset.broken)
|
||||
.filter(reason => reason !== undefined && !reason.includes('cannot be resolved'))).toEqual([])
|
||||
})
|
||||
|
||||
it('prepends the shipped root before configured roots and the derived user root', async () => {
|
||||
|
||||
@@ -22,6 +22,11 @@ interface TypertPlugin {
|
||||
|
||||
const DECORATOR_SYNTAX = /^\s*@[A-Za-z_$][\w$]*/m
|
||||
|
||||
// This plugin consumes tsc-emitted `lib/types` output, so every project it
|
||||
// would re-diagnose has already passed the workspace tsc build in the same
|
||||
// orchestration; the generator skips its per-package diagnostic pass here.
|
||||
const TSC_VERIFIED_INPUT = { checkDiagnostics: false } as const
|
||||
|
||||
/** Generation scope selected by a tsdown build phase. */
|
||||
export interface TypertPluginOptions {
|
||||
/** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */
|
||||
@@ -78,7 +83,7 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu
|
||||
if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return
|
||||
let artifacts = artifactsByRoot.get(root)
|
||||
if (artifacts === undefined) {
|
||||
const generator = new WorkspaceTypertGenerator(root)
|
||||
const generator = new WorkspaceTypertGenerator(root, TSC_VERIFIED_INPUT)
|
||||
artifacts = pluginOptions.faces === undefined
|
||||
? generator.generate()
|
||||
: generator.generate(undefined, pluginOptions.faces)
|
||||
@@ -89,7 +94,7 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu
|
||||
}
|
||||
|
||||
function emitWorkspace(root: string, faces: readonly TypertFace[] | undefined): void {
|
||||
const generator = new WorkspaceTypertGenerator(root)
|
||||
const generator = new WorkspaceTypertGenerator(root, TSC_VERIFIED_INPUT)
|
||||
const packages = generator.discover(faces)
|
||||
.filter(candidate => hasTypertExport(readManifest(join(root, candidate.root)).exports))
|
||||
.map(candidate => candidate.package)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts'
|
||||
import { TypertAnalysisError, WorkspaceAnalyzer, WorkspaceCaches } from './analyzer.ts'
|
||||
import type { DiscoveredTypertPackage } from './analyzer.ts'
|
||||
import { FaceModelEmitter } from './emitter.ts'
|
||||
import type { ModelEmitResult } from './emitter.ts'
|
||||
@@ -16,13 +16,32 @@ export interface WorkspaceEmitResult extends ModelEmitResult {
|
||||
readonly packageRoot: string
|
||||
}
|
||||
|
||||
/** Behavior switches for one {@link WorkspaceTypertGenerator}. */
|
||||
export interface WorkspaceTypertGeneratorOptions {
|
||||
/**
|
||||
* Run the per-package syntactic/semantic diagnostic pass before analysis
|
||||
* (default true). Pass false only when the same orchestration already
|
||||
* verified the workspace with tsc; the Typert-specific analysis checks
|
||||
* (annotation coverage, private cross-package references, unretainable
|
||||
* merges) run regardless.
|
||||
*/
|
||||
readonly checkDiagnostics?: boolean
|
||||
}
|
||||
|
||||
/** Discover, analyze, and emit package reflection from independent faces. */
|
||||
export class WorkspaceTypertGenerator {
|
||||
/** Parsed-config and program-host state shared by every analyzer this generator creates. */
|
||||
private readonly caches = new WorkspaceCaches()
|
||||
|
||||
/**
|
||||
* Bind generation to one workspace root.
|
||||
* @param root - directory containing face aggregate tsconfigs.
|
||||
* @param options - behavior switches applied to every pass of this generator.
|
||||
*/
|
||||
constructor(private readonly root: string) {}
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly options: WorkspaceTypertGeneratorOptions = {},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find public package faces that contribute Cordis services/events or
|
||||
@@ -33,6 +52,7 @@ export class WorkspaceTypertGenerator {
|
||||
discover(faces?: readonly TypertFace[]): DiscoveredTypertPackage[] {
|
||||
return new WorkspaceAnalyzer({
|
||||
root: this.root,
|
||||
caches: this.caches,
|
||||
...(faces === undefined ? {} : { faces }),
|
||||
}).discoverPackages()
|
||||
}
|
||||
@@ -48,7 +68,9 @@ export class WorkspaceTypertGenerator {
|
||||
const workspace = new WorkspaceAnalyzer({
|
||||
root: this.root,
|
||||
packages: selected,
|
||||
caches: this.caches,
|
||||
...(faces === undefined ? {} : { faces }),
|
||||
...(this.options.checkDiagnostics === undefined ? {} : { checkDiagnostics: this.options.checkDiagnostics }),
|
||||
}).analyze()
|
||||
const artifacts: WorkspaceEmitResult[] = []
|
||||
for (const face of workspace.faces) {
|
||||
|
||||
@@ -23,11 +23,13 @@ function fakeParent(): Agent {
|
||||
vi.setConfig({ testTimeout: 30_000 })
|
||||
|
||||
/**
|
||||
* Wait up to 10 seconds for CPU-bound worker startup or cross-thread delivery on contended CI.
|
||||
* Host reactions after an observed event use explicit tight overrides, so this generous startup
|
||||
* Wait up to 60 seconds for CPU-bound worker startup or cross-thread delivery on contended CI:
|
||||
* startup is the only environment-sensitive phase of a same-process worker exchange, and the
|
||||
* loaded self-hosted Windows pool stretches the tsx-in-worker boot past 10 seconds. Host
|
||||
* reactions after an observed event use explicit tight overrides, so this generous startup
|
||||
* allowance cannot hide multi-second reap regressions.
|
||||
*/
|
||||
function waitFor(assertion: () => void, timeout = 10_000): Promise<void> {
|
||||
function waitFor(assertion: () => void, timeout = 60_000): Promise<void> {
|
||||
return vi.waitFor(assertion, { timeout, interval: 50 })
|
||||
}
|
||||
|
||||
@@ -181,7 +183,9 @@ async function run(ctx: Context, parent: Agent, source: { script: string; meta:
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-workflow-worker-thread', () => {
|
||||
// The per-test cap leaves room for one generous startup wait plus the tight
|
||||
// post-event assertions; explicit narrower timeouts inside stay authoritative.
|
||||
describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => {
|
||||
describe('script execution over a real worker thread', () => {
|
||||
it('runs a script end-to-end: agent() text results, phases, log, args, return value, events', async () => {
|
||||
const { ctx, parent, provider } = await setup({ reply: (_request, index) => text(`answer-${index}`) })
|
||||
|
||||
Generated
+3
@@ -6628,6 +6628,9 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/cordis-plugin-group':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/group
|
||||
'@deepseek-ai/cordis-plugin-include':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/include
|
||||
|
||||
@@ -98,9 +98,9 @@ describe('CI workflow', () => {
|
||||
))
|
||||
expect(buildCommands.map(step => step.run)).toContain('pnpm run check:ci:windows-blocking')
|
||||
|
||||
// windows-coverage uses the lower 4-partition profile.
|
||||
// windows-coverage runs the 6-partition profile.
|
||||
expect(windowsCoverage.name).toBe('windows node 24 / coverage')
|
||||
expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '4' })
|
||||
expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '6' })
|
||||
const coverageSteps = windowsCoverage.steps as unknown[]
|
||||
const coverageCommands = coverageSteps.filter((step): step is Record<string, unknown> & { run: string } => (
|
||||
isRecord(step) && typeof step.run === 'string'
|
||||
|
||||
@@ -34,20 +34,21 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [
|
||||
filter: 'packages/typert/generator/tests/',
|
||||
exclude: 'packages/typert/generator/tests/**',
|
||||
},
|
||||
// The webworker-runtime package is outside the coverage requirement by
|
||||
// decision: vitest.config.ts threshold-excludes its src, so every suite
|
||||
// runs uninstrumented. This tree includes the full-corpus import gate, a
|
||||
// single 900s-budget case that spawns a child sweep over every built
|
||||
// bundle; inside an instrumented partition it exceeds the Windows
|
||||
// partition budget under load.
|
||||
{
|
||||
filter: 'packages/experimental/webworker-runtime/tests/',
|
||||
exclude: 'packages/experimental/webworker-runtime/tests/**',
|
||||
},
|
||||
// Real child-process fixtures over scripts/ sources, which coverage never measures.
|
||||
{ filter: 'scripts/install-lefthook.spec.ts', exclude: 'scripts/install-lefthook.spec.ts' },
|
||||
{ filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' },
|
||||
{ filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' },
|
||||
{ filter: 'scripts/translation-pairing-merge.spec.ts', exclude: 'scripts/translation-pairing-merge.spec.ts' },
|
||||
// Spawns the full-corpus transform gate in a child process (Node's ESM
|
||||
// loader is its oracle), so no measured file executes in-process; the
|
||||
// package src is threshold-excluded in vitest.config.ts. A single
|
||||
// 900s-budget case; running it inside an instrumented partition exceeds
|
||||
// the Windows partition budget under load.
|
||||
{
|
||||
filter: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts',
|
||||
exclude: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts',
|
||||
},
|
||||
// Built-artifact proof. Packer/runtime src is threshold-excluded, and the
|
||||
// native Windows aggregate makes this uninstrumented gate wait for build so
|
||||
// the suite never observes a partially emitted workspace closure.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Tests for the documentation website projection adapter. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
@@ -9,6 +9,7 @@ import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { cleanDocSiteOutput, docSiteBuildOptions } from '../website/build.ts'
|
||||
import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts'
|
||||
import {
|
||||
addProjectionFrontmatter, emitRawMarkdownPages, llmsTxt, projectedPageContent, publishableImage,
|
||||
@@ -70,6 +71,78 @@ describe('website source layout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('documentation site build', () => {
|
||||
it.each([
|
||||
{ mode: 'SPA', mpa: false, expectedMpa: undefined },
|
||||
{ mode: 'MPA', mpa: true, expectedMpa: 'true' },
|
||||
])('$mode build removes stale output before writing', async ({ mpa, expectedMpa }) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-'))
|
||||
roots.push(root)
|
||||
const outDir = join(root, '.dist')
|
||||
const stale = join(outDir, 'stale.md')
|
||||
mkdirSync(outDir)
|
||||
writeFileSync(stale, 'stale\n')
|
||||
|
||||
const options = docSiteBuildOptions(root, mpa)
|
||||
expect(options.mpa).toBe(expectedMpa)
|
||||
expect(existsSync(stale)).toBe(true)
|
||||
await options.onAfterConfigResolve?.({ outDir } as never)
|
||||
expect(existsSync(outDir)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to remove the site root or an outside directory', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-root-'))
|
||||
const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-outside-'))
|
||||
roots.push(root, outside)
|
||||
writeFileSync(join(root, 'keep'), 'root\n')
|
||||
writeFileSync(join(outside, 'keep'), 'outside\n')
|
||||
|
||||
expect(() => {
|
||||
cleanDocSiteOutput(root, root)
|
||||
}).toThrow('must be a child of site root')
|
||||
expect(() => {
|
||||
cleanDocSiteOutput(root, outside)
|
||||
}).toThrow('must be a child of site root')
|
||||
expect(readFileSync(join(root, 'keep'), 'utf8')).toBe('root\n')
|
||||
expect(readFileSync(join(outside, 'keep'), 'utf8')).toBe('outside\n')
|
||||
})
|
||||
|
||||
it('unlinks a link-shaped output without removing its target', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-link-root-'))
|
||||
const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-link-target-'))
|
||||
roots.push(root, outside)
|
||||
const outDir = join(root, '.dist')
|
||||
const keep = join(outside, 'keep')
|
||||
writeFileSync(keep, 'outside\n')
|
||||
symlinkSync(outside, outDir, 'junction')
|
||||
|
||||
cleanDocSiteOutput(root, outDir)
|
||||
|
||||
expect(existsSync(outDir)).toBe(false)
|
||||
expect(readFileSync(keep, 'utf8')).toBe('outside\n')
|
||||
})
|
||||
|
||||
it('refuses output whose nearest existing parent resolves outside the site root', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-parent-link-root-'))
|
||||
const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-parent-link-target-'))
|
||||
roots.push(root, outside)
|
||||
const linkedParent = join(root, 'linked')
|
||||
const outDir = join(linkedParent, 'missing', '.dist')
|
||||
const keep = join(outside, 'keep')
|
||||
writeFileSync(keep, 'outside\n')
|
||||
symlinkSync(outside, linkedParent, 'junction')
|
||||
|
||||
try {
|
||||
expect(() => {
|
||||
cleanDocSiteOutput(root, outDir)
|
||||
}).toThrow('must resolve inside site root')
|
||||
expect(readFileSync(keep, 'utf8')).toBe('outside\n')
|
||||
} finally {
|
||||
unlinkSync(linkedParent)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('publishableImage', () => {
|
||||
it('accepts a regular file inside the repository', () => {
|
||||
const { root } = fixture()
|
||||
|
||||
+35
-9
@@ -11,7 +11,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
||||
import { isEntry, run } from './process.ts'
|
||||
import { isEntry, runConcurrent } from './process.ts'
|
||||
import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts'
|
||||
|
||||
/** Where pack output lands when `--out` is omitted. */
|
||||
@@ -24,8 +24,8 @@ const DEFAULT_OUTPUT = 'dist/npm'
|
||||
* @param destination - absolute output directory.
|
||||
* @returns The tarball filename.
|
||||
*/
|
||||
function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): string {
|
||||
run('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination])
|
||||
async function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): Promise<string> {
|
||||
await runConcurrent('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination])
|
||||
|
||||
const filename = tarballName(member)
|
||||
const tarball = join(destination, filename)
|
||||
@@ -34,13 +34,27 @@ function packMember(family: ReleaseFamily, member: ReleaseMember, destination: s
|
||||
return filename
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The validated `--concurrency` value; 1 (the default) packs the
|
||||
* members one at a time, exactly as the credentialed publish workflows run it.
|
||||
*/
|
||||
function parseConcurrency(raw: string | undefined): number {
|
||||
if (raw === undefined) return 1
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
|
||||
throw new Error(`--concurrency must be a positive integer, got ${JSON.stringify(raw)}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** Pack the family named by `--family` into `--out`. */
|
||||
function main(): void {
|
||||
async function main(): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' }, out: { type: 'string' } },
|
||||
options: { family: { type: 'string' }, out: { type: 'string' }, concurrency: { type: 'string' } },
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.family === undefined) throw new Error('usage: pack.ts --family <dsh|vendor> [--out dist/npm]')
|
||||
if (values.family === undefined) throw new Error('usage: pack.ts --family <dsh|vendor> [--out dist/npm] [--concurrency 1]')
|
||||
const concurrency = parseConcurrency(values.concurrency)
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const root = process.cwd()
|
||||
@@ -52,11 +66,23 @@ function main(): void {
|
||||
rmSync(destination, { recursive: true, force: true })
|
||||
mkdirSync(destination, { recursive: true })
|
||||
|
||||
const order: string[] = []
|
||||
for (const member of members) order.push(packMember(family, member, destination))
|
||||
// Members pack in a bounded pool; the recorded publish order stays the
|
||||
// members' order regardless of completion order, because each worker writes
|
||||
// its result at the member's own position.
|
||||
const order = new Array<string>(members.length)
|
||||
let cursor = 0
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, members.length) }, async () => {
|
||||
while (cursor < members.length) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
const member = members[index]
|
||||
if (member === undefined) break
|
||||
order[index] = await packMember(family, member, destination)
|
||||
}
|
||||
}))
|
||||
writeFileSync(join(destination, PUBLISH_ORDER_FILE), `${order.join('\n')}\n`)
|
||||
|
||||
console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
if (isEntry(import.meta.url)) await main()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* `pnpm`, `npm`, and `tar`, and each needs one of three failure behaviours.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
@@ -71,16 +71,23 @@ export function capture(command: string, args: readonly string[], options: RunOp
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command with inherited streams, so its progress reaches the log, and
|
||||
* fail on a non-zero exit.
|
||||
* Run a command with inherited streams without blocking the event loop, so a
|
||||
* caller can hold several commands in flight, and fail on a non-zero exit.
|
||||
* Concurrent children interleave their output at line granularity.
|
||||
* @param command - executable name.
|
||||
* @param args - command arguments.
|
||||
* @param options - working directory and environment.
|
||||
* @returns Resolves when the command exits with status zero.
|
||||
*/
|
||||
export function run(command: string, args: readonly string[], options: RunOptions = {}): void {
|
||||
const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, stdio: 'inherit' })
|
||||
if (result.error !== undefined) throw result.error
|
||||
if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`)
|
||||
export function runConcurrent(command: string, args: readonly string[], options: RunOptions = {}): Promise<void> {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const child = spawn(command, [...args], { cwd: options.cwd, env: options.env, stdio: 'inherit' })
|
||||
child.once('error', rejectRun)
|
||||
child.once('close', (status, signal) => {
|
||||
if (status === 0) resolveRun()
|
||||
else rejectRun(new Error(`${command} ${args.join(' ')} exited with ${String(status ?? signal)}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -306,6 +306,10 @@ export default defineConfig({
|
||||
// would put whole-workspace compiler analysis under v8
|
||||
// instrumentation — the coverage lane's longest tail.
|
||||
'packages/typert/generator/src/*.ts',
|
||||
// Experimental webworker-runtime is outside the coverage requirement
|
||||
// by decision: its correctness signal is its uninstrumented suite and
|
||||
// the packer's end-to-end image spec.
|
||||
'packages/experimental/webworker-runtime/src/**/*.ts',
|
||||
'packages/host/apiproxy/src/index.ts',
|
||||
'packages/host/apiproxy/src/invariant.ts',
|
||||
'packages/host/apiproxy/src/api-proxy.ts',
|
||||
|
||||
@@ -10,6 +10,8 @@ Keep canonical prose and generated catalogs in their owning `docs/` tier, then e
|
||||
|
||||
The projector writes disposable Markdown to the ignored `website/.generated/` directory. Never edit or commit `.generated/`, `.cache/`, or `.dist/`.
|
||||
|
||||
Production builds remove the configured output directory after VitePress resolves the site configuration and before it writes files. They reject output whose lexical path or nearest existing parent escapes the real site root, and unlink a link-shaped output instead of traversing its target. Raw-Markdown emission then treats files produced by that build as occupied and never overwrites them.
|
||||
|
||||
The build also emits each route's raw-Markdown twin (with a parent-level alias per index route) and a root `llms.txt` index into `.dist/`, so a page's URL, minus any trailing slash, plus `.md` serves it as plain Markdown. Both derive from the publication manifest at build time; neither is ever a file in this tree.
|
||||
|
||||
Run `pnpm docs:check` after changing this subtree; the gate rejects additional non-ignored Markdown under `website/`.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/** Production documentation-site build with project-owned output preparation. */
|
||||
|
||||
import { lstatSync, realpathSync, rmSync, unlinkSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'vitepress'
|
||||
|
||||
const websiteRoot = resolve(import.meta.dirname)
|
||||
type DocSiteBuildOptions = NonNullable<Parameters<typeof build>[1]>
|
||||
|
||||
function escapesRoot(root: string, candidate: string): boolean {
|
||||
const child = relative(root, candidate)
|
||||
return child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)
|
||||
}
|
||||
|
||||
function nearestExistingAncestor(path: string): string {
|
||||
let ancestor = path
|
||||
for (;;) {
|
||||
if (lstatSync(ancestor, { throwIfNoEntry: false }) !== undefined) return ancestor
|
||||
const parent = dirname(ancestor)
|
||||
if (parent === ancestor) {
|
||||
throw new Error(`website/build: no existing ancestor found for ${JSON.stringify(path)}.`)
|
||||
}
|
||||
ancestor = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one documentation build output without traversing a link-shaped output or an outside parent.
|
||||
* @param siteRoot - VitePress site root that owns the output.
|
||||
* @param outDir - Resolved VitePress output directory.
|
||||
* @throws When `outDir` is not a proper child of `siteRoot` or its existing parent resolves outside it.
|
||||
*/
|
||||
export function cleanDocSiteOutput(siteRoot: string, outDir: string): void {
|
||||
const root = resolve(siteRoot)
|
||||
const output = resolve(outDir)
|
||||
const child = relative(root, output)
|
||||
if (child === '' || escapesRoot(root, output)) {
|
||||
throw new Error(`website/build: output directory ${JSON.stringify(output)} must be a child of site root ${JSON.stringify(root)}.`)
|
||||
}
|
||||
|
||||
const realRoot = realpathSync(root)
|
||||
const realParent = realpathSync(nearestExistingAncestor(dirname(output)))
|
||||
if (escapesRoot(realRoot, realParent)) {
|
||||
throw new Error(`website/build: output directory ${JSON.stringify(output)} must resolve inside site root ${JSON.stringify(realRoot)}.`)
|
||||
}
|
||||
|
||||
const outputStats = lstatSync(output, { throwIfNoEntry: false })
|
||||
if (outputStats?.isSymbolicLink()) {
|
||||
unlinkSync(output)
|
||||
return
|
||||
}
|
||||
rmSync(output, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create VitePress build options that remove the resolved output directory before bundling.
|
||||
* @param siteRoot - VitePress site root to build.
|
||||
* @param mpa - Whether to use VitePress's multi-page application build.
|
||||
* @returns VitePress options with project-owned output preparation.
|
||||
*/
|
||||
export function docSiteBuildOptions(siteRoot: string, mpa: boolean): DocSiteBuildOptions {
|
||||
const root = resolve(siteRoot)
|
||||
return {
|
||||
...mpa ? { mpa: 'true' } : {},
|
||||
onAfterConfigResolve(siteConfig) {
|
||||
cleanDocSiteOutput(root, siteConfig.outDir)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function buildDocSite(siteRoot: string, mpa: boolean): Promise<void> {
|
||||
const root = resolve(siteRoot)
|
||||
await build(root, docSiteBuildOptions(root, mpa))
|
||||
}
|
||||
|
||||
function parseMpa(args: string[]): boolean {
|
||||
if (args.length === 0) return false
|
||||
if (args.length === 1 && args[0] === '--mpa') return true
|
||||
throw new Error(`website/build: expected no arguments or --mpa, got ${JSON.stringify(args)}.`)
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
if (invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href) {
|
||||
await buildDocSite(websiteRoot, parseMpa(process.argv.slice(2)))
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vitepress dev . --host 127.0.0.1 --port 5173",
|
||||
"build": "vitepress build .",
|
||||
"build": "tsx build.ts",
|
||||
"preview": "vitepress preview . --host 127.0.0.1 --port 4173"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user