fix(api-gateway): harden remote lifecycle and recovery

This commit is contained in:
imccyu
2026-08-07 16:53:04 +08:00
parent e89d078819
commit 686ee5b3f6
21 changed files with 218 additions and 34 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
2026-08-02-typert-remote-method-calls.md: c4f3a5b94bf25b4581b9430cfcb4f02f707e0749 2026-08-02-typert-remote-method-calls.md: 3d5a79fd4a26f7d232dcc7635625899e2eb9df6b
2026-08-02-typert-remote-method-calls.zh.md: e11d8ebe42d44cc9805e942a31f13f7ae847815a 2026-08-02-typert-remote-method-calls.zh.md: 3d6ec680ba97a532f18219670e8dba799a94ed7b

View File

@@ -164,7 +164,7 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client
The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service.
Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle.
The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program.

View File

@@ -164,7 +164,7 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder
lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。
业务对象包通过 `register()` 拥有稳定声明和默认 resolverHost 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent``session` 创建共享的 `agentFor()` resolverlive Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` resolver 返回解析所得 Agent 的 Session因而两种参数不会产生两套恢复生命周期。 业务对象包和 scoped Context 包通过 `lookups.register()``contexts.registerHost()` 拥有稳定声明和默认 resolverHost 组合通过 `lookups.configure()` `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent``session` lookup 和 `agent` Host Context 创建共享的 `agentFor()` resolverlive Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` lookup 返回解析所得 Agent 的 Session`agent` Host Context 返回其 Context因此三种投影共用一个恢复生命周期。
Registry 的 Host 根入口拥有完整 `TypeRTService` interface mergeHost 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface mergeHost 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。

View File

@@ -134,7 +134,8 @@ class ClientApiService extends Service implements TypeRTClientApi {
for (const method of methods) record.service.assertMethodAvailable(method) for (const method of methods) record.service.assertMethodAvailable(method)
} else { } else {
for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) 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`) 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 if (namespace.tokens.get(descriptor.method) !== token) return
namespace.service.remove(descriptor.method) namespace.service.remove(descriptor.method)
namespace.tokens.delete(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 ctx: Context
private readonly ownerCtx: Context private readonly ownerCtx: Context
private readonly methods = new Set<string>() private readonly methods = new Set<string>()
private provided = false private disposeService: (() => void) | undefined
readonly name: string readonly name: string
static assertMethodAvailable(namespace: string, method: string): void { static assertMethodAvailable(namespace: string, method: string): void {
@@ -331,12 +333,7 @@ class ScopedRemoteNamespace {
}, },
}) })
if (activate) { if (activate) {
if (this.provided) { this.disposeService = this.ownerCtx.reflect.provide(this.name, this)
this.ownerCtx.set(this.name, this)
} else {
this.ownerCtx.reflect.provide(this.name, this)
this.provided = true
}
} }
} catch (error) { } catch (error) {
Reflect.deleteProperty(this, method) Reflect.deleteProperty(this, method)
@@ -348,11 +345,14 @@ class ScopedRemoteNamespace {
remove(method: string): void { remove(method: string): void {
Reflect.deleteProperty(this, method) Reflect.deleteProperty(this, method)
this.methods.delete(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 { function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
return `${descriptor.namespace}/${descriptor.method}` return `${descriptor.namespace}/${descriptor.method}`

View File

@@ -134,7 +134,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
const endpoint = endpointOf(request.namespace, request.method) const endpoint = endpointOf(request.namespace, request.method)
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
assertExactArguments(request.args, descriptor, 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 const receiver = receiverContext.get(descriptor.service) as unknown
if (!isObject(receiver)) { if (!isObject(receiver)) {
throw new TypertGatewayError( throw new TypertGatewayError(
@@ -331,11 +331,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
} }
} }
private resolveReceiverContext( private async resolveReceiverContext(
descriptor: InvocationDescriptor, descriptor: InvocationDescriptor,
args: Readonly<Record<string, unknown>>, args: Readonly<Record<string, unknown>>,
endpoint: string, endpoint: string,
): Context { ): Promise<Context> {
if (descriptor.invocation.kind === 'direct') return this.ctx if (descriptor.invocation.kind === 'direct') return this.ctx
const invocation = descriptor.invocation const invocation = descriptor.invocation
const provider = this.ctx.typert.contexts.getHost(invocation.context) 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) const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
let context: Context | undefined let context: Context | undefined
try { try {
context = provider.resolve(identity) context = await provider.resolve(identity)
} catch (cause) { } catch (cause) {
if (cause instanceof TypeRTLookupFailure) throw cause
throw new TypertGatewayError( throw new TypertGatewayError(
'context-failed', 'context-failed',
endpoint, endpoint,

View File

@@ -526,6 +526,20 @@ describe('Client TypeRT API', () => {
await retry() 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 () => { 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 }))

View File

@@ -538,6 +538,22 @@ describe('TypertGatewayService', () => {
expect(error.cause).toEqual(new Error('provider failed')) 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 () => { it('reports Context provider metadata mismatch and unresolved identities', async () => {
const { ctx } = await setup() const { ctx } = await setup()
registerStrict(ctx, [renameDescriptor()]) registerStrict(ctx, [renameDescriptor()])

View File

@@ -187,6 +187,7 @@ export function createApiRemoteAgentResolver(
} }
typeCtx.typert.lookups.configure('agent', resolveAgent) typeCtx.typert.lookups.configure('agent', resolveAgent)
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx)
}) })
return agentFor return agentFor

View File

@@ -5,6 +5,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session' import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes' import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes'
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
const sid = (value: string): SessionId => value as SessionId const sid = (value: string): SessionId => value as SessionId
@@ -14,6 +16,7 @@ function header(id: SessionId): SessionHeader {
async function createContext(): Promise<Context> { async function createContext(): Promise<Context> {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
return ctx return ctx
@@ -107,4 +110,45 @@ describe('API Remote Agent resolver races', () => {
await ctx.fiber.dispose() await ctx.fiber.dispose()
} }
}) })
it('uses the shared cold-resume policy for the Agent Host Context', async () => {
const ctx = await createContext()
const sessionId = sid('context-cold-resume')
const meta = header(sessionId)
let published: Session | undefined
provideSession(ctx, meta, () => {
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
return Promise.resolve({ meta, events: [] })
})
const agentCtx = ctx.extend()
vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
if (published === undefined) throw new Error('Session was not published')
return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() }
})
const defaultProvider = ctx.typert.contexts.getHost('agent')
createApiRemoteAgentResolver(ctx, {})
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
const provider = ctx.typert.contexts.getHost('agent')
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx)
await ctx.fiber.dispose()
})
it('applies the subagent ownership fence to the Agent Host Context', async () => {
const ctx = await createContext()
const sessionId = sid('context-owned-subagent')
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
ctx.agents.register(stubAgent(ctx.extend(), session))
const defaultProvider = ctx.typert.contexts.getHost('agent')
createApiRemoteAgentResolver(ctx, {})
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
const provider = ctx.typert.contexts.getHost('agent')
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
const resolution = provider.resolve(sessionId)
await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure)
await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
await ctx.fiber.dispose()
})
}) })

View File

@@ -38,10 +38,10 @@ const NS = 'goal'
/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */
export const inject = ['slots', 'sessions', 'api', 'locale'] export const inject = ['slots', 'sessions', 'api', 'locale']
/** Map one generated Remote call onto the strip's inline-render shape. */ /** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */
async function settle(result: Promise<unknown>): Promise<GoalActionResult> { async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> {
try { try {
await result await invoke()
return { ok: true } return { ok: true }
} catch (error) { } catch (error) {
const cause = error instanceof Error ? error.cause : undefined const cause = error instanceof Error ? error.cause : undefined
@@ -94,22 +94,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.api.goals.edit(sessionId, ref, { objective })) return settle(() => ctx.api.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.api.goals.pause(sessionId, ref)) return settle(() => ctx.api.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.api.goals.resume(sessionId, ref)) return settle(() => ctx.api.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.api.goals.clear(sessionId, ref)) return settle(() => ctx.api.goals.clear(sessionId, ref))
}, },
}), }),
}, GoalDock)) }, GoalDock))

View File

@@ -70,7 +70,7 @@ async function bench(options: {
resume: answer(`${prefix}/resume`, { ref }), resume: answer(`${prefix}/resume`, { ref }),
clear: answer(`${prefix}/clear`, ref), clear: answer(`${prefix}/clear`, ref),
}) })
let activeGoals = goals('goals') let activeGoals: ReturnType<typeof goals> | undefined = goals('goals')
ctx.provide('api', { ctx.provide('api', {
get goals() { return activeGoals }, get goals() { return activeGoals },
}) })
@@ -95,6 +95,7 @@ async function bench(options: {
fiber, fiber,
calls, calls,
remountGoals: () => { activeGoals = goals('remounted-goals') }, remountGoals: () => { activeGoals = goals('remounted-goals') },
unmountGoals: () => { activeGoals = undefined },
entry: () => { entry: () => {
const entry = ctx.slots.entries('conversation.input.dock')[0] const entry = ctx.slots.entries('conversation.input.dock')[0]
if (entry === undefined) return undefined if (entry === undefined) return undefined
@@ -141,6 +142,18 @@ describe('ui-goal browser plugin', () => {
expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }]) expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }])
}) })
it('settles every verb when the Remote namespace is temporarily absent', async () => {
const b = await bench({ projection: makeProjection() })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
b.unmountGoals()
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
}
expect(b.calls).toHaveLength(0)
})
it('a null or absent projection short-circuits every verb without touching the wire', async () => { it('a null or absent projection short-circuits every verb without touching the wire', async () => {
for (const projection of [null, undefined]) { for (const projection of [null, undefined]) {
const b = await bench({ projection }) const b = await bench({ projection })

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/typert/registry/README.md # pnpm run verify-translation-pairing --write packages/typert/registry/README.md
README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48 README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1
README.zh.md: aea74b3753feccd88ee132363dc60ade02161498 README.zh.md: 343e43aaca6ddaa0bb5e8d3130c85f37e4b4cb93

View File

@@ -10,6 +10,7 @@ Package reflection is keyed by `<package>#<face>`. Schemas are keyed by `<packag
- `TypertRegistry` is the default plugin and provides `ctx.typert`. - `TypertRegistry` is the default plugin and provides `ctx.typert`.
- `ctx.typert.lookups.register()` registers the wire declaration and default resolver owned by the business package; `configure()` registers a resolver owned by Host composition that may run asynchronously. Their lifetimes are independent: configuration may precede the provider, and unloading the configuration restores the default policy. - `ctx.typert.lookups.register()` registers the wire declaration and default resolver owned by the business package; `configure()` registers a resolver owned by Host composition that may run asynchronously. Their lifetimes are independent: configuration may precede the provider, and unloading the configuration restores the default policy.
- `ctx.typert.contexts.registerHost()` and `configureHost()` apply the same ownership split to scoped Context identity; `registerClient()` supplies the corresponding Client Context binder.
- `register(contribution)` rejects malformed identities and duplicate package-face or schema keys before committing anything, then returns the exact Cordis effect disposer. - `register(contribution)` rejects malformed identities and duplicate package-face or schema keys before committing anything, then returns the exact Cordis effect disposer.
- `get(key)`, `resolve(key)`, and `list(filter?)` query live schemas. `resolve()` distinguishes a malformed key, an absent package, and a package that contributes no schema under that name. - `get(key)`, `resolve(key)`, and `list(filter?)` query live schemas. `resolve()` distinguishes a malformed key, an absent package, and a package that contributes no schema under that name.
- `getPackage(packageName, face?)` and `listPackages(filter?)` query generated service, event, and object reflection; the default face is `host`. - `getPackage(packageName, face?)` and `listPackages(filter?)` query generated service, event, and object reflection; the default face is `host`.

View File

@@ -10,6 +10,7 @@
- `TypertRegistry` 是默认插件,并提供 `ctx.typert` - `TypertRegistry` 是默认插件,并提供 `ctx.typert`
- `ctx.typert.lookups.register()` 注册业务包拥有的 wire 声明和默认 resolver`configure()` 注册 Host 组合拥有、可异步执行的 resolver。两者生命周期独立配置可以先于 provider卸载配置会恢复默认策略。 - `ctx.typert.lookups.register()` 注册业务包拥有的 wire 声明和默认 resolver`configure()` 注册 Host 组合拥有、可异步执行的 resolver。两者生命周期独立配置可以先于 provider卸载配置会恢复默认策略。
- `ctx.typert.contexts.registerHost()``configureHost()` 将同一所有权拆分应用于 scoped Context 身份;`registerClient()` 提供对应的 Client Context binder。
- `register(contribution)` 会在提交任何内容之前拒绝格式错误的标识,以及重复的包与 face 组合键或 schema 键,随后返回 Cordis effect 提供的同一资源释放函数。 - `register(contribution)` 会在提交任何内容之前拒绝格式错误的标识,以及重复的包与 face 组合键或 schema 键,随后返回 Cordis effect 提供的同一资源释放函数。
- `get(key)``resolve(key)``list(filter?)` 查询当前有效的 schema。`resolve()` 能区分格式错误的键、未注册的包,以及已注册但未以该名称提供 schema 的包。 - `get(key)``resolve(key)``list(filter?)` 查询当前有效的 schema。`resolve()` 能区分格式错误的键、未注册的包,以及已注册但未以该名称提供 schema 的包。
- `getPackage(packageName, face?)``listPackages(filter?)` 查询生成的服务、事件和对象反射信息;默认 face 为 `host` - `getPackage(packageName, face?)``listPackages(filter?)` 查询生成的服务、事件和对象反射信息;默认 face 为 `host`

View File

@@ -15,6 +15,7 @@ import type {
TypeRTContextWire, TypeRTContextWire,
TypeRTDisposer, TypeRTDisposer,
TypeRTHostContextProvider, TypeRTHostContextProvider,
TypeRTHostContextResolver,
TypeRTLocalRegistry, TypeRTLocalRegistry,
TypeRTLookupHost, TypeRTLookupHost,
TypeRTLookupDefinition, TypeRTLookupDefinition,
@@ -334,6 +335,7 @@ function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLooku
class ContextStore { class ContextStore {
private readonly hosts = new Map<string, ProviderEntry<TypeRTHostContextProvider>>() private readonly hosts = new Map<string, ProviderEntry<TypeRTHostContextProvider>>()
private readonly hostResolvers = new Map<string, ProviderEntry<HostContextResolverEntry>>()
private readonly clients = new Map<string, ProviderEntry<TypeRTClientContextBinder>>() private readonly clients = new Map<string, ProviderEntry<TypeRTClientContextBinder>>()
private readonly changes: ChangeSource private readonly changes: ChangeSource
@@ -347,16 +349,56 @@ class ContextStore {
key: K, key: K,
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>, provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
) => this.registerHost(ctx, key, provider), ) => this.registerHost(ctx, key, provider),
configureHost: <K extends Extract<keyof TypeRTContextMap, string>>(
key: K,
resolver: TypeRTHostContextResolver<TypeRTContextWire<TypeRTContextMap[K]>>,
) => this.configureHost(ctx, key, resolver),
registerClient: <K extends Extract<keyof TypeRTContextMap, string>>( registerClient: <K extends Extract<keyof TypeRTContextMap, string>>(
key: K, key: K,
binder: TypeRTClientContextBinder<TypeRTContextWire<TypeRTContextMap[K]>>, binder: TypeRTClientContextBinder<TypeRTContextWire<TypeRTContextMap[K]>>,
) => this.registerClient(ctx, key, binder), ) => this.registerClient(ctx, key, binder),
getHost: key => this.hosts.get(key)?.provider, getHost: key => this.getHost(key),
getClient: key => this.clients.get(key)?.provider, getClient: key => this.clients.get(key)?.provider,
subscribe: listener => this.changes.subscribe(ctx, listener), subscribe: listener => this.changes.subscribe(ctx, listener),
} }
} }
private getHost(key: string): TypeRTHostContextProvider | undefined {
const provider = this.hosts.get(key)?.provider
if (provider === undefined) return undefined
const resolver = this.hostResolvers.get(key)?.provider
if (resolver === undefined) return provider
return {
wire: provider.wire,
wireTypeSymbol: provider.wireTypeSymbol,
resolve: id => resolver.resolve(id),
}
}
private configureHost<Wire>(
ctx: Context,
key: string,
resolver: TypeRTHostContextResolver<Wire>,
): TypeRTDisposer {
validateSegment('Context key', key)
if (this.hostResolvers.has(key)) throw new Error(`typert: host-context "${key}" resolver is already configured`)
const entry: ProviderEntry<HostContextResolverEntry> = {
provider: { resolve: async id => resolver(id as Wire) },
owner: {},
}
const { hostResolvers, changes } = this
return ctx.effect(function* () {
hostResolvers.set(key, entry)
changes.emit({ kind: 'host-context', key })
yield () => {
/* v8 ignore next -- duplicate configuration is rejected, so this effect remains the key's unique owner. */
if (hostResolvers.get(key) !== entry) return
hostResolvers.delete(key)
changes.emit({ kind: 'host-context', key })
}
}, `typert.contexts.configureHost(${JSON.stringify(key)})`)
}
private registerHost<Wire>(ctx: Context, key: string, provider: TypeRTHostContextProvider<Wire>): TypeRTDisposer { private registerHost<Wire>(ctx: Context, key: string, provider: TypeRTHostContextProvider<Wire>): TypeRTDisposer {
validateSegment('Context key', key) validateSegment('Context key', key)
validateWireName('Context wire field', provider.wire) validateWireName('Context wire field', provider.wire)
@@ -392,6 +434,10 @@ class ContextStore {
} }
} }
interface HostContextResolverEntry {
resolve(id: unknown): Promise<Context | undefined>
}
/** /**
* Registry of generated schemas, package reflection, invocations, and Remote * Registry of generated schemas, package reflection, invocations, and Remote
* dependency providers. * dependency providers.

View File

@@ -389,6 +389,36 @@ describe('TypertRegistry', () => {
await disposeReloadedProvider() await disposeReloadedProvider()
}) })
it('configures an asynchronous Host Context resolver independently of provider load order', async () => {
const ctx = await makeCtx()
const fallback = ctx.extend()
const configured = ctx.extend()
const disposeResolver = ctx.typert.contexts.configureHost('registryFixture', async id =>
id === 'configured' ? configured : undefined)
expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined()
const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', {
wire: 'agentId',
wireTypeSymbol: '@fixture/session#SessionId',
resolve: id => id === 'fallback' ? fallback : undefined,
})
await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured')
await disposeProvider()
expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined()
const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', {
wire: 'agentId',
wireTypeSymbol: '@fixture/session#SessionId',
resolve: id => id === 'fallback' ? fallback : undefined,
})
await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
await disposeResolver()
expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('fallback')).toBe(fallback)
await disposeReloadedProvider()
})
it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => {
const ctx = await makeCtx() const ctx = await makeCtx()
const changes: string[] = [] const changes: string[] = []

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md # pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md
README.md: b394c843409e840b75bbb08b128614379e528001 README.md: a76169742cb78d0d19814bcd0f978c71036a5a1c
README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5 README.zh.md: 6f2d2fd6e241441fae8102c0639608e9b27b9bec

View File

@@ -20,7 +20,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the
Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API.
Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path.
## Model Experience ## Model Experience

View File

@@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用
业务包扩展 `TypeRTLookupMap``TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap``TypeRTRemoteContextMap``TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 业务包扩展 `TypeRTLookupMap``TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap``TypeRTRemoteContextMap``TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。
查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolverHost 组合可以另行配置同步或异步 resolver策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema`src-json` 编解码器标识约束更弱的源码启动路径。 查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolverHost 组合可以另行配置同步或异步 resolver策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema`src-json` 编解码器标识约束更弱的源码启动路径。
## 模型体验 ## 模型体验

View File

@@ -50,6 +50,7 @@ export type {
TypeRTContextWire, TypeRTContextWire,
TypeRTDisposer, TypeRTDisposer,
TypeRTHostContextProvider, TypeRTHostContextProvider,
TypeRTHostContextResolver,
TypeRTLocalRegistry, TypeRTLocalRegistry,
TypeRTLookup, TypeRTLookup,
TypeRTLookupDefinition, TypeRTLookupDefinition,

View File

@@ -238,9 +238,14 @@ export interface TypeRTHostContextProvider<Wire = unknown> {
* @param id - validated wire identity. * @param id - validated wire identity.
* @returns the scoped Context, or `undefined` when unavailable. * @returns the scoped Context, or `undefined` when unavailable.
*/ */
resolve(id: Wire): Context | undefined resolve(id: Wire): Context | undefined | Promise<Context | undefined>
} }
/** Composition-owned resolver replacing one Host Context provider's default lookup policy. */
export type TypeRTHostContextResolver<Wire = unknown> = (
id: Wire,
) => Context | undefined | Promise<Context | undefined>
/** Client resolver for the identity carried by the calling scoped Context. */ /** Client resolver for the identity carried by the calling scoped Context. */
export interface TypeRTClientContextBinder<Wire = unknown> { export interface TypeRTClientContextBinder<Wire = unknown> {
/** /**
@@ -367,6 +372,17 @@ export interface TypeRTContextRegistry {
key: K, key: K,
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>, provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
): TypeRTDisposer ): TypeRTDisposer
/**
* Override one Host Context key's identity policy for the calling fiber.
* Configuration may precede provider registration and restores the provider's default resolver on disposal.
* @param key - merge-declared Context key.
* @param resolver - composition-owned resolver used by every Host Context lookup of this key.
* @returns disposer restoring the provider's default resolver.
*/
configureHost<K extends StringKeyOf<TypeRTContextMap>>(
key: K,
resolver: TypeRTHostContextResolver<TypeRTContextWire<TypeRTContextMap[K]>>,
): TypeRTDisposer
/** /**
* Register a Client Context identity binder. * Register a Client Context identity binder.
* @param key - merge-declared Context key. * @param key - merge-declared Context key.