feat(settings): resolve namespaces per named scope

A settings namespace is now a kind; each registration is an instance
filed under the caller's nearest named scope (dsh-scope gains
createScope(..., { id }) and scopeIdOf). Instances resolve defaults →
composition base → global user section → scopes.<id>.<ns>. Global writes
fan out to every instance gated on its own resolved value; scoped writes
commit one instance. Revisions are per section so a scope nothing has
registered yet can still be written when the kind exists. describe(),
the controller verbs, and both events take an optional trailing scope.
Presets name their standing scopes preset/<id>; skill-filesystem is the
first consumer, resolving customSkillDirs per scope with live updates.
This commit is contained in:
Yichen Jiang committed 2026-09-04 03:03:43 +08:00
1 parent dc969e2ee8
commit 80ea5fcdf5
63 files changed
+1784 -435

No files matched your search

+41 -18
View File
@@ -30,7 +30,7 @@ import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from '.
export { CredentialsController } from './credentials.ts'
export type * from './types.ts'
const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) })
const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1), scope: z.string().min(1).optional() })
/** Native document-opening policy. */
export interface Config {
@@ -61,10 +61,13 @@ export interface SettingsControllerInternals {
function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
return {
ns: String(descriptor.ns),
...descriptor.scope === undefined ? {} : { scope: String(descriptor.scope) },
registered: descriptor.registered,
schema: descriptor.schema as JsonValue,
value: descriptor.value as JsonValue,
...descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue },
...descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue },
...descriptor.inherited === undefined ? {} : { inherited: descriptor.inherited as JsonValue },
applies: descriptor.applies,
secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
revision: descriptor.revision,
@@ -108,18 +111,29 @@ export class SettingsController extends TypertRemoteService {
}
/**
* Describe every registered namespace for a configuration page: redacted
* layered values plus the serialized schema the page renders its form from.
* @returns provider writability, local-document presence, and one view per namespace.
* @throws RemoteError when no settings provider is mounted.
* Describe every namespace kind for a configuration page: redacted layered
* values plus the serialized schema the page renders its form from, under
* the global scope or one named scope (an agent preset's `preset/<id>`).
* @param scope - the named scope to describe; the global scope when omitted.
* @returns provider writability, local-document presence, one view per
* namespace kind, and every scope some namespace is registered under.
* @throws RemoteError when no settings provider is mounted or the scope id is malformed.
*/
@Remote
describe(): SettingsDescribeValue {
describe(scope?: string): SettingsDescribeValue {
const settings = this.provider()
let views: SettingsNamespaceView[]
try {
views = settings.describe({ redactSecrets: true, ...scope === undefined ? {} : { scope } }).map(namespaceView)
} catch (error: unknown) {
throw new RemoteError('gateway/bad-request', `invalid scope for settings.describe: ${messageOf(error)}`, {}, { cause: error })
}
return {
writable: settings.writable,
hasDocument: settings.documentPath !== undefined,
namespaces: settings.describe({ redactSecrets: true }).map(namespaceView),
namespaces: views,
...scope === undefined ? {} : { scope },
scopes: settings.scopes().map(String),
}
}
@@ -137,7 +151,8 @@ export class SettingsController extends TypertRemoteService {
* @param ns - namespace key to write.
* @param patch - fields to merge into the user section.
* @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
* @returns the namespace's redacted view after the write.
* @param scope - the named scope whose section to write; the global section when omitted.
* @returns the namespace's redacted view under that scope after the write.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
@@ -145,8 +160,9 @@ export class SettingsController extends TypertRemoteService {
ns: string,
patch: Record<string, JsonValue>,
expectedRevision: number | undefined,
scope?: string,
): Promise<SettingsNamespaceView> {
return this.write(ns, 'update', patch, expectedRevision)
return this.write(ns, 'update', patch, expectedRevision, scope)
}
/**
@@ -154,7 +170,8 @@ export class SettingsController extends TypertRemoteService {
* @param ns - namespace key to write.
* @param section - complete replacement user section.
* @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
* @returns the namespace's redacted view after the write.
* @param scope - the named scope whose section to write; the global section when omitted.
* @returns the namespace's redacted view under that scope after the write.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
@@ -162,8 +179,9 @@ export class SettingsController extends TypertRemoteService {
ns: string,
section: Record<string, JsonValue>,
expectedRevision: number | undefined,
scope?: string,
): Promise<SettingsNamespaceView> {
return this.write(ns, 'replace', section, expectedRevision)
return this.write(ns, 'replace', section, expectedRevision, scope)
}
/**
@@ -173,7 +191,8 @@ export class SettingsController extends TypertRemoteService {
* @param ns - namespace key to write.
* @param ops - the edits to apply, in order.
* @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
* @returns the namespace's redacted view after the write.
* @param scope - the named scope whose section to write; the global section when omitted.
* @returns the namespace's redacted view under that scope after the write.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
@@ -181,8 +200,9 @@ export class SettingsController extends TypertRemoteService {
ns: string,
ops: SettingsPathOpView[],
expectedRevision: number | undefined,
scope?: string,
): Promise<SettingsNamespaceView> {
return this.write(ns, 'mutate', ops, expectedRevision)
return this.write(ns, 'mutate', ops, expectedRevision, scope)
}
/**
@@ -262,21 +282,24 @@ export class SettingsController extends TypertRemoteService {
mode: 'update' | 'replace' | 'mutate',
input: Record<string, JsonValue> | SettingsPathOpView[],
expectedRevision: number | undefined,
scope: string | undefined,
): Promise<SettingsNamespaceView> {
const parsed = settingsNamespaceRequestSchema.safeParse({ ns })
const parsed = settingsNamespaceRequestSchema.safeParse({ ns, ...scope === undefined ? {} : { scope } })
if (!parsed.success) {
throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues })
}
const settings = this.provider()
const namespace = parsed.data.ns
try {
if (mode === 'update') await settings.update(namespace, input, expectedRevision)
else if (mode === 'replace') await settings.replace(namespace, input, expectedRevision)
else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision)
if (mode === 'update') await settings.update(namespace, input, expectedRevision, scope)
else if (mode === 'replace') await settings.replace(namespace, input, expectedRevision, scope)
else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision, scope)
} catch (error: unknown) {
throw rejected(ns, error)
}
const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === namespace)
const descriptor = settings
.describe({ redactSecrets: true, ...scope === undefined ? {} : { scope } })
.find(candidate => candidate.ns === namespace)
if (descriptor === undefined) {
// The write committed but the namespace vanished before this read: only a
// concurrent registrant disposal can produce it.