fix(web): include the HMR receiver in the initial client graph

This commit is contained in:
Turtle
2026-08-10 19:58:40 +08:00
parent 37ee7b0f24
commit a4d8c0da9b
19 changed files with 125 additions and 48 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md
2026-08-06-app-owned-command-line.md: 21433d96d1dbcb26f4104fffb5a78b389d78bca8 2026-08-06-app-owned-command-line.md: 8556c2bbe27189a0784edf4b2a376c932807e020
2026-08-06-app-owned-command-line.zh.md: 7b123f89c8f844ae396df09136d69215f5ad8d26 2026-08-06-app-owned-command-line.zh.md: f5a7be3500f239e03e0f05d724fa53ffaf28e624
@@ -27,7 +27,7 @@ Four framework facts shape the mechanism:
- **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context. - **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context.
- **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services. - **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services.
- **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. - **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services.
- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain); the enabled row then follows ordinary injection ordering. - **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain). Enablement is an in-memory Loader override rather than an options rewrite, so Include reapplication cannot silently disable it. The Web bundle also starts client discovery only after enabling the optional row, ensuring the first browser graph already contains its HMR receiver.
This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services.
@@ -27,7 +27,7 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo
- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。 - **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。
- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。 - **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。
- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 - **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。
- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路);启用后的行继续遵循普通注入顺序。 - **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路)。启用采用 Loader 的内存覆盖而非改写选项,因此 Include 重新应用配置时不会悄然将其禁用。Web 组合包还会在启用可选行之后才启动客户端发现,确保首份浏览器图中已经包含 HMR 接收端。
这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md # pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 836a7f6a81f8c77be12fd10be5b8204be8c1acc0 config-catalog.md: 64e65e93b165ede2ac6c8fa399b9ce461938b939
config-catalog.zh.md: 22dca1252d93ea9ce223464079f3c51c35eeb89d config-catalog.zh.md: 9f4a7ab071d68cfaf8ae3ea42458babfee67c9fd
+1 -1
View File
@@ -2546,7 +2546,7 @@ export interface Config {
export type WebMode = 'production' | 'development' export type WebMode = 'production' | 'development'
``` ```
Source: [`packages/bundle/web-app/src/index.ts:41`](../packages/bundle/web-app/src/index.ts) Source: [`packages/bundle/web-app/src/index.ts:40`](../packages/bundle/web-app/src/index.ts)
## `@deepseek-ai/dsh-web-fetch-local` ## `@deepseek-ai/dsh-web-fetch-local`
+1 -1
View File
@@ -2547,7 +2547,7 @@ export interface Config {
export type WebMode = 'production' | 'development' export type WebMode = 'production' | 'development'
``` ```
来源:[`packages/bundle/web-app/src/index.ts:41`](../packages/bundle/web-app/src/index.ts) 来源:[`packages/bundle/web-app/src/index.ts:40`](../packages/bundle/web-app/src/index.ts)
## `@deepseek-ai/dsh-web-fetch-local` ## `@deepseek-ai/dsh-web-fetch-local`
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md
README.md: dc267080d32d492e132df4592ddf742454a95ad2 README.md: 571ea7acf9f7be1ee2bdadafae2fc71b99d4536a
README.zh.md: e183156ab4a7907f8ae1e3259b2e09d458cec47d README.zh.md: 271acd6be4d58bf12d41bc02dd3ccabc7359a269
+1 -1
View File
@@ -56,7 +56,7 @@ Every row the app configures from flags then reads what the startup row resolved
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. 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). Loader applies the enabled row's ordinary injection ordering. `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.
### One command line, one owner ### One command line, one owner
+1 -1
View File
@@ -56,7 +56,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字
Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。 Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。Loader 会对启用后的行应用普通的注入顺序。 `enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。
### 一条命令行,一个所有者 ### 一条命令行,一个所有者
+3 -1
View File
@@ -221,6 +221,8 @@ export function runStartup<T>(
* A row cannot be inserted from inside a mounting plugin — the Loader returns a * 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 * 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. * 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 ctx - plugin context whose Loader tree carries the row.
* @param id - the row id. * @param id - the row id.
* @returns nothing once the row has started or is waiting for its dependencies. * @returns nothing once the row has started or is waiting for its dependencies.
@@ -231,7 +233,7 @@ export async function enableRow(ctx: Context, id: string): Promise<void> {
if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service') 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) 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`) if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`)
await entry.update({ disabled: false }) await entry.enableRuntime()
} }
/** /**
+49 -3
View File
@@ -234,17 +234,63 @@ describe('enableRow', () => {
await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service') await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service')
const ctx = new Context() const ctx = new Context()
let update: unknown let enabled = false
ctx.provide('loader', { ctx.provide('loader', {
entries: () => [{ entries: () => [{
options: { id: 'client-hmr' }, options: { id: 'client-hmr' },
update: async (options: unknown) => { update = options }, enableRuntime: async () => { enabled = true },
}], }],
} as never) } as never)
await enableRow(ctx, 'client-hmr') await enableRow(ctx, 'client-hmr')
expect(update).toEqual({ disabled: false }) expect(enabled).toBe(true)
await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable') 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', () => { describe('provideCmdline', () => {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
README.md: fb6a1a3ee5293c7e90afae11a76fe5a8598f3ee8 README.md: 47b582225e768ac035d12947939c7a7eb700458c
README.zh.md: d8276514d94e658788371034795a073abefbf6ac README.zh.md: 61e134f90e7ae57cb6220e92880c001f0d06bae2
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md) 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 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. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`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, lanAddresses}`). 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, 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. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
## Model Experience ## Model Experience
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文 [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 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `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`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`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, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `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`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
## 模型体验 ## 模型体验
+8 -6
View File
@@ -116,8 +116,8 @@
lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? [] lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? []
# The client-plugin reload chain: a dev-only row this bundle ships off, # The client-plugin reload chain: a dev-only row this bundle ships off,
# which the runtime row turns on for `--dev`. It is a row rather than a # which the runtime row turns on before client discovery. It is a row rather
# child of web-runtime because its node half is a client-side package, # than a child of web-runtime because its node half is a client-side package,
# which a host-side bundle cannot import. # which a host-side bundle cannot import.
- id: client-hmr - id: client-hmr
name: '@deepseek-ai/dsh-client-hmr' name: '@deepseek-ai/dsh-client-hmr'
@@ -126,12 +126,14 @@
# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──
# Dual-face: node half scans this very tree for dsh.client rows, composes # Dual-face: this waits for the runtime row to decide whether HMR belongs
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the # in the first graph. The node half then scans this tree, composes
# module table the shell kernel constructs before cordis exists (adopted # window.__DSH_BOOT__, and serves /plugins/<id>/client.js; the browser half
# as a plugin entry by the kernel, never fetched). # is the module table the shell kernel constructs before cordis exists
# (adopted as a plugin entry by the kernel, never fetched).
- id: modules - id: modules
name: '@deepseek-ai/dsh-client-modules' name: '@deepseek-ai/dsh-client-modules'
inject: [webClientRoster]
# Owns both ends of the web transport: node half binds the gateway to the # Owns both ends of the web transport: node half binds the gateway to the
# webserver under /api; browser half is the fetch/SSE client. # webserver under /api; browser half is the fetch/SSE client.
+9 -8
View File
@@ -25,11 +25,10 @@ import type {} from '@deepseek-ai/dsh-bash-env'
/** Stable Cordis plugin name. */ /** Stable Cordis plugin name. */
export const name = 'web-app' export const name = 'web-app'
/** The client-plugin reload chain row this bundle ships disabled, for `--dev`. */
const HMR_ROW_ID = 'client-hmr'
/** This dsh installation's root, from either this package's source or built entry. */ /** This dsh installation's root, from either this package's source or built entry. */
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
const HMR_ROW_ID = 'client-hmr'
const CLIENT_ROSTER_SERVICE = 'webClientRoster'
/** Services required before the web runtime can mount. */ /** Services required before the web runtime can mount. */
export const inject = ['httpServer'] export const inject = ['httpServer']
@@ -118,14 +117,16 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex
* variables, and the URL line. * variables, and the URL line.
* @param ctx - plugin context carrying the httpServer service. * @param ctx - plugin context carrying the httpServer service.
* @param config - validated {@link Config}. * @param config - validated {@link Config}.
* @returns nothing once optional development rows are active and runtime contributions are registered. * @returns nothing once the invocation's client roster and runtime contributions are registered.
*/ */
export async function apply(ctx: Context, config: Config): Promise<void> { export async function apply(ctx: Context, config: Config): Promise<void> {
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) // Client discovery must start after the optional HMR row has a pending
// The client-plugin reload chain is a row this bundle ships off, because it // fiber. Otherwise its first browser graph omits the reload receiver, which
// exists only in development. Turning it on belongs here rather than in the // cannot use that receiver to discover itself later.
// startup row: it needs host services that also activate after webStartup.
if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID) if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID)
// Release client discovery only after the optional row has a pending fiber.
ctx.provide(CLIENT_ROSTER_SERVICE, true)
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
if (config.surfaceContext) { if (config.surfaceContext) {
ctx.inject(['systemPrompt'], (promptCtx) => { ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, SOURCE_ROOT) addHarnessSourceSection(promptCtx, SOURCE_ROOT)
+19 -12
View File
@@ -49,6 +49,19 @@ function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } {
return { server, seat: () => fallback } 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 { interface BashContribution {
name: string name: string
variables: Record<string, { description: string }> variables: Record<string, { description: string }>
@@ -68,14 +81,7 @@ describe('web-app runtime glue', () => {
return () => {} return () => {}
}, },
} as never) } as never)
const hmrUpdates: unknown[] = [] const enabledRows = provideHmrRow(ctx)
ctx.provide('loader', {
entries: () => [{
options: { id: 'client-hmr' },
update: async (options: unknown) => { hmrUpdates.push(options) },
}],
await: async () => {},
} as never)
const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] }))
await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(SystemPrompt, { persona: '' })
@@ -83,7 +89,8 @@ describe('web-app runtime glue', () => {
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
expect(seat()).toBeDefined() // frontend-static claimed the fallback expect(seat()).toBeDefined() // frontend-static claimed the fallback
expect(hmrUpdates).toEqual([{ disabled: false }]) expect(enabledRows).toEqual(['client-hmr'])
expect(ctx.get('webClientRoster')).toBe(true)
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
const assembly = await ctx.systemPrompt.assemble() const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
@@ -148,7 +155,7 @@ describe('web-app runtime glue', () => {
// this row itself has activated. // this row itself has activated.
const ready = new Context() const ready = new Context()
ready.provide('httpServer', fakeHttpServer().server) ready.provide('httpServer', fakeHttpServer().server)
ready.provide('loader', { await: () => Promise.resolve() } as never) provideHmrRow(ready)
let announce: () => void let announce: () => void
ready.provide('appReady', new Promise<void>((resolve) => { announce = resolve })) ready.provide('appReady', new Promise<void>((resolve) => { announce = resolve }))
const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const log = vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -182,7 +189,7 @@ describe('web-app runtime glue', () => {
settled.provide('httpServer', fakeHttpServer().server) settled.provide('httpServer', fakeHttpServer().server)
let release: () => void let release: () => void
const settlement = new Promise<void>((resolve) => { release = resolve }) 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(() => {}) const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
@@ -202,7 +209,7 @@ describe('web-app runtime glue', () => {
await child await child
let releaseTorn: () => void let releaseTorn: () => void
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve }) const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
torn.provide('loader', { await: () => tornSettlement } as never) provideHmrRow(torn, () => tornSettlement)
await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await child.dispose() // the httpServer service goes away await child.dispose() // the httpServer service goes away
releaseTorn!() releaseTorn!()
+1
View File
@@ -46,6 +46,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures.
15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). 15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
16. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. 16. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`.
17. **In-memory Loader entry activation in `loader/src/config/entry.ts`**: an invocation can activate a row shipped with `disabled: true` without mutating its serialized options. The override belongs to the mounted entry object, survives Include config reapplication, respects disabled ancestors, and disappears with the entry. Covered by `packages/boot/cmdline/tests/cmdline.spec.ts` and `apps/web/tests/hmr-live.e2e.ts`.
## Sync procedure ## Sync procedure
+20 -2
View File
@@ -73,6 +73,8 @@ export class Entry {
_initTask?: Promise<void> _initTask?: Promise<void>
_disposing = 0 _disposing = 0
private runtimeEnabled = false
private runtimeEnableTask?: Promise<void>
constructor(public loader: Loader) { constructor(public loader: Loader) {
this.ctx = loader.ctx.extend({ [Entry.key]: this }) this.ctx = loader.ctx.extend({ [Entry.key]: this })
@@ -99,15 +101,31 @@ export class Entry {
private _disabled(options: EntryOptions) { private _disabled(options: EntryOptions) {
// group is always enabled // group is always enabled
if (options.group) return false if (options.group) return false
if (options.disabled) return true if (options.disabled && !this.runtimeEnabled) return true
let entry = this.parent.ctx.fiber.entry let entry = this.parent.ctx.fiber.entry
while (entry) { while (entry) {
if (entry.options.disabled) return true if (entry.options.disabled && !entry.runtimeEnabled) return true
entry = entry.parent.ctx.fiber.entry entry = entry.parent.ctx.fiber.entry
} }
return false return false
} }
/**
* Enable this in-memory entry without rewriting its configured `disabled`
* value; the override survives config reapplication for this entry object.
* @returns a promise settling after its initial activation attempt.
*/
enableRuntime(): Promise<void> {
if (this.runtimeEnableTask !== undefined) return this.runtimeEnableTask
this.runtimeEnabled = true
this.runtimeEnableTask = this.refresh().catch((error: unknown) => {
this.runtimeEnabled = false
this.runtimeEnableTask = undefined
throw error
})
return this.runtimeEnableTask
}
evaluate(expr: string) { evaluate(expr: string) {
return evaluate(this.ctx, expr) return evaluate(this.ctx, expr)
} }