fix(agent-presets,web): broken presets are roster rows, not gaps
A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
This commit is contained in:
@@ -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: 26f54f3efe4eadc933b0b3aed7b0ed8f8816b7d4
|
||||
README.zh.md: 6688e84994beac9f937a8f01501d726267af08cf
|
||||
README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6
|
||||
README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170
|
||||
|
||||
@@ -8,20 +8,20 @@ The mechanism is two seams. Entry contexts chain to the context a subtree was pl
|
||||
|
||||
## Service: `AgentPresets` (ctx key: `agentPresets`)
|
||||
|
||||
Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call, so a preset authored while the process runs is visible immediately and a deleted one disappears from the next read.
|
||||
Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call, so a preset authored while the process runs is visible immediately and a deleted one disappears from the next read. Discovery also owns preset **health**: a directory whose composition is missing or unloadable (unparsable YAML — checked with the loader's own dialect, `!!js` included — or not a list of named plugin rows) is listed with a `broken` reason rather than skipped, because a skipped directory would still occupy its id on disk while every surface shows nothing to delete. A directory whose name is not a usable preset id (`[a-z0-9][a-z0-9-]*`) is skipped outright: no copy could ever claim it.
|
||||
|
||||
- `ctx.agentPresets.defaultId: string` The preset id mounted when a caller names none.
|
||||
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id.
|
||||
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it.
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record.
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was.
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn.
|
||||
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason.
|
||||
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row.
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved.
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`.
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`.
|
||||
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all.
|
||||
- `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored.
|
||||
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows.
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick.
|
||||
|
||||
`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file).
|
||||
`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), `path` (the absolute composition file), and — only when the preset cannot compose a session — `broken` (one human-readable reason, shown verbatim on roster surfaces).
|
||||
|
||||
### Where to call `mount()`
|
||||
|
||||
@@ -44,7 +44,7 @@ The restriction to a produced-nothing agent is a product rule, not a mechanical
|
||||
Authoring is copy-only. A new preset is a whole-directory copy of an existing one — composition, metadata, skill directories, assets — landed under the first `user` root; the inputs are two ids the service resolves against its own roots plus an optional display name, so no caller ever supplies composition text and a copy grants nothing the roster did not already carry. Everything after creation happens in the preset's own files. `copy()` refuses three things before anything lands:
|
||||
|
||||
- **An id that is not `[a-z0-9][a-z0-9-]*`.** The id becomes a directory name, so containment is a property of the id itself rather than of a path check after the fact — `../escape`, `a/b`, and an absolute path are all rejected as ids.
|
||||
- **An id that is already taken.** A copy never overwrites: any root supplying the id refuses it (a user directory named like a shipped preset would be shadowed by it), and a directory occupying the name on disk without being a preset refuses it too.
|
||||
- **An id that is already taken.** A copy never overwrites: any root supplying the id refuses it (a user directory named like a shipped preset would be shadowed by it), and a directory occupying the name on disk refuses it too. Discovery lists such a directory as a broken preset, so the refusal's way out — delete it — is on the same page that reported it.
|
||||
- **An unknown source.** The source may be any trust — copying a shipped preset is the primary case — but it must exist; a failed copy rolls its half-made directory back rather than leaving one discovery cannot see.
|
||||
|
||||
The copied tree is re-tightened to owner-only (`0o600` files keeping their owner-execute bit, `0o700` directories), symlinks are dereferenced so the copy is self-contained, and the root is created on first copy — a deployment configuring a user root that does not exist yet is the normal first-run state. The copied `preset.yml` is rewritten: the source's description is kept for the author to edit in place, but its name and roster `order` are dropped — a copy presenting itself identically to its source, or sorted into the shipped set's declared order, would make the roster stop distinguishing them. `remove()` refuses a preset that ships with the deployment; the shipped set is the known-good compositions copies start from.
|
||||
@@ -122,6 +122,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before
|
||||
|
||||
- **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards.
|
||||
- **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. Sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions).
|
||||
- **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy that fails at the next session that selects it, exactly as the source would.
|
||||
- **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.
|
||||
- **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" (that is the bundle layer's `cordis.patch.yml`); the shipped set itself accepts the same cost — `cordis` and `code` are full copies of `standard` — 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()`.
|
||||
|
||||
@@ -8,20 +8,20 @@
|
||||
|
||||
## 服务:`AgentPresets`(ctx 键:`agentPresets`)
|
||||
|
||||
发现过程不做缓存:`list()` 与 `resolve()` 每次调用都重新读取各个根目录,因此进程运行期间新写的 preset 立即可见,被删除的 preset 也会在下一次读取时消失。
|
||||
发现过程不做缓存:`list()` 与 `resolve()` 每次调用都重新读取各个根目录,因此进程运行期间新写的 preset 立即可见,被删除的 preset 也会在下一次读取时消失。发现过程同时负责 preset 的**健康**:组装文件缺失或不可加载(YAML 无法解析——用加载器自己的方言检查,含 `!!js`——或不是由具名插件行组成的列表)的目录会作为携带 `broken` 原因的行列出而不是被跳过,因为被跳过的目录仍在磁盘上占着它的 id,而各个界面却没有任何可删的东西。目录名不是可用 preset id(`[a-z0-9][a-z0-9-]*`)的目录才被直接跳过:复制永远不可能占用那种名字。
|
||||
|
||||
- `ctx.agentPresets.defaultId: string` 调用方未指定时挂载的 preset id。
|
||||
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出。
|
||||
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。
|
||||
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。
|
||||
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。
|
||||
- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。
|
||||
- `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容逐字一致。
|
||||
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。
|
||||
|
||||
`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。
|
||||
`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)、`path`(组装文件的绝对路径),以及——仅当该 preset 无法组装会话时——`broken`(一条人类可读的原因,名单界面原样展示)。
|
||||
|
||||
### 应在何处调用 `mount()`
|
||||
|
||||
@@ -44,7 +44,7 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
|
||||
创作即复制。新 preset 是某个既有 preset 的整目录副本——组装、元数据、skill 目录、附带资产——落在首个 `user` 根目录之下;输入只有两个由服务对照自身根目录解析的 id 加一个可选显示名,因此调用方从不提供组装文本,一次复制不会授予 roster 尚未携带的任何能力。创建之后的一切都发生在 preset 自己的文件里。`copy()` 在任何内容落盘之前拒绝三种情况:
|
||||
|
||||
- **不符合 `[a-z0-9][a-z0-9-]*` 的 id。** id 会成为目录名,因此约束是 id 自身的性质,而非事后再做一次路径检查——`../escape`、`a/b` 与绝对路径都作为 id 被拒绝。
|
||||
- **已被占用的 id。** 复制从不覆写:任一根目录已提供该 id 即拒绝(与随附 preset 同名的用户目录只会被它遮蔽),磁盘上占着该名字却不是 preset 的目录同样拒绝。
|
||||
- **已被占用的 id。** 复制从不覆写:任一根目录已提供该 id 即拒绝(与随附 preset 同名的用户目录只会被它遮蔽),磁盘上占着该名字的目录同样拒绝。发现过程会把这样的目录列为损坏的 preset,所以这条拒绝的出路——删掉它——就在报告它的同一页面上。
|
||||
- **未知的来源。** 来源可以是任何信任级别——复制随附 preset 正是主要用途——但必须存在;复制失败会回滚做到一半的目录,而不是留下一个 discovery 看不见的目录。
|
||||
|
||||
复制出的目录树被收紧为仅属主可用(文件 `0o600` 并保留属主执行位,目录 `0o700`),符号链接被解引用以保证副本自包含,且根目录在首次复制时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。复制出的 `preset.yml` 会被重写:保留来源的描述供作者就地编辑,但丢弃其名称与 roster `order`——副本若与来源呈现得一模一样、或按随附集合声明的顺序排序,roster 就不再能区分它们。`remove()` 拒绝随部署提供的 preset;随附集合正是副本的已知良好起点。
|
||||
@@ -122,6 +122,7 @@ Indirectly, through the plugins a standing composition registers, which own ever
|
||||
|
||||
- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
|
||||
- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。
|
||||
- **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出同样在下一个选择它的会话处失败的副本,与来源的失败方式完全一致。
|
||||
- **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。
|
||||
- **健康是形状检查,不是挂载** —— 发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。
|
||||
- **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis` 与 `code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。
|
||||
- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。
|
||||
|
||||
@@ -17,16 +17,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { METADATA_FILE, renderPresetMetadata } from './metadata.ts'
|
||||
import type { AgentPreset, PresetRoot } from './types.ts'
|
||||
|
||||
/**
|
||||
* Ids a preset directory may use.
|
||||
*
|
||||
* The id becomes a path segment, so this is a containment boundary rather than
|
||||
* a style rule: `..`, a separator, or an absolute-looking name would place the
|
||||
* composition outside the root the deployment authorised.
|
||||
*/
|
||||
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
|
||||
import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts'
|
||||
|
||||
/** A preset id that cannot be used as a directory name under a root. */
|
||||
export class InvalidPresetIdError extends Error {
|
||||
|
||||
@@ -4,18 +4,92 @@
|
||||
* its display text; the directory name is the preset id. Discovery
|
||||
* re-reads the roots on every call so a preset authored while the process is
|
||||
* running is visible without a restart.
|
||||
*
|
||||
* Discovery also owns preset HEALTH: a directory whose composition is
|
||||
* missing or unloadable is reported as a broken roster row rather than
|
||||
* skipped. A skipped directory would still occupy its id on disk — the copy
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh-agent-presets/discovery
|
||||
*/
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
import { entryListSchema } from '@cordisjs/plugin-include'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { readPresetMetadata } from './metadata.ts'
|
||||
import type { AgentPreset, PresetRoot } from './types.ts'
|
||||
import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts'
|
||||
|
||||
/** The composition file that makes a directory a preset. */
|
||||
export const COMPOSITION_FILE = 'agent.cordis.yml'
|
||||
|
||||
/**
|
||||
* Why `rows` cannot be an entry list, or undefined when it can.
|
||||
*
|
||||
* A shallow shape check, deliberately short of the loader's work: it does not
|
||||
* resolve plugin names or apply configs. What it catches is the hand-edit
|
||||
* that produces a file the loader cannot even begin with — and it must accept
|
||||
* everything the loader accepts, which is why rows are only required to be
|
||||
* maps carrying a plugin `name` (groups recurse into their own lists).
|
||||
* @param rows - the parsed composition document.
|
||||
* @param at - row-path prefix for nested diagnostics, empty at the top level.
|
||||
* @returns one human-readable reason, or undefined when the shape holds.
|
||||
*/
|
||||
function entryListProblem(rows: unknown, at = ''): string | undefined {
|
||||
if (!Array.isArray(rows)) {
|
||||
return at === ''
|
||||
? 'the composition must be a top-level list of plugin rows'
|
||||
: `group ${at} must hold a list of plugin rows`
|
||||
}
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const label = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}`
|
||||
if (typeof row !== 'object' || row === null || Array.isArray(row)) {
|
||||
return `${label} is not a plugin row (expected a map with a "name")`
|
||||
}
|
||||
const { name, group, config } = row as { name?: unknown; group?: unknown; config?: unknown }
|
||||
if (typeof name !== 'string' || name === '') {
|
||||
return `${label} names no plugin (a "name" string is required)`
|
||||
}
|
||||
if (group === true) {
|
||||
const nested = entryListProblem(config, label)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns one human-readable reason, or undefined when the file is loadable.
|
||||
*/
|
||||
async function compositionProblem(path: string): Promise<string | undefined> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(path, 'utf8')
|
||||
} catch {
|
||||
// The caller statted this file moments ago; any read failure now —
|
||||
// deleted in between, permissions — is the same answer as unparsable.
|
||||
return `the composition file ${COMPOSITION_FILE} cannot be read`
|
||||
}
|
||||
let rows: unknown
|
||||
try {
|
||||
rows = load(content, { schema: entryListSchema })
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- js-yaml throws YAMLException (an Error) for every parse failure; the fallback keeps a hostile value readable */
|
||||
const full = error instanceof Error ? error.message : String(error)
|
||||
// First line only: js-yaml appends a multi-line code-frame snippet, and
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `path` names an existing regular file.
|
||||
* @param path - absolute path to test.
|
||||
@@ -38,6 +112,12 @@ async function isFile(path: string): Promise<boolean> {
|
||||
* An absent root yields no presets rather than throwing: the user root does
|
||||
* not exist until the first locally authored preset, and naming a default
|
||||
* that no root supplies already fails loud at resolution.
|
||||
*
|
||||
* Every directory whose name is a usable preset id is a roster row — broken
|
||||
* when its composition is missing or unloadable. A directory named outside
|
||||
* {@link PRESET_ID} is skipped instead: no copy could ever claim that name,
|
||||
* 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.
|
||||
* @returns the root's presets ordered by id.
|
||||
*/
|
||||
@@ -52,14 +132,19 @@ export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
|
||||
}
|
||||
const found: AgentPreset[] = []
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue
|
||||
const directory = join(dir, child.name)
|
||||
const path = join(directory, COMPOSITION_FILE)
|
||||
if (!await isFile(path)) continue
|
||||
const broken = await isFile(path)
|
||||
? await compositionProblem(path)
|
||||
: `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.
|
||||
const metadata = await readPresetMetadata(directory)
|
||||
found.push({ id: child.name, trust: root.trust, path, ...metadata })
|
||||
found.push({
|
||||
id: child.name, trust: root.trust, path, ...metadata,
|
||||
...broken === undefined ? {} : { broken },
|
||||
})
|
||||
}
|
||||
// Declared order first so the shipped set reads by capability; everything
|
||||
// else falls back to the id, which keeps authored presets stable.
|
||||
|
||||
@@ -153,6 +153,10 @@ export class AgentPresets extends Service {
|
||||
|
||||
/**
|
||||
* Resolve one preset by id.
|
||||
*
|
||||
* A broken preset resolves — deleting one, reading one, and reporting one
|
||||
* all need the row — and the mounting paths refuse it AFTER resolution
|
||||
* through {@link resolveMountable}.
|
||||
* @param id - the preset id, or `undefined` for {@link defaultId}.
|
||||
* @returns the resolved preset.
|
||||
* @throws when no configured root supplies that id.
|
||||
@@ -167,6 +171,24 @@ export class AgentPresets extends Service {
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one preset that is about to compose an agent, refusing a broken
|
||||
* one with its discovery-reported reason. Failing here rather than inside
|
||||
* the loader keeps the answer the same for every unloadable shape — ghost
|
||||
* directory, unparsable YAML, rowless list — and spends no mount attempt
|
||||
* on a composition discovery already read as unusable.
|
||||
* @param id - the preset id, or `undefined` for {@link defaultId}.
|
||||
* @returns the resolved, mountable preset.
|
||||
* @throws when the preset is unknown or discovery reports it broken.
|
||||
*/
|
||||
private async resolveMountable(id?: string): Promise<AgentPreset> {
|
||||
const preset = await this.resolve(id)
|
||||
if (preset.broken !== undefined) {
|
||||
throw new PresetMountError(preset.id, preset.broken)
|
||||
}
|
||||
return preset
|
||||
}
|
||||
|
||||
/**
|
||||
* Standing mounts by preset id, single-flight so two agents racing the
|
||||
* first use of one preset share one composition. A settled failure is
|
||||
@@ -198,7 +220,7 @@ export class AgentPresets extends Service {
|
||||
if (agentKey === undefined) {
|
||||
throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset')
|
||||
}
|
||||
const preset = await this.resolve(id)
|
||||
const preset = await this.resolveMountable(id)
|
||||
const standing = await this.ensureStanding(preset)
|
||||
setScopeParent(agentKey, standing.key)
|
||||
return preset
|
||||
@@ -314,7 +336,7 @@ export class AgentPresets extends Service {
|
||||
if (agentKey === undefined) {
|
||||
throw new Error('agent-presets: refusing to recompose an unscoped context')
|
||||
}
|
||||
const preset = await this.resolve(id)
|
||||
const preset = await this.resolveMountable(id)
|
||||
const standing = await this.ensureStanding(preset)
|
||||
setScopeParent(agentKey, standing.key)
|
||||
return preset
|
||||
@@ -332,7 +354,7 @@ export class AgentPresets extends Service {
|
||||
* @throws when the preset is unknown or its composition is unusable.
|
||||
*/
|
||||
async standingKeyFor(id?: string): Promise<ScopeKey> {
|
||||
const preset = await this.resolve(id)
|
||||
const preset = await this.resolveMountable(id)
|
||||
return (await this.ensureStanding(preset)).key
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
*/
|
||||
export type PresetTrust = 'system' | 'user'
|
||||
|
||||
/**
|
||||
* Ids a preset directory may use.
|
||||
*
|
||||
* The id becomes a path segment, so this is a containment boundary rather than
|
||||
* a style rule: `..`, a separator, or an absolute-looking name would place the
|
||||
* composition outside the root the deployment authorised. Discovery shares it:
|
||||
* a directory whose name no copy could ever claim is not a preset slot.
|
||||
*/
|
||||
export const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
|
||||
|
||||
/** One preset directory that carries a mountable agent composition. */
|
||||
export interface AgentPreset {
|
||||
/** Stable identifier; the preset directory's name. */
|
||||
@@ -21,6 +31,13 @@ export interface AgentPreset {
|
||||
readonly description?: string
|
||||
/** Declared position within its group; absent sorts after those that declare one. */
|
||||
readonly order?: number
|
||||
/**
|
||||
* Why this preset cannot compose a session, absent when it can. A broken
|
||||
* preset stays on the roster — hiding it would leave its directory blocking
|
||||
* the id with nothing to see or delete — but every mounting path refuses it
|
||||
* up front with this reason instead of failing deep inside the loader.
|
||||
*/
|
||||
readonly broken?: string
|
||||
}
|
||||
|
||||
/** One directory scanned for preset subdirectories. */
|
||||
|
||||
@@ -258,11 +258,36 @@ describe('display metadata beside a composition', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('a stray file beside the preset directories', () => {
|
||||
it('does not become a preset', async () => {
|
||||
await mkdir(join(userRoot, 'not-a-preset'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'not-a-preset', 'README.txt'), 'nope\n')
|
||||
describe('the on-disk occupancy backstop', () => {
|
||||
it('refuses a directory the roster cannot see', async () => {
|
||||
// The service's roster check sees every id-shaped directory now, so this
|
||||
// is the race backstop: a directory appearing between the roster read and
|
||||
// the copy still gets the readable refusal, not a filesystem error code.
|
||||
await mkdir(join(userRoot, 'raced'), { recursive: true })
|
||||
const source = await ctx.agentPresets.resolve('standard')
|
||||
|
||||
expect((await ctx.agentPresets.list()).some(preset => preset.id === 'not-a-preset')).toBe(false)
|
||||
await expect(copyComposition(
|
||||
[{ path: userRoot, trust: 'user' as const }], source, 'raced',
|
||||
)).rejects.toThrow(/already exists/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a ghost directory under the user root', () => {
|
||||
it('lists broken, blocks its id, and clears through remove', async () => {
|
||||
// The classic hand-edit: the composition file was deleted, the directory
|
||||
// stayed. It must not vanish from the roster — its id is still taken, so
|
||||
// there has to be something to see and delete.
|
||||
await mkdir(join(userRoot, 'ghost'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'ghost', 'README.txt'), 'composition deleted by hand\n')
|
||||
|
||||
const ghost = (await ctx.agentPresets.list()).find(preset => preset.id === 'ghost')
|
||||
expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/)
|
||||
await expect(ctx.agentPresets.copy('standard', 'ghost')).rejects.toThrow(/already exists/)
|
||||
|
||||
// remove is the way out the roster row offers; the id is claimable again.
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -57,10 +57,28 @@ describe('preset discovery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a directory that holds no composition file', async () => {
|
||||
it('reports a directory with no composition as a broken preset slot', async () => {
|
||||
const found = await scanRoot(USER)
|
||||
|
||||
expect(found.map(preset => preset.id)).not.toContain('not-a-preset')
|
||||
// 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.
|
||||
const ghost = found.find(preset => preset.id === 'not-a-preset')
|
||||
expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/)
|
||||
})
|
||||
|
||||
it('skips a directory whose name no preset id could ever claim', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-'))
|
||||
await mkdir(join(root, '.hidden'))
|
||||
await mkdir(join(root, 'Has_Caps'))
|
||||
await mkdir(join(root, 'usable'))
|
||||
await writeFile(join(root, 'usable', COMPOSITION_FILE), '[]\n')
|
||||
|
||||
const found = await scanRoot({ path: root, trust: 'user' })
|
||||
|
||||
// `.hidden` and `Has_Caps` cannot collide with any copy target, so
|
||||
// reporting tool residue as broken presets would only train users to
|
||||
// ignore the marker.
|
||||
expect(found.map(preset => preset.id)).toEqual(['usable'])
|
||||
})
|
||||
|
||||
it('records the root trust on every preset it discovers', async () => {
|
||||
@@ -111,3 +129,69 @@ describe('preset discovery', () => {
|
||||
expect(found).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('composition health', () => {
|
||||
/** 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-health-'))
|
||||
await mkdir(join(root, 'probe'))
|
||||
await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' })
|
||||
return preset?.broken
|
||||
}
|
||||
|
||||
it('reports unparsable YAML with the parser\'s reason', async () => {
|
||||
expect(await scanned('- id: x\n name: [unclosed\n')).toMatch(/not valid YAML/)
|
||||
})
|
||||
|
||||
it('reports a composition that is not a list of rows', async () => {
|
||||
expect(await scanned('name: not-a-list\n')).toMatch(/top-level list of plugin rows/)
|
||||
})
|
||||
|
||||
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'))
|
||||
.toMatch(/row 2 names no plugin/)
|
||||
})
|
||||
|
||||
it('reports a row that is not a map at all', async () => {
|
||||
expect(await scanned('- just-a-string\n')).toMatch(/row 1 is not a plugin row/)
|
||||
})
|
||||
|
||||
it('descends into a group\'s own row list', async () => {
|
||||
const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n'
|
||||
expect(await scanned(composition)).toMatch(/row 1 row 1 names no plugin/)
|
||||
})
|
||||
|
||||
it('reports a group whose config is not a list', async () => {
|
||||
const composition = '- id: grp\n name: cordis:group\n group: true\n config: not-a-list\n'
|
||||
expect(await scanned(composition)).toMatch(/group row 1 must hold a list/)
|
||||
})
|
||||
|
||||
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'
|
||||
expect(await scanned(composition)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a composition that stats but cannot be read', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-'))
|
||||
await mkdir(join(root, 'sealed'))
|
||||
const path = join(root, 'sealed', COMPOSITION_FILE)
|
||||
await writeFile(path, '[]\n')
|
||||
await chmod(path, 0o000)
|
||||
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' })
|
||||
|
||||
expect(preset?.broken).toMatch(/cannot be read/)
|
||||
})
|
||||
|
||||
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'
|
||||
expect(await scanned(composition)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts an empty list', async () => {
|
||||
expect(await scanned('[]\n')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,9 +238,11 @@ describe('the preset roster', () => {
|
||||
it('lists every root\'s presets with the earlier root winning', async () => {
|
||||
const listed = await ctx.agentPresets.list()
|
||||
|
||||
// `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', 'pending', 'standard', 'two-broken'])
|
||||
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', '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/)
|
||||
})
|
||||
|
||||
it('exposes the configured default id', () => {
|
||||
@@ -248,6 +250,41 @@ describe('the preset roster', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('composing from a broken preset', () => {
|
||||
/** A roster whose only user preset carries `composition`. */
|
||||
async function rosterWith(composition: string): Promise<Context> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-'))
|
||||
await mkdir(join(root, 'damaged'))
|
||||
await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition)
|
||||
return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] })
|
||||
}
|
||||
|
||||
it('refuses the mount up front with the discovery-reported reason', async () => {
|
||||
const scoped = await rosterWith('- id: x\n name: [unclosed\n')
|
||||
|
||||
// The refusal happens before the loader ever sees the file, so every
|
||||
// unloadable shape gets the same early PresetMountError — and a rejected
|
||||
// setup rolls the whole agent creation back.
|
||||
await expect(agentOn(scoped, 'sess-broken', 'damaged')).rejects.toThrow(PresetMountError)
|
||||
await expect(agentOn(scoped, 'sess-broken-2', 'damaged')).rejects.toThrow(/not valid YAML/)
|
||||
expect(livePresetMounts().filter(mount => mount.presetId === 'damaged')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses the standing key a cold reader would mount by', async () => {
|
||||
const scoped = await rosterWith('rows: not-a-list\n')
|
||||
|
||||
await expect(scoped.agentPresets.standingKeyFor('damaged'))
|
||||
.rejects.toThrow(/top-level list of plugin rows/)
|
||||
})
|
||||
|
||||
it('still resolves the broken row for the surfaces that manage it', async () => {
|
||||
const scoped = await rosterWith('- id: x\n name: [unclosed\n')
|
||||
|
||||
// Deleting and reporting need the row; only composing refuses it.
|
||||
expect((await scoped.agentPresets.resolve('damaged')).broken).toMatch(/not valid YAML/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a roster with nothing in it', () => {
|
||||
it('says so instead of naming an empty list of candidates', async () => {
|
||||
const bare = new Context()
|
||||
|
||||
Reference in New Issue
Block a user