Merge remote-tracking branch 'origin/master' into worktree/windows-acl-hardening-followup
This commit is contained in:
@@ -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/boot/README.md
|
||||
README.md: 5e4e483b60adab0b22ddb5279f4cd8fb699b9c35
|
||||
README.zh.md: 95a3f98129a7d1fdfaffb3cac6fed77bab7cff56
|
||||
README.md: 79d653260ea4a9d9a4c71a593b41a6a7e17efa14
|
||||
README.zh.md: 839be164328ef168cd6ac18bf2f1dcb930dfce3e
|
||||
|
||||
@@ -7,5 +7,6 @@ The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit` |
|
||||
|
||||
The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md).
|
||||
The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md); app-owned command lines are documented in [`cmdline/README.md`](cmdline/README.md).
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
|
||||
| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit` |
|
||||
|
||||
启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md)。
|
||||
启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md);由应用持有的命令行见 [`cmdline/README.md`](cmdline/README.md)。
|
||||
|
||||
@@ -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/boot/app-boot/README.md
|
||||
README.md: f3ffdae3846edba6f1a1a4821adade7b6c7fce76
|
||||
README.zh.md: 4f31fd743f1ddc57edc9c215a42e79a16afcdecb
|
||||
README.md: 1d56b2b6d22c08574f8e361955bee1dbe2aca601
|
||||
README.zh.md: 5429a1322d0311f03c7c43946753a290e28cd936
|
||||
|
||||
@@ -42,7 +42,7 @@ User-level machine-local preferences also live in the Harness home:
|
||||
- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback.
|
||||
- **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
|
||||
|
||||
Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`
|
||||
- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。
|
||||
- **`cordis.patch.yml`**(home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。
|
||||
|
||||
长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ export function loadLayeredEnv(
|
||||
const bootstrapIncludes = new WeakMap<Context, Entry>()
|
||||
|
||||
// The include's YAML dialect (`!!js` scalars become expression nodes the
|
||||
// Loader interpolates against each entry's context at mount time), imported
|
||||
// Loader interpolates against each entry's injection-ready context), imported
|
||||
// from the include itself so patch parsing and config dumping can never drift
|
||||
// from what the include mounts. User patch layers share it so they may
|
||||
// reference `process.env`.
|
||||
@@ -215,7 +215,7 @@ export interface UserPatchWatchOptions {
|
||||
* Compose the full patch list for a fresh user-layer generation —
|
||||
* the same composition the app booted with, so a reload can interleave the
|
||||
* new user patches between app-owned layers (bundle layers below,
|
||||
* overlay/flag patches above). Identity when omitted: the user layer
|
||||
* overlays above). Identity when omitted: the user layer
|
||||
* is the whole patch list.
|
||||
*/
|
||||
compose?: (userPatches: PatchOptions[]) => PatchOptions[]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -699,7 +699,18 @@ describe('boot', () => {
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
|
||||
writeFileSync(join(dir, 'delayed.mjs'), [
|
||||
'await new Promise(resolve => setTimeout(resolve, 10))',
|
||||
'export function apply() {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: exiting',
|
||||
' name: ./exiting.mjs',
|
||||
'- id: delayed',
|
||||
' name: ./delayed.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
expect(ctx.get('loader')).toBeUndefined()
|
||||
})
|
||||
@@ -712,6 +723,25 @@ describe('boot', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('labels a deferred config failure with its row and leaves the source file unchanged', async () => {
|
||||
const dir = tmp()
|
||||
const configPath = join(dir, 'cordis.yml')
|
||||
const config = [
|
||||
'- id: invalid-config',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: !!js "JSON.parse(\'invalid\')"',
|
||||
'',
|
||||
].join('\n')
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(configPath, config)
|
||||
|
||||
await expect(boot(NAME, configPath)).rejects.toThrow(
|
||||
'failed to apply loader entry invalid-config (./noop.mjs)',
|
||||
)
|
||||
expect(readFileSync(configPath, 'utf8')).toBe(config)
|
||||
})
|
||||
|
||||
it('appends the deepest cause with its original stack to the load failure', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'failing.mjs'), [
|
||||
|
||||
@@ -11,6 +11,7 @@ import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Hmr from '@deepseek-ai/cordis-plugin-hmr'
|
||||
import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Timer from '@deepseek-ai/cordis-plugin-timer'
|
||||
import {
|
||||
@@ -92,23 +93,101 @@ describe('loadOptionalPatches', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot with user patches', () => {
|
||||
function writeTree(dir: string): string {
|
||||
writeFileSync(join(dir, 'noop.mjs'), [
|
||||
'export const name = "noop"',
|
||||
'export function apply(_ctx, config = {}) {',
|
||||
' if (config.fail) throw new Error("candidate config failed")',
|
||||
'}',
|
||||
function writeTree(dir: string): string {
|
||||
writeFileSync(join(dir, 'noop.mjs'), [
|
||||
'export const name = "noop"',
|
||||
'export function apply(_ctx, config = {}) {',
|
||||
' if (config.fail) throw new Error("candidate config failed")',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
return join(dir, 'cordis.yml')
|
||||
}
|
||||
|
||||
function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
|
||||
describe('Loader config interpolation', () => {
|
||||
it("resolves Include's own !!js options", async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href)
|
||||
try {
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: { __jsExpr: "ctx.get('includePath')" } },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for row injections before resolving !!js and resolves again after provider replacement', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'provider.mjs'), [
|
||||
'export const name = "provider"',
|
||||
'export function apply(ctx, config) { ctx.provide("phaseOne", config) }',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
return join(dir, 'cordis.yml')
|
||||
}
|
||||
writeFileSync(join(dir, 'reader.mjs'), [
|
||||
'export const name = "reader"',
|
||||
'export const inject = ["phaseOne"]',
|
||||
'export function apply(ctx, config) { ctx.provide("readerResult", config) }',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
|
||||
const composition: PatchOptions[] = [{
|
||||
insert: [
|
||||
{
|
||||
// Consumer-first order proves interpolation follows injection
|
||||
// readiness rather than YAML position.
|
||||
id: 'reader',
|
||||
name: './reader.mjs',
|
||||
inject: ['phaseOne'],
|
||||
config: { value: { __jsExpr: 'ctx.phaseOne.fail ? (() => { throw new Error("rejected provider") })() : ctx.phaseOne.value' } },
|
||||
},
|
||||
{ id: 'provider', name: './provider.mjs', config: { value: 'first' } },
|
||||
],
|
||||
}]
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'), composition)
|
||||
try {
|
||||
expect(ctx.get('readerResult')).toEqual({ value: 'first' })
|
||||
const provider = [...ctx.loader.entries()].find(entry => entry.options.id === 'provider')
|
||||
expect(provider).toBeDefined()
|
||||
await provider?.update({ disabled: true })
|
||||
await ctx.loader.await()
|
||||
expect(ctx.get('readerResult')).toBeUndefined()
|
||||
await provider?.update({ config: { value: 'second' } })
|
||||
await provider?.update({ disabled: false })
|
||||
await ctx.loader.await()
|
||||
expect(ctx.get('readerResult')).toEqual({ value: 'second' })
|
||||
|
||||
function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
await provider?.update({ disabled: true })
|
||||
await provider?.update({ config: { fail: true } })
|
||||
await provider?.update({ disabled: false })
|
||||
await expect(ctx.loader.await()).rejects.toThrow('rejected provider')
|
||||
expect(ctx.get('readerResult')).toBeUndefined()
|
||||
|
||||
await provider?.update({ disabled: true })
|
||||
await provider?.update({ config: { value: 'recovered' } })
|
||||
await provider?.update({ disabled: false })
|
||||
await ctx.loader.await()
|
||||
expect(ctx.get('readerResult')).toEqual({ value: 'recovered' })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot with user patches', () => {
|
||||
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
|
||||
const dir = tmp()
|
||||
const userDir = tmp()
|
||||
|
||||
6
packages/boot/cmdline/README.i18n.yaml
Normal file
6
packages/boot/cmdline/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/boot/cmdline/README.md
|
||||
README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96
|
||||
README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114
|
||||
74
packages/boot/cmdline/README.md
Normal file
74
packages/boot/cmdline/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `@deepseek-ai/dsh-cmdline`
|
||||
|
||||
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 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.
|
||||
|
||||
An embedding host with no command line provides an empty list; that is the honest answer, not a missing value.
|
||||
|
||||
## Ordinary providers and injected config
|
||||
|
||||
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service:
|
||||
|
||||
```ts ignore
|
||||
export const name = 'web-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, webCommand(), planWebStartup)
|
||||
if (values !== undefined) ctx.provide('webStartup', values)
|
||||
}
|
||||
```
|
||||
|
||||
Its Loader row carries no launcher marker or special kind:
|
||||
|
||||
```yaml
|
||||
- id: web-startup
|
||||
name: '@deepseek-ai/dsh-web-app/startup'
|
||||
```
|
||||
|
||||
Every row configured from those values uses ordinary service injection and direct lazy config access:
|
||||
|
||||
```yaml
|
||||
- id: webserver
|
||||
name: '@deepseek-ai/dsh-host-webserver'
|
||||
inject: [webStartup]
|
||||
config:
|
||||
host: !!js ctx.webStartup.host ?? '127.0.0.1'
|
||||
port: !!js ctx.webStartup.port ?? 3080
|
||||
```
|
||||
|
||||
`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate.
|
||||
|
||||
### How injection orders config
|
||||
|
||||
Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset.
|
||||
|
||||
`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering.
|
||||
|
||||
### Shared immutable arguments
|
||||
|
||||
`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments.
|
||||
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package resolves the process's own command line before any session exists.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
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 `-- --`.
|
||||
- **An app-owned service has no statically declared provider.** Consumer rows name it through ordinary injection; a bundle that omits its provider fails at settlement with 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.
|
||||
74
packages/boot/cmdline/README.zh.md
Normal file
74
packages/boot/cmdline/README.zh.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `@deepseek-ai/dsh-cmdline`
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 启动器交给它所引导应用的那条命令行。启动器只解析属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给配置树,因此 flag 家族、`--help` 文本和解析错误都由应用自己持有,启动器不必知道它们。
|
||||
|
||||
## 启动器提供的值
|
||||
|
||||
启动器在任何配置树条目挂载之前调用 `provideCmdline(ctx, host)`,它提供:
|
||||
|
||||
- `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。
|
||||
- `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。
|
||||
|
||||
没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。
|
||||
|
||||
## 普通提供方与注入配置
|
||||
|
||||
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有:
|
||||
|
||||
```ts ignore
|
||||
export const name = 'web-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, webCommand(), planWebStartup)
|
||||
if (values !== undefined) ctx.provide('webStartup', values)
|
||||
}
|
||||
```
|
||||
|
||||
它的 Loader 行不携带启动器标记,也没有特殊类型:
|
||||
|
||||
```yaml
|
||||
- id: web-startup
|
||||
name: '@deepseek-ai/dsh-web-app/startup'
|
||||
```
|
||||
|
||||
所有由这些取值配置的行都使用普通服务注入,并在惰性配置中直接访问该服务:
|
||||
|
||||
```yaml
|
||||
- id: webserver
|
||||
name: '@deepseek-ai/dsh-host-webserver'
|
||||
inject: [webStartup]
|
||||
config:
|
||||
host: !!js ctx.webStartup.host ?? '127.0.0.1'
|
||||
port: !!js ctx.webStartup.port ?? 3080
|
||||
```
|
||||
|
||||
`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。
|
||||
|
||||
### 注入如何排列配置求值
|
||||
|
||||
Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。
|
||||
|
||||
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。
|
||||
|
||||
### 共享不可变参数
|
||||
|
||||
`get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。
|
||||
|
||||
树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。本包在任何会话存在之前解析进程自身的命令行。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;本包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。
|
||||
- **应用自有服务没有静态声明的提供方**:消费行通过普通注入点名它;缺少提供方的组合包会在结算时失败,由待处理条目点名该服务,而不是在加载时失败。
|
||||
- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。
|
||||
39
packages/boot/cmdline/package.json
Normal file
39
packages/boot/cmdline/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-cmdline",
|
||||
"description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"commander": "^15.0.0",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
174
packages/boot/cmdline/src/index.ts
Normal file
174
packages/boot/cmdline/src/index.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-cmdline — 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 through the
|
||||
* {@link CmdlineArgs} service, so an app owns its flag family, its `--help`
|
||||
* text, and its parse errors instead of the launcher knowing them.
|
||||
*
|
||||
* Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A
|
||||
* provider may publish the parsed values as its own service, and ordinary rows
|
||||
* can inject that service and read it from lazily resolved config —
|
||||
* `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written
|
||||
* beside it. No row has launcher-level command-line status.
|
||||
* @module @deepseek-ai/dsh-cmdline
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
// Empty type import carries the Loader Context merge used by enableRow.
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
|
||||
/**
|
||||
* The invocation's inner arguments: everything after the launcher's own flags,
|
||||
* verbatim and in argv order. `dsh --profile tui --resume abc` yields
|
||||
* `['--resume', 'abc']`.
|
||||
*/
|
||||
export interface CmdlineArgs {
|
||||
/**
|
||||
* Read the inner arguments.
|
||||
* @returns the arguments in argv order; empty when the invocation carried none.
|
||||
*/
|
||||
get(): readonly string[]
|
||||
}
|
||||
|
||||
/** Request bounded process exit; the launcher wires it to its shutdown controller. */
|
||||
export interface AppExit {
|
||||
/**
|
||||
* Request exit once the tree has been disposed.
|
||||
* @param code - the process exit code.
|
||||
*/
|
||||
(code: number): void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** The invocation's inner arguments; provided by a launcher before the tree mounts. */
|
||||
cmdlineArgs?: CmdlineArgs
|
||||
/** Bounded process-exit request; provided by a launcher before the tree mounts. */
|
||||
appExit?: AppExit
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher facts an app needs. */
|
||||
export interface CmdlineHost {
|
||||
/** The invocation's inner arguments, in argv order. */
|
||||
args: readonly string[]
|
||||
/** Bounded process-exit request. */
|
||||
exit: AppExit
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the command line and the exit request on a host context before any
|
||||
* tree entry mounts. Both are launcher facts, not config: an embedding host
|
||||
* with no command line provides an empty argument list.
|
||||
* @param ctx - the host context the tree will mount under.
|
||||
* @param host - the invocation's arguments and its exit request.
|
||||
*/
|
||||
export function provideCmdline(ctx: Context, host: CmdlineHost): void {
|
||||
const snapshot: readonly string[] = Object.freeze([...host.args])
|
||||
ctx.provide('cmdlineArgs', { get: () => snapshot })
|
||||
ctx.provide('appExit', host.exit)
|
||||
}
|
||||
|
||||
/** 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,
|
||||
stderr: process.stderr,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve parsed arguments into an app-owned value. Call
|
||||
* `program.error(...)` to reject the invocation with a usage message instead
|
||||
* of throwing.
|
||||
* @param program - the parsed commander program.
|
||||
* @param ctx - the plugin context that received the command line.
|
||||
* @returns the value an ordinary provider plugin may publish.
|
||||
*/
|
||||
export type CmdlinePlan<T = unknown> = (program: Command, ctx: Context) => T
|
||||
|
||||
/**
|
||||
* Parse the launcher's immutable argument snapshot with an app's commander
|
||||
* program. The caller decides whether and how to publish the returned value;
|
||||
* this helper has no Loader-row or service ownership semantics.
|
||||
*
|
||||
* Help, version, and rejected arguments are terminal for the process: commander
|
||||
* writes the text, the helper requests `ctx.appExit`, and it returns
|
||||
* `undefined` so the caller publishes nothing.
|
||||
* @param ctx - plugin context carrying `cmdlineArgs` and `appExit`.
|
||||
* @param program - the app's commander program, with its flags and description already declared.
|
||||
* @param plan - this invocation's resolved value; omitted returns an empty object.
|
||||
* @returns the resolved value, or `undefined` when the app asked to exit.
|
||||
* @throws when the launcher did not provide the command line and exit request.
|
||||
*/
|
||||
export function parseCmdline<T>(
|
||||
ctx: Context,
|
||||
program: Command,
|
||||
plan: CmdlinePlan<T> = (() => ({}) as T),
|
||||
): T | undefined {
|
||||
// Read through the global service store, not the property proxy: appExit is
|
||||
// an optional host value and the plugin only needs to inject cmdlineArgs.
|
||||
const args = ctx.get('cmdlineArgs')
|
||||
const exit = ctx.get('appExit')
|
||||
if (args === undefined || exit === undefined) {
|
||||
throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`)
|
||||
}
|
||||
program
|
||||
.exitOverride()
|
||||
.configureOutput({
|
||||
writeOut: text => void internals.stdout.write(text),
|
||||
writeErr: text => void internals.stderr.write(text),
|
||||
})
|
||||
try {
|
||||
program.parse(args.get(), { from: 'user' })
|
||||
return plan(program, 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
|
||||
// text through the output configured above.
|
||||
if (!isCommanderError(error)) throw error
|
||||
exit(error.exitCode)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on a row this composition ships disabled, because this invocation asked
|
||||
* for it (`dsh web --dev` and its client-plugin reload chain).
|
||||
*
|
||||
* 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 a row mounted beside it enables it after startup resolves the invocation.
|
||||
* The Loader keeps that activation in memory, separate from serialized options,
|
||||
* so reapplying the composition cannot restore the invocation's row to disabled.
|
||||
* @param ctx - plugin context whose Loader tree carries the row.
|
||||
* @param id - the row 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 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.enableRuntime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a thrown value is commander's own control-flow error (help, version,
|
||||
* a parse error, or `program.error`).
|
||||
*
|
||||
* Detected structurally, not with `instanceof`: an out-of-tree plugin brings
|
||||
* its own commander copy, whose `CommanderError` class is a different identity
|
||||
* from this package's, and an identity check there would rethrow a printed
|
||||
* help as a fatal load failure.
|
||||
* @param error - the thrown value.
|
||||
* @returns true when the value carries commander's error code and exit code.
|
||||
*/
|
||||
function isCommanderError(error: unknown): error is { code: string; exitCode: number } {
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const candidate = error as { code?: unknown; exitCode?: unknown }
|
||||
return typeof candidate.code === 'string' && candidate.code.startsWith('commander.')
|
||||
&& typeof candidate.exitCode === 'number'
|
||||
}
|
||||
30
packages/boot/cmdline/src/invariant.ts
Normal file
30
packages/boot/cmdline/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-cmdline`.
|
||||
* @module @deepseek-ai/dsh-cmdline/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-cmdline'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'cmdline-invariant'
|
||||
/** Service required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: `cmdlineArgs` is an immutable launcher fact that any
|
||||
* number of ordinary plugins may read. App-owned providers and consumers use
|
||||
* normal Cordis service injection, whose missing dependencies are already
|
||||
* reported by Loader settlement.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
265
packages/boot/cmdline/tests/cmdline.spec.ts
Normal file
265
packages/boot/cmdline/tests/cmdline.spec.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* The launcher-to-app command line over a REAL Loader tree, mounted the way a
|
||||
* 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'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Command } from 'commander'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan,
|
||||
} from '../src/index.ts'
|
||||
|
||||
/** Every value one boot of the fixture tree observed. */
|
||||
interface Observed {
|
||||
/** Config the reading row started with; absent means it never started. */
|
||||
started?: Record<string, unknown>
|
||||
exits: number[]
|
||||
out: string
|
||||
}
|
||||
|
||||
/** A booted fixture tree: what it observed, and its root for direct parser calls. */
|
||||
interface Fixture {
|
||||
observed: Observed
|
||||
ctx: Context
|
||||
}
|
||||
|
||||
const disposers: (() => Promise<void>)[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dispose of disposers.splice(0)) await dispose()
|
||||
internals.stdout = process.stdout
|
||||
internals.stderr = process.stderr
|
||||
})
|
||||
|
||||
/** The fixture app's flag family: one `--port` its rows read from the service. */
|
||||
function demoCommand(): Command {
|
||||
return new Command().name('demo').exitOverride().option('--port <port>', 'listen port')
|
||||
}
|
||||
|
||||
/** The fixture app's plan: the resolved values its rows read. */
|
||||
const demoPlan: CmdlinePlan<{ port?: number }> = (program) => {
|
||||
const port = program.opts<{ port?: string }>().port
|
||||
if (port === undefined) return {}
|
||||
if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`)
|
||||
return { port: Number(port) }
|
||||
}
|
||||
|
||||
/** A YAML `!!js` expression node, as the include parses one out of a patch file. */
|
||||
const expression = (source: string): unknown => ({ __jsExpr: source })
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function bootFixture(
|
||||
args: string[],
|
||||
plan: CmdlinePlan = demoPlan,
|
||||
options: { objectInject?: boolean; withoutProvider?: boolean } = {},
|
||||
): Promise<Fixture> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
|
||||
const observed: Observed = { exits: [], out: '' }
|
||||
writeFileSync(join(dir, 'reader.mjs'), `
|
||||
export const name = 'reader'
|
||||
export const inject = ['demoStartup']
|
||||
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, 'startup.mjs'), `
|
||||
export const name = 'demo-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) }
|
||||
`)
|
||||
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
|
||||
const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
|
||||
internals.stdout = observing
|
||||
internals.stderr = observing
|
||||
const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void }
|
||||
globals.__observed = observed
|
||||
globals.__provideDemoArgs = (ctx: Context) => {
|
||||
const values = parseCmdline(ctx, demoCommand(), plan)
|
||||
if (values !== undefined) ctx.provide('demoStartup', values)
|
||||
}
|
||||
|
||||
// The composition, exactly as a profile delivers one: include patches whose
|
||||
// config carries `!!js` expressions.
|
||||
const composition: PatchOptions[] = [{
|
||||
insert: [
|
||||
...options.withoutProvider === true
|
||||
? []
|
||||
: [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href }],
|
||||
{
|
||||
id: 'reader',
|
||||
name: pathToFileURL(join(dir, 'reader.mjs')).href,
|
||||
inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'],
|
||||
config: { port: expression('ctx.demoStartup.port ?? 3080') },
|
||||
},
|
||||
],
|
||||
}]
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) })
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(join(dir, 'cordis.yml')).href, patches: structuredClone(composition) },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
disposers.push(async () => { await ctx.fiber.dispose() })
|
||||
return { observed, ctx }
|
||||
}
|
||||
|
||||
describe('parseCmdline', () => {
|
||||
it('lets a row read the flag value the app resolved', async () => {
|
||||
const { observed } = await bootFixture(['--port', '8080'])
|
||||
expect(observed.started).toEqual({ port: 8080 })
|
||||
expect(observed.exits).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a row on the value written beside the expression when no flag names one', async () => {
|
||||
const { observed } = await bootFixture([])
|
||||
expect(observed.started).toEqual({ port: 3080 })
|
||||
})
|
||||
|
||||
it('recognizes the Loader object form of a provider-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')
|
||||
expect(observed.started).toBeUndefined()
|
||||
expect(observed.exits).toEqual([0])
|
||||
})
|
||||
|
||||
it('rejects the invocation from the plan without starting the app', async () => {
|
||||
const { observed } = await bootFixture(['--port', 'abc'])
|
||||
expect(observed.out).toContain('--port must be a number')
|
||||
expect(observed.started).toBeUndefined()
|
||||
expect(observed.exits).toEqual([1])
|
||||
})
|
||||
|
||||
it('rethrows a plan failure that is not commander asking to exit', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
|
||||
const plan: CmdlinePlan = () => { throw new Error('plan exploded') }
|
||||
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded')
|
||||
})
|
||||
|
||||
it('rethrows a thrown value that is not an object at all', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
|
||||
const plan: CmdlinePlan = () => {
|
||||
const thrown: unknown = 'plan threw a string'
|
||||
throw thrown
|
||||
}
|
||||
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string')
|
||||
})
|
||||
|
||||
it('returns values without inspecting Loader rows or owning a service', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
|
||||
expect(parseCmdline(ctx, demoCommand())).toEqual({})
|
||||
expect(ctx.get('demoStartup')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
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 enabled = false
|
||||
ctx.provide('loader', {
|
||||
entries: () => [{
|
||||
options: { id: 'client-hmr' },
|
||||
enableRuntime: async () => { enabled = true },
|
||||
}],
|
||||
} as never)
|
||||
await enableRow(ctx, 'client-hmr')
|
||||
expect(enabled).toBe(true)
|
||||
await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable')
|
||||
})
|
||||
|
||||
it('keeps invocation-only activation through config reapplication', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-'))
|
||||
const observed = { starts: 0, stops: 0 }
|
||||
;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed
|
||||
writeFileSync(join(dir, 'conditional.mjs'), `
|
||||
export function apply(ctx) {
|
||||
globalThis.__runtimeEnableObserved.starts += 1
|
||||
ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 })
|
||||
}
|
||||
`)
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: conditional',
|
||||
` name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`,
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(join(dir, 'cordis.yml')).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional')
|
||||
const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include')
|
||||
expect(conditional).toBeDefined()
|
||||
expect(include?.fiber).toBeDefined()
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 0, stops: 0 })
|
||||
|
||||
await enableRow(ctx, 'conditional')
|
||||
await ctx.loader.await()
|
||||
expect(conditional?.disabled).toBe(false)
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 1, stops: 0 })
|
||||
|
||||
await include!.fiber!.update(include!.options.config, true)
|
||||
await ctx.loader.await()
|
||||
expect(conditional?.disabled).toBe(false)
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 1, stops: 0 })
|
||||
disposers.push(async () => { await ctx.fiber.dispose() })
|
||||
})
|
||||
})
|
||||
|
||||
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: () => {} })
|
||||
args.push('--tampered')
|
||||
expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc'])
|
||||
})
|
||||
|
||||
it('fails loud when a parser runs without the launcher values', () => {
|
||||
const ctx = new Context()
|
||||
expect(() => { parseCmdline(ctx, demoCommand()) })
|
||||
.toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit')
|
||||
})
|
||||
|
||||
it('lets multiple parsers read the same immutable snapshot', () => {
|
||||
const ctx = new Context()
|
||||
provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} })
|
||||
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
|
||||
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
|
||||
expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true)
|
||||
})
|
||||
})
|
||||
24
packages/boot/cmdline/tsconfig.json
Normal file
24
packages/boot/cmdline/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/include"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: f8b76b77f2beb22f501a49f0fc4cf5cd72223765
|
||||
README.zh.md: aae8ab5bea663b8909de942f72615f5ef9b16c84
|
||||
README.md: 31a4894dbb191d2244371ca7272339e96e253053
|
||||
README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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}`). It mounts no Host, HTTP server, Web runtime, or browser plugin.
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `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 launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row.
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `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 保持为空。进程不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。
|
||||
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 激活前被拒绝。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +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 launcher
|
||||
# patches the runner's `task`; the direct driver creates an Agent through the
|
||||
# core registry and prints the final durable assistant message.
|
||||
# It mounts no Host, HTTP server, Web runtime, or browser plugin. An ordinary
|
||||
# provider plugin injects `cmdlineArgs`, parses the task positional
|
||||
# (`dsh --profile headless "<task>"`) and this app's --help, then the direct
|
||||
# driver creates an Agent through the core registry and prints its durable result.
|
||||
|
||||
- id: system-prompt
|
||||
config:
|
||||
@@ -22,5 +23,12 @@
|
||||
- id: code-runtime
|
||||
name: '@deepseek-ai/dsh-code-runtime-worker'
|
||||
|
||||
- id: headless-startup
|
||||
name: '@deepseek-ai/dsh-headless/startup'
|
||||
|
||||
# Reads its task from the ordinary headlessStartup provider.
|
||||
- id: headless-runner
|
||||
name: '@deepseek-ai/dsh-headless'
|
||||
inject: [headlessStartup]
|
||||
config:
|
||||
task: !!js ctx.headlessStartup.task
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./startup": {
|
||||
"types": "./lib/types/startup.d.ts",
|
||||
"default": "./lib/startup.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
@@ -22,6 +26,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/startup.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
@@ -32,10 +37,13 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-cmdline": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "^3.18.0"
|
||||
"@deepseek-ai/schemastery": "^3.18.0",
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-default-model": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
|
||||
@@ -25,7 +25,7 @@ export const name = 'headless-runner'
|
||||
/** Core services required before the one-shot turn can start. */
|
||||
export const inject = ['agentDefaultModel', 'agents', 'sessions']
|
||||
|
||||
/** Plugin config: the task, patched in by the launcher. */
|
||||
/** Plugin config: the task resolved from this app's injected provider service. */
|
||||
export interface Config {
|
||||
/** The prompt text for the single run. */
|
||||
task: string
|
||||
|
||||
62
packages/bundle/headless/src/startup.ts
Normal file
62
packages/bundle/headless/src/startup.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The one-shot app's command-line provider: it parses the task positional and
|
||||
* `--help`, then publishes {@link HEADLESS_STARTUP_SERVICE}. The runner is an
|
||||
* ordinary consumer whose lazy config waits for that service.
|
||||
* @module @deepseek-ai/dsh-headless/startup
|
||||
*/
|
||||
|
||||
import { Command } from 'commander'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'headless-startup'
|
||||
|
||||
/** Services required before the task can be resolved. */
|
||||
export const inject = ['cmdlineArgs']
|
||||
|
||||
/** Service provided by this plugin and injected by the one-shot runner. */
|
||||
export const HEADLESS_STARTUP_SERVICE = 'headlessStartup'
|
||||
|
||||
/** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */
|
||||
export interface HeadlessStartupValues {
|
||||
/** The task text this invocation asked for. */
|
||||
task: string
|
||||
}
|
||||
|
||||
/**
|
||||
* This app's command: the task positional, its description, and its help text.
|
||||
* @returns a fresh program, so one process can parse more than once (tests).
|
||||
*/
|
||||
function headlessCommand(): Command {
|
||||
return new Command()
|
||||
.name('dsh --profile headless')
|
||||
.description('Answer one task, print the final assistant message, and exit.')
|
||||
.helpOption('-h, --help', 'show this help')
|
||||
.argument('[task...]', 'the task text; multiple words are joined by spaces')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
dsh --profile headless "run the tests" answer one task and exit
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the parsed command line into the runner's task.
|
||||
* @param program - the parsed headless command.
|
||||
* @returns the runner's service value.
|
||||
*/
|
||||
function planHeadlessStartup(program: Command): HeadlessStartupValues {
|
||||
const task = program.args.join(' ')
|
||||
if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
|
||||
return { task }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and provide the one-shot task as an ordinary Cordis service.
|
||||
* @param ctx - plugin context carrying the command line.
|
||||
* @returns nothing once the task is provided, or when the command requested exit.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup)
|
||||
if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values)
|
||||
}
|
||||
106
packages/bundle/headless/tests/startup.spec.ts
Normal file
106
packages/bundle/headless/tests/startup.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* The one-shot app's ordinary command-line provider over a real Loader tree:
|
||||
* the task becomes injected runner config, while help and usage errors leave
|
||||
* the consumer pending.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts'
|
||||
|
||||
/** What one boot of the fixture tree observed. */
|
||||
interface Observed {
|
||||
exits: number[]
|
||||
out: string
|
||||
runnerConfig?: unknown
|
||||
}
|
||||
|
||||
const disposers: (() => Promise<void>)[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dispose of disposers.splice(0)) await dispose()
|
||||
internals.stdout = process.stdout
|
||||
internals.stderr = process.stderr
|
||||
})
|
||||
|
||||
/**
|
||||
* Mount the real provider over a runner stand-in.
|
||||
* @param args - the invocation's inner arguments.
|
||||
* @returns the resolved service value and observed runner/process effects.
|
||||
*/
|
||||
async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-'))
|
||||
const observed: Observed = { exits: [], out: '' }
|
||||
writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n')
|
||||
// Loader imports through Node's resolver, so this fixture delegates to the
|
||||
// source-plane plugin already imported by the test.
|
||||
writeFileSync(join(dir, 'startup.mjs'), `
|
||||
export const name = 'headless-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
export const apply = ctx => globalThis.__headlessStartupApply(ctx)
|
||||
`)
|
||||
const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: headless-runner',
|
||||
` name: ${rowUrl}`,
|
||||
` inject: [${HEADLESS_STARTUP_SERVICE}]`,
|
||||
' config:',
|
||||
' task: !!js ctx.headlessStartup.task',
|
||||
'- id: headless-startup',
|
||||
` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
|
||||
internals.stdout = observing
|
||||
internals.stderr = observing
|
||||
const globals = globalThis as unknown as {
|
||||
__headlessStartupApply: typeof apply
|
||||
__headlessStartupObserved: Observed
|
||||
}
|
||||
globals.__headlessStartupApply = apply
|
||||
globals.__headlessStartupObserved = observed
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) })
|
||||
await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } })
|
||||
await ctx.loader.await()
|
||||
disposers.push(async () => { await ctx.fiber.dispose() })
|
||||
return {
|
||||
task: ctx.get(HEADLESS_STARTUP_SERVICE) as HeadlessStartupValues | undefined,
|
||||
observed,
|
||||
}
|
||||
}
|
||||
|
||||
describe('headless command-line provider', () => {
|
||||
it('joins the task positional into the runner config', async () => {
|
||||
const { task, observed } = await bootStartup(['run', 'the', 'tests'])
|
||||
expect(task).toEqual({ task: 'run the tests' })
|
||||
expect(observed.runnerConfig).toEqual({ task: 'run the tests' })
|
||||
expect(observed.exits).toEqual([])
|
||||
})
|
||||
|
||||
it.each([{ args: [] }, { args: [' '] }])('rejects an invocation with no non-whitespace task ($args)', async ({ args }) => {
|
||||
const { task, observed } = await bootStartup(args)
|
||||
expect(observed.out).toContain('a task is required')
|
||||
expect(task).toBeUndefined()
|
||||
expect(observed.runnerConfig).toBeUndefined()
|
||||
expect(observed.exits).toEqual([1])
|
||||
})
|
||||
|
||||
it('prints its own help and leaves the runner pending', async () => {
|
||||
const { task, observed } = await bootStartup(['--help'])
|
||||
expect(observed.out).toContain('dsh --profile headless')
|
||||
expect(task).toBeUndefined()
|
||||
expect(observed.runnerConfig).toBeUndefined()
|
||||
expect(observed.exits).toEqual([0])
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../boot/cmdline"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/web-app/README.md
|
||||
README.md: d89ae4a7e28506166498caf0032f864bbb109cc5
|
||||
README.zh.md: 746ec2e8b6748a0d72f697d0aea5f3809e7106ee
|
||||
README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd
|
||||
README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Web-surface prompt section and bash runtime variables
|
||||
### Harness-source and Web-surface context
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When `surfaceContext` is true, the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither the section nor the variables are registered.
|
||||
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One prompt paragraph per session plus two managed-environment variable lines; constant per process.
|
||||
One source line and one prompt paragraph per session plus two managed-environment variable lines; constant per process.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、`--dev`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Web 表层提示词段落与 bash 运行时变量
|
||||
### Harness 源码与 Web 表层上下文
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
当 `surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 −98)向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。
|
||||
当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个会话一段提示词,外加两行受管环境变量;每个进程内保持恒定。
|
||||
每个会话一行源码说明和一段提示词,外加两行受管环境变量;每个进程内保持恒定。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
# the profile's own cordis.patch.yml and any --patch overlays still to come.
|
||||
#
|
||||
# A patch replaces the targeted row's whole `config`, so each row below
|
||||
# restates every key it owns. The `dsh web` launcher alias turns --host/--port/
|
||||
# --dev/--trusted-host into further patches over these rows
|
||||
# (`--dev` inserts the dsh-client-hmr row).
|
||||
# restates every key it owns.
|
||||
#
|
||||
# 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.
|
||||
|
||||
# ── surface-specific values the base deliberately omits ─────────────────────
|
||||
|
||||
@@ -76,44 +80,70 @@
|
||||
- id: api-gateway
|
||||
name: '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
# Ordinary provider for the parsed Web flags. Its plugin-level injection
|
||||
# waits for cmdlineArgs; no launcher metadata or special row kind is needed.
|
||||
- id: web-startup
|
||||
name: '@deepseek-ai/dsh-web-app/startup'
|
||||
|
||||
# ── layer 2: transport/service ──────────────────────────────────────────────
|
||||
|
||||
# Plain route-registration carrier; host and port arrive as `dsh web`
|
||||
# flag patches over these defaults. The dist is served by the web-runtime
|
||||
# row below through the fallback seat.
|
||||
# Plain route-registration carrier; host and port come from the app's
|
||||
# webStartup provider, with these deployment fallbacks. The dist is served by
|
||||
# the web-runtime row below through the fallback seat.
|
||||
- id: webserver
|
||||
name: '@deepseek-ai/dsh-host-webserver'
|
||||
inject: [webStartup]
|
||||
config:
|
||||
host: 127.0.0.1
|
||||
port: 3080
|
||||
host: !!js ctx.webStartup.host ?? '127.0.0.1'
|
||||
port: !!js ctx.webStartup.port ?? 3080
|
||||
|
||||
# Web glue owned by this bundle: resolves the built frontend dist (an
|
||||
# assembly fact of dsh-web-app, never user config), mounts the
|
||||
# frontend-static fallback owner, registers the web-surface prompt
|
||||
# section and bash runtime variables, and prints the URL line. `dsh web`
|
||||
# patches mode/lanAddresses over these defaults. A complete agent-preset
|
||||
# section and bash runtime variables, and prints the URL line. The webStartup
|
||||
# provider supplies invocation-only values; after the server binds, this row
|
||||
# samples LAN trust once and provides `webRuntime`. A complete agent-preset
|
||||
# persona suppresses the prompt section for that agent while retaining
|
||||
# these host-owned shell variables.
|
||||
- id: web-runtime
|
||||
name: '@deepseek-ai/dsh-web-app'
|
||||
inject: [webStartup]
|
||||
config:
|
||||
mode: production
|
||||
mode: !!js ctx.webStartup.mode
|
||||
printUrl: true
|
||||
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: node half scans this very tree for dsh.client rows, composes
|
||||
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; 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: 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).
|
||||
- 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.
|
||||
- id: connection
|
||||
name: '@deepseek-ai/dsh-client-connection'
|
||||
inject: [webRuntime]
|
||||
config:
|
||||
# LAN literals derived from the active bind plus --trusted-host extras.
|
||||
# A deployment adding authorities keeps this expression and concatenates
|
||||
# its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts].
|
||||
trustedHosts: !!js ctx.webRuntime.trustedHosts
|
||||
|
||||
- id: api-remotes
|
||||
name: '@deepseek-ai/dsh-api-remotes'
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./startup": {
|
||||
"types": "./lib/types/startup.d.ts",
|
||||
"default": "./lib/startup.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
@@ -22,6 +26,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/startup.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
@@ -33,8 +38,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
@@ -61,6 +66,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-cmdline": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
"@deepseek-ai/dsh-frontend": "workspace:^",
|
||||
"@deepseek-ai/dsh-frontend-static": "workspace:^",
|
||||
@@ -74,15 +80,18 @@
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "^3.18.0"
|
||||
"@deepseek-ai/schemastery": "^3.18.0",
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-bash-env": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-env": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -4,15 +4,19 @@
|
||||
* 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 { networkInterfaces } from 'node:os'
|
||||
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'
|
||||
@@ -22,41 +26,51 @@ import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
/** Stable Cordis plugin name. */
|
||||
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'
|
||||
|
||||
/** Runtime service that releases Web rows after bind-dependent values resolve. */
|
||||
const WEB_RUNTIME_SERVICE = 'webRuntime'
|
||||
|
||||
/** 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 command-line values. */
|
||||
export interface Config {
|
||||
/** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
|
||||
mode: WebMode
|
||||
/** Print the URL line on activation; a headless layer over this bundle turns it off. */
|
||||
/** Print the URL line on activation; a non-interactive layer can turn it off. */
|
||||
printUrl: boolean
|
||||
/**
|
||||
* Register the model-visible surface context (the `app:web-surface` prompt
|
||||
* section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot
|
||||
* layer turns it off: its user is not interacting through the GUI, so the
|
||||
* non-interactive layer can turn it off when its user is not in the GUI, so the
|
||||
* orientation text would be false.
|
||||
*/
|
||||
surfaceContext: boolean
|
||||
/**
|
||||
* LAN IPv4 addresses sampled once by the launcher 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.
|
||||
*/
|
||||
lanAddresses: string[]
|
||||
/** Explicit `--trusted-host` authorities from this invocation. */
|
||||
trustedHosts: string[]
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
mode: z.union([z.const('production'), z.const('development')]).default('production'),
|
||||
printUrl: z.boolean().default(true),
|
||||
surfaceContext: z.boolean().default(true),
|
||||
lanAddresses: z.array(String).default([]),
|
||||
trustedHosts: z.array(String).default([]),
|
||||
})
|
||||
|
||||
/** Bind-dependent Web values shared by the trust fence and URL display. */
|
||||
export interface WebRuntimeValues {
|
||||
/** LAN IPv4 literals sampled once when the server binds all interfaces. */
|
||||
lanAddresses: string[]
|
||||
/** LAN literals followed by explicit invocation authorities. */
|
||||
trustedHosts: string[]
|
||||
}
|
||||
|
||||
/** Environment variable naming the canonical local URL of this Web GUI. */
|
||||
const DSH_WEB_URL = 'DSH_WEB_URL' as const
|
||||
/** Environment variable naming the Web runtime mode. */
|
||||
@@ -65,6 +79,27 @@ const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
|
||||
// Display-only mirror of the webserver schema's loopback host: the address the
|
||||
// local URL always prints. Not a source of truth — the schema is.
|
||||
const LOOPBACK_HOST = '127.0.0.1'
|
||||
/** The webserver schema's all-interfaces bind literal. */
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
/**
|
||||
* Resolve one LAN-trust snapshot from the active server bind.
|
||||
*
|
||||
* Derived entries are port-less IP literals: DNS rebinding needs an
|
||||
* attacker-controlled name, while an IP-literal Host is safe on any port and
|
||||
* an OS-assigned port is unknowable before bind.
|
||||
* @param bindHost - the active webserver bind host.
|
||||
* @param extra - explicit `--trusted-host` values, in argument order.
|
||||
* @returns the LAN display addresses and invocation-derived fence authorities.
|
||||
*/
|
||||
export function resolveLanTrust(bindHost: string, extra: readonly string[]): WebRuntimeValues {
|
||||
const lanAddresses = bindHost === ALL_INTERFACES_HOST
|
||||
? Object.values(networkInterfaces()).flat()
|
||||
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
||||
.map(iface => iface.address)
|
||||
: []
|
||||
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
|
||||
}
|
||||
|
||||
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
|
||||
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
|
||||
@@ -109,11 +144,21 @@ 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 the invocation's client roster and runtime contributions are registered.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
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)
|
||||
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.
|
||||
ctx.provide(WEB_RUNTIME_SERVICE, runtime)
|
||||
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
|
||||
if (config.surfaceContext) {
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
addHarnessSourceSection(promptCtx, SOURCE_ROOT)
|
||||
promptCtx.systemPrompt.section({
|
||||
name: 'app:web-surface',
|
||||
order: -98,
|
||||
@@ -137,21 +182,24 @@ 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
|
||||
// LAN URL must name an address the /api trust fence was configured with.
|
||||
const lanCandidate = config.lanAddresses[0]
|
||||
// Reuse the exact LAN snapshot provided to the /api trust fence.
|
||||
const lanCandidate = runtime.lanAddresses[0]
|
||||
const port = ctx.httpServer.port
|
||||
console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`)
|
||||
}
|
||||
const loader = ctx.get('loader')
|
||||
if (loader === undefined) printUrl()
|
||||
// This row's own activation can precede a sibling failure. The app owns
|
||||
// readiness by waiting for its Loader tree, or prints at once in a
|
||||
// hand-built context without Loader.
|
||||
const settled = ctx.get('loader')?.await()
|
||||
if (settled === undefined) printUrl()
|
||||
else {
|
||||
void loader.await().then(() => {
|
||||
// The tree can be disposed while settlement was in flight (early
|
||||
void settled.then(() => {
|
||||
// The tree can be disposed while the boot was in flight (early
|
||||
// SIGTERM); a URL line for a dead server would only mislead, and
|
||||
// reading the torn-down port would turn a clean shutdown into a crash.
|
||||
if (ctx.get('httpServer') !== undefined) printUrl()
|
||||
})
|
||||
// Loader reports a failed boot; this row only stays quiet.
|
||||
}, () => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
90
packages/bundle/web-app/src/startup.ts
Normal file
90
packages/bundle/web-app/src/startup.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The web app's command-line provider: it parses the `dsh --profile web` flag
|
||||
* family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help`
|
||||
* text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}.
|
||||
* Ordinary rows inject that service before reading it from lazy config.
|
||||
* @module @deepseek-ai/dsh-web-app/startup
|
||||
*/
|
||||
|
||||
import { Command } from 'commander'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'web-startup'
|
||||
|
||||
/** Services required before the flags can be resolved. */
|
||||
export const inject = ['cmdlineArgs']
|
||||
|
||||
/** Service provided by this ordinary plugin and injected by flag-configured rows. */
|
||||
export const WEB_STARTUP_SERVICE = 'webStartup'
|
||||
|
||||
/** What the web rows read from {@link WEB_STARTUP_SERVICE}. */
|
||||
export interface WebStartupValues {
|
||||
/** `--host`, absent when the invocation did not name one. */
|
||||
host?: string
|
||||
/** `--port`, absent when the invocation did not name one. */
|
||||
port?: number
|
||||
/** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */
|
||||
mode: 'production' | 'development'
|
||||
/** Explicit `--trusted-host` authorities, in argument order. */
|
||||
trustedHosts: string[]
|
||||
}
|
||||
|
||||
/** The web flag family, as commander parsed it. */
|
||||
interface WebOptions {
|
||||
host?: string
|
||||
port?: string
|
||||
dev?: boolean
|
||||
trustedHost?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* This app's command: its flags, its description, and its help text.
|
||||
* @returns a fresh program, so one process can parse more than once (tests).
|
||||
*/
|
||||
function webCommand(): Command {
|
||||
return new Command()
|
||||
.name('dsh --profile web')
|
||||
.description('Serve the DeepSeek Harness browser UI.')
|
||||
.helpOption('-h, --help', 'show this help')
|
||||
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
|
||||
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
|
||||
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
dsh --profile web serve on the composed host and port
|
||||
dsh --profile web --port 8080 serve on another port
|
||||
dsh --profile web --host 0.0.0.0 reach it from another machine on the LAN
|
||||
dsh --profile web --dev mount the client-plugin HMR receiver
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the parsed flags into the value injected rows read.
|
||||
* @param program - the parsed web command.
|
||||
* @returns this invocation's immutable Web options.
|
||||
*/
|
||||
function planWebStartup(program: Command): WebStartupValues {
|
||||
const options = program.opts<WebOptions>()
|
||||
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||
}
|
||||
return {
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
mode: options.dev === true ? 'development' : 'production',
|
||||
trustedHosts: options.trustedHost ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and provide the Web invocation as an ordinary Cordis service.
|
||||
* @param ctx - plugin context carrying the command line.
|
||||
* @returns nothing once values are provided, or when the command requested exit.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, webCommand(), planWebStartup)
|
||||
if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values)
|
||||
}
|
||||
135
packages/bundle/web-app/tests/startup.spec.ts
Normal file
135
packages/bundle/web-app/tests/startup.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The Web command-line provider over a real Loader tree: its ordinary service
|
||||
* releases a consumer whose config reads `ctx.webStartup` directly.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { apply, WEB_STARTUP_SERVICE, type WebStartupValues } from '../src/startup.ts'
|
||||
|
||||
/** What one fixture boot observed. */
|
||||
interface Observed {
|
||||
exits: number[]
|
||||
out: string
|
||||
readerConfig?: unknown
|
||||
}
|
||||
|
||||
const disposers: (() => Promise<void>)[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dispose of disposers.splice(0)) await dispose()
|
||||
internals.stdout = process.stdout
|
||||
internals.stderr = process.stderr
|
||||
})
|
||||
|
||||
/**
|
||||
* Mount the real provider and a consumer using injection-ordered config.
|
||||
* @param args - the invocation's inner arguments.
|
||||
* @returns the service value and observed consumer/process effects.
|
||||
*/
|
||||
async function bootProvider(args: string[]): Promise<{
|
||||
values: WebStartupValues | undefined
|
||||
observed: Observed
|
||||
}> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-'))
|
||||
const observed: Observed = { exits: [], out: '' }
|
||||
writeFileSync(join(dir, 'reader.mjs'), `
|
||||
export function apply(_ctx, config) { globalThis.__webStartupObserved.readerConfig = config }
|
||||
`)
|
||||
// Node imports the fixture row outside Vite's source resolver, so delegate
|
||||
// to the source-plane plugin already imported by this test.
|
||||
writeFileSync(join(dir, 'provider.mjs'), `
|
||||
export const name = 'web-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
export const apply = ctx => globalThis.__webStartupApply(ctx)
|
||||
`)
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: reader',
|
||||
` name: ${pathToFileURL(join(dir, 'reader.mjs')).href}`,
|
||||
` inject: [${WEB_STARTUP_SERVICE}]`,
|
||||
' config:',
|
||||
" host: !!js ctx.webStartup.host ?? '127.0.0.1'",
|
||||
' port: !!js ctx.webStartup.port ?? 3080',
|
||||
' mode: !!js ctx.webStartup.mode',
|
||||
' trustedHosts: !!js ctx.webStartup.trustedHosts',
|
||||
'- id: provider',
|
||||
` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
|
||||
internals.stdout = observing
|
||||
internals.stderr = observing
|
||||
const globals = globalThis as unknown as {
|
||||
__webStartupApply: typeof apply
|
||||
__webStartupObserved: Observed
|
||||
}
|
||||
globals.__webStartupApply = apply
|
||||
globals.__webStartupObserved = observed
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) })
|
||||
await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } })
|
||||
await ctx.loader.await()
|
||||
disposers.push(async () => { await ctx.fiber.dispose() })
|
||||
return {
|
||||
values: ctx.get(WEB_STARTUP_SERVICE) as WebStartupValues | undefined,
|
||||
observed,
|
||||
}
|
||||
}
|
||||
|
||||
describe('web command-line provider', () => {
|
||||
it('publishes each flag and releases direct service expressions', async () => {
|
||||
const { values, observed } = await bootProvider([
|
||||
'--host', '0.0.0.0',
|
||||
'--port', '8080',
|
||||
'--dev',
|
||||
'--trusted-host', 'lab.internal', 'lab-2.internal',
|
||||
'--trusted-host', '10.0.0.9',
|
||||
])
|
||||
expect(values).toEqual({
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
mode: 'development',
|
||||
trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'],
|
||||
})
|
||||
expect(observed.readerConfig).toEqual(values)
|
||||
expect(observed.exits).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves deployment values to each consumer when flags omit them', async () => {
|
||||
const { values, observed } = await bootProvider([])
|
||||
expect(values).toEqual({ mode: 'production', trustedHosts: [] })
|
||||
expect(observed.readerConfig).toEqual({
|
||||
host: '127.0.0.1',
|
||||
port: 3080,
|
||||
mode: 'production',
|
||||
trustedHosts: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('prints its own help and leaves the consumer pending', async () => {
|
||||
const { values, observed } = await bootProvider(['--help'])
|
||||
expect(observed.out).toContain('dsh --profile web')
|
||||
expect(observed.out).toContain('--trusted-host')
|
||||
expect(values).toBeUndefined()
|
||||
expect(observed.readerConfig).toBeUndefined()
|
||||
expect(observed.exits).toEqual([0])
|
||||
})
|
||||
|
||||
it('rejects a non-numeric port before the consumer activates', async () => {
|
||||
const { values, observed } = await bootProvider(['--port', 'abc'])
|
||||
expect(observed.out).toContain('--port must be a number')
|
||||
expect(values).toBeUndefined()
|
||||
expect(observed.readerConfig).toBeUndefined()
|
||||
expect(observed.exits).toEqual([1])
|
||||
})
|
||||
})
|
||||
34
packages/bundle/web-app/tests/trusted-hosts.spec.ts
Normal file
34
packages/bundle/web-app/tests/trusted-hosts.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveLanTrust } from '../src/index.ts'
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
networkInterfaces: () => ({
|
||||
lo0: [
|
||||
{ family: 'IPv4', internal: true, address: '127.0.0.1' },
|
||||
],
|
||||
en0: [
|
||||
{ family: 'IPv6', internal: false, address: 'fe80::1' },
|
||||
{ family: 'IPv4', internal: false, address: '192.168.1.5' },
|
||||
],
|
||||
en1: [
|
||||
{ family: 'IPv4', internal: false, address: '10.0.0.7' },
|
||||
],
|
||||
utun0: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('resolveLanTrust', () => {
|
||||
it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
|
||||
const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
|
||||
expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
|
||||
expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
|
||||
})
|
||||
|
||||
it('derives nothing for a loopback bind — extras alone stand, no LAN URL to print', () => {
|
||||
expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
|
||||
expect(resolveLanTrust('127.0.0.1', ['lab.internal']))
|
||||
.toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
* runtime's bind-dependent LAN snapshot.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
@@ -14,6 +14,14 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { apply, Config, internals } from '../src/index.ts'
|
||||
|
||||
vi.mock('node:os', async importOriginal => ({
|
||||
...await importOriginal<typeof import('node:os')>(),
|
||||
networkInterfaces: () => ({
|
||||
lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
|
||||
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }],
|
||||
}),
|
||||
}))
|
||||
|
||||
let dist: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
@@ -36,9 +44,10 @@ function stageDist(): string {
|
||||
}
|
||||
|
||||
/** A fake httpServer capturing the fallback seat and index taps. */
|
||||
function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } {
|
||||
function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: HttpServerService; seat: () => unknown } {
|
||||
let fallback: unknown
|
||||
const server = {
|
||||
host,
|
||||
port: 4567,
|
||||
registerFallback: (handler: unknown) => {
|
||||
fallback = handler
|
||||
@@ -49,6 +58,19 @@ function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } {
|
||||
return { server, seat: () => fallback }
|
||||
}
|
||||
|
||||
/** Install the optional HMR row the runtime sequences before client discovery. */
|
||||
function provideHmrRow(ctx: Context, settle: () => Promise<void> = async () => {}): string[] {
|
||||
const updates: string[] = []
|
||||
ctx.provide('loader', {
|
||||
entries: () => [{
|
||||
options: { id: 'client-hmr' },
|
||||
enableRuntime: async () => { updates.push('client-hmr') },
|
||||
}],
|
||||
await: settle,
|
||||
} as never)
|
||||
return updates
|
||||
}
|
||||
|
||||
interface BashContribution {
|
||||
name: string
|
||||
variables: Record<string, { description: string }>
|
||||
@@ -59,7 +81,7 @@ describe('web-app runtime glue', () => {
|
||||
it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
const { server, seat } = fakeHttpServer()
|
||||
const { server, seat } = fakeHttpServer('0.0.0.0')
|
||||
ctx.provide('httpServer', server)
|
||||
const contributions: BashContribution[] = []
|
||||
ctx.provide('bashEnv', {
|
||||
@@ -68,15 +90,22 @@ describe('web-app runtime glue', () => {
|
||||
return () => {}
|
||||
},
|
||||
} as never)
|
||||
const enabledRows = provideHmrRow(ctx)
|
||||
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, 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(ctx.get('webRuntime')).toEqual({
|
||||
lanAddresses: ['192.168.1.5'],
|
||||
trustedHosts: ['192.168.1.5', 'lab.internal'],
|
||||
})
|
||||
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 +119,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, trustedHosts: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
@@ -111,11 +140,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, trustedHosts: [] }))
|
||||
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,13 +155,13 @@ 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, trustedHosts: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('defers the URL line until Loader settlement and drops it when the server is gone', async () => {
|
||||
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
|
||||
// RPC immediately after observing it.
|
||||
@@ -139,9 +169,9 @@ describe('web-app runtime glue', () => {
|
||||
settled.provide('httpServer', fakeHttpServer().server)
|
||||
let release: () => void
|
||||
const settlement = new Promise<void>((resolve) => { release = resolve })
|
||||
settled.provide('loader', { await: () => settlement } as never)
|
||||
provideHmrRow(settled, () => settlement)
|
||||
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, trustedHosts: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
release!()
|
||||
@@ -149,6 +179,17 @@ describe('web-app runtime glue', () => {
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
||||
await settled.fiber.dispose()
|
||||
|
||||
// Failed path: Loader reports the sibling failure; the app prints no URL
|
||||
// for a process that is about to exit.
|
||||
log.mockClear()
|
||||
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: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
await failed.fiber.dispose()
|
||||
|
||||
// Torn-down path: settlement resolves after the webserver is gone — no
|
||||
// line, no crash.
|
||||
log.mockClear()
|
||||
@@ -159,8 +200,8 @@ describe('web-app runtime glue', () => {
|
||||
await child
|
||||
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: [] }))
|
||||
provideHmrRow(torn, () => tornSettlement)
|
||||
await 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))
|
||||
@@ -176,7 +217,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, trustedHosts: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../boot/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../boot/cmdline"
|
||||
},
|
||||
{
|
||||
"path": "../../host/frontend-static"
|
||||
},
|
||||
|
||||
@@ -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/core/agent-default-model/README.md
|
||||
README.md: 98bc7d082e62a764868f8acd323c4617e9839e61
|
||||
README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080
|
||||
README.md: e86be7c37a1f994ca52f018144ef6a2409bd1eea
|
||||
README.zh.md: 00250c28ef8c03d4b33fe1c1bfca138a022f6638
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh run` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults.
|
||||
The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh --profile headless` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults.
|
||||
|
||||
The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh run` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。
|
||||
该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh --profile headless` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。
|
||||
|
||||
插件配置必须提供 `{ provider, model }`。该组合配置项构成 Settings 中 `agent-default-model` 分节的基础层;挂载的设置提供方在其上叠加用户选择,更改会在下一次调用 `currentSelection()` 时可见。`reasoningEffort` 属于该 Settings 分节,但特意不属于插件配置:完整保存的选择必须能在下一个选定模型没有推理(reasoning)强度时清除旧值,而组合配置值会再次被继承。
|
||||
|
||||
|
||||
@@ -113,27 +113,19 @@ describe('config-driven session id', () => {
|
||||
const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), provider: 'mock', model: 'mock' }] }
|
||||
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
let first: Agent | undefined
|
||||
for (let i = 0; i < 50 && first === undefined; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
first = ctx.agents.get(SessionId('config-exact-reload'))
|
||||
}
|
||||
expect(first).toBeDefined()
|
||||
first!.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, first!)
|
||||
await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined()
|
||||
const first = ctx.agents.get(SessionId('config-exact-reload'))!
|
||||
first.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, first)
|
||||
await firstLoop.dispose()
|
||||
|
||||
const secondLoop = await ctx.plugin(AgentLoop, config)
|
||||
let second: Agent | undefined
|
||||
for (let i = 0; i < 50 && second === undefined; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
second = ctx.agents.get(SessionId('config-exact-reload'))
|
||||
}
|
||||
expect(second).toBeDefined()
|
||||
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
|
||||
second!.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, second!)
|
||||
await ctx.sessions.flush(second!.session)
|
||||
await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined()
|
||||
const second = ctx.agents.get(SessionId('config-exact-reload'))!
|
||||
expect(JSON.stringify(second.session.deriveMessages())).toContain('remember me')
|
||||
second.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, second)
|
||||
await ctx.sessions.flush(second.session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
|
||||
expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
|
||||
@@ -423,18 +415,14 @@ describe('config-driven session id', () => {
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
|
||||
|
||||
// The deferred resume runs on a microtask after the backend is available.
|
||||
let resumed: Agent | undefined
|
||||
for (let i = 0; i < 50 && !resumed; i++) {
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
resumed = ctx2.agents.get(SessionId('sticky-1'))
|
||||
}
|
||||
expect(resumed).toBeDefined()
|
||||
// The deferred resume runs after the backend is available.
|
||||
await expect.poll(() => ctx2.agents.get(SessionId('sticky-1')), { timeout: 5_000 }).toBeDefined()
|
||||
const resumed = ctx2.agents.get(SessionId('sticky-1'))!
|
||||
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),
|
||||
// and the prior turn's user message is in the derived history.
|
||||
expect(resumed!.id).toBe(SessionId('sticky-1'))
|
||||
expect(resumed!.session.id).toBe('sticky-1')
|
||||
const derived = resumed!.session.deriveMessages()
|
||||
expect(resumed.id).toBe(SessionId('sticky-1'))
|
||||
expect(resumed.session.id).toBe('sticky-1')
|
||||
const derived = resumed.session.deriveMessages()
|
||||
expect(JSON.stringify(derived)).toContain('remember me')
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -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/examples/README.md
|
||||
README.md: d8369b1e263e72c7b0ac1687c3b14a5d723ab944
|
||||
README.zh.md: acb402e925f692beaacbe0ab4e029691d664dbe8
|
||||
README.md: 0048d14ec49776f036d841bbc0579a6867e513bb
|
||||
README.zh.md: 1b7acc5646071f4fc21e9238e4e440c192a1e82a
|
||||
|
||||
@@ -10,7 +10,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
|
||||
| [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle |
|
||||
| [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime |
|
||||
|
||||
`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it.
|
||||
`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh --profile headless`; no package in this directory provides it.
|
||||
|
||||
These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP(Agent Client Protocol)自动化应用组合包 |
|
||||
| [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | 外部配置 JSON-RPC 运行时 |
|
||||
|
||||
`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh run` 提供;本目录没有任何包提供该功能。
|
||||
`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh --profile headless` 提供;本目录没有任何包提供该功能。
|
||||
|
||||
这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。
|
||||
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 03726c6671ec711704870d23722d83c72d9c4d35
|
||||
README.zh.md: f001866af2015671ed6429b392e3f880000e6c38
|
||||
README.md: d59f3f5ddb9929356e23a467ce5840f1673663f7
|
||||
README.zh.md: 737361f2bd85c0ea02b9d29734f58c34bc324969
|
||||
|
||||
@@ -56,7 +56,7 @@ The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-pag
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package.
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh --profile headless` is a direct core entry point and does not mount this package.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh run` 是直连 core 的入口,不挂载本包。
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
Reference in New Issue
Block a user