Files
deepseek-harness/packages/boot/app-boot/src/compose-stack.ts
T
Yichen Jiang aaf58eb5bf fix(boot): own the rows a config override sets, queue recompositions, and let records follow the configuration
Second review round on the contained-group composition:

- Ownership scanned only `insert` rows, so an external bundle could set a
  built-in row's id as a child through `{ id: own, config: [...] }` and the
  Loader re-parented that row under the bundle while `originOf` still named
  the built-in layer. The rows an id-targeted patch sets as a group's config
  now count as the layer's own: an external bundle that sets an id another
  layer owns is left out whole, a built-in layer that takes another layer's
  id fails loud, and a layer restating its own child is not a repeat.
- `recompose` read the committed profile before awaiting the update, so a
  watcher firing while a bundle was being enabled composed the tree before
  the enable and applied it after; the plugin vanished while `layers` kept
  listing it. Recompositions now queue, each reading what the previous one
  committed, and a rejection stays its own caller's outcome.
- A patch that replaced a built-in target's config removed the wrapper the
  bundle had put there, yet later inserts still targeted the detached
  wrapper and vanished. The composition forgets the wrapper on such an
  override and creates a new one under the same id.
- Generated wrapper ids bypassed the duplicate check, so a bundle declaring
  a row spelled like a wrapper produced two nodes with one id. Declared and
  generated ids now go through one registration.
- Failure records outlived their rows when a bundle update dropped a failed
  row without unmounting the group, or when a pending row sat in a plain
  nested group whose id the record named. A contained group now drops the
  records of rows its configuration no longer names after each update, and
  the audit records every row under the contained group that isolates it.
- `applyEntryPatches` assigned override values by reference, so applying a
  patch list carried a later insert's rows back into the patch object; the
  vendored include now clones object-valued overrides as it does inserts.
2026-09-07 11:28:19 +08:00

223 lines
10 KiB
TypeScript

/**
* The profile patch stack composed with tree-wide row-id ownership. Entry ids
* are unique per Loader tree, and the vendored group `create()` re-parents an
* existing id instead of rejecting it, so a shared id namespace needs its
* check before anything mounts: built-in and boot-staged layers claim their
* ids first and a duplicate among or inside them fails loud; an external
* bundle whose id is already claimed, or which declares one of its own ids
* twice, is skipped whole and recorded as a conflict; a user layer's insert
* of a claimed id drops that row and records it. One composition yields the
* patches, the owner of every id, and the conflicts, and each contained
* layer is rendered once. Boot, live recomposition, and the config dump all
* compose through here, so they agree.
* @module @deepseek-ai/dsh-app-boot/compose-stack
*/
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { composeExternalLayer, isContainedLayer, type ComposedExternalLayer } from './external-bundles.ts'
import { visitPatchRows, visitRowTree } from './patch-rows.ts'
import type { ProfileLayer } from './profile.ts'
/** One user-owned patch list in the stack: the profile file, the home file, or a `--patch` overlay. */
export interface StackUserLayer {
/** How diagnostics name the layer (a file path or the flag that supplied it). */
readonly label: string
/** The layer's patches, as parsed from disk. */
readonly patches: PatchOptions[]
}
/** One row a layer could not mount because its id is already declared. */
export interface RowConflict {
/** The id declared more than once. */
readonly rowId: string
/** The module the losing row named. */
readonly moduleName: string
/** The layer that lost: a bundle's package name or a user layer's label. */
readonly layer: string
/** The external bundle that lost, when the loser is one; its whole layer is left out. */
readonly packageName?: string
/**
* The layer that already declares the id: a bundle's package name or a user
* layer's label, or the losing layer itself when it declares the id twice.
*/
readonly declaredBy: string
/** The reason as diagnostics and the plugin list state it, without the layer that lost. */
readonly message: string
}
/** The stack as the root include should mount it, with what it left out. */
export interface ComposedStack {
/** Patches in application order: bundle layers in manifest order, then the user layers as given. */
readonly patches: PatchOptions[]
/** The same patches per layer, labelled by package name or user-layer label; a skipped bundle has no entry. */
readonly layers: StackUserLayer[]
/** The bundle layer that owns each id a bundle layer introduces; rows user layers insert are not listed. */
readonly owners: ReadonlyMap<string, ProfileLayer>
/** Every row left out, in stack order. */
readonly conflicts: RowConflict[]
/** External bundles left out because a row id was already claimed or repeated. */
readonly skippedBundles: string[]
}
/** Row-id ownership across the bundle layers: who owns each id, which external bundles lost, and how the rest mount. */
export interface LayerOwnership {
/** The layer that owns each id a bundle layer introduces. */
readonly owners: Map<string, ProfileLayer>
/** The conflicts of each external bundle left out, by package name. */
readonly skipped: Map<string, RowConflict[]>
/** The composition of each contained layer that owns its ids, by package name; rendered once and mounted as is. */
readonly composed: Map<string, ComposedExternalLayer>
}
/** One conflict with its message: the id's other declarer, or the losing layer itself declaring it twice. */
function rowConflict(fields: Omit<RowConflict, 'message'>): RowConflict {
const id = JSON.stringify(fields.rowId)
const message = fields.declaredBy === fields.layer
? `row ${id} is declared twice by ${fields.layer}`
: `row ${id} is already declared by ${fields.declaredBy}`
return { ...fields, message }
}
/** The ids one inserted row carries: its own and, for a group, its children's. */
function rowIds(row: EntryOptions): string[] {
const ids: string[] = []
visitRowTree(row, (entry) => {
if (typeof entry.id === 'string') ids.push(entry.id)
})
return ids
}
/**
* Decide row-id ownership across the bundle layers. A layer introduces the
* rows it inserts and the rows its config overrides set as a group's
* children. Built-in and boot-staged layers claim first, in manifest order;
* an id two of them declare, or one inserts twice, is a defect of the shipped
* composition and throws, while a config override restating a row the same
* layer declared is that layer keeping its own child. Contained external
* layers then claim in manifest order, each rendered once here; one whose id
* is already owned, or which declares an id twice, is left out whole.
* @param layers - the profile's bundle layers, in manifest order.
* @returns the owner of every claimed id, the conflicts of each skipped bundle, and the composition of each mounted one.
* @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
*/
export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
const owners = new Map<string, ProfileLayer>()
for (const layer of layers) {
if (isContainedLayer(layer)) continue
visitPatchRows(layer.patches, (row, source) => {
if (typeof row.id !== 'string') return
const owner = owners.get(row.id)
if (owner === layer) {
if (source === 'insert') throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`)
return
}
if (owner !== undefined) {
throw new Error(`row ${JSON.stringify(row.id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
}
owners.set(row.id, layer)
})
}
const skipped = new Map<string, RowConflict[]>()
const composed = new Map<string, ComposedExternalLayer>()
for (const layer of layers) {
if (!isContainedLayer(layer)) continue
const { packageName } = layer
const composition = composeExternalLayer(layer)
const conflicts: RowConflict[] = composition.duplicates.map(({ rowId, moduleName }) => (
rowConflict({ rowId, moduleName, layer: packageName, packageName, declaredBy: packageName })
))
for (const [rowId, moduleName] of composition.rows) {
const owner = owners.get(rowId)
if (owner !== undefined) {
conflicts.push(rowConflict({ rowId, moduleName, layer: packageName, packageName, declaredBy: owner.packageName }))
}
}
if (conflicts.length > 0) {
skipped.set(packageName, conflicts)
continue
}
for (const id of composition.rows.keys()) owners.set(id, layer)
composed.set(packageName, composition)
}
return { owners, skipped, composed }
}
/**
* Compose the stack the root include mounts: every bundle layer that owns its
* ids, in manifest order, then the user layers with any insert of an already
* owned id dropped. Patches are passed by reference; callers that mount them
* clone, because the include pushes inserted rows into the tree as they are.
* @param binName - the diagnostic prefix on a thrown built-in duplicate.
* @param layers - the profile's bundle layers, in manifest order.
* @param userLayers - the user-owned layers, in application order.
* @returns the patches to mount, the owner of every bundle id, the conflicts, and the bundles left out.
* @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
*/
export function composeProfileStack(
binName: string, layers: readonly ProfileLayer[], userLayers: readonly StackUserLayer[],
): ComposedStack {
let ownership: LayerOwnership
try {
ownership = claimLayerIds(layers)
} catch (error) {
throw new Error(`${binName}: ${(error as Error).message}`)
}
const composedLayers: StackUserLayer[] = []
const conflicts: RowConflict[] = []
const skippedBundles: string[] = []
for (const layer of layers) {
const lost = ownership.skipped.get(layer.packageName)
if (lost !== undefined) {
conflicts.push(...lost)
skippedBundles.push(layer.packageName)
continue
}
const composition = ownership.composed.get(layer.packageName)
composedLayers.push({ label: layer.packageName, patches: composition === undefined ? layer.patches : composition.patches })
}
const claimed = new Map<string, string>()
for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName)
for (const userLayer of userLayers) {
const patches: PatchOptions[] = []
for (const patch of userLayer.patches) {
if (patch.insert === undefined) {
patches.push(patch)
continue
}
const kept: EntryOptions[] = []
for (const row of patch.insert) {
const ids = rowIds(row)
const taken = ids.map(id => [id, claimed.get(id)] as const).find(([, owner]) => owner !== undefined)
if (taken?.[1] !== undefined) {
conflicts.push(rowConflict({ rowId: taken[0], moduleName: row.name, layer: userLayer.label, declaredBy: taken[1] }))
continue
}
for (const id of ids) claimed.set(id, userLayer.label)
kept.push(row)
}
if (kept.length === patch.insert.length) patches.push(patch)
else if (kept.length > 0) patches.push({ ...patch, insert: kept })
}
composedLayers.push({ label: userLayer.label, patches })
}
return {
patches: composedLayers.flatMap(layer => layer.patches),
layers: composedLayers,
owners: ownership.owners,
conflicts,
skippedBundles,
}
}
/**
* One diagnostic line for a conflict, as boot and the config dump print it.
* @param conflict - the conflict to describe.
* @returns the line, without a binary-name prefix.
*/
export function formatRowConflict(conflict: RowConflict): string {
return conflict.packageName === undefined
? `${conflict.layer}: insert of ${conflict.moduleName} skipped — ${conflict.message}`
: `bundle ${conflict.packageName} left out — ${conflict.message}`
}