Files
deepseek-harness/packages/api/settings-controller/src/index.ts
T
imccyu 804b1ffbfc refactor(api): converge the Remote failure vocabulary and client surface
Single RemoteError with a merge-extensible, domain-prefixed code map;
owners throw at the failure point; streams surface marked failures;
clients consume ctx.remote directly with isRemoteFailure as the only
discrimination point and construct no failure instances.
2026-08-28 22:37:36 +08:00

335 lines
14 KiB
TypeScript

/**
* Host Remote owner for the configuration surfaces over the settings-domain
* seams. Two namespaces: `settings`, the redacted reads and writes of
* `ctx.settings`, owned by the class below; and `credentials`, mounted from
* here as its own plugin.
*
* @module @deepseek-ai/dsh-api-settings-controller
*/
import { dirname } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
// Type-only: resolves the `agentPresets` Context augmentation this controller reads.
import type {} from '@deepseek-ai/dsh-agent-presets'
import {
canOpenNativePath,
openNativePath,
openNativeTextFile,
} from '@deepseek-ai/dsh-native-command'
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings'
import type {
SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView,
} from '@deepseek-ai/dsh-settings/types'
import type { JsonValue } from '@deepseek-ai/dsh-session/types'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { z } from 'zod'
import { CredentialsController } from './credentials.ts'
import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts'
export { CredentialsController } from './credentials.ts'
export type * from './types.ts'
const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) })
/** Native document-opening policy. */
export interface Config {
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
}
/** Read abort state afresh after an awaited provider or opener call. */
function isAborted(signal: AbortSignal): boolean {
return signal.aborted
}
/** Host integrations replaceable by direct unit tests. */
export interface SettingsControllerInternals {
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
readonly openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
readonly canOpenPath?: () => boolean
}
/**
* Project one redacted descriptor onto its wire view, field by field. The
* Gateway returns a business result without decoding it, so a provider whose
* descriptor carried extra enumerable properties would otherwise serialize them
* to the caller.
* @param descriptor - one descriptor read under `redactSecrets`.
* @returns the same facts with nothing else attached.
*/
function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
return {
ns: String(descriptor.ns),
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 },
applies: descriptor.applies,
secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
revision: descriptor.revision,
}
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host owner of the `settings` Remote namespace. */
settingsController: SettingsController
}
}
/**
* Host service backing the generated `ctx.remote.settings` namespace. Every
* remote read uses `redactSecrets: true`, so a `role('secret')` field cannot
* ride a response. Writes expose the settings service's merge, replacement,
* and path-addressed operations, and classify every provider refusal as
* `settings/conflict` or `settings/rejected` with the service's message.
*/
export class SettingsController extends TypertRemoteService {
static Config: Schema<Config> = Schema.object({ nativeOpen: Schema.boolean() })
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
private readonly openTextFile: (path: string, signal: AbortSignal) => Promise<void>
private readonly canOpenPath: () => boolean
/**
* Register the settings namespace and mount the credentials namespace beside
* it. Both namespaces stay registered when a provider is absent so calls can
* return the configuration API's actionable missing-provider diagnostic.
* @param ctx - Host context where settings and credential providers may be mounted.
*/
constructor(ctx: Context, config: Config = {}, internals: SettingsControllerInternals = {}) {
super(ctx, 'settingsController', { namespace: 'settings' })
this.openPath = internals.openPath ?? openNativePath
this.openTextFile = internals.openTextFile ?? openNativeTextFile
this.canOpenPath = internals.canOpenPath
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
ctx.plugin(CredentialsController)
}
/**
* 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.
*/
@Remote
describe(): SettingsDescribeValue {
const settings = this.provider()
return {
writable: settings.writable,
hasDocument: settings.documentPath !== undefined,
namespaces: settings.describe({ redactSecrets: true }).map(namespaceView),
}
}
/**
* Report whether this deployment can open an authored Agent preset directory natively.
* @returns true when the matching open operation is available.
*/
@Remote
canOpenAgentPresetDirectory(): boolean {
return this.canOpenPath()
}
/**
* Merge a patch into one namespace's stored user section.
* @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.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
update(
ns: string,
patch: Record<string, JsonValue>,
expectedRevision: number | undefined,
): Promise<SettingsNamespaceView> {
return this.write(ns, 'update', patch, expectedRevision)
}
/**
* Replace one namespace's stored user section wholesale.
* @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.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
replace(
ns: string,
section: Record<string, JsonValue>,
expectedRevision: number | undefined,
): Promise<SettingsNamespaceView> {
return this.write(ns, 'replace', section, expectedRevision)
}
/**
* Apply path-addressed edits to one namespace's user section, resolved against
* the section as stored rather than against whatever the caller last read,
* then answer with that namespace's new redacted view.
* @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.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
async mutate(
ns: string,
ops: SettingsPathOpView[],
expectedRevision: number | undefined,
): Promise<SettingsNamespaceView> {
return this.write(ns, 'mutate', ops, expectedRevision)
}
/**
* Materialize the provider-owned settings document and open it in a native text editor.
* @param signal - caller lifetime; abort terminates preparation or the native command.
* @returns confirmation after the native opener accepts the document.
* @throws RemoteError when no document exists, preparation fails, or opening fails.
*/
@Remote
async openSettingsDocument(signal: AbortSignal): Promise<SettingsDocumentOpenValue> {
const settings = this.provider()
if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {})
let path: string | undefined
try {
path = await settings.prepareDocument()
} catch (error: unknown) {
if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document preparation was aborted', {})
throw new RemoteError('gateway/internal', `settings document preparation failed: ${messageOf(error)}`, {}, { cause: error })
}
if (path === undefined) {
throw new RemoteError('gateway/internal', 'settings provider has no local document to open', {})
}
if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {})
try {
await this.openTextFile(path, signal)
return { opened: true }
} catch (error: unknown) {
if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {})
throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error })
}
}
/**
* Open one user-authored Agent preset directory or return its path when no native opener exists.
* @param agentPreset - preset id resolved against Host-owned roots.
* @param signal - caller lifetime; abort terminates the native command.
* @returns an opened confirmation or the resolved directory for text display.
* @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened.
*/
@Remote
async openAgentPresetDirectory(
agentPreset: string,
signal: AbortSignal,
): Promise<AgentPresetDirectoryOpenValue> {
if (agentPreset.length === 0) {
throw new RemoteError('gateway/bad-request', 'agent preset id must not be empty', {})
}
const presets = this.ctx.get('agentPresets')
if (presets === undefined) {
throw new RemoteError(
'agent-preset/not-found',
'this deployment composes no agent presets',
{ agentPreset, available: [] },
)
}
const preset = await presets.resolve(agentPreset)
if (preset.trust !== 'user') {
throw new RemoteError(
'agent-preset/read-only',
`agent-presets: preset "${preset.id}" cannot be written: it ships with the deployment`,
{ agentPreset: preset.id, reason: 'it ships with the deployment' },
)
}
const directory = dirname(preset.path)
if (!this.canOpenPath()) return { opened: false, path: directory }
try {
await this.openPath(directory, signal)
return { opened: true }
} catch (error: unknown) {
if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {})
throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error })
}
}
private async write(
ns: string,
mode: 'update' | 'replace' | 'mutate',
input: Record<string, JsonValue> | SettingsPathOpView[],
expectedRevision: number | undefined,
): Promise<SettingsNamespaceView> {
const parsed = settingsNamespaceRequestSchema.safeParse({ ns })
if (!parsed.success) {
throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues })
}
const settings = this.provider()
let branded
try {
// A malformed name can address no registration, so it fails exactly as an
// unregistered one does.
branded = settingsNamespace(parsed.data.ns)
} catch (error: unknown) {
throw new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error })
}
try {
if (mode === 'update') await settings.update(branded, input, expectedRevision)
else if (mode === 'replace') await settings.replace(branded, input, expectedRevision)
else await settings.mutate(branded, input as SettingsPathOp[], expectedRevision)
} catch (error: unknown) {
throw rejected(ns, error)
}
const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
if (descriptor === undefined) {
// The write committed but the namespace vanished before this read: only a
// concurrent registrant disposal can produce it.
throw new RemoteError('gateway/internal', `settings namespace "${ns}" was disposed after the ${mode}`, {})
}
return namespaceView(descriptor)
}
/** Resolve the optional provider or report how to supply it. */
private provider(): SettingsProvider {
const settings = this.ctx.get('settings')
if (settings === undefined) {
throw new RemoteError(
'gateway/internal',
'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
{},
)
}
return settings
}
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Classify one seam refusal. A stale writer is its own outcome, not a malformed
* request: the client must re-read and re-apply rather than treat the write as
* invalid.
* @param ns - the namespace the write addressed.
* @param error - whatever the seam threw.
* @returns the failure to raise for that refusal.
*/
function rejected(ns: string, error: unknown): RemoteError {
if (error instanceof SettingsConflictError) {
return new RemoteError(
'settings/conflict',
error.message,
{ ns, expected: error.expected, actual: error.actual },
{ cause: error },
)
}
return new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error })
}
export default SettingsController