mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(typert): preserve remote lookup semantics
This commit is contained in:
@@ -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: ddc93b4fc672f320b4e3dc3e11586d92604e6aa4
|
||||
2026-08-02-typert-remote-method-calls.zh.md: 808c7d54bff19d9a4e9cf924769df1d405d997b5
|
||||
2026-08-02-typert-remote-method-calls.md: d91f6f173c1b56efcd21d3136392837e61f54aae
|
||||
2026-08-02-typert-remote-method-calls.zh.md: 0c548522d1137f0e0002a740d12ca0b796da5e39
|
||||
|
||||
@@ -154,7 +154,7 @@ Descriptors exist only in the local registry on each side. The wire carries only
|
||||
```text
|
||||
ctx.typert.local 当前进程自己的 Host 或 Client reflection
|
||||
ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution
|
||||
ctx.typert.lookups wire ID 到 Host 活对象的 provider
|
||||
ctx.typert.lookups wire ID 到 Host 对象的 provider 与组合策略
|
||||
ctx.typert.contexts Host Context resolver 与 Client Context binder
|
||||
```
|
||||
|
||||
@@ -162,6 +162,8 @@ 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. The standard Web Host's API Proxy configures the same `agentFor()` 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 `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles.
|
||||
|
||||
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.
|
||||
|
||||
## Canonical types, symbols, and Zod
|
||||
@@ -432,7 +434,7 @@ ctx.api.goals.create(sessionId, request, signal?)
|
||||
→ Client result codec 验证并返回 CreateGoalResult
|
||||
```
|
||||
|
||||
Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The current adapter converts every Gateway and business-invocation failure to the existing `RpcError` envelope with `code: 'internal'`; the Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection.
|
||||
Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The adapter converts ordinary Gateway and business-invocation failures to the existing `RpcError` envelope with `code: 'internal'`; an existing RPC error carried by a resolver in `TypeRTLookupFailure` is returned unchanged, preserving stable error codes for cold-resume failures and ownership fences. The Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection.
|
||||
|
||||
The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work.
|
||||
|
||||
@@ -451,11 +453,12 @@ The Gateway registers only its ownership matcher and RPC handler with Connection
|
||||
- `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages.
|
||||
- Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport.
|
||||
- Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries.
|
||||
- API Proxy Host composition: configures cold resume, concurrent deduplication, and subagent ownership policy for `agent`/`session` lookups through the existing `agentFor()`.
|
||||
- Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath.
|
||||
|
||||
## Shipped scope and deferred work
|
||||
|
||||
The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode.
|
||||
The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode.
|
||||
|
||||
Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision.
|
||||
|
||||
@@ -486,6 +489,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp
|
||||
- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace.
|
||||
- Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub.
|
||||
- Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope.
|
||||
- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation.
|
||||
- The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI.
|
||||
- Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback.
|
||||
- Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`.
|
||||
@@ -514,3 +518,5 @@ Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted
|
||||
`hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition.
|
||||
|
||||
Cancellation-aware Remote signatures receive Connection's request `AbortSignal`, so an HTTP disconnect or Client-side abort reaches ongoing business work without entering the JSON protocol. Cancellation remains cooperative: methods without the reserved final parameter continue running, and a method that receives the signal must pass it to its own cancellable operations or observe it directly.
|
||||
|
||||
Lookup configuration currently operates at key granularity, so every `agent` or `session` parameter uses the same cold-resume policy. A specific Remote that requires live-only semantics must wait for an explicit per-parameter or per-endpoint policy; the business implementation cannot be left to guess whether the object was just resumed.
|
||||
|
||||
@@ -154,7 +154,7 @@ descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、e
|
||||
```text
|
||||
ctx.typert.local 当前进程自己的 Host 或 Client reflection
|
||||
ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution
|
||||
ctx.typert.lookups wire ID 到 Host 活对象的 provider
|
||||
ctx.typert.lookups wire ID 到 Host 对象的 provider 与组合策略
|
||||
ctx.typert.contexts Host Context resolver 与 Client Context binder
|
||||
```
|
||||
|
||||
@@ -162,6 +162,8 @@ 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。标准 Web Host 的 API Proxy 为 `agent` 和 `session` 配置同一套 `agentFor()`:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。
|
||||
|
||||
Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。
|
||||
|
||||
## 唯一类型、符号与 Zod
|
||||
@@ -432,7 +434,7 @@ ctx.api.goals.create(sessionId, request, signal?)
|
||||
→ Client result codec 验证并返回 CreateGoalResult
|
||||
```
|
||||
|
||||
Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。
|
||||
Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。adapter 把普通 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;resolver 通过 `TypeRTLookupFailure` 携带的既有 RPC error 则原样返回,使冷恢复失败和 ownership fence 保持稳定错误码。Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。
|
||||
|
||||
Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。
|
||||
|
||||
@@ -451,11 +453,12 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H
|
||||
- `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。
|
||||
- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。
|
||||
- Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。
|
||||
- API Proxy Host 组合:用既有 `agentFor()` 配置 `agent`/`session` lookup 的冷恢复、并发去重和 subagent ownership 策略。
|
||||
- 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。
|
||||
|
||||
## 已交付范围与后续工作
|
||||
|
||||
已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。
|
||||
已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。
|
||||
|
||||
Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。
|
||||
|
||||
@@ -486,6 +489,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS
|
||||
- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。
|
||||
- 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。
|
||||
- Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。
|
||||
- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。
|
||||
- Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。
|
||||
- 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。
|
||||
- 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。
|
||||
@@ -514,3 +518,5 @@ Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接
|
||||
`hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。
|
||||
|
||||
支持取消的 Remote 签名会接收 Connection 请求的 `AbortSignal`,因此 HTTP 断连或 Client 侧 abort 能在不进入 JSON 协议的情况下传递到正在进行的业务工作。取消仍是协作式的:没有保留末位参数的方法会继续运行;收到 signal 的方法必须将它传给自身支持取消的操作,或自行观测它。
|
||||
|
||||
lookup 配置当前以 key 为粒度,因此每个 `agent` 或 `session` 参数都采用同一套冷恢复策略。需要 live-only 语义的特定 Remote 必须等待显式的逐参数或逐 endpoint 策略,不能靠业务实现猜测对象是否刚被恢复。
|
||||
|
||||
@@ -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 docs/api-gateway.md
|
||||
api-gateway.md: 76af93880d278a17dc46370fd5065fdcdadb9fb6
|
||||
api-gateway.zh.md: d447cea6b64bf88084f86a210a5f654bd9445d6c
|
||||
api-gateway.md: 2e0717fd7b0e5b9ca33d650ffad7ac454046f780
|
||||
api-gateway.zh.md: 4d1beebf92cae702dac323cdd974b6220091a214
|
||||
|
||||
@@ -8,7 +8,7 @@ This is the current-state reference for the TypeRT API Gateway. It describes how
|
||||
|
||||
Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.api`.
|
||||
|
||||
`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to the current live object before invoking the business method.
|
||||
`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a default resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to a Host object before invoking the business method. Host composition can use `ctx.typert.lookups.configure()` to override the resolution policy for a lookup key without changing the parameter name, wire field, or canonical type symbol owned by the business package.
|
||||
|
||||
`@RemoteContext(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly.
|
||||
|
||||
@@ -117,6 +117,8 @@ The Connection performs the unified trust check for `/api` before the HTTP bridg
|
||||
|
||||
For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails at the boundary before entering or after leaving business code.
|
||||
|
||||
The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The standard Web Host's API Proxy configures the same `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error.
|
||||
|
||||
Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation.
|
||||
|
||||
## SRC development fallback
|
||||
@@ -155,3 +157,5 @@ The running Client watcher consumes these generated files when it rebundles; wit
|
||||
## Boundaries
|
||||
|
||||
Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors.
|
||||
|
||||
Lookup policy is currently configured per key, so all `agent` or `session` parameters share the cold-resume behavior. If a Remote endpoint must accept live objects only, an explicit per-parameter or per-endpoint policy must be added later; the business method must not guess whether the object came from restoration.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。
|
||||
|
||||
`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为当前的实时对象。
|
||||
`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册默认解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为 Host 对象。Host 组合可以用 `ctx.typert.lookups.configure()` 覆盖某个 lookup key 的解析策略,而不改变业务包拥有的参数名、wire 字段或规范类型 symbol。
|
||||
|
||||
`@RemoteContext(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。
|
||||
|
||||
@@ -117,6 +117,8 @@ Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共
|
||||
|
||||
Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。
|
||||
|
||||
lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。标准 Web Host 的 API Proxy 为 `agent` 与 `session` 配置同一套 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。
|
||||
|
||||
Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。
|
||||
|
||||
## SRC 开发回退
|
||||
@@ -155,3 +157,5 @@ pnpm run build:lib:contracts
|
||||
## 边界
|
||||
|
||||
Remote 只处理有单个请求与单个结果的一元方法调用。Session event stream、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。
|
||||
|
||||
当前 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。某个 Remote endpoint 若必须只接受 live 对象,需要后续增加显式的逐参数或逐 endpoint 策略,不能通过业务方法内部猜测恢复来源。
|
||||
|
||||
@@ -2593,7 +2593,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[]
|
||||
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema
|
||||
```
|
||||
|
||||
Source: [`packages/typert/registry/src/service.ts:346`](../../packages/typert/registry/src/service.ts)
|
||||
Source: [`packages/typert/registry/src/service.ts:400`](../../packages/typert/registry/src/service.ts)
|
||||
|
||||
## `ctx.typertGateway` — `TypertGatewayService`
|
||||
|
||||
@@ -2604,12 +2604,12 @@ Resolve strict generated definitions or conservative SRC markers against current
|
||||
* Invoke one live Remote method through strict generated reflection or SRC markers.
|
||||
* @param request - decoded endpoint and exact named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
async invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
```
|
||||
|
||||
Source: [`packages/host/api-gateway/src/index.ts:76`](../../packages/host/api-gateway/src/index.ts)
|
||||
Source: [`packages/host/api-gateway/src/index.ts:78`](../../packages/host/api-gateway/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
|
||||
@@ -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 docs/core-data-structures/typert.md
|
||||
typert.md: da6e229ff6a2300c36f5734ad05c621a5e63082d
|
||||
typert.zh.md: b3b0e8897756b5b4f9b645522cc5a1b27eac1d33
|
||||
typert.md: 1ff0fe80e483d481f686336c86038cdd169ecdbc
|
||||
typert.zh.md: 3cc0aa26406e01db5a6c05074210fc9d40b8ec00
|
||||
|
||||
@@ -114,7 +114,7 @@ interface InvocationDescriptor {
|
||||
|
||||
## TypeRT registry
|
||||
|
||||
`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, live lookup providers, and scoped Context providers. Registrations are Cordis-owned effects and return awaitable disposers.
|
||||
`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, and scoped Context providers. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers.
|
||||
|
||||
```ts type-equiv
|
||||
/** Minimal TypeRT runtime consumed through dependency inversion. */
|
||||
@@ -135,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {}
|
||||
|
||||
## Host Gateway
|
||||
|
||||
Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code.
|
||||
Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures use the Gateway's in-process error taxonomy, ordinary exceptions are folded by the RPC adapter into the transport's `internal` error code, and existing RPC errors carried by lookup policy through `TypeRTLookupFailure` are returned unchanged.
|
||||
|
||||
```ts type-equiv
|
||||
/** One Remote method request after a carrier has decoded its envelope. */
|
||||
@@ -180,7 +180,7 @@ interface TypertGateway {
|
||||
* Invoke one live Remote method without assuming a carrier or response envelope.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ interface InvocationDescriptor {
|
||||
|
||||
## TypeRT 注册表
|
||||
|
||||
`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、活 lookup 提供方与 scoped Context 提供方。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。
|
||||
`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与 scoped Context 提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。
|
||||
|
||||
```ts type-equiv
|
||||
/** Minimal TypeRT runtime consumed through dependency inversion. */
|
||||
@@ -135,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {}
|
||||
|
||||
## Host Gateway
|
||||
|
||||
Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。
|
||||
Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,普通异常由 RPC 适配器折叠为传输层的 `internal` 错误码,lookup 策略通过 `TypeRTLookupFailure` 携带的既有 RPC error 则原样返回。
|
||||
|
||||
```ts type-equiv
|
||||
/** One Remote method request after a carrier has decoded its envelope. */
|
||||
@@ -180,7 +180,7 @@ interface TypertGateway {
|
||||
* Invoke one live Remote method without assuming a carrier or response envelope.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@ function isRemoteError(value: unknown): value is { readonly code: string; readon
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
|
||||
|
||||
const { goals } = ctx.api
|
||||
|
||||
const sessions = ctx.sessions
|
||||
|
||||
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
|
||||
@@ -96,22 +94,22 @@ export function apply(ctx: ClientContext): void {
|
||||
onEdit: async (objective) => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle(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(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(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(goals.clear(sessionId, ref))
|
||||
return settle(ctx.api.goals.clear(sessionId, ref))
|
||||
},
|
||||
}),
|
||||
}, GoalDock))
|
||||
|
||||
@@ -64,12 +64,16 @@ async function bench(options: {
|
||||
}
|
||||
}
|
||||
const ref = { id: 'g-1', revision: 3 }
|
||||
ctx.provide('api', { goals: {
|
||||
edit: answer('goals/edit', { ref }),
|
||||
pause: answer('goals/pause', { ref }),
|
||||
resume: answer('goals/resume', { ref }),
|
||||
clear: answer('goals/clear', ref),
|
||||
} })
|
||||
const goals = (prefix: string) => ({
|
||||
edit: answer(`${prefix}/edit`, { ref }),
|
||||
pause: answer(`${prefix}/pause`, { ref }),
|
||||
resume: answer(`${prefix}/resume`, { ref }),
|
||||
clear: answer(`${prefix}/clear`, ref),
|
||||
})
|
||||
let activeGoals = goals('goals')
|
||||
ctx.provide('api', {
|
||||
get goals() { return activeGoals },
|
||||
})
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
|
||||
@@ -90,6 +94,7 @@ async function bench(options: {
|
||||
ctx,
|
||||
fiber,
|
||||
calls,
|
||||
remountGoals: () => { activeGoals = goals('remounted-goals') },
|
||||
entry: () => {
|
||||
const entry = ctx.slots.entries('conversation.input.dock')[0]
|
||||
if (entry === undefined) return undefined
|
||||
@@ -126,6 +131,16 @@ describe('ui-goal browser plugin', () => {
|
||||
expect(b.calls[3]?.args).toEqual(['s1', ref])
|
||||
})
|
||||
|
||||
it('verbs read a remounted Remote namespace at action time', async () => {
|
||||
const b = await bench({ projection: makeProjection() })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
b.remountGoals()
|
||||
|
||||
expect(await verbs.onPause()).toEqual({ ok: true })
|
||||
expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }])
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
@@ -1160,7 +1160,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'async invoke(request: InvokeRemoteRequest): Promise<unknown>',
|
||||
jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.\n */',
|
||||
jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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/host/api-gateway/README.md
|
||||
README.md: 43e8f464e2a2790d05628a7fba61143a6a5ab26a
|
||||
README.zh.md: 761045d0c1afc17dfc230f9f45849c46e4e579fc
|
||||
README.md: eb48c29628d39e381235b1f72754eb114960b1ad
|
||||
README.zh.md: e53bb6c216e42fe2e970bf2cb80eac9ea7426497
|
||||
|
||||
@@ -8,9 +8,9 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry
|
||||
|
||||
`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance.
|
||||
|
||||
Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
|
||||
Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
|
||||
|
||||
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs.
|
||||
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypeRTLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences.
|
||||
|
||||
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.
|
||||
|
||||
@@ -32,7 +32,8 @@ No direct effect; invoked business Services own any model-visible result.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The Connection adapter currently maps dispatch and business failures to the RPC `internal` code with empty details. Structured `TypertGatewayError` categories remain available only to same-process callers.
|
||||
- The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypeRTLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers.
|
||||
- SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields.
|
||||
- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection.
|
||||
- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection.
|
||||
- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。
|
||||
|
||||
严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
|
||||
严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
|
||||
|
||||
Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。
|
||||
Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypeRTLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。
|
||||
|
||||
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
|
||||
|
||||
@@ -32,7 +32,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 适配器目前将分发故障和业务故障映射为 RPC 的 `internal` 代码,且不附带详细信息。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。
|
||||
- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypeRTLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。
|
||||
- SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。
|
||||
- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。
|
||||
- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。
|
||||
- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Context, Service, symbols } from 'cordis'
|
||||
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
||||
import {
|
||||
remoteMethods,
|
||||
TypeRTLookupFailure,
|
||||
type InvocationDescriptor,
|
||||
type InvocationParameterDescriptor,
|
||||
type TypeRTCodec,
|
||||
@@ -36,6 +37,7 @@ interface ResolvedBinding {
|
||||
}
|
||||
|
||||
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
|
||||
type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
|
||||
const NEVER_ABORTED_SIGNAL = new AbortController().signal
|
||||
|
||||
/** Dispatch failure produced outside the invoked business method. */
|
||||
@@ -126,7 +128,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
* Invoke one live Remote method through strict generated reflection or SRC markers.
|
||||
* @param request - decoded endpoint and exact named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
async invoke(request: InvokeRemoteRequest): Promise<unknown> {
|
||||
const endpoint = endpointOf(request.namespace, request.method)
|
||||
@@ -142,7 +144,8 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
)
|
||||
}
|
||||
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
|
||||
const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))
|
||||
const args = await Promise.all(descriptor.parameters.map(parameter =>
|
||||
this.resolveParameter(parameter, request.args, endpoint)))
|
||||
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
|
||||
const implementation = descriptor.implementation ?? descriptor.method
|
||||
const method = Reflect.get(receiver, implementation) as unknown
|
||||
@@ -375,11 +378,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
return context
|
||||
}
|
||||
|
||||
private resolveParameter(
|
||||
private async resolveParameter(
|
||||
parameter: InvocationParameterDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): unknown {
|
||||
): Promise<unknown> {
|
||||
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
|
||||
if (parameter.source === 'json') return value
|
||||
const key = parameter.lookup
|
||||
@@ -412,8 +415,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
}
|
||||
let resolved: unknown
|
||||
try {
|
||||
resolved = provider.resolve(value)
|
||||
resolved = await provider.resolve(value)
|
||||
} catch (cause) {
|
||||
if (cause instanceof TypeRTLookupFailure) throw cause
|
||||
throw new TypertGatewayError(
|
||||
'lookup-failed',
|
||||
endpoint,
|
||||
@@ -434,6 +438,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
}
|
||||
|
||||
function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
if (error instanceof TypeRTLookupFailure) {
|
||||
return { ok: false, error: error.failure as ConnectionRpcError }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface TypertGateway {
|
||||
* Invoke one live Remote method without assuming a carrier or response envelope.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
@@ -484,6 +484,27 @@ describe('Client TypeRT API', () => {
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('withdraws a fresh direct namespace when its first method fails to install', 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 === 'create') throw new Error('fixture direct method installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] }))
|
||||
.toThrow('fixture direct method installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.api 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')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('withdraws a fresh scoped Service when its first method fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
bindTypeRTGateway,
|
||||
Remote,
|
||||
RemoteContext,
|
||||
TypeRTLookupFailure,
|
||||
type InvocationDescriptor,
|
||||
type TypeRTContext,
|
||||
type TypeRTLookup,
|
||||
@@ -91,7 +92,7 @@ class GoalService extends Service {
|
||||
|
||||
type FakeRpcResult =
|
||||
| { readonly ok: true; readonly value: unknown }
|
||||
| { readonly ok: false; readonly error: { readonly code: 'internal'; readonly message: string; readonly details: object } }
|
||||
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } }
|
||||
|
||||
type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<FakeRpcResult>
|
||||
|
||||
@@ -568,7 +569,7 @@ describe('TypertGatewayService', () => {
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
const throwing = ctx.typert.lookups.register('gatewayFixture', {
|
||||
...agentLookup({ id: 'agent-1' }),
|
||||
resolve: () => { throw new Error('lookup failed') },
|
||||
resolve: async () => { throw new Error('lookup failed') },
|
||||
})
|
||||
const failure = await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
@@ -578,15 +579,26 @@ describe('TypertGatewayService', () => {
|
||||
expect(failure.cause).toEqual(new Error('lookup failed'))
|
||||
await throwing()
|
||||
|
||||
ctx.typert.lookups.register('gatewayFixture', {
|
||||
const missing = ctx.typert.lookups.register('gatewayFixture', {
|
||||
...agentLookup({ id: 'agent-1' }),
|
||||
resolve: () => undefined,
|
||||
resolve: () => Promise.resolve(undefined),
|
||||
})
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'lookup-not-found')
|
||||
await missing()
|
||||
|
||||
ctx.typert.lookups.register('gatewayFixture', {
|
||||
...agentLookup({ id: 'agent-1' }),
|
||||
resolve: async id => ({ id }),
|
||||
})
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
})).resolves.toMatchObject({ agentId: 'agent-1', title: 'ship' })
|
||||
})
|
||||
|
||||
it('never downgrades an observed strict endpoint after definition disposal', async () => {
|
||||
@@ -968,6 +980,30 @@ describe('TypertGatewayService', () => {
|
||||
expect(connection.handler).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves a lookup policy rejection through the Connection RPC result', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(FakeConnectionService)
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
await ctx.plugin(GoalService)
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
const failure = {
|
||||
code: 'agent-busy',
|
||||
message: 'session is owned by subagent routing',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
}
|
||||
ctx.typert.lookups.register('gatewayFixture', {
|
||||
...agentLookup({ id: 'agent-1' }),
|
||||
resolve: () => { throw new TypeRTLookupFailure(failure) },
|
||||
})
|
||||
const handler = rawConnection(ctx).handler
|
||||
if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
|
||||
|
||||
await expect(handler('goals/create', {
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}, new AbortController().signal)).resolves.toEqual({ ok: false, error: failure })
|
||||
})
|
||||
|
||||
it('caches SRC ownership until the Cordis Service set changes', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
|
||||
@@ -19,6 +19,9 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
// Type-only: resolves the optional `ctx.typert` lookup-policy composition.
|
||||
import type {} from '@deepseek-ai/dsh-typert-registry'
|
||||
import {
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
|
||||
WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
|
||||
@@ -1099,6 +1102,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
}
|
||||
|
||||
// Remote object parameters use the same identity policy as API Proxy methods:
|
||||
// ordinary cold sessions resume once, while subagent-owned identities retain
|
||||
// their stable caller-facing rejection. The provider packages continue to
|
||||
// own wire declarations and live-only defaults; this Host composition owns
|
||||
// the broader lookup policy.
|
||||
ctx.inject(['typert'], (typeCtx) => {
|
||||
const resolveAgent = async (sessionId: SessionId): Promise<Agent> => {
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) throw new TypeRTLookupFailure(found.error)
|
||||
return found.agent
|
||||
}
|
||||
typeCtx.typert.lookups.configure('agent', resolveAgent)
|
||||
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
|
||||
})
|
||||
|
||||
type SessionReadState = {
|
||||
id: SessionId
|
||||
header: SessionHeader
|
||||
|
||||
@@ -11,6 +11,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -180,6 +182,100 @@ describe('cold history recovery view', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote Agent and Session lookup policy', () => {
|
||||
it('deduplicates a cold resume across Agent and Session parameters', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const sessionId = sid('session-remote-cold')
|
||||
const meta = header(sessionId, 1000)
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] }))
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session
|
||||
const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
|
||||
await release.promise
|
||||
return { agent: resumedAgent, dispose: () => Promise.resolve() }
|
||||
})
|
||||
const defaultAgentLookup = ctx.typert.lookups.get('agent')
|
||||
const defaultSessionLookup = ctx.typert.lookups.get('session')
|
||||
createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
|
||||
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
|
||||
})
|
||||
const agentLookup = ctx.typert.lookups.get('agent')
|
||||
const sessionLookup = ctx.typert.lookups.get('session')
|
||||
if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
|
||||
|
||||
const resolvedAgent = Promise.resolve(agentLookup.resolve(sessionId))
|
||||
const resolvedSession = Promise.resolve(sessionLookup.resolve(sessionId))
|
||||
await vi.waitFor(() => { expect(resume).toHaveBeenCalledOnce() })
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(resolvedAgent).resolves.toBe(resumedAgent)
|
||||
await expect(resolvedSession).resolves.toBe(resumedSession)
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves the subagent ownership fence for cold and live Remote lookups', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const coldId = sid('session-remote-cold-child')
|
||||
const coldMeta = header(coldId, 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
origin: 'subagent',
|
||||
})
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] }))
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([coldMeta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const liveSession = ctx.sessions.create(sid('session-remote-live-child'), {
|
||||
meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' },
|
||||
})
|
||||
const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(liveAgent)
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
const defaultAgentLookup = ctx.typert.lookups.get('agent')
|
||||
const defaultSessionLookup = ctx.typert.lookups.get('session')
|
||||
createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
|
||||
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
|
||||
})
|
||||
const agentLookup = ctx.typert.lookups.get('agent')
|
||||
const sessionLookup = ctx.typert.lookups.get('session')
|
||||
if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
|
||||
const ownershipFailure = {
|
||||
failure: {
|
||||
code: 'agent-busy',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
},
|
||||
}
|
||||
|
||||
const coldFailure = Promise.resolve(agentLookup.resolve(coldId))
|
||||
const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id))
|
||||
await expect(coldFailure).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(coldFailure).rejects.toMatchObject(ownershipFailure)
|
||||
await expect(liveFailure).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(liveFailure).rejects.toMatchObject(ownershipFailure)
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent ownership fence', () => {
|
||||
it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
|
||||
@@ -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: 83c03ab284abf2b7cab4dd1ee70d7e855184a1e0
|
||||
README.zh.md: db2140e51d85be53bbf6eb4d1dd86ec38ceefc58
|
||||
README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48
|
||||
README.zh.md: aea74b3753feccd88ee132363dc60ade02161498
|
||||
|
||||
@@ -9,6 +9,7 @@ Package reflection is keyed by `<package>#<face>`. Schemas are keyed by `<packag
|
||||
## Public API
|
||||
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
## 公开 API
|
||||
|
||||
- `TypertRegistry` 是默认插件,并提供 `ctx.typert`。
|
||||
- `ctx.typert.lookups.register()` 注册业务包拥有的 wire 声明和默认 resolver;`configure()` 注册 Host 组合拥有、可异步执行的 resolver。两者生命周期独立,配置可以先于 provider,卸载配置会恢复默认策略。
|
||||
- `register(contribution)` 会在提交任何内容之前拒绝格式错误的标识,以及重复的包与 face 组合键或 schema 键,随后返回 Cordis effect 提供的同一资源释放函数。
|
||||
- `get(key)`、`resolve(key)` 和 `list(filter?)` 查询当前有效的 schema。`resolve()` 能区分格式错误的键、未注册的包,以及已注册但未以该名称提供 schema 的包。
|
||||
- `getPackage(packageName, face?)` 和 `listPackages(filter?)` 查询生成的服务、事件和对象反射信息;默认 face 为 `host`。
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
TypeRTLookupDefinition,
|
||||
TypeRTLookupMap,
|
||||
TypeRTLookupProvider,
|
||||
TypeRTLookupResolver,
|
||||
TypeRTLookupRegistry,
|
||||
TypeRTLookupWire,
|
||||
TypeRTRemoteContribution,
|
||||
@@ -213,6 +214,7 @@ class RemoteStore {
|
||||
|
||||
class LookupStore {
|
||||
private readonly providers = new Map<string, ProviderEntry<TypeRTLookupProvider>>()
|
||||
private readonly resolvers = new Map<string, ProviderEntry<LookupResolverEntry>>()
|
||||
private readonly definitions = new Map<string, TypeRTLookupDefinition>()
|
||||
private readonly changes: ChangeSource
|
||||
|
||||
@@ -229,13 +231,61 @@ class LookupStore {
|
||||
TypeRTLookupWire<TypeRTLookupMap[K]>
|
||||
>,
|
||||
) => this.register(ctx, key, provider),
|
||||
get: key => this.providers.get(key)?.provider,
|
||||
configure: <K extends Extract<keyof TypeRTLookupMap, string>>(
|
||||
key: K,
|
||||
resolver: TypeRTLookupResolver<
|
||||
TypeRTLookupHost<TypeRTLookupMap[K]>,
|
||||
TypeRTLookupWire<TypeRTLookupMap[K]>
|
||||
>,
|
||||
) => this.configure(ctx, key, resolver),
|
||||
get: key => this.get(key),
|
||||
definitions: () => [...this.definitions.values()],
|
||||
keys: () => [...this.providers.keys()],
|
||||
subscribe: listener => this.changes.subscribe(ctx, listener),
|
||||
}
|
||||
}
|
||||
|
||||
private get(key: string): TypeRTLookupProvider | undefined {
|
||||
const provider = this.providers.get(key)?.provider
|
||||
if (provider === undefined) return undefined
|
||||
const resolver = this.resolvers.get(key)?.provider
|
||||
if (resolver === undefined) return provider
|
||||
return {
|
||||
parameter: provider.parameter,
|
||||
wire: provider.wire,
|
||||
hostTypeSymbol: provider.hostTypeSymbol,
|
||||
wireTypeSymbol: provider.wireTypeSymbol,
|
||||
resolve: id => resolver.resolve(id),
|
||||
}
|
||||
}
|
||||
|
||||
private configure<Host, Wire>(
|
||||
ctx: Context,
|
||||
key: string,
|
||||
resolver: TypeRTLookupResolver<Host, Wire>,
|
||||
): TypeRTDisposer {
|
||||
validateSegment('lookup key', key)
|
||||
if (this.resolvers.has(key)) throw new Error(`typert: lookup "${key}" resolver is already configured`)
|
||||
const owner = {}
|
||||
// The map erases each merge-declared Wire type; restore it only at the
|
||||
// typed configure() boundary so strict function variance remains sound.
|
||||
const entry: ProviderEntry<LookupResolverEntry> = {
|
||||
provider: { resolve: async id => resolver(id as Wire) },
|
||||
owner,
|
||||
}
|
||||
const { resolvers, changes } = this
|
||||
return ctx.effect(function* () {
|
||||
resolvers.set(key, entry)
|
||||
changes.emit({ kind: 'lookup', key })
|
||||
yield () => {
|
||||
/* v8 ignore next -- duplicate configuration is rejected, so this effect remains the key's unique owner. */
|
||||
if (resolvers.get(key) !== entry) return
|
||||
resolvers.delete(key)
|
||||
changes.emit({ kind: 'lookup', key })
|
||||
}
|
||||
}, `typert.lookups.configure(${JSON.stringify(key)})`)
|
||||
}
|
||||
|
||||
private register<Host, Wire>(ctx: Context, key: string, provider: TypeRTLookupProvider<Host, Wire>): TypeRTDisposer {
|
||||
validateSegment('lookup key', key)
|
||||
validateSegment('lookup parameter', provider.parameter)
|
||||
@@ -271,6 +321,10 @@ class LookupStore {
|
||||
}
|
||||
}
|
||||
|
||||
interface LookupResolverEntry {
|
||||
resolve(id: unknown): Promise<unknown>
|
||||
}
|
||||
|
||||
function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLookupDefinition): boolean {
|
||||
return left.parameter === right.parameter
|
||||
&& left.wire === right.wire
|
||||
|
||||
@@ -355,6 +355,40 @@ describe('TypertRegistry', () => {
|
||||
expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('configures an asynchronous lookup resolver independently of provider load order', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const fallback = { id: 'fallback' }
|
||||
const configured = { id: 'configured' }
|
||||
const disposeResolver = ctx.typert.lookups.configure('fixture', async id =>
|
||||
id === configured.id ? configured : undefined)
|
||||
|
||||
expect(ctx.typert.lookups.get('fixture')).toBeUndefined()
|
||||
const disposeProvider = ctx.typert.lookups.register('fixture', {
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
hostTypeSymbol: '@fixture/agent#Agent',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
resolve: id => id === fallback.id ? fallback : undefined,
|
||||
})
|
||||
await expect(ctx.typert.lookups.get('fixture')?.resolve('configured')).resolves.toBe(configured)
|
||||
expect(() => ctx.typert.lookups.configure('fixture', () => undefined)).toThrow('already configured')
|
||||
|
||||
await disposeProvider()
|
||||
expect(ctx.typert.lookups.get('fixture')).toBeUndefined()
|
||||
const disposeReloadedProvider = ctx.typert.lookups.register('fixture', {
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
hostTypeSymbol: '@fixture/agent#Agent',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
resolve: id => id === fallback.id ? fallback : undefined,
|
||||
})
|
||||
await expect(ctx.typert.lookups.get('fixture')?.resolve('configured')).resolves.toBe(configured)
|
||||
|
||||
await disposeResolver()
|
||||
expect(ctx.typert.lookups.get('fixture')?.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: 245df305efcf711486b2d3f32e40a8b415f2682e
|
||||
README.zh.md: 592aa5d027a52a7a277a90ba5d51f19101f055f6
|
||||
README.md: b394c843409e840b75bbb08b128614379e528001
|
||||
README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5
|
||||
|
||||
@@ -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`. 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 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` 注册身份解析。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。
|
||||
查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -18,6 +18,25 @@ export function isTypeRTRemoteSegment(value: string): boolean {
|
||||
return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* A lookup policy rejection whose typed payload belongs to the active boundary adapter.
|
||||
* Gateway adapters preserve this payload instead of collapsing it into an infrastructure failure.
|
||||
*/
|
||||
export class TypeRTLookupFailure<Failure = unknown> extends Error {
|
||||
/** Adapter-owned failure returned to the caller. */
|
||||
readonly failure: Failure
|
||||
|
||||
/**
|
||||
* Wrap one adapter failure without exposing the rejected identity.
|
||||
* @param failure - typed failure owned by the active boundary adapter.
|
||||
*/
|
||||
constructor(failure: Failure) {
|
||||
super('TypeRT lookup policy rejected the requested identity')
|
||||
this.name = 'TypeRTLookupFailure'
|
||||
this.failure = failure
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
InvocationDescriptor,
|
||||
InvocationParameterDescriptor,
|
||||
@@ -36,6 +55,7 @@ export type {
|
||||
TypeRTLookupHost,
|
||||
TypeRTLookupMap,
|
||||
TypeRTLookupProvider,
|
||||
TypeRTLookupResolver,
|
||||
TypeRTLookupRegistry,
|
||||
TypeRTLookupWire,
|
||||
TypeRTRemoteContextApi,
|
||||
|
||||
@@ -176,7 +176,16 @@ export interface TypeRTRemoteContribution {
|
||||
readonly descriptors: readonly InvocationDescriptor[]
|
||||
}
|
||||
|
||||
/** Runtime resolver for one declared Host object lookup. */
|
||||
/**
|
||||
* Resolve one validated wire identity, synchronously or asynchronously.
|
||||
* @param id - validated wire identity.
|
||||
* @returns the Host object, or `undefined` when unavailable.
|
||||
*/
|
||||
export type TypeRTLookupResolver<Host = unknown, Wire = unknown> = (
|
||||
id: Wire,
|
||||
) => Host | undefined | Promise<Host | undefined>
|
||||
|
||||
/** Runtime provider for one declared Host object lookup. */
|
||||
export interface TypeRTLookupProvider<Host = unknown, Wire = unknown> {
|
||||
/** Source parameter name recognized by the SRC weak parser. */
|
||||
readonly parameter: string
|
||||
@@ -187,11 +196,11 @@ export interface TypeRTLookupProvider<Host = unknown, Wire = unknown> {
|
||||
/** Canonical wire type symbol used by strict generation. */
|
||||
readonly wireTypeSymbol: string
|
||||
/**
|
||||
* Resolve a wire identity to the current live Host object.
|
||||
* Resolve a wire identity through the provider's default policy.
|
||||
* @param id - validated wire identity.
|
||||
* @returns the live object, or `undefined` when it is unavailable.
|
||||
* @returns the object, `undefined` when unavailable, or either asynchronously.
|
||||
*/
|
||||
resolve(id: Wire): Host | undefined
|
||||
resolve(id: Wire): Host | undefined | Promise<Host | undefined>
|
||||
}
|
||||
|
||||
/** Stable wire declaration retained after a lookup provider unloads. */
|
||||
@@ -304,6 +313,20 @@ export interface TypeRTLookupRegistry {
|
||||
TypeRTLookupWire<TypeRTLookupMap[K]>
|
||||
>,
|
||||
): TypeRTDisposer
|
||||
/**
|
||||
* Replace one provider's default resolution policy while this contribution is active.
|
||||
* Configuration may precede provider registration; without a live provider, `get()` remains unavailable.
|
||||
* @param key - lookup key whose wire declaration remains provider-owned.
|
||||
* @param resolver - composition-owned resolver used by every lookup of this key.
|
||||
* @returns disposer restoring the provider's default resolver.
|
||||
*/
|
||||
configure<K extends StringKeyOf<TypeRTLookupMap>>(
|
||||
key: K,
|
||||
resolver: TypeRTLookupResolver<
|
||||
TypeRTLookupHost<TypeRTLookupMap[K]>,
|
||||
TypeRTLookupWire<TypeRTLookupMap[K]>
|
||||
>,
|
||||
): TypeRTDisposer
|
||||
/**
|
||||
* Look up one provider by runtime key.
|
||||
* @param key - descriptor lookup key.
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -3838,6 +3838,12 @@ importers:
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
'@deepseek-ai/dsh-type-meta':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/type-meta
|
||||
'@deepseek-ai/dsh-typert-registry':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/registry
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:^
|
||||
version: link:../../ui/user-approval
|
||||
|
||||
Reference in New Issue
Block a user