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.
This commit is contained in:
Yichen Jiang committed 2026-09-08 16:19:42 +08:00
1 parent d6f5160591
commit c73d88e4e5
13 files changed
+66 -30

No files matched your search

+2 -2
View File
@@ -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
+2 -1
View File
@@ -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) |
+2 -1
View File
@@ -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) |
+2 -2
View File
@@ -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
+10
View File
@@ -861,6 +861,16 @@ originOf(rowId: string): RowOrigin | undefined
*/
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
* as they stand now. The root Include re-applies the stack transactionally:
+10
View File
@@ -871,6 +871,16 @@ originOf(rowId: string): RowOrigin | undefined
*/
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
* as they stand now. The root Include re-applies the stack transactionally:
-1
View File
@@ -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,
+14 -10
View File
@@ -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<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) {
if (typeof current.options.id === 'string' && userDisabled.has(current.options.id)) return true
}
@@ -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<Entry | undefined>(
(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 () => {
@@ -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<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.',
+2 -3
View File
@@ -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<string>()
const listed = new Set<string>()
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 } },
})
}
@@ -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' })
+1
View File
@@ -661,6 +661,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
'AsyncIterable',
'Context',
'Error',
'Entry',
'EntryTree',
'Exclude',
'Extract',