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
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { DirectoryPicker, DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import { DirectoryPickerController } from '../src/directory-picker.ts'
const roots: Context[] = []
@@ -60,8 +60,9 @@ async function refused(call: Promise<unknown>): Promise<{ code: string; message:
try {
await call
} catch (error: unknown) {
if (!(error instanceof TypertRemoteFailure)) throw error
return { ...error.failure }
const failure = remoteErrorOf(error)
if (failure === undefined) throw error
return { code: failure.code, message: failure.message, details: failure.details }
}
throw new Error('the call was expected to be refused')
}
@@ -85,18 +86,18 @@ describe('directoryPicker pick Remote', () => {
const abort = new AbortController()
const pending = refused(picker.pick(abort.signal))
abort.abort()
expect((await pending).code).toBe('cancelled')
expect((await pending).code).toBe('gateway/cancelled')
const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
const failure = await refused(broken.pick(new AbortController().signal))
expect(failure.code).toBe('internal')
expect(failure.code).toBe('gateway/internal')
expect(failure.message).toContain('no chooser installed')
})
it('refuses the native verb under a browse composition', async () => {
const picker = await harness(BROWSE_STUB)
const failure = await refused(picker.pick(new AbortController().signal))
expect(failure.code).toBe('directory-picker-unavailable')
expect(failure.code).toBe('directory-picker/unavailable')
expect(failure.message).toContain('needs the native capability')
expect(failure.details).toEqual({ capability: 'browse' })
})
@@ -115,12 +116,12 @@ describe('directoryPicker browse Remotes', () => {
it('maps the seam\'s typed failures and folds unknown throws to internal', async () => {
const picker = await harness(BROWSE_STUB)
expect(await refused(picker.list('/denied', new AbortController().signal)))
.toMatchObject({ code: 'directory-unreadable', details: { path: '/denied' } })
expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-exists')
expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('internal')
.toMatchObject({ code: 'directory-picker/unreadable', details: { path: '/denied' } })
expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-picker/exists')
expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('gateway/internal')
const thrown = await refused(picker.createDirectory('/home/user', 'gone'))
expect(thrown).toMatchObject({ code: 'internal', message: 'the volume vanished' })
expect(thrown).toMatchObject({ code: 'gateway/internal', message: 'the volume vanished' })
})
it('rejects invalid child names before capability dispatch', async () => {
@@ -134,7 +135,7 @@ describe('directoryPicker browse Remotes', () => {
for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
const failure = await refused(picker.createDirectory('/home/user', name))
expect(failure).toMatchObject({
code: 'bad-request',
code: 'gateway/bad-request',
message: 'invalid payload for host.createDirectory',
})
expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true)
@@ -153,14 +154,14 @@ describe('directoryPicker browse Remotes', () => {
const abort = new AbortController()
const pending = refused(picker.list(undefined, abort.signal))
abort.abort()
expect((await pending).code).toBe('cancelled')
expect((await pending).code).toBe('gateway/cancelled')
})
it('refuses the browse verbs under a native composition', async () => {
const picker = await harness()
expect(await refused(picker.list(undefined, new AbortController().signal)))
.toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } })
.toMatchObject({ code: 'directory-picker/unavailable', details: { capability: 'native' } })
expect(await refused(picker.createDirectory('/x', 'y')))
.toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } })
.toMatchObject({ code: 'directory-picker/unavailable', details: { capability: 'native' } })
})
})
@@ -15,11 +15,10 @@ import type {
WorkspaceOrderValue,
WorkspaceRenameRequest,
WorkspaceValue,
WorkspaceError,
WorkspaceId,
WorkspaceView,
} from '../src/types.ts'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
const sid = (id: string): SessionId => id as SessionId
@@ -44,7 +43,7 @@ function remoteOk<T>(value: T): RemoteResult<T> {
return { ok: true, value }
}
function workspaceError(error: WorkspaceError): RemoteResult<never> {
function workspaceError(error: RemoteFailure): RemoteResult<never> {
return { ok: false, error }
}
@@ -158,17 +157,17 @@ describe('ClientWorkspaceModel', () => {
model.handleCarrierFailure()
expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'loading', error: null })
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['visible'])
model.handleStreamFailure(new Error('wire down'))
model.handleStreamFailure(new RemoteError('gateway/internal', 'wire down', {}))
expect(model.getSnapshot()).toMatchObject({
phase: 'ready', state: 'error', error: { code: 'internal', message: 'wire down' },
phase: 'ready', state: 'error', error: { code: 'gateway/internal', message: 'wire down' },
})
model.handleStreamFailure('plain failure')
expect(model.getSnapshot().error?.message).toBe('plain failure')
// An unmarked value never crosses the stream boundary: it is a local fault.
expect(() => { model.handleStreamFailure('plain failure') }).toThrow()
baseline(model, [workspace('restored')])
expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null })
})
it('creates by path, prepends the returned row, and folds rejected calls', async () => {
it('creates by path and prepends the returned row', async () => {
const remote = new FakeWorkspaceRemote()
const model = modelFor(remote)
remote.onCreate = request => Promise.resolve(remoteOk({
@@ -178,11 +177,6 @@ describe('ClientWorkspaceModel', () => {
await expect(model.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
expect(remote.calls).toContainEqual({ method: 'create', request: { path: '/w/created' } })
expect(model.getSnapshot().items[0]?.workspaceId).toBe('created')
remote.onCreate = () => Promise.reject(new Error('create transport'))
await expect(model.create({ path: '/w/existing' })).resolves.toMatchObject({
ok: false, error: { code: 'internal', message: 'create transport' },
})
})
it('lets newer stream order outrank unary echoes and rolls failures back', async () => {
@@ -199,22 +193,16 @@ describe('ClientWorkspaceModel', () => {
await pending
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
remote.onInsertBefore = () => Promise.resolve(workspaceError({
code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('three') },
}))
remote.onInsertBefore = () => Promise.resolve(workspaceError(
new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('three') }),
))
const rejected = model.insertBefore(wid('three'))
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
await expect(rejected).resolves.toMatchObject({ ok: false })
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
remote.onInsertBefore = () => Promise.reject(new Error('transport down'))
const disconnected = model.insertBefore(wid('three'), wid('one'))
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
await expect(disconnected).rejects.toThrow('transport down')
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
})
it('keeps a newer optimistic reorder when an older transport call rejects', async () => {
it('keeps a newer optimistic reorder when an older refused call settles', async () => {
const remote = new FakeWorkspaceRemote()
const model = modelFor(remote)
baseline(model, [workspace('one'), workspace('two'), workspace('three')])
@@ -225,8 +213,10 @@ describe('ClientWorkspaceModel', () => {
const first = model.insertBefore(wid('three'), wid('one'))
const second = model.insertBefore(wid('two'), wid('three'))
firstGate.reject(new Error('first transport failed'))
await expect(first).rejects.toThrow('first transport failed')
firstGate.resolve(workspaceError(
new RemoteError('workspace/not-found', 'first refused', { workspaceId: wid('three') }),
))
await expect(first).resolves.toMatchObject({ ok: false })
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
secondGate.resolve(remoteOk({ workspaceIds: [wid('two'), wid('three'), wid('one')] }))
await expect(second).resolves.toMatchObject({ ok: true })
@@ -244,14 +234,10 @@ describe('ClientWorkspaceModel', () => {
const first = model.insertBefore(wid('three'), wid('one'))
const second = model.insertBefore(wid('two'), wid('three'))
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
firstGate.resolve(workspaceError({
code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: wid('three') },
}))
firstGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'first rejected', { workspaceId: wid('three') })))
await expect(first).resolves.toMatchObject({ ok: false })
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
secondGate.resolve(workspaceError({
code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: wid('two') },
}))
secondGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'second rejected', { workspaceId: wid('two') })))
await expect(second).resolves.toMatchObject({ ok: false })
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
})
@@ -284,15 +270,11 @@ describe('ClientWorkspaceModel', () => {
const model = modelFor(remote)
baseline(model, [workspace('one', [sid('first'), sid('second')])], [sid('archived')])
remote.onRename = () => Promise.resolve(workspaceError({
code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') },
}))
remote.onRename = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') })))
await expect(model.rename(wid('one'), 'ignored')).resolves.toMatchObject({ ok: false })
expect(model.getSnapshot().items[0]?.title).toBe('one')
remote.onDelete = () => Promise.resolve(workspaceError({
code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') },
}))
remote.onDelete = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') })))
await expect(model.delete(wid('one'))).resolves.toMatchObject({ ok: false })
expect(model.getSnapshot().items).toHaveLength(1)
@@ -306,11 +288,9 @@ describe('ClientWorkspaceModel', () => {
request: { workspaceId: 'one', sessionId: 'second', beforeSessionId: 'first' },
})
remote.onInsertSessionBefore = () => Promise.resolve(workspaceError({
code: 'workspace-move-invalid',
message: 'invalid move',
details: { workspaceId: wid('one'), sessionId: sid('second') },
}))
remote.onInsertSessionBefore = () => Promise.resolve(workspaceError(
new RemoteError('workspace/move-invalid', 'invalid move', { workspaceId: wid('one'), sessionId: sid('second') }),
))
await expect(model.insertSessionBefore(wid('one'), sid('second')))
.resolves.toMatchObject({ ok: false })
expect(remote.calls).toContainEqual({
@@ -318,9 +298,9 @@ describe('ClientWorkspaceModel', () => {
request: { workspaceId: 'one', sessionId: 'second' },
})
remote.onArchiveSession = () => Promise.resolve(workspaceError({
code: 'session-not-found', message: 'missing', details: { sessionId: sid('missing') },
}))
remote.onArchiveSession = () => Promise.resolve(workspaceError(
new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }),
))
await expect(model.archiveSession(sid('missing'))).resolves.toMatchObject({ ok: false })
expect(model.getSnapshot().archivedSessionIds).toEqual(['archived'])
remote.onArchiveSession = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
@@ -3,11 +3,12 @@ import { describe, expect, it, vi } from 'vitest'
import {
RemoteStream,
RemoteStreamCarrierError,
type ClientRemote,
type RemoteStreamOptions,
} from '@deepseek-ai/dsh-api-gateway/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import * as WorkspaceClientPlugin from '../src/client/index.ts'
import {
ClientWorkspaceModel,
@@ -29,7 +30,6 @@ import type {
WorkspaceInsertSessionBeforeRequest,
WorkspaceOrderValue,
WorkspaceRenameRequest,
WorkspaceError,
WorkspaceId,
WorkspaceValue,
WorkspaceView,
@@ -45,11 +45,11 @@ const AVAILABLE_CONNECTION = {
function workspaceClient(
remote: WorkspaceRemote,
connection: Pick<ConnectionHandle, 'generation'> = AVAILABLE_CONNECTION,
) {
): ClientRemote {
return {
workspace: remote,
$stream: <Item>(options: RemoteStreamOptions<Item>) => new RemoteStream(connection, options),
}
} as unknown as ClientRemote
}
interface Generation {
@@ -94,7 +94,7 @@ function remoteOk<T>(value: T): RemoteResult<T> {
return { ok: true, value }
}
function remoteFailure(error: WorkspaceError): RemoteResult<never> {
function remoteFailure(error: RemoteFailure): RemoteResult<never> {
return { ok: false, error }
}
@@ -250,7 +250,7 @@ describe('Workspace Controller Client apply', () => {
phase: 'ready',
state: 'error',
items: [{ workspaceId: 'fresh' }],
error: { code: 'internal', message: 'Workspace state stream emitted more than one opening snapshot' },
error: { code: 'gateway/internal', message: 'Workspace state stream emitted more than one opening snapshot' },
})
})
@@ -445,40 +445,27 @@ describe('WorkspaceController', () => {
it('maps generated business failures to the command facade errors', async () => {
const remote = new CommandWorkspaceRemote()
const controller = new WorkspaceController(new Context(), new ClientWorkspaceModel(remote))
const missingWorkspace: WorkspaceError = {
code: 'workspace-not-found',
message: 'gone',
details: { workspaceId: wid('missing') },
}
const missingSession: WorkspaceError = {
code: 'session-not-found',
message: 'missing session',
details: { sessionId: sid('session') },
}
const missingWorkspace = new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('missing') })
const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') })
remote.create.mockResolvedValueOnce(remoteFailure({
code: 'workspace-invalid-path',
message: 'missing path',
details: { path: '/missing' },
}))
remote.create.mockResolvedValueOnce(remoteFailure(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' })))
const create = controller.create({ path: '/missing' })
await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError)
await expect(create).rejects.toThrow('workspace-invalid-path: missing path')
await expect(create).rejects.toThrow('workspace/invalid-path: missing path')
remote.rename.mockResolvedValueOnce(remoteFailure(missingWorkspace))
await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace-not-found: gone')
await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace/not-found: gone')
remote.delete.mockResolvedValueOnce(remoteFailure(missingWorkspace))
await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace-not-found: gone')
await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace/not-found: gone')
remote.insertBefore.mockResolvedValueOnce(remoteFailure(missingWorkspace))
await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace-not-found: gone')
await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace/not-found: gone')
remote.archiveSession.mockResolvedValueOnce(remoteFailure(missingSession))
await expect(controller.archiveSession(sid('session'))).rejects.toThrow('workspace session archive failed: session-not-found: missing session')
remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure({
code: 'workspace-move-invalid',
message: 'invalid move',
details: { workspaceId: wid('missing'), sessionId: sid('session') },
}))
await expect(controller.archiveSession(sid('session')))
.rejects.toThrow('workspace session archive failed: session/not-found: missing session')
remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure(new RemoteError(
'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') },
)))
await expect(controller.insertSessionBefore(wid('missing'), sid('session')))
.rejects.toThrow('workspace move failed: workspace-move-invalid: invalid move')
.rejects.toThrow('workspace move failed: workspace/move-invalid: invalid move')
})
})
@@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import Storage from '@deepseek-ai/dsh-storage'
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import WorkspaceController from '../src/index.ts'
@@ -14,6 +14,12 @@ import { WorkspaceFeed } from '../src/feed.ts'
import type { WorkspaceFollowFrame } from '../src/types.ts'
import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
'fixture/failure': {}
}
}
const roots: Context[] = []
afterEach(async () => {
@@ -94,34 +100,29 @@ describe('WorkspaceController commands', () => {
const second = await controller.create({ path: stageDir(root, 'second') })
await expect(controller.create({ path: join(root, 'missing') })).rejects.toMatchObject({
failure: { code: 'workspace-invalid-path', details: { path: join(root, 'missing') } },
code: 'workspace/invalid-path',
details: { path: join(root, 'missing') },
})
expect(existsSync(join(root, 'missing'))).toBe(false)
await expect(controller.rename({ workspaceId: first.workspace.workspaceId, title: ' ' }))
.rejects.toMatchObject({ failure: { code: 'bad-request' } })
.rejects.toMatchObject({ code: 'gateway/bad-request' })
await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'occupied' })
await expect(controller.rename({ workspaceId: second.workspace.workspaceId, title: ' occupied ' }))
.rejects.toMatchObject({ failure: { code: 'workspace-name-conflict' } })
.rejects.toMatchObject({ code: 'workspace/name-conflict' })
await expect(controller.delete({ workspaceId: 'missing' as WorkspaceId }))
.rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
.rejects.toMatchObject({ code: 'workspace/not-found' })
})
it('preserves Remote failures and propagates unexpected registry failures', async () => {
const { controller, ctx, root } = await harness()
const remoteFailure = new TypertRemoteFailure({
code: 'fixture-failure',
message: 'already mapped',
details: {},
})
const remoteFailure = new RemoteError('fixture/failure', 'already mapped', {})
const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath')
.mockRejectedValueOnce(remoteFailure)
.mockRejectedValueOnce('plain failure')
await expect(controller.create({ path: stageDir(root, 'remote-failure') }))
.rejects.toBe(remoteFailure)
const plainFailure = controller.create({ path: stageDir(root, 'plain-failure') })
await expect(plainFailure).rejects.toMatchObject({
failure: { code: 'workspace-invalid-path' },
})
await expect(plainFailure).rejects.toMatchObject({ code: 'workspace/invalid-path' })
await expect(plainFailure).rejects.toThrow('plain failure')
resolveByPath.mockRestore()
@@ -168,7 +169,7 @@ describe('WorkspaceController commands', () => {
gate.resolve(undefined)
await blocker
await expect(deletion).resolves.toEqual({ deleted: true })
await expect(staleRename).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
await expect(staleRename).rejects.toMatchObject({ code: 'workspace/not-found' })
})
it('reorders Workspaces and Sessions and archives only known Sessions', async () => {
@@ -182,7 +183,7 @@ describe('WorkspaceController commands', () => {
workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId],
})
await expect(controller.insertBefore({ workspaceId: 'missing' as WorkspaceId }))
.rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
.rejects.toMatchObject({ code: 'workspace/not-found' })
const session = ctx.sessions.create(SessionId('session-one'), {
meta: { cwd: first.workspace.path },
@@ -197,26 +198,24 @@ describe('WorkspaceController commands', () => {
await expect(controller.insertSessionBefore({
workspaceId: first.workspace.workspaceId,
sessionId: SessionId('missing-session'),
})).rejects.toMatchObject({ failure: { code: 'workspace-move-invalid' } })
})).rejects.toMatchObject({ code: 'workspace/move-invalid' })
await expect(controller.insertSessionBefore({
workspaceId: first.workspace.workspaceId,
sessionId: session.id,
beforeSessionId: SessionId('missing-anchor'),
})).rejects.toMatchObject({
failure: {
code: 'workspace-move-invalid',
details: { beforeSessionId: 'missing-anchor' },
},
code: 'workspace/move-invalid',
details: { beforeSessionId: 'missing-anchor' },
})
await expect(controller.insertSessionBefore({
workspaceId: 'missing' as WorkspaceId,
sessionId: session.id,
})).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
})).rejects.toMatchObject({ code: 'workspace/not-found' })
await expect(controller.archiveSession({ sessionId: session.id }))
.resolves.toEqual({ archivedSessionIds: [session.id] })
await expect(controller.archiveSession({ sessionId: SessionId('unknown') }))
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
.rejects.toMatchObject({ code: 'session/not-found' })
})
})