refactor(cmdline)!: an app's entrypoint provides values its rows read

Replaces the patch round trip. An app's entrypoint resolves the command
line into a service, and the rows it configures read that service from
their own config — port: !!js ctx.get('webStartup')?.port ?? 3080 — so the
resolved value beats the value written beside it and nothing is written
back into a row or handed to the launcher.

A bundle names the entrypoint row in its manifest (dsh.bundle.entrypoint),
which is what lets the boot mount in two passes: entrypoints alone, then
the whole composition. That ordering is required, not cosmetic — a row's
config expressions are evaluated when the include applies the row, and a
strict ctx.get only answers for a service whose providing fiber is already
active.

What this removes: ctx.appPatches and the launcher-owned patch layer, the
disable/re-enable recycle and its in-flight-mount barrier, overrideConfig,
and the reload hazard they existed for. A live config edit now re-applies
the second pass against services that are still up, so a served port
survives by construction.

What it adds: ctx.appReady, because Loader settlement no longer means the
app is up — a row mounted in the second pass can observe a settled tree
while that pass is still running, or already rolling back. The web URL line
waits for it, so a boot that fails in the second pass announces nothing.
This commit is contained in:
Turtle
2026-08-07 11:58:03 +08:00
parent f749e04881
commit 1f0a0440f3
23 changed files with 720 additions and 593 deletions

View File

@@ -4,57 +4,59 @@ English | [中文](README.zh.md)
The command line a dsh launcher hands to the app it boots. The launcher parses only its own flags (`--profile`, `--patch`, the config dumps) and hands **everything after them** to the tree verbatim, so an app owns its flag family, its `--help` text, and its parse errors instead of the launcher knowing them.
## The three launcher values
## The launcher values
A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which provides:
- `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`.
- `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller.
- `ctx.appPatches` — where a startup row records its decisions, for a launcher that recomposes its tree. Omitted by a host that never does.
- `ctx.appReady` — settles when the launcher has finished mounting, for a row that publishes readiness (a URL line a supervisor waits for).
An embedding host with no command line provides an empty list; that is the honest answer, not a missing value.
## Startup rows and the services their rows wait for
## Entrypoints, and the service their app reads
An app reads those arguments from a **startup row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`:
An app reads those arguments from its **entrypoint row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`:
```ts ignore
export const name = 'web-startup'
export const inject = ['cmdlineArgs']
export function apply(ctx: Context): Promise<void> {
return runStartup(ctx, 'webStartup', webCommand(), planWebStartup)
export function apply(ctx: Context): void {
runStartup(ctx, 'webStartup', webCommand(), planWebStartup)
}
```
Every row the app configures from flags injects that startup service in the bundle patch:
The bundle's `package.json` names that row, which is what makes the boot mount it before everything else:
```json
{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } }
```
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:
```yaml
- id: webserver
name: '@deepseek-ai/dsh-host-webserver'
inject: [webStartup]
config:
host: 127.0.0.1
port: 3080
host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1'
port: !!js ctx.get('webStartup')?.port ?? 3080
```
`runStartup` parses the arguments, asks `plan` what each waiting row's values should be, applies them, and provides the startup service, which is what lets those rows start. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, disables the waiting rows, and requests exit — the app never starts, and the settlement audit sees a tree that was asked not to start it.
`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, and the rest of the composition never mounts.
`plan` receives every waiting row's **composed** options, so a decision reads what the bundle patches and the user's own layers agreed on before overriding it; `overrideConfig(row, { port })` replaces exactly the named keys. A row absent from the plan starts on its composed values, and planning a change for a row also enables it.
`plan` receives the options of every row that injects the service, for a value that has to take the composition into account: the `/api` fence authorities are the shipped example, since a bind the composition configured decides whether LAN literals are derived at all.
A row whose required config the startup **supplies** rather than overrides must ship `disabled: true`, because a waiting row's config is validated when its fiber is created — before the startup service arrives — and a missing required key fails the boot there. The one-shot runner's `task` is the shipped example. A row shipped disabled for another reason is turned on the same way: `dsh web --dev` plans `{ disabled: false }` for the HMR receiver.
### Why the boot has phases
The decisions also reach the launcher through `ctx.appPatches`, which is what keeps them alive across a recomposition: without it, a user editing a live patch file would rebuild every row from its composed options and silently move a server started on `--port 8080` back to the composed port.
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.
### Why a changed row is recycled
A waiting row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting. Writing a new config onto that fiber never reaches the plugin, so each changed row is disabled and re-enabled, which drops the stale fiber and resolves the config again. A row whose own mount is still in flight is allowed to settle first, so the disable has a fiber to dispose instead of racing one into existence.
Recycling deliberately leaves `inject` alone. Updating a row's `inject` restarts it from its unwrapped callback, which loses the plugin's own static injections — a row that declares `inject = ['httpServer', 'apiProxy']` would come back unable to read either.
`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.
### One command line, one owner
A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and names both startup services, so the rows it absorbed start on their composed values — [`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 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).
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.
@@ -69,4 +71,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 a startup row provides it; nothing links the two statically, so a bundle that ships waiting rows without its startup row fails at settlement (pending entries naming the service) rather than at load.
- **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 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.