Files
deepseek-harness/apps/cli/src/profile-boot.ts
Turtle 273f27260d fix(ci): telemetry switch trivially satisfied without the row; coverage-lane test fixes
A custom profile that mounts no telemetry-otel row exports nothing, so
DSH_TELEMETRY_DISABLED must not fail its boot (CI exports the switch
globally, which broke the lifecycle-fixture profile). The web-app dist
resolution test accepts the fail-loud unbuilt outcome the CI coverage lane
sees before any build, and the headless spec covers the idle-anchor and
pre-start skip branches under the per-file gate.
2026-08-06 07:30:32 +08:00

235 lines
10 KiB
TypeScript

/**
* Shared profile boot for every `dsh` surface: resolve the profile, stack its
* patch layers (bundle layers in `dsh.plugins` order, the profile's own
* `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry
* switch), mount the tree over the profile's empty root config, keep the
* profile patch layer live, and wire fail-loud plus bounded shutdown.
* @module @deepseek-ai/dsh/profile-boot
*/
import { writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import {
boot,
composeEntries,
healProfilesModuleFallback,
installFailLoud,
loadOverlayPatches,
loadProfile,
watchPersonalPatches,
type Profile,
} from '@deepseek-ai/dsh-app-boot'
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
const NAME = 'dsh'
/** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
const TELEMETRY_ROW_ID = 'telemetry-otel'
/** The one-shot runner row a positional task requires and configures. */
const HEADLESS_ROW_ID = 'headless-runner'
/** The empty root entry list every profile tree patches over. */
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.plugins, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]
`
/** Root config filename inside a profile directory. */
const PROFILE_ROOT_FILENAME = 'cordis.yml'
/**
* Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
* value (including `'0'`/`'false'`) disables: a privacy switch prefers
* off-by-mistake over on-by-mistake. A composition without the telemetry row
* exports nothing, so the switch is then trivially satisfied and no patch is
* generated — custom profiles need not mount telemetry to run with the
* switch set.
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
* @param hasRow - whether the composition carries the telemetry row.
* @returns the disable patch, or `undefined` when telemetry stays enabled or is not mounted.
*/
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '' || !hasRow) return undefined
return { id: TELEMETRY_ROW_ID, disabled: true }
}
/** Load a resolved profile for `name`, healing the shared module fallback first. */
function prepareProfile(name: string): Profile {
healProfilesModuleFallback(INSTALL_ANCHOR)
const profile = loadProfile(NAME, name, INSTALL_ANCHOR)
const rootConfig = join(profile.dir, PROFILE_ROOT_FILENAME)
// The root is always rewritten to the empty list: the whole composition is
// patch layers, and the vendored Loader's tree write-back (a plugin
// self-disposing persists the current tree) can bake composed rows into
// this file — which would duplicate every bundle insert on the next boot.
// The file stays a real on-disk include root only because the Loader needs
// one to anchor `baseUrl` at the profile directory.
writeFileSync(rootConfig, PROFILE_ROOT_CONFIG)
return profile
}
/** One profile's full patch stack and the row index of its composed tree. */
interface ComposedProfile {
profile: Profile
/** Bundle + profile + --patch + flag layers, in application order. */
patches: PatchOptions[]
/** id → composed row (post-composition), for flag merges and row checks. */
rows: Map<string, { name?: string; config?: unknown }>
}
/**
* Load `name` and compose its effective patch stack. Flag patches derive from
* the pre-flag composition (`deriveFlagPatches` receives the row index of
* bundle + profile + overlay layers), then apply last, then the telemetry
* switch.
* @param name - the profile name.
* @param patchFiles - `--patch` overlay paths, in argv order.
* @param deriveFlagPatches - launcher hook turning composed rows into flag patches.
* @returns the profile, its patch stack, and the composed row index (flags included).
*/
function composeProfile(
name: string,
patchFiles: readonly string[],
deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [],
): ComposedProfile {
const profile = prepareProfile(name)
const overlayLayers = patchFiles.map(file => loadOverlayPatches(NAME, resolve(file)))
const layers = [
...profile.layers.map(layer => layer.patches),
profile.patches,
...overlayLayers,
]
const indexRows = (composedEntries: { id?: string; name?: string; config?: unknown; group?: unknown }[]): ComposedProfile['rows'] => {
const rows = new Map<string, { name?: string; config?: unknown }>()
const walk = (entries: typeof composedEntries): void => {
for (const row of entries) {
if (typeof row.id === 'string') rows.set(row.id, row)
if (row.group === true && Array.isArray(row.config)) walk(row.config as typeof composedEntries)
}
}
walk(composedEntries)
return rows
}
const flagPatches = deriveFlagPatches(indexRows(composeEntries(layers)))
layers.push(flagPatches)
const rows = indexRows(composeEntries(layers))
const patches = layers.flat()
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) patches.push(telemetryPatch)
return { profile, patches, rows }
}
/** Options for {@link runProfile}. */
export interface RunProfileOptions {
/** The profile name to boot. */
profile: string
/** `--patch` overlay paths, in argv order. */
patchFiles: readonly string[]
/** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */
deriveFlagPatches?: (rows: ComposedProfile['rows']) => PatchOptions[]
/** One-shot task text; requires the composition to mount the headless runner row. */
task?: string
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
prepare?: (ctx: Context) => Promise<void> | void
}
/**
* Boot one profile invocation end to end and leave process lifetime to the
* mounted plugins (or to the one-shot runner when `task` is present).
* @param options - profile name, overlays, flag patches, and the optional task.
* @returns the settled root context and the shutdown controller.
*/
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches)
if (options.task !== undefined) {
if (!composed.rows.has(HEADLESS_ROW_ID)) {
throw new Error(
`dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row `
+ '(the headless profile does)',
)
}
composed.patches.push({ id: HEADLESS_ROW_ID, config: { task: options.task } })
} else if (composed.rows.has(HEADLESS_ROW_ID)) {
// The inverse misuse: a one-shot composition booted without its task
// would otherwise die in the runner row's schema with a raw "required"
// error naming no fix.
throw new Error(
`dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: `
+ `dsh --profile ${options.profile} "<task>"`,
)
}
const app: { current?: Context } = {}
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
// Signals own teardown throughout the startup window, not only after boot()
// settles: an inserted front door can publish readiness before sibling rows
// finish mounting.
process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
installFailLoud(NAME, process, async () => {
await app.current?.fiber.dispose()
})
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
// Recomposition for the live profile layer: bundle layers below, overlays
// and flag patches above, so a profile edit can never displace them.
const overlayAndFlags = composed.patches.slice(
composed.profile.layers.reduce((n, layer) => n + layer.patches.length, 0)
+ composed.profile.patches.length,
)
const composeLive = (profilePatches: PatchOptions[]): PatchOptions[] => [
...composed.profile.layers.flatMap(layer => layer.patches),
...profilePatches,
...overlayAndFlags,
]
// One-shot runs exit through the runner; watching would only hold the
// process open after its exit request.
const watchProfilePatch = options.task === undefined
const ctx = await boot(NAME, rootConfig, composed.patches, async (hostCtx) => {
app.current = hostCtx
if (options.task !== undefined) {
const io: HeadlessIo = {
stdout: process.stdout,
stderr: process.stderr,
exit: (code) => { void shutdown.shutdown(code) },
}
hostCtx.provide('headlessIo', io)
}
await options.prepare?.(hostCtx)
})
app.current = ctx
// A surface can dispose the whole tree while startup was still in flight
// (early SIGTERM); the Loader service goes with it and there is nothing to
// keep live.
if (watchProfilePatch && ctx.get('loader') !== undefined) {
// Config-only HMR for the live profile patch layer: the web bundle
// disables the shared module-reload `hmr` row (its reload lifecycle is
// untested), so when the composition leaves no HMR service, mount a
// watch-only instance with no module roots — cordis.patch.yml edits stay
// live on every long-lived surface. A silent skip would break the
// documented hot-reload contract. HMR injects the timer service, which a
// bare custom profile may not mount either.
if (ctx.get('hmr') === undefined) {
if (ctx.get('timer') === undefined) {
await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
}
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
}
await watchPersonalPatches(ctx, {
binName: NAME,
filename: composed.profile.patchPath,
compose: composeLive,
})
}
return { ctx, shutdown }
}