refactor(cmdline): trim the command-line seams to existing interfaces

The web runtime creates its dev-mode client-hmr row in the root tree
after Loader settlement with plain loader.create, deleting the vendored
Entry.enableRuntime state machine and dsh-cmdline's enableRow export.
Include declares the existing EntryGroup.key tree-carrier marker instead
of the EntryConfigResolver protocol (its own path stays literal; nothing
used a dynamic path). The launcher recognizes no app row: SIGTERM exits
0 on every surface, every boot watches its user patch layers, and the
headless runner exits through ctx.appExit, deleting ctx.headlessIo. Also
restores the vendor README rescope entry to the position the
rescope-vendor exact-edit anchor requires, fixing the master hygiene
regression.
This commit is contained in:
Turtle
2026-08-11 14:17:13 +08:00
parent 2d2b89825e
commit c598989d08
32 changed files with 312 additions and 299 deletions

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: 31a4894dbb191d2244371ca7272339e96e253053
README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a
README.md: 3d9ca350f5f8891e60cfc57c9ca89ef57d9790d3
README.zh.md: 1dcba9635b37efebeb0cc1129cc67bc7c01d0d1d

View File

@@ -4,7 +4,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}`, resolved from the injected `headlessStartup` provider). 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 ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
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.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (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 ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
## Model Experience
@@ -17,4 +17,4 @@ None; the runner adds nothing to the request prefix.
## Known Limitations and Deferred Work
- **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval.
- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the hook.
- **`ctx.appExit` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the exit request.

View File

@@ -4,7 +4,7 @@
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR热模块替换、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 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`,并提供 `headlessStartup`runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
Loader 结算后runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent智能体将任务作为普通用户消息提交并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout再经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.md)请求退出(最终 `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`,并提供 `headlessStartup`runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
## 模型体验
@@ -17,4 +17,4 @@ Loader 结算后runner 读取共享的 [`ctx.agentDefaultModel`](../../core/a
## 已知限制与延期工作
- **只提交一个任务**runner 没有用于交互式后续输入的 surface它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。
- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该钩子
- **`ctx.appExit` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该退出请求

View File

@@ -16,8 +16,10 @@ import type {} from '@deepseek-ai/dsh-agent-default-model'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
// Empty type import carries the loader Context merge for the settlement await.
// Empty type imports carry the loader Context merge for the settlement await
// and the cmdline Context merge for the appExit host value.
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-cmdline'
/** Stable Cordis plugin name. */
export const name = 'headless-runner'
@@ -41,22 +43,18 @@ interface RunOutcome {
reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
}
/**
* Process-facing effects of one run, injectable for tests. The launcher owns
* bounded tree shutdown and wires `exit()` to it.
*/
export interface HeadlessIo {
/** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */
interface HeadlessIo {
stdout: { write(chunk: string): unknown }
stderr: { write(chunk: string): unknown }
/** Request process exit with `code` after the tree disposes. */
exit(code: number): void
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Process-facing effects provided before the headless tree mounts. */
headlessIo?: HeadlessIo
}
/** The process streams the runner writes to; tests substitute captures. */
export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = {
stdout: process.stdout,
stderr: process.stderr,
}
/** Aggregate the last assistant text and turn outcome in one owned interval. */
@@ -137,13 +135,16 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
/**
* Mount the one-shot direct driver.
* @param ctx - plugin context carrying core services and the launcher-owned IO seam.
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
* @param config - validated task config.
*/
export function apply(ctx: Context, config: Config): void {
const io = ctx.headlessIo
if (io === undefined) {
throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
// Read through the global service store, not the property proxy: appExit is
// an optional host value, never an injected dependency.
const exit = ctx.get('appExit')
if (exit === undefined) {
throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts')
}
const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit }
void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
}

View File

@@ -1,6 +1,6 @@
/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
@@ -8,7 +8,10 @@ import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model'
import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { apply, Config, type HeadlessIo } from '../src/index.ts'
import { apply, Config, internals } from '../src/index.ts'
const originalInternals = { ...internals }
afterEach(() => { Object.assign(internals, originalInternals) })
interface Script {
before?(session: Session): void
@@ -93,13 +96,10 @@ async function bench(script: Script): Promise<{
let err = ''
const order: string[] = []
ctx.on('session/flush', () => { order.push('flush') })
internals.stdout = { write: (chunk: string) => { out += chunk; return true } }
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
const exited = new Promise<number>((resolve) => {
const io: HeadlessIo = {
stdout: { write: (chunk: string) => { out += chunk; return true } },
stderr: { write: (chunk: string) => { err += chunk; return true } },
exit: (code) => { order.push('exit'); resolve(code) },
}
ctx.provide('headlessIo', io)
ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
})
apply(ctx, { task: 'do the thing' })
return { code: await exited, out, err, order }
@@ -181,12 +181,10 @@ describe('headless runner', () => {
it('reports a direct Agent creation failure', async () => {
const ctx = new Context()
let err = ''
internals.stdout = { write: () => true }
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
const exited = new Promise<number>((resolve) => {
ctx.provide('headlessIo', {
stdout: { write: () => true },
stderr: { write: (chunk: string) => { err += chunk; return true } },
exit: resolve,
} satisfies HeadlessIo)
ctx.provide('appExit', resolve)
})
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
@@ -200,12 +198,10 @@ describe('headless runner', () => {
it('stringifies a non-Error Agent creation failure', async () => {
const ctx = new Context()
let err = ''
internals.stdout = { write: () => true }
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
const exited = new Promise<number>((resolve) => {
ctx.provide('headlessIo', {
stdout: { write: () => true },
stderr: { write: (chunk: string) => { err += chunk; return true } },
exit: resolve,
} satisfies HeadlessIo)
ctx.provide('appExit', resolve)
})
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
@@ -224,11 +220,9 @@ describe('headless runner', () => {
it('abandons a run when the tree is disposed during Loader settlement', async () => {
const ctx = new Context()
let exited = false
ctx.provide('headlessIo', {
stdout: { write: () => true },
stderr: { write: () => true },
exit: () => { exited = true },
} satisfies HeadlessIo)
internals.stdout = { write: () => true }
internals.stderr = { write: () => true }
ctx.provide('appExit', () => { exited = true })
const services = ctx.plugin((child: Context) => {
child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
child.provide('sessions', {} as never)
@@ -246,9 +240,9 @@ describe('headless runner', () => {
await ctx.fiber.dispose()
})
it('fails loud without the launcher-owned headlessIo seam', () => {
it('fails loud without the launcher-provided exit request', () => {
const ctx = new Context()
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo')
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.appExit')
})
it('validates config: the task is required', () => {

View File

@@ -8,8 +8,8 @@
# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an
# ordinary Cordis service. Rows configured from flags inject that service, so
# Loader resolves their expressions only after it exists. The web runtime then
# provides bind-dependent `webRuntime` values to the trust fence and client
# roster. `dsh --profile web --help` provides neither service, so no server binds.
# provides bind-dependent `webRuntime` values to the trust fence.
# `dsh --profile web --help` provides neither service, so no server binds.
# ── surface-specific values the base deliberately omits ─────────────────────
@@ -119,25 +119,17 @@
surfaceContext: true
trustedHosts: !!js ctx.webStartup.trustedHosts
# The client-plugin reload chain: a dev-only row this bundle ships off,
# which the runtime row turns on before client discovery. It is a row rather
# than a child of web-runtime because its node half is a client-side package,
# which a host-side bundle cannot import.
- id: client-hmr
name: '@deepseek-ai/dsh-client-hmr'
inject: [webStartup]
disabled: true
# ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ──
# Dual-face: this waits for the runtime row to decide whether HMR belongs
# in the first graph. The node half then scans this tree, composes
# window.__DSH_BOOT__, and serves /plugins/<id>/client.js; the browser half
# is the module table the shell kernel constructs before cordis exists
# (adopted as a plugin entry by the kernel, never fetched).
# Dual-face: the node half scans this tree, composes window.__DSH_BOOT__,
# and serves /plugins/<id>/client.js; the browser half is the module table
# the shell kernel constructs before cordis exists (adopted as a plugin
# entry by the kernel, never fetched). In development mode the web-runtime
# row creates the client-plugin reload chain (dsh-client-hmr) as a root
# tree row after Loader settlement; the incremental scan adds it to the
# roster before any page loads.
- id: modules
name: '@deepseek-ai/dsh-client-modules'
inject: [webRuntime]
# Owns both ends of the web transport: node half binds the gateway to the
# webserver under /api; browser half is the fetch/SSE client.

View File

@@ -16,7 +16,6 @@ import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import { enableRow } from '@deepseek-ai/dsh-cmdline'
import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -28,7 +27,7 @@ export const name = 'web-app'
/** This dsh installation's root, from either this package's source or built entry. */
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
const HMR_ROW_ID = 'client-hmr'
const HMR_ROW_NAME = '@deepseek-ai/dsh-client-hmr'
/** Runtime service that releases Web rows after bind-dependent values resolve. */
const WEB_RUNTIME_SERVICE = 'webRuntime'
@@ -141,19 +140,36 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex
/**
* Mount the Web runtime: dist serving, surface prompt, bash runtime
* variables, and the URL line.
* variables, the development-mode client-hmr row, and the URL line.
* @param ctx - plugin context carrying the httpServer service.
* @param config - validated {@link Config}.
* @returns nothing once the invocation's client roster and runtime contributions are registered.
*/
export async function apply(ctx: Context, config: Config): Promise<void> {
// Client discovery must start after the optional HMR row has a pending
// fiber. Otherwise its first browser graph omits the reload receiver, which
// cannot use that receiver to discover itself later.
if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID)
export function apply(ctx: Context, config: Config): void {
if (config.mode === 'development') {
// The dev reload chain is mounted as a real tree row so the browser
// roster scan includes its client half; it is a row rather than a child
// of this plugin because its node half is a client-side package, which a
// host-side bundle cannot import. Created in the root tree after Loader
// settlement: row creation must stay out of the mounting transaction,
// and a root-tree row survives user-patch reapplication of the include.
// The incremental roster scan picks it up before any page load — a
// browser arrives only after a human reads the URL line.
const loader = ctx.get('loader')
if (loader === undefined) {
ctx.logger.warn('web-app: development mode without a Loader tree mounts no client-hmr row')
} else {
void loader.await().then(async () => {
// The tree can be disposed while settlement was in flight (early
// SIGTERM); re-check before mutating it. A reload of this fiber must
// not duplicate the row a previous generation created.
if (ctx.get('loader') === undefined) return
const mounted = [...ctx.loader.entries()].some(entry => entry.options.name === HMR_ROW_NAME)
if (!mounted) await ctx.loader.create({ name: HMR_ROW_NAME })
}).catch((error: unknown) => { ctx.logger.error(error) })
}
}
const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts)
// Release dependent rows only after the optional row has a pending fiber and
// bind-dependent trust has been sampled once.
// Release dependent rows only after bind-dependent trust has been sampled once.
ctx.provide(WEB_RUNTIME_SERVICE, runtime)
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
if (config.surfaceContext) {

View File

@@ -58,17 +58,20 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
return { server, seat: () => fallback }
}
/** Install the optional HMR row the runtime sequences before client discovery. */
/** A fake Loader capturing the dev-mode row creation the runtime performs after settlement. */
function provideHmrRow(ctx: Context, settle: () => Promise<void> = async () => {}): string[] {
const updates: string[] = []
const created: string[] = []
const entries: { options: { name: string } }[] = []
ctx.provide('loader', {
entries: () => [{
options: { id: 'client-hmr' },
enableRuntime: async () => { updates.push('client-hmr') },
}],
entries: () => entries[Symbol.iterator](),
create: (options: { name: string }) => {
created.push(options.name)
entries.push({ options })
return Promise.resolve(options.name)
},
await: settle,
} as never)
return updates
return created
}
interface BashContribution {
@@ -92,13 +95,13 @@ describe('web-app runtime glue', () => {
} as never)
const enabledRows = provideHmrRow(ctx)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
await ctx.plugin(SystemPrompt, { persona: '' })
// Settle the injected registrations.
await new Promise(resolve => setTimeout(resolve, 0))
expect(seat()).toBeDefined() // frontend-static claimed the fallback
expect(enabledRows).toEqual(['client-hmr'])
expect(enabledRows).toEqual(['@deepseek-ai/dsh-client-hmr'])
expect(ctx.get('webRuntime')).toEqual({
lanAddresses: ['192.168.1.5'],
trustedHosts: ['192.168.1.5', 'lab.internal'],
@@ -119,7 +122,7 @@ describe('web-app runtime glue', () => {
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
@@ -140,7 +143,7 @@ describe('web-app runtime glue', () => {
return () => {}
},
} as never)
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] }))
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
const assembly = await ctx.systemPrompt.assemble()
@@ -155,12 +158,94 @@ describe('web-app runtime glue', () => {
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
await ctx.fiber.dispose()
})
it('creates the client-hmr row exactly once across runtime reloads', async () => {
stageDist()
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const created = provideHmrRow(ctx)
const mount = async (): Promise<() => Promise<void>> => {
const fiber = ctx.plugin((child: Context) => {
apply(child, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] }))
})
await fiber
await new Promise(resolve => setTimeout(resolve, 0))
return () => fiber.dispose()
}
const disposeFirst = await mount()
expect(created).toEqual(['@deepseek-ai/dsh-client-hmr'])
await disposeFirst()
// A reload generation must not duplicate the row the previous one created.
const disposeSecond = await mount()
expect(created).toEqual(['@deepseek-ai/dsh-client-hmr'])
await disposeSecond()
await ctx.fiber.dispose()
})
it('skips the dev row when the tree is disposed during settlement and logs a creation failure', async () => {
stageDist()
const raced = new Context()
raced.provide('httpServer', fakeHttpServer().server)
let release!: () => void
const settlement = new Promise<void>((resolve) => { release = resolve })
const created: string[] = []
const disposeLoader = raced.provide('loader', {
entries: () => [][Symbol.iterator](),
create: (options: { name: string }) => {
created.push(options.name)
return Promise.resolve(options.name)
},
await: () => settlement,
} as never)
apply(raced, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] }))
disposeLoader()
release()
await new Promise(resolve => setTimeout(resolve, 0))
expect(created).toEqual([])
await raced.fiber.dispose()
const failing = new Context()
failing.provide('httpServer', fakeHttpServer().server)
const failure = new Error('row creation failed')
failing.provide('loader', {
entries: () => [][Symbol.iterator](),
create: () => Promise.reject(failure),
await: () => Promise.resolve(),
} as never)
const errors: unknown[] = []
failing.logger.error = ((error: unknown) => { errors.push(error) }) as typeof failing.logger.error
apply(failing, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(errors).toEqual([failure])
await failing.fiber.dispose()
})
it('mounts no dev row in production and only warns without a Loader in development', async () => {
stageDist()
const prod = new Context()
prod.provide('httpServer', fakeHttpServer().server)
const created = provideHmrRow(prod)
apply(prod, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(created).toEqual([])
await prod.fiber.dispose()
const bare = new Context()
bare.provide('httpServer', fakeHttpServer().server)
const warnings: string[] = []
bare.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof bare.logger.warn
apply(bare, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] }))
expect(warnings).toEqual(['web-app: development mode without a Loader tree mounts no client-hmr row'])
// Let the vitest invariant host settle before tearing the root down.
await new Promise(resolve => setTimeout(resolve, 0))
await bare.fiber.dispose()
})
it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => {
stageDist()
// Settlement path: the line waits for loader.await() so supervisors can
@@ -171,7 +256,7 @@ describe('web-app runtime glue', () => {
const settlement = new Promise<void>((resolve) => { release = resolve })
provideHmrRow(settled, () => settlement)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
release!()
@@ -185,7 +270,7 @@ describe('web-app runtime glue', () => {
const failed = new Context()
failed.provide('httpServer', fakeHttpServer().server)
provideHmrRow(failed, async () => { throw new Error('boot failed') })
await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
await failed.fiber.dispose()
@@ -201,7 +286,7 @@ describe('web-app runtime glue', () => {
let releaseTorn: () => void
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
provideHmrRow(torn, () => tornSettlement)
await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
await child.dispose() // the httpServer service goes away
releaseTorn!()
await new Promise(resolve => setTimeout(resolve, 0))
@@ -217,7 +302,7 @@ describe('web-app runtime glue', () => {
const { server } = fakeHttpServer()
Object.defineProperty(server, 'port', { get: () => undefined })
ctx.provide('httpServer', server)
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')