Files
deepseek-harness/packages/boot/app-boot/src/compose-stack.ts
T
Yichen Jiang 0caceb2ced fix(boot): commit a recomposition only once the root include accepts it
`ProfileRuntime.recompose({ reloadBundles: true })` replaced its profile
before the Loader had accepted the update, so a rejected recomposition left
`current`, `layers`, and `originOf` describing a stack that never mounted.
The user patch watchers took a second path through the launcher's
`composeLive`, which wrote the new conflicts into the failure registry
before the update and kept composing from the runtime's stale profile.
Conflict records shared the registry with row failures under a fabricated
entry id and a group id that meant three different things, so the plugin
inventory attributed a conflict to the layer that won the id, and a bundle
disabled after failing kept its import and apply records forever.

The runtime now holds one committed composition — profile, stack ownership,
conflicts — and publishes a candidate only after `entry.update` resolves;
`originOf` reads the committed owners instead of recomputing them. The
watchers call `recompose` through a `reapply` callback, so every live change
goes through the one entry point. Conflicts live on the runtime with their
messages and never enter `pluginFailures`, whose records name rows that
reached the Loader; the inventory lists them under the layer that lost. A
contained group clears its rows' records when it unmounts.
2026-09-05 22:19:36 +08:00

218 lines
9.8 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 { visitInsertedRows, 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. Built-in and boot-staged
* layers claim first, in manifest order; an id two of them declare, or one
* declares twice, is a defect of the shipped composition and throws.
* 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
visitInsertedRows(layer.patches, (row) => {
if (typeof row.id !== 'string') return
const owner = owners.get(row.id)
if (owner === layer) throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`)
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}`
}