refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
/**
|
||||
* Creating, reading, and deleting locally authored presets.
|
||||
* Copying, reading, and deleting locally authored presets.
|
||||
*
|
||||
* Authoring is confined to a `user` root: the shipped `.system` set is part of
|
||||
* the deployment, and letting a browser rewrite it would turn "reset to a known
|
||||
* preset" into something the same caller could have broken first.
|
||||
*
|
||||
* The only authoring write is a whole-directory copy of an existing preset.
|
||||
* No caller supplies composition text: the inputs are ids the host resolves
|
||||
* against its own roots plus an optional display name, so authoring grants no
|
||||
* capability the copied preset did not already carry.
|
||||
* @module @deepseek-ai/dsh-agent-presets/authoring
|
||||
*/
|
||||
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { entryListSchema } from '@cordisjs/plugin-include'
|
||||
import { chmod, cp, readdir, readFile, rm, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { COMPOSITION_FILE } from './discovery.ts'
|
||||
import { METADATA_FILE, renderPresetMetadata, type PresetMetadata } from './metadata.ts'
|
||||
import { METADATA_FILE, renderPresetMetadata } from './metadata.ts'
|
||||
import type { AgentPreset, PresetRoot } from './types.ts'
|
||||
|
||||
/**
|
||||
@@ -39,13 +41,16 @@ export class InvalidPresetIdError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A composition that is not a usable entry list. */
|
||||
export class InvalidCompositionError extends Error {
|
||||
/** A copy target that is already occupied — a copy never overwrites. */
|
||||
export class PresetExistsError extends Error {
|
||||
constructor(
|
||||
/** Why the text cannot be a composition. */
|
||||
readonly reason: string,
|
||||
/** The id that is already taken. */
|
||||
readonly presetId: string,
|
||||
) {
|
||||
super(`agent-presets: composition is not a valid entry list: ${reason}`)
|
||||
super(
|
||||
`agent-presets: preset "${presetId}" already exists — `
|
||||
+ 'a copy never overwrites; delete the existing preset first or choose another id',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,30 +79,6 @@ export function writableRoot(roots: readonly PresetRoot[]): string {
|
||||
return resolve(expandHomePath(root.path))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one composition's text without mounting it.
|
||||
*
|
||||
* This is the shape check the Include performs when it reads a file — a
|
||||
* top-level list of entries. It cannot prove the composition mounts (that
|
||||
* needs the plugins), so it is a guard against saving something no session
|
||||
* could ever load, not a substitute for trying it.
|
||||
* @param content - the YAML text.
|
||||
* @throws when the text does not parse or is not a top-level array.
|
||||
*/
|
||||
export function assertComposition(content: string): void {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(content, { schema: entryListSchema })
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- js-yaml rejects with a YAMLException, which is an Error; the
|
||||
fallback keeps a hostile throw readable rather than printing `undefined`. */
|
||||
throw new InvalidCompositionError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new InvalidCompositionError('a composition must be a top-level list of plugin rows')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one preset's composition text.
|
||||
* @param preset - the resolved preset.
|
||||
@@ -107,40 +88,93 @@ export async function readComposition(preset: AgentPreset): Promise<string> {
|
||||
return await readFile(preset.path, 'utf8')
|
||||
}
|
||||
|
||||
/** Whether anything occupies the path (cp's own errorOnExist backstops races). */
|
||||
async function occupied(path: string): Promise<boolean> {
|
||||
let present = true
|
||||
try {
|
||||
await stat(path)
|
||||
} catch {
|
||||
// Every stat failure means the same thing here: nothing usable occupies
|
||||
// the path, so the copy may claim it.
|
||||
present = false
|
||||
}
|
||||
return present
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace a locally authored preset.
|
||||
* @param roots - the configured roots; the first `user` one receives the write.
|
||||
* @param id - the preset id, which becomes its directory name.
|
||||
* @param content - the composition text.
|
||||
* @param metadata - display name and description; clearing both removes the file.
|
||||
* @returns the absolute path written.
|
||||
* @throws when the id is unusable, the content is not an entry list, or the
|
||||
* deployment has no writable root.
|
||||
* Re-tighten a copied tree to owner-only. A shipped preset is world-readable
|
||||
* in its install and `cp` preserves that; the copy carries the same weight as
|
||||
* the settings document beside it, so group/other access is stripped. A
|
||||
* file's owner-execute bit survives — a preset may ship runnable helpers.
|
||||
*/
|
||||
export async function writeComposition(
|
||||
async function tightenModes(dir: string): Promise<void> {
|
||||
await chmod(dir, 0o700)
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const target = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await tightenModes(target)
|
||||
} else {
|
||||
await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a preset by copying an existing one's whole directory.
|
||||
*
|
||||
* The copy carries everything the source directory holds — composition,
|
||||
* metadata, skill directories, assets — because a preset is its directory,
|
||||
* not one file. Symlinks are dereferenced so the copy is self-contained
|
||||
* rather than a set of links back into the install it was copied from.
|
||||
*
|
||||
* The copied metadata is then rewritten: the source's description is kept
|
||||
* (the file is the author's to edit afterwards), but its name and roster
|
||||
* `order` are not — a copy presenting itself identically to its source, or
|
||||
* sorted into the shipped set's declared order, would make the roster stop
|
||||
* distinguishing them. With no name given and no description to keep, the
|
||||
* file is removed so the copy publishes nothing rather than a blank.
|
||||
* @param roots - the configured roots; the first `user` one receives the copy.
|
||||
* @param source - the resolved preset the copy starts from.
|
||||
* @param id - the new preset's id, which becomes its directory name.
|
||||
* @param name - display name for the copy; omitted falls back to the id.
|
||||
* @returns the absolute path of the new preset directory.
|
||||
* @throws when the id is unusable or already occupied on disk, or the
|
||||
* deployment configures no writable root.
|
||||
*/
|
||||
export async function copyComposition(
|
||||
roots: readonly PresetRoot[],
|
||||
source: AgentPreset,
|
||||
id: string,
|
||||
content: string,
|
||||
metadata: PresetMetadata = {},
|
||||
name?: string,
|
||||
): Promise<string> {
|
||||
if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id)
|
||||
assertComposition(content)
|
||||
const dir = join(writableRoot(roots), id)
|
||||
const path = join(dir, COMPOSITION_FILE)
|
||||
// Owner-only: a composition names the plugins a session runs, so it carries
|
||||
// the same weight as the settings document beside it.
|
||||
await writeFileAtomic(path, content, { mode: 0o600, dirMode: 0o700 })
|
||||
// Display text lands after the composition, and only when there is any: a
|
||||
// preset with no name should carry no metadata file rather than an empty
|
||||
// one. Clearing both fields therefore removes the file.
|
||||
const rendered = renderPresetMetadata(metadata)
|
||||
const metadataPath = join(dir, METADATA_FILE)
|
||||
if (rendered === undefined) {
|
||||
await rm(metadataPath, { force: true })
|
||||
} else {
|
||||
await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 })
|
||||
// The roster check upstream only sees discovered presets; a directory with
|
||||
// no composition file still occupies the name and deserves a readable
|
||||
// refusal rather than a filesystem error code.
|
||||
if (await occupied(dir)) throw new PresetExistsError(id)
|
||||
try {
|
||||
await cp(dirname(source.path), dir, {
|
||||
recursive: true, dereference: true, force: false, errorOnExist: true,
|
||||
})
|
||||
await tightenModes(dir)
|
||||
const rendered = renderPresetMetadata({
|
||||
...name === undefined ? {} : { name },
|
||||
...source.description === undefined ? {} : { description: source.description },
|
||||
})
|
||||
const metadataPath = join(dir, METADATA_FILE)
|
||||
if (rendered === undefined) {
|
||||
await rm(metadataPath, { force: true })
|
||||
} else {
|
||||
await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 })
|
||||
}
|
||||
} catch (error) {
|
||||
// A half-copied directory would be invisible to discovery at best and a
|
||||
// mountable-but-incomplete preset at worst; a failed copy leaves nothing.
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
return path
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,16 +21,16 @@
|
||||
* @module @deepseek-ai/dsh-agent-presets
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { createScope, scopeOf, setScopeParent, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { deleteComposition, readComposition, writeComposition } from './authoring.ts'
|
||||
import type { PresetMetadata } from './metadata.ts'
|
||||
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
|
||||
import { mountPreset, serviceForAgent } from './mount.ts'
|
||||
import { PresetNotWritableError } from './authoring.ts'
|
||||
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||
import { PresetExistsError } from './authoring.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
export const SETTINGS_NAMESPACE = 'agent-presets'
|
||||
@@ -55,8 +55,8 @@ export {
|
||||
type PresetMount,
|
||||
} from './mount.ts'
|
||||
export {
|
||||
assertComposition, deleteComposition, InvalidCompositionError, InvalidPresetIdError,
|
||||
PresetNotWritableError, readComposition, writableRoot, writeComposition,
|
||||
copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError,
|
||||
PresetNotWritableError, readComposition, writableRoot,
|
||||
} from './authoring.ts'
|
||||
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
|
||||
export { PresetMountError, UnknownPresetError } from './types.ts'
|
||||
@@ -171,10 +171,12 @@ export class AgentPresets extends Service {
|
||||
* Standing mounts by preset id, single-flight so two agents racing the
|
||||
* first use of one preset share one composition. A settled failure is
|
||||
* removed so a later session retries a preset whose file has been fixed; a
|
||||
* settled success is permanent for the process — the composition a running
|
||||
* session joined must survive the file changing or disappearing underneath
|
||||
* it, so file edits reach only future generations (a later authoring layer
|
||||
* swaps this pointer; it never disposes a joined generation).
|
||||
* settled success serves until the composition FILE visibly changes — each
|
||||
* generation records its file stamp, and a stale stamp starts the next
|
||||
* generation for sessions created afterwards. Sessions already joined keep
|
||||
* the generation they run on; a superseded one is never disposed while the
|
||||
* process lives (reclaimed only by whole-tree teardown), so editing files
|
||||
* is bounded by how often compositions change, not by session count.
|
||||
*/
|
||||
private readonly standing = new Map<string, Promise<StandingMount>>()
|
||||
|
||||
@@ -218,29 +220,32 @@ export class AgentPresets extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace a locally authored preset.
|
||||
* Create a locally authored preset by copying an existing one whole.
|
||||
*
|
||||
* The text is shape-checked before it lands, so a save cannot leave a file no
|
||||
* session could load; it is NOT mounted, so a composition that parses but
|
||||
* names a missing plugin still fails at the next session that selects it.
|
||||
* @param id - the preset id, which becomes its directory name.
|
||||
* @param content - the composition text.
|
||||
* @param metadata - display name and description; clearing both removes the file.
|
||||
* @throws when the id is unusable, the text is not an entry list, or the
|
||||
* deployment configures no writable root.
|
||||
* Copy is the only authoring write. Composition text never crosses this
|
||||
* seam: the source is named by id and its directory is copied as it stands,
|
||||
* so the copy is exactly as loadable as its source and authoring grants no
|
||||
* capability the roster did not already carry. The copy is NOT mounted to
|
||||
* validate — a source that mounts today yields a copy that mounts today.
|
||||
* @param from - the preset the copy starts from; shipped presets are the
|
||||
* primary source, so any trust is accepted.
|
||||
* @param id - the new preset's id, which becomes its directory name.
|
||||
* @param name - display name for the copy; absent falls back to the id.
|
||||
* @throws when the source is unknown, the id is unusable or already taken,
|
||||
* or the deployment configures no writable root.
|
||||
*/
|
||||
async write(id: string, content: string, metadata: PresetMetadata = {}): Promise<void> {
|
||||
// A shipped preset belongs to the deployment: overwriting it would remove
|
||||
// the known-good composition a broken local one is compared against.
|
||||
const existing = (await this.list()).find(preset => preset.id === id)
|
||||
if (existing !== undefined && existing.trust !== 'user') {
|
||||
throw new PresetNotWritableError(id, 'it ships with the deployment')
|
||||
async copy(from: string, id: string, name?: string): Promise<void> {
|
||||
const source = await this.resolve(from)
|
||||
// The roster check refuses ids any root supplies — shipped ones included,
|
||||
// since a user directory named like a shipped preset is shadowed by it.
|
||||
// The disk check inside copyComposition only sees the writable root.
|
||||
if ((await this.list()).some(preset => preset.id === id)) {
|
||||
throw new PresetExistsError(id)
|
||||
}
|
||||
await writeComposition(this.config.roots, id, content, metadata)
|
||||
// Future generations only: the standing pointer is dropped so the NEXT
|
||||
// session composes the edited file, while every session already joined
|
||||
// keeps the mount it runs on — a superseded generation is never disposed
|
||||
// while the process lives (reclaimed only by whole-tree teardown).
|
||||
await copyComposition(this.config.roots, 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.
|
||||
this.standing.delete(id)
|
||||
}
|
||||
|
||||
@@ -251,8 +256,8 @@ export class AgentPresets extends Service {
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
await deleteComposition(this.config.roots, await this.resolve(id))
|
||||
// Same generation rule as `write`: sessions on the deleted preset keep
|
||||
// their standing mount; only new sessions see the roster without it.
|
||||
// Sessions on the deleted preset keep their standing mount; only new
|
||||
// sessions see the roster without it.
|
||||
this.standing.delete(id)
|
||||
// Storing a default that does not exist YET is deliberate — the roster is a
|
||||
// live directory, so a name absent now may exist by the time a session asks
|
||||
@@ -332,32 +337,79 @@ export class AgentPresets extends Service {
|
||||
}
|
||||
|
||||
/** Resolve (or create, single-flight) the standing mount of one preset. */
|
||||
private ensureStanding(preset: AgentPreset): Promise<StandingMount> {
|
||||
private async ensureStanding(preset: AgentPreset): Promise<StandingMount> {
|
||||
const pending = this.standing.get(preset.id)
|
||||
if (pending !== undefined) return pending
|
||||
if (pending !== undefined) {
|
||||
const mounted = await pending
|
||||
// Files are the only composition editor (authoring is copy/delete), so
|
||||
// the stamp is what notices an edit: a changed file starts the next
|
||||
// generation here, for this and later sessions. An unreadable stamp
|
||||
// serves the current generation — a mount must survive its file
|
||||
// disappearing, and failing the session over a stat would not.
|
||||
const current = await compositionStamp(preset.path)
|
||||
if (current === undefined || sameStamp(mounted.stamp, current)) return mounted
|
||||
// Guarded delete: a caller that raced this one may have already started
|
||||
// the next generation, and dropping THAT pointer would fork a third.
|
||||
if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id)
|
||||
return this.ensureStanding(preset)
|
||||
}
|
||||
const created = (async (): Promise<StandingMount> => {
|
||||
const key: ScopeKey = { agentPreset: preset.id }
|
||||
const scope = createScope(this.selfCtx, key)
|
||||
try {
|
||||
// Stamped before the file is read: an edit racing the mount makes the
|
||||
// stamp stale rather than silently current, so the next session
|
||||
// refreshes instead of trusting a composition older than its stamp.
|
||||
const stamp = await compositionStamp(preset.path)
|
||||
if (stamp === undefined) {
|
||||
throw new PresetMountError(preset.id, `composition file is unreadable: ${preset.path}`)
|
||||
}
|
||||
await mountPreset(scope.ctx, preset)
|
||||
return { key, scope, stamp }
|
||||
} catch (error) {
|
||||
this.standing.delete(preset.id)
|
||||
await scope.dispose()
|
||||
throw error
|
||||
}
|
||||
return { key, scope }
|
||||
})()
|
||||
this.standing.set(preset.id, created)
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
/** The composition file identity one standing generation was mounted from. */
|
||||
interface CompositionStamp {
|
||||
/** Modification time in milliseconds, as `stat` reports it. */
|
||||
readonly mtimeMs: number
|
||||
/** File size in bytes, the tiebreak for edits within one mtime tick. */
|
||||
readonly size: number
|
||||
}
|
||||
|
||||
/** Read one composition file's stamp, or undefined when it cannot be statted. */
|
||||
async function compositionStamp(path: string): Promise<CompositionStamp | undefined> {
|
||||
try {
|
||||
const { mtimeMs, size } = await stat(path)
|
||||
return { mtimeMs, size }
|
||||
} catch {
|
||||
// Deleted, replaced by an unreadable entry, or otherwise unstattable all
|
||||
// mean the same to the caller: the file offers no identity to compare.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether two stamps name the same file state. */
|
||||
function sameStamp(a: CompositionStamp, b: CompositionStamp): boolean {
|
||||
return a.mtimeMs === b.mtimeMs && a.size === b.size
|
||||
}
|
||||
|
||||
/** One preset's standing composition. */
|
||||
interface StandingMount {
|
||||
/** Scope key agents are parented to; also the mount's registration scope. */
|
||||
readonly key: ScopeKey
|
||||
/** Disposal boundary; held for whole-tree teardown, never per-session. */
|
||||
readonly scope: Scope
|
||||
/** Stamp of the composition file this generation was mounted from. */
|
||||
readonly stamp: CompositionStamp
|
||||
}
|
||||
|
||||
export default AgentPresets
|
||||
|
||||
Reference in New Issue
Block a user