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.
This commit is contained in:
imccyu
2026-08-28 22:37:36 +08:00
parent 12d7b4ed0c
commit 804b1ffbfc
252 changed files with 3182 additions and 3832 deletions
@@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { z } from 'zod'
/**
@@ -30,11 +30,7 @@ const unsetRequestSchema = z.object({ ref: credentialRefSchema })
function parseRequest<T>(method: string, schema: z.ZodType<T>, value: unknown): T {
const parsed = schema.safeParse(value)
if (!parsed.success) {
throw new TypertRemoteFailure({
code: 'bad-request',
message: `invalid payload for ${method}`,
details: { issues: parsed.error.issues },
})
throw new RemoteError('gateway/bad-request', `invalid payload for ${method}`, { issues: parsed.error.issues })
}
return parsed.data
}
@@ -78,9 +74,10 @@ export class CredentialsController extends TypertRemoteService {
* Describe several references for one configuration surface. Batched because
* a settings page describes every reference its rows name at once, and one
* round trip keeps those rows from settling separately.
* @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`.
* @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar
* rejects the whole call as `gateway/bad-request`.
* @returns one view per requested name, keyed by that name.
* @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted.
* @throws RemoteError when the request is invalid or no credential provider is mounted.
*/
@Remote
async describe(refs: string[]): Promise<Record<string, CredentialInfo>> {
@@ -97,7 +94,7 @@ export class CredentialsController extends TypertRemoteService {
* this direction only: no read path returns it.
* @param ref - reference name to store under.
* @param value - the non-empty secret value.
* @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
async set(ref: string, value: string): Promise<void> {
@@ -110,7 +107,7 @@ export class CredentialsController extends TypertRemoteService {
/**
* Remove one reference from a configuration surface.
* @param ref - reference name to remove.
* @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
async unset(ref: string): Promise<void> {
@@ -124,17 +121,17 @@ export class CredentialsController extends TypertRemoteService {
private provider(): CredentialProvider {
const credentials = this.ctx.get('credentials')
if (credentials === undefined) {
throw new TypertRemoteFailure({
code: 'internal',
message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
details: {},
})
throw new RemoteError(
'gateway/internal',
'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
{},
)
}
return credentials
}
/**
* Run one remote write and report every refusal as `credential-rejected`
* Run one remote write and report every refusal as `credential/rejected`
* carrying the seam's own message: a read-only source shadowing the reference
* is what a configuration surface must show verbatim. Callers brand the
* reference before entering, so a name outside the grammar never reaches this
@@ -145,11 +142,12 @@ export class CredentialsController extends TypertRemoteService {
try {
await write()
} catch (error: unknown) {
throw new TypertRemoteFailure({
code: 'credential-rejected',
message: error instanceof Error ? error.message : String(error),
details: { ref },
})
throw new RemoteError(
'credential/rejected',
error instanceof Error ? error.message : String(error),
{ ref },
{ cause: error },
)
}
}
}
+49 -101
View File
@@ -10,12 +10,8 @@
import { dirname } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import {
InvalidPresetIdError,
PresetExistsError,
PresetNotWritableError,
UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
// Type-only: resolves the `agentPresets` Context augmentation this controller reads.
import type {} from '@deepseek-ai/dsh-agent-presets'
import {
canOpenNativePath,
openNativePath,
@@ -27,7 +23,7 @@ import type {
SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView,
} from '@deepseek-ai/dsh-settings/types'
import type { JsonValue } from '@deepseek-ai/dsh-session/types'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
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'
@@ -88,7 +84,7 @@ declare module '@deepseek-ai/cordis' {
* 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.
* `settings/conflict` or `settings/rejected` with the service's message.
*/
export class SettingsController extends TypertRemoteService {
static Config: Schema<Config> = Schema.object({ nativeOpen: Schema.boolean() })
@@ -116,7 +112,7 @@ 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 TypertRemoteFailure when no settings provider is mounted.
* @throws RemoteError when no settings provider is mounted.
*/
@Remote
describe(): SettingsDescribeValue {
@@ -143,7 +139,7 @@ export class SettingsController extends TypertRemoteService {
* @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 TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
update(
@@ -160,7 +156,7 @@ export class SettingsController extends TypertRemoteService {
* @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 TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
replace(
@@ -179,7 +175,7 @@ export class SettingsController extends TypertRemoteService {
* @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 TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
*/
@Remote
async mutate(
@@ -194,29 +190,29 @@ export class SettingsController extends TypertRemoteService {
* 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 TypertRemoteFailure when no document exists, preparation fails, or opening fails.
* @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 cancelled('settings document open was aborted')
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 cancelled('settings document preparation was aborted')
throw internal(`settings document preparation failed: ${messageOf(error)}`)
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 internal('settings provider has no local document to open')
throw new RemoteError('gateway/internal', 'settings provider has no local document to open', {})
}
if (isAborted(signal)) throw cancelled('settings document open was aborted')
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 cancelled('settings document open was aborted')
throw internal(`path open failed: ${messageOf(error)}`)
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 })
}
}
@@ -225,7 +221,7 @@ export class SettingsController extends TypertRemoteService {
* @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 TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened.
* @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened.
*/
@Remote
async openAgentPresetDirectory(
@@ -233,35 +229,32 @@ export class SettingsController extends TypertRemoteService {
signal: AbortSignal,
): Promise<AgentPresetDirectoryOpenValue> {
if (agentPreset.length === 0) {
throw new TypertRemoteFailure({
code: 'bad-request', message: 'agent preset id must not be empty', details: {},
})
throw new RemoteError('gateway/bad-request', 'agent preset id must not be empty', {})
}
const presets = this.ctx.get('agentPresets')
if (presets === undefined) {
throw new TypertRemoteFailure({
code: 'agent-preset-not-found',
message: 'this deployment composes no agent presets',
details: { agentPreset, available: [] },
})
throw new RemoteError(
'agent-preset/not-found',
'this deployment composes no agent presets',
{ agentPreset, available: [] },
)
}
let directory: string
try {
const preset = await presets.resolve(agentPreset)
if (preset.trust !== 'user') {
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
}
directory = dirname(preset.path)
} catch (error: unknown) {
throw presetFailure(agentPreset, error)
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 cancelled('path open was aborted')
throw internal(`path open failed: ${messageOf(error)}`)
if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {})
throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error })
}
}
@@ -273,11 +266,7 @@ export class SettingsController extends TypertRemoteService {
): Promise<SettingsNamespaceView> {
const parsed = settingsNamespaceRequestSchema.safeParse({ ns })
if (!parsed.success) {
throw new TypertRemoteFailure({
code: 'bad-request',
message: `invalid payload for settings.${mode}`,
details: { issues: parsed.error.issues },
})
throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues })
}
const settings = this.provider()
let branded
@@ -286,7 +275,7 @@ export class SettingsController extends TypertRemoteService {
// unregistered one does.
branded = settingsNamespace(parsed.data.ns)
} catch (error: unknown) {
throw rejected(ns, error)
throw new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error })
}
try {
if (mode === 'update') await settings.update(branded, input, expectedRevision)
@@ -299,11 +288,7 @@ export class SettingsController extends TypertRemoteService {
if (descriptor === undefined) {
// The write committed but the namespace vanished before this read: only a
// concurrent registrant disposal can produce it.
throw new TypertRemoteFailure({
code: 'internal',
message: `settings namespace "${ns}" was disposed after the ${mode}`,
details: {},
})
throw new RemoteError('gateway/internal', `settings namespace "${ns}" was disposed after the ${mode}`, {})
}
return namespaceView(descriptor)
}
@@ -312,11 +297,11 @@ export class SettingsController extends TypertRemoteService {
private provider(): SettingsProvider {
const settings = this.ctx.get('settings')
if (settings === undefined) {
throw new TypertRemoteFailure({
code: 'internal',
message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
details: {},
})
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
}
@@ -326,40 +311,6 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function internal(message: string): TypertRemoteFailure {
return new TypertRemoteFailure({ code: 'internal', message, details: {} })
}
function cancelled(message: string): TypertRemoteFailure {
return new TypertRemoteFailure({ code: 'cancelled', message, details: {} })
}
function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure {
if (error instanceof UnknownPresetError) {
return new TypertRemoteFailure({
code: 'agent-preset-not-found',
message: error.message,
details: { agentPreset: error.presetId, available: [...error.available] },
})
}
if (error instanceof PresetNotWritableError) {
return new TypertRemoteFailure({
code: 'agent-preset-read-only',
message: error.message,
details: { agentPreset, reason: error.message },
})
}
if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
return new TypertRemoteFailure({
code: 'agent-preset-invalid',
message: error.message,
details: { agentPreset, reason: error.message },
})
}
if (error instanceof TypertRemoteFailure) return error
return internal(`agent preset "${agentPreset}": ${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
@@ -368,19 +319,16 @@ function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure
* @param error - whatever the seam threw.
* @returns the failure to raise for that refusal.
*/
function rejected(ns: string, error: unknown): TypertRemoteFailure {
function rejected(ns: string, error: unknown): RemoteError {
if (error instanceof SettingsConflictError) {
return new TypertRemoteFailure({
code: 'settings-conflict',
message: error.message,
details: { ns, expected: error.expected, actual: error.actual },
})
return new RemoteError(
'settings/conflict',
error.message,
{ ns, expected: error.expected, actual: error.actual },
{ cause: error },
)
}
return new TypertRemoteFailure({
code: 'settings-rejected',
message: error instanceof Error ? error.message : String(error),
details: { ns },
})
return new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error })
}
export default SettingsController
+19 -39
View File
@@ -7,28 +7,26 @@
* @module @deepseek-ai/dsh-api-settings-controller/types
*/
/** Stable settings failure details returned by the `settings` namespace. */
export interface SettingsErrorDetailsMap {
/**
* Every seam refusal that is not a stale write: an unregistered or malformed
* namespace, a read-only provider, schema validation, storage.
*/
'settings-rejected': { readonly ns: string }
/**
* The stored revision moved after the caller read it. Its own outcome rather
* than an invalid request: the caller must re-read and re-apply.
*/
'settings-conflict': { readonly ns: string; readonly expected: number; readonly actual: number }
}
/** Settings business failure carried by a rejected Remote call. */
export type SettingsError = {
[Code in keyof SettingsErrorDetailsMap]: {
readonly code: Code
readonly message: string
readonly details: SettingsErrorDetailsMap[Code]
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
/**
* Every seam refusal that is not a stale write: an unregistered or malformed
* namespace, a read-only provider, schema validation, storage.
*/
'settings/rejected': { readonly ns: string }
/**
* The stored revision moved after the caller read it. Its own outcome rather
* than an invalid request: the caller must re-read and re-apply.
*/
'settings/conflict': { readonly ns: string; readonly expected: number; readonly actual: number }
/**
* The provider refused a valid credential write, for example because a
* read-only source shadows the reference. The details name only the
* reference, never the value.
*/
'credential/rejected': { readonly ref: string }
}
}[keyof SettingsErrorDetailsMap]
}
/** Confirmation that the settings document was handed to the native editor. */
export interface SettingsDocumentOpenValue {
@@ -39,21 +37,3 @@ export interface SettingsDocumentOpenValue {
export type AgentPresetDirectoryOpenValue =
| { readonly opened: true }
| { readonly opened: false; readonly path: string }
/** Stable credential failure details returned by the `credentials` namespace. */
export interface CredentialErrorDetailsMap {
/**
* The provider refused a valid write, for example because a read-only source
* shadows the reference. The details name only the reference, never the value.
*/
'credential-rejected': { readonly ref: string }
}
/** Credential business failure carried by a rejected Remote call. */
export type CredentialError = {
[Code in keyof CredentialErrorDetailsMap]: {
readonly code: Code
readonly message: string
readonly details: CredentialErrorDetailsMap[Code]
}
}[keyof CredentialErrorDetailsMap]