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:
@@ -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: {} } }
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export type {
|
|||||||
InvocationDescriptor,
|
InvocationDescriptor,
|
||||||
InvocationParameterDescriptor,
|
InvocationParameterDescriptor,
|
||||||
InvocationSourceLocation,
|
InvocationSourceLocation,
|
||||||
|
RemoteFailure,
|
||||||
|
RemoteResult,
|
||||||
TypeRTClientRemote,
|
TypeRTClientRemote,
|
||||||
TypeRTClientContextBinder,
|
TypeRTClientContextBinder,
|
||||||
TypeRTCodec,
|
TypeRTCodec,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user