Просмотр исходного кода

fix(boot): answer user-disabled entries from the profile runtime

The inventory keeps dsh-app-boot an optional, type-only peer, so the walk
over an entry's owning groups moves behind ProfileRuntime.userDisables(): a
consumer with the runtime asks it, one without reports composition as before.
The module graph and the cordis catalog follow.
Yichen Jiang 2 недель назад
Родитель
Сommit
c73d88e4e5

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -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/module-graph.md
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: e435185d0d184d66c7d29df8a31ec2946ed8fdfc
-module-graph.zh.md: adf406a333885adaa980f93d4f4d6ecfd63bace7
+module-graph.md: e427d893e3b0a3cd1b2f9881a27366ccdcbe3949
+module-graph.zh.md: e94742d107f76554bbc3d6cb5f54b502cb868970

+ 2 - 1
docs/module-graph.md

@@ -392,6 +392,7 @@ flowchart TD
   pkg_storage_json --> pkg_storage
   pkg_storage_json --> pkg_storage
   pkg_storage_sqlite --> pkg_storage
   pkg_storage_sqlite --> pkg_storage
   pkg_subprocess --> pkg_http_proxy
   pkg_subprocess --> pkg_http_proxy
+  pkg_subprocess --> pkg_launch_environment
   pkg_typert_loader --> pkg_typert_registry
   pkg_typert_loader --> pkg_typert_registry
   pkg_session --> pkg_scope
   pkg_session --> pkg_scope
   pkg_system_prompt --> pkg_invariants
   pkg_system_prompt --> pkg_invariants
@@ -1259,7 +1260,7 @@ flowchart TD
 | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
 | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
 | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
 | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
 | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
 | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
-| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
+| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) |
 | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
 | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
 | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
 | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

+ 2 - 1
docs/module-graph.zh.md

@@ -394,6 +394,7 @@ flowchart TD
   pkg_storage_json --> pkg_storage
   pkg_storage_json --> pkg_storage
   pkg_storage_sqlite --> pkg_storage
   pkg_storage_sqlite --> pkg_storage
   pkg_subprocess --> pkg_http_proxy
   pkg_subprocess --> pkg_http_proxy
+  pkg_subprocess --> pkg_launch_environment
   pkg_typert_loader --> pkg_typert_registry
   pkg_typert_loader --> pkg_typert_registry
   pkg_session --> pkg_scope
   pkg_session --> pkg_scope
   pkg_system_prompt --> pkg_invariants
   pkg_system_prompt --> pkg_invariants
@@ -1261,7 +1262,7 @@ flowchart TD
 | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
 | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
 | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
 | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
 | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
 | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
-| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
+| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) |
 | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
 | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
 | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
 | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

+ 2 - 2
docs/subsystems/core.i18n.yaml

@@ -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/subsystems/core.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
-core.md: 4028b19620e72a3c39164444453bb5fc1f818d7f
-core.zh.md: 2c75c867db13173c6db34b8a83f47c3f10f28972
+core.md: 275b95ac088260e01dacae0d24a3e0acdb935494
+core.zh.md: 23895d409d2f71021c8e38e98c423114c82ed23f

+ 10 - 0
docs/subsystems/core.md

@@ -861,6 +861,16 @@ originOf(rowId: string): RowOrigin | undefined
  */
  */
 userDisabledRowIds(): ReadonlySet<string>
 userDisabledRowIds(): ReadonlySet<string>
 
 
+/**
+ * Whether the user patch layers disable an entry: its own row id, or the
+ * id of a group holding it, is among {@link userDisabledRowIds}. The Loader
+ * disables every descendant of a disabled group, so a child's own id alone
+ * does not say who switched it off.
+ * @param entry - the Loader entry.
+ * @returns true when the user's patches disable the entry or one of the groups holding it.
+ */
+userDisables(entry: Entry): boolean
+
 /**
 /**
  * Recompose the host tree from the profile's layers and the user patch files
  * Recompose the host tree from the profile's layers and the user patch files
  * as they stand now. The root Include re-applies the stack transactionally:
  * as they stand now. The root Include re-applies the stack transactionally:

+ 10 - 0
docs/subsystems/core.zh.md

@@ -871,6 +871,16 @@ originOf(rowId: string): RowOrigin | undefined
  */
  */
 userDisabledRowIds(): ReadonlySet<string>
 userDisabledRowIds(): ReadonlySet<string>
 
 
+/**
+ * Whether the user patch layers disable an entry: its own row id, or the
+ * id of a group holding it, is among {@link userDisabledRowIds}. The Loader
+ * disables every descendant of a disabled group, so a child's own id alone
+ * does not say who switched it off.
+ * @param entry - the Loader entry.
+ * @returns true when the user's patches disable the entry or one of the groups holding it.
+ */
+userDisables(entry: Entry): boolean
+
 /**
 /**
  * Recompose the host tree from the profile's layers and the user patch files
  * Recompose the host tree from the profile's layers and the user patch files
  * as they stand now. The root Include re-applies the stack transactionally:
  * as they stand now. The root Include re-applies the stack transactionally:

+ 0 - 1
packages/boot/app-boot/src/index.ts

@@ -65,7 +65,6 @@ export {
 } from './compose-stack.ts'
 } from './compose-stack.ts'
 export {
 export {
   ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin,
   ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin,
-  userDisablesEntry,
 } from './profile-runtime.ts'
 } from './profile-runtime.ts'
 export {
 export {
   PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache,
   PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache,

+ 14 - 10
packages/boot/app-boot/src/profile-runtime.ts

@@ -131,6 +131,18 @@ export class ProfileRuntime extends Service {
     return this.committed.stack.userDisabledRowIds
     return this.committed.stack.userDisabledRowIds
   }
   }
 
 
+  /**
+   * Whether the user patch layers disable an entry: its own row id, or the
+   * id of a group holding it, is among {@link userDisabledRowIds}. The Loader
+   * disables every descendant of a disabled group, so a child's own id alone
+   * does not say who switched it off.
+   * @param entry - the Loader entry.
+   * @returns true when the user's patches disable the entry or one of the groups holding it.
+   */
+  userDisables(entry: Entry): boolean {
+    return userDisablesEntry(entry, this.committed.stack.userDisabledRowIds)
+  }
+
   /**
   /**
    * Recompose the host tree from the profile's layers and the user patch files
    * Recompose the host tree from the profile's layers and the user patch files
    * as they stand now. The root Include re-applies the stack transactionally:
    * as they stand now. The root Include re-applies the stack transactionally:
@@ -169,16 +181,8 @@ export class ProfileRuntime extends Service {
   }
   }
 }
 }
 
 
-/**
- * Whether the user patch layers disable an entry: its own row id, or the id
- * of a group holding it, is in the set. The Loader disables every descendant
- * of a disabled group, so a child's own id alone does not say who switched
- * it off.
- * @param entry - the Loader entry.
- * @param userDisabled - the ids the user layers disable, from `userDisabledRowIds()`.
- * @returns true when the user's patches disable the entry or one of the groups holding it.
- */
-export function userDisablesEntry(entry: Entry, userDisabled: ReadonlySet<string>): boolean {
+/** The walk behind {@link ProfileRuntime.userDisables}: the entry, then each group holding it, outward. */
+function userDisablesEntry(entry: Entry, userDisabled: ReadonlySet<string>): boolean {
   for (let current: Entry | undefined = entry; current !== undefined; current = current.parent.ctx.fiber.entry) {
   for (let current: Entry | undefined = entry; current !== undefined; current = current.parent.ctx.fiber.entry) {
     if (typeof current.options.id === 'string' && userDisabled.has(current.options.id)) return true
     if (typeof current.options.id === 'string' && userDisabled.has(current.options.id)) return true
   }
   }

+ 7 - 5
packages/boot/app-boot/tests/profile-runtime.spec.ts

@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { Context } from '@deepseek-ai/cordis'
 import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { claimLayerIds, ProfileRuntime, userDisablesEntry, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
+import { claimLayerIds, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
 
 
 const contexts: Context[] = []
 const contexts: Context[] = []
 afterEach(async () => {
 afterEach(async () => {
@@ -100,13 +100,15 @@ describe('ProfileRuntime', () => {
     expect([...runtime.userDisabledRowIds()]).toEqual(['booted-off'])
     expect([...runtime.userDisabledRowIds()]).toEqual(['booted-off'])
   })
   })
 
 
-  it('tells a row the user disabled through a group holding it from one the composition gates', () => {
+  it('tells a row the user disabled through a group holding it from one the composition gates', async () => {
+    const { runtime } = await harness([layer('a', 'builtin', [])])
     const chain = (ids: string[]): Entry => ids.reduceRight<Entry | undefined>(
     const chain = (ids: string[]): Entry => ids.reduceRight<Entry | undefined>(
       (parent, id) => ({ options: { id }, parent: { ctx: { fiber: { entry: parent } } } } as unknown as Entry), undefined,
       (parent, id) => ({ options: { id }, parent: { ctx: { fiber: { entry: parent } } } } as unknown as Entry), undefined,
     ) as Entry
     ) as Entry
-    expect(userDisablesEntry(chain(['kid', 'grp', 'root']), new Set(['grp']))).toBe(true)
-    expect(userDisablesEntry(chain(['kid', 'grp']), new Set(['kid']))).toBe(true)
-    expect(userDisablesEntry(chain(['kid', 'grp']), new Set(['other']))).toBe(false)
+    // The booted composition disables 'booted-off'.
+    expect(runtime.userDisables(chain(['kid', 'booted-off', 'root']))).toBe(true)
+    expect(runtime.userDisables(chain(['booted-off', 'grp']))).toBe(true)
+    expect(runtime.userDisables(chain(['kid', 'grp']))).toBe(false)
   })
   })
 
 
   it('recomposes through the root include, optionally re-reading the profile first, and commits on acceptance', async () => {
   it('recomposes through the root include, optionally re-reading the profile first, and commits on acceptance', async () => {

+ 6 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1403,6 +1403,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [],
         parameters: [],
         returns: 'the ids, from the committed composition.',
         returns: 'the ids, from the committed composition.',
       },
       },
+      {
+        signature: 'userDisables(entry: Entry): boolean',
+        description: 'Whether the user patch layers disable an entry: its own row id, or the id of a group holding it, is among userDisabledRowIds. The Loader disables every descendant of a disabled group, so a child\'s own id alone does not say who switched it off.',
+        parameters: [{ name: 'entry', description: 'the Loader entry.' }],
+        returns: 'true when the user\'s patches disable the entry or one of the groups holding it.',
+      },
       {
       {
         signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>',
         signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>',
         description: 'Recompose the host tree from the profile\'s layers and the user patch files as they stand now. The root Include re-applies the stack transactionally: a row whose options changed is updated in place, a row that appeared is created, a row that vanished is disposed, and a failure rolls the whole update back with the previous tree still running. The candidate profile, its ownership, and its conflicts become the committed composition only once the update holds; until then, and after a rejection, `current`, `layers`, `originOf`, and `conflicts` keep describing the running tree. Calls queue: one that arrives while another is in flight starts after it settled and reads what it committed. A rejection is that call\'s outcome alone and does not stop the ones behind it.',
         description: 'Recompose the host tree from the profile\'s layers and the user patch files as they stand now. The root Include re-applies the stack transactionally: a row whose options changed is updated in place, a row that appeared is created, a row that vanished is disposed, and a failure rolls the whole update back with the previous tree still running. The candidate profile, its ownership, and its conflicts become the committed composition only once the update holds; until then, and after a rejection, `current`, `layers`, `originOf`, and `conflicts` keep describing the running tree. Calls queue: one that arrives while another is in flight starts after it settled and reads what it committed. A rejection is that call\'s outcome alone and does not stop the ones behind it.',

+ 2 - 3
packages/host/plugin-inventory/src/index.ts

@@ -6,7 +6,7 @@ import type {} from '@deepseek-ai/cordis-plugin-loader'
 import type {} from '@deepseek-ai/dsh-agent-presets'
 import type {} from '@deepseek-ai/dsh-agent-presets'
 // Type-only: the optional profile runtime and contained-failure registry the
 // Type-only: the optional profile runtime and contained-failure registry the
 // boot glue provides, both resolved through `ctx.get`.
 // boot glue provides, both resolved through `ctx.get`.
-import { userDisablesEntry } from '@deepseek-ai/dsh-app-boot'
+import type {} from '@deepseek-ai/dsh-app-boot'
 import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
 import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
 // Typert-generated ./typert and ./remote artifacts import Zod at runtime.
 // Typert-generated ./typert and ./remote artifacts import Zod at runtime.
 import type {} from 'zod'
 import type {} from 'zod'
@@ -79,7 +79,6 @@ export class PluginInventoryGateway extends TypertRemoteService {
     const entries: PluginInventoryEntry[] = []
     const entries: PluginInventoryEntry[] = []
     const runtime = this.ctx.get('profileRuntime')
     const runtime = this.ctx.get('profileRuntime')
     const failures = this.ctx.get('pluginFailures')
     const failures = this.ctx.get('pluginFailures')
-    const userDisabled = runtime?.userDisabledRowIds() ?? new Set<string>()
     const listed = new Set<string>()
     const listed = new Set<string>()
     for (const entry of this.ctx.loader.entries()) {
     for (const entry of this.ctx.loader.entries()) {
       if (entry.options.group) continue
       if (entry.options.group) continue
@@ -96,7 +95,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
         fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
         fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
         trust: origin?.trust ?? 'builtin',
         trust: origin?.trust ?? 'builtin',
         ...origin === undefined ? {} : { package: packageRef(origin) },
         ...origin === undefined ? {} : { package: packageRef(origin) },
-        ...enabled ? {} : { disabledBy: userDisablesEntry(entry, userDisabled) ? 'user' as const : 'composition' as const },
+        ...enabled ? {} : { disabledBy: runtime?.userDisables(entry) === true ? 'user' as const : 'composition' as const },
         ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
         ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
       })
       })
     }
     }

+ 8 - 5
packages/host/plugin-inventory/tests/inventory.spec.ts

@@ -3,7 +3,7 @@ import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis'
 import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
 import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
 import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
 import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
 import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
 import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
-import { ensurePluginFailures, type ProfileRuntime, type RowOrigin } from '@deepseek-ai/dsh-app-boot'
+import { ensurePluginFailures, ProfileRuntime, type ComposedStack, type Profile, type RowOrigin } from '@deepseek-ai/dsh-app-boot'
 import PluginInventoryGateway from '../src/index.ts'
 import PluginInventoryGateway from '../src/index.ts'
 
 
 const contexts: Context[] = []
 const contexts: Context[] = []
@@ -111,7 +111,7 @@ describe('PluginInventoryGateway', () => {
     ])
     ])
     ctx.provide('profileRuntime', {
     ctx.provide('profileRuntime', {
       originOf: (rowId: string) => origins.get(rowId),
       originOf: (rowId: string) => origins.get(rowId),
-      userDisabledRowIds: () => new Set([off]),
+      userDisables: (entry: { id: string }) => entry.id === off,
       layers: [{ packageName: 'late', version: '9.9.9' }],
       layers: [{ packageName: 'late', version: '9.9.9' }],
       conflicts: [
       conflicts: [
         { rowId: 'tool', moduleName: 'late', layer: 'late', packageName: 'late', declaredBy: 'ext', message: 'row "tool" is already declared by ext' },
         { rowId: 'tool', moduleName: 'late', layer: 'late', packageName: 'late', declaredBy: 'ext', message: 'row "tool" is already declared by ext' },
@@ -160,9 +160,12 @@ describe('PluginInventoryGateway', () => {
     const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [{ name: 'cordis:active' }] })
     const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [{ name: 'cordis:active' }] })
     const child = [...ctx.loader.entries()].find(entry => !entry.options.group && entry.parent.ctx.fiber.entry?.options.id === groupId)
     const child = [...ctx.loader.entries()].find(entry => !entry.options.group && entry.parent.ctx.fiber.entry?.options.id === groupId)
     expect(child).toBeDefined()
     expect(child).toBeDefined()
-    ctx.provide('profileRuntime', {
-      originOf: () => undefined, userDisabledRowIds: () => new Set([groupId]), layers: [], conflicts: [],
-    } as unknown as ProfileRuntime)
+    // The real runtime over a composition whose user layer disabled the group.
+    const profile: Profile = { name: 'web', dir: '/p', layers: [], patchPath: '/p/cordis.patch.yml', patches: [], patchReload: 'live' }
+    const stack: ComposedStack = {
+      patches: [], layers: [], owners: new Map(), conflicts: [], skippedBundles: [], userDisabledRowIds: new Set([groupId]),
+    }
+    await ctx.plugin(ProfileRuntime, { profile, stack, loadProfile: () => profile, compose: () => stack, rootEntry: () => undefined })
     await ctx.loader.update(groupId, { disabled: true })
     await ctx.loader.update(groupId, { disabled: true })
     const listed = (await inventory.list()).entries.find(entry => entry.entryId === child?.id)
     const listed = (await inventory.list()).entries.find(entry => entry.entryId === child?.id)
     expect(listed).toMatchObject({ enabled: false, disabledBy: 'user' })
     expect(listed).toMatchObject({ enabled: false, disabledBy: 'user' })

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -661,6 +661,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
   'AsyncIterable',
   'AsyncIterable',
   'Context',
   'Context',
   'Error',
   'Error',
+  'Entry',
   'EntryTree',
   'EntryTree',
   'Exclude',
   'Exclude',
   'Extract',
   'Extract',