feat(agent-presets): own the writable preset root instead of awaiting an app
`Config.roots` defaulted to `[]`, so a deployment that did not patch both roots in got a roster with none — `dsh run` once booted exactly that and failed resolving `standard`. The user root never needed an app: `<dshHome>/.agent-presets` is the same place in every deployment, resolvable here the way `dsh-skill-local` resolves `<dshHome>/skills`. The roster now derives that root itself unless `includeUserRoot` is false, and `apps/cli` supplies only the SHIPPED root, whose path an installed app alone can resolve. The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it and `writableRoot()` still prefers an explicitly configured `user` root; the set is resolved once at construction, because a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. Every test that pins an exact roster now says `includeUserRoot: false` — the machine's real harness home must not decide what a golden or an assertion counts.
This commit is contained in:
@@ -25,6 +25,17 @@ import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts'
|
||||
/** The composition file that makes a directory a preset. */
|
||||
export const COMPOSITION_FILE = 'agent.cordis.yml'
|
||||
|
||||
/**
|
||||
* Harness-home directory holding locally authored presets.
|
||||
*
|
||||
* This package owns the writable root the way `dsh-skill-local` owns
|
||||
* `<dshHome>/skills`. An app must assemble the SHIPPED root, whose path only
|
||||
* the installed app can resolve; where a person's own presets go is the same
|
||||
* place in every deployment that does not say otherwise, so a launcher that
|
||||
* forgets to configure one still finds them.
|
||||
*/
|
||||
export const USER_PRESET_DIR = '.agent-presets'
|
||||
|
||||
/**
|
||||
* Why `rows` cannot be an entry list, or undefined when it can.
|
||||
*
|
||||
|
||||
@@ -28,11 +28,12 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type
|
||||
// Type-only: resolves the `agent/created` lifecycle event this service watches.
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { discoverPresets, USER_PRESET_DIR } from './discovery.ts'
|
||||
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
|
||||
import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
|
||||
import { PresetExistsError } from './authoring.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts'
|
||||
import type {} from './types.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
@@ -49,7 +50,7 @@ export const AgentPresetSettingsSchema: z<AgentPresetSettings> = z.object({
|
||||
default: z.string(),
|
||||
})
|
||||
|
||||
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
||||
export { COMPOSITION_FILE, discoverPresets, scanRoot, USER_PRESET_DIR } from './discovery.ts'
|
||||
export {
|
||||
METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata,
|
||||
} from './metadata.ts'
|
||||
@@ -88,8 +89,21 @@ export class AgentPresets extends Service {
|
||||
path: z.string().required(),
|
||||
trust: z.union(['system', 'user'] as const).default('user'),
|
||||
})).default([]),
|
||||
includeUserRoot: z.boolean().default(true),
|
||||
}) as z<Config>
|
||||
|
||||
/**
|
||||
* The roots discovery and authoring actually scan: every configured root in
|
||||
* order, then the harness-home user root unless `includeUserRoot` is false.
|
||||
*
|
||||
* Derived once, because a root set that changed between `list()` and the
|
||||
* `copy()` acting on its answer would author into a directory the caller
|
||||
* never saw. Appending rather than prepending keeps an earlier configured
|
||||
* root winning a duplicate id, so a shipped preset still shadows a
|
||||
* locally authored directory that claimed its name.
|
||||
*/
|
||||
private readonly resolvedRoots: readonly PresetRoot[]
|
||||
|
||||
/**
|
||||
* The user layer over `config.default`, present only while a settings
|
||||
* provider is composed. Held rather than snapshotted so a hot-reloaded
|
||||
@@ -116,6 +130,9 @@ export class AgentPresets extends Service {
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentPresets')
|
||||
this.selfCtx = ctx
|
||||
this.resolvedRoots = config.includeUserRoot
|
||||
? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }]
|
||||
: [...config.roots]
|
||||
// Deliberately not `installSettingsSection`: that helper exists to re-judge
|
||||
// what a consumer DERIVED from the source — memoized resolutions,
|
||||
// registration-level facts — across attach, detach, and change. Nothing
|
||||
@@ -147,7 +164,7 @@ export class AgentPresets extends Service {
|
||||
// does that today — the Web surface mounts in `setup` and children join
|
||||
// through `composeFrom` before publication.
|
||||
ctx.on('agent/created', ({ agent }) => {
|
||||
if (this.config.roots.length === 0) return
|
||||
if (this.resolvedRoots.length === 0) return
|
||||
if (this.composedPreset(agent.ctx) !== undefined) return
|
||||
ctx.logger.warn(
|
||||
`agent "${agent.id}" was published without joining an agent preset; `
|
||||
@@ -180,7 +197,7 @@ export class AgentPresets extends Service {
|
||||
* @returns the presets, first-root-wins per id.
|
||||
*/
|
||||
async list(): Promise<AgentPreset[]> {
|
||||
return await discoverPresets(this.config.roots)
|
||||
return await discoverPresets(this.resolvedRoots)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,7 +339,7 @@ export class AgentPresets extends Service {
|
||||
|
||||
/** Whether this deployment configures a root locally authored presets go to. */
|
||||
get authorable(): boolean {
|
||||
return this.config.roots.some(root => root.trust === 'user')
|
||||
return this.resolvedRoots.some(root => root.trust === 'user')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,7 +375,7 @@ export class AgentPresets extends Service {
|
||||
if ((await this.list()).some(preset => preset.id === id)) {
|
||||
throw new PresetExistsError(id)
|
||||
}
|
||||
await copyComposition(this.config.roots, source, id, name)
|
||||
await copyComposition(this.resolvedRoots, source, id, name)
|
||||
// A settled mount under this id can only be stale (its preset was deleted
|
||||
// from disk outside `remove`); the new preset must not inherit it. Every
|
||||
// session already joined keeps the generation it runs on regardless.
|
||||
@@ -371,7 +388,7 @@ export class AgentPresets extends Service {
|
||||
* @throws when the preset is unknown or ships with the deployment.
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
await deleteComposition(this.config.roots, await this.resolve(id))
|
||||
await deleteComposition(this.resolvedRoots, await this.resolve(id))
|
||||
// Sessions on the deleted preset keep their standing mount; only new
|
||||
// sessions see the roster without it.
|
||||
this.standing.delete(id)
|
||||
|
||||
@@ -54,6 +54,11 @@ export interface Config {
|
||||
default: string
|
||||
/** Scanned roots in precedence order; an earlier root wins a duplicate id. */
|
||||
roots: PresetRoot[]
|
||||
/**
|
||||
* Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
|
||||
* configured root. False mounts a roster over `roots` alone.
|
||||
*/
|
||||
includeUserRoot: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user