feat(typert): deliver the carrier outcome from ctx.remote

Every generated Remote method now resolves to `RemoteResult<T>`: the Client
face folds a carrier failure, a transport throw and a rejected result payload
into one error branch, so no consumer wraps a call to recover them. Only
assembly faults still reject — a wrong argument count, an unmounted method, a
missing Context binder, an absent Connection.

`RemoteFailure.code` stays an open string because the closed RPC code union
lives in the carrier package, which already depends on type-meta; naming it
here would invert that edge.

The goal surface drops its own try/catch plus the structural probe that
guessed whether a thrown cause was an RPC failure, and reads the result
instead.
This commit is contained in:
imccyu
2026-08-11 23:33:15 +08:00
parent 027e5fe9a4
commit a2981207b0
7 changed files with 262 additions and 125 deletions
+30 -13
View File
@@ -6,10 +6,11 @@
import { Service } from '@deepseek-ai/cordis' import { Service } from '@deepseek-ai/cordis'
import type { Context, Events } from '@deepseek-ai/cordis' import type { Context, Events } from '@deepseek-ai/cordis'
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { import type {
InvocationDescriptor, InvocationDescriptor,
TypeRTClientRemote, TypeRTClientRemote,
RemoteResult,
TypeRTCodec, TypeRTCodec,
TypeRTDisposer, TypeRTDisposer,
TypeRTRemoteContribution, TypeRTRemoteContribution,
@@ -328,7 +329,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
scoped: ScopedMethod | undefined, scoped: ScopedMethod | undefined,
callerCtx: Context, callerCtx: Context,
values: readonly unknown[], values: readonly unknown[],
): Promise<unknown> { ): Promise<RemoteResult<unknown>> {
if (scoped !== undefined) { if (scoped !== undefined) {
const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
const identity = binder?.identity(callerCtx) const identity = binder?.identity(callerCtx)
@@ -359,9 +360,9 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
callerCtx: Context, callerCtx: Context,
values: readonly unknown[], values: readonly unknown[],
boundIdentity?: BoundContextIdentity, boundIdentity?: BoundContextIdentity,
): Promise<unknown> { ): Promise<RemoteResult<unknown>> {
const endpoint = endpointOf(descriptor) const endpoint = endpointOf(descriptor)
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) if (!token.active) return withdrawn(endpoint)
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
if (values.length !== expected && !hasCallerSignal) { if (values.length !== expected && !hasCallerSignal) {
@@ -391,7 +392,8 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
let valueIndex = 0 let valueIndex = 0
descriptor.parameters.forEach((parameter, parameterIndex) => { descriptor.parameters.forEach((parameter, parameterIndex) => {
if (parameterIndex === projection?.parameterIndex) return if (parameterIndex === projection?.parameterIndex) return
args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire) const value = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
if (value !== undefined) args[parameter.wire] = value
valueIndex += 1 valueIndex += 1
}) })
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
@@ -400,10 +402,16 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
const signal = callerSignal === undefined const signal = callerSignal === undefined
? token.abort.signal ? token.abort.signal
: AbortSignal.any([token.abort.signal, callerSignal]) : AbortSignal.any([token.abort.signal, callerSignal])
const result = await connection.rpc.call('/api', endpoint, { args }, signal) try {
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) const result = await connection.rpc.call('/api', endpoint, { args }, signal)
if (!result.ok) throw remoteFailure(endpoint, result.error) if (!mountActive(token)) return withdrawn(endpoint)
return parse(descriptor.result, result.value, endpoint, 'result') if (!result.ok) return { ok: false, error: result.error }
return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') }
} catch (error) {
// Carrier throws (offline, abort, a rejected result payload) are outcomes
// of the call, not assembly faults, so they join the same error branch.
return carrierFailure(endpoint, error)
}
} }
} }
@@ -412,7 +420,7 @@ type InvokeRemote = (
scoped: ScopedMethod | undefined, scoped: ScopedMethod | undefined,
callerCtx: Context, callerCtx: Context,
args: readonly unknown[], args: readonly unknown[],
) => Promise<unknown> ) => Promise<RemoteResult<unknown>>
class RemoteNamespaceService extends Service { class RemoteNamespaceService extends Service {
private readonly methods = new Map<string, RemoteMethodRecord>() private readonly methods = new Map<string, RemoteMethodRecord>()
@@ -467,7 +475,7 @@ class RemoteNamespaceService extends Service {
Object.defineProperty(this, method, { Object.defineProperty(this, method, {
configurable: true, configurable: true,
enumerable: true, enumerable: true,
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> { get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<RemoteResult<unknown>> {
const callerCtx = this.ctx const callerCtx = this.ctx
const current = this.methods.get(method) const current = this.methods.get(method)
const direct = current?.direct const direct = current?.direct
@@ -566,6 +574,15 @@ function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: stri
} }
} }
function remoteFailure(endpoint: string, error: RpcError): Error { /** The namespace retired before or during the call, so no request outcome exists. */
return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error }) function withdrawn(endpoint: string): RemoteResult<never> {
return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)
}
function carrierFailure(endpoint: string, error: unknown): RemoteResult<never> {
return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`)
}
function internalFailure(message: string): RemoteResult<never> {
return { ok: false, error: { code: 'internal', message, details: {} } }
} }
+134 -80
View File
@@ -42,23 +42,24 @@ declare module '@deepseek-ai/dsh-type-meta' {
} }
interface TypeRTRemoteMap { interface TypeRTRemoteMap {
'goals/create': ( 'probe/create': (
agentId: string, agentId: string,
request: { readonly objective: string }, request: { readonly objective: string },
signal?: AbortSignal, signal?: AbortSignal,
) => Promise<{ readonly ref: string }> ) => Promise<{ readonly ref: string }>
'probe/maybe': (value: string | null | undefined) => Promise<string | null | undefined>
} }
interface TypeRTRemoteScopeMap { interface TypeRTRemoteScopeMap {
'fixture:goals/create': ( 'fixture:probe/create': (
request: { readonly objective: string }, request: { readonly objective: string },
signal?: AbortSignal, signal?: AbortSignal,
) => Promise<{ readonly ref: string }> ) => Promise<{ readonly ref: string }>
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> 'fixture:probe/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
} }
interface TypeRTRemoteNamespaceMap { interface TypeRTRemoteNamespaceMap {
goals: TypeRTRemoteNamespace<'goals'> probe: TypeRTRemoteNamespace<'probe'>
} }
} }
@@ -87,9 +88,9 @@ const renameResultSchema = z.object({ renamed: z.boolean() })
function directDescriptor(): InvocationDescriptor { function directDescriptor(): InvocationDescriptor {
return { return {
id: '@fixture/goals#goals/create', id: '@fixture/probe#probe/create',
service: 'goals', service: 'probe',
namespace: 'goals', namespace: 'probe',
method: 'create', method: 'create',
invocation: { kind: 'direct' }, invocation: { kind: 'direct' },
scope: { context: 'fixture', wire: 'agentId' }, scope: { context: 'fixture', wire: 'agentId' },
@@ -112,9 +113,9 @@ function directDescriptor(): InvocationDescriptor {
function contextDescriptor(): InvocationDescriptor { function contextDescriptor(): InvocationDescriptor {
return { return {
id: '@fixture/goals#goals/rename', id: '@fixture/probe#probe/rename',
service: 'goals', service: 'probe',
namespace: 'goals', namespace: 'probe',
method: 'rename', method: 'rename',
invocation: { invocation: {
kind: 'context', kind: 'context',
@@ -132,6 +133,25 @@ function contextDescriptor(): InvocationDescriptor {
} }
} }
function maybeDescriptor(): InvocationDescriptor {
const schema = z.union([z.string(), z.null(), z.undefined()])
return {
id: '@fixture/probe#probe/maybe',
service: 'probe',
namespace: 'probe',
method: 'maybe',
invocation: { kind: 'direct' },
parameters: [{
name: 'value',
wire: 'value',
source: 'json',
acceptsUndefined: true,
codec: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema },
}],
result: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema },
}
}
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> { async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
const { ctx } = await benchFiber(call) const { ctx } = await benchFiber(call)
return ctx return ctx
@@ -153,24 +173,24 @@ describe('Client TypeRT API', () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>() const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
const ctx = await bench(call) const ctx = await bench(call)
const businessGoals = { owner: 'host business service' } const businessProbe = { owner: 'host business service' }
const disposeBusinessGoals = ctx.provide('goals', businessGoals) const disposeBusinessProbe = ctx.provide('probe', businessProbe)
const assembly = ctx.plugin(Object.assign( const assembly = ctx.plugin(Object.assign(
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }),
{ inject: ['remote'] }, { inject: ['remote'] },
)) ))
await assembly await assembly
const retained = ctx.remote.goals.create const retained = ctx.remote.probe.create
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
expect(call).toHaveBeenCalledWith( expect(call).toHaveBeenCalledWith(
'/api', '/api',
'goals/create', 'probe/create',
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } }, { args: { agentId: 'agent-1', request: { objective: 'ship' } } },
expect.any(AbortSignal), expect.any(AbortSignal),
) )
const callerAbort = new AbortController() const callerAbort = new AbortController()
await expect(ctx.remote.goals.create( await expect(ctx.remote.probe.create(
'agent-1', 'agent-1',
{ objective: 'cancel me' }, { objective: 'cancel me' },
callerAbort.signal, callerAbort.signal,
@@ -182,18 +202,52 @@ describe('Client TypeRT API', () => {
callerAbort.abort(cancellation) callerAbort.abort(cancellation)
expect(combinedSignal?.aborted).toBe(true) expect(combinedSignal?.aborted).toBe(true)
expect(combinedSignal?.reason).toBe(cancellation) expect(combinedSignal?.reason).toBe(cancellation)
await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') await expect(ctx.remote.probe.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
await assembly.dispose() await assembly.dispose()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined() expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined() expect(ctx.get('remote.probe')).toBeUndefined()
expect(ctx.get('goals')).toBe(businessGoals) expect(ctx.get('probe')).toBe(businessProbe)
expect(ctx.typert.remotes.list()).toEqual([]) expect(ctx.typert.remotes.list()).toEqual([])
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
disposeBusinessGoals() disposeBusinessProbe()
})
it('encodes declared undefined as an omitted argument and distinguishes it from null results', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValueOnce({ ok: true, value: undefined })
.mockResolvedValueOnce({ ok: true, value: null })
const ctx = await bench(call)
const dispose = await ctx.remote.$mount({
package: '@fixture/maybe',
descriptors: [maybeDescriptor()],
})
// The analyzers disagree on the key-remapped namespace projection: tsc
// resolves this method, oxlint reads it as an error type.
// oxlint-disable-next-line typescript/no-unsafe-call
await expect(ctx.remote.probe.maybe(undefined)).resolves.toBeUndefined()
expect(call).toHaveBeenNthCalledWith(
1,
'/api',
'probe/maybe',
{ args: {} },
expect.any(AbortSignal),
)
// oxlint-disable-next-line typescript/no-unsafe-call
await expect(ctx.remote.probe.maybe(null)).resolves.toBeNull()
expect(call).toHaveBeenNthCalledWith(
2,
'/api',
'probe/maybe',
{ args: { value: null } },
expect.any(AbortSignal),
)
await dispose()
}) })
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => { it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
@@ -205,24 +259,24 @@ describe('Client TypeRT API', () => {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
}) })
const assembly = ctx.plugin(Object.assign( const assembly = ctx.plugin(Object.assign(
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }),
{ inject: ['remote'] }, { inject: ['remote'] },
)) ))
await assembly await assembly
await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) await expect(agentCtx.remote.probe.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
expect(call).toHaveBeenCalledWith( expect(call).toHaveBeenCalledWith(
'/api', '/api',
'goals/create', 'probe/create',
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
expect.any(AbortSignal), expect.any(AbortSignal),
) )
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' })) await expect((ctx as FixtureContext).remote.probe.create({ objective: 'wrong scope' }))
.rejects.toThrow('expected 2 business argument(s)') .rejects.toThrow('expected 2 business argument(s)')
await assembly.dispose() await assembly.dispose()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined() expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined() expect(ctx.get('remote.probe')).toBeUndefined()
}) })
it('uses the caller Context identity for scoped namespace methods', async () => { it('uses the caller Context identity for scoped namespace methods', async () => {
@@ -234,23 +288,23 @@ describe('Client TypeRT API', () => {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
}) })
const assembly = ctx.plugin(Object.assign( const assembly = ctx.plugin(Object.assign(
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }), (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }),
{ inject: ['remote'] }, { inject: ['remote'] },
)) ))
await assembly await assembly
await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) await expect(agentCtx.remote.probe.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
expect(call).toHaveBeenCalledWith( expect(call).toHaveBeenCalledWith(
'/api', '/api',
'goals/rename', 'probe/rename',
{ args: { agentId: 'agent-2', request: { objective: 'land' } } }, { args: { agentId: 'agent-2', request: { objective: 'land' } } },
expect.any(AbortSignal), expect.any(AbortSignal),
) )
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' })) await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'land' }))
.rejects.toThrow('requires a "fixture" Context') .rejects.toThrow('requires a "fixture" Context')
await assembly.dispose() await assembly.dispose()
expect(ctx.get('remote.goals')).toBeUndefined() expect(ctx.get('remote.probe')).toBeUndefined()
}) })
it('rejects weak descriptors and namespace collisions before registration', async () => { it('rejects weak descriptors and namespace collisions before registration', async () => {
@@ -282,32 +336,32 @@ describe('Client TypeRT API', () => {
await expect(ctx.remote.$mount({ await expect(ctx.remote.$mount({
package: '@fixture/direct-duplicates', package: '@fixture/direct-duplicates',
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], descriptors: [direct, { ...direct, id: '@fixture/probe#probe/create-again' }],
})).rejects.toThrow('repeats direct method') })).rejects.toThrow('repeats direct method')
await expect(ctx.remote.$mount({ await expect(ctx.remote.$mount({
package: '@fixture/scoped-duplicates', package: '@fixture/scoped-duplicates',
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], descriptors: [context, { ...context, id: '@fixture/probe#probe/rename-again' }],
})).rejects.toThrow('repeats scoped method') })).rejects.toThrow('repeats scoped method')
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] }) const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] })
await expect(ctx.remote.$mount({ await expect(ctx.remote.$mount({
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#probe/create' }],
})).rejects.toThrow('direct method goals/create is already mounted') })).rejects.toThrow('direct method probe/create is already mounted')
await disposeDirect() await disposeDirect()
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] }) const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] })
await expect(ctx.remote.$mount({ await expect(ctx.remote.$mount({
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#probe/rename' }],
})).rejects.toThrow('scoped method goals/rename is already mounted') })).rejects.toThrow('scoped method probe/rename is already mounted')
await expect(ctx.remote.$mount({ await expect(ctx.remote.$mount({
package: '@fixture/service-method-conflict', package: '@fixture/service-method-conflict',
descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], descriptors: [{ ...context, id: '@fixture/probe#probe/remove', method: 'remove' }],
})).rejects.toThrow('conflicts with its namespace service') })).rejects.toThrow('conflicts with its namespace service')
const scopedService = ctx.get('remote.goals') as unknown as object const scopedService = ctx.get('remote.probe') as unknown as object
Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined }) Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
await expect(ctx.remote.$mount({ await expect(ctx.remote.$mount({
package: '@fixture/service-own-property-conflict', package: '@fixture/service-own-property-conflict',
descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], descriptors: [{ ...direct, id: '@fixture/probe#probe/custom', method: 'custom' }],
})).rejects.toThrow('conflicts with its namespace service') })).rejects.toThrow('conflicts with its namespace service')
Reflect.deleteProperty(scopedService, 'custom') Reflect.deleteProperty(scopedService, 'custom')
await disposeScoped() await disposeScoped()
@@ -323,10 +377,10 @@ describe('Client TypeRT API', () => {
package: '@fixture/multiple-scoped', package: '@fixture/multiple-scoped',
descriptors: [directDescriptor(), contextDescriptor()], descriptors: [directDescriptor(), contextDescriptor()],
}) })
await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) await expect(agentCtx.remote.probe.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
expect(call).toHaveBeenLastCalledWith( expect(call).toHaveBeenLastCalledWith(
'/api', '/api',
'goals/rename', 'probe/rename',
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } }, { args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
expect.any(AbortSignal), expect.any(AbortSignal),
) )
@@ -338,7 +392,7 @@ describe('Client TypeRT API', () => {
const { scope: _scope, ...first } = directDescriptor() const { scope: _scope, ...first } = directDescriptor()
const second: InvocationDescriptor = { const second: InvocationDescriptor = {
...first, ...first,
id: '@fixture/goals#goals/archive', id: '@fixture/probe#probe/archive',
method: 'archive', method: 'archive',
} }
const defineProperty = Object.defineProperty const defineProperty = Object.defineProperty
@@ -353,11 +407,11 @@ describe('Client TypeRT API', () => {
spy.mockRestore() spy.mockRestore()
} }
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined() expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
expect(ctx.remote.goals.create).toBeTypeOf('function') expect(ctx.remote.probe.create).toBeTypeOf('function')
expect((ctx.remote.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function') expect((ctx.remote.probe as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
await retry() await retry()
}) })
@@ -367,7 +421,7 @@ describe('Client TypeRT API', () => {
package: '@fixture/context-anchor', package: '@fixture/context-anchor',
descriptors: [contextDescriptor()], descriptors: [contextDescriptor()],
}) })
const namespace = ctx.get('remote.goals') as unknown as { const namespace = ctx.get('remote.probe') as unknown as {
installScoped: (...args: unknown[]) => void installScoped: (...args: unknown[]) => void
readonly create?: unknown readonly create?: unknown
} }
@@ -429,28 +483,28 @@ describe('Client TypeRT API', () => {
const ctx = await bench(call) const ctx = await bench(call)
const descriptor = directDescriptor() const descriptor = directDescriptor()
const dispose = await ctx.remote.$mount({ const dispose = await ctx.remote.$mount({
package: '@fixture/goals', package: '@fixture/probe',
descriptors: [descriptor, contextDescriptor()], descriptors: [descriptor, contextDescriptor()],
}) })
const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise<unknown> const create = ctx.remote.probe.create as unknown as (...args: unknown[]) => Promise<unknown>
const goals = (ctx as FixtureContext).remote.goals const probe = (ctx as FixtureContext).remote.probe
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown> const rename = probe.rename as unknown as (...args: unknown[]) => Promise<unknown>
await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
.rejects.toThrow('got 4') .rejects.toThrow('got 4')
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') await expect(rename.call(probe)).rejects.toThrow('expected 1 argument(s), got 0')
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' })) await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' }))
.rejects.toThrow('expected 2 business argument(s)') .rejects.toThrow('expected 2 business argument(s)')
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' })) await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' }))
.rejects.toThrow('no Client Context binder') .rejects.toThrow('no Client Context binder')
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
ctx.set('connection', undefined) ctx.set('connection', undefined)
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
await dispose() await dispose()
}) })
@@ -464,23 +518,23 @@ describe('Client TypeRT API', () => {
const { scope: _scope, ...first } = directDescriptor() const { scope: _scope, ...first } = directDescriptor()
const second: InvocationDescriptor = { const second: InvocationDescriptor = {
...first, ...first,
id: '@fixture/goals#goals/archive', id: '@fixture/probe#probe/archive',
method: 'archive', method: 'archive',
} }
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] }) const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [first, second] })
const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' }) const invocation = ctx.remote.probe.create('agent-1', { objective: 'ship' })
await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
await dispose() await dispose()
resolveCall({ ok: true, value: { ref: 'goal-1' } }) resolveCall({ ok: true, value: { ref: 'goal-1' } })
await expect(invocation).rejects.toThrow('withdrawn during invocation') await expect(invocation).rejects.toThrow('withdrawn during invocation')
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined() expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
}) })
it('fails a method obtained from a withdrawn namespace getter', async () => { it('fails a method obtained from a withdrawn namespace getter', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>()) const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
const namespace = ctx.get('remote.goals') as unknown as object const namespace = ctx.get('remote.probe') as unknown as object
const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace) const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace)
await dispose() await dispose()
@@ -498,7 +552,7 @@ describe('Client TypeRT API', () => {
const { scope: _scope, ...base } = directDescriptor() const { scope: _scope, ...base } = directDescriptor()
const descriptor: InvocationDescriptor = { const descriptor: InvocationDescriptor = {
...base, ...base,
id: '@fixture/goals#goals/prototype', id: '@fixture/probe#probe/prototype',
method: 'prototype', method: 'prototype',
parameters: [{ parameters: [{
name: 'value', name: 'value',
@@ -509,7 +563,7 @@ describe('Client TypeRT API', () => {
} }
const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] }) const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
const method = (ctx.remote.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype const method = (ctx.remote.probe as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> } const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
expect(Object.getPrototypeOf(payload.args)).toBeNull() expect(Object.getPrototypeOf(payload.args)).toBeNull()
@@ -526,15 +580,15 @@ describe('Client TypeRT API', () => {
return defineProperty(target, key, attributes) return defineProperty(target, key, attributes)
}) })
try { try {
await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) await expect(ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }))
.rejects.toThrow('fixture namespace startup failure') .rejects.toThrow('fixture namespace startup failure')
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
} finally { } finally {
spy.mockRestore() spy.mockRestore()
} }
const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) const retry = await ctx.remote.$mount({ package: '@fixture/probe-retry', descriptors: [directDescriptor()] })
expect(ctx.remote.goals.create).toBeTypeOf('function') expect(ctx.remote.probe.create).toBeTypeOf('function')
await retry() await retry()
}) })
@@ -554,13 +608,13 @@ describe('Client TypeRT API', () => {
spy.mockRestore() spy.mockRestore()
} }
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined() expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = await ctx.remote.$mount({ const retry = await ctx.remote.$mount({
package: '@fixture/direct-method-retry', package: '@fixture/direct-method-retry',
descriptors: [directDescriptor()], descriptors: [directDescriptor()],
}) })
expect(ctx.remote.goals.create).toBeTypeOf('function') expect(ctx.remote.probe.create).toBeTypeOf('function')
await retry() await retry()
}) })
@@ -578,35 +632,35 @@ describe('Client TypeRT API', () => {
spy.mockRestore() spy.mockRestore()
} }
expect(ctx.get('remote.goals')).toBeUndefined() expect(ctx.get('remote.probe')).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
expect((ctx.get('remote.goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function') expect((ctx.get('remote.probe') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
await retry() await retry()
}) })
it('unregisters an empty scoped namespace so another provider can claim its name', async () => { it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>()) const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
expect(ctx.get('remote.goals')).toBeDefined() expect(ctx.get('remote.probe')).toBeDefined()
await dispose() await dispose()
expect(ctx.get('remote.goals')).toBeUndefined() expect(ctx.get('remote.probe')).toBeUndefined()
const replacement = { owner: 'replacement' } const replacement = { owner: 'replacement' }
const disposeReplacement = ctx.reflect.provide('remote.goals', replacement) const disposeReplacement = ctx.reflect.provide('remote.probe', replacement)
expect(ctx.get('remote.goals')).toBe(replacement) expect(ctx.get('remote.probe')).toBe(replacement)
await disposeReplacement() await disposeReplacement()
}) })
it('throws RPC failures with the structured error as its cause', async () => { it('throws RPC failures with the structured error as its cause', async () => {
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError })) const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
let failure: unknown let failure: unknown
try { try {
await ctx.remote.goals.create('agent-1', { objective: 'ship' }) await ctx.remote.probe.create('agent-1', { objective: 'ship' })
} catch (error) { } catch (error) {
failure = error failure = error
} }
+10 -27
View File
@@ -8,6 +8,7 @@
* their CAS ref reads the session's current projected value at call time. * their CAS ref reads the session's current projected value at call time.
* Goal creation stays on the /goal host command. * Goal creation stays on the /goal host command.
*/ */
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -40,29 +41,11 @@ const NS = 'goal'
/** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */ /** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents'] export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents']
/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */ /** Narrow one Remote mutation's result to the fields the goal strip renders. */
async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> { function settle(result: RemoteResult<unknown>): GoalActionResult {
try { return result.ok
await invoke() ? { ok: true }
return { ok: true } : { ok: false, error: { code: result.error.code, message: result.error.message } }
} catch (error) {
const cause = error instanceof Error ? error.cause : undefined
if (isRemoteError(cause)) return { ok: false, error: { code: cause.code, message: cause.message } }
return {
ok: false,
error: {
code: 'internal',
message: error instanceof Error ? error.message : 'goal mutation failed',
},
}
}
}
function isRemoteError(value: unknown): value is { readonly code: string; readonly message: string } {
return value !== null
&& typeof value === 'object'
&& typeof (value as { code?: unknown }).code === 'string'
&& typeof (value as { message?: unknown }).message === 'string'
} }
/** /**
@@ -103,22 +86,22 @@ export function apply(ctx: ClientContext): void {
onEdit: async (objective) => { onEdit: async (objective) => {
const ref = refOf(sessionId) const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal if (ref === undefined) return noCurrentGoal
return settle(() => ctx.remote.goals.edit(sessionId, ref, { objective })) return settle(await ctx.remote.goals.edit(sessionId, ref, { objective }))
}, },
onPause: async () => { onPause: async () => {
const ref = refOf(sessionId) const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal if (ref === undefined) return noCurrentGoal
return settle(() => ctx.remote.goals.pause(sessionId, ref)) return settle(await ctx.remote.goals.pause(sessionId, ref))
}, },
onResume: async () => { onResume: async () => {
const ref = refOf(sessionId) const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal if (ref === undefined) return noCurrentGoal
return settle(() => ctx.remote.goals.resume(sessionId, ref)) return settle(await ctx.remote.goals.resume(sessionId, ref))
}, },
onClear: async () => { onClear: async () => {
const ref = refOf(sessionId) const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal if (ref === undefined) return noCurrentGoal
return settle(() => ctx.remote.goals.clear(sessionId, ref)) return settle(await ctx.remote.goals.clear(sessionId, ref))
}, },
}), }),
}, GoalDock)) }, GoalDock))
+7 -2
View File
@@ -308,6 +308,7 @@ export class FaceModelEmitter {
lines.push(` wire: ${quote(parameter.wire)},`) lines.push(` wire: ${quote(parameter.wire)},`)
lines.push(` source: ${quote(parameter.source)},`) lines.push(` source: ${quote(parameter.source)},`)
if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`) if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`)
if (parameter.boundary.acceptsUndefined) lines.push(' acceptsUndefined: true,')
lines.push(` codec: ${indent(strictCodec( lines.push(` codec: ${indent(strictCodec(
parameter.boundary, parameter.boundary,
schemas.boundary(parameterBoundaryKey(invocation, index)), schemas.boundary(parameterBoundaryKey(invocation, index)),
@@ -342,6 +343,7 @@ export class FaceModelEmitter {
const lines = [ const lines = [
'/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */',
'import type {', 'import type {',
' RemoteResult,',
' TypeRTRemoteContribution,', ' TypeRTRemoteContribution,',
'} from \'@deepseek-ai/dsh-type-meta\'', '} from \'@deepseek-ai/dsh-type-meta\'',
] ]
@@ -462,10 +464,13 @@ export class FaceModelEmitter {
): string { ): string {
const parameters = invocation.parameters.filter(parameter => const parameters = invocation.parameters.filter(parameter =>
!scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter =>
`${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal')
const result = this.renderer.renderType(invocation.result.type, referenceNames) const result = this.renderer.renderType(invocation.result.type, referenceNames)
return `(${parameters.join(', ')}) => Promise<${result}>` // The Client Remote face delivers the carrier's outcome, so every generated
// consumer signature resolves to a result the caller reads instead of a
// value it must guard with its own try/catch.
return `(${parameters.join(', ')}) => Promise<RemoteResult<${result}>>`
} }
} }
@@ -23,6 +23,7 @@ interface RuntimeDescriptor {
readonly cancellation?: { readonly parameter: 'signal' } readonly cancellation?: { readonly parameter: 'signal' }
readonly parameters: readonly { readonly parameters: readonly {
readonly wire: string readonly wire: string
readonly acceptsUndefined?: true
readonly codec: { readonly schema: RuntimeSchema } readonly codec: { readonly schema: RuntimeSchema }
}[] }[]
readonly result: { readonly schema: RuntimeSchema } readonly result: { readonly schema: RuntimeSchema }
@@ -146,6 +147,57 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap) assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap)
}) })
it('projects authored optionality and absence onto consumers and codecs', async () => {
const root = copyFixture()
editFile(root, 'packages/remote/src/index.ts', source => source.replace(
'\n}\n\nexport type {',
`
@Remote
maybe(value: string | undefined): string | undefined {
return value
}
@Remote
labelled(id: string, label?: string): string {
return label ?? id
}
@Remote
clear(): void {}
}
export type {`,
))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
expect(artifact?.remote?.dts).toContain(
"'goals/maybe': (value: string | undefined) => Promise<string | undefined>",
)
expect(artifact?.remote?.dts).toContain("'goals/clear': () => Promise<void>")
// An explicit `T | undefined` stays a required argument; only authored
// optionality lets a consumer omit the field.
expect(artifact?.remote?.dts).not.toContain('value?: string')
expect(artifact?.remote?.dts).toContain("'goals/labelled': (id: string, label?: string) => Promise<string>")
const remoteJs = artifact?.remote?.js
if (remoteJs === undefined) throw new Error('undefined Remote fixture emitted no Host-for-Client JavaScript')
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
const maybe = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/maybe'))
const clear = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/clear'))
expect(maybe?.parameters[0]?.acceptsUndefined).toBe(true)
expect(maybe?.parameters[0]?.codec.schema.safeParse(undefined).success).toBe(true)
expect(maybe?.result.schema.safeParse(undefined).success).toBe(true)
expect(clear?.result.schema.safeParse(undefined).success).toBe(true)
expect(clear?.result.schema.safeParse(null).success).toBe(false)
const labelled = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/labelled'))
expect(labelled?.parameters[0]?.acceptsUndefined).toBeUndefined()
expect(labelled?.parameters[1]?.acceptsUndefined).toBe(true)
expect(labelled?.parameters[1]?.codec.schema.safeParse(undefined).success).toBe(true)
expect(labelled?.parameters[1]?.codec.schema.safeParse(7).success).toBe(false)
})
it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => {
const root = copyFixture() const root = copyFixture()
editFile(root, 'packages/remote/src/types.ts', source => `${source} editFile(root, 'packages/remote/src/types.ts', source => `${source}
@@ -436,9 +488,9 @@ export interface ClientMarker {
message: 'Remote parameters cannot have default values', message: 'Remote parameters cannot have default values',
}, },
{ {
name: 'optional parameter', name: 'optional lookup parameter',
edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), edit: (source: string) => source.replace('agent: Agent,', 'agent?: Agent,'),
message: 'Remote parameters cannot be optional', message: 'lookup parameter for agent cannot be optional',
}, },
{ {
name: 'wrong cancellation type', name: 'wrong cancellation type',
+2
View File
@@ -41,6 +41,8 @@ export type {
InvocationDescriptor, InvocationDescriptor,
InvocationParameterDescriptor, InvocationParameterDescriptor,
InvocationSourceLocation, InvocationSourceLocation,
RemoteFailure,
RemoteResult,
TypeRTClientRemote, TypeRTClientRemote,
TypeRTClientContextBinder, TypeRTClientContextBinder,
TypeRTCodec, TypeRTCodec,
+24
View File
@@ -39,6 +39,28 @@ export interface TypeRTContextMap {}
/** Merge-extensible direct Remote method signatures generated for consumers. */ /** Merge-extensible direct Remote method signatures generated for consumers. */
export interface TypeRTRemoteMap {} export interface TypeRTRemoteMap {}
/**
* One Remote call's failure as the carrier reported it. `code` stays open here:
* the closed RPC code union belongs to the carrier package, which already
* depends on this one, so naming it would invert that edge.
*/
export interface RemoteFailure {
readonly code: string
readonly message: string
readonly details: object
}
/**
* What every generated Remote method resolves to. The Remote face itself folds
* carrier failures into the error branch, so no consumer wraps a call to
* recover one; only assembly faults (arity, an unmounted method, a missing
* Context binder) still reject.
* @template T - the Host method's business result.
*/
export type RemoteResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: RemoteFailure }
/** Merge-extensible scoped Remote method signatures generated for consumers. */ /** Merge-extensible scoped Remote method signatures generated for consumers. */
export interface TypeRTRemoteScopeMap {} export interface TypeRTRemoteScopeMap {}
@@ -136,6 +158,8 @@ export interface InvocationParameterDescriptor {
readonly lookup?: string readonly lookup?: string
/** Boundary codec for the wire representation. */ /** Boundary codec for the wire representation. */
readonly codec: TypeRTCodec readonly codec: TypeRTCodec
/** Missing wire fields decode to `undefined` only for an explicitly declared `T | undefined`. */
readonly acceptsUndefined?: true
} }
/** Source position retained for diagnostics from generated definitions. */ /** Source position retained for diagnostics from generated definitions. */