Merge remote-tracking branch 'origin/master' into claude/unified-environment-credentials-c8841a

# Conflicts:
#	docs/config-catalog.md
#	packages/llm/llm-pi-ai/src/config.ts
#	packages/llm/llm-pi-ai/src/index.ts
#	packages/llm/llm-pi-ai/tests/sdk-options.spec.ts
This commit is contained in:
Yichen Jiang
2026-08-06 16:52:19 +08:00
157 changed files with 8735 additions and 942 deletions
+49 -6
View File
@@ -44,6 +44,26 @@ export interface SettingsRegisterOptions<T> {
base?: Partial<T>
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
applies?: SettingsApplies
/**
* Reject a resolved section the owner could not act on, for constraints its
* schema cannot express — a cross-field requirement, or one field's validity
* depending on another's. Throwing here refuses the *write* that produced the
* value, so a caller learns at `update`/`replace`/`mutate` instead of storing
* something that would silently disable the owner.
*
* Kept separate from the schema because the schema is also what a
* configuration surface renders and what an absent section resolves through;
* folding a cross-field check into it would change both.
*
* Once the owner is registered, a stored section that fails this keeps the
* namespace's last good value and warns, exactly as a schema failure does,
* so an externally edited document cannot strand a running owner. At
* registration there is no last good value yet, so a stored section that
* already fails rejects the registration itself — again exactly as a schema
* failure does.
* @param value - the resolved section, schema-valid by construction.
*/
validate?: (value: T) => void
}
/** One registered namespace as surfaced to configuration UIs. */
@@ -343,6 +363,8 @@ interface SettingsRegistration {
schema: z<unknown>
base: unknown
applies: SettingsApplies
/** Owner-supplied check for constraints the schema cannot express. */
validate?: (value: unknown) => void
resolved: unknown
/**
* Monotonic counter over this namespace's RAW user section — bumped by any
@@ -456,7 +478,10 @@ export abstract class Settings extends Service {
schema: schema as z<unknown>,
base: options?.base,
applies: options?.applies ?? 'live',
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))),
...options?.validate === undefined
? {}
: { validate: options.validate as (value: unknown) => void },
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns), options?.validate)),
revision: 0,
watchers: new Set(),
}
@@ -642,7 +667,7 @@ export abstract class Settings extends Service {
: mode === 'replace'
? snapshot
: (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current)
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate))
await this.persist(ns, section)
// The write reached storage either way; the cache must say so. Commit
// only when this registration is still the namespace owner — a fiber
@@ -684,7 +709,7 @@ export abstract class Settings extends Service {
for (const registration of this.registrations.values()) {
let next: unknown
try {
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns)))
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate))
} catch (error) {
this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns)
this.ctx.logger.warn(error)
@@ -706,10 +731,19 @@ export abstract class Settings extends Service {
}
/** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
private resolve<T>(schema: z<T>, base: unknown, section: Record<string, unknown> | undefined): T {
private resolve<T>(
schema: z<T>,
base: unknown,
section: Record<string, unknown> | undefined,
validate?: (value: T) => void,
): T {
// The merged candidate is untyped by construction; the schema call is the
// runtime validation that admits it into T.
return schema(mergeLayers(base, section) as never)
const value = schema(mergeLayers(base, section) as never)
// The owner's own check runs on the admitted value, so it sees defaults
// and the composition base exactly as the owner will.
validate?.(value)
return value
}
/**
@@ -842,6 +876,12 @@ export interface SettingsSectionHooks<T> {
* memoized resolutions — after an attach, a detach, or a committed change.
*/
onChange(): void
/**
* Reject a resolved section this consumer could not act on, for constraints
* its schema cannot express. See {@link SettingsRegisterOptions.validate}.
* @param value - the resolved section, schema-valid by construction.
*/
validate?: (value: T) => void
}
/**
@@ -865,7 +905,10 @@ export function installSettingsSection<T>(
hooks: SettingsSectionHooks<T>,
): void {
ctx.inject(['settings'], (sctx) => {
const scope = sctx.settings.register(ns, schema, { base: entry })
const scope = sctx.settings.register(ns, schema, {
base: entry,
...hooks.validate === undefined ? {} : { validate: hooks.validate },
})
hooks.setSource(() => scope.get())
sctx.effect(() => () => {
// This disposer runs for two different reasons. A settings provider
@@ -95,6 +95,44 @@ describe('registration', () => {
expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
})
it('refuses a write its owner could not act on, and keeps the last good value for a stored one', async () => {
const { ctx } = await boot()
const ns = settingsNamespace('ui-theme')
// A constraint the schema cannot express: this owner cannot serve a size
// it considers unreadable, whatever the schema admits.
const scope = ctx.settings.register(ns, ThemeSchema, {
validate: (value) => {
if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`)
},
})
const before = scope.get()
await expect(ctx.settings.update(ns, { fontSize: 4 })).rejects.toThrow(/unreadable/)
expect(scope.get()).toEqual(before)
// An externally edited document must not strand the owner: the namespace
// keeps its last good value, exactly as a schema failure would.
;(ctx.settings as unknown as { publish(doc: Record<string, unknown>): void })
.publish({ 'ui-theme': { fontSize: 4 } })
expect(scope.get()).toEqual(before)
await ctx.settings.update(ns, { fontSize: 18 })
expect(scope.get()).toMatchObject({ fontSize: 18 })
})
it('fails the registration itself when the already-stored section is unserviceable', async () => {
// The other direction of the same contract: `register` resolves inline, so
// at cold start there is no last good value to keep. A stored section the
// owner cannot serve therefore refuses the registration rather than
// mounting an owner over configuration it rejects.
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 4 } } })
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
validate: (value) => {
if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`)
},
})).toThrow(/unreadable/)
})
it('rejects a duplicate namespace loud', async () => {
const { ctx } = await boot()
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)