fix(api-gateway): harden remote lifecycle and recovery
This commit is contained in:
+2
-2
@@ -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 .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.zh.md: e11d8ebe42d44cc9805e942a31f13f7ae847815a
|
||||
2026-08-02-typert-remote-method-calls.md: 3d5a79fd4a26f7d232dcc7635625899e2eb9df6b
|
||||
2026-08-02-typert-remote-method-calls.zh.md: 3d6ec680ba97a532f18219670e8dba799a94ed7b
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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 会直接失败。
|
||||
|
||||
业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent` 和 `session` 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。
|
||||
业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent`、`session` lookup 和 `agent` Host Context 创建共享的 `agentFor()` resolver:live 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 merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。
|
||||
|
||||
|
||||
@@ -134,7 +134,8 @@ class ClientApiService extends Service implements TypeRTClientApi {
|
||||
for (const method of methods) record.service.assertMethodAvailable(method)
|
||||
} else {
|
||||
for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method)
|
||||
if (this.ownerCtx.reflect.props[namespace] !== undefined) {
|
||||
const property = this.ownerCtx.reflect.props[namespace]
|
||||
if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) {
|
||||
throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`)
|
||||
}
|
||||
}
|
||||
@@ -224,6 +225,7 @@ class ClientApiService extends Service implements TypeRTClientApi {
|
||||
if (namespace.tokens.get(descriptor.method) !== token) return
|
||||
namespace.service.remove(descriptor.method)
|
||||
namespace.tokens.delete(descriptor.method)
|
||||
if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +291,7 @@ class ScopedRemoteNamespace {
|
||||
private readonly ctx: Context
|
||||
private readonly ownerCtx: Context
|
||||
private readonly methods = new Set<string>()
|
||||
private provided = false
|
||||
private disposeService: (() => void) | undefined
|
||||
readonly name: string
|
||||
|
||||
static assertMethodAvailable(namespace: string, method: string): void {
|
||||
@@ -331,12 +333,7 @@ class ScopedRemoteNamespace {
|
||||
},
|
||||
})
|
||||
if (activate) {
|
||||
if (this.provided) {
|
||||
this.ownerCtx.set(this.name, this)
|
||||
} else {
|
||||
this.ownerCtx.reflect.provide(this.name, this)
|
||||
this.provided = true
|
||||
}
|
||||
this.disposeService = this.ownerCtx.reflect.provide(this.name, this)
|
||||
}
|
||||
} catch (error) {
|
||||
Reflect.deleteProperty(this, method)
|
||||
@@ -348,11 +345,14 @@ class ScopedRemoteNamespace {
|
||||
remove(method: string): void {
|
||||
Reflect.deleteProperty(this, method)
|
||||
this.methods.delete(method)
|
||||
if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined)
|
||||
if (this.methods.size !== 0) return
|
||||
const disposeService = this.disposeService
|
||||
this.disposeService = undefined
|
||||
disposeService?.()
|
||||
}
|
||||
}
|
||||
|
||||
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided'])
|
||||
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx'])
|
||||
|
||||
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||
return `${descriptor.namespace}/${descriptor.method}`
|
||||
|
||||
@@ -134,7 +134,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
const endpoint = endpointOf(request.namespace, request.method)
|
||||
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
|
||||
assertExactArguments(request.args, descriptor, endpoint)
|
||||
const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint)
|
||||
const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
|
||||
const receiver = receiverContext.get(descriptor.service) as unknown
|
||||
if (!isObject(receiver)) {
|
||||
throw new TypertGatewayError(
|
||||
@@ -331,11 +331,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveReceiverContext(
|
||||
private async resolveReceiverContext(
|
||||
descriptor: InvocationDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Context {
|
||||
): Promise<Context> {
|
||||
if (descriptor.invocation.kind === 'direct') return this.ctx
|
||||
const invocation = descriptor.invocation
|
||||
const provider = this.ctx.typert.contexts.getHost(invocation.context)
|
||||
@@ -358,8 +358,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
|
||||
let context: Context | undefined
|
||||
try {
|
||||
context = provider.resolve(identity)
|
||||
context = await provider.resolve(identity)
|
||||
} catch (cause) {
|
||||
if (cause instanceof TypeRTLookupFailure) throw cause
|
||||
throw new TypertGatewayError(
|
||||
'context-failed',
|
||||
endpoint,
|
||||
|
||||
@@ -526,6 +526,20 @@ describe('Client TypeRT API', () => {
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
|
||||
expect(ctx.get('goals')).toBeDefined()
|
||||
|
||||
await dispose()
|
||||
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
const replacement = { owner: 'replacement' }
|
||||
const disposeReplacement = ctx.reflect.provide('goals', replacement)
|
||||
expect(ctx.get('goals')).toBe(replacement)
|
||||
await disposeReplacement()
|
||||
})
|
||||
|
||||
it('throws RPC failures with the structured error as its cause', async () => {
|
||||
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
|
||||
@@ -538,6 +538,22 @@ describe('TypertGatewayService', () => {
|
||||
expect(error.cause).toEqual(new Error('provider failed'))
|
||||
})
|
||||
|
||||
it('preserves a Host Context policy rejection for the active RPC adapter', async () => {
|
||||
const { ctx } = await setup()
|
||||
const rejection = new TypeRTLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } })
|
||||
ctx.typert.contexts.registerHost('gatewayFixture', {
|
||||
...contextProvider(ctx.extend()),
|
||||
resolve: async () => { throw rejection },
|
||||
})
|
||||
registerStrict(ctx, [renameDescriptor()])
|
||||
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
})).rejects.toBe(rejection)
|
||||
})
|
||||
|
||||
it('reports Context provider metadata mismatch and unresolved identities', async () => {
|
||||
const { ctx } = await setup()
|
||||
registerStrict(ctx, [renameDescriptor()])
|
||||
|
||||
@@ -187,6 +187,7 @@ export function createApiRemoteAgentResolver(
|
||||
}
|
||||
typeCtx.typert.lookups.configure('agent', resolveAgent)
|
||||
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
|
||||
typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx)
|
||||
})
|
||||
|
||||
return agentFor
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore 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 { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
|
||||
@@ -14,6 +16,7 @@ function header(id: SessionId): SessionHeader {
|
||||
|
||||
async function createContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return ctx
|
||||
@@ -107,4 +110,45 @@ describe('API Remote Agent resolver races', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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. */
|
||||
export const inject = ['slots', 'sessions', 'api', 'locale']
|
||||
|
||||
/** Map one generated Remote call onto the strip's inline-render shape. */
|
||||
async function settle(result: Promise<unknown>): Promise<GoalActionResult> {
|
||||
/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */
|
||||
async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> {
|
||||
try {
|
||||
await result
|
||||
await invoke()
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const cause = error instanceof Error ? error.cause : undefined
|
||||
@@ -94,22 +94,22 @@ export function apply(ctx: ClientContext): void {
|
||||
onEdit: async (objective) => {
|
||||
const ref = refOf(sessionId)
|
||||
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 () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle(ctx.api.goals.pause(sessionId, ref))
|
||||
return settle(() => ctx.api.goals.pause(sessionId, ref))
|
||||
},
|
||||
onResume: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle(ctx.api.goals.resume(sessionId, ref))
|
||||
return settle(() => ctx.api.goals.resume(sessionId, ref))
|
||||
},
|
||||
onClear: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle(ctx.api.goals.clear(sessionId, ref))
|
||||
return settle(() => ctx.api.goals.clear(sessionId, ref))
|
||||
},
|
||||
}),
|
||||
}, GoalDock))
|
||||
|
||||
@@ -70,7 +70,7 @@ async function bench(options: {
|
||||
resume: answer(`${prefix}/resume`, { ref }),
|
||||
clear: answer(`${prefix}/clear`, ref),
|
||||
})
|
||||
let activeGoals = goals('goals')
|
||||
let activeGoals: ReturnType<typeof goals> | undefined = goals('goals')
|
||||
ctx.provide('api', {
|
||||
get goals() { return activeGoals },
|
||||
})
|
||||
@@ -95,6 +95,7 @@ async function bench(options: {
|
||||
fiber,
|
||||
calls,
|
||||
remountGoals: () => { activeGoals = goals('remounted-goals') },
|
||||
unmountGoals: () => { activeGoals = undefined },
|
||||
entry: () => {
|
||||
const entry = ctx.slots.entries('conversation.input.dock')[0]
|
||||
if (entry === undefined) return undefined
|
||||
@@ -141,6 +142,18 @@ describe('ui-goal browser plugin', () => {
|
||||
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 () => {
|
||||
for (const projection of [null, undefined]) {
|
||||
const b = await bench({ projection })
|
||||
|
||||
@@ -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/typert/registry/README.md
|
||||
README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48
|
||||
README.zh.md: aea74b3753feccd88ee132363dc60ade02161498
|
||||
README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1
|
||||
README.zh.md: 343e43aaca6ddaa0bb5e8d3130c85f37e4b4cb93
|
||||
@@ -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`.
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- `TypertRegistry` 是默认插件,并提供 `ctx.typert`。
|
||||
- `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 提供的同一资源释放函数。
|
||||
- `get(key)`、`resolve(key)` 和 `list(filter?)` 查询当前有效的 schema。`resolve()` 能区分格式错误的键、未注册的包,以及已注册但未以该名称提供 schema 的包。
|
||||
- `getPackage(packageName, face?)` 和 `listPackages(filter?)` 查询生成的服务、事件和对象反射信息;默认 face 为 `host`。
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
TypeRTContextWire,
|
||||
TypeRTDisposer,
|
||||
TypeRTHostContextProvider,
|
||||
TypeRTHostContextResolver,
|
||||
TypeRTLocalRegistry,
|
||||
TypeRTLookupHost,
|
||||
TypeRTLookupDefinition,
|
||||
@@ -334,6 +335,7 @@ function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLooku
|
||||
|
||||
class ContextStore {
|
||||
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 changes: ChangeSource
|
||||
|
||||
@@ -347,16 +349,56 @@ class ContextStore {
|
||||
key: K,
|
||||
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
|
||||
) => 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>>(
|
||||
key: K,
|
||||
binder: TypeRTClientContextBinder<TypeRTContextWire<TypeRTContextMap[K]>>,
|
||||
) => this.registerClient(ctx, key, binder),
|
||||
getHost: key => this.hosts.get(key)?.provider,
|
||||
getHost: key => this.getHost(key),
|
||||
getClient: key => this.clients.get(key)?.provider,
|
||||
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 {
|
||||
validateSegment('Context key', key)
|
||||
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
|
||||
* dependency providers.
|
||||
|
||||
@@ -389,6 +389,36 @@ describe('TypertRegistry', () => {
|
||||
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 () => {
|
||||
const ctx = await makeCtx()
|
||||
const changes: string[] = []
|
||||
|
||||
@@ -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/typert/type-meta/README.md
|
||||
README.md: b394c843409e840b75bbb08b128614379e528001
|
||||
README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5
|
||||
README.md: a76169742cb78d0d19814bcd0f978c71036a5a1c
|
||||
README.zh.md: 6f2d2fd6e241441fae8102c0639608e9b27b9bec
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用
|
||||
|
||||
业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。
|
||||
|
||||
查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。
|
||||
查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export type {
|
||||
TypeRTContextWire,
|
||||
TypeRTDisposer,
|
||||
TypeRTHostContextProvider,
|
||||
TypeRTHostContextResolver,
|
||||
TypeRTLocalRegistry,
|
||||
TypeRTLookup,
|
||||
TypeRTLookupDefinition,
|
||||
|
||||
@@ -238,9 +238,14 @@ export interface TypeRTHostContextProvider<Wire = unknown> {
|
||||
* @param id - validated wire identity.
|
||||
* @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. */
|
||||
export interface TypeRTClientContextBinder<Wire = unknown> {
|
||||
/**
|
||||
@@ -367,6 +372,17 @@ export interface TypeRTContextRegistry {
|
||||
key: K,
|
||||
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
|
||||
): 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.
|
||||
* @param key - merge-declared Context key.
|
||||
|
||||
Reference in New Issue
Block a user