mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(api-gateway): harden remote lifecycle and recovery
This commit is contained in:
@@ -134,7 +134,8 @@ class ClientApiService extends Service implements TypeRTClientApi {
|
||||
for (const method of methods) record.service.assertMethodAvailable(method)
|
||||
} else {
|
||||
for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method)
|
||||
if (this.ownerCtx.reflect.props[namespace] !== undefined) {
|
||||
const property = this.ownerCtx.reflect.props[namespace]
|
||||
if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) {
|
||||
throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`)
|
||||
}
|
||||
}
|
||||
@@ -224,6 +225,7 @@ class ClientApiService extends Service implements TypeRTClientApi {
|
||||
if (namespace.tokens.get(descriptor.method) !== token) return
|
||||
namespace.service.remove(descriptor.method)
|
||||
namespace.tokens.delete(descriptor.method)
|
||||
if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +291,7 @@ class ScopedRemoteNamespace {
|
||||
private readonly ctx: Context
|
||||
private readonly ownerCtx: Context
|
||||
private readonly methods = new Set<string>()
|
||||
private provided = false
|
||||
private disposeService: (() => void) | undefined
|
||||
readonly name: string
|
||||
|
||||
static assertMethodAvailable(namespace: string, method: string): void {
|
||||
@@ -331,12 +333,7 @@ class ScopedRemoteNamespace {
|
||||
},
|
||||
})
|
||||
if (activate) {
|
||||
if (this.provided) {
|
||||
this.ownerCtx.set(this.name, this)
|
||||
} else {
|
||||
this.ownerCtx.reflect.provide(this.name, this)
|
||||
this.provided = true
|
||||
}
|
||||
this.disposeService = this.ownerCtx.reflect.provide(this.name, this)
|
||||
}
|
||||
} catch (error) {
|
||||
Reflect.deleteProperty(this, method)
|
||||
@@ -348,11 +345,14 @@ class ScopedRemoteNamespace {
|
||||
remove(method: string): void {
|
||||
Reflect.deleteProperty(this, method)
|
||||
this.methods.delete(method)
|
||||
if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined)
|
||||
if (this.methods.size !== 0) return
|
||||
const disposeService = this.disposeService
|
||||
this.disposeService = undefined
|
||||
disposeService?.()
|
||||
}
|
||||
}
|
||||
|
||||
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided'])
|
||||
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx'])
|
||||
|
||||
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||
return `${descriptor.namespace}/${descriptor.method}`
|
||||
|
||||
@@ -134,7 +134,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
const endpoint = endpointOf(request.namespace, request.method)
|
||||
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
|
||||
assertExactArguments(request.args, descriptor, endpoint)
|
||||
const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint)
|
||||
const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
|
||||
const receiver = receiverContext.get(descriptor.service) as unknown
|
||||
if (!isObject(receiver)) {
|
||||
throw new TypertGatewayError(
|
||||
@@ -331,11 +331,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveReceiverContext(
|
||||
private async resolveReceiverContext(
|
||||
descriptor: InvocationDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Context {
|
||||
): Promise<Context> {
|
||||
if (descriptor.invocation.kind === 'direct') return this.ctx
|
||||
const invocation = descriptor.invocation
|
||||
const provider = this.ctx.typert.contexts.getHost(invocation.context)
|
||||
@@ -358,8 +358,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
|
||||
let context: Context | undefined
|
||||
try {
|
||||
context = provider.resolve(identity)
|
||||
context = await provider.resolve(identity)
|
||||
} catch (cause) {
|
||||
if (cause instanceof TypeRTLookupFailure) throw cause
|
||||
throw new TypertGatewayError(
|
||||
'context-failed',
|
||||
endpoint,
|
||||
|
||||
@@ -526,6 +526,20 @@ describe('Client TypeRT API', () => {
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
|
||||
expect(ctx.get('goals')).toBeDefined()
|
||||
|
||||
await dispose()
|
||||
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
const replacement = { owner: 'replacement' }
|
||||
const disposeReplacement = ctx.reflect.provide('goals', replacement)
|
||||
expect(ctx.get('goals')).toBe(replacement)
|
||||
await disposeReplacement()
|
||||
})
|
||||
|
||||
it('throws RPC failures with the structured error as its cause', async () => {
|
||||
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
|
||||
@@ -538,6 +538,22 @@ describe('TypertGatewayService', () => {
|
||||
expect(error.cause).toEqual(new Error('provider failed'))
|
||||
})
|
||||
|
||||
it('preserves a Host Context policy rejection for the active RPC adapter', async () => {
|
||||
const { ctx } = await setup()
|
||||
const rejection = new TypeRTLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } })
|
||||
ctx.typert.contexts.registerHost('gatewayFixture', {
|
||||
...contextProvider(ctx.extend()),
|
||||
resolve: async () => { throw rejection },
|
||||
})
|
||||
registerStrict(ctx, [renameDescriptor()])
|
||||
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
})).rejects.toBe(rejection)
|
||||
})
|
||||
|
||||
it('reports Context provider metadata mismatch and unresolved identities', async () => {
|
||||
const { ctx } = await setup()
|
||||
registerStrict(ctx, [renameDescriptor()])
|
||||
|
||||
Reference in New Issue
Block a user