From c73d88e4e55306f7e62850a812e16a034ef20476 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 16:19:42 +0800 Subject: [PATCH] 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. --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 10 ++++++++ docs/subsystems/core.zh.md | 10 ++++++++ packages/boot/app-boot/src/index.ts | 1 - packages/boot/app-boot/src/profile-runtime.ts | 24 +++++++++++-------- .../app-boot/tests/profile-runtime.spec.ts | 12 ++++++---- .../extensions/tool-cordis/src/api-catalog.ts | 6 +++++ packages/host/plugin-inventory/src/index.ts | 5 ++-- .../plugin-inventory/tests/inventory.spec.ts | 13 ++++++---- scripts/gen-cordis-catalog.ts | 1 + 13 files changed, 66 insertions(+), 30 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 2dcce27352..b27ec8b506 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: e435185d0d184d66c7d29df8a31ec2946ed8fdfc -module-graph.zh.md: adf406a333885adaa980f93d4f4d6ecfd63bace7 +module-graph.md: e427d893e3b0a3cd1b2f9881a27366ccdcbe3949 +module-graph.zh.md: e94742d107f76554bbc3d6cb5f54b502cb868970 diff --git a/docs/module-graph.md b/docs/module-graph.md index e435185d0d..e427d893e3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -392,6 +392,7 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy + pkg_subprocess --> pkg_launch_environment pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope 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-json`](../packages/storage/storage-json) | `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) | | [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index adf406a333..e94742d107 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -394,6 +394,7 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy + pkg_subprocess --> pkg_launch_environment pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope 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-json`](../packages/storage/storage-json) | `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) | | [`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) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 83f0caf22c..286af6cf6f 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: 4028b19620e72a3c39164444453bb5fc1f818d7f -core.zh.md: 2c75c867db13173c6db34b8a83f47c3f10f28972 +core.md: 275b95ac088260e01dacae0d24a3e0acdb935494 +core.zh.md: 23895d409d2f71021c8e38e98c423114c82ed23f diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 4028b19620..275b95ac08 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -861,6 +861,16 @@ originOf(rowId: string): RowOrigin | undefined */ userDisabledRowIds(): ReadonlySet +/** + * 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 * as they stand now. The root Include re-applies the stack transactionally: diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 2c75c867db..23895d409d 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -871,6 +871,16 @@ originOf(rowId: string): RowOrigin | undefined */ userDisabledRowIds(): ReadonlySet +/** + * 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 * as they stand now. The root Include re-applies the stack transactionally: diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 4706d81398..0e824e1473 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -65,7 +65,6 @@ export { } from './compose-stack.ts' export { ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin, - userDisablesEntry, } from './profile-runtime.ts' export { PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache, diff --git a/packages/boot/app-boot/src/profile-runtime.ts b/packages/boot/app-boot/src/profile-runtime.ts index 2a137563dd..b5d719ee1a 100644 --- a/packages/boot/app-boot/src/profile-runtime.ts +++ b/packages/boot/app-boot/src/profile-runtime.ts @@ -131,6 +131,18 @@ export class ProfileRuntime extends Service { 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 * 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): boolean { +/** The walk behind {@link ProfileRuntime.userDisables}: the entry, then each group holding it, outward. */ +function userDisablesEntry(entry: Entry, userDisabled: ReadonlySet): boolean { 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 } diff --git a/packages/boot/app-boot/tests/profile-runtime.spec.ts b/packages/boot/app-boot/tests/profile-runtime.spec.ts index b9dd5de0b9..40af095e16 100644 --- a/packages/boot/app-boot/tests/profile-runtime.spec.ts +++ b/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 type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader' 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[] = [] afterEach(async () => { @@ -100,13 +100,15 @@ describe('ProfileRuntime', () => { 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( (parent, id) => ({ options: { id }, parent: { ctx: { fiber: { entry: parent } } } } as unknown as Entry), undefined, ) 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 () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f4db783efe..c301fe1aba 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1403,6 +1403,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], 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', 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.', diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index ddfa081017..df3a1fbae5 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/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' // Type-only: the optional profile runtime and contained-failure registry the // 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' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. import type {} from 'zod' @@ -79,7 +79,6 @@ export class PluginInventoryGateway extends TypertRemoteService { const entries: PluginInventoryEntry[] = [] const runtime = this.ctx.get('profileRuntime') const failures = this.ctx.get('pluginFailures') - const userDisabled = runtime?.userDisabledRowIds() ?? new Set() const listed = new Set() for (const entry of this.ctx.loader.entries()) { if (entry.options.group) continue @@ -96,7 +95,7 @@ export class PluginInventoryGateway extends TypertRemoteService { fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], trust: origin?.trust ?? 'builtin', ...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 } }, }) } diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 50a4aeb22d..48428f4735 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/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 { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' 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' const contexts: Context[] = [] @@ -111,7 +111,7 @@ describe('PluginInventoryGateway', () => { ]) ctx.provide('profileRuntime', { originOf: (rowId: string) => origins.get(rowId), - userDisabledRowIds: () => new Set([off]), + userDisables: (entry: { id: string }) => entry.id === off, layers: [{ packageName: 'late', version: '9.9.9' }], conflicts: [ { 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 child = [...ctx.loader.entries()].find(entry => !entry.options.group && entry.parent.ctx.fiber.entry?.options.id === groupId) 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 }) const listed = (await inventory.list()).entries.find(entry => entry.entryId === child?.id) expect(listed).toMatchObject({ enabled: false, disabledBy: 'user' }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index db8680fc5a..425ebb0f0b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -661,6 +661,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'AsyncIterable', 'Context', 'Error', + 'Entry', 'EntryTree', 'Exclude', 'Extract',