mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
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:
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
|
||||
import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import CredentialsController from '../src/credentials.ts'
|
||||
import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts'
|
||||
|
||||
@@ -60,9 +60,8 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
|
||||
() => ctx.credentialsController.unset('DEEPSEEK_API_KEY'),
|
||||
]) {
|
||||
const failure = await call().catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
expect((failure as TypertRemoteFailure).failure).toEqual({
|
||||
code: 'internal',
|
||||
expect(remoteErrorOf(failure)).toMatchObject({
|
||||
code: 'gateway/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: {},
|
||||
})
|
||||
@@ -87,8 +86,7 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
|
||||
() => controller.unset('not a var'),
|
||||
]) {
|
||||
const failure = await call().catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
|
||||
expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -97,7 +95,7 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
|
||||
const accepted = Array.from({ length: 64 }, (_unused, index) => `REF_${String(index)}`)
|
||||
expect(Object.keys(await controller.describe(accepted))).toHaveLength(64)
|
||||
const failure = await controller.describe([...accepted, 'REF_64']).catch((error: unknown) => error)
|
||||
expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
|
||||
expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' })
|
||||
})
|
||||
|
||||
it('answers only the fields the view declares, whatever a provider returns', async () => {
|
||||
@@ -117,12 +115,11 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
|
||||
.toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } })
|
||||
})
|
||||
|
||||
it('reports a refused write as credential-rejected naming only the reference', async () => {
|
||||
it('reports a refused write as credential/rejected naming only the reference', async () => {
|
||||
const controller = await boot({}, RejectingCredentials)
|
||||
const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
const { code, message, details } = (failure as TypertRemoteFailure).failure
|
||||
expect(code).toBe('credential-rejected')
|
||||
const { code, message, details } = remoteErrorOf(failure) ?? {}
|
||||
expect(code).toBe('credential/rejected')
|
||||
expect(message).toContain('read-only source')
|
||||
expect(details).toEqual({ ref: 'DEEPSEEK_API_KEY' })
|
||||
})
|
||||
@@ -130,12 +127,12 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
|
||||
it('reports an empty value as bad-request', async () => {
|
||||
const controller = await boot()
|
||||
const failure = await controller.set('DEEPSEEK_API_KEY', '').catch((error: unknown) => error)
|
||||
expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
|
||||
expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' })
|
||||
})
|
||||
|
||||
it('stringifies a refusal that is not an Error', async () => {
|
||||
const controller = await boot({}, LiteralRejectingCredentials)
|
||||
const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error)
|
||||
expect((failure as TypertRemoteFailure).failure.message).toBe('the store refused')
|
||||
expect(remoteErrorOf(failure)?.message).toBe('the store refused')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import {
|
||||
InvalidPresetIdError,
|
||||
PresetExistsError,
|
||||
UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { RemoteError, remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import SettingsController from '../src/index.ts'
|
||||
import { MemorySettings } from '../../../settings/settings/tests/memory.ts'
|
||||
|
||||
@@ -103,9 +98,8 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
]
|
||||
for (const call of calls) {
|
||||
const failure = await Promise.resolve().then(call).catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
expect((failure as TypertRemoteFailure).failure).toEqual({
|
||||
code: 'internal',
|
||||
expect(remoteErrorOf(failure)).toMatchObject({
|
||||
code: 'gateway/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: {},
|
||||
})
|
||||
@@ -186,16 +180,15 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
expect(replaced.secrets).toEqual([{ path: ['apiKey'], set: false }])
|
||||
})
|
||||
|
||||
it('refuses a stale write as settings-conflict carrying both revisions', async () => {
|
||||
it('refuses a stale write as settings/conflict carrying both revisions', async () => {
|
||||
const { controller } = await boot()
|
||||
const held = controller.describe().namespaces[0]!.revision
|
||||
await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], held)
|
||||
const failure = await controller
|
||||
.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'light' }], held)
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
const { code, details } = (failure as TypertRemoteFailure).failure
|
||||
expect(code).toBe('settings-conflict')
|
||||
const { code, details } = remoteErrorOf(failure) ?? {}
|
||||
expect(code).toBe('settings/conflict')
|
||||
expect(details).toMatchObject({ ns: 'ui-test', expected: held })
|
||||
})
|
||||
|
||||
@@ -204,8 +197,8 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
for (const ns of ['Not A Namespace', 'unregistered']) {
|
||||
const failure = await controller.mutate(ns, [{ op: 'unset', path: ['preference'] }], undefined)
|
||||
.catch((error: unknown) => error)
|
||||
expect((failure as TypertRemoteFailure).failure).toMatchObject({
|
||||
code: 'settings-rejected',
|
||||
expect(remoteErrorOf(failure)).toMatchObject({
|
||||
code: 'settings/rejected',
|
||||
details: { ns },
|
||||
})
|
||||
}
|
||||
@@ -219,17 +212,16 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
() => controller.mutate('', [], undefined),
|
||||
]) {
|
||||
const failure = await call().catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
|
||||
expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a refused write as settings-rejected carrying the seam message', async () => {
|
||||
it('reports a refused write as settings/rejected carrying the seam message', async () => {
|
||||
const { controller } = await boot(RefusingSettings)
|
||||
const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined)
|
||||
.catch((error: unknown) => error)
|
||||
const { code, message } = (failure as TypertRemoteFailure).failure
|
||||
expect(code).toBe('settings-rejected')
|
||||
const { code, message } = remoteErrorOf(failure) ?? {}
|
||||
expect(code).toBe('settings/rejected')
|
||||
expect(message).toContain('read-only in this deployment')
|
||||
})
|
||||
|
||||
@@ -237,15 +229,15 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
const { controller } = await boot(LiteralRefusingSettings)
|
||||
const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined)
|
||||
.catch((error: unknown) => error)
|
||||
expect((failure as TypertRemoteFailure).failure.message).toBe('the document is locked')
|
||||
expect(remoteErrorOf(failure)?.message).toBe('the document is locked')
|
||||
})
|
||||
|
||||
it('reports a namespace disposed between the write and its read-back', async () => {
|
||||
const { controller } = await boot(VanishingSettings)
|
||||
const failure = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined)
|
||||
.catch((error: unknown) => error)
|
||||
const { code, message } = (failure as TypertRemoteFailure).failure
|
||||
expect(code).toBe('internal')
|
||||
const { code, message } = remoteErrorOf(failure) ?? {}
|
||||
expect(code).toBe('gateway/internal')
|
||||
expect(message).toContain('was disposed after the mutate')
|
||||
})
|
||||
|
||||
@@ -265,13 +257,13 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
it('preserves settings-document absence, failure, and cancellation', async () => {
|
||||
const absent = await boot()
|
||||
const missingDocument = absent.controller.openSettingsDocument(new AbortController().signal)
|
||||
await expect(missingDocument).rejects.toMatchObject({ failure: { code: 'internal' } })
|
||||
await expect(missingDocument).rejects.toMatchObject({ code: 'gateway/internal' })
|
||||
await expect(missingDocument).rejects.toThrow('no local document')
|
||||
|
||||
const failed = await boot(DocumentSettings)
|
||||
vi.spyOn(failed.ctx.settings, 'prepareDocument').mockRejectedValue(new Error('read failed'))
|
||||
const failedRead = failed.controller.openSettingsDocument(new AbortController().signal)
|
||||
await expect(failedRead).rejects.toMatchObject({ failure: { code: 'internal' } })
|
||||
await expect(failedRead).rejects.toMatchObject({ code: 'gateway/internal' })
|
||||
await expect(failedRead).rejects.toThrow('read failed')
|
||||
|
||||
const cancelled = new AbortController()
|
||||
@@ -279,7 +271,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
const prepare = vi.spyOn(failed.ctx.settings, 'prepareDocument')
|
||||
prepare.mockClear()
|
||||
await expect(failed.controller.openSettingsDocument(cancelled.signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'cancelled' } })
|
||||
.rejects.toMatchObject({ code: 'gateway/cancelled' })
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -296,7 +288,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
abort.abort(new Error('cancelled'))
|
||||
prepared.resolve('/tmp/settings.yaml')
|
||||
|
||||
await expect(opening).rejects.toMatchObject({ failure: { code: 'cancelled' } })
|
||||
await expect(opening).rejects.toMatchObject({ code: 'gateway/cancelled' })
|
||||
expect(openTextFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -309,9 +301,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
})
|
||||
|
||||
await expect(controller.openSettingsDocument(new AbortController().signal))
|
||||
.rejects.toMatchObject({
|
||||
failure: { code: 'internal', message: 'path open failed: no default editor' },
|
||||
})
|
||||
.rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: no default editor' })
|
||||
})
|
||||
|
||||
it('classifies cancellation while preparing or opening the settings document', async () => {
|
||||
@@ -324,7 +314,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
})
|
||||
const preparingController = new SettingsController(preparing)
|
||||
await expect(preparingController.openSettingsDocument(prepareAbort.signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'cancelled' } })
|
||||
.rejects.toMatchObject({ code: 'gateway/cancelled' })
|
||||
|
||||
const opening = new Context()
|
||||
await opening.plugin(DocumentSettings)
|
||||
@@ -337,7 +327,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
},
|
||||
})
|
||||
await expect(openingController.openSettingsDocument(openAbort.signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'cancelled' } })
|
||||
.rejects.toMatchObject({ code: 'gateway/cancelled' })
|
||||
})
|
||||
|
||||
it('opens a user Agent preset directory or returns its path without a native opener', async () => {
|
||||
@@ -391,11 +381,11 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
} as never)
|
||||
const controller = new SettingsController(ctx)
|
||||
await expect(controller.openAgentPresetDirectory('standard', new AbortController().signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'agent-preset-read-only' } })
|
||||
.rejects.toMatchObject({ code: 'agent-preset/read-only' })
|
||||
|
||||
const missing = new SettingsController(new Context())
|
||||
await expect(missing.openAgentPresetDirectory('mine', new AbortController().signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'agent-preset-not-found' } })
|
||||
.rejects.toMatchObject({ code: 'agent-preset/not-found' })
|
||||
})
|
||||
|
||||
it('rejects an empty Agent preset id before resolving a provider', async () => {
|
||||
@@ -405,23 +395,20 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
const controller = new SettingsController(ctx)
|
||||
|
||||
await expect(controller.openAgentPresetDirectory('', new AbortController().signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'bad-request' } })
|
||||
.rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[new UnknownPresetError('missing', ['standard']), 'agent-preset-not-found'],
|
||||
[new InvalidPresetIdError('../bad'), 'agent-preset-invalid'],
|
||||
[new PresetExistsError('taken'), 'agent-preset-invalid'],
|
||||
[new TypertRemoteFailure({ code: 'cancelled', message: 'cancelled', details: {} }), 'cancelled'],
|
||||
['unexpected preset failure', 'internal'],
|
||||
] as const)('maps Agent preset resolution failure %#', async (error, code) => {
|
||||
it('raises an Agent preset resolution failure as the roster reported it', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('agentPresets', { resolve: async () => { throw error } } as never)
|
||||
const reported = new RemoteError('agent-preset/not-found', 'no such preset', {
|
||||
agentPreset: 'mine', available: ['standard'],
|
||||
})
|
||||
ctx.provide('agentPresets', { resolve: async () => { throw reported } } as never)
|
||||
const controller = new SettingsController(ctx)
|
||||
|
||||
await expect(controller.openAgentPresetDirectory('mine', new AbortController().signal))
|
||||
.rejects.toMatchObject({ failure: { code } })
|
||||
.rejects.toBe(reported)
|
||||
})
|
||||
|
||||
it('classifies cancellation and non-Error failures from the preset opener', async () => {
|
||||
@@ -441,10 +428,8 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
const controller = new SettingsController(ctx, { nativeOpen: true }, { openPath })
|
||||
|
||||
await expect(controller.openAgentPresetDirectory('first', abort.signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'cancelled' } })
|
||||
.rejects.toMatchObject({ code: 'gateway/cancelled' })
|
||||
await expect(controller.openAgentPresetDirectory('second', new AbortController().signal))
|
||||
.rejects.toMatchObject({
|
||||
failure: { code: 'internal', message: 'path open failed: desktop unavailable' },
|
||||
})
|
||||
.rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: desktop unavailable' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user