Explorar o código

fix: client module loader report package resolve info

imccyu hai 1 mes
pai
achega
4517aa349c

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -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 .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: b0873b7aa7bccd3d613f3113fa18207770952e5d
-2026-07-23-client-plugin-loading-model.zh.md: f3472dbfc5a78924e77337bf92ce5983c8492c4c
+2026-07-23-client-plugin-loading-model.md: 02347f2964942b89ec1f0a6ec483f4c2b2f9e68c
+2026-07-23-client-plugin-loading-model.zh.md: ea927d35860fbbba567c47cea0ee3a45133ce0f4

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -56,8 +56,8 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
 
 **Host side — compose the graph.**
 
-1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`.
-2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports).
+1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
+2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
 
 Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -56,8 +56,8 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
 
 **host 侧——组合这张图。**
 
-1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获
-2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)
+1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))
+2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误
 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
 
 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。

+ 2 - 2
packages/client/modules/README.i18n.yaml

@@ -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/client/modules/README.md
-README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
-README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72
+README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
+README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f

+ 2 - 0
packages/client/modules/README.md

@@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
 
 Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
 
+The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
+
 ## Model Experience
 
 None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.

+ 2 - 0
packages/client/modules/README.zh.md

@@ -8,6 +8,8 @@
 
 解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。
 
+Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
+
 ## 模型体验
 
 无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。

+ 47 - 6
packages/client/modules/src/index.ts

@@ -58,6 +58,40 @@ interface PkgMeta {
   immediately: boolean
 }
 
+/** Missing built client export, retained as structured data for activation-error grouping. */
+class MissingClientBundleError extends Error {
+  constructor(
+    readonly packageName: string,
+    readonly clientPath: string,
+    cause: unknown,
+  ) {
+    super(
+      `client-modules: ${packageName} needs to be built before source launch; client bundle not found at ${clientPath}`,
+      { cause },
+    )
+  }
+}
+
+/** Activation failures grouped by actionable package-build errors and unrelated failures. */
+class ClientPackageCompositionError extends AggregateError {
+  constructor(failures: Error[]) {
+    const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
+    const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
+    const packageNoun = failures.length === 1 ? 'package' : 'packages'
+    const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
+    if (missingBundles.length > 0) {
+      lines.push('  client packages requiring a build before source launch:')
+      for (const error of missingBundles) {
+        lines.push(`    - package: ${error.packageName}`, `      path: ${error.clientPath}`)
+      }
+    }
+    if (otherFailures.length > 0) {
+      lines.push('  other failures:', ...otherFailures.map(error => `    - ${error.message}`))
+    }
+    super(failures, lines.join('\n'))
+  }
+}
+
 /** One composed table row: the wire entry plus its bundle path. */
 interface WebPluginRecord {
   entry: WebBootEntry
@@ -138,7 +172,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
  * + bundle route + index tap. Construction runs the activation scan
  * synchronously — a malformed declaration or missing bundle among the
  * already-loaded entries aggregates into one loud throw (FAILED fiber; the
- * boot sweep reports it).
+ * boot activation audit reports it).
  */
 export class ClientModuleHostService extends Service {
   static inject = ['httpServer', 'loader']
@@ -194,10 +228,7 @@ export class ClientModuleHostService extends Service {
     const failures: Error[] = []
     this.flush(err => failures.push(err))
     if (failures.length > 0) {
-      throw new AggregateError(
-        failures,
-        `client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => `  - ${e.message}`).join('\n')}`,
-      )
+      throw new ClientPackageCompositionError(failures)
     }
 
     ctx.effect(
@@ -322,6 +353,16 @@ export class ClientModuleHostService extends Service {
     return meta
   }
 
+  /** Read the activation-time bundle revision, translating only a missing build artifact into source-launch guidance. */
+  private initialBundleRevision(pkgName: string, clientPath: string): string {
+    try {
+      return shortHash(readFileSync(clientPath))
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+      throw new MissingClientBundleError(pkgName, clientPath, error)
+    }
+  }
+
   /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
   private processOne(entryName: string): boolean {
     let qualifies = false
@@ -337,7 +378,7 @@ export class ClientModuleHostService extends Service {
     if (meta === null) return false
     // The rev rides the row from here on: a fiber restart reuses the row (and
     // its rev) untouched; only rebuilt() re-reads the bundle.
-    const rev = shortHash(readFileSync(meta.clientPath))
+    const rev = this.initialBundleRevision(entryName, meta.clientPath)
     this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
     return true
   }

+ 85 - 0
packages/client/modules/tests/node-half.spec.ts

@@ -0,0 +1,85 @@
+/** Node-half composition diagnostics for package metadata and built client bundles. */
+
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from 'cordis'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
+import { ClientModuleHostService } from '../src/index.ts'
+
+let root: string | undefined
+
+afterEach(() => {
+  if (root !== undefined) rmSync(root, { recursive: true, force: true })
+  root = undefined
+})
+
+/** Create a resolvable dshClient package whose client export points at the returned path. */
+function writePackage(packageName: string): string {
+  root ??= mkdtempSync(join(tmpdir(), 'dsh-client-modules-'))
+  const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
+  const clientPath = join(pkgRoot, 'lib', 'client.js')
+  mkdirSync(pkgRoot, { recursive: true })
+  writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
+    name: packageName,
+    exports: {
+      './client': './lib/client.js',
+      './package.json': './package.json',
+    },
+    dshClient: { platform: 'web' },
+  }))
+  return clientPath
+}
+
+/** Construct the node-half service over the enabled fixture entries. */
+function construct(packageNames: string[]): ClientModuleHostService {
+  const ctx = new Context()
+  ctx.baseUrl = pathToFileURL(root!).href + '/'
+  ctx.provide('loader', {
+    *entries() {
+      for (const packageName of packageNames) {
+        yield { options: { name: packageName }, fiber: {}, disabled: false }
+      }
+    },
+  })
+  const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
+    port: 0,
+    register: () => () => {},
+    tapIndex: () => () => {},
+  }
+  ctx.provide('httpServer', httpServer as HttpServerService)
+  return new ClientModuleHostService(ctx)
+}
+
+describe('client bundle activation', () => {
+  it('groups missing bundles under one source-build instruction with a package/path list', () => {
+    const firstName = '@fixture/missing-first'
+    const secondName = '@fixture/missing-second'
+    const firstPath = writePackage(firstName)
+    const secondPath = writePackage(secondName)
+    expect(() => construct([firstName, secondName])).toThrow([
+      'client-modules: 2 client packages failed to compose:',
+      '  client packages requiring a build before source launch:',
+      `    - package: ${firstName}`,
+      `      path: ${firstPath}`,
+      `    - package: ${secondName}`,
+      `      path: ${secondPath}`,
+    ].join('\n'))
+  })
+
+  it('does not report other bundle read failures as missing builds', () => {
+    const packageName = '@fixture/unreadable-client'
+    const clientPath = writePackage(packageName)
+    mkdirSync(clientPath, { recursive: true })
+    let thrown: unknown
+    try {
+      construct([packageName])
+    } catch (error) {
+      thrown = error
+    }
+    expect(String(thrown)).toContain('EISDIR')
+    expect(String(thrown)).not.toContain('requiring a build before source launch')
+  })
+})