refactor(cli): discover app startup rows from injection
This commit is contained in:
@@ -39,7 +39,6 @@ export {
|
||||
PROFILES_DIR,
|
||||
readProfileManifest,
|
||||
resolveBundleDir,
|
||||
resolveEntrypoints,
|
||||
resolveProfileDir,
|
||||
writeProfileManifest,
|
||||
type DshBundleManifest,
|
||||
@@ -532,10 +531,10 @@ export async function mountRootInclude(
|
||||
* 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 entrypoint row
|
||||
* 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 entrypoints are
|
||||
* 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.
|
||||
@@ -545,7 +544,7 @@ export async function mountRootInclude(
|
||||
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 an entrypoint is still parsing
|
||||
// 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
|
||||
|
||||
@@ -42,16 +42,6 @@ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
|
||||
export interface DshBundleManifest {
|
||||
/** The patch layer this bundle exports, relative to its package root. */
|
||||
patch: string
|
||||
/**
|
||||
* Id of the row in that patch which must run before every other row of the
|
||||
* composition — the app's entrypoint.
|
||||
*
|
||||
* An entrypoint resolves what the rest of the tree needs in order to be
|
||||
* configured at all (the command line an app was invoked with), and provides
|
||||
* it as a service. The boot mounts entrypoints alone first, so by the time
|
||||
* any other row's config is resolved, `ctx.get('<service>')` answers.
|
||||
*/
|
||||
entrypoint?: string
|
||||
}
|
||||
|
||||
/** The profile half of the `dsh` manifest section: what a profile directory composes. */
|
||||
@@ -89,37 +79,6 @@ export interface ProfileLayer {
|
||||
patchPath: string
|
||||
/** The parsed patch list. */
|
||||
patches: PatchOptions[]
|
||||
/** Row id this bundle declares as its entrypoint, when it has one. */
|
||||
entrypoint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The composition's entrypoint row ids, in bundle order.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param profile - the loaded profile.
|
||||
* @param rows - the composed rows, so an entrypoint a later layer removed or
|
||||
* disabled is not mounted (the one-shot bundle takes over the web one this way).
|
||||
* @returns the row ids to mount before the rest of the tree.
|
||||
* @throws when a bundle declares an entrypoint its own patch never inserts.
|
||||
*/
|
||||
export function resolveEntrypoints(
|
||||
binName: string,
|
||||
profile: Profile,
|
||||
rows: readonly { id?: string; disabled?: boolean | null }[],
|
||||
): string[] {
|
||||
const entrypoints: string[] = []
|
||||
for (const layer of profile.layers) {
|
||||
if (layer.entrypoint === undefined) continue
|
||||
const row = rows.find(candidate => candidate.id === layer.entrypoint)
|
||||
if (row === undefined) {
|
||||
throw new Error(
|
||||
`${binName}: bundle ${JSON.stringify(layer.packageName)} declares entrypoint ${JSON.stringify(layer.entrypoint)}, `
|
||||
+ 'which the composed tree has no row for',
|
||||
)
|
||||
}
|
||||
if (row.disabled !== true) entrypoints.push(layer.entrypoint)
|
||||
}
|
||||
return entrypoints
|
||||
}
|
||||
|
||||
/** A loaded profile: resolved bundle layers plus the user's own patch layer. */
|
||||
@@ -432,14 +391,7 @@ export function loadProfile(
|
||||
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
|
||||
}
|
||||
const patchPath = join(packageDir, declared)
|
||||
const entrypoint = bundleManifest.dsh?.bundle?.entrypoint
|
||||
return {
|
||||
packageName,
|
||||
packageDir,
|
||||
patchPath,
|
||||
patches: loadOverlayPatches(binName, patchPath),
|
||||
...entrypoint === undefined ? {} : { entrypoint },
|
||||
}
|
||||
return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
|
||||
})
|
||||
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
|
||||
const patches = options.userLayer !== false && existsSync(patchPath)
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
PROFILE_TEMPLATES,
|
||||
readProfileManifest,
|
||||
resolveBundleDir,
|
||||
resolveEntrypoints,
|
||||
resolveProfileDir,
|
||||
writeProfileManifest,
|
||||
} from '../src/index.ts'
|
||||
@@ -198,37 +197,6 @@ describe('loadProfile', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEntrypoints', () => {
|
||||
const profile = (layers: { packageName: string; entrypoint?: string }[]): Parameters<typeof resolveEntrypoints>[1] => ({
|
||||
name: 'p',
|
||||
dir: '/p',
|
||||
patchPath: '/p/cordis.patch.yml',
|
||||
patches: [],
|
||||
layers: layers.map(layer => ({ ...layer, packageDir: '/b', patchPath: '/b/cordis.patch.yml', patches: [] })),
|
||||
})
|
||||
|
||||
it('names each bundle entrypoint in bundle order', () => {
|
||||
expect(resolveEntrypoints(
|
||||
'dsh',
|
||||
profile([{ packageName: 'a' }, { packageName: 'b', entrypoint: 'b-startup' }, { packageName: 'c', entrypoint: 'c-startup' }]),
|
||||
[{ id: 'b-startup' }, { id: 'c-startup' }, { id: 'other' }],
|
||||
)).toEqual(['b-startup', 'c-startup'])
|
||||
})
|
||||
|
||||
it('skips an entrypoint a later layer disabled, which is how one app takes over another', () => {
|
||||
expect(resolveEntrypoints(
|
||||
'dsh',
|
||||
profile([{ packageName: 'web', entrypoint: 'web-startup' }, { packageName: 'one-shot', entrypoint: 'one-shot-startup' }]),
|
||||
[{ id: 'web-startup', disabled: true }, { id: 'one-shot-startup' }],
|
||||
)).toEqual(['one-shot-startup'])
|
||||
})
|
||||
|
||||
it('fails loud when a bundle declares an entrypoint its patch never inserts', () => {
|
||||
expect(() => resolveEntrypoints('dsh', profile([{ packageName: 'b', entrypoint: 'absent' }]), [{ id: 'other' }]))
|
||||
.toThrow('declares entrypoint "absent", which the composed tree has no row for')
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeEntries', () => {
|
||||
it('applies layers over an empty root and reports skipped patches', () => {
|
||||
const warnings: string[] = []
|
||||
|
||||
@@ -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: acdc3a310f0062f1b27dbd74d20b81e1a8198bca
|
||||
README.zh.md: 365a2c7f3cdf5710ce7e3abe76f009dc1ba4217f
|
||||
README.md: 242ba184507d88c50e0dcf2ada0a0f7714d87e28
|
||||
README.zh.md: 76a76ad6090fcc28d50f9ea2a48d4e2581e361f2
|
||||
|
||||
@@ -14,9 +14,9 @@ A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which
|
||||
|
||||
An embedding host with no command line provides an empty list; that is the honest answer, not a missing value.
|
||||
|
||||
## Entrypoints, and the service their app reads
|
||||
## Startup rows, and the service their app reads
|
||||
|
||||
An app reads those arguments from its **entrypoint row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`:
|
||||
An app reads those arguments from its **startup row** — a Loader row and plugin that inject `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`:
|
||||
|
||||
```ts ignore
|
||||
export const name = 'web-startup'
|
||||
@@ -27,13 +27,17 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
```
|
||||
|
||||
The bundle's `package.json` names that row, which is what makes the boot mount it before everything else:
|
||||
The Loader-row injection is also its discovery declaration, so no bundle manifest field is needed:
|
||||
|
||||
```json
|
||||
{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } }
|
||||
```yaml
|
||||
- id: web-startup
|
||||
name: '@deepseek-ai/dsh-web-app/startup'
|
||||
inject: [cmdlineArgs]
|
||||
```
|
||||
|
||||
Every row the app configures from flags then reads what the entrypoint resolved, naming the key it takes and the value it falls back to:
|
||||
The launcher finds active rows with that injection in the composed tree and mounts them before everything else.
|
||||
|
||||
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:
|
||||
|
||||
```yaml
|
||||
- id: webserver
|
||||
@@ -50,13 +54,13 @@ Every row the app configures from flags then reads what the entrypoint resolved,
|
||||
|
||||
### Why the boot has phases
|
||||
|
||||
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: the entrypoints alone, then everything else — which is exactly what the manifest declaration buys. The rows of a 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.
|
||||
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.
|
||||
|
||||
`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 an entrypoint: 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). 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.
|
||||
|
||||
### One command line, one owner
|
||||
|
||||
A composition has exactly one command-line owner. An app that layers over another one disables the underlying entrypoint row and names both services, so the rows it absorbed start on the values their own fallbacks name — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md).
|
||||
A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and names both services, so the rows it absorbed start on the values their own fallbacks name — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md).
|
||||
|
||||
An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure.
|
||||
|
||||
@@ -71,5 +75,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`.
|
||||
- **A startup service has no declared owner.** The rows name it and an entrypoint provides it; nothing links the two statically, so a bundle that ships reading rows without its entrypoint fails at settlement (pending entries naming the service) rather than at load.
|
||||
- **A startup service has no declared owner.** Reading rows name it and a `cmdlineArgs` consumer provides it; nothing links those two injections statically, so a bundle that ships reading rows without its startup row fails at settlement (pending entries naming the service) rather than at load.
|
||||
- **A user patch that replaces a row's whole `config` drops its expressions.** A flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning.
|
||||
|
||||
@@ -14,9 +14,9 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属
|
||||
|
||||
没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。
|
||||
|
||||
## 入口点,以及它的应用所读取的服务
|
||||
## 启动行,以及它的应用所读取的服务
|
||||
|
||||
应用从自己的**入口点行**读取这些参数:入口点行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件:
|
||||
应用从自己的**启动行**读取这些参数:这是一个在 Loader 行与插件中都注入 `cmdlineArgs`,并调用 `runStartup(ctx, service, program, plan)` 的插件:
|
||||
|
||||
```ts ignore
|
||||
export const name = 'web-startup'
|
||||
@@ -27,13 +27,17 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
```
|
||||
|
||||
组合包的 `package.json` 点名那一行,这正是 boot 先于其他一切挂载它的依据:
|
||||
Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字段:
|
||||
|
||||
```json
|
||||
{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } }
|
||||
```yaml
|
||||
- id: web-startup
|
||||
name: '@deepseek-ai/dsh-web-app/startup'
|
||||
inject: [cmdlineArgs]
|
||||
```
|
||||
|
||||
应用用 flag 配置的每一行随后读取入口点解析出的取值,各自点名自己取用的键,以及回退时使用的值:
|
||||
启动器在组合结果中找出带有该注入的活跃行,并先于其他一切挂载它们。
|
||||
|
||||
应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值:
|
||||
|
||||
```yaml
|
||||
- id: webserver
|
||||
@@ -50,13 +54,13 @@ export function apply(ctx: Context): void {
|
||||
|
||||
### 为什么 boot 分阶段
|
||||
|
||||
行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各入口点,然后才是其余部分——这正是 manifest(元数据清单)声明所换来的东西。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。
|
||||
行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各个活跃的 `cmdlineArgs` 消费方,然后才是其余部分。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。
|
||||
|
||||
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从入口点:在第一趟被启用的行会去等待第二趟才挂载的服务。
|
||||
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从启动行:在第一趟被启用的行会去等待第二趟才挂载的服务。
|
||||
|
||||
### 一条命令行,一个所有者
|
||||
|
||||
一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的入口点行,并同时点名两个服务,使它吸收过来的行按各自回退值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。
|
||||
一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并同时点名两个服务,使它吸收过来的行按各自回退值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。
|
||||
|
||||
树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。
|
||||
|
||||
@@ -71,5 +75,5 @@ export function apply(ctx: Context): void {
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。
|
||||
- **启动服务没有声明所有者**:各行点名它,由入口点提供它;两者之间没有静态关联,因此交付了读取行却缺少对应入口点的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。
|
||||
- **启动服务没有声明所有者**:读取行点名它,由 `cmdlineArgs` 消费方提供它;这两种注入之间没有静态关联,因此交付了读取行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。
|
||||
- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-cmdline",
|
||||
"description": "Command-line seam between a dsh launcher and surface bundles: the cmdlineArgs service exposing the invocation's inner arguments, the startup host for contributing flag-derived config patches, and the commander adapter startup plugins share",
|
||||
"description": "Command-line seam between a dsh launcher and app bundles: cmdlineArgs exposes inner arguments, while injected startup rows parse them into app-owned runtime services",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -28,7 +28,6 @@
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-include": "^1.0.4",
|
||||
"@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -10,14 +10,12 @@
|
||||
* An app consumes those arguments from a **startup plugin**: a row that
|
||||
* injects `cmdlineArgs` and calls {@link runStartup}. What that plugin resolves
|
||||
* becomes its own service, and the rows it configures read the values from
|
||||
* there — `port: !!js ctx.get('webStartup')?.port ?? 3080` — so a flag beats
|
||||
* there — `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats
|
||||
* the value written beside it. Nothing is handed back to the launcher.
|
||||
*
|
||||
* Those rows ship `disabled: true`, because a row's config is resolved when the
|
||||
* Loader creates its fiber and a strict `ctx.get` only sees a service whose
|
||||
* providing fiber is already active. The startup plugin enables them once its
|
||||
* own fiber is active, and keeps them enabled when a recomposition of the tree
|
||||
* puts them back.
|
||||
* Loader delays each row's config interpolation until its declared injections
|
||||
* are active. A startup row consumes `cmdlineArgs`, provides the app's resolved
|
||||
* values, and thereby activates only the rows that depend on those values.
|
||||
* @module @deepseek-ai/dsh-cmdline
|
||||
*/
|
||||
|
||||
@@ -70,10 +68,9 @@ export interface CmdlineHost {
|
||||
* Settles when the launcher has finished mounting, which a row that
|
||||
* publishes readiness (a URL line a supervisor waits for) must await.
|
||||
*
|
||||
* A boot mounts in phases, so Loader settlement no longer means the whole
|
||||
* composition is up: a row mounted in a later phase can observe a settled
|
||||
* tree while rows beside it have yet to mount, or while the phase that
|
||||
* mounted it is already rolling back. Rejects with the boot failure.
|
||||
* Loader mounts sibling rows concurrently, so one row can become active
|
||||
* while another is still mounting or while the whole boot is rolling back.
|
||||
* Rejects with the boot failure.
|
||||
*/
|
||||
ready?: Promise<void>
|
||||
}
|
||||
@@ -92,6 +89,20 @@ export function provideCmdline(ctx: Context, host: CmdlineHost): void {
|
||||
if (host.ready !== undefined) ctx.provide('appReady', host.ready)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether an active row consumes the launcher's command line.
|
||||
*
|
||||
* The Loader-row injection is the declaration: an active row that names
|
||||
* `cmdlineArgs` owns startup for this composition. No bundle manifest field or
|
||||
* plugin import is needed, so an out-of-tree app adds its command line by
|
||||
* adding the same injection its startup plugin already requires.
|
||||
* @param rows - the composed Loader rows.
|
||||
* @returns whether this composition has a command-line owner.
|
||||
*/
|
||||
export function hasCmdlineConsumer(rows: readonly EntryOptions[]): boolean {
|
||||
return rows.some(row => row.disabled !== true && waitsForAny(row.inject, ['cmdlineArgs']))
|
||||
}
|
||||
|
||||
/** The process streams commander output is written to; production writes to the process. */
|
||||
export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = {
|
||||
stdout: process.stdout,
|
||||
@@ -107,26 +118,26 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w
|
||||
* to reject the invocation with a usage message instead of throwing.
|
||||
* @param program - the parsed commander program.
|
||||
* @param rows - the waiting rows' composed options, in tree order.
|
||||
* @param ctx - the startup row's context, for resolving composed fallbacks before the service exists.
|
||||
* @returns the service value the app's rows read; `undefined` keys let a row's
|
||||
* own fallback stand.
|
||||
*/
|
||||
export type StartupPlan<T = unknown> = (program: Command, rows: readonly EntryOptions[]) => T
|
||||
export type StartupPlan<T = unknown> = (program: Command, rows: readonly EntryOptions[], ctx: Context) => T
|
||||
|
||||
/**
|
||||
* Run one app's startup: parse the invocation's inner arguments with the app's
|
||||
* own commander program, provide the resolved values as `service`, and start
|
||||
* the rows that were waiting for it.
|
||||
* own commander program and provide the resolved values as `service`. The
|
||||
* Loader then activates the rows that were waiting for the provided service.
|
||||
*
|
||||
* The rows read their values from the service, so nothing is written into
|
||||
* their config from here: a row asks for `ctx.get('<service>')?.<key>` and
|
||||
* falls back to the value written beside it, which is why a flag wins. They are
|
||||
* enabled from inside an injection on the service itself, because a strict
|
||||
* `ctx.get` only resolves a service whose providing fiber is already active,
|
||||
* and re-enabled whenever a recomposition of the tree disables them again — a
|
||||
* user editing a live patch file must not take the app down.
|
||||
* their config from here: a row asks for `ctx.<service>.<key>` and
|
||||
* falls back to the value written beside it, which is why a flag wins. Loader
|
||||
* resolves a row's config only after its injections are active. A live
|
||||
* recomposition reads the service that remains active, so editing a user patch
|
||||
* cannot reset an invocation value.
|
||||
*
|
||||
* Help, version, and rejected arguments are terminal for the process: the text
|
||||
* is written, the service is never provided, the app's rows stay disabled, and
|
||||
* is written, the service is never provided, dependent rows stay pending, and
|
||||
* `ctx.appExit` is requested.
|
||||
*
|
||||
* An app that layers over another one (the one-shot bundle rides over the web
|
||||
@@ -171,7 +182,7 @@ export function runStartup<T>(
|
||||
// and nothing to start, and the check below would blame the bundle for a
|
||||
// tree that simply went away.
|
||||
if (ctx.get('loader') === undefined) return undefined
|
||||
values = plan(program, waitingRows(ctx, names))
|
||||
values = plan(program, waitingRows(ctx, names), ctx)
|
||||
} catch (error) {
|
||||
// exitOverride turns help, version, a parse error, and a plan's own
|
||||
// program.error() into a CommanderError; commander has already written the
|
||||
@@ -191,17 +202,16 @@ export function runStartup<T>(
|
||||
*
|
||||
* A row cannot be inserted from inside a mounting plugin — the Loader returns a
|
||||
* prefixed id it then fails to resolve — so a conditional row ships disabled
|
||||
* and an entrypoint enables it.
|
||||
* Call it from a row that mounts alongside the one being enabled: an
|
||||
* entrypoint runs before the rest of the composition, so a row it enabled
|
||||
* there would wait for services that have yet to mount.
|
||||
* and a row mounted beside it enables it after startup resolves the invocation.
|
||||
* @param ctx - plugin context whose Loader tree carries the row.
|
||||
* @param id - the row id.
|
||||
* @returns nothing once the row has started.
|
||||
* @throws when the composition has no row with that id.
|
||||
* @returns nothing once the row has started or is waiting for its dependencies.
|
||||
* @throws when the Loader or named row is absent.
|
||||
*/
|
||||
export async function enableRow(ctx: Context, id: string): Promise<void> {
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === id)
|
||||
const loader = ctx.get('loader')
|
||||
if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service')
|
||||
const entry = [...loader.entries()].find(candidate => candidate.options.id === id)
|
||||
if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`)
|
||||
await entry.update({ disabled: false })
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* The launcher-to-app command line over a REAL Loader tree, mounted the way a
|
||||
* profile boot mounts it: the entrypoint row first, then the rest of the
|
||||
* composition, whose rows read the entrypoint's values from their own config
|
||||
* expressions. `--help` never reaches that second phase.
|
||||
* profile boot mounts it: Loader holds each row until its injections are
|
||||
* active, then resolves that row's config against its injection-ready context.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
@@ -15,7 +14,9 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { internals, provideCmdline, runStartup, type StartupPlan } from '../src/index.ts'
|
||||
import {
|
||||
enableRow, hasCmdlineConsumer, internals, provideCmdline, runStartup, type StartupPlan,
|
||||
} from '../src/index.ts'
|
||||
|
||||
/** Every value one boot of the fixture tree observed. */
|
||||
interface Observed {
|
||||
@@ -56,8 +57,8 @@ const demoPlan: StartupPlan<{ port?: number }> = (program) => {
|
||||
const expression = (source: string): unknown => ({ __jsExpr: source })
|
||||
|
||||
/**
|
||||
* Mount a two-row composition the way a profile boot does: the entrypoint row
|
||||
* alone first, then everything.
|
||||
* Mount a two-row composition the way a profile boot does: both rows at once,
|
||||
* with Loader ordering config resolution from their injections.
|
||||
* @param args - the invocation's inner arguments.
|
||||
* @param plan - the app's plan; defaults to the fixture's own.
|
||||
* @returns the booted fixture.
|
||||
@@ -65,7 +66,7 @@ const expression = (source: string): unknown => ({ __jsExpr: source })
|
||||
async function bootFixture(
|
||||
args: string[],
|
||||
plan: StartupPlan = demoPlan,
|
||||
options: { withoutEntrypoint?: boolean } = {},
|
||||
options: { objectInject?: boolean; withoutStartup?: boolean } = {},
|
||||
): Promise<Fixture> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
|
||||
const observed: Observed = { exits: [], out: '' }
|
||||
@@ -77,7 +78,7 @@ export function apply(ctx, config) { globalThis.__observed.started = config }
|
||||
// The Loader imports a row through Node's own resolver, which cannot resolve
|
||||
// this workspace's sources; the row delegates to the real function the test
|
||||
// imported through the source-plane path mapping.
|
||||
writeFileSync(join(dir, 'entrypoint.mjs'), `
|
||||
writeFileSync(join(dir, 'startup.mjs'), `
|
||||
export const name = 'demo-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
export function apply(ctx) { return globalThis.__runStartup(ctx) }
|
||||
@@ -94,14 +95,14 @@ export function apply(ctx) { return globalThis.__runStartup(ctx) }
|
||||
// config carries `!!js` expressions.
|
||||
const composition: PatchOptions[] = [{
|
||||
insert: [
|
||||
...options.withoutEntrypoint === true
|
||||
...options.withoutStartup === true
|
||||
? []
|
||||
: [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'entrypoint.mjs')).href }],
|
||||
: [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href, inject: ['cmdlineArgs'] }],
|
||||
{
|
||||
id: 'reader',
|
||||
name: pathToFileURL(join(dir, 'reader.mjs')).href,
|
||||
inject: ['demoStartup'],
|
||||
config: { port: expression("ctx.get('demoStartup')?.port ?? 3080") },
|
||||
inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'],
|
||||
config: { port: expression('ctx.demoStartup?.port ?? 3080') },
|
||||
},
|
||||
],
|
||||
}]
|
||||
@@ -109,22 +110,29 @@ export function apply(ctx) { return globalThis.__runStartup(ctx) }
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) })
|
||||
const rootConfig = { path: pathToFileURL(join(dir, 'cordis.yml')).href }
|
||||
// Phase one: the entrypoint alone.
|
||||
const includeId = await ctx.loader.create({
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { ...rootConfig, patches: [...structuredClone(composition), { id: 'reader', disabled: true }] },
|
||||
config: { path: pathToFileURL(join(dir, 'cordis.yml')).href, patches: structuredClone(composition) },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
disposers.push(async () => { await ctx.fiber.dispose() })
|
||||
if (observed.exits.length === 0) {
|
||||
// Phase two: the whole composition, now that the entrypoint's values answer.
|
||||
await ctx.loader.resolve(includeId).update({ config: { ...rootConfig, patches: structuredClone(composition) } })
|
||||
await ctx.loader.await()
|
||||
}
|
||||
return { observed, ctx }
|
||||
}
|
||||
|
||||
describe('hasCmdlineConsumer', () => {
|
||||
it('recognizes active array and object injections', () => {
|
||||
expect(hasCmdlineConsumer([
|
||||
{ id: 'ordinary', name: 'ordinary' },
|
||||
{ id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true },
|
||||
{ id: 'tui-startup', name: 'tui-startup', inject: { cmdlineArgs: { required: true } } },
|
||||
])).toBe(true)
|
||||
expect(hasCmdlineConsumer([
|
||||
{ id: 'ordinary', name: 'ordinary' },
|
||||
{ id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true },
|
||||
])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runStartup', () => {
|
||||
it('lets a row read the flag value the app resolved', async () => {
|
||||
const { observed } = await bootFixture(['--port', '8080'])
|
||||
@@ -137,6 +145,11 @@ describe('runStartup', () => {
|
||||
expect(observed.started).toEqual({ port: 3080 })
|
||||
})
|
||||
|
||||
it('recognizes the Loader object form of a startup-service injection', async () => {
|
||||
const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true })
|
||||
expect(observed.started).toEqual({ port: 8080 })
|
||||
})
|
||||
|
||||
it('prints the app help, starts no reading row, and requests exit 0', async () => {
|
||||
const { observed } = await bootFixture(['--help'])
|
||||
expect(observed.out).toContain('Usage: demo')
|
||||
@@ -152,13 +165,13 @@ describe('runStartup', () => {
|
||||
})
|
||||
|
||||
it('rethrows a plan failure that is not commander asking to exit', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true })
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true })
|
||||
const plan: StartupPlan = () => { throw new Error('plan exploded') }
|
||||
expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan exploded')
|
||||
})
|
||||
|
||||
it('rethrows a thrown value that is not an object at all', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true })
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true })
|
||||
const plan: StartupPlan = () => {
|
||||
const thrown: unknown = 'plan threw a string'
|
||||
throw thrown
|
||||
@@ -167,36 +180,57 @@ describe('runStartup', () => {
|
||||
})
|
||||
|
||||
it('fails loud when no row injects the service the app provides', async () => {
|
||||
// The bundle patch and its entrypoint disagree; a silent no-op would leave
|
||||
// The bundle patch and its startup row disagree; a silent no-op would leave
|
||||
// every row of the app on its fallbacks with no explanation.
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true })
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true })
|
||||
expect(() => { runStartup(ctx, 'absentStartup', demoCommand()) })
|
||||
.toThrow('absentStartup: no row injects this startup service')
|
||||
})
|
||||
|
||||
it('provides an empty value when the app declares no plan', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true })
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true })
|
||||
runStartup(ctx, 'demoStartup', demoCommand())
|
||||
expect(ctx.get('demoStartup')).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('enableRow', () => {
|
||||
it('enables the named Loader row and fails loud when the Loader or row is absent', async () => {
|
||||
const withoutLoader = new Context()
|
||||
await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service')
|
||||
|
||||
const ctx = new Context()
|
||||
let update: unknown
|
||||
ctx.provide('loader', {
|
||||
entries: () => [{
|
||||
options: { id: 'client-hmr' },
|
||||
update: async (options: unknown) => { update = options },
|
||||
}],
|
||||
} as never)
|
||||
await enableRow(ctx, 'client-hmr')
|
||||
expect(update).toEqual({ disabled: false })
|
||||
await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('provideCmdline', () => {
|
||||
it('hands the app a snapshot the caller cannot mutate afterwards', () => {
|
||||
const ctx = new Context()
|
||||
const args = ['--resume', 'abc']
|
||||
provideCmdline(ctx, { args, exit: () => {} })
|
||||
const ready = Promise.resolve()
|
||||
provideCmdline(ctx, { args, exit: () => {}, ready })
|
||||
args.push('--tampered')
|
||||
expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc'])
|
||||
expect(ctx.appReady).toBe(ready)
|
||||
})
|
||||
|
||||
it('fails loud when an entrypoint runs without the launcher values', () => {
|
||||
it('fails loud when a startup row runs without the launcher values', () => {
|
||||
const ctx = new Context()
|
||||
expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) })
|
||||
.toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit')
|
||||
})
|
||||
|
||||
it('resolves nothing when the tree was disposed while the entrypoint parsed', () => {
|
||||
it('resolves nothing when the tree was disposed while the startup row parsed', () => {
|
||||
// An early SIGTERM takes the Loader with it; there is nothing left to
|
||||
// configure, and the bundle did nothing wrong.
|
||||
const exits: number[] = []
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml",
|
||||
"entrypoint": "web-startup"
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user