refactor(cli): discover app startup rows from injection

This commit is contained in:
Turtle
2026-08-07 15:43:26 +08:00
parent 1f0a0440f3
commit b692f38506
28 changed files with 302 additions and 283 deletions

View File

@@ -1,8 +1,8 @@
# The dsh-headless bundle patch: one-shot task mode directly over dsh-base.
# It mounts no Host, HTTP server, Web runtime, or browser plugin. The startup
# row owns the task positional (`dsh --profile headless "<task>"`) and this
# app's --help; the direct driver creates an Agent through the core registry
# and prints the final durable assistant message.
# row injects `cmdlineArgs`, owns the task positional
# (`dsh --profile headless "<task>"`) and this app's --help; the direct driver
# creates an Agent through the core registry and prints its durable result.
- id: system-prompt
config:
@@ -25,6 +25,7 @@
- id: headless-startup
name: '@deepseek-ai/dsh-headless/startup'
inject: [cmdlineArgs]
# Reads its task from the headlessStartup service after the startup row
# resolves this app's command line.

View File

@@ -33,8 +33,7 @@
"license": "BSD-3-Clause",
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml",
"entrypoint": "headless-startup"
"patch": "./cordis.patch.yml"
}
},
"dependencies": {
@@ -50,7 +49,6 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-web-app": "^0.0.1",
"@deepseek-ai/cordis": "^4.0.0-rc.7"
},
"devDependencies": {
@@ -60,7 +58,6 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-web-app": "workspace:^",
"@deepseek-ai/cordis": "^4.0.0-rc.7"
}
}

View File

@@ -4,11 +4,6 @@
* `--help` text, then provides {@link HEADLESS_STARTUP_SERVICE} with the task
* the user asked for. The runner waits for it, so a missing task is a usage
* error printed by this command instead of a schema failure inside the runner.
*
* This app layers over the web app, and a composition has exactly one
* command-line owner: the bundle patch disables the web startup row, and this
* one also provides {@link WEB_STARTUP_SERVICE} so the web rows start on their
* composed (one-shot) values.
* @module @deepseek-ai/dsh-headless/startup
*/
@@ -16,7 +11,6 @@ import { Command } from 'commander'
import type { Context } from 'cordis'
import type { EntryOptions } from '@cordisjs/plugin-loader'
import { runStartup } from '@deepseek-ai/dsh-cmdline'
import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup'
/** Stable Cordis plugin name. */
export const name = 'headless-startup'
@@ -75,5 +69,5 @@ function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): H
* @returns nothing once the runner is started, or once `--help` or a missing task requested exit.
*/
export function apply(ctx: Context): void {
runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup)
runStartup(ctx, HEADLESS_STARTUP_SERVICE, headlessCommand(), planHeadlessStartup)
}

View File

@@ -1,5 +1,5 @@
/**
* The one-shot app's entrypoint row over a REAL Loader tree: the task
* 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.
@@ -32,7 +32,7 @@ afterEach(async () => {
})
/**
* Mount the real entrypoint row over stand-ins for the runner row and one web
* 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.
* @param args - the invocation's inner arguments.
* @param options - fixture knobs for the shapes a composition can take.
@@ -48,7 +48,7 @@ async function bootStartup(
// 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, 'entrypoint.mjs'), `
writeFileSync(join(dir, 'startup.mjs'), `
export const name = 'headless-startup'
export const inject = ['cmdlineArgs']
export const apply = ctx => globalThis.__headlessStartupApply(ctx)
@@ -56,7 +56,7 @@ 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
// entrypoint reaches its own row check rather than the generic one.
// 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}]`,
@@ -66,7 +66,8 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx)
` inject: [${WEB_STARTUP_SERVICE}]`,
' disabled: true',
'- id: headless-startup',
` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`,
` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`,
' inject: [cmdlineArgs]',
'',
].join('\n'))
const observing = { write: (chunk: string) => { observed.out += chunk; return true } }

View File

@@ -7,11 +7,11 @@
#
# 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 is this bundle's
# manifest-declared entrypoint, so it runs before any of them and has already
# parsed --host/--port/--dev/--workspace-root/--trusted-host by the time their
# config is resolved. `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`,
# 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.
# ── surface-specific values the base deliberately omits ─────────────────────
@@ -85,11 +85,12 @@
config:
workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot
# This bundle's entrypoint (declared in its package.json): it owns the web
# flag family and its --help, and provides webStartup with the values this
# invocation resolved. The boot runs it before every row above.
# 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.
- id: web-startup
name: '@deepseek-ai/dsh-web-app/startup'
inject: [cmdlineArgs]
# ── layer 2: transport/service ──────────────────────────────────────────────

View File

@@ -33,8 +33,7 @@
"license": "BSD-3-Clause",
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml",
"entrypoint": "web-startup"
"patch": "./cordis.patch.yml"
}
},
"dependencies": {

View File

@@ -4,15 +4,17 @@
* manifest field). The plugin owns the browser-surface glue: it resolves
* the built frontend dist (workspace knowledge of this bundle, never user
* config), mounts the `frontend-static` fallback owner over it, registers the
* web-surface prompt section and the bash-visible web runtime variables, and
* prints the URL line when configured to. Flag-derived values (`mode`,
* `lanAddresses`, `printUrl`) arrive as launcher patches over this row.
* harness-source and web-surface prompt sections, the bash-visible web runtime
* variables, and the URL line. App command-line values arrive through the
* `webStartup` service expressions in the bundle patch.
* @module @deepseek-ai/dsh-web-app
*/
import { createRequire } from 'node:module'
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'
@@ -26,13 +28,16 @@ export const name = 'web-app'
/** The client-plugin reload chain row this bundle ships disabled, for `--dev`. */
const HMR_ROW_ID = 'client-hmr'
/** This dsh installation's root, from either this package's source or built entry. */
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
/** Services required before the web runtime can mount. */
export const inject = ['httpServer']
/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */
export type WebMode = 'production' | 'development'
/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */
/** Plugin config: composed deployment settings plus per-invocation startup values. */
export interface Config {
/** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
mode: WebMode
@@ -46,7 +51,7 @@ export interface Config {
*/
surfaceContext: boolean
/**
* LAN IPv4 addresses sampled once by the launcher when the effective bind
* LAN IPv4 addresses sampled once by the app startup row when the effective bind
* is all-interfaces — the exact snapshot the /api trust fence was
* configured with, so the printed LAN URL can never name an address the
* fence rejects. Empty on a loopback bind.
@@ -113,16 +118,17 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex
* variables, and the URL line.
* @param ctx - plugin context carrying the httpServer service.
* @param config - validated {@link Config}.
* @returns nothing once optional development rows are active and runtime contributions are registered.
*/
export function apply(ctx: Context, config: Config): void {
export async function apply(ctx: Context, config: Config): Promise<void> {
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
// The client-plugin reload chain is a row this bundle ships off, because it
// exists only in development. Turning it on belongs here rather than in the
// entrypoint: it needs the host rows this phase of the boot mounts, and the
// entrypoint runs before them.
if (config.mode === 'development') void enableRow(ctx, HMR_ROW_ID)
// startup row: it needs host services that also activate after webStartup.
if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID)
if (config.surfaceContext) {
ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, SOURCE_ROOT)
promptCtx.systemPrompt.section({
name: 'app:web-surface',
order: -98,
@@ -146,16 +152,15 @@ export function apply(ctx: Context, config: Config): void {
// sibling rows (the /api route owner) are still mounting. Await Loader
// settlement first; a hand-built tree without a Loader prints at once.
const printUrl = (): void => {
// The launcher's boot-time LAN snapshot, not a fresh sample: the printed
// The startup row's boot-time LAN snapshot, not a fresh sample: the printed
// LAN URL must name an address the /api trust fence was configured with.
const lanCandidate = config.lanAddresses[0]
const port = ctx.httpServer.port
console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`)
}
// A launcher that mounts in phases tells this row when the whole
// composition is up; Loader settlement alone would let the line print
// between phases, announcing a server whose boot can still fail. A
// hand-built tree has neither and prints at once.
// A launcher tells this row when the whole concurrent composition is up;
// this row's own activation can precede a sibling failure. A hand-built
// tree falls back to Loader settlement, or prints at once without Loader.
const settled = ctx.get('appReady') ?? ctx.get('loader')?.await()
if (settled === undefined) printUrl()
else {

View File

@@ -1,5 +1,5 @@
/**
* The web app's entrypoint row over a REAL Loader tree: every flag lands in the
* The web app's startup row over a REAL Loader tree: every flag lands in the
* `webStartup` service the web rows read, the bind it reports comes from the
* flag or from what the composition falls back to, `--help` resolves nothing,
* and a rejected argument exits without resolving anything.
@@ -39,7 +39,7 @@ afterEach(async () => {
})
/**
* Mount the real entrypoint row over a stand-in for the `webserver` row whose
* 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.
* @param args - the invocation's inner arguments.
* @param webserverConfig - the composed `webserver` row config, or `null` to omit the row.
@@ -55,7 +55,7 @@ async function bootStartup(
// 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, 'entrypoint.mjs'), `
writeFileSync(join(dir, 'startup.mjs'), `
export const name = 'web-startup'
export const inject = ['cmdlineArgs']
export const apply = ctx => globalThis.__webStartupApply(ctx)
@@ -82,7 +82,8 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
` inject: [${WEB_STARTUP_SERVICE}]`,
' disabled: true',
'- id: web-startup',
` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`,
` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`,
' inject: [cmdlineArgs]',
'',
].join('\n'))
const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
@@ -155,7 +156,7 @@ describe('web startup', () => {
})
it('fails the boot when the composition lost the row whose bind it reads', async () => {
// The bundle patch and this entrypoint must agree on the row set; a
// The bundle patch and this startup row must agree on the row set; a
// missing row would otherwise silently drop the flag that targets it.
await expect(bootStartup([], null))
.rejects.toThrow('the web composition has no waiting "webserver" row to configure')

View File

@@ -2,7 +2,7 @@
* Web runtime glue behavior: dist resolution through the bundle's own hook,
* the frontend-static child claiming the fallback seat, the web-surface
* prompt section and bash runtime variables, and URL-line printing with the
* launcher's LAN snapshot.
* app startup row's LAN snapshot.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
@@ -68,15 +68,25 @@ describe('web-app runtime glue', () => {
return () => {}
},
} as never)
const hmrUpdates: unknown[] = []
ctx.provide('loader', {
entries: () => [{
options: { id: 'client-hmr' },
update: async (options: unknown) => { hmrUpdates.push(options) },
}],
await: async () => {},
} as never)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] }))
await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] }))
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(hmrUpdates).toEqual([{ disabled: false }])
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
expect(section?.text).toContain('http://127.0.0.1:4567')
expect(section?.text).toContain('--dev')
@@ -90,7 +100,7 @@ describe('web-app runtime glue', () => {
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
@@ -111,11 +121,12 @@ describe('web-app runtime glue', () => {
return () => {}
},
} as never)
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] }))
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false)
expect(contributions).toEqual([])
await ctx.fiber.dispose()
})
@@ -125,23 +136,23 @@ describe('web-app runtime glue', () => {
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
await ctx.fiber.dispose()
})
it('waits for the launcher readiness the phased boot provides, and stays quiet when that boot failed', async () => {
it('waits for launcher readiness and stays quiet when the whole boot failed', async () => {
stageDist()
// The launcher-provided readiness wins over Loader settlement: a phased
// boot settles the Loader between phases, long before the app is up.
// Launcher readiness covers siblings that may still be mounting after
// this row itself has activated.
const ready = new Context()
ready.provide('httpServer', fakeHttpServer().server)
ready.provide('loader', { await: () => Promise.resolve() } as never)
let announce: () => void
ready.provide('appReady', new Promise<void>((resolve) => { announce = resolve }))
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
announce!()
@@ -157,7 +168,7 @@ describe('web-app runtime glue', () => {
const rejection = Promise.reject(new Error('boot failed'))
rejection.catch(() => {})
failed.provide('appReady', rejection)
apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
await failed.fiber.dispose()
@@ -173,7 +184,7 @@ describe('web-app runtime glue', () => {
const settlement = new Promise<void>((resolve) => { release = resolve })
settled.provide('loader', { await: () => settlement } as never)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
release!()
@@ -192,7 +203,7 @@ describe('web-app runtime glue', () => {
let releaseTorn: () => void
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
torn.provide('loader', { await: () => tornSettlement } as never)
apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await child.dispose() // the httpServer service goes away
releaseTorn!()
await new Promise(resolve => setTimeout(resolve, 0))
@@ -208,7 +219,7 @@ describe('web-app runtime glue', () => {
const { server } = fakeHttpServer()
Object.defineProperty(server, 'port', { get: () => undefined })
ctx.provide('httpServer', server)
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')