fix(plugin): retain scoped row identity and source diagnostics

This commit is contained in:
Tianyi Cui committed 2026-10-06 23:48:59 +08:00
1 parent 652fce6964
commit 996007e103
24 files changed
+261 -69

No files matched your search

+1 -1
View File
@@ -139,7 +139,7 @@ export type Config = PresetDefinition
- `inject`: `loader` · `sessionProjections`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts)
- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:15`](../packages/preset/agent-preset-registry/src/preset.ts)
```ts config-catalog
/** Registry selection policy. */
+1 -1
View File
@@ -141,7 +141,7 @@ export type Config = PresetDefinition
- `inject`: `loader` · `sessionProjections`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts)
- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:15`](../packages/preset/agent-preset-registry/src/preset.ts)
```ts config-catalog
/** Registry selection policy. */
+1 -1
View File
@@ -32,7 +32,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.configEditor` — `ConfigEditor`
Persist complete raw configs and apply them through the normal Loader path.
Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader.
```ts cordis-catalog
/** Addressable profile rows; nested Includes have independent configuration ownership.
+1 -1
View File
@@ -32,7 +32,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.configEditor` — `ConfigEditor`
Persist complete raw configs and apply them through the normal Loader path.
Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader.
```ts cordis-catalog
/** Addressable profile rows; nested Includes have independent configuration ownership.
+1 -1
View File
@@ -502,7 +502,7 @@ async register(definition: PresetDefinition): Promise<() => Promise<void>>
inspectCompositions(ctx?: Context): AgentPresetInspection[]
/** Read every declared preset, including activation failures.
* @returns Display metadata and loading diagnostics.
* @returns Display metadata, declaring Loader row identities, and loading diagnostics.
*/
async list(): Promise<AgentPreset[]>
+1 -1
View File
@@ -512,7 +512,7 @@ async register(definition: PresetDefinition): Promise<() => Promise<void>>
inspectCompositions(ctx?: Context): AgentPresetInspection[]
/** Read every declared preset, including activation failures.
* @returns Display metadata and loading diagnostics.
* @returns Display metadata, declaring Loader row identities, and loading diagnostics.
*/
async list(): Promise<AgentPreset[]>
+2 -2
View File
@@ -24,8 +24,8 @@
en: 1099528bde0e2aee
zh: b68772bdb9bb7856
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
en: ea35d8b939e84ff5
zh: 1bc1f7a14b1628f8
en: 1db121100677e45f
zh: c6a8c6316020eb1b
/deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration:
en: 646a0ccdd7881ad1
zh: e3696acb1573dd7b
+1 -1
View File
@@ -76,7 +76,7 @@ A profile patch may set `preset` to an outer row id and apply the remaining ordi
disabled: true
```
`ProfilePatch` extends the native patch fields with `preset`. `applyProfilePatches` composes these operations; `compileProfilePatches` returns ordinary Include patches. Startup, profile reload, effective-config dumps, schema inspection, and compatibility preflight share this compiler. Scoped `insert` module paths resolve beside their declaring patch, including nested groups; `!!js` remains unevaluated until each child activates. Native included-file patch lists keep their ordinary Include syntax.
`ProfilePatch` extends the native patch fields with `preset`. `applyProfilePatches` composes these operations; `compileProfilePatches` returns ordinary Include patches. Startup, profile reload, effective-config dumps, schema inspection, and compatibility preflight share this compiler. Scoped `insert` module paths resolve beside their declaring patch, including nested groups; `!!js` remains unevaluated until each child activates. Native included-file patch lists keep their ordinary Include syntax. Loaded scoped insertions without IDs receive stable internal IDs derived from the canonical declaring file and entry position, including nested groups; ordinary anonymous root rows keep Loader’s existing behavior. Generated IDs identify contributions across reloads and retained revisions, and are implementation details rather than names to use in authored patches. Compiler failures name the declaring patch file and operation, including after profile layers are combined.
Before mounting profile rows, the `dsh` launcher computes one immutable runtime resolution from the installation and ordered bundle dependency graphs. Every profile launcher uses runtime resolution, including plain Node, packaged executables, and the Electron Host. It installs the runtime resolution through Node's ESM and CommonJS resolvers without creating fallback links.
+1 -1
View File
@@ -76,7 +76,7 @@ profile patch 可以将 `preset` 设为外层条目 id,把其余普通 patch
disabled: true
```
`ProfilePatch` 在原生 patch 字段上增加 `preset`。`applyProfilePatches` 组合这些操作;`compileProfilePatches` 返回普通 Include patch。启动、profile 重载、有效配置 dump、schema 检查及兼容性预检共用此编译器。限定到 preset 的 `insert` 模块路径以声明它的 patch 所在目录为基准,包括嵌套分组;`!!js` 保持不求值,直至对应子条目激活。原生被 Include 引入文件的 patch 列表仍使用普通 Include 语法。
`ProfilePatch` 在原生 patch 字段上增加 `preset`。`applyProfilePatches` 组合这些操作;`compileProfilePatches` 返回普通 Include patch。启动、profile 重载、有效配置 dump、schema 检查及兼容性预检共用此编译器。限定到 preset 的 `insert` 模块路径以声明它的 patch 所在目录为基准,包括嵌套分组;`!!js` 保持不求值,直至对应子条目激活。原生被 Include 引入文件的 patch 列表仍使用普通 Include 语法。 读取的作用域插入行若未声明 ID,会依据声明文件的规范路径及条目位置获得稳定的内部 ID,嵌套组内的行也如此;普通匿名根行保留 Loader 的既有行为。生成的 ID 用于在重载和保留代际之间识别贡献项,是实现细节,不是作者应在 patch 中引用的名称。编译失败会标明声明 patch 文件和操作序号,profile 层合并后也保留这些信息。
挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 runtime resolution。普通 Node、打包可执行文件与 Electron Host 等所有 profile 启动器都使用 runtime 解析,将 runtime resolution 安装到 Node 的 ESM 与 CommonJS 解析器中,不创建 fallback 链接。
+2 -3
View File
@@ -25,7 +25,7 @@ export {
setProfileVersionExemption, type ProfileCompatibility,
} from './profile-compatibility.ts'
import { prepareProfilePatches } from './compatibility-preflight.ts'
import { applyProfilePatches, compileProfilePatches, profilePatchPreset, type ProfilePatch } from './profile-patches.ts'
import { applyProfilePatches, compileProfilePatches, profilePatchPreset, prepareLoadedProfilePatches, type ProfilePatch } from './profile-patches.ts'
export { applyProfilePatches, compileProfilePatches, profilePatchPreset, type ProfilePatch, type ProfilePatchWarning } from './profile-patches.ts'
export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts'
export { readPluginMeta, resolvePluginResource } from './package-meta.ts'
@@ -386,7 +386,6 @@ function anchorInsertedPluginNames(patches: ProfilePatch[], file: string): Profi
}
}
for (const patch of patches) {
profilePatchPreset(patch)
patch.insert?.forEach(visit)
}
return patches
@@ -421,7 +420,7 @@ function parsePatchList(
throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
}
})
return anchorInsertedPluginNames(parsed as ProfilePatch[], file)
return anchorInsertedPluginNames(prepareLoadedProfilePatches(parsed as ProfilePatch[], file, binName), file)
}
/** One overlay patch list with the source label printed in dump comments. */
@@ -2,7 +2,7 @@
import { join } from 'node:path'
import { composeEntries, loadProfileDirectory, PROFILE_PATCH_FILENAME, type Profile } from './profile.ts'
import { loadOptionalPatches } from './index.ts'
import type { ProfilePatch } from './profile-patches.ts'
import { cloneProfilePatches, type ProfilePatch } from './profile-patches.ts'
/** Application-owned package manager executable; environment applies only to package operations. */
export interface ProfilePnpmInvocation {
@@ -62,7 +62,7 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b
*/
export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): ProfilePatch[] {
const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false })
const patches = structuredClone([
const patches = cloneProfilePatches([
...profile.layers.flatMap(layer => layer.patches),
...(initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? []),
...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []),
+72 -13
View File
@@ -1,5 +1,7 @@
/** Compile preset-scoped profile operations to the native Include patch language. */
import { createHash } from 'node:crypto'
import { realpathSync } from 'node:fs'
import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
@@ -9,6 +11,59 @@ export interface ProfilePatch extends PatchOptions {
preset?: string
}
interface PatchSource { readonly binName: string; readonly file: string; readonly operation: number }
const patchSources = new WeakMap<ProfilePatch, PatchSource>()
function sourcedError(patch: ProfilePatch, error: unknown): unknown {
const source = patchSources.get(patch)
return source === undefined ? error : new Error(
`${source.binName}: ${error instanceof Error ? error.message : String(error)} in ${source.file} (patch ${source.operation + 1})`,
{ cause: error },
)
}
/** Clone patch data while preserving the file attribution held outside its serialized fields.
* @param patches Complete ordered patches, possibly loaded from files.
* @returns Detached patches with the same diagnostic sources and shared-reference relationships.
*/
export function cloneProfilePatches(patches: readonly ProfilePatch[]): ProfilePatch[] {
const copied = structuredClone([...patches])
const sources = patches.map(patch => patchSources.get(patch))
copied.forEach((patch, index) => {
const source = sources[index]
if (source !== undefined) patchSources.set(patch, source)
})
return copied
}
/** Attach source diagnostics and stable internal identities to parsed scoped insertions.
* @param patches Parsed file patches, modified before they reach any composition consumer.
* @param file Declaring patch file; its canonical path distinguishes anonymous contributions.
* @param binName Diagnostic prefix.
* @returns The same patches, with anonymous scoped rows identified by file and position.
* @throws When a preset target is malformed, naming its declaring file and operation.
*/
export function prepareLoadedProfilePatches(patches: ProfilePatch[], file: string, binName: string): ProfilePatch[] {
const canonical = realpathSync(file)
patches.forEach((patch, operation) => {
patchSources.set(patch, { binName, file, operation })
try {
if (profilePatchPreset(patch) === undefined || patch.insert === undefined) return
const identify = (rows: EntryOptions[], parent: readonly number[]): void => {
rows.forEach((row, index) => {
const position = [...parent, index]
if (!row.id) row.id = `__dsh_profile_${createHash('sha256').update(JSON.stringify([canonical, operation, position])).digest('hex').slice(0, 32)}`
if (row.group && Array.isArray(row.config)) identify(row.config as EntryOptions[], position)
})
}
identify(patch.insert, [])
} catch (error) {
throw sourcedError(patch, error)
}
})
return patches
}
/** Native Include diagnostic arguments.
* @param message Diagnostic format; `%C` marks a value rendered as code.
* @param args Values substituted into the diagnostic format.
@@ -71,20 +126,24 @@ export function compileProfilePatches(
initial: EntryOptions[], patches: readonly ProfilePatch[], warn: ProfilePatchWarning = () => {},
): PatchOptions[] {
const lowered: PatchOptions[] = []
for (const patch of structuredClone(patches)) {
const preset = profilePatchPreset(patch)
if (preset === undefined) {
lowered.push(patch)
continue
for (const patch of cloneProfilePatches(patches)) {
try {
const preset = profilePatchPreset(patch)
if (preset === undefined) {
lowered.push(patch)
continue
}
const target = targetOf(initial, lowered, preset)
const config = target.config as Record<string, unknown> & { plugins: EntryOptions[] }
const { preset: _preset, ...operation } = patch
const plugins = applyEntryPatches(config.plugins, [operation], (message: string, ...args: unknown[]) => {
warn(`preset ${JSON.stringify(preset)}: ${message}`, ...args)
})
if (operation.insert !== undefined) assertInsertedIds(preset, plugins)
lowered.push({ id: preset, config: { ...config, plugins } })
} catch (error) {
throw sourcedError(patch, error)
}
const target = targetOf(initial, lowered, preset)
const config = target.config as Record<string, unknown> & { plugins: EntryOptions[] }
const { preset: _preset, ...operation } = patch
const plugins = applyEntryPatches(config.plugins, [operation], (message: string, ...args: unknown[]) => {
warn(`preset ${JSON.stringify(preset)}: ${message}`, ...args)
})
if (operation.insert !== undefined) assertInsertedIds(preset, plugins)
lowered.push({ id: preset, config: { ...config, plugins } })
}
return lowered
}
@@ -14,6 +14,8 @@ import {
prepareProfilePatches, profilePatchPreset, reconcileProfilePatches, renderConfigDump, type ProfilePatch,
} from '../src/index.ts'
import { cloneProfilePatches } from '../src/profile-patches.ts'
const preset = (plugins: EntryOptions[] = []): Omit<EntryOptions, 'config'> & {
config: { id: string; description: string; plugins: EntryOptions[] }
} => ({
@@ -109,6 +111,7 @@ describe('preset patch composition', () => {
const path = join(dir, 'patch.yml')
writeFileSync(path, JSON.stringify([{ preset: target, insert: [] }]))
expect(() => loadOverlayPatches('test', path)).toThrow('nonempty literal row id')
expect(() => loadOverlayPatches('test', path)).toThrow(path)
})
it('allows callers to omit a warning sink for skipped ordinary and scoped rows', () => {
@@ -142,7 +145,64 @@ describe('preset patch composition', () => {
const path = join(temp(), 'anonymous.patch.yml')
writeFileSync(path, JSON.stringify([{ preset: 'preset-standard', insert: [{ name: 'anonymous' }] }]))
expect(children(applyProfilePatches([preset()], loadOverlayPatches('test', path))))
.toEqual([{ name: 'anonymous' }])
.toEqual([{ id: expect.any(String) as string, name: 'anonymous' }])
})
it('keeps anonymous scoped identities stable by file and position without naming ordinary anonymous rows', () => {
const dir = temp()
const file = join(dir, 'contribution.patch.yml')
const content = JSON.stringify([
{ insert: [{ name: 'ordinary-anonymous' }] },
{ preset: 'preset-standard', insert: [
{ name: 'anonymous' },
{ id: 'explicit', name: 'cordis:group', group: true, config: [{ name: 'nested-anonymous' }] },
] },
{ preset: 'preset-standard', insert: [{ name: 'anonymous' }] },
])
writeFileSync(file, content)
const first = loadOverlayPatches('test', file)
const second = loadOverlayPatches('test', file)
expect(second).toEqual(first)
expect(first[0]?.insert?.[0]?.id).toBeUndefined()
expect(first[1]?.insert?.[1]?.id).toBe('explicit')
const nested = first[1]?.insert?.[1]?.config as EntryOptions[]
const ids = [first[1]?.insert?.[0]?.id, nested[0]?.id, first[2]?.insert?.[0]?.id]
expect(ids.every(id => typeof id === 'string' && id.length > 0)).toBe(true)
expect(new Set(ids).size).toBe(3)
const other = join(dir, 'other.patch.yml')
writeFileSync(other, content)
expect(loadOverlayPatches('test', other)[1]?.insert?.[0]?.id).not.toBe(ids[0])
expect(children(applyProfilePatches([preset()], first))).toEqual(children(applyProfilePatches([preset()], second)))
expect(Object.keys(first[1]!)).toEqual(['preset', 'insert'])
})
it.each([
{ initial: [], insert: [] },
{ initial: [preset()], insert: [{ id: 'group', name: 'cordis:group', group: true, config: [row('duplicate'), row('duplicate')] }] },
{ initial: [{ ...preset(), config: null }], insert: [] },
])('attributes scoped errors after cloning and flattening through composition, dumps, and boot', async ({ initial, insert }) => {
const dir = temp()
const path = join(dir, 'cordis.yml')
writeFileSync(path, JSON.stringify(initial))
const patchFile = join(dir, 'third-party.patch.yml')
writeFileSync(patchFile, JSON.stringify([{ preset: 'preset-standard', insert }]))
const patches = cloneProfilePatches(loadOverlayPatches('test', patchFile))
expect(() => composeEntries([[{ insert: initial }], patches])).toThrow(patchFile)
expect(() => renderConfigDump('test', path, [{ label: 'third-party', patches }])).toThrow(patchFile)
await expect(boot('test', path, patches)).rejects.toThrow(patchFile)
})
it('keeps anonymous rows from the initial preset beside newly inserted rows', () => {
const initial = load(JSON.stringify([{ ...preset(), config: { id: 'standard', plugins: [{ name: 'anonymous-base' }] } }])) as EntryOptions[]
expect(children(applyProfilePatches(initial, [{ preset: 'preset-standard', insert: [row('contributed')] }])))
.toEqual([{ name: 'anonymous-base' }, row('contributed')])
})
it('attributes errors from a caller warning sink to the loaded scoped operation', () => {
const path = join(temp(), 'warning.patch.yml')
writeFileSync(path, JSON.stringify([{ preset: 'preset-standard', id: 'absent', disabled: true }]))
expect(() => applyProfilePatches([preset()], loadOverlayPatches('test', path), () => { throw 'warning sink unavailable' }))
.toThrow(`warning sink unavailable in ${path}`)
})
it('attributes ordinary and scoped warnings to their own dump layers', () => {
@@ -15,8 +15,8 @@
en: 87679c51aa3693f5
zh: 11684a48a5806afb
/deepseek-ai-dsh-plugin-manager/use-this-package:
en: 304878de50619b9a
zh: 17a15046d560a524
en: 4b72417803fc5b3e
zh: 8f55ccd657483255
/deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
en: 920c0acb3b9feaf3
zh: ba6e832a256880a7
+1 -1
View File
@@ -41,7 +41,7 @@ A plugin toggle updates only `disabled` in the last matching override in the pro
A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable.
Scoped bundle rows carry both their declared `rowId` and outer `preset` target. The manager reads declarations without composing the bundle alone, so rows remain visible while Off. Their optional `composition` state comes from the current preset inventory; they have no editable root `entryId`, and their `preset-managed` read-only reason directs configuration to the preset or bundle. Enabling audits every affected preset, including failures the registry retained instead of throwing; unrelated unchanged preset failures become warnings. Remove refuses while a current or retained preset generation uses a contributed row, matched by defining preset row, child row ID, and resolved module, or any module belonging to the bundle package itself. This protection lasts after Off until those generations release the modules; unrelated rows using a shared dependency do not prevent removal.
Scoped bundle rows carry both their declared `rowId` and outer `preset` target. The manager reads declarations without composing the bundle alone, so rows remain visible while Off. Their optional `composition` state comes from the current preset inventory; they have no editable root `entryId`, and their `preset-managed` read-only reason directs configuration to the preset or bundle. Enabling audits every affected preset using its registered declaring row, including failures the registry retained instead of throwing; unrelated unchanged preset failures become warnings. Bundle toggles validate the saved composition even without HMR; composition failures keep the saved selection and return a diagnostic immediately. Remove refuses while a current or retained preset generation uses a contributed row, matched by defining preset row, child row ID, and resolved module, or any module belonging to the bundle package itself. This protection lasts after Off until those generations release the modules; unrelated rows using a shared dependency do not prevent removal. Anonymous scoped insertions receive the same protection. Missing module files do not make a different contribution match; identical retained declarations remain protected when their files are unavailable.
`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results.
+1 -1
View File
@@ -41,7 +41,7 @@ kind: "package-reference"
已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。
限定到 preset 的组合包行同时携带声明的 `rowId` 和外层 `preset` 目标。管理器读取声明,不单独组合该 bundle,因此 Off 状态下仍能列出这些行。可选的 `composition` 状态来自当前 preset 清单;这些行没有可编辑的根条目 `entryId`,其 `preset-managed` 只读原因指引用户通过预设或组合包进行配置。启用会检查所有受影响的 preset,包括注册表保留而未抛出的故障;无关且未变化的 preset 故障作为警告返回。当前或被保留的 preset 代仍使用贡献行(按定义 preset 的行、子行 ID 和解析后的模块匹配),或属于组合包自身的任意模块时,Remove 都会拒绝。这项保护在 Off 后仍持续,直到这些代释放模块;无关行使用共享依赖不会阻止移除。
限定到 preset 的组合包行同时携带声明的 `rowId` 和外层 `preset` 目标。管理器读取声明,不单独组合该 bundle,因此 Off 状态下仍能列出这些行。可选的 `composition` 状态来自当前 preset 清单;这些行没有可编辑的根条目 `entryId`,其 `preset-managed` 只读原因指引用户通过预设或组合包进行配置。启用根据注册的声明行检查所有受影响的 preset,包括注册表保留而未抛出的故障;无关且未变化的 preset 故障作为警告返回。即使没有 HMR,组合包开关也会校验保存后的组合;组合失败会保留已保存的选择并立即返回诊断。当前或被保留的 preset 代仍使用贡献行(按定义 preset 的行、子行 ID 和解析后的模块匹配),或属于组合包自身的任意模块时,Remove 都会拒绝。这项保护在 Off 后仍持续,直到这些代释放模块;无关行使用共享依赖不会阻止移除。匿名作用域插入行受到相同保护。模块文件缺失不会导致另一贡献项被误认为匹配;文件不可用时,相同的保留声明仍受保护。
`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。
+30 -21
View File
@@ -8,7 +8,7 @@ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { Context } from '@deepseek-ai/cordis'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
import type { AgentPreset } from '@deepseek-ai/dsh-agent-preset-registry'
import z from '@deepseek-ai/schemastery'
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
@@ -281,6 +281,7 @@ export class PluginManager extends TypertRemoteService {
@Remote
async listBundles(): Promise<BundleInfo[]> {
const inventory = await readPluginInventory(this.ctx)
const definitions = await this.ctx.get('agentPresets')?.list() ?? []
const manifest = readProfileManifest('dsh', this.profile.dir)
const exemptions = readProfileVersionExemptions(this.profile.dir)
const selected = manifest.dsh?.profile?.bundles ?? []
@@ -319,7 +320,7 @@ export class PluginManager extends TypertRemoteService {
...meta === undefined ? {} : { meta },
...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
...(readOnlyReason === undefined ? {} : { readOnlyReason }),
...this.declaredRows(name, info, inventory) })
...this.declaredRows(name, info, inventory, definitions) })
} catch (error) {
if (enabled || installed) {
bundles.push({ name, ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
@@ -653,7 +654,7 @@ export class PluginManager extends TypertRemoteService {
}
/** The rows a bundle declares, joined by outer preset and child id where applicable. */
private declaredRows(name: string, info: ProfileManifest, inventory: PluginInventorySnapshot): Pick<BundleInfo, 'rows' | 'overrides'> {
private declaredRows(name: string, info: ProfileManifest, inventory: PluginInventorySnapshot, definitions: readonly AgentPreset[]): Pick<BundleInfo, 'rows' | 'overrides'> {
const patches = this.bundlePatches(name, info)
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
const entries = [...this.ctx.loader.entries()]
@@ -665,7 +666,7 @@ export class PluginManager extends TypertRemoteService {
const active = matches.length === 1 ? matches[0] : undefined
const base = active?.parent.tree.ctx.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href
const meta = packages?.metaOf(row.name, base)
const presetId = preset === undefined ? undefined : this.presetId(preset)
const presetId = preset === undefined ? undefined : definitions.find(item => item.definitionEntryId === preset)?.id
const composition = inventory.agentPresets?.find(item => item.id === presetId)?.rows
.find(item => item.entryId === row.id && item.moduleName === row.name)
rows.push({ rowId: row.id, moduleName: row.name,
@@ -683,12 +684,6 @@ export class PluginManager extends TypertRemoteService {
return { rows, overrides }
}
/** Map an outer profile row to the preset identity registered by its current configuration. */
private presetId(outer: string): string | undefined {
const config = [...this.ctx.loader.entries()].find(entry => entry.options.id === outer)?.options.config as { id?: unknown } | undefined
return typeof config?.id === 'string' ? config.id : undefined
}
/** Retained revisions still hold module code even after a bundle has been deselected. */
private retainedBundleModules(name: string, declarations: readonly BundleDeclaration[]): boolean {
const presets = this.ctx.get('agentPresets')
@@ -696,14 +691,24 @@ export class PluginManager extends TypertRemoteService {
const manifest = join(resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir), 'package.json')
const base = pathToFileURL(manifest).href
const owner = realpathSync(manifest)
const moduleKey = (specifier: string, parentURL: string): string => specifier.startsWith('cordis:')
? specifier : realpathSync(resolvePluginResource(specifier, parentURL))
const moduleKey = (specifier: string, parentURL: string): string | undefined => {
if (specifier.startsWith('cordis:')) return specifier
try { return realpathSync(resolvePluginResource(specifier, parentURL)) }
catch (error) {
// An externally removed module cannot match another resolved file; identical declarations remain protected below.
if (['ENOENT', 'ENOTDIR', 'ERR_MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(String((error as NodeJS.ErrnoException).code))) return undefined
throw error
}
}
return presets.inspectCompositions().some(composition => composition.modules.some((module) => {
const moduleBase = module.baseUrl ?? base
if (bundleModuleOwner(module.moduleName, moduleBase, this.ctx.get('pluginPackages')) === owner) return true
return module.useHostBase && declarations.some(({ row, preset }) => preset !== undefined
&& preset === composition.definitionEntryId && row.id === module.entryId
&& moduleKey(row.name, base) === moduleKey(module.moduleName, moduleBase))
return module.useHostBase && declarations.some(({ row, preset }) => {
if (preset === undefined || preset !== composition.definitionEntryId || row.id !== module.entryId) return false
const declared = moduleKey(row.name, base)
const retained = moduleKey(module.moduleName, moduleBase)
return declared === undefined || retained === undefined ? row.name === module.moduleName : declared === retained
})
}))
}
@@ -829,24 +834,28 @@ export class PluginManager extends TypertRemoteService {
}
/** Configuration identity distinguishes a changed failed preset from an unrelated pre-existing warning. */
private presetConfiguration(id: string): string {
private presetConfiguration(preset: AgentPreset): string {
return JSON.stringify([...this.ctx.loader.entries()].flatMap((entry) => {
const config = entry.options.config as { id?: unknown; plugins?: unknown } | undefined
return config?.id === id && Array.isArray(config.plugins) ? [config] : []
return entry.options.id === preset.definitionEntryId && config?.id === preset.id && Array.isArray(config.plugins) ? [config] : []
}))
}
private async reload(requiredIds: readonly string[] = [], requiredPresets: readonly string[] = []): Promise<string[]> {
if (this.ownerContext.get('hmr') === undefined) return []
if (this.ownerContext.get('hmr') === undefined) {
composeEntries([readProfilePatches('dsh', this.profile)])
return []
}
const presets = this.ctx.get('agentPresets')
const before = new Map((await presets?.list() ?? []).map(row =>
[row.id, { broken: row.broken, config: this.presetConfiguration(row.id) }]))
[row.id, { broken: row.broken, config: this.presetConfiguration(row) }]))
const warnings = await reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds)
const required = new Set([...requiredIds, ...requiredPresets].map(id => this.presetId(id)))
const after = await this.ctx.get('agentPresets')?.list() ?? []
const required = new Set(after.filter(row => row.definitionEntryId !== undefined && requiredPresets.includes(row.definitionEntryId))
.map(row => row.id))
const broken = after.filter(row => row.broken !== undefined)
const failed = broken.filter(row => required.has(row.id) || before.get(row.id)?.broken !== row.broken
|| before.get(row.id)?.config !== this.presetConfiguration(row.id))
|| before.get(row.id)?.config !== this.presetConfiguration(row))
if (failed.length > 0) throw new Error(failed.map(row => `agent preset ${row.id}: ${row.broken}`).join('\n'))
return [...warnings, ...broken.map(row => `agent preset ${row.id}: ${row.broken}`)]
}
@@ -17,8 +17,9 @@ import SessionTitle, { SessionTitleProviderId, type SessionTitleProviderRequest,
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import PluginManager from '../src/index.ts'
import * as operations from '../src/operations.ts'
import * as appBoot from '@deepseek-ai/dsh-app-boot'
async function fixture(options: { title?: boolean } = {}) {
async function fixture(options: { title?: boolean; hmr?: boolean; presets?: boolean } = {}) {
const home = mkdtempSync(join(tmpdir(), 'preset-bundle-'))
let owner: Context | undefined
onTestFinished(async () => { await owner?.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
@@ -40,7 +41,7 @@ async function fixture(options: { title?: boolean } = {}) {
{ id: 'sessions', name: 'cordis:sessions' },
{ id: 'session-projections', name: 'cordis:session-projections' },
{ id: 'presets', name: 'cordis:presets', config: { default: 'standard' } },
...['standard', 'cordis'].map(id => ({ id: `preset-${id}`, name: 'cordis:preset', config: { id, plugins: [] } })),
...(options.presets === false ? [] : ['standard', 'cordis']).map(id => ({ id: `preset-${id}`, name: 'cordis:preset', config: { id, plugins: [] } })),
...options.title === true ? [
{ id: 'title', name: 'cordis:title', config: { fallbackMaxWords: 5, fallbackMaxBytes: 100, maxTitleBytes: 100 } },
{ id: 'default-title', name: 'cordis:title-provider', config: { id: 'default' } },
@@ -90,8 +91,10 @@ async function fixture(options: { title?: boolean } = {}) {
await host.plugin(PluginPackages, { resolution })
})
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
await ctx.hmr.runExclusive(async () => {})
if (options.hmr !== false) {
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
await ctx.hmr.runExclusive(async () => {})
}
onTestFinished(() => {
for (const call of calls) call.pending.resolve({ title: 'teardown', messageSeqs: call.request.messages.map(message => message.seq) })
})
@@ -182,6 +185,30 @@ it('retains a failed selection, reports affected preset failures again, and keep
expect((await ctx.agentPresets.list()).every(row => row.broken === undefined)).toBe(true)
})
it.each([{ id: 'standard' }, { id: 'standard', plugins: [] }])(
'keeps existing preset failures as warnings when an unrelated Host config is %j', async (config) => {
const { manager, bundle, ctx } = await fixture()
expect(await ctx.agentPresets.list()).toMatchObject([{ id: 'cordis', definitionEntryId: 'preset-cordis' }, { id: 'standard', definitionEntryId: 'preset-standard' }])
expect((await ctx.agentPresets.remoteExportList()).presets.every(row => !Object.hasOwn(row, 'definitionEntryId'))).toBe(true)
bundle('broken', [{ preset: 'preset-standard', insert: [{ id: 'broken', name: './plugin.mjs', config: { fail: true } }] }])
expect(await manager.setBundleEnabled('broken', true)).toMatchObject({ application: 'failed' })
bundle('ordinary', [{ insert: [{ id: 'ordinary', name: './plugin.mjs', config }] }])
expect(await manager.setBundleEnabled('ordinary', true)).toMatchObject({
application: 'applied', warnings: [expect.stringContaining('agent preset standard')],
})
},
)
it('reports invalid composition immediately without HMR while retaining the saved selection', async () => {
const { manager, dir } = await fixture({ hmr: false, presets: false })
expect(await manager.setBundleEnabled('addon', true)).toMatchObject({
application: 'failed', changed: true, error: { diagnostic: expect.stringContaining(join('addon', 'cordis.patch.yml')) as string },
})
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('addon')
expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'restart-required', changed: true })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('addon')
})
it('refuses Remove after Off while an old Agent scope retains the bundle, then removes after release', async () => {
const { manager, ctx, dir, trace } = await fixture()
expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' })
@@ -205,8 +232,8 @@ it('refuses Remove after Off while an old Agent scope retains the bundle, then r
expect(remove).toHaveBeenCalledOnce()
})
it('removes a released contribution while base presets keep using the same shared module', async () => {
const { manager, ctx, dir, addon, bundle } = await fixture()
it.each([false, true])('removes a released contribution while base presets keep using the same shared module (anonymous=%s)', async (anonymous) => {
const { manager, ctx, dir, addon } = await fixture()
const shared = join(dir, 'node_modules', 'shared-fixture')
mkdirSync(shared)
writeFileSync(join(shared, 'package.json'), JSON.stringify({ name: 'shared-fixture', version: '1.0.0', type: 'module', exports: './index.mjs' }))
@@ -218,7 +245,9 @@ it('removes a released contribution while base presets keep using the same share
if (row.id === 'preset-cordis') row.config = { id: 'cordis', plugins: [{ id: 'addon-shared', name: 'shared-fixture' }] }
}
writeFileSync(join(core, 'cordis.patch.yml'), JSON.stringify(corePatches))
bundle('addon', [{ preset: 'preset-standard', insert: [{ id: 'addon-shared', name: 'shared-fixture' }] }])
writeFileSync(join(addon, 'cordis.patch.yml'), JSON.stringify([{
preset: 'preset-standard', insert: [{ ...anonymous ? {} : { id: 'addon-shared' }, name: 'shared-fixture' }],
}]))
for (const packageDir of [core, addon]) {
const manifest = readProfileManifest('test', packageDir)
manifest.dependencies = { 'shared-fixture': '1.0.0' }
@@ -232,7 +261,7 @@ it('removes a released contribution while base presets keep using the same share
expect(ctx.agentPresets.inspectCompositions(owner.ctx)).toMatchObject([{
id: 'standard', definitionEntryId: 'preset-standard', modules: [
{ entryId: 'base-shared', moduleName: 'shared-fixture' },
{ entryId: 'addon-shared', moduleName: 'shared-fixture' },
{ entryId: anonymous ? expect.any(String) as string : 'addon-shared', moduleName: 'shared-fixture' },
],
}])
const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
@@ -251,6 +280,34 @@ it('removes a released contribution while base presets keep using the same share
expect(remove).toHaveBeenCalledOnce()
})
it('removes an unrelated bundle when another retained row with the same id has lost its module file', async () => {
const { manager, ctx, dir, addon, bundle } = await fixture()
expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' })
const owner = createScope(ctx, {})
onTestFinished(() => owner.dispose())
await ctx.agentPresets.mount(owner.ctx, 'standard')
expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' })
bundle('unrelated', [{ preset: 'preset-standard', insert: [{ id: 'shared-id', name: './plugin.mjs' }] }])
const manifest = readProfileManifest('test', dir)
manifest.dependencies = { ...manifest.dependencies, unrelated: '1.0.0' }
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
rmSync(join(addon, 'plugin.mjs'))
const remove = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({
exitCode: 0, output: '', truncated: false, logPath: join(dir, 'remove.log'),
})
onTestFinished(() => { remove.mockRestore() })
expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } })
expect(remove).not.toHaveBeenCalled()
const resolution = vi.spyOn(appBoot, 'resolvePluginResource').mockImplementationOnce(() => { throw new Error('resolver unavailable') })
onTestFinished(() => { resolution.mockRestore() })
expect(await manager.removeBundle('unrelated')).toMatchObject({ application: 'failed', error: { diagnostic: 'resolver unavailable' } })
expect(remove).not.toHaveBeenCalled()
resolution.mockRestore()
expect(await manager.removeBundle('unrelated')).toMatchObject({ application: 'applied' })
expect(remove).toHaveBeenCalledOnce()
expect(ctx.agentPresets.inspectCompositions(owner.ctx)[0]?.modules).toHaveLength(1)
})
it('retains contributed builtin rows even when no package owns their module names', async () => {
const { manager, ctx, bundle } = await fixture()
ctx.loader.builtins['retained-fixture'] = { apply() {} }
@@ -151,7 +151,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async list(): Promise<AgentPreset[]>',
description: 'Read every declared preset, including activation failures.',
parameters: [],
returns: 'Display metadata and loading diagnostics.',
returns: 'Display metadata, declaring Loader row identities, and loading diagnostics.',
},
{
signature: '@Remote(\'list\') async remoteExportList(): Promise<AgentPresetRoster>',
@@ -715,8 +715,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'configEditor',
summary: 'Persist complete raw configs and apply them through the normal Loader path.',
description: 'Persist complete raw configs and apply them through the normal Loader path.',
summary: 'Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader.',
description: 'Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader.',
methods: [
{
signature: 'entries(): Entry[]',
@@ -21,8 +21,8 @@
en: cd28d9b4f46c46a8
zh: 4921d853f98469fe
/deepseek-ai-dsh-agent-preset-registry/understand-the-implementation:
en: b87ab379168e4252
zh: eaad2a9453c744e1
en: a57f03b8891bddfa
zh: 91528f95aef11757
/deepseek-ai-dsh-agent-preset-registry/further-exploration:
en: bbae8d79844bc481
zh: 4302107fd1f460e6
@@ -59,6 +59,8 @@ A preset service provider and its consumers must share one `cordis:group` isolat
Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition.
`list()` includes the current definition’s optional `definitionEntryId`, supplied by its registering Loader row, even when activation fails. The client roster omits this Host inspection field.
`inspectCompositions()` reads all revisions retained by this registry; passing an Agent context selects its exact revision, or returns an empty list when it has no binding here. Each `AgentPresetInspection` contains a preset ID, the defining Host row ID when configured, detached active-module references with Loader row IDs and resolution bases, and leaked-service names. These identities remain attached to the retained revision after its definition changes or disappears. Inspectors query the runtime service rather than a module-local mount table; the results contain no Loader trees or fibers. [composition-inventory.ts](src/composition-inventory.ts) defines these records.
| File | Responsibility |
@@ -59,6 +59,8 @@ preset 的服务提供方及其消费方必须共享同一个 `cordis:group` 隔
激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。
`list()` 包含当前定义可选的 `definitionEntryId`,由注册它的 Loader 行提供,激活失败时也保留。Client 名单不包含这个 Host 检查字段。
`inspectCompositions()` 读取本注册表仍保留的全部代际;传入 Agent 上下文时只选择它绑定的精确代际,在此没有绑定时返回空列表。每个 `AgentPresetInspection` 包含 preset ID、已配置时的 Host 定义行 ID、纯数据形式的活动模块引用及其 Loader 行 ID 和解析基址,以及泄漏的服务名。定义变更或消失后,这些标识仍附属于被保留的代际。检查器查询运行中的服务,而不是模块级挂载表;结果不包含 Loader 树或 fiber。这些记录定义在 [composition-inventory.ts](src/composition-inventory.ts) 中。
| 文件 | 职责 |
@@ -176,13 +176,15 @@ export class AgentPresetRegistry extends TypertRemoteService {
}
/** Read every declared preset, including activation failures.
* @returns Display metadata and loading diagnostics.
* @returns Display metadata, declaring Loader row identities, and loading diagnostics.
*/
async list(): Promise<AgentPreset[]> {
const rows = await Promise.all([...this.definitions.values()].map(async (record) => {
const broken = await this.diagnostic(record)
const definitionEntryId = record.context.fiber.entry?.options.id
return {
id: record.config.id,
...definitionEntryId === undefined ? {} : { definitionEntryId },
...(record.config.name === undefined ? {} : { name: record.config.name }),
...(record.config.description === undefined ? {} : { description: record.config.description }),
...(record.config.order === undefined ? {} : { order: record.config.order }),
@@ -198,7 +200,7 @@ export class AgentPresetRegistry extends TypertRemoteService {
@Remote('list')
async remoteExportList(): Promise<AgentPresetRoster> {
const defaultId = this.defaultId
return { presets: (await this.list()).map(row => ({ ...row, isDefault: row.id === defaultId })) }
return { presets: (await this.list()).map(({ definitionEntryId: _entryId, ...row }) => ({ ...row, isDefault: row.id === defaultId })) }
}
/** Resolve an identity without starting an Agent.
@@ -3,6 +3,8 @@ import type { Volatile } from '@deepseek-ai/cordis'
/** One declared preset and its current activation failure, if any. */
export interface AgentPreset {
readonly id: string
/** Loader row that registered this current definition, when present. */
readonly definitionEntryId?: string
readonly name?: string
readonly description?: string
readonly order?: number