Explorar el Código

Merge branch 'worktree/pr3594-manifest-mergeup' into feat/plugin-mgmt-3-settings

Yichen Jiang hace 1 mes
padre
commit
a840641d78

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.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-09-11-native-entry-diagnostics-and-static-plugin-declarations.md
-2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md: ff2024ce0ad82a43692a0a1f77d906789320ea13
-2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md: 7fc80b864e9d92e72756e7dc7470debc9d41b793
+2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md: 72e537a31c7ca8e770991ed5f70fc7c922873305
+2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md: d14acc21cfe9d04f57920824068baac8cb2573c4

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md

@@ -16,7 +16,7 @@ Third-party plugin failures must leave the application’s management endpoints
 
 **Diagnostics describe attempts and running instances separately.** `Entry.lastFailure` passively retains import, activation or update errors where the Loader already catches or propagates them. It does not restore transactions or change native continuation. Asynchronous activation failures come from `fiber.await()`, pending dependencies from injection state, and disabled-expression failures from evaluating the effective gate. An active old fiber can coexist with an update failure. Removed entries need no separate failure registry. Nested continuation failures remain advisory; readiness does not promise every nested plugin callback succeeded.
 
-**Recomposition is serialized and nontransactional.** The launcher provides `ProfileRuntime` before startup auditing. Preparation rejects malformed files before mutation. Accepted updates settle current Loader work and await removed fibers before publishing composition facts and returning issues. Old fiber references are necessary because removed entries are absent from Loader task enumeration. Successful sibling changes remain applied after another entry fails. Fresh patch copies prevent a later override from mutating data reused by another composition.
+**Recomposition is serialized and nontransactional.** The launcher provides `ProfileRuntime` before startup auditing. Preparation rejects malformed files before mutation. Accepted updates settle current Loader work and await removed fibers before publishing composition facts and returning issues. Old fiber references are necessary because removed entries are absent from Loader task enumeration. Successful sibling changes remain applied after another entry fails. Fresh patch copies prevent a later override from mutating data reused by another composition. `ProfileRuntime` declares Loader as a required injection so both root and plugin-context handles can reconcile and inspect the tree.
 
 **Discovery reads declarations without executing modules.** `readPackageMetadata` reads package identity, bundle patches and explicit `dsh.plugins` entries. `.` names the main export; subpaths and default configs remain available for composition editors. Undeclared packages remain installed and unknown. A declaration does not certify importability, Config validity or activation. Physical Cordis peer resolution is advisory and cannot detect an inlined copy. Runtime validation belongs to each actual mounted row, including separate mounts of one module in different presets.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 **诊断分别描述操作尝试与运行实例。** `Entry.lastFailure` 在 Loader 已有的捕获或传播位置被动保留导入、激活或更新错误。它不恢复事务,也不改变原生继续加载行为。异步激活失败取自 `fiber.await()`,等待依赖取自注入状态,disabled 表达式失败取自有效条件的求值。活跃的旧 fiber 可以与更新失败同时存在。移除的条目无需独立失败注册表。嵌套延续任务失败仍为提示信息;就绪不保证每个嵌套插件回调均成功。
 
-**重组串行执行且不具备事务性。** launcher 在启动审计前提供 `ProfileRuntime`。准备阶段在修改运行树前拒绝无效文件。已接受的更新等待当前 Loader 工作与已移除 fiber 完成后,发布组合信息并返回问题。必须保留旧 fiber 引用,因为已移除的条目不在 Loader 任务枚举中。某行失败后,其他行已成功的变化保留。新的 patch 副本避免后续覆盖修改其他重组会复用的数据。
+**重组串行执行且不具备事务性。** launcher 在启动审计前提供 `ProfileRuntime`。准备阶段在修改运行树前拒绝无效文件。已接受的更新等待当前 Loader 工作与已移除 fiber 完成后,发布组合信息并返回问题。必须保留旧 fiber 引用,因为已移除的条目不在 Loader 任务枚举中。某行失败后,其他行已成功的变化保留。新的 patch 副本避免后续覆盖修改其他重组会复用的数据。 `ProfileRuntime` 将 Loader 声明为必需注入,使根上下文与插件上下文取得的句柄都能重组并检查配置树。
 
 **发现阶段只读声明,不执行模块。** `readPackageMetadata` 读取包身份、组合包 patch 和显式 `dsh.plugins` 条目。`.` 表示主入口;子路径与默认配置继续供组合编辑器使用。未声明的包保持已安装和未知状态。声明不证明可导入、Config 有效或激活成功。Cordis 物理 peer 解析仅供提示,无法识别内联副本。运行校验属于每个实际挂载行,包括同一模块在不同预设中的独立挂载。
 

+ 2 - 2
packages/boot/app-boot/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/boot/app-boot/README.md
-README.md: 27fa2d9039b95e39631a574e02359b312f3d9312
-README.zh.md: a9de7f1b4048adbf4d7b3e502ca5c6811e129af4
+README.md: 29265155fded79eee93a5ec8da7f7c198f551049
+README.zh.md: 2a9c3994ef64c423fdefbda2002f0e1eecb15f70

+ 1 - 1
packages/boot/app-boot/README.md

@@ -60,7 +60,7 @@ Inserted plugin names may be absolute filesystem paths, file URLs, or package sp
 
 Installed dependencies default to `external`; template bundles and packages in `dsh.profile.firstParty` are `builtin`. Effective stage is `dsh.profile.stages[package]`, then the package’s `dsh.bundle.stage`, then `runtime`. Stage selects startup failure policy, not a later execution phase. Bundle patches retain their declared ids, parents, and ordering. Built-in and boot-staged layers claim ids first; an optional bundle with a duplicate id is omitted whole and reported, while a conflicting user insert is omitted per row. `dependencies` records installation; `dsh.profile.bundles` selects enabled layers, including all their inserts and overrides.
 
-The launcher provides `ctx.profileRuntime` before any configuration entry mounts. It owns row provenance, accepted composition, conflicts, and user-disabled rows. Watchers and management operations share its serial recomposition queue. Recomposition waits for current entries and removed fibers, then publishes accepted options and reports per-entry issues; a failed update can leave a fiber running its previous valid config. `installFailLoud` remains installed until shutdown for process-level unhandled rejections. Observers can await `whenIdle()` before publishing a refreshed view of composition ownership.
+The launcher provides `ctx.profileRuntime` before any configuration entry mounts. The runtime requires Loader and supports calls through both root and plugin contexts. It owns row provenance, accepted composition, conflicts, and user-disabled rows. Watchers and management operations share its serial recomposition queue. Recomposition waits for current entries and removed fibers, then publishes accepted options and reports per-entry issues; a failed update can leave a fiber running its previous valid config. `installFailLoud` remains installed until shutdown for process-level unhandled rejections. Observers can await `whenIdle()` before publishing a refreshed view of composition ownership.
 
 <a id="patch-files"></a>
 ### Patch files

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -60,7 +60,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 已安装依赖默认为 `external`;模板组合包和 `dsh.profile.firstParty` 中的包为 `builtin`。有效 stage 依次取 `dsh.profile.stages[package]`、包的 `dsh.bundle.stage`,最后默认为 `runtime`。stage 决定启动失败策略,不表示稍后执行。组合包 patch 保留声明的 id、父组和顺序。内置与 boot 阶段层优先占有 id;可选组合包有重复 id 时整层被排除并报告,冲突的用户插入则逐行排除。`dependencies` 记录安装;`dsh.profile.bundles` 选择启用的层,包括它们的全部插入和覆盖。
 
-launcher 在任何配置行挂载前提供 `ctx.profileRuntime`。它拥有行来源、已接受的组合、冲突和用户禁用行信息。文件监听与管理操作共用它的串行重组队列。重组等待当前条目和已移除 fiber 完成后,发布已接受的选项并报告逐行问题;更新失败时,fiber 可能仍使用先前的有效配置运行。`installFailLoud` 保持到应用关闭,处理进程级未处理 rejection。 观测方可等待 `whenIdle()` 后,再发布包含组合归属的刷新视图。
+launcher 在任何配置行挂载前提供 `ctx.profileRuntime`。该运行时要求注入 Loader,支持从根上下文和插件上下文取得的句柄调用。它拥有行来源、已接受的组合、冲突和用户禁用行信息。文件监听与管理操作共用它的串行重组队列。重组等待当前条目和已移除 fiber 完成后,发布已接受的选项并报告逐行问题;更新失败时,fiber 可能仍使用先前的有效配置运行。`installFailLoud` 保持到应用关闭,处理进程级未处理 rejection。 观测方可等待 `whenIdle()` 后,再发布包含组合归属的刷新视图。
 
 <a id="patch-files"></a>
 ### 补丁文件

+ 2 - 0
packages/boot/app-boot/src/profile-runtime.ts

@@ -51,6 +51,8 @@ interface CommittedComposition {
 
 /** Facts and recomposition of the booted profile. */
 export class ProfileRuntime extends Service {
+  static inject = ['loader']
+
   private committed: CommittedComposition
   /** The recomposition in flight, or a settled promise; the next one chains behind it. */
   private queue: Promise<void> = Promise.resolve()

+ 15 - 0
packages/boot/app-boot/tests/profile-runtime.spec.ts

@@ -135,6 +135,21 @@ describe('ProfileRuntime', () => {
     expect([...runtime.userDisabledRowIds()]).toEqual(['reloaded-off'])
   })
 
+  it('recomposes through a plugin context handle and publishes the accepted profile', async () => {
+    const update = vi.fn(async () => {})
+    const entry = { options: { config: { path: 'file:///root/cordis.yml' } }, update } as unknown as Entry
+    const reloaded = profile([layer('external', 'external', [])])
+    const { ctx, runtime } = await harness([], { rootEntry: () => entry, reloaded })
+    const caller = ctx.plugin({ inject: ['loader'], apply() {} })
+    await caller.await()
+
+    const handle = caller.ctx.get('profileRuntime')!
+    await expect(handle.recompose({ reloadBundles: true })).resolves.toEqual([])
+    expect(update).toHaveBeenCalledOnce()
+    expect(runtime.layers.map(current => current.packageName)).toEqual(['external'])
+    await expect(handle.whenIdle()).resolves.toBeUndefined()
+  })
+
   it('runs recompositions one at a time, each from what the previous one committed', async () => {
     let release = (): void => {}
     const gate = new Promise<void>((resolve) => { release = resolve })

+ 2 - 2
packages/boot/plugin-manager/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/boot/plugin-manager/README.md
-README.md: 54f3eabaca809cd40c3b26810c3c21bcf835be09
-README.zh.md: 6a3ef96352586c5c015e99908179e94dd4d97779
+README.md: 4281edec98760479c37f2a55a9e50efab7bd3880
+README.zh.md: 6c7e4adbb32d0d9748064b73d37d08484158e493

+ 1 - 1
packages/boot/plugin-manager/README.md

@@ -74,7 +74,7 @@ console.log(await manager.list())
 
 `list` reports package identity, `bundle` / `plugin` / `unknown` classification, trust, stage, installation and enablement. Runtime rows carry actual phases and failures; disabled bundles show static patch declarations. `addable` comes only from `dsh.plugins`, including `.` for the main export and declared defaults. An active row can retain its previous config after an update fails. Package `issues` also names failed rows its patch overrides, without transferring their ownership.
 
-`enable` selects the whole bundle layer and recomposes live profiles. Per-row failures retain the enabled choice and successful siblings; results report `issues`, and the list can show `partial` or `failed`. Preparation failures revert the enable selection and raise `plugins/enable-failed`. `disable` removes the whole layer, including overrides. `retry` disables, awaits cleanup, and enables again. Startup-only profiles report `effect: restart`. `uninstall` disables the bundle, removes user-inserted references, and runs pnpm remove.
+`enable` selects the whole bundle layer and recomposes live profiles. Per-row failures retain the enabled choice and successful siblings; results report `issues`, and the list can show `partial` or `failed`. Preparation failures revert the enable selection and raise `plugins/enable-failed`. `disable` removes the whole layer, including overrides. `retry` disables, awaits cleanup, and enables again. Startup-only profiles report `effect: restart`. Unapplied selections on live profiles report `failed`, preserving the rows actually running; `restart-required` is reserved for startup-only profiles. `uninstall` disables the bundle, removes user-inserted references, and runs pnpm remove.
 
 `addRow` writes an explicitly declared `dsh.plugins` module to the profile’s global `cordis.patch.yml` or a preset user layer. It preserves declared defaults, checks the target row id, and performs no pre-mount import. `removeRow` removes a user insert. `setRowDisabled` writes or removes `disabled: true`, preserving the bundle’s own condition. Global edits recompose immediately on live profiles; preset edits apply to subsequent generations. `dependents` reports injection dependents and user-layer module references.
 

+ 1 - 1
packages/boot/plugin-manager/README.zh.md

@@ -74,7 +74,7 @@ console.log(await manager.list())
 
 `list` 报告包身份、`bundle` / `plugin` / `unknown` 分类、trust、stage、安装与启用状态。运行行携带实际阶段与失败;禁用组合包显示静态 patch 声明。`addable` 只来自 `dsh.plugins`,包括表示主入口的 `.` 和声明的默认配置。行更新失败后,活跃实例可能保留先前配置。包的 `issues` 还报告其 patch 覆盖的失败行,但不转移这些行的归属。
 
-`enable` 选择整份组合包层,并在实时 profile 中重组。逐行失败保留启用选择与成功的其他行;结果报告 `issues`,列表可显示 `partial` 或 `failed`。准备失败会撤销启用选择,并抛出 `plugins/enable-failed`。`disable` 移除整层,包括覆盖。`retry` 先禁用并等待清理,再启用。仅启动时生效的 profile 报告 `effect: restart`。`uninstall` 禁用组合包、删除用户插入的引用、执行 pnpm remove。
+`enable` 选择整份组合包层,并在实时 profile 中重组。逐行失败保留启用选择与成功的其他行;结果报告 `issues`,列表可显示 `partial` 或 `failed`。准备失败会撤销启用选择,并抛出 `plugins/enable-failed`。`disable` 移除整层,包括覆盖。`retry` 先禁用并等待清理,再启用。仅启动时生效的 profile 报告 `effect: restart`。实时 profile 中尚未应用的选择报告 `failed`,并保留实际运行的行;`restart-required` 只用于仅启动时生效的 profile。`uninstall` 禁用组合包、删除用户插入的引用、执行 pnpm remove。
 
 `addRow` 将显式声明的 `dsh.plugins` 模块写入 profile 的全局 `cordis.patch.yml` 或预设用户层。它保留声明的默认配置,检查目标行 id,不在挂载前 import。`removeRow` 删除用户插入。`setRowDisabled` 写入或删除 `disabled: true`,保留组合包自己的条件。全局编辑在实时 profile 中立即重组;预设编辑应用于后续代际。`dependents` 报告注入依赖方与用户层模块引用。
 

+ 1 - 1
packages/boot/plugin-manager/src/manager.ts

@@ -517,7 +517,7 @@ export class PluginManager {
   /** Whether the target layer's composition already carries a row with `rowId`. */
   private async rowExists(runtime: ProfileRuntime, target: PluginRowTarget, rowId: string): Promise<boolean> {
     if (target.kind === 'global') {
-      const tree = rootIncludeEntry(this.ctx)?.subtree
+      const tree = rootIncludeEntry(this.ctx.root)?.subtree
       return [...this.ctx.loader.entries()].some(entry => entry.parent.tree === tree && entry.options.id === rowId)
         || (await readPatchListFile(NAME, runtime.patchPath, 'patches') ?? []).some(patch => patch.insert?.some(row => row.id === rowId))
     }

+ 1 - 1
packages/boot/plugin-manager/src/types.ts

@@ -21,7 +21,7 @@ export type PluginPackageStage = 'boot' | 'runtime'
  *
  * - `running`: enabled, all enabled rows active, and no current issue;
  * - `partial`: at least one active row, with a failed attempt or unresolved dependency;
- * - `failed`: enabled and no row active;
+ * - `failed`: enabled with no active row, or the requested live layer selection has not been applied;
  * - `disabled`: installed and not in the layer list;
  * - `not-enableable`: bundle declarations cannot be read;
  * - `restart-required`: its manifest state and the live tree disagree, which a profile without live reload resolves at the next start;

+ 7 - 5
packages/boot/plugin-manager/src/view.ts

@@ -82,16 +82,18 @@ export function packageView(
     ?? (manifest.dsh?.profile?.stages?.[name] ?? packageManifest?.dsh?.bundle?.stage ?? 'runtime')
   const kind = metadata?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'unknown')
   const composed = layer !== undefined
-  const rootTree = rootIncludeEntry(ctx)?.subtree
+  const rootTree = rootIncludeEntry(ctx.root)?.subtree
   const affected = allIssues.filter(issue => runtime.originOfEntry(issue.entry)?.packageName === name
     || (issue.entry.parent.tree === rootTree && metadata?.overrides.includes(issue.entry.options.id)))
   const rows = composed ? composedRows(ctx, runtime, name, allIssues) : declaredRows(metadata)
   const status = packageStatus({ kind, installed, enabled, composed, liveReload, metadataFailure, rows, affected })
   const reason = metadataFailure ?? (status === 'restart-required'
     ? 'the profile applies layer changes at its next start'
-    : status === 'failed' || status === 'partial'
-      ? affected[0]?.message ?? rows.find(row => row.failure !== undefined)?.failure?.message
-      : undefined)
+    : status === 'failed' && enabled !== composed
+      ? 'the requested bundle state has not been applied to the live profile'
+      : status === 'failed' || status === 'partial'
+        ? affected[0]?.message ?? rows.find(row => row.failure !== undefined)?.failure?.message
+        : undefined)
   return {
     name,
     ...optional('version', packageManifest?.version),
@@ -129,7 +131,7 @@ function packageStatus(facts: {
 }): PluginPackageStatus {
   if (facts.kind !== 'bundle') return 'plain'
   if (facts.installed && facts.metadataFailure !== undefined) return 'not-enableable'
-  if (facts.enabled !== facts.composed) return 'restart-required'
+  if (facts.enabled !== facts.composed) return facts.liveReload ? 'failed' : 'restart-required'
   if (!facts.enabled) return 'disabled'
   const live = facts.rows.filter(row => row.enabled)
   if (live.length === 0) return facts.affected.length === 0 ? 'running' : 'failed'

+ 23 - 1
packages/boot/plugin-manager/tests/plugin-manager.spec.ts

@@ -228,7 +228,9 @@ async function bootProfile(staged: StagedHome, internals: Internals = {}, config
   const log: PluginInstallLogChunk[] = []
   ctx.on('plugins/changed', (change) => { changes.push(change) })
   ctx.on('plugins/install-log', (chunk) => { log.push(chunk) })
-  const manager = managerOver(ctx, internals, config)
+  const host = ctx.plugin({ inject: ['loader'], apply() {} })
+  await host.await()
+  const manager = managerOver(host.ctx, internals, config)
   return { ctx, manager, runtime: ctx.profileRuntime, changes, log }
 }
 
@@ -445,6 +447,26 @@ describe('PluginManager', () => {
       expect(views.find(view => view.name === 'ext-later')).toMatchObject({ status: 'restart-required', liveReload: false })
     })
 
+    it('reports unapplied live selections as failures instead of requiring a restart', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-live', { patch: BUNDLE_ONE_ROW })
+      addDependency(staged.profileDir, 'ext-live')
+      const initial = manifestOf(staged.profileDir)
+      initial.dsh.profile.bundles = ['ext-live']
+      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(initial))
+      const { ctx, manager } = await bootProfile(staged)
+      try {
+        initial.dsh.profile.bundles = []
+        writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(initial))
+        const [view] = await manager.list()
+        expect(view).toMatchObject({ enabled: false, liveReload: true, status: 'failed', reason: expect.stringContaining('not been applied') as string })
+        expect(view?.rows[0]?.phase).toBe('active')
+      } finally {
+        await ctx.fiber.dispose()
+        rmSync(staged.home, { recursive: true, force: true })
+      }
+    })
+
     it('reports unreadable declarations and treats physical peer identity as advisory', async () => {
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW })