refactor(loader): resolve config after injected services

This commit is contained in:
Turtle
2026-08-07 17:27:38 +08:00
parent b692f38506
commit 7e3a82eacc
38 changed files with 404 additions and 306 deletions

View File

@@ -199,7 +199,7 @@ export function loadLayeredEnv(
const bootstrapIncludes = new WeakMap<Context, Entry>()
// The include's YAML dialect (`!!js` scalars become expression nodes the
// Loader interpolates against each entry's context at mount time), imported
// Loader interpolates against each entry's injection-ready context), imported
// from the include itself so patch parsing and config dumping can never drift
// from what the include mounts. User patch layers share it so they may
// reference `process.env`.
@@ -527,31 +527,6 @@ export async function mountRootInclude(
return entry
}
/**
* Re-apply the root include's patch list on a booted tree, and wait for the
* result to settle.
*
* This is how a boot mounts its composition in phases: an app's startup row
* resolves what the rest of the tree reads (`!!js ctx.get('webStartup')?.port`),
* and a row's config expressions are evaluated when the include applies them —
* so the rest of the composition must be applied after the startup rows are
* active, not before.
* @param ctx - the booted context whose root include to re-apply.
* @param patches - the full patch list for this generation.
* @returns nothing once the new generation has settled; a disposed tree is a no-op.
* @throws when the tree was booted without the root include.
*/
export async function applyRootPatches(ctx: Context, patches: readonly PatchOptions[]): Promise<void> {
const entry = bootstrapIncludes.get(ctx)
if (entry === undefined) throw new Error('dsh: applying root patches requires the root Include entry')
// A surface can dispose the whole tree while a startup row is still parsing
// (`--help`, or an early SIGTERM); there is then nothing left to mount.
if (ctx.get('loader') === undefined) return
const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config
await entry.update({ config: { ...includeConfig, patches: [...patches] } })
await ctx.get('loader')?.await()
}
/**
* The slice of `process` {@link installFailLoud} needs — injectable so tests
* exercise the handler without registering on (or exiting) the real process.

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -699,7 +699,18 @@ describe('boot', () => {
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
writeFileSync(join(dir, 'delayed.mjs'), [
'await new Promise(resolve => setTimeout(resolve, 10))',
'export function apply() {}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), [
'- id: exiting',
' name: ./exiting.mjs',
'- id: delayed',
' name: ./delayed.mjs',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('loader')).toBeUndefined()
})
@@ -712,6 +723,25 @@ describe('boot', () => {
)
})
it('labels a deferred config failure with its row and leaves the source file unchanged', async () => {
const dir = tmp()
const configPath = join(dir, 'cordis.yml')
const config = [
'- id: invalid-config',
' name: ./noop.mjs',
' config:',
' value: !!js "JSON.parse(\'invalid\')"',
'',
].join('\n')
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
writeFileSync(configPath, config)
await expect(boot(NAME, configPath)).rejects.toThrow(
'failed to apply loader entry invalid-config (./noop.mjs)',
)
expect(readFileSync(configPath, 'utf8')).toBe(config)
})
it('appends the deepest cause with its original stack to the load failure', async () => {
const dir = tmp()
writeFileSync(join(dir, 'failing.mjs'), [

View File

@@ -11,11 +11,10 @@ import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Hmr from '@deepseek-ai/cordis-plugin-hmr'
import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import {
applyRootPatches,
boot,
loadOptionalPatches,
PROFILE_PATCH_FILENAME,
@@ -110,61 +109,81 @@ function entryConfig(ctx: Context, id: string): unknown {
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
}
describe('applyRootPatches', () => {
it('mounts a later phase whose rows read what the first phase provided', async () => {
// The phased boot in one test: a row's `!!js` config is evaluated when the
// include applies it, so a value an earlier phase provided is what a later
// phase's rows read.
describe('Loader config interpolation', () => {
it("resolves Include's own !!js options", async () => {
const dir = tmp()
writeFileSync(join(dir, 'provider.mjs'), [
'export const name = "provider"',
'export function apply(ctx) { ctx.provide("phaseOne", { value: "resolved" }) }',
'',
].join('\n'))
writeFileSync(join(dir, 'reader.mjs'), [
'export const name = "reader"',
'export const inject = ["phaseOne"]',
'export function apply() {}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
const composition: PatchOptions[] = [{
insert: [
{ id: 'provider', name: './provider.mjs' },
{
id: 'reader',
name: './reader.mjs',
inject: ['phaseOne'],
config: { value: { __jsExpr: "ctx.get('phaseOne')?.value ?? 'fallback'" } },
},
],
}]
const ctx = await boot(NAME, join(dir, 'cordis.yml'), [
...structuredClone(composition),
{ id: 'reader', disabled: true },
])
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const ctx = new Context()
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href)
try {
// Phase one leaves the reader disabled, so the plugin never ran.
const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader')
expect(reader?.fiber).toBeUndefined()
await applyRootPatches(ctx, structuredClone(composition))
// Phase two evaluates its config expression against the provided value.
expect(entryConfig(ctx, 'reader')).toEqual({ value: 'resolved' })
await ctx.loader.create({
name: 'cordis:include',
config: { path: { __jsExpr: "ctx.get('includePath')" } },
})
await ctx.loader.await()
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true)
} finally {
await ctx.fiber.dispose()
}
})
it('does nothing on a tree that was already disposed', async () => {
it('waits for row injections before resolving !!js and resolves again after provider replacement', async () => {
const dir = tmp()
const ctx = await boot(NAME, writeTree(dir))
await ctx.fiber.dispose()
await expect(applyRootPatches(ctx, [])).resolves.toBeUndefined()
})
writeFileSync(join(dir, 'provider.mjs'), [
'export const name = "provider"',
'export function apply(ctx, config) { ctx.provide("phaseOne", config) }',
'',
].join('\n'))
writeFileSync(join(dir, 'reader.mjs'), [
'export const name = "reader"',
'export const inject = ["phaseOne"]',
'export function apply(ctx, config) { ctx.provide("readerResult", config) }',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
const composition: PatchOptions[] = [{
insert: [
{
// Consumer-first order proves interpolation follows injection
// readiness rather than YAML position.
id: 'reader',
name: './reader.mjs',
inject: ['phaseOne'],
config: { value: { __jsExpr: 'ctx.phaseOne.fail ? (() => { throw new Error("rejected provider") })() : ctx.phaseOne.value' } },
},
{ id: 'provider', name: './provider.mjs', config: { value: 'first' } },
],
}]
const ctx = await boot(NAME, join(dir, 'cordis.yml'), composition)
try {
expect(ctx.get('readerResult')).toEqual({ value: 'first' })
const provider = [...ctx.loader.entries()].find(entry => entry.options.id === 'provider')
expect(provider).toBeDefined()
await provider?.update({ disabled: true })
await ctx.loader.await()
expect(ctx.get('readerResult')).toBeUndefined()
await provider?.update({ config: { value: 'second' } })
await provider?.update({ disabled: false })
await ctx.loader.await()
expect(ctx.get('readerResult')).toEqual({ value: 'second' })
it('fails loud when the tree was booted without the root include', async () => {
const ctx = new Context()
await expect(applyRootPatches(ctx, [])).rejects.toThrow('requires the root Include entry')
await provider?.update({ disabled: true })
await provider?.update({ config: { fail: true } })
await provider?.update({ disabled: false })
await expect(ctx.loader.await()).rejects.toThrow('rejected provider')
expect(ctx.get('readerResult')).toBeUndefined()
await provider?.update({ disabled: true })
await provider?.update({ config: { value: 'recovered' } })
await provider?.update({ disabled: false })
await ctx.loader.await()
expect(ctx.get('readerResult')).toEqual({ value: 'recovered' })
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/cmdline/README.md
README.md: 242ba184507d88c50e0dcf2ada0a0f7714d87e28
README.zh.md: 76a76ad6090fcc28d50f9ea2a48d4e2581e361f2
README.md: cd3350678d38802c18ff26dd47214b5019b8c404
README.zh.md: ad726cd0726cbbd22736321a8c52b04e23d557fa

View File

@@ -35,7 +35,7 @@ The Loader-row injection is also its discovery declaration, so no bundle manifes
inject: [cmdlineArgs]
```
The launcher finds active rows with that injection in the composed tree and mounts them before everything else.
The launcher uses that injection only to reject arguments for a composition with no command-line owner. Loader mounts the composition once and holds each row until its own injections are active.
Every row the app configures from flags then reads what the startup row resolved, naming the key it takes and the value it falls back to:
@@ -44,19 +44,19 @@ Every row the app configures from flags then reads what the startup row resolved
name: '@deepseek-ai/dsh-host-webserver'
inject: [webStartup]
config:
host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1'
port: !!js ctx.get('webStartup')?.port ?? 3080
host: !!js ctx.webStartup.host ?? '127.0.0.1'
port: !!js ctx.webStartup.port ?? 3080
```
`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, and the rest of the composition never mounts.
`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, so rows that depend on the startup service never activate.
`plan` receives the options of every row that injects the service, for a value that has to take the composition into account: the `/api` fence authorities are the shipped example, since a bind the composition configured decides whether LAN literals are derived at all.
`plan` receives the startup context and the options of every row that injects the service, for a value that has to take the composition into account. Include still holds nested expressions raw at this point, so a plan that needs a composed fallback can interpolate the relevant row config against the pre-service startup context; the `/api` fence authorities are the shipped example.
### Why the boot has phases
### How injection orders config
A row's config expressions are evaluated when the include applies it, and a strict `ctx.get` only answers for a service whose providing fiber is already active. A composition therefore mounts in two passes: active `cmdlineArgs` consumers alone, then everything else. The rows of the later pass read live values, a `--help` exits before the second pass exists, and a user editing a live patch file re-runs that pass against services that are still up, so a flag cannot be silently reset.
Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset.
`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Call it from a row that mounts beside the one being enabled, not from the startup row: a row enabled in the first pass would wait for services the second pass has yet to mount.
`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Loader applies the enabled row's ordinary injection ordering.
### One command line, one owner

View File

@@ -35,7 +35,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字
inject: [cmdlineArgs]
```
启动器在组合结果中找出带有该注入的活跃行,并先于其他一切挂载它们
启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活
应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值:
@@ -44,19 +44,19 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字
name: '@deepseek-ai/dsh-host-webserver'
inject: [webStartup]
config:
host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1'
port: !!js ctx.get('webStartup')?.port ?? 3080
host: !!js ctx.webStartup.host ?? '127.0.0.1'
port: !!js ctx.webStartup.port ?? 3080
```
`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,组合的其余部分也从不挂载
`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,因此依赖启动服务的行不会激活
`plan` 收到的是所有注入该服务的行的选项,用于那些必须顾及组合本身的取值:随附的例子是 `/api` 栅栏 authority因为组合所配置的 bind 决定了是否要派生 LAN 字面量
`plan` 收到启动上下文,以及所有注入该服务的行的选项,用于那些必须顾及组合本身的取值。此时 Include 仍保留着嵌套表达式的原始形态,因此需要组合回退值的 plan 可以基于服务提供前的启动上下文插值相关行配置;随附的例子是 `/api` 栅栏 authority
### 为什么 boot 分阶段
### 注入如何排列配置求值
行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各个活跃的 `cmdlineArgs` 消费方,然后才是其余部分。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。
Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`Loader 索取 `webserver` 的配置之前Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从启动行:在第一趟被启用的行会去等待第二趟才挂载的服务
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。Loader 会对启用后的行应用普通的注入顺序
### 一条命令行,一个所有者

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
README.md: 45c87f0c85cbb68ad0366ea5f2c86e55fc307309
README.zh.md: 22322692450fa85a87e9faf903abee0d38968f91
README.md: 459d0f32788265d43e75922067da3c03d054f444
README.zh.md: e3ca9d13512e3a13ac71c5cda650fca958609062

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, shipped disabled until the startup row supplies the task). It mounts no Host, HTTP server, Web runtime, or browser plugin.
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected startup service). It mounts no Host, HTTP server, Web runtime, or browser plugin.
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the `headless-startup` row ([`src/startup.ts`](src/startup.ts)) reads it as the positional argument of `dsh --profile headless "task"` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), prints the app's `--help`, and rejects an invocation with no task instead of letting the runner's schema fail.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR热模块替换、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`在启动行供给任务之前以禁用状态交付)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR热模块替换、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`从注入的启动服务解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
Loader 结算后runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent智能体将任务作为普通用户消息提交并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0否则为 1。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:`headless-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`[`dsh-cmdline`](../../boot/cmdline/README.md))把它读作 `dsh --profile headless "task"` 的位置参数,打印应用自己的 `--help`,并拒绝没有任务的调用,而不是让 runner 的 schema 失败。

View File

@@ -33,4 +33,4 @@
name: '@deepseek-ai/dsh-headless'
inject: [headlessStartup]
config:
task: !!js ctx.get('headlessStartup')?.task
task: !!js ctx.headlessStartup.task

View File

@@ -64,7 +64,7 @@ function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): H
}
/**
* Resolve the task and start the runner that reads it.
* Resolve the task for the runner waiting on `headlessStartup`.
* @param ctx - plugin context carrying the command line and the Loader.
* @returns nothing once the runner is started, or once `--help` or a missing task requested exit.
*/

View File

@@ -1,8 +1,7 @@
/**
* The one-shot app's startup row over a REAL Loader tree: the task
* positional becomes the value the runner row reads, a missing task is a usage
* error, and the web service this app absorbs is provided too, so the web rows
* it rides over resolve on their own fallbacks.
* The one-shot app's startup row over a real Loader tree: the task positional
* becomes the injected runner config, while help and usage errors leave the
* runner pending.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
@@ -13,7 +12,6 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline'
import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup'
import { afterEach, describe, expect, it } from 'vitest'
import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts'
@@ -21,6 +19,7 @@ import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../
interface Observed {
exits: number[]
out: string
runnerConfig?: unknown
}
const disposers: (() => Promise<void>)[] = []
@@ -32,22 +31,20 @@ afterEach(async () => {
})
/**
* Mount the real startup row over stand-ins for the runner row and one web
* row this app absorbs, the way a profile mounts phase one.
* Mount the real startup row over a runner stand-in.
* @param args - the invocation's inner arguments.
* @param options - fixture knobs for the shapes a composition can take.
* @returns the resolved service values (absent when the app requested exit) and what the boot observed.
* @param options - fixture knobs for invalid compositions.
* @returns the resolved startup value and observed runner/process effects.
*/
async function bootStartup(
args: string[],
options: { withoutRunner?: boolean } = {},
): Promise<{ task: HeadlessStartupValues | undefined; web: unknown; observed: Observed }> {
): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-'))
const observed: Observed = { exits: [], out: '' }
writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n')
// The Loader imports a row through Node's own resolver, which cannot resolve
// this workspace's sources; the row delegates to the real plugin the test
// imported through the source-plane path mapping.
writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n')
// Loader imports through Node's resolver, so this fixture delegates to the
// source-plane plugin already imported by the test.
writeFileSync(join(dir, 'startup.mjs'), `
export const name = 'headless-startup'
export const inject = ['cmdlineArgs']
@@ -55,16 +52,11 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx)
`)
const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href
writeFileSync(join(dir, 'cordis.yml'), [
// A composition that lost the runner still injects the service, so the
// startup row reaches its own row check rather than the generic one.
options.withoutRunner === true ? '- id: displaced-runner' : '- id: headless-runner',
` name: ${rowUrl}`,
` inject: [${HEADLESS_STARTUP_SERVICE}]`,
' disabled: true',
'- id: webserver',
` name: ${rowUrl}`,
` inject: [${WEB_STARTUP_SERVICE}]`,
' disabled: true',
' config:',
' task: !!js ctx.headlessStartup.task',
'- id: headless-startup',
` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`,
' inject: [cmdlineArgs]',
@@ -73,7 +65,12 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx)
const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
internals.stdout = observing
internals.stderr = observing
;(globalThis as unknown as { __headlessStartupApply: typeof apply }).__headlessStartupApply = apply
const globals = globalThis as unknown as {
__headlessStartupApply: typeof apply
__headlessStartupObserved: Observed
}
globals.__headlessStartupApply = apply
globals.__headlessStartupObserved = observed
const ctx = new Context()
await ctx.plugin(Loader)
@@ -84,38 +81,35 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx)
disposers.push(async () => { await ctx.fiber.dispose() })
return {
task: ctx.get(HEADLESS_STARTUP_SERVICE) as HeadlessStartupValues | undefined,
web: ctx.get(WEB_STARTUP_SERVICE),
observed,
}
}
describe('headless startup', () => {
it('joins the task positional into the value the runner reads', async () => {
it('joins the task positional into the runner config', async () => {
const { task, observed } = await bootStartup(['run', 'the', 'tests'])
expect(task).toEqual({ task: 'run the tests' })
expect(observed.runnerConfig).toEqual({ task: 'run the tests' })
expect(observed.exits).toEqual([])
})
it('provides the web service it absorbed, so those rows resolve on their own fallbacks', async () => {
const { web } = await bootStartup(['task'])
expect(web).toEqual({ task: 'task' })
})
it('rejects an invocation with no task instead of failing inside the runner schema', async () => {
it('rejects an invocation with no task and leaves the runner pending', async () => {
const { task, observed } = await bootStartup([])
expect(observed.out).toContain('a task is required')
expect(task).toBeUndefined()
expect(observed.runnerConfig).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('prints its own help and resolves nothing', async () => {
it('prints its own help and leaves the runner pending', async () => {
const { task, observed } = await bootStartup(['--help'])
expect(observed.out).toContain('dsh --profile headless')
expect(task).toBeUndefined()
expect(observed.runnerConfig).toBeUndefined()
expect(observed.exits).toEqual([0])
})
it('fails the boot when the composition has no runner row to give the task to', async () => {
it('fails when the composition has no runner row', async () => {
await expect(bootStartup(['task'], { withoutRunner: true }))
.rejects.toThrow('the composition has no waiting "headless-runner" row')
})

View File

@@ -7,11 +7,10 @@
#
# Rows this app configures from flags read them from the `webStartup` service:
# each names the key it takes and the value it falls back to, so a flag wins
# over the value written beside it. The web-startup row injects `cmdlineArgs`,
# so the launcher runs it first; it has parsed --host/--port/--dev/
# --workspace-root/--trusted-host by the time those configs resolve.
# `dsh --profile web --help` therefore prints this app's own help and exits
# before the rest of the composition mounts at all.
# over the value written beside it. The web-startup row injects `cmdlineArgs`
# and provides `webStartup`; Loader delays dependent-row config interpolation
# until that service is active. `dsh --profile web --help` provides no service,
# so the server rows never activate.
# ── surface-specific values the base deliberately omits ─────────────────────
@@ -85,9 +84,8 @@
config:
workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot
# This app's command-line startup row: its `cmdlineArgs` injection makes the
# launcher mount it first. It owns the web flag family and its --help, and
# provides webStartup with the values this invocation resolved.
# This app's command-line startup row. It owns the web flag family and its
# --help, and provides webStartup to the rows that inject it.
- id: web-startup
name: '@deepseek-ai/dsh-web-app/startup'
inject: [cmdlineArgs]

View File

@@ -11,7 +11,7 @@
import { networkInterfaces } from 'node:os'
import { Command } from 'commander'
import type { Context } from 'cordis'
import type { EntryOptions } from '@cordisjs/plugin-loader'
import { interpolate, type EntryOptions } from '@cordisjs/plugin-loader'
import { runStartup } from '@deepseek-ai/dsh-cmdline'
/** Stable Cordis plugin name. */
@@ -50,6 +50,20 @@ export interface WebStartupValues {
/** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */
const ALL_INTERFACES_HOST = '0.0.0.0'
/**
* Read the deployment trust list before its row mounts and validates config.
* @param config - the connection row's config resolved before `webStartup` exists.
* @returns its configured authorities, or an empty list when absent.
* @throws when the file-backed config is not an array of strings.
*/
function configuredTrustedHosts(config: unknown): string[] {
const value = (config as { trustedHosts?: unknown } | undefined)?.trustedHosts
if (value === undefined) return []
const valid = Array.isArray(value) && value.every((entry: unknown) => typeof entry === 'string')
if (!valid) throw new Error('web-startup: the composed connection trustedHosts must be an array of strings')
return value
}
/**
* Non-internal IPv4 interface addresses of this machine — the IP-literal
* authorities an all-interfaces bind is reachable by on the LAN.
@@ -118,19 +132,33 @@ Examples:
* Turn the parsed flags into the values the web rows read.
* @param program - the parsed web command.
* @param rows - the waiting rows' composed options, in tree order.
* @param ctx - the startup context used to resolve composed fallbacks before `webStartup` exists.
* @returns the web rows' service value.
*/
function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebStartupValues {
function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Context): WebStartupValues {
const options = program.opts<WebOptions>()
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
}
const webserver = rows.find(row => row.id === 'webserver')
if (webserver === undefined) throw new Error('web-startup: the web composition has no waiting "webserver" row to configure')
// The bind this invocation ends on: the flag, else what the row falls back
// to, which is the same literal its config expression names.
const bindHost = options.host ?? (webserver.config as { host?: string } | undefined)?.host
const { lanAddresses, trustedHosts } = resolveLanTrust(bindHost, options.trustedHost ?? [])
const row = (id: string): EntryOptions => {
const found = rows.find(candidate => candidate.id === id)
if (found === undefined) throw new Error(`web-startup: the web composition has no waiting ${JSON.stringify(id)} row to configure`)
return found
}
const webserver = row('webserver')
row('api-gateway')
row('web-runtime')
const connection = row('connection')
// Include preserves nested row expressions until their own injections are
// active. Resolve just the composed fields this startup plan needs against
// the pre-service context, where their `ctx.get('webStartup')` fallback wins.
const webserverConfig = interpolate(ctx, webserver.config) as { host?: string } | undefined
const connectionConfig: unknown = interpolate(ctx, connection.config)
const bindHost = options.host ?? webserverConfig?.host
const sampled = resolveLanTrust(bindHost, options.trustedHost ?? [])
// Preserve deployment authorities when invocation-derived LAN literals or
// explicit extras become the runtime value read by the connection row.
const composedTrusted = configuredTrustedHosts(connectionConfig)
return {
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
@@ -138,15 +166,15 @@ function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebSta
// mode and lanAddresses describe this invocation, never the deployment, so
// they are resolved on every boot.
mode: options.dev === true ? 'development' : 'production',
trustedHosts,
lanAddresses,
trustedHosts: [...composedTrusted, ...sampled.trustedHosts],
lanAddresses: sampled.lanAddresses,
}
}
/**
* Resolve the web flag family and start the rows that read it.
* Resolve the web flag family for rows waiting on `webStartup`.
* @param ctx - plugin context carrying the command line and the Loader.
* @returns nothing once the web rows are started, or once `--help` requested exit.
* @returns nothing once the values are provided, or once `--help` requested exit.
*/
export function apply(ctx: Context): void {
runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup)

View File

@@ -40,14 +40,16 @@ afterEach(async () => {
/**
* Mount the real startup row over a stand-in for the `webserver` row whose
* composed bind it reads, the way a profile mounts phase one.
* composed bind it reads before the dependent rows activate.
* @param args - the invocation's inner arguments.
* @param webserverConfig - the composed `webserver` row config, or `null` to omit the row.
* @param trustedHosts - authorities the composed connection row already carries, or `null` when it carries none.
* @returns the resolved service value (absent when the app requested exit) and what the boot observed.
*/
async function bootStartup(
args: string[],
webserverConfig: Record<string, unknown> | null = { host: '127.0.0.1', port: 3080 },
trustedHosts: unknown = [],
): Promise<{ values: WebStartupValues | undefined; observed: Observed; ctx: Context }> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-'))
const observed: Observed = { exits: [], out: '' }
@@ -68,8 +70,20 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
` inject: [${WEB_STARTUP_SERVICE}]`,
' disabled: true',
' config:',
...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`),
...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.${key} ?? ${JSON.stringify(value)}`),
],
'- id: connection',
` name: ${rowUrl}`,
` inject: [${WEB_STARTUP_SERVICE}]`,
' disabled: true',
...trustedHosts === null ? [] : [
' config:',
` trustedHosts: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.trustedHosts ?? ${JSON.stringify(trustedHosts)}`,
],
'- id: api-gateway',
` name: ${rowUrl}`,
` inject: [${WEB_STARTUP_SERVICE}]`,
' disabled: true',
// A second reader keeps the composition honest when the webserver row is
// the one under test: the service must still have someone to serve.
'- id: web-runtime',
@@ -121,13 +135,36 @@ describe('web startup', () => {
expect(values).not.toHaveProperty('port')
})
it('derives the LAN literals for an all-interfaces bind, and the extras with them', async () => {
const { values } = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal'])
expect(values?.trustedHosts).toEqual(['192.168.1.5', 'lab.internal'])
it('adds LAN literals and explicit extras after the composed fence authorities', async () => {
const { values } = await bootStartup(
['--host', '0.0.0.0', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9'],
{ host: '127.0.0.1', port: 3080 },
['profile.internal'],
)
expect(values?.trustedHosts).toEqual([
'profile.internal', '192.168.1.5', 'lab.internal', 'lab-2.internal', '10.0.0.9',
])
// Display gets the same single sample the fence was configured with.
expect(values?.lanAddresses).toEqual(['192.168.1.5'])
})
it('starts from an empty trust list when the composed connection row names none', async () => {
const { values } = await bootStartup(
['--trusted-host', 'lab.internal'],
{ host: '127.0.0.1', port: 3080 },
null,
)
expect(values?.trustedHosts).toEqual(['lab.internal'])
})
it.each([
'profile.internal',
['profile.internal', 1],
])('rejects an invalid composed trust list before transforming it (%j)', async (trustedHosts) => {
await expect(bootStartup([], { host: '127.0.0.1', port: 3080 }, trustedHosts))
.rejects.toThrow('the composed connection trustedHosts must be an array of strings')
})
it('reads the composed bind when no flag names one, so a configured 0.0.0.0 still derives them', async () => {
const { values } = await bootStartup([], { host: '0.0.0.0', port: 3080 })
expect(values?.lanAddresses).toEqual(['192.168.1.5'])
@@ -135,8 +172,8 @@ describe('web startup', () => {
it('reports the development mode for --dev, which the web runtime reads', async () => {
const { values } = await bootStartup(['--dev'])
// The runtime row is what turns the reload chain on, in the phase whose
// host rows it needs; this row only reports the mode.
// The runtime row turns the reload chain on after its host dependencies
// activate; this row only reports the mode.
expect(values?.mode).toBe('development')
})