diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 752a5d4c8b..bd83c38a3e 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -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: 91ab8e44ff8aedf666fe3426b85b54491deb340c -2026-08-02-typert-remote-method-calls.zh.md: 73abd53109d871076aa41af39825c80c35ac3f26 +2026-08-02-typert-remote-method-calls.md: 4268539ecf0d40a9e8080e0571992cc2c5d724af +2026-08-02-typert-remote-method-calls.zh.md: f9f426f2fb80c74cb9ebaef15e801ccfcf67e027 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 91ab8e44ff..4268539ecf 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -76,6 +76,8 @@ An endpoint selects exactly one invocation mode. A flow that needs an explicit ` Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides declaration protocols for decorators, `bindTypeRTGateway()`, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. + ## Decorators and the explicit Gateway facet A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. `typertGateway` is the sole explicit marker that a Service has joined the Gateway, making this capability visible on both the business class and its runtime instance. @@ -126,6 +128,7 @@ InvocationDescriptor { parameters: [ { name, wire, source: json | lookup, lookup?, codec } ] + cancellation?: { parameter: 'signal' } result: codec sourceLocation } @@ -135,7 +138,7 @@ InvocationDescriptor { The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error. -Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. +Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. A cancellation descriptor reserves only the final `signal` position and keeps it outside named `args`; Connection or a direct Gateway caller supplies the actual signal. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys. @@ -239,6 +242,7 @@ interface TypeRTRemoteNamespace$676f616c73 { create: ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -246,6 +250,7 @@ interface TypeRTRemoteMap { 'goals/create': ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -256,6 +261,7 @@ interface TypeRTRemoteNamespaceMap { interface TypeRTRemoteContextMap { 'agent:goals/create': ( request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } ``` @@ -296,7 +302,7 @@ Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client` `ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. -The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args })`. +The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. @@ -332,11 +338,11 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteContext()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. -For example, `@Remote('create') remoteExportCreate(agent, request)` resolves to the external method `create`, implementation member `remoteExportCreate`, and two top-level parameters. Lookup registration rewrites `agent` to the wire field `agentId`, while `request` is passed as a same-named JSON parameter. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. +For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. -LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, and result codecs, then generates strict descriptors. +LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, result codecs, and that a reserved final `signal` parameter has the global `AbortSignal` type, then generates strict descriptors. At runtime, LIB only loads definitions from `lib`; it does not start the TypeScript compiler. The subsequent association of Services, lookup, Context resolution, invocation, and response encoding in the Host Gateway does not depend on whether a descriptor came from permissive SRC parsing or strict LIB generation. @@ -348,17 +354,18 @@ The Host Gateway registers one `/api` interceptor with Connection and does not m Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. -An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order. +An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order, followed by the carrier signal when the descriptor declares cancellation. A `@RemoteContext('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. ```text -ctx.typertGateway.invoke({ namespace, method, args }) +ctx.typertGateway.invoke({ namespace, method, args, signal }) → 查找本地 InvocationDescriptor 与 live receiver → 按参数 descriptor 读取具名 wire 字段 → codec 解码普通值或 lookup ID → lookup provider 把 ID 解析为活对象 → direct 使用原 Service;context 先解析 scoped Context 和 Service +→ cancellation descriptor 存在时把 signal 追加到业务参数末尾 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) → result codec 编码业务结果 ``` @@ -373,10 +380,10 @@ Connection owns one `/api` route on the HTTP Server. The Gateway mounts a synchr ctx.connection.rpc.intercept( '/api', endpoint => ownsRemoteEndpoint(endpoint), - (endpoint, payload) => { + (endpoint, payload, signal) => { const { namespace, method } = parseEndpoint(endpoint) const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) + return ctx.typertGateway.invoke({ namespace, method, args, signal }) }, ) ``` @@ -405,15 +412,16 @@ The Remote payload is a named JSON object, not a positional array, and does not The complete path is: ```text -ctx.api.goals.create(sessionId, request) +ctx.api.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api', 'goals/create', { args }) +→ Client 合并 caller signal 与 contribution mount lifetime +→ ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) → Connection 创建 rpcId 和既有 client-request envelope → 当前 carrier 发送 POST /api/goals/create → Connection Host half 执行共享 trust,再由 bridge 创建标准 Request → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler -→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) -→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(..., request.signal) +→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId → Client result codec 验证并返回 CreateGoalResult @@ -421,7 +429,7 @@ ctx.api.goals.create(sessionId, request) 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. -The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. 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. +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. ## Connection and protocol boundaries @@ -475,6 +483,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp - 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. - 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`. - Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged. ## Consequences @@ -499,4 +508,4 @@ 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. -Connection supplies an `AbortSignal`, but Remote business signatures have no cancellation parameter. A client disconnect therefore does not cancel business work; cancellation remains deferred rather than being implied by the transport handler shape. +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. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 73abd53109..f9f426f2fb 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -76,6 +76,8 @@ export class ScopedGoalService extends Service { 业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 decorator、`bindTypeRTGateway()`、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 + ## Decorator 与显式 Gateway facet Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。`typertGateway` 是 Service 加入 Gateway 的唯一显式标志,使业务类和运行时实例都能直接看出这项能力。 @@ -126,6 +128,7 @@ InvocationDescriptor { parameters: [ { name, wire, source: json | lookup, lookup?, codec } ] + cancellation?: { parameter: 'signal' } result: codec sourceLocation } @@ -135,7 +138,7 @@ InvocationDescriptor { 严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 -参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 +参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。取消 descriptor 只保留最后一个 `signal` 位置,并使其不进入具名 `args`;实际 signal 由 Connection 或直接调用 Gateway 的调用方提供。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。 @@ -239,6 +242,7 @@ interface TypeRTRemoteNamespace$676f616c73 { create: ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -246,6 +250,7 @@ interface TypeRTRemoteMap { 'goals/create': ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -256,6 +261,7 @@ interface TypeRTRemoteNamespaceMap { interface TypeRTRemoteContextMap { 'agent:goals/create': ( request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } ``` @@ -296,7 +302,7 @@ Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接 `ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args })`。 +API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 @@ -332,11 +338,11 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完 SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 -例如 `@Remote('create') remoteExportCreate(agent, request)` 解析为外部方法 `create`、实现成员 `remoteExportCreate` 和两个顶层参数;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 +例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。 -LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec 和结果 codec,并生成严格 descriptor。 +LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec、结果 codec,以及保留的最后一个 `signal` 参数是否具有全局 `AbortSignal` 类型,并生成严格 descriptor。 LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler。Host Gateway 后续的 Service 关联、lookup、Context 解析、调用和响应编码不区分 descriptor 来自 SRC 弱解析还是 LIB 严格生成。 @@ -348,17 +354,18 @@ Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 -普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员。 +普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员;若 descriptor 声明取消,则在这些参数之后追加 carrier signal。 `@RemoteContext('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 ```text -ctx.typertGateway.invoke({ namespace, method, args }) +ctx.typertGateway.invoke({ namespace, method, args, signal }) → 查找本地 InvocationDescriptor 与 live receiver → 按参数 descriptor 读取具名 wire 字段 → codec 解码普通值或 lookup ID → lookup provider 把 ID 解析为活对象 → direct 使用原 Service;context 先解析 scoped Context 和 Service +→ cancellation descriptor 存在时把 signal 追加到业务参数末尾 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) → result codec 编码业务结果 ``` @@ -373,10 +380,10 @@ Connection 在 HTTP Server 上持有唯一 `/api` route。Gateway 把同步 endp ctx.connection.rpc.intercept( '/api', endpoint => ownsRemoteEndpoint(endpoint), - (endpoint, payload) => { + (endpoint, payload, signal) => { const { namespace, method } = parseEndpoint(endpoint) const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) + return ctx.typertGateway.invoke({ namespace, method, args, signal }) }, ) ``` @@ -405,15 +412,16 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` 完整链路为: ```text -ctx.api.goals.create(sessionId, request) +ctx.api.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api', 'goals/create', { args }) +→ Client 合并 caller signal 与 contribution mount lifetime +→ ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) → Connection 创建 rpcId 和既有 client-request envelope → 当前 carrier 发送 POST /api/goals/create → Connection Host half 执行共享 trust,再由 bridge 创建标准 Request → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler -→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) -→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(..., request.signal) +→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId → Client result codec 验证并返回 CreateGoalResult @@ -421,7 +429,7 @@ ctx.api.goals.create(sessionId, request) Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 -Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 +Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 ## Connection 与协议边界 @@ -475,6 +483,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 - Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 - 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 +- 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。 - 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。 ## 后果 @@ -499,4 +508,4 @@ Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接 `hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。 -Connection 提供 `AbortSignal`,但 Remote 业务签名没有取消参数。因此 Client 断连不会取消业务工作;取消仍作为后续工作,而不能由 transport handler 的形状暗示已经支持。 +支持取消的 Remote 签名会接收 Connection 请求的 `AbortSignal`,因此 HTTP 断连或 Client 侧 abort 能在不进入 JSON 协议的情况下传递到正在进行的业务工作。取消仍是协作式的:没有保留末位参数的方法会继续运行;收到 signal 的方法必须将它传给自身支持取消的操作,或自行观测它。 diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index be40eeb20a..a5484d06c4 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/typert.i18n.yaml @@ -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: 9f5c63fc554a43fd0248ed08a64dcff566c83b58 -typert.zh.md: 2b74c8325a510ba39d134fa6d463dab273239772 +typert.md: da6e229ff6a2300c36f5734ad05c621a5e63082d +typert.zh.md: b3b0e8897756b5b4f9b645522cc5a1b27eac1d33 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index 9f5c63fc55..da6e229ff6 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -38,7 +38,7 @@ interface TypeRTLookupDefinition { ## Invocation descriptors -An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. +An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -100,6 +100,11 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ @@ -130,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields; 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, although the current RPC adapter folds them into the transport's `internal` error code. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -141,6 +146,8 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } ``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 2b74c8325a..b3b0e88977 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -38,7 +38,7 @@ interface TypeRTLookupDefinition { ## 调用 descriptor -`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。 +`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -100,6 +100,11 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ @@ -130,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求携带精确的具名 wire 字段;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -141,6 +146,8 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4fe2b12323..d8d067ce3e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2097,7 +2097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationDescriptor', - declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', }, { name: 'InvocationParameterDescriptor', @@ -2109,7 +2109,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvokeRemoteRequest', - declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n}', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly signal?: AbortSignal;\n}', }, { name: 'JsonSchemaNode', diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index 747aa65665..a1c22433f3 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/api-gateway/README.i18n.yaml @@ -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: cc80bb19fec15414aa0857154a8a36fb4f642672 -README.zh.md: 6febb1cfe4fc7fa4c5a17e1e4f6a21e2ee03e295 +README.md: 9cb6e7e1c0a23789ab4ab2c999b5a6c2d4cd32f9 +README.zh.md: 609580ceb77649ba8df6103093a72092c9ccc8a1 diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index cc80bb19fe..9cb6e7e1c0 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -12,11 +12,13 @@ Strict mode reads generated invocation descriptors from `ctx.typert.local`. Look 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 cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. + ## Client service: `ClientApi` (ctx key: `api`) `ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. -Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 6febb1cfe4..609580ceb7 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -12,11 +12,13 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 +支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 + ## Client 服务:`ClientApi`(ctx key:`api`) `ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 -每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 5cd8ab75d1..292df54152 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -223,9 +223,13 @@ class ClientApiService extends Service implements ClientApi { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) - if (values.length !== expected) { + const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 + if (values.length !== expected && !hasCallerSignal) { + const contract = descriptor.cancellation === undefined + ? `${String(expected)} argument(s)` + : `${String(expected)} business argument(s) plus an optional AbortSignal` throw new Error( - `client api: ${endpoint} expected ${String(expected)} argument(s), got ${String(values.length)}`, + `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`, ) } const args: Record = {} @@ -248,7 +252,11 @@ class ClientApiService extends Service implements ClientApi { }) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) - const result = await connection.rpc.call('/api', endpoint, { args }, token.abort.signal) + const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined + const signal = callerSignal === undefined + ? token.abort.signal + : AbortSignal.any([token.abort.signal, callerSignal]) + const result = await connection.rpc.call('/api', endpoint, { args }, signal) if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) if (!result.ok) throw remoteFailure(endpoint, result.error) return parse(descriptor.result, result.value, endpoint, 'result') diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index 64d5715719..c4a61cef8d 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -36,6 +36,7 @@ interface ResolvedBinding { } type ConnectionRpcResult = Awaited> +const NEVER_ABORTED_SIGNAL = new AbortController().signal /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -129,6 +130,7 @@ 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)) + 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 if (typeof method !== 'function') { @@ -146,13 +148,12 @@ export class TypertGatewayService extends Service implements TypertGateway { private async dispatchRpc( endpoint: string, payload: unknown, - _signal: AbortSignal, + signal: AbortSignal, ): Promise { - // Remote methods have no cancellation parameter yet, so disconnects do not cancel business work. - return this.invokeRpc(endpoint, payload) + return this.invokeRpc(endpoint, payload, signal) } - private async invokeRpc(endpoint: string, payload: unknown): Promise { + private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { try { const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { @@ -171,6 +172,7 @@ export class TypertGatewayService extends Service implements TypertGateway { namespace, method, args: payload.args, + signal, }) return { ok: true, value } } catch (error) { @@ -226,9 +228,22 @@ export class TypertGatewayService extends Service implements TypertGateway { endpoint: string, ): InvocationDescriptor { const names = methodParameterNames(binding.service, marker.method, endpoint) + const signalIndex = names.indexOf('signal') + if (signalIndex >= 0 && signalIndex !== names.length - 1) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + 'SRC cancellation parameter signal must be the final parameter', + { field: 'signal' }, + ) + } + const cancellation = signalIndex >= 0 + ? { parameter: 'signal' as const } + : undefined + const businessNames = cancellation === undefined ? names : names.slice(0, -1) const parameters: InvocationParameterDescriptor[] = [] const wires = new Set() - for (const name of names) { + for (const name of businessNames) { const matches = this.ctx.typert.lookups.definitions() .filter(definition => definition.parameter === name) if (matches.length > 1) { @@ -295,6 +310,7 @@ export class TypertGatewayService extends Service implements TypertGateway { ...(marker.method === method ? {} : { implementation: marker.method }), invocation: receiver, parameters, + ...(cancellation === undefined ? {} : { cancellation }), result: { mode: 'src-json' }, } } diff --git a/packages/host/api-gateway/src/types.ts b/packages/host/api-gateway/src/types.ts index eea2bdc4f1..b7f36eb340 100644 --- a/packages/host/api-gateway/src/types.ts +++ b/packages/host/api-gateway/src/types.ts @@ -11,6 +11,8 @@ export interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } /** Stable infrastructure and boundary failures emitted before or after business execution. */ diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 2e00d29c0d..3ad00ff0fc 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -17,11 +17,18 @@ declare module '@deepseek-ai/dsh-type-meta' { } interface TypeRTRemoteMap { - 'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'goals/create': ( + agentId: string, + request: { readonly objective: string }, + signal?: AbortSignal, + ) => Promise<{ readonly ref: string }> } interface TypeRTRemoteContextMap { - 'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'fixture:goals/create': ( + request: { readonly objective: string }, + signal?: AbortSignal, + ) => Promise<{ readonly ref: string }> 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> } @@ -58,6 +65,7 @@ function directDescriptor(): InvocationDescriptor { source: 'json', codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema }, }], + cancellation: { parameter: 'signal' }, result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema }, } } @@ -114,6 +122,19 @@ describe('Client TypeRT API', () => { { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, expect.any(AbortSignal), ) + const callerAbort = new AbortController() + await expect(ctx.api.goals.create( + 'agent-1', + { objective: 'cancel me' }, + callerAbort.signal, + )).resolves.toEqual({ ref: 'goal-1' }) + const combinedSignal = call.mock.calls.at(-1)?.[3] + expect(combinedSignal).toBeInstanceOf(AbortSignal) + expect(combinedSignal).not.toBe(callerAbort.signal) + const cancellation = new Error('caller cancelled') + callerAbort.abort(cancellation) + expect(combinedSignal?.aborted).toBe(true) + expect(combinedSignal?.reason).toBe(cancellation) await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) @@ -299,10 +320,18 @@ describe('Client TypeRT API', () => { .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) const descriptor = directDescriptor() - const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] }) + const dispose = ctx.api.mount({ + package: '@fixture/goals', + descriptors: [descriptor, contextDescriptor()], + }) const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise + const goals = (ctx as FixtureContext).goals + const rename = goals.rename as unknown as (...args: unknown[]) => Promise - await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1') + await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') + await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) + .rejects.toThrow('got 4') + await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) .rejects.toThrow('no Client Context binder') diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 4aeadeedb8..c05bfefb93 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -45,6 +45,7 @@ const emptyModel: TypertContribution['model'] = { class GoalService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'goals') readonly calls: string[] = [] + lastSignal: AbortSignal | undefined nextResult: unknown = undefined businessError: Error | undefined @@ -53,8 +54,9 @@ class GoalService extends Service { } @Remote - create(agent: FixtureAgent, request: { readonly title: string }): unknown { + create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown { this.calls.push('create') + this.lastSignal = signal return { agentId: agent.id, title: request.title, @@ -224,6 +226,19 @@ class RestParameterService extends Service { } } +class NonFinalSignalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'nonFinalSignal', { namespace: 'invalid-signal' }) + + constructor(ctx: Context) { + super(ctx, 'nonFinalSignal') + } + + @Remote + run(signal: AbortSignal, value: string): string { + return signal.aborted ? '' : value + } +} + class WrongBindingService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' }) @@ -334,13 +349,24 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, agent) registerStrict(ctx, [createDescriptor()]) const caller = ctx.extend({ fixtureScope: 'direct-caller' }) + const abort = new AbortController() await expect(caller.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: ' ship ' } }, + signal: abort.signal, })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' }) expect(service.calls).toEqual(['create']) + expect(service.lastSignal).toBe(abort.signal) + + await expect(caller.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'again' } }, + })).resolves.toEqual({ agentId: 'agent-1', title: 'again', scope: 'direct-caller' }) + expect(service.lastSignal).toBeInstanceOf(AbortSignal) + expect(service.lastSignal?.aborted).toBe(false) }) it('resolves strict Remote Context identity without adding a business argument', async () => { @@ -358,16 +384,19 @@ describe('TypertGatewayService', () => { }) it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => { - const { ctx } = await setup() + const { ctx, service } = await setup() const agent = { id: 'agent-1' } registerAgentLookup(ctx, agent) const caller = ctx.extend({ fixtureScope: 'direct-src' }) + const abort = new AbortController() await expect(caller.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, + signal: abort.signal, })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' }) + expect(service.lastSignal).toBe(abort.signal) }) it('does not downgrade an observed SRC lookup after its provider unloads', async () => { @@ -605,6 +634,7 @@ describe('TypertGatewayService', () => { { plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } }, { plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } }, { plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } }, + { plugin: NonFinalSignalService, namespace: 'invalid-signal', args: { value: 'x' } }, ] as const for (const testCase of cases) { const ctx = await setupGateway() @@ -874,7 +904,8 @@ describe('TypertGatewayService', () => { expect(connection.matches?.('goals')).toBe(false) expect(connection.matches?.('goals/missing')).toBe(false) expect(connection.matches?.('legacy/list')).toBe(false) - const signal = new AbortController().signal + const abort = new AbortController() + const signal = abort.signal const handler = connection.handler if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') await expect(handler('goals/create', { @@ -883,6 +914,10 @@ describe('TypertGatewayService', () => { ok: true, value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' }, }) + const service = rawGoalService(ctx) + expect(service.lastSignal).toBe(signal) + abort.abort(new Error('client disconnected')) + expect(service.lastSignal?.aborted).toBe(true) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -904,7 +939,6 @@ describe('TypertGatewayService', () => { expect(result.error.message).toContain('plain-object args field') } - const service = rawGoalService(ctx) service.businessError = 'non-error failure' as unknown as Error await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({ ok: false, @@ -1099,6 +1133,7 @@ function createDescriptor(): InvocationDescriptor { })), }, ], + cancellation: { parameter: 'signal' }, result: strictCodec('@fixture/gateway#CreateResult', z.object({ agentId: z.string(), title: z.string(), diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index f430d757fb..87a23f17f5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -963,8 +963,9 @@ class FaceAnalyzer { const lookups = this.lookupDeclarations() const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup])) const parameters: InvocationParameterModel[] = [] + let cancellation: InvocationModel['cancellation'] const wires = new Set() - for (const parameter of method.parameters) { + for (const [parameterIndex, parameter] of method.parameters.entries()) { if (!ts.isIdentifier(parameter.name)) { this.fail(parameter, 'Remote parameters must use identifier bindings') } @@ -973,6 +974,18 @@ class FaceAnalyzer { if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') const authoredType = this.requiredType(parameter, parameter.type, 'parameter') + const cancellationName = parameter.name.text === 'signal' + const cancellationType = this.isGlobalAbortSignal(authoredType) + if (cancellationName || cancellationType) { + if (!cancellationName || !cancellationType) { + this.fail(parameter, 'Remote cancellation must use a parameter named signal with the global AbortSignal type') + } + if (parameterIndex !== method.parameters.length - 1) { + this.fail(parameter, 'Remote cancellation signal must be the final parameter') + } + cancellation = { parameter: 'signal' } + continue + } const hostSymbol = this.symbolAtType(authoredType) const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) let modeled: InvocationParameterModel @@ -1065,6 +1078,7 @@ class FaceAnalyzer { invocation: receiver, ...(scope === undefined ? {} : { scope }), parameters, + ...(cancellation === undefined ? {} : { cancellation }), result: this.remoteBoundary( resultType, `${registration.name}#${binding.namespace}/${exportedMethod}:result`, @@ -1181,6 +1195,13 @@ class FaceAnalyzer { return resultType } + private isGlobalAbortSignal(type: ts.TypeNode): boolean { + const symbol = this.symbolAtType(type) + if (symbol?.name !== 'AbortSignal') return false + return symbol.declarations?.some(declaration => + isStandardLibraryFile(declaration.getSourceFile().fileName)) === true + } + private lookupDeclarations(): readonly StaticLookupDeclaration[] { if (this.staticLookups !== undefined) return this.staticLookups const byKey = new Map() diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 63b1ee7ace..c8b9ab4195 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -315,6 +315,9 @@ export class FaceModelEmitter { lines.push(' },') }) lines.push(' ],') + if (invocation.cancellation !== undefined) { + lines.push(" cancellation: { parameter: 'signal' },") + } lines.push(` result: ${indent(strictCodec( invocation.result, schemas.boundary(resultBoundaryKey(invocation)), @@ -459,6 +462,7 @@ export class FaceModelEmitter { const parameters = invocation.parameters.filter(parameter => !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) return `(${parameters.join(', ')}) => Promise<${result}>` } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 7f15c8407c..81bc6a91a1 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -140,6 +140,9 @@ export interface InvocationModel { readonly wire: string } readonly parameters: readonly InvocationParameterModel[] + readonly cancellation?: { + readonly parameter: 'signal' + } readonly result: RemoteBoundaryModel readonly location: SourceLocation } diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index 816a13a5a7..115b3b87a6 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -12,7 +12,8 @@ export class GoalService { readonly typertGateway = bindTypeRTGateway(this, 'goals') @Remote - async create(agent: Agent, request: CreateGoalRequest): Promise { + async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() return { ref: `${agent.id}:${request.title}` } } diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index cb6e6e6060..d5838f39ce 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -16,6 +16,7 @@ interface RuntimeSchema { interface RuntimeDescriptor { readonly id: string + readonly cancellation?: { readonly parameter: 'signal' } readonly parameters: readonly { readonly wire: string readonly codec: { readonly schema: RuntimeSchema } @@ -83,6 +84,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' }, }, ], + cancellation: { parameter: 'signal' }, result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, }) expect(model.invocations[1]).toMatchObject({ @@ -107,12 +109,12 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(artifact?.js).toContain('invocations: [') expect(artifact?.remote?.dts).toContain( - "'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise", + "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise", ) expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:') expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73") expect(artifact?.remote?.dts).toContain( - "'agent:goals/create': (request: CreateGoalRequest) => Promise", + "'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise", ) expect(artifact?.remote?.dts).toContain( "'agent:goals/rename': (request: RenameGoalRequest) => Promise", @@ -124,6 +126,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote') const create = generated.TYPERT_REMOTE.descriptors[0] + expect(create?.cancellation).toEqual({ parameter: 'signal' }) expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true) expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false) expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true) @@ -234,8 +237,8 @@ export type GenericResult = { edit: (source: string) => source .replace('export class GoalService', 'export abstract class GoalService') .replace( - ' async create(agent: Agent, request: CreateGoalRequest): Promise {\n return { ref: `${agent.id}:${request.title}` }\n }', - ' abstract create(agent: Agent, request: CreateGoalRequest): Promise', + ' async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise {\n signal.throwIfAborted()\n return { ref: `${agent.id}:${request.title}` }\n }', + ' abstract create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise', ), message: 'Remote methods must have a concrete implementation', }, @@ -267,6 +270,24 @@ export type GenericResult = { edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), message: 'Remote parameters cannot be optional', }, + { + name: 'wrong cancellation type', + edit: (source: string) => source.replace('signal: AbortSignal', 'signal: string'), + message: 'cancellation must use a parameter named signal with the global AbortSignal type', + }, + { + name: 'wrong cancellation name', + edit: (source: string) => source.replace('signal: AbortSignal', 'abort: AbortSignal'), + message: 'cancellation must use a parameter named signal with the global AbortSignal type', + }, + { + name: 'non-final cancellation', + edit: (source: string) => source.replace( + 'agent: Agent, request: CreateGoalRequest, signal: AbortSignal', + 'agent: Agent, signal: AbortSignal, request: CreateGoalRequest', + ), + message: 'cancellation signal must be the final parameter', + }, ])('rejects $name', ({ edit, message }) => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', edit) @@ -399,12 +420,14 @@ declare const create: TypeRTRemoteMap['goals/create'] declare const createScoped: TypeRTRemoteContextMap['agent:goals/create'] declare const rename: TypeRTRemoteContextMap['agent:goals/rename'] const created: Promise = create('agent-1', { title: 'ship' }) +const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise = createScoped({ title: 'ship' }) const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) declare const ctx: { api: TypeRTRemoteNamespaceMap } const navigated: Promise = ctx.api.goals.create('agent-1', { title: 'navigate' }) void contribution void created +void cancellable void createdScoped void renamed void navigated diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index efe0fa6f94..575d066e0d 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -226,6 +226,12 @@ function requireInvocation(pkgName: string, value: unknown): void { parameters.set(wire, parameter) requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`) } + if (invocation.cancellation !== undefined) { + const cancellation = requireObject(pkgName, invocation.cancellation, `invocation "${id}" cancellation`) + if (cancellation.parameter !== 'signal') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" cancellation parameter must be "signal"`) + } + } if (invocation.scope !== undefined) { if (receiver.kind !== 'direct') { throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index ec407f82d9..750cc92e57 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -83,6 +83,7 @@ function invocationTypertSource(pkgName: string): string { ' name: \'request\', wire: \'request\', source: \'json\',', ` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`, ' }],', + " cancellation: { parameter: 'signal' },", ` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`, ' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },', ' }],', @@ -157,6 +158,7 @@ describe('typert loader', () => { id: '@fixture/invocation#goals/create', invocation: { kind: 'direct' }, parameters: [{ wire: 'request', source: 'json' }], + cancellation: { parameter: 'signal' }, sourceLocation: { file: 'src/index.ts', line: 8, column: 3 }, }) expect(descriptor?.parameters[0]?.codec.mode).toBe('strict') @@ -502,6 +504,9 @@ describe('validateTypertManifest', () => { const descriptor = strictInvocation() const manifest = { ...base, invocations: [descriptor] } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + const cancellable = { ...descriptor, cancellation: { parameter: 'signal' } } + expect(validateTypertManifest('pkg', { ...base, invocations: [cancellable] }).invocations) + .toEqual([cancellable]) const scoped = { ...descriptor, scope: { context: 'agent', wire: 'agentId' }, @@ -526,6 +531,14 @@ describe('validateTypertManifest', () => { ...base, invocations: [{ ...descriptor, result: { mode: 'src-json' } }], })).toThrow('result codec must use a strict codec') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...descriptor, cancellation: null }], + })).toThrow('cancellation must be an object') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...descriptor, cancellation: { parameter: 'abort' } }], + })).toThrow('cancellation parameter must be "signal"') expect(() => validateTypertManifest('pkg', { ...base, invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }], diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 6749cdbeb9..229dc7affc 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -562,6 +562,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) } + if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') { + throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`) + } if (descriptor.scope !== undefined) { if (descriptor.invocation.kind !== 'direct') { throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 51e7594749..5603ce8954 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -411,6 +411,7 @@ describe('TypertRegistry', () => { ...invocation('@fixture/remote#strict'), implementation: 'remoteExportCreate', parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }], + cancellation: { parameter: 'signal' }, result: strict, } const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] }) @@ -420,6 +421,10 @@ describe('TypertRegistry', () => { [{ ...invocation(), id: '' }, 'invocation id'], [{ ...invocation(), namespace: 'bad/name' }, 'namespace'], [{ ...invocation(), implementation: 'bad/name' }, 'implementation method'], + [{ + ...invocation(), + cancellation: { parameter: 'abort' } as unknown as { readonly parameter: 'signal' }, + }, 'cancellation parameter'], [{ ...invocation(), parameters: [ diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 90d93152b7..9751c4c088 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/README.i18n.yaml @@ -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: 9dd8dadd07b219c7471c8851262958d4d9e96a43 -README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e +README.md: 95716446c01c7fd510cdf55a82509b5b8af6f3ae +README.zh.md: 0d30b3122265d9bb3caa289345f843fe67377be3 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 9dd8dadd07..95716446c0 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -11,6 +11,8 @@ Compiler-independent declarations shared by business packages, generated TypeRT - `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace. - `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. +A Host method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter. `InvocationDescriptor.cancellation` records that reserved injection point; the signal never becomes a JSON parameter or lookup field. SRC recognizes the final parameter name, while strict generation also verifies the global `AbortSignal` type. + Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field. ## TypeRT protocol diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 5716f56d98..0d30b31222 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -11,6 +11,8 @@ - `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。 - `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 +Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用协作式取消。`InvocationDescriptor.cancellation` 记录这个保留的注入点;signal 绝不会成为 JSON 参数或 lookup 字段。SRC 识别末位参数名,严格生成还会校验它是否具有全局 `AbortSignal` 类型。 + 装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。 ## TypeRT 协议 diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index f9ed7ffa97..6de5c7f823 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -157,6 +157,11 @@ export interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */