refactor(api): expose traced remote namespaces

This commit is contained in:
imccyu
2026-08-07 18:28:30 +08:00
parent 146097368b
commit d6ffd87c5f
38 changed files with 566 additions and 492 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/README.md
README.md: 0dcded5922fea1ea6676315029ba0eadd74dd3df
README.zh.md: 1b9bb9133a955d0cbef0ca91728aab1545831d94
README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1
README.zh.md: 87bd15fc4e5ad23ef785f7c9ee805a4aa1a35e46

View File

@@ -6,10 +6,10 @@ The application-facing Remote stack. `remotes` owns BFF policy and the selected
| Package | Role | ctx key |
|---|---|---|
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.api` |
| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client API endpoint | `ctx.typertGateway` / `ctx.api` |
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.remote` |
| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientApi` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry.
The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientRemote` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry.
## Known Limitations and Deferred Work

View File

@@ -6,10 +6,10 @@
| 包 | 职责 | ctx key |
|---|---|---|
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.api` |
| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client API endpoint | `ctx.typertGateway` / `ctx.api` |
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` |
| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
运行时依赖方向为 `remotes → gateway → connection → webserver`BFF 消费共享的 `TypeRTClientApi` 契约Gateway 把传输交给 ConnectionConnection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
运行时依赖方向为 `remotes → gateway → connection → webserver`BFF 消费共享的 `TypeRTClientRemote` 契约Gateway 把传输交给 ConnectionConnection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
## 已知限制与延期工作

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/gateway/README.md
README.md: 9e3d4d89788bbc6edebfc0c0127999fed3ed9261
README.zh.md: 9bbd46c71185a2fbf8da163565d6c19141c079ca
README.md: e37359db71c1388667e9e61f538354711e90c0c1
README.zh.md: 2054febb9a5423297c32b029b40a035062250aab

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection.
Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection.
## Host service: `TypertGatewayService` (ctx key: `typertGateway`)
@@ -14,13 +14,13 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
## Client service: `ClientApi` (ctx key: `api`)
## Client service: `ClientRemote` (ctx key: `remote`)
`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.<namespace>` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
Generated declaration merges provide the TypeScript API through the shared `TypeRTClientApi` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.
Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway``@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes将传输、请求关联、信任和响应封装交给 Connection。
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway``@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes将传输、请求关联、信任和响应封装交给 Connection。
## Host 服务:`TypertGatewayService`ctx key`typertGateway`
@@ -14,13 +14,13 @@ Connection 可用时Host 入口会在 Connection 共享的 `/api` FetchHandle
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数Connection 将它提供给 GatewayGateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
## Client 服务:`ClientApi`ctx key`api`
## Client 服务:`ClientRemote`ctx key`remote`
`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
生成的声明合并通过共享的 `TypeRTClientApi` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
生成的声明合并通过共享的 `TypeRTClientRemote` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
## 模型体验

View File

@@ -1,37 +1,25 @@
/**
* Client projection of generated TypeRT Remote descriptors. Contributions
* install concrete namespace methods; no JavaScript Proxy participates in
* lookup, invocation, or type exposure.
* install traced `remote.<namespace>` services; no JavaScript Proxy
* participates in method lookup, invocation, or type exposure.
*/
import { Service, symbols } from 'cordis'
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
import type {
InvocationDescriptor,
TypeRTClientApi,
TypeRTClientRemote,
TypeRTCodec,
TypeRTDisposer,
TypeRTRemoteContribution,
} from '@deepseek-ai/dsh-type-meta'
type RemoteMethod = (...args: unknown[]) => Promise<unknown>
interface MountToken {
active: boolean
readonly abort: AbortController
}
interface DirectNamespaceRecord {
readonly value: Record<string, RemoteMethod>
readonly tokens: Map<string, MountToken>
}
interface ScopedNamespaceRecord {
readonly service: ScopedRemoteNamespace
readonly tokens: Map<string, MountToken>
}
interface ScopedProjection {
readonly context: string
readonly wire: string
@@ -39,13 +27,36 @@ interface ScopedProjection {
readonly parameterIndex?: number
}
/** Typed API service augmented by generated direct Remote namespaces. */
export type ClientApi = TypeRTClientApi
interface DirectMethod {
readonly descriptor: InvocationDescriptor
readonly token: MountToken
}
interface ScopedMethod extends DirectMethod {
readonly projection: ScopedProjection
}
interface RemoteMethodRecord {
direct?: DirectMethod
scoped?: ScopedMethod
}
interface BoundContextIdentity {
readonly value: unknown
}
interface RemoteNamespaceHandle {
readonly service: RemoteNamespaceService
readonly dispose: TypeRTDisposer
}
/** Typed Remote service augmented by generated direct namespaces. */
export type ClientRemote = TypeRTClientRemote
declare module 'cordis' {
interface Context {
/** Generated direct Remote namespaces selected by the Client assembly. */
api: ClientApi
/** Generated Remote namespaces selected by the Client assembly. */
remote: ClientRemote
}
}
@@ -53,48 +64,56 @@ declare module 'cordis' {
export const inject = ['typert', 'connection']
/**
* Install the typed Client API service.
* Install the typed Client Remote service.
* @param ctx - Client Cordis root.
*/
export function apply(ctx: Context): void {
new ClientApiService(ctx)
new ClientRemoteService(ctx)
}
class ClientApiService extends Service implements TypeRTClientApi {
class ClientRemoteService extends Service implements TypeRTClientRemote {
private readonly ownerCtx: Context
private readonly direct = new Map<string, DirectNamespaceRecord>()
private readonly scoped = new Map<string, ScopedNamespaceRecord>()
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
private mutations = Promise.resolve()
constructor(ctx: Context) {
super(ctx, 'api')
super(ctx, 'remote')
this.ownerCtx = ctx
}
mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientApi['mount']> {
this.validateContribution(contribution)
async $mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientRemote['$mount']> {
const callerCtx = this.ctx
const owned = callerCtx.effect(async () => {
const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution))
return () => this.enqueue(dispose)
}, `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`)
await owned
return async () => { await owned() }
}
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
const result = this.mutations.then(operation, operation)
this.mutations = result.then(() => undefined, () => undefined)
return result
}
private async mountContribution(
callerCtx: Context,
contribution: TypeRTRemoteContribution,
): Promise<TypeRTDisposer> {
this.validateContribution(contribution)
const disposeRemote = callerCtx.typert.remotes.register(contribution)
let disposeMethods: () => void | Promise<void>
const installed: TypeRTDisposer[] = []
try {
disposeMethods = callerCtx.effect(() => {
const installed: Array<() => void> = []
try {
for (const descriptor of contribution.descriptors) installed.push(this.install(descriptor))
} catch (error) {
for (const dispose of installed.reverse()) dispose()
throw error
}
return () => {
for (const dispose of installed.reverse()) dispose()
}
}, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`)
for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor))
} catch (error) {
/* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */
Promise.resolve(disposeRemote()).catch(() => {})
for (const dispose of installed.reverse()) await dispose()
await disposeRemote()
throw error
}
return async () => {
await Promise.all([disposeMethods(), disposeRemote()])
for (const dispose of installed.reverse()) await dispose()
await disposeRemote()
}
}
@@ -112,10 +131,8 @@ class ClientApiService extends Service implements TypeRTClientApi {
}
methods.add(descriptor.method)
table.set(descriptor.namespace, methods)
const live = kind === 'direct'
? this.direct.get(descriptor.namespace)?.tokens
: this.scoped.get(descriptor.namespace)?.tokens
if (live?.has(descriptor.method) === true) {
const namespace = this.namespaces.get(descriptor.namespace)?.service
if (namespace?.has(kind, descriptor.method) === true) {
throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`)
}
}
@@ -124,118 +141,151 @@ class ClientApiService extends Service implements TypeRTClientApi {
if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct')
if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped')
}
for (const namespace of direct.keys()) {
if (!this.direct.has(namespace) && namespace in this) {
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the API service`)
}
}
for (const [namespace, methods] of scoped) {
const record = this.scoped.get(namespace)
if (record !== undefined) {
for (const method of methods) record.service.assertMethodAvailable(method)
} else {
for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method)
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`)
const namespaces = new Set([...direct.keys(), ...scoped.keys()])
for (const namespace of namespaces) {
const service = this.namespaces.get(namespace)?.service
if (service === undefined) {
if (namespace in this) {
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`)
}
const serviceKey = remoteServiceKey(namespace)
const property = this.ownerCtx.reflect.props[serviceKey]
if (property?.type === 'accessor' || this.ownerCtx.get(serviceKey) !== undefined) {
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`)
}
}
for (const method of new Set([...(direct.get(namespace) ?? []), ...(scoped.get(namespace) ?? [])])) {
if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method)
else service.assertMethodAvailable(method)
}
}
}
private install(descriptor: InvocationDescriptor): () => void {
private async install(descriptor: InvocationDescriptor): Promise<TypeRTDisposer> {
const token: MountToken = { active: true, abort: new AbortController() }
const installed: (() => void)[] = []
const installed: TypeRTDisposer[] = []
try {
if (descriptor.invocation.kind === 'direct') {
installed.push(this.installDirect(descriptor, token))
installed.push(await this.installDirect(descriptor, token))
}
const projection = scopedProjection(descriptor)
if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token))
if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token))
} catch (error) {
token.active = false
for (const dispose of installed.reverse()) dispose()
token.abort.abort()
for (const dispose of installed.reverse()) await dispose()
throw error
}
return () => {
return async () => {
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
if (!token.active) return
token.active = false
for (const dispose of installed.reverse()) dispose()
token.abort.abort()
for (const dispose of installed.reverse()) await dispose()
}
}
private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void {
let namespace = this.direct.get(descriptor.namespace)
const fresh = namespace === undefined
if (namespace === undefined) {
namespace = { value: Object.create(null) as Record<string, RemoteMethod>, tokens: new Map() }
Object.defineProperty(this, descriptor.namespace, {
configurable: true,
enumerable: true,
value: namespace.value,
})
}
private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise<TypeRTDisposer> {
const namespace = await this.namespace(descriptor.namespace)
try {
Object.defineProperty(namespace.value, descriptor.method, {
configurable: true,
enumerable: true,
value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args),
})
namespace.service.installDirect(descriptor, token)
} catch (error) {
if (fresh) Reflect.deleteProperty(this, descriptor.namespace)
await this.disposeNamespace(descriptor.namespace, namespace)
throw error
}
if (fresh) this.direct.set(descriptor.namespace, namespace)
namespace.tokens.set(descriptor.method, token)
return () => {
/* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */
if (namespace.tokens.get(descriptor.method) !== token) return
Reflect.deleteProperty(namespace.value, descriptor.method)
namespace.tokens.delete(descriptor.method)
if (namespace.tokens.size !== 0) return
this.direct.delete(descriptor.namespace)
Reflect.deleteProperty(this, descriptor.namespace)
return async () => {
if (!namespace.service.remove('direct', descriptor.method, token)) return
await this.disposeNamespace(descriptor.namespace, namespace)
}
}
private installScoped(
private async installScoped(
descriptor: InvocationDescriptor,
projection: ScopedProjection,
token: MountToken,
): () => void {
let namespace = this.scoped.get(descriptor.namespace)
if (namespace === undefined) {
const service = new ScopedRemoteNamespace(
this.ownerCtx,
descriptor.namespace,
(current, currentProjection, currentToken, caller, args) =>
this.invoke(current, currentProjection, currentToken, caller, args),
)
service.install(descriptor, projection, token)
namespace = { service, tokens: new Map() }
this.scoped.set(descriptor.namespace, namespace)
} else {
namespace.service.install(descriptor, projection, token)
): Promise<TypeRTDisposer> {
const namespace = await this.namespace(descriptor.namespace)
try {
namespace.service.installScoped(descriptor, projection, token)
} catch (error) {
await this.disposeNamespace(descriptor.namespace, namespace)
throw error
}
namespace.tokens.set(descriptor.method, token)
return () => {
/* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */
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)
return async () => {
if (!namespace.service.remove('scoped', descriptor.method, token)) return
await this.disposeNamespace(descriptor.namespace, namespace)
}
}
private async namespace(name: string): Promise<RemoteNamespaceHandle> {
let namespace = this.namespaces.get(name)
if (namespace !== undefined) return namespace
let service: RemoteNamespaceService | undefined
const fiber = this.ownerCtx.plugin({
name: remoteServiceKey(name),
apply: (ctx: Context) => {
service = new RemoteNamespaceService(
ctx,
name,
(direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args),
)
},
})
try {
await fiber
} catch (error) {
await fiber.dispose()
throw error
}
/* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */
if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
namespace = { service, dispose: fiber.dispose }
this.namespaces.set(name, namespace)
return namespace
}
private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise<void> {
if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return
this.namespaces.delete(name)
await namespace.dispose()
}
private invokeMethod(
direct: DirectMethod | undefined,
scoped: ScopedMethod | undefined,
callerCtx: Context,
values: readonly unknown[],
): Promise<unknown> {
if (scoped !== undefined) {
const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
const identity = binder?.identity(callerCtx)
if (identity !== undefined) {
return this.invoke(
scoped.descriptor,
scoped.projection,
scoped.token,
callerCtx,
values,
{ value: identity },
)
}
}
if (direct !== undefined) {
return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values)
}
if (scoped !== undefined) {
return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values)
}
throw new Error('client api: Remote method is no longer mounted')
}
private async invoke(
descriptor: InvocationDescriptor,
projection: ScopedProjection | undefined,
token: MountToken,
callerCtx: Context,
values: readonly unknown[],
boundIdentity?: BoundContextIdentity,
): Promise<unknown> {
const endpoint = endpointOf(descriptor)
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
@@ -251,11 +301,15 @@ class ClientApiService extends Service implements TypeRTClientApi {
}
const args = Object.create(null) as Record<string, unknown>
if (projection !== undefined) {
const binder = this.ownerCtx.typert.contexts.getClient(projection.context)
if (binder === undefined) {
const binder = boundIdentity === undefined
? this.ownerCtx.typert.contexts.getClient(projection.context)
: undefined
if (boundIdentity === undefined && binder === undefined) {
throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`)
}
const identity = binder.identity(callerCtx)
const identity = boundIdentity === undefined
? binder?.identity(callerCtx)
: boundIdentity.value
if (identity === undefined) {
throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
}
@@ -281,23 +335,19 @@ class ClientApiService extends Service implements TypeRTClientApi {
}
type InvokeRemote = (
descriptor: InvocationDescriptor,
projection: ScopedProjection,
token: MountToken,
direct: DirectMethod | undefined,
scoped: ScopedMethod | undefined,
callerCtx: Context,
args: readonly unknown[],
) => Promise<unknown>
class ScopedRemoteNamespace {
private readonly ctx: Context
private readonly ownerCtx: Context
private readonly methods = new Set<string>()
private disposeService: TypeRTDisposer | undefined
readonly name: string
class RemoteNamespaceService extends Service {
private readonly methods = new Map<string, RemoteMethodRecord>()
private readonly namespace: string
static assertMethodAvailable(namespace: string, method: string): void {
if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) {
throw new Error(`client api: scoped method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`)
if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) {
throw new Error(`client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`)
}
}
@@ -306,54 +356,92 @@ class ScopedRemoteNamespace {
name: string,
private readonly invokeRemote: InvokeRemote,
) {
this.ctx = ctx
this.ownerCtx = ctx
this.name = name
Object.defineProperty(this, symbols.tracker, {
value: { associate: name, property: 'ctx' },
})
super(ctx, remoteServiceKey(name))
this.namespace = name
}
assertMethodAvailable(method: string): void {
ScopedRemoteNamespace.assertMethodAvailable(this.name, method)
if (method in this) {
throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`)
RemoteNamespaceService.assertMethodAvailable(this.namespace, method)
if (method in this && !this.methods.has(method)) {
throw new Error(`client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`)
}
}
install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
this.assertMethodAvailable(descriptor.method)
const activate = this.methods.size === 0
const method = descriptor.method
get empty(): boolean {
return this.methods.size === 0
}
has(kind: 'direct' | 'scoped', method: string): boolean {
return this.methods.get(method)?.[kind] !== undefined
}
installDirect(descriptor: InvocationDescriptor, token: MountToken): void {
this.install(descriptor.method, 'direct', { descriptor, token })
}
installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
this.install(descriptor.method, 'scoped', { descriptor, projection, token })
}
private install(method: string, kind: 'direct', value: DirectMethod): void
private install(method: string, kind: 'scoped', value: ScopedMethod): void
private install(method: string, kind: 'direct' | 'scoped', value: DirectMethod | ScopedMethod): void {
this.assertMethodAvailable(method)
let record = this.methods.get(method)
const fresh = record === undefined
record ??= {}
if (record[kind] !== undefined) {
throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`)
}
try {
Object.defineProperty(this, method, {
configurable: true,
enumerable: true,
value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise<unknown> {
return this.invokeRemote(descriptor, projection, token, this.ctx, args)
},
})
if (activate) {
this.disposeService = this.ownerCtx.reflect.provide(this.name, this)
if (fresh) {
Object.defineProperty(this, method, {
configurable: true,
enumerable: true,
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
const callerCtx = this.ctx
const current = this.methods.get(method)
const direct = current?.direct
const scoped = current?.scoped
return (...args: unknown[]) => {
return this.invokeRemote(direct, scoped, callerCtx, args)
}
},
})
this.methods.set(method, record)
}
if (kind === 'direct') record.direct = value
else record.scoped = value as ScopedMethod
} catch (error) {
Reflect.deleteProperty(this, method)
if (kind === 'direct') delete record.direct
else delete record.scoped
if (fresh) {
this.methods.delete(method)
Reflect.deleteProperty(this, method)
}
throw error
}
this.methods.add(method)
}
remove(method: string): void {
Reflect.deleteProperty(this, method)
remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean {
const record = this.methods.get(method)
const current = record?.[kind]
/* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */
if (record === undefined || current?.token !== token) return false
if (kind === 'direct') delete record.direct
else delete record.scoped
if (record.direct !== undefined || record.scoped !== undefined) return true
this.methods.delete(method)
if (this.methods.size !== 0) return
const disposeService = this.disposeService
this.disposeService = undefined
void disposeService?.()
Reflect.deleteProperty(this, method)
return true
}
}
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx'])
const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace'])
function remoteServiceKey(namespace: string): string {
return `remote.${namespace}`
}
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
return `${descriptor.namespace}/${descriptor.method}`

View File

@@ -1,4 +1,4 @@
import { Context } from 'cordis'
import { Context, Service } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
@@ -38,7 +38,7 @@ declare module '@deepseek-ai/dsh-type-meta' {
}
type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'>
type FixtureContext = Omit<Context, 'remote'> & { readonly remote: TypeRTRemoteContextApi<'fixture'> }
const idSchema = z.string().min(1)
const requestSchema = z.object({ objective: z.string().min(1) })
@@ -105,17 +105,16 @@ describe('Client TypeRT API', () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
const ctx = await bench(call)
let retained: typeof ctx.api.goals.create | undefined
const businessGoals = { owner: 'host business service' }
const disposeBusinessGoals = ctx.provide('goals', businessGoals)
const assembly = ctx.plugin(Object.assign(
(scope: Context) => {
scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
retained = scope.api.goals.create
},
{ inject: ['api'] },
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
{ inject: ['remote'] },
))
await assembly
const retained = ctx.remote.goals.create
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
expect(call).toHaveBeenCalledWith(
'/api',
'goals/create',
@@ -123,7 +122,7 @@ describe('Client TypeRT API', () => {
expect.any(AbortSignal),
)
const callerAbort = new AbortController()
await expect(ctx.api.goals.create(
await expect(ctx.remote.goals.create(
'agent-1',
{ objective: 'cancel me' },
callerAbort.signal,
@@ -135,16 +134,18 @@ describe('Client TypeRT API', () => {
callerAbort.abort(cancellation)
expect(combinedSignal?.aborted).toBe(true)
expect(combinedSignal?.reason).toBe(cancellation)
await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
await assembly.dispose()
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
expect(ctx.get('goals')).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
expect(ctx.get('goals')).toBe(businessGoals)
expect(ctx.typert.remotes.list()).toEqual([])
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
disposeBusinessGoals()
})
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
@@ -156,26 +157,24 @@ describe('Client TypeRT API', () => {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
})
const assembly = ctx.plugin(Object.assign(
(scope: Context) => {
scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
},
{ inject: ['api'] },
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
{ inject: ['remote'] },
))
await assembly
await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
expect(call).toHaveBeenCalledWith(
'/api',
'goals/create',
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
expect.any(AbortSignal),
)
await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' }))
.rejects.toThrow('requires a "fixture" Context')
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' }))
.rejects.toThrow('expected 2 business argument(s)')
await assembly.dispose()
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
expect(ctx.get('goals')).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
})
it('uses the caller Context identity for scoped namespace methods', async () => {
@@ -187,25 +186,23 @@ describe('Client TypeRT API', () => {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
})
const assembly = ctx.plugin(Object.assign(
(scope: Context) => {
scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] })
},
{ inject: ['api'] },
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }),
{ inject: ['remote'] },
))
await assembly
await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
expect(call).toHaveBeenCalledWith(
'/api',
'goals/rename',
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
expect.any(AbortSignal),
)
await expect((ctx as FixtureContext).goals.rename({ objective: 'land' }))
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' }))
.rejects.toThrow('requires a "fixture" Context')
await assembly.dispose()
expect(ctx.get('goals')).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
})
it('rejects weak descriptors and namespace collisions before registration', async () => {
@@ -215,12 +212,12 @@ describe('Client TypeRT API', () => {
result: { mode: 'src-json' },
}
expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] }))
.toThrow('has no strict codec')
expect(() => ctx.api.mount({
await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] }))
.rejects.toThrow('has no strict codec')
await expect(ctx.remote.$mount({
package: '@fixture/conflict',
descriptors: [{ ...directDescriptor(), namespace: 'mount' }],
})).toThrow('conflicts with the API service')
descriptors: [{ ...directDescriptor(), namespace: '$mount' }],
})).rejects.toThrow('conflicts with the Remote service')
expect(ctx.typert.remotes.list()).toEqual([])
})
@@ -235,48 +232,50 @@ describe('Client TypeRT API', () => {
const direct = directDescriptor()
const context = contextDescriptor()
expect(() => ctx.api.mount({
await expect(ctx.remote.$mount({
package: '@fixture/direct-duplicates',
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
})).toThrow('repeats direct method')
expect(() => ctx.api.mount({
})).rejects.toThrow('repeats direct method')
await expect(ctx.remote.$mount({
package: '@fixture/scoped-duplicates',
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
})).toThrow('repeats scoped method')
})).rejects.toThrow('repeats scoped method')
const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] })
expect(() => ctx.api.mount({
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] })
await expect(ctx.remote.$mount({
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
})).toThrow('direct method goals/create is already mounted')
})).rejects.toThrow('direct method goals/create is already mounted')
await disposeDirect()
const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] })
expect(() => ctx.api.mount({
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] })
await expect(ctx.remote.$mount({
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
})).toThrow('scoped method goals/rename is already mounted')
expect(() => ctx.api.mount({
})).rejects.toThrow('scoped method goals/rename is already mounted')
await expect(ctx.remote.$mount({
package: '@fixture/service-method-conflict',
descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
})).toThrow('conflicts with its namespace service')
const scopedService = ctx.get('goals') as unknown as object
})).rejects.toThrow('conflicts with its namespace service')
const scopedService = ctx.get('remote.goals') as unknown as object
Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
expect(() => ctx.api.mount({
await expect(ctx.remote.$mount({
package: '@fixture/service-own-property-conflict',
descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
})).toThrow('conflicts with its namespace service')
})).rejects.toThrow('conflicts with its namespace service')
Reflect.deleteProperty(scopedService, 'custom')
await disposeScoped()
expect(() => ctx.api.mount({
const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' })
await expect(ctx.remote.$mount({
package: '@fixture/context-property-conflict',
descriptors: [{ ...context, namespace: 'typert' }],
})).toThrow('conflicts with an existing Context property')
})).rejects.toThrow('conflicts with an existing Remote namespace')
await disposeRemoteTypert()
const disposeMultipleScoped = ctx.api.mount({
const disposeMultipleScoped = await ctx.remote.$mount({
package: '@fixture/multiple-scoped',
descriptors: [directDescriptor(), contextDescriptor()],
})
await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
expect(call).toHaveBeenLastCalledWith(
'/api',
'goals/rename',
@@ -286,41 +285,6 @@ describe('Client TypeRT API', () => {
await disposeMultipleScoped()
})
it('rolls back direct projection when scoped installation fails', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const disposeScoped = ctx.api.mount({
package: '@fixture/scoped-base',
descriptors: [contextDescriptor()],
})
const defineProperty = Object.defineProperty
let createDefinitions = 0
const definePropertySpy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
// The direct projection defines `create` first; fail the following scoped projection.
if (key === 'create' && ++createDefinitions === 2) throw new Error('simulated scoped installation failure')
return defineProperty(target, key, attributes)
})
try {
expect(() => ctx.api.mount({
package: '@fixture/failing-install',
descriptors: [directDescriptor()],
})).toThrow('simulated scoped installation failure')
} finally {
definePropertySpy.mockRestore()
}
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
expect(ctx.get('goals') !== undefined).toBe(true)
expect(ctx.typert.remotes.list()).toHaveLength(1)
const disposeRetry = ctx.api.mount({
package: '@fixture/retry',
descriptors: [directDescriptor()],
})
await disposeRetry()
await disposeScoped()
})
it('rolls back earlier descriptors when a later descriptor fails to install', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const { scope: _scope, ...first } = directDescriptor()
@@ -335,17 +299,17 @@ describe('Client TypeRT API', () => {
return defineProperty(target, key, attributes)
})
try {
expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
.toThrow('fixture later-descriptor failure')
await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
.rejects.toThrow('fixture later-descriptor failure')
} finally {
spy.mockRestore()
}
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
expect(ctx.api.goals.create).toBeTypeOf('function')
expect((ctx.api.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
expect(ctx.remote.goals.create).toBeTypeOf('function')
expect((ctx.remote.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
await retry()
})
@@ -353,7 +317,7 @@ describe('Client TypeRT API', () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const direct = directDescriptor()
const context = contextDescriptor()
expect(() => ctx.api.mount({
await expect(ctx.remote.$mount({
package: '@fixture/weak-parameter',
descriptors: [{
...direct,
@@ -361,19 +325,19 @@ describe('Client TypeRT API', () => {
? { ...parameter, codec: { mode: 'src-json' } }
: parameter),
}],
})).toThrow('has no strict codec')
expect(() => ctx.api.mount({
})).rejects.toThrow('has no strict codec')
await expect(ctx.remote.$mount({
package: '@fixture/weak-context',
descriptors: [{
...context,
invocation: { ...context.invocation, codec: { mode: 'src-json' } },
} as InvocationDescriptor],
})).toThrow('has no strict codec')
expect(() => ctx.api.mount({
})).rejects.toThrow('has no strict codec')
await expect(ctx.remote.$mount({
package: '@fixture/malformed-scope',
descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }],
})).toThrow('scope must select its only lookup parameter')
expect(() => ctx.api.mount({
})).rejects.toThrow('scope must select its only lookup parameter')
await expect(ctx.remote.$mount({
package: '@fixture/ambiguous-scope',
descriptors: [{
...direct,
@@ -382,7 +346,7 @@ describe('Client TypeRT API', () => {
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
}],
}],
})).toThrow('scope must select its only lookup parameter')
})).rejects.toThrow('scope must select its only lookup parameter')
})
it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
@@ -390,27 +354,29 @@ describe('Client TypeRT API', () => {
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
const ctx = await bench(call)
const descriptor = directDescriptor()
const dispose = ctx.api.mount({
const dispose = await ctx.remote.$mount({
package: '@fixture/goals',
descriptors: [descriptor, contextDescriptor()],
})
const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
const goals = (ctx as FixtureContext).goals
const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
const goals = (ctx as FixtureContext).remote.goals
const rename = goals.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', { objective: 'ship' }, undefined, 'extra'))
.rejects.toThrow('got 4')
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
await expect((ctx as FixtureContext).goals.create({ objective: 'ship' }))
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' }))
.rejects.toThrow('expected 2 business argument(s)')
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' }))
.rejects.toThrow('no Client Context binder')
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
ctx.set('connection', undefined)
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
await dispose()
})
@@ -427,14 +393,14 @@ describe('Client TypeRT API', () => {
id: '@fixture/goals#goals/archive',
method: 'archive',
}
const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] })
const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' })
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] })
const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' })
await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
await dispose()
resolveCall({ ok: true, value: { ref: 'goal-1' } })
await expect(invocation).rejects.toThrow('withdrawn during invocation')
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
})
it('preserves a __proto__ wire parameter as an own named argument', async () => {
@@ -453,9 +419,9 @@ describe('Client TypeRT API', () => {
codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() },
}],
}
const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] })
const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
const method = (ctx.api.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
const method = (ctx.remote.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
expect(Object.getPrototypeOf(payload.args)).toBeNull()
@@ -464,23 +430,23 @@ describe('Client TypeRT API', () => {
await dispose()
})
it('rolls back Remote registration when concrete method installation fails', async () => {
it('rolls back Remote registration when namespace Service startup fails', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const defineProperty = Object.defineProperty
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
if (key === 'goals') throw new Error('fixture installation failure')
if (key === Service.tracker) throw new Error('fixture namespace startup failure')
return defineProperty(target, key, attributes)
})
try {
expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
.toThrow('fixture installation failure')
await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
.rejects.toThrow('fixture namespace startup failure')
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
} finally {
spy.mockRestore()
}
const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
expect(ctx.api.goals.create).toBeTypeOf('function')
const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
expect(ctx.remote.goals.create).toBeTypeOf('function')
await retry()
})
@@ -492,16 +458,21 @@ describe('Client TypeRT API', () => {
return defineProperty(target, key, attributes)
})
try {
expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] }))
.toThrow('fixture direct method installation failure')
await expect(ctx.remote.$mount({
package: '@fixture/direct-method-failure',
descriptors: [directDescriptor()],
})).rejects.toThrow('fixture direct method installation failure')
} finally {
spy.mockRestore()
}
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = ctx.api.mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()] })
expect(ctx.api.goals.create).toBeTypeOf('function')
const retry = await ctx.remote.$mount({
package: '@fixture/direct-method-retry',
descriptors: [directDescriptor()],
})
expect(ctx.remote.goals.create).toBeTypeOf('function')
await retry()
})
@@ -513,41 +484,41 @@ describe('Client TypeRT API', () => {
return defineProperty(target, key, attributes)
})
try {
expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
.toThrow('fixture scoped installation failure')
await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
.rejects.toThrow('fixture scoped installation failure')
} finally {
spy.mockRestore()
}
expect(ctx.get('goals')).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
expect((ctx.get('goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
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')
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()
const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
expect(ctx.get('remote.goals')).toBeDefined()
await dispose()
expect(ctx.get('goals')).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
const replacement = { owner: 'replacement' }
const disposeReplacement = ctx.reflect.provide('goals', replacement)
expect(ctx.get('goals')).toBe(replacement)
const disposeReplacement = ctx.reflect.provide('remote.goals', replacement)
expect(ctx.get('remote.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 }))
ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
let failure: unknown
try {
await ctx.api.goals.create('agent-1', { objective: 'ship' })
await ctx.remote.goals.create('agent-1', { objective: 'ship' })
} catch (error) {
failure = error
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/remotes/README.md
README.md: cf54a56a849246d4efdca09cadd42e157064bdee
README.zh.md: 5cd7ef21c926440ca4df6d88ee4adfe87defcc3f
README.md: 7f6a2114d900413d972584c0f1c141b7f835ba36
README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientApi` interface through Cordis and does not import the concrete Gateway.
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway.
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.api` contract.
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.
## Model Experience

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence并为 TypeRT `agent``session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、具体的根级方法和作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 接口,不导入具体 Gateway。
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用其 Client face。
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。
## 模型体验

View File

@@ -2,25 +2,25 @@
import type { Context } from 'cordis'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import type { TypeRTClientApi } from '@deepseek-ai/dsh-type-meta'
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientApi as ClientApi } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
export type {} from '@deepseek-ai/dsh-goal/remote'
declare module 'cordis' {
interface Context {
/** Generated direct Remote namespaces selected by this Client assembly. */
api: TypeRTClientApi
/** Generated Remote namespaces selected by this Client assembly. */
remote: TypeRTClientRemote
}
}
/** Required service: the typed Client API contribution mount. */
export const inject = ['api']
/** Required service: the typed Client Remote contribution mount. */
export const inject = ['remote']
/**
* Mount the Host capabilities explicitly selected for this Client assembly.
* @param ctx - Client Cordis root carrying the typed API service.
*/
export function apply(ctx: Context): void {
ctx.api.mount(goalsRemote)
export function apply(ctx: Context): Promise<() => Promise<void>> {
return ctx.remote.$mount(goalsRemote)
}

View File

@@ -143,18 +143,18 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
let invalidRejected = false
try {
await client.api.goals.create(rootAgent.id, { objective: 1 })
await client.remote.goals.create(rootAgent.id, { objective: 1 })
} catch {
invalidRejected = true
}
const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' })
const rootEdit = await client.api.goals.edit(
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
const rootEdit = await client.remote.goals.edit(
rootAgent.id,
rootResult.ref,
{ objective: 'edited root goal' },
)
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
const result = {
invalidRejected,
rootResult,