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
@@ -7,7 +7,7 @@ import {
type ClientRemote,
} from '@deepseek-ai/dsh-api-gateway/client'
import type { WorkspaceFollowFrame, WorkspaceFollowIncrement } from '../types.ts'
import type { WorkspaceFollowSink, WorkspaceRemote } from './model.ts'
import type { WorkspaceFollowSink } from './model.ts'
import { ClientWorkspaceModel } from './model.ts'
import { WorkspaceController } from './service.ts'
@@ -19,10 +19,6 @@ export { WorkspaceController, WorkspaceCreateError } from './service.ts'
export type { IWorkspaces, WorkspaceSource } from './service.ts'
export type { WorkspaceId, WorkspaceView } from '../types.ts'
type WorkspaceStreamRemote = Pick<ClientRemote, '$stream'> & {
readonly workspace: WorkspaceRemote
}
type WorkspaceBaselineFrame = Extract<WorkspaceFollowFrame, { type: 'baseline' }>
/** Gateway-owned snapshot stream configured for Workspace state. */
@@ -46,10 +42,9 @@ export const inject = ['remote', 'remote.workspace']
* @param ctx - Client root Context.
*/
export function apply(ctx: Context): void {
const remote = ctx.remote as WorkspaceStreamRemote
const model = new ClientWorkspaceModel(remote.workspace)
const model = new ClientWorkspaceModel(ctx.remote.workspace)
new WorkspaceController(ctx, model)
const control = createWorkspaceStateStream(remote, {
const control = createWorkspaceStateStream(ctx.remote, {
accept: model,
carrierFailed: () => { model.handleCarrierFailure() },
failed: (error) => { model.handleStreamFailure(error) },
@@ -73,12 +68,12 @@ export interface WorkspaceStateStreamOptions {
/**
* Create the reconnecting Workspace state stream.
* @param remote - generated Workspace namespace and Gateway stream factory.
* @param remote - Client Remote face carrying the Workspace namespace and the stream factory.
* @param options - Workspace state destinations.
* @returns an unstarted stream owned by the Client Workspace runtime.
*/
export function createWorkspaceStateStream(
remote: WorkspaceStreamRemote,
remote: ClientRemote,
options: WorkspaceStateStreamOptions,
): WorkspaceStateStream {
const stream = remote.$stream<WorkspaceFollowFrame>({
@@ -2,6 +2,7 @@
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
import type { RemoteFailure, RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
import type {
WorkspaceArchiveSessionRequest,
@@ -82,12 +83,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
* @returns generated Remote result.
*/
async create(input: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
let result: RemoteResult<WorkspaceCreateValue>
try {
result = await this.remote.create(input)
} catch (error) {
result = failureResult(error)
}
const result = await this.remote.create(input)
if (result.ok) this.upsert(result.value.workspace)
return result
}
@@ -129,19 +125,10 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
const frameGeneration = this.orderFrameGeneration
const localOrder = this.items.map(workspace => workspace.workspaceId)
this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId))
let result: RemoteResult<WorkspaceOrderValue>
try {
result = await this.remote.insertBefore({
workspaceId,
...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
})
} catch (error) {
if (requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(this.committedOrder)
}
throw error
}
const result = await this.remote.insertBefore({
workspaceId,
...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
})
if (requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(result.ok ? result.value.workspaceIds : this.committedOrder, result.ok)
@@ -233,8 +220,9 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
* @param error - terminal stream failure.
*/
handleStreamFailure(error: unknown): void {
if (!isRemoteFailure(error)) throw error
this.state = 'error'
this.error = failureOf(error)
this.error = error
this.invalidate()
}
@@ -369,15 +357,3 @@ function insertIdBefore(
const at = beforeId === undefined ? without.length : without.indexOf(beforeId)
return [...without.slice(0, at), id, ...without.slice(at)]
}
function failureResult<T>(error: unknown): RemoteResult<T> {
return { ok: false, error: failureOf(error) }
}
function failureOf(error: unknown): RemoteFailure {
return {
code: 'internal',
message: error instanceof Error ? error.message : String(error),
details: {},
}
}
@@ -11,7 +11,7 @@ import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts'
export class WorkspaceCreateError extends Error {
override readonly name = 'WorkspaceCreateError'
/** @param rpcError - Host business or folded transport failure. */
/** @param rpcError - Host business or folded carrier failure. */
constructor(readonly rpcError: RemoteFailure) {
super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`)
}
@@ -8,7 +8,7 @@ import {
WorkspaceOrderInvalidError,
WorkspaceUnknownSessionError,
} from '@deepseek-ai/dsh-workspace'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import { workspaceView } from './feed.ts'
import type {
WorkspaceArchiveSessionRequest,
@@ -46,11 +46,12 @@ export class WorkspaceCommands {
const workspace = await this.ctx.workspaceRegistry.create(request.path)
return { workspace: workspaceView(workspace), created: true }
} catch (error) {
if (error instanceof TypertRemoteFailure) throw error
throw failure(
'workspace-invalid-path',
if (remoteErrorOf(error) !== undefined) throw error
throw new RemoteError(
'workspace/invalid-path',
`cannot create a Workspace at "${request.path}": ${errorMessage(error)}`,
{ path: request.path },
{ cause: error },
)
}
})
@@ -64,19 +65,15 @@ export class WorkspaceCommands {
rename(request: WorkspaceRenameRequest): Promise<WorkspaceValue> {
const title = request.title.trim()
if (title === '') {
return Promise.reject(failure(
'bad-request',
'Workspace rename requires a non-blank title',
{},
))
return Promise.reject(new RemoteError('gateway/bad-request', 'Workspace rename requires a non-blank title', {}))
}
return this.enqueue(async () => {
const workspace = this.requireWorkspace(request.workspaceId)
if (title !== workspace.title) {
if (this.ctx.workspaceRegistry.list().some(candidate =>
candidate.id !== workspace.id && candidate.title === title)) {
throw failure(
'workspace-name-conflict',
throw new RemoteError(
'workspace/name-conflict',
`Workspace name '${title}' is already in use`,
{ name: title },
)
@@ -132,8 +129,8 @@ export class WorkspaceCommands {
await workspace.insertSessionBefore(request.sessionId, request.beforeSessionId)
} catch (error) {
if (!(error instanceof WorkspaceMoveInvalidError)) throw error
throw failure(
'workspace-move-invalid',
throw new RemoteError(
'workspace/move-invalid',
error.message,
{
workspaceId: request.workspaceId,
@@ -142,6 +139,7 @@ export class WorkspaceCommands {
? {}
: { beforeSessionId: request.beforeSessionId },
},
{ cause: error },
)
}
return { workspace: workspaceView(workspace) }
@@ -157,7 +155,7 @@ export class WorkspaceCommands {
await this.ctx.workspaceRegistry.archiveSession(request.sessionId)
} catch (error) {
if (!(error instanceof WorkspaceUnknownSessionError)) throw error
throw failure('session-not-found', error.message, { sessionId: request.sessionId })
throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error })
}
return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }
}
@@ -175,22 +173,14 @@ export class WorkspaceCommands {
}
}
function workspaceNotFound(workspaceId: WorkspaceId): TypertRemoteFailure {
return failure(
'workspace-not-found',
function workspaceNotFound(workspaceId: WorkspaceId): RemoteError<'workspace/not-found'> {
return new RemoteError(
'workspace/not-found',
`Workspace "${workspaceId}" not found`,
{ workspaceId },
)
}
function failure(
code: string,
message: string,
details: object,
): TypertRemoteFailure {
return new TypertRemoteFailure({ code, message, details })
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
@@ -6,12 +6,14 @@
import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerCapabilities } from '@deepseek-ai/dsh-host-directory-picker'
import type {
DirectoryPickerCapabilities, DirectoryPickerErrorCode,
} from '@deepseek-ai/dsh-host-directory-picker'
// The seam owns the listing declaration; the generator requires the reference
// site to name that package rather than this package's re-export of it.
import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type { DirectoryPickerErrorDetailsMap } from './types.ts'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type { RemoteErrorCode } from '@deepseek-ai/dsh-typert-protocol'
const createDirectoryRequestSchema = z.object({
path: z.string(),
@@ -86,8 +88,8 @@ export class DirectoryPickerController extends TypertRemoteService {
async createDirectory(path: string, name: string): Promise<string> {
const request = createDirectoryRequestSchema.safeParse({ path, name })
if (!request.success) {
throw pickerFailureOf(
'bad-request',
throw new RemoteError(
'gateway/bad-request',
'invalid payload for host.createDirectory',
{ issues: request.error.issues },
)
@@ -107,8 +109,8 @@ export class DirectoryPickerController extends TypertRemoteService {
): DirectoryPickerCapabilities[Kind] {
const capability = this.ctx.directoryPicker.capability()
if (capability.kind !== kind) {
throw pickerFailureOf(
'directory-picker-unavailable',
throw new RemoteError(
'directory-picker/unavailable',
`directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`,
{ capability: capability.kind },
)
@@ -118,19 +120,15 @@ export class DirectoryPickerController extends TypertRemoteService {
}
/**
* Raise one entry of the picking wire failure vocabulary.
* @param code - the failure code a caller discriminates on.
* @param message - operator-facing description.
* @param details - the payload this code carries.
* @returns the failure to throw across the Remote boundary.
* Wire code answered for each seam browse failure. The seam's closed codes are
* its own local vocabulary, so this controller owns the projection onto the
* `directory-picker/*` codes a Remote caller discriminates on.
*/
function pickerFailureOf<Code extends keyof DirectoryPickerErrorDetailsMap>(
code: Code,
message: string,
details: DirectoryPickerErrorDetailsMap[Code],
): TypertRemoteFailure {
return new TypertRemoteFailure({ code, message, details })
}
const BROWSE_FAILURE_CODES = {
'directory-unreadable': 'directory-picker/unreadable',
'directory-exists': 'directory-picker/exists',
'directory-create-failed': 'directory-picker/create-failed',
} as const satisfies Record<DirectoryPickerErrorCode, RemoteErrorCode>
/**
* Classify a browse-primitive rejection: the seam's own closed codes carry the
@@ -138,16 +136,21 @@ function pickerFailureOf<Code extends keyof DirectoryPickerErrorDetailsMap>(
* @param error - the primitive's rejection.
* @returns the failure to throw across the Remote boundary.
*/
function browseFailure(error: unknown): TypertRemoteFailure {
function browseFailure(error: unknown): RemoteError {
if (error instanceof DirectoryPickerError) {
return pickerFailureOf(error.code, error.message, { path: error.path })
return new RemoteError(
BROWSE_FAILURE_CODES[error.code],
error.message,
{ path: error.path },
{ cause: error },
)
}
return pickerFailureOf('internal', errorMessage(error), {})
return new RemoteError('gateway/internal', errorMessage(error), {}, { cause: error })
}
/**
* Classify a cancellable primitive's rejection. An abort is the caller's own
* timeout or disconnect, not a backend failure, so it answers `cancelled`
* timeout or disconnect, not a backend failure, so it answers `gateway/cancelled`
* before the business classification runs.
* @param error - the primitive's rejection.
* @param signal - the caller lifetime the primitive ran under.
@@ -160,10 +163,10 @@ function cancellableFailure(
signal: AbortSignal,
cancelled: string,
failed?: string,
): TypertRemoteFailure {
if (signal.aborted) return pickerFailureOf('cancelled', cancelled, {})
): RemoteError {
if (signal.aborted) return new RemoteError('gateway/cancelled', cancelled, {}, { cause: error })
if (failed === undefined) return browseFailure(error)
return pickerFailureOf('internal', `${failed}: ${errorMessage(error)}`, {})
return new RemoteError('gateway/internal', `${failed}: ${errorMessage(error)}`, {}, { cause: error })
}
function errorMessage(error: unknown): string {
+20 -41
View File
@@ -7,9 +7,6 @@
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { z as zCore } from 'zod'
type ZodIssue = zCore.core.$ZodIssue
export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
@@ -29,45 +26,27 @@ export interface WorkspaceView {
readonly updatedAt: string
}
/** Stable Workspace failure details returned by unary methods. */
export interface WorkspaceErrorDetailsMap {
'bad-request': Record<never, never>
'workspace-invalid-path': { readonly path: string }
'workspace-not-found': { readonly workspaceId: WorkspaceId }
'workspace-name-conflict': { readonly name: string }
'workspace-move-invalid': {
readonly workspaceId: WorkspaceId
readonly sessionId: SessionId
readonly beforeSessionId?: SessionId
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
/** The requested directory cannot back a Workspace. */
'workspace/invalid-path': { readonly path: string }
/** Another Workspace already uses the requested name. */
'workspace/name-conflict': { readonly name: string }
/** The Session or its anchor is not in the Workspace's manual order. */
'workspace/move-invalid': {
readonly workspaceId: WorkspaceId
readonly sessionId: SessionId
readonly beforeSessionId?: SessionId
}
/** The verb needs an interaction the composed backend does not serve. */
'directory-picker/unavailable': { readonly capability: string }
/** The target is not fully qualified, or the backend cannot list it. */
'directory-picker/unreadable': { readonly path: string }
/** A child of that name is already there. */
'directory-picker/exists': { readonly path: string }
/** The parent is not fully qualified, the name is not one segment, or creation failed. */
'directory-picker/create-failed': { readonly path: string }
}
'session-not-found': { readonly sessionId: SessionId }
}
/** Workspace business failure returned without throwing a carrier error. */
export type WorkspaceError = {
[Code in keyof WorkspaceErrorDetailsMap]: {
readonly code: Code
readonly message: string
readonly details: WorkspaceErrorDetailsMap[Code]
}
}[keyof WorkspaceErrorDetailsMap]
/** Stable directory-picking failure details returned by the picking wire verbs. */
export interface DirectoryPickerErrorDetailsMap {
/** The directory creation request violates its semantic input constraints. */
'bad-request': { readonly issues: ZodIssue[] }
/** The verb needs an interaction the composed backend does not serve. */
'directory-picker-unavailable': { readonly capability: string }
/** The target is not fully qualified, or the backend cannot list it. */
'directory-unreadable': { readonly path: string }
/** A child of that name is already there. */
'directory-exists': { readonly path: string }
/** The parent is not fully qualified, the name is not one segment, or creation failed. */
'directory-create-failed': { readonly path: string }
/** The caller's own timeout or disconnect ended the chooser or the scan. */
cancelled: Record<never, never>
/** A backend failure with no seam code of its own. */
internal: Record<never, never>
}
/** Existing directory requested for Workspace adoption. */