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 1e4aeaabd7..341bf44923 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: 3d5a79fd4a26f7d232dcc7635625899e2eb9df6b -2026-08-02-typert-remote-method-calls.zh.md: 3d6ec680ba97a532f18219670e8dba799a94ed7b +2026-08-02-typert-remote-method-calls.md: a8254090e042e4b359ae74fc5c19bad8abc5ef89 +2026-08-02-typert-remote-method-calls.zh.md: f1b7e5f9c61b474379962ce007e5d6bb966e5ebd 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 3d5a79fd4a..a8254090e0 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 @@ -18,11 +18,11 @@ The Host and Browser Client use separate TypeScript Programs because each side a A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteContext()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. -The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. +The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client Remote Service. The projection and Remote abstraction remain platform-independent so that a future TUI can reuse them. -`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. +`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.remote`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. -`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientApi` contract through Cordis rather than importing the concrete Gateway implementation. +`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientRemote` contract through Cordis rather than importing the concrete Gateway implementation. ## Components and Cordis services @@ -33,12 +33,12 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | | Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback | -| API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | +| API Gateway's Client face | `ctx.remote`, `ctx.remote.` | Mounts Remote contributions, materializes each namespace as a traced `remote.` child Service, and delegates canonical calls to `ctx.connection.rpc` | | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | | Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath | -The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client API does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Client API namespaces. +The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client Remote does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Remote namespaces. ## Business declarations @@ -121,7 +121,7 @@ The Client also registers an `agent` Context binder. The binder only retrieves a ## InvocationDescriptor -TypeRT, the permissive SRC parser, Host Gateway, and Client API exchange one canonical description: +TypeRT, the permissive SRC parser, Host Gateway, and Client Remote exchange one canonical description: ```text InvocationDescriptor { @@ -141,7 +141,7 @@ InvocationDescriptor { } ``` -`method` is the external short name used by the endpoint and Client API; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. +`method` is the external short name used by the endpoint and Client Remote; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. 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. @@ -179,9 +179,9 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file. -Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. +Remote methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. -TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client API may use it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. +TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client Remote uses it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by TypeRT need no additional names. @@ -238,7 +238,7 @@ This import brings the `.d.ts` map augmentation into the current TypeScript proj The business package's published files must include both `lib/typert.remote-client.d.ts.map` and the `src` file referenced by that map. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it together with the source. -Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the API Service. +Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the Client Remote Service. Workspace resolution for `/remote` must explicitly target generated `lib` artifacts and must not let a general package-to-`src` paths rule redirect it to Host source. Ordinary business imports may continue resolving to SRC or LIB according to each environment's existing rules. @@ -275,18 +275,18 @@ interface TypeRTRemoteContextMap { } ``` -`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root API type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. +`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root Remote type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. TypeRT projects `TypeRTRemoteContextMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: ```text -api.goals.create(agentId, request) -agent.goals.create(request) +ctx.remote.goals.create(agentId, request) +agentCtx.remote.goals.create(request) ``` -The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. In this phase, only the Client Agent Context gains `goals`; the Root Context does not. A future TUI must preserve the same Scope restriction. +The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. -`RemoteApi` remains platform-independent, and the Browser Client uses it as its `ClientApi`. If a future TUI reuses this type, it must likewise access it through a dedicated API object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. +`TypeRTClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. ## Client TypeRT and the API Gateway Client face @@ -303,39 +303,39 @@ TypeRT.remotes 已导入的 Remote contribution import goalsRemote from '@deepseek-ai/dsh-goal/remote' import sessionsRemote from '@deepseek-ai/dsh-session/remote' -ctx.api.mount(goalsRemote) -ctx.api.mount(sessionsRemote) +await ctx.remote.$mount(goalsRemote) +await ctx.remote.$mount(sessionsRemote) ``` -Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientApi` contract and Cordis `ctx.api` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. +Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientRemote` contract and Cordis `ctx.remote` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. -`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. +`ctx.remote.$mount()` registers a contribution with `TypeRT.remotes`, installs its namespace Services and concrete methods, and resolves only after they are ready. 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 }, 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. +The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.` child Service. 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. +Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. 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. ```text -root ctx.api.goals.create(agentId, request) +root ctx.remote.goals.create(agentId, request) → direct descriptor → ctx.connection.rpc.call('/api', 'goals/create', { args }) -agent.goals.create(request) - → tracker 将 namespace Service rebind 到 agent Context +agentCtx.remote.goals.create(request) + → remote.goals accessor 捕获 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. +The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteContextApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. -Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. +Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The Client Remote Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. ## Cross-environment isomorphism constraints Remote API is a consumer capability, not a synonym for Browser API. The shipped runtime implements Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. -Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. +Remote DTS, Remote JS, `TypeRTClientRemote`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. @@ -421,7 +421,7 @@ 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, signal?) +ctx.remote.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } → Client 合并 caller signal 与 contribution mount lifetime → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) @@ -442,7 +442,7 @@ The Gateway does not handle per-method permissions, caller identity, idempotency ## Connection and protocol boundaries -The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. +The Client Remote Service owns Remote contributions, namespace Service materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client Remote types. The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. @@ -451,8 +451,8 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. -- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. -- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged API types to business packages through the shared `TypeRTClientApi` contract. +- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypeRTClientRemote` contract. - 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: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods. @@ -460,7 +460,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection ## 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)`. 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. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.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. @@ -482,7 +482,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h **Generate only Remote DTS, without JS.** Types would work, but the runtime could not enumerate endpoints, codecs, and Context modes without a Proxy or another hand-written registry. The same Host projection therefore emits a Remote JS contribution as well. -**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the API Service. +**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the Client Remote Service. **Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. @@ -490,7 +490,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h - Goal Service directly decorates mutation methods whose business signatures already match the Remote contract and keeps `remoteExportCreate(...)` only to adapt `GoalView` into `CreateGoalResult`, without a second route, codec, or Client method list. - A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. -- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. +- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `ctx.remote.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. @@ -509,13 +509,13 @@ The permissive SRC descriptor does not validate the internal structure of ordina Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them. -Type imports and runtime contributions have different effects. `import type {}` extends only the static API. If a real calling environment omits the value contribution, the API Service must fail with an explicit "Remote not mounted" error. +Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error. Browser and Host each hold their own Zod instances and cannot compare object identities across realms. Consistency is guaranteed only by canonical symbol keys, the same generated model, and wire behavior. A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. -Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the API exposes `fetch`, an HTTP request, or a route handle to the Gateway/API Service, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. +Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the Client Remote or Gateway exposes `fetch`, an HTTP request, or a route handle, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint. 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 3d6ec680ba..f1b7e5f9c6 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 @@ -18,11 +18,11 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 -Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 +Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client Remote Service;该投影和 Remote 抽象保持平台无关,以便未来 TUI 复用。 -`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 +`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.remote`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 -`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 契约,而不导入具体 Gateway 实现。 +`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 契约,而不导入具体 Gateway 实现。 ## 组件和 Cordis 服务 @@ -33,12 +33,12 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | | Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 | -| API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | +| API Gateway 的 Client face | `ctx.remote`、`ctx.remote.` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.` 子 Service,并把规范调用交给 `ctx.connection.rpc` | | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | | Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 | -Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client API 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Client API namespace。 +Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client Remote 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Remote namespace。 ## 业务声明 @@ -121,7 +121,7 @@ Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所 ## InvocationDescriptor -TypeRT、SRC 弱解析器、Host Gateway 和 Client API 之间只交换一种规范描述: +TypeRT、SRC 弱解析器、Host Gateway 和 Client Remote 之间只交换一种规范描述: ```text InvocationDescriptor { @@ -141,7 +141,7 @@ InvocationDescriptor { } ``` -`method` 是 endpoint 和 Client API 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 +`method` 是 endpoint 和 Client Remote 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 @@ -179,9 +179,9 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ 因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。 -Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 +Remote 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 -TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client API 可以用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 +TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client Remote 用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 TypeRT 明确支持的简单组合不需要额外命名。 @@ -238,7 +238,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' 业务 package 的发布文件必须同时包含 `lib/typert.remote-client.d.ts.map` 和 map 指向的 `src` 文件。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责把它与源码一起发布。 -仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 API Service。 +仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 Client Remote Service。 workspace 对 `/remote` 的解析必须明确指向 `lib` 生成物,不能被通用 package-to-`src` paths 规则带回 Host 源码。普通业务 import 仍可按各环境既有规则解析到 SRC 或 LIB。 @@ -275,18 +275,18 @@ interface TypeRTRemoteContextMap { } ``` -`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 API 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 +`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 Remote 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 TypeRT 把 `TypeRTRemoteContextMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: ```text -api.goals.create(agentId, request) -agent.goals.create(request) +ctx.remote.goals.create(agentId, request) +agentCtx.remote.goals.create(request) ``` -Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。本期只有 Client Agent Context 获得 `goals`,Root Context 不获得该属性;未来 TUI 复用时必须维持相同的 Scope 限制。 +Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 -`RemoteApi` 保持平台无关,Browser Client 把它作为自己的 `ClientApi`。未来 TUI 若复用该类型,也必须通过专用 API 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 +`TypeRTClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 ## Client TypeRT 与 API Gateway Client face @@ -303,39 +303,39 @@ TypeRT.remotes 已导入的 Remote contribution import goalsRemote from '@deepseek-ai/dsh-goal/remote' import sessionsRemote from '@deepseek-ai/dsh-session/remote' -ctx.api.mount(goalsRemote) -ctx.api.mount(sessionsRemote) +await ctx.remote.$mount(goalsRemote) +await ctx.remote.$mount(sessionsRemote) ``` -Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientApi` 契约和 Cordis `ctx.api` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 +Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientRemote` 契约和 Cordis `ctx.remote` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 -`ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 +`ctx.remote.$mount()` 把 contribution 注册到 `TypeRT.remotes`,安装它的 namespace Service 和具体方法,并在它们就绪后才 resolve。调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 +Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.` 子 Service 上的真实函数。函数按 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` 调用。 +带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 ```text -root ctx.api.goals.create(agentId, request) +root ctx.remote.goals.create(agentId, request) → direct descriptor → ctx.connection.rpc.call('/api', 'goals/create', { args }) -agent.goals.create(request) - → tracker 将 namespace Service rebind 到 agent Context +agentCtx.remote.goals.create(request) + → remote.goals accessor 捕获 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 +根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteContextApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 -生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 +生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。Client Remote Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 ## 跨环境同构约束 Remote API 是消费端能力,不等同于 Browser API。已交付的运行时实现 Browser Client contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 -Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 +Remote DTS、Remote JS、`TypeRTClientRemote`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 @@ -421,7 +421,7 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` 完整链路为: ```text -ctx.api.goals.create(sessionId, request, signal?) +ctx.remote.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } → Client 合并 caller signal 与 contribution mount lifetime → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) @@ -442,7 +442,7 @@ Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。 ## Connection 与协议边界 -API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 +Client Remote Service 负责 Remote contribution、namespace Service 实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client Remote 类型。 Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 @@ -451,8 +451,8 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 -- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 -- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientApi` 契约向业务包暴露合并后的 API 类型。 +- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientRemote` 契约向业务包暴露合并后的 Remote 类型。 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 - API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。 @@ -460,7 +460,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H ## 已交付范围与后续工作 -已交付的纵向链路是 `@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 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.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 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 @@ -482,7 +482,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS **只生成 Remote DTS,不生成 JS。** 类型可以成立,但运行时无法枚举 endpoint、codec 和 Context 模式,只能依赖 Proxy 或另一份手写注册表,因此同一次 Host 投影同时生成 Remote JS contribution。 -**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 API Service 显式挂载。 +**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 Client Remote Service 显式挂载。 **为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 @@ -490,7 +490,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。 - 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 -- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 +- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `ctx.remote.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`。 @@ -509,13 +509,13 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化 公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 -类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 API;真实调用环境遗漏 value contribution 时,API Service 必须以明确的“Remote 未挂载”错误失败。 +类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的“Remote 未挂载”错误失败。 Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 比较;一致性只由规范 symbol key、同一生成模型和 wire 行为保证。 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 -Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若接口把 `fetch`、HTTP request 或 route handle 暴露给 Gateway/API Service,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 +Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若 Client Remote 或 Gateway 暴露 `fetch`、HTTP request 或 route handle,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 05038eb8b9..d07272c182 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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/api-gateway.md -api-gateway.md: 090758d58306d5ea806567f0de710a1c1f5ed747 -api-gateway.zh.md: 9d7286b6b86918f3bc1e7a6cdd9bdf04447abc57 +api-gateway.md: 90aa661cc86a4f419e173560c55511c969182990 +api-gateway.zh.md: 6fcbb562b204e71d00833042ee0632bda0217940 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 090758d583..90aa661cc8 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -6,7 +6,7 @@ This is the current-state reference for the TypeRT API Gateway. It describes how ## Programming model -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`. +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.remote`. `@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. @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct Remotes appear under `ctx.api.`; when an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generator also projects the method without that identity parameter onto the corresponding scoped Context. `@RemoteContext` generates only the scoped invocation interface. +The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteContext` generates only the scoped invocation interface. ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -67,13 +67,13 @@ declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId -await ctx.api.goals.create(agentId, { objective: 'ship it' }) -await agentCtx.goals.create({ objective: 'ship it' }) +await ctx.remote.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.remote.goals.create({ objective: 'ship it' }) ``` -Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. +Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions through `ctx.remote.$mount()`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. -A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. +A future TUI can assemble the same React-independent `api-remotes` and `ctx.remote` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. ## Component responsibilities @@ -84,11 +84,11 @@ A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | | Host | `@deepseek-ai/dsh-api-remotes` | Owns the application Agent/Session identity policy and configures the corresponding TypeRT lookups | | Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | -| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | +| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.` child Services, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | | Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and current `/api` HTTP bridge | -The API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. +The API Gateway package owns the Host dispatcher and Client Remote endpoint as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. ## Strict generation pipeline @@ -106,13 +106,13 @@ Each contributing business package writes generated files to its own `lib/` dire Business packages expose the Host Loader entry through `./typert` and the Host-for-Client entry through `./remote`. The generator also validates these package exports and published-file lists; it generates artifacts only for explicit contribution packages that provide the corresponding entry. -Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.api.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. +Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.remote.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build or earliest resolvable runtime boundary fails. ## Runtime invocation -Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client API calls `connection.rpc.call('/api', '/', { args }, signal)`; the current HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. +Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the current HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler in interceptor order. The TypeRT Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; unclaimed requests fall back to the existing API Proxy. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier in the future does not require changes to Remote descriptors or the Client programming interface. @@ -128,7 +128,7 @@ When the Host starts from source through `node --import tsx/esm`, it does not ex The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteContext` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. -SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client API refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. +SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client Remote refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. ## Development mode diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 9d7286b6b8..6fcbb562b2 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -6,7 +6,7 @@ ## 编程模型 -业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。 +业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 `@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。 @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接 Remote 出现在 `ctx.api.`;当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成器还会把去掉该 identity 参数后的方法投影到对应作用域 Context。`@RemoteContext` 只生成作用域调用界面。 +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteContext` 只生成作用域调用界面。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -67,13 +67,13 @@ declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId -await ctx.api.goals.create(agentId, { objective: 'ship it' }) -await agentCtx.goals.create({ objective: 'ship it' }) +await ctx.remote.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.remote.goals.create({ objective: 'ship it' }) ``` -Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 +Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,通过 `ctx.remote.$mount()` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 -未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 +未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.remote` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 ## 组件职责 @@ -84,11 +84,11 @@ Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导 | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | | Host | `@deepseek-ai/dsh-api-remotes` | 负责应用的 Agent/Session 身份策略,并配置对应的 TypeRT lookup | | Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | -| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | +| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.` 子 Service,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | | Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与当前 `/api` HTTP bridge | -API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 +API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 ## 严格生成链路 @@ -106,13 +106,13 @@ API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口, 业务包通过 `./typert` 暴露 Host Loader 入口,通过 `./remote` 暴露 Host-for-Client 入口。生成器同时校验这些 package export 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 -Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.api.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 +Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.remote.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册,缺少任一侧都会在构建或最早可解析的运行时边界报错。 ## 运行时调用 -当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。Client API 调用 `connection.rpc.call('/api', '/', { args }, signal)`;当前 HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 +当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;当前 HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内按 interceptor 顺序分发。TypeRT Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;未认领的请求回退到既有 API Proxy。Connection 拥有传输、RPC id、响应 envelope 和 request cancellation,Gateway 只拥有 Remote 数据协议和业务分发。未来替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程界面。 @@ -128,7 +128,7 @@ Host 通过 `node --import tsx/esm` 从源码启动时不会执行 TypeRT 编译 SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteContext` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 -SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client API 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 +SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client Remote 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 ## 开发模式 diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index a6e1eb5415..75b7837687 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: a61ed8587833e03fd5c1246311e62a6ffaeb3bd0 -typert.zh.md: 18c24018f4abd644cf35185c2bd06b6980195481 +typert.md: c70e50e2fea8455eb75dfdf8c309f659ab9cb2f9 +typert.zh.md: 2cd1636d4cc8dbcfa009073b4a8e1dcc8d5897e4 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index a61ed85878..c70e50e2fe 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -126,10 +126,10 @@ interface TypeRTService { } ``` -Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientApi`. +Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientRemote`. ```ts type-equiv -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ interface TypeRTRemoteNamespaceMap {} ``` @@ -186,18 +186,18 @@ interface TypertGateway { } ``` -## Consumer API +## Consumer Remote -`ctx.api` exposes only namespaces contributed by imported `/remote` artifacts. Mounting installs the generated descriptors and concrete root/scoped methods as one fiber-owned operation; no JavaScript Proxy or Host Service type enters the consumer. +`ctx.remote` exposes only namespaces contributed by imported `/remote` artifacts. `$mount()` installs generated descriptors and concrete methods as one fiber-owned operation. Each namespace is a traced `remote.` Cordis child Service whose lifetime spans its mounted methods; no JavaScript Proxy or Host business Service type enters the consumer. ```ts type-equiv -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } ``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 18c24018f4..2cd1636d4c 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -126,10 +126,10 @@ interface TypeRTService { } ``` -生成的消费方声明会把 direct namespace 合并到 `TypeRTClientApi` 继承的 map 中。 +生成的消费方声明会把 direct namespace 合并到 `TypeRTClientRemote` 继承的 map 中。 ```ts type-equiv -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ interface TypeRTRemoteNamespaceMap {} ``` @@ -186,18 +186,18 @@ interface TypertGateway { } ``` -## 消费方 API +## 消费方 Remote -`ctx.api` 只暴露由已导入 `/remote` 产物贡献的 namespace。挂载会把生成的 descriptor 与具体的 root/scoped 方法作为一项由 fiber 持有的操作统一注册;JavaScript Proxy 与 Host 服务类型都不会进入消费方。 +`ctx.remote` 只暴露由已导入 `/remote` 产物贡献的 namespace。`$mount()` 会把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册。每个 namespace 都是可追踪的 `remote.` Cordis 子 Service,其生命周期覆盖已挂载的方法;JavaScript Proxy 与 Host 业务 Service 类型都不会进入消费方。 ```ts type-equiv -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } ``` diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b0809af72e..933f204fa0 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: f832956c4c7cbde96613a69db6c636a2246786a7 -development.zh.md: 3ae70e7135ad5faee0e37d99f55cdb41373aab2c +development.md: 37bc88c7c1cfedfbe1a93e08a4cbde833ac32372 +development.zh.md: a738e53cb3434d7930aa82107782a4c22aea1470 diff --git a/docs/development.md b/docs/development.md index f832956c4c..37bc88c7c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ Host and client stay two aggregate programs because both sides declaration-merge Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). -Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. +Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. If a relevant local check consumes built package output, build once first: diff --git a/docs/development.zh.md b/docs/development.zh.md index 3ae70e7135..a738e53cb3 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,7 +62,7 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 -业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 855eeb8eaa..6a834cdf4d 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/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/api/README.md -README.md: 0dcded5922fea1ea6676315029ba0eadd74dd3df -README.zh.md: 1b9bb9133a955d0cbef0ca91728aab1545831d94 +README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1 +README.zh.md: 87bd15fc4e5ad23ef785f7c9ee805a4aa1a35e46 diff --git a/packages/api/README.md b/packages/api/README.md index 0dcded5922..7c75e80124 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -6,10 +6,10 @@ The application-facing Remote stack. `remotes` owns BFF policy and the selected | Package | Role | ctx key | |---|---|---| -| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.api` | -| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client API endpoint | `ctx.typertGateway` / `ctx.api` | +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.remote` | +| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | -The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientApi` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. +The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientRemote` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. ## Known Limitations and Deferred Work diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 1b9bb9133a..87bd15fc4e 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -6,10 +6,10 @@ | 包 | 职责 | ctx key | |---|---|---| -| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.api` | -| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client API endpoint | `ctx.typertGateway` / `ctx.api` | +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` | +| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | -运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientApi` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 +运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientRemote` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 ## 已知限制与延期工作 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 41bbb0621f..3a9a0ba50d 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/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/api/gateway/README.md -README.md: 9e3d4d89788bbc6edebfc0c0127999fed3ed9261 -README.zh.md: 9bbd46c71185a2fbf8da163565d6c19141c079ca +README.md: e37359db71c1388667e9e61f538354711e90c0c1 +README.zh.md: 2054febb9a5423297c32b029b40a035062250aab diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 9e3d4d8978..e37359db71 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. +Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) @@ -14,13 +14,13 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. -## Client service: `ClientApi` (ctx key: `api`) +## Client service: `ClientRemote` (ctx key: `remote`) -`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. +`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. -Generated declaration merges provide the TypeScript API through the shared `TypeRTClientApi` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. +Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. ## Model Experience diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 9bbd46c711..2054febb9a 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 +为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) @@ -14,13 +14,13 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -## Client 服务:`ClientApi`(ctx key:`api`) +## Client 服务:`ClientRemote`(ctx key:`remote`) -`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 +`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 -生成的声明合并通过共享的 `TypeRTClientApi` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 +生成的声明合并通过共享的 `TypeRTClientRemote` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 ## 模型体验 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index a9343823ff..d0429339c8 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -1,37 +1,25 @@ /** * Client projection of generated TypeRT Remote descriptors. Contributions - * install concrete namespace methods; no JavaScript Proxy participates in - * lookup, invocation, or type exposure. + * install traced `remote.` services; no JavaScript Proxy + * participates in method lookup, invocation, or type exposure. */ -import { Service, symbols } from 'cordis' +import { Service } from 'cordis' import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, - TypeRTClientApi, + TypeRTClientRemote, TypeRTCodec, TypeRTDisposer, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' -type RemoteMethod = (...args: unknown[]) => Promise - interface MountToken { active: boolean readonly abort: AbortController } -interface DirectNamespaceRecord { - readonly value: Record - readonly tokens: Map -} - -interface ScopedNamespaceRecord { - readonly service: ScopedRemoteNamespace - readonly tokens: Map -} - interface ScopedProjection { readonly context: string readonly wire: string @@ -39,13 +27,36 @@ interface ScopedProjection { readonly parameterIndex?: number } -/** Typed API service augmented by generated direct Remote namespaces. */ -export type ClientApi = TypeRTClientApi +interface DirectMethod { + readonly descriptor: InvocationDescriptor + readonly token: MountToken +} + +interface ScopedMethod extends DirectMethod { + readonly projection: ScopedProjection +} + +interface RemoteMethodRecord { + direct?: DirectMethod + scoped?: ScopedMethod +} + +interface BoundContextIdentity { + readonly value: unknown +} + +interface RemoteNamespaceHandle { + readonly service: RemoteNamespaceService + readonly dispose: TypeRTDisposer +} + +/** Typed Remote service augmented by generated direct namespaces. */ +export type ClientRemote = TypeRTClientRemote declare module 'cordis' { interface Context { - /** Generated direct Remote namespaces selected by the Client assembly. */ - api: ClientApi + /** Generated Remote namespaces selected by the Client assembly. */ + remote: ClientRemote } } @@ -53,48 +64,56 @@ declare module 'cordis' { export const inject = ['typert', 'connection'] /** - * Install the typed Client API service. + * Install the typed Client Remote service. * @param ctx - Client Cordis root. */ export function apply(ctx: Context): void { - new ClientApiService(ctx) + new ClientRemoteService(ctx) } -class ClientApiService extends Service implements TypeRTClientApi { +class ClientRemoteService extends Service implements TypeRTClientRemote { private readonly ownerCtx: Context - private readonly direct = new Map() - private readonly scoped = new Map() + private readonly namespaces = new Map() + private mutations = Promise.resolve() constructor(ctx: Context) { - super(ctx, 'api') + super(ctx, 'remote') this.ownerCtx = ctx } - mount(contribution: TypeRTRemoteContribution): ReturnType { - this.validateContribution(contribution) + async $mount(contribution: TypeRTRemoteContribution): ReturnType { const callerCtx = this.ctx + const owned = callerCtx.effect(async () => { + const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution)) + return () => this.enqueue(dispose) + }, `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`) + await owned + return async () => { await owned() } + } + + private enqueue(operation: () => T | Promise): Promise { + const result = this.mutations.then(operation, operation) + this.mutations = result.then(() => undefined, () => undefined) + return result + } + + private async mountContribution( + callerCtx: Context, + contribution: TypeRTRemoteContribution, + ): Promise { + this.validateContribution(contribution) const disposeRemote = callerCtx.typert.remotes.register(contribution) - let disposeMethods: () => void | Promise + const installed: TypeRTDisposer[] = [] try { - disposeMethods = callerCtx.effect(() => { - const installed: Array<() => void> = [] - try { - for (const descriptor of contribution.descriptors) installed.push(this.install(descriptor)) - } catch (error) { - for (const dispose of installed.reverse()) dispose() - throw error - } - return () => { - for (const dispose of installed.reverse()) dispose() - } - }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) + for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor)) } catch (error) { - /* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */ - Promise.resolve(disposeRemote()).catch(() => {}) + for (const dispose of installed.reverse()) await dispose() + await disposeRemote() throw error } return async () => { - await Promise.all([disposeMethods(), disposeRemote()]) + for (const dispose of installed.reverse()) await dispose() + await disposeRemote() } } @@ -112,10 +131,8 @@ class ClientApiService extends Service implements TypeRTClientApi { } methods.add(descriptor.method) table.set(descriptor.namespace, methods) - const live = kind === 'direct' - ? this.direct.get(descriptor.namespace)?.tokens - : this.scoped.get(descriptor.namespace)?.tokens - if (live?.has(descriptor.method) === true) { + const namespace = this.namespaces.get(descriptor.namespace)?.service + if (namespace?.has(kind, descriptor.method) === true) { throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`) } } @@ -124,118 +141,151 @@ class ClientApiService extends Service implements TypeRTClientApi { if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct') if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped') } - for (const namespace of direct.keys()) { - if (!this.direct.has(namespace) && namespace in this) { - throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the API service`) - } - } - for (const [namespace, methods] of scoped) { - const record = this.scoped.get(namespace) - if (record !== undefined) { - for (const method of methods) record.service.assertMethodAvailable(method) - } else { - for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) - const property = this.ownerCtx.reflect.props[namespace] - if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) { - throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + const namespaces = new Set([...direct.keys(), ...scoped.keys()]) + for (const namespace of namespaces) { + const service = this.namespaces.get(namespace)?.service + if (service === undefined) { + if (namespace in this) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`) } + const serviceKey = remoteServiceKey(namespace) + const property = this.ownerCtx.reflect.props[serviceKey] + if (property?.type === 'accessor' || this.ownerCtx.get(serviceKey) !== undefined) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`) + } + } + for (const method of new Set([...(direct.get(namespace) ?? []), ...(scoped.get(namespace) ?? [])])) { + if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method) + else service.assertMethodAvailable(method) } } } - private install(descriptor: InvocationDescriptor): () => void { + private async install(descriptor: InvocationDescriptor): Promise { const token: MountToken = { active: true, abort: new AbortController() } - const installed: (() => void)[] = [] + const installed: TypeRTDisposer[] = [] try { if (descriptor.invocation.kind === 'direct') { - installed.push(this.installDirect(descriptor, token)) + installed.push(await this.installDirect(descriptor, token)) } const projection = scopedProjection(descriptor) - if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) + if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token)) } catch (error) { token.active = false - for (const dispose of installed.reverse()) dispose() token.abort.abort() + for (const dispose of installed.reverse()) await dispose() throw error } - return () => { + return async () => { /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ if (!token.active) return token.active = false - for (const dispose of installed.reverse()) dispose() token.abort.abort() + for (const dispose of installed.reverse()) await dispose() } } - private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { - let namespace = this.direct.get(descriptor.namespace) - const fresh = namespace === undefined - if (namespace === undefined) { - namespace = { value: Object.create(null) as Record, tokens: new Map() } - Object.defineProperty(this, descriptor.namespace, { - configurable: true, - enumerable: true, - value: namespace.value, - }) - } + private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise { + const namespace = await this.namespace(descriptor.namespace) try { - Object.defineProperty(namespace.value, descriptor.method, { - configurable: true, - enumerable: true, - value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), - }) + namespace.service.installDirect(descriptor, token) } catch (error) { - if (fresh) Reflect.deleteProperty(this, descriptor.namespace) + await this.disposeNamespace(descriptor.namespace, namespace) throw error } - if (fresh) this.direct.set(descriptor.namespace, namespace) - namespace.tokens.set(descriptor.method, token) - return () => { - /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ - if (namespace.tokens.get(descriptor.method) !== token) return - Reflect.deleteProperty(namespace.value, descriptor.method) - namespace.tokens.delete(descriptor.method) - if (namespace.tokens.size !== 0) return - this.direct.delete(descriptor.namespace) - Reflect.deleteProperty(this, descriptor.namespace) + return async () => { + if (!namespace.service.remove('direct', descriptor.method, token)) return + await this.disposeNamespace(descriptor.namespace, namespace) } } - private installScoped( + private async installScoped( descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken, - ): () => void { - let namespace = this.scoped.get(descriptor.namespace) - if (namespace === undefined) { - const service = new ScopedRemoteNamespace( - this.ownerCtx, - descriptor.namespace, - (current, currentProjection, currentToken, caller, args) => - this.invoke(current, currentProjection, currentToken, caller, args), - ) - service.install(descriptor, projection, token) - namespace = { service, tokens: new Map() } - this.scoped.set(descriptor.namespace, namespace) - } else { - namespace.service.install(descriptor, projection, token) + ): Promise { + const namespace = await this.namespace(descriptor.namespace) + try { + namespace.service.installScoped(descriptor, projection, token) + } catch (error) { + await this.disposeNamespace(descriptor.namespace, namespace) + throw error } - namespace.tokens.set(descriptor.method, token) - return () => { - /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ - if (namespace.tokens.get(descriptor.method) !== token) return - namespace.service.remove(descriptor.method) - namespace.tokens.delete(descriptor.method) - if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace) + return async () => { + if (!namespace.service.remove('scoped', descriptor.method, token)) return + await this.disposeNamespace(descriptor.namespace, namespace) } } + private async namespace(name: string): Promise { + let namespace = this.namespaces.get(name) + if (namespace !== undefined) return namespace + let service: RemoteNamespaceService | undefined + const fiber = this.ownerCtx.plugin({ + name: remoteServiceKey(name), + apply: (ctx: Context) => { + service = new RemoteNamespaceService( + ctx, + name, + (direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args), + ) + }, + }) + try { + await fiber + } catch (error) { + await fiber.dispose() + throw error + } + /* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */ + if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`) + namespace = { service, dispose: fiber.dispose } + this.namespaces.set(name, namespace) + return namespace + } + + private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise { + if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return + this.namespaces.delete(name) + await namespace.dispose() + } + + private invokeMethod( + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, + callerCtx: Context, + values: readonly unknown[], + ): Promise { + if (scoped !== undefined) { + const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) + const identity = binder?.identity(callerCtx) + if (identity !== undefined) { + return this.invoke( + scoped.descriptor, + scoped.projection, + scoped.token, + callerCtx, + values, + { value: identity }, + ) + } + } + if (direct !== undefined) { + return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values) + } + if (scoped !== undefined) { + return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values) + } + throw new Error('client api: Remote method is no longer mounted') + } + private async invoke( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, token: MountToken, callerCtx: Context, values: readonly unknown[], + boundIdentity?: BoundContextIdentity, ): Promise { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) @@ -251,11 +301,15 @@ class ClientApiService extends Service implements TypeRTClientApi { } const args = Object.create(null) as Record if (projection !== undefined) { - const binder = this.ownerCtx.typert.contexts.getClient(projection.context) - if (binder === undefined) { + const binder = boundIdentity === undefined + ? this.ownerCtx.typert.contexts.getClient(projection.context) + : undefined + if (boundIdentity === undefined && binder === undefined) { throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`) } - const identity = binder.identity(callerCtx) + const identity = boundIdentity === undefined + ? binder?.identity(callerCtx) + : boundIdentity.value if (identity === undefined) { throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`) } @@ -281,23 +335,19 @@ class ClientApiService extends Service implements TypeRTClientApi { } type InvokeRemote = ( - descriptor: InvocationDescriptor, - projection: ScopedProjection, - token: MountToken, + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], ) => Promise -class ScopedRemoteNamespace { - private readonly ctx: Context - private readonly ownerCtx: Context - private readonly methods = new Set() - private disposeService: TypeRTDisposer | undefined - readonly name: string +class RemoteNamespaceService extends Service { + private readonly methods = new Map() + private readonly namespace: string static assertMethodAvailable(namespace: string, method: string): void { - if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { - throw new Error(`client api: scoped method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) + if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) { + throw new Error(`client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) } } @@ -306,54 +356,92 @@ class ScopedRemoteNamespace { name: string, private readonly invokeRemote: InvokeRemote, ) { - this.ctx = ctx - this.ownerCtx = ctx - this.name = name - Object.defineProperty(this, symbols.tracker, { - value: { associate: name, property: 'ctx' }, - }) + super(ctx, remoteServiceKey(name)) + this.namespace = name } assertMethodAvailable(method: string): void { - ScopedRemoteNamespace.assertMethodAvailable(this.name, method) - if (method in this) { - throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`) + RemoteNamespaceService.assertMethodAvailable(this.namespace, method) + if (method in this && !this.methods.has(method)) { + throw new Error(`client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`) } } - install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { - this.assertMethodAvailable(descriptor.method) - const activate = this.methods.size === 0 - const method = descriptor.method + get empty(): boolean { + return this.methods.size === 0 + } + + has(kind: 'direct' | 'scoped', method: string): boolean { + return this.methods.get(method)?.[kind] !== undefined + } + + installDirect(descriptor: InvocationDescriptor, token: MountToken): void { + this.install(descriptor.method, 'direct', { descriptor, token }) + } + + installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { + this.install(descriptor.method, 'scoped', { descriptor, projection, token }) + } + + private install(method: string, kind: 'direct', value: DirectMethod): void + private install(method: string, kind: 'scoped', value: ScopedMethod): void + private install(method: string, kind: 'direct' | 'scoped', value: DirectMethod | ScopedMethod): void { + this.assertMethodAvailable(method) + let record = this.methods.get(method) + const fresh = record === undefined + record ??= {} + if (record[kind] !== undefined) { + throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`) + } try { - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { - return this.invokeRemote(descriptor, projection, token, this.ctx, args) - }, - }) - if (activate) { - this.disposeService = this.ownerCtx.reflect.provide(this.name, this) + if (fresh) { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { + const callerCtx = this.ctx + const current = this.methods.get(method) + const direct = current?.direct + const scoped = current?.scoped + return (...args: unknown[]) => { + return this.invokeRemote(direct, scoped, callerCtx, args) + } + }, + }) + this.methods.set(method, record) } + if (kind === 'direct') record.direct = value + else record.scoped = value as ScopedMethod } catch (error) { - Reflect.deleteProperty(this, method) + if (kind === 'direct') delete record.direct + else delete record.scoped + if (fresh) { + this.methods.delete(method) + Reflect.deleteProperty(this, method) + } throw error } - this.methods.add(method) } - remove(method: string): void { - Reflect.deleteProperty(this, method) + remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean { + const record = this.methods.get(method) + const current = record?.[kind] + /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ + if (record === undefined || current?.token !== token) return false + if (kind === 'direct') delete record.direct + else delete record.scoped + if (record.direct !== undefined || record.scoped !== undefined) return true this.methods.delete(method) - if (this.methods.size !== 0) return - const disposeService = this.disposeService - this.disposeService = undefined - void disposeService?.() + Reflect.deleteProperty(this, method) + return true } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx']) +const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace']) + +function remoteServiceKey(namespace: string): string { + return `remote.${namespace}` +} function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index feae3056c9..216f2359e7 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context, Service } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -38,7 +38,7 @@ declare module '@deepseek-ai/dsh-type-meta' { } -type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'> +type FixtureContext = Omit & { readonly remote: TypeRTRemoteContextApi<'fixture'> } const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) @@ -105,17 +105,16 @@ describe('Client TypeRT API', () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) - let retained: typeof ctx.api.goals.create | undefined + const businessGoals = { owner: 'host business service' } + const disposeBusinessGoals = ctx.provide('goals', businessGoals) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) - retained = scope.api.goals.create - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + { inject: ['remote'] }, )) await assembly + const retained = ctx.remote.goals.create - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) expect(call).toHaveBeenCalledWith( '/api', 'goals/create', @@ -123,7 +122,7 @@ describe('Client TypeRT API', () => { expect.any(AbortSignal), ) const callerAbort = new AbortController() - await expect(ctx.api.goals.create( + await expect(ctx.remote.goals.create( 'agent-1', { objective: 'cancel me' }, callerAbort.signal, @@ -135,16 +134,18 @@ describe('Client TypeRT API', () => { callerAbort.abort(cancellation) expect(combinedSignal?.aborted).toBe(true) expect(combinedSignal?.reason).toBe(cancellation) - await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') + await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') await assembly.dispose() - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals')).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() + expect(ctx.get('goals')).toBe(businessGoals) expect(ctx.typert.remotes.list()).toEqual([]) await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') + disposeBusinessGoals() }) it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => { @@ -156,26 +157,24 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + { inject: ['remote'] }, )) await assembly - await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) + await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) expect(call).toHaveBeenCalledWith( '/api', 'goals/create', { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' })) - .rejects.toThrow('requires a "fixture" Context') + await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' })) + .rejects.toThrow('expected 2 business argument(s)') await assembly.dispose() - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals')).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() }) it('uses the caller Context identity for scoped namespace methods', async () => { @@ -187,25 +186,23 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }) - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }), + { inject: ['remote'] }, )) await assembly - await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenCalledWith( '/api', 'goals/rename', { args: { agentId: 'agent-2', request: { objective: 'land' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).goals.rename({ objective: 'land' })) + await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' })) .rejects.toThrow('requires a "fixture" Context') await assembly.dispose() - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() }) it('rejects weak descriptors and namespace collisions before registration', async () => { @@ -215,12 +212,12 @@ describe('Client TypeRT API', () => { result: { mode: 'src-json' }, } - expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] })) - .toThrow('has no strict codec') - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] })) + .rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/conflict', - descriptors: [{ ...directDescriptor(), namespace: 'mount' }], - })).toThrow('conflicts with the API service') + descriptors: [{ ...directDescriptor(), namespace: '$mount' }], + })).rejects.toThrow('conflicts with the Remote service') expect(ctx.typert.remotes.list()).toEqual([]) }) @@ -235,48 +232,50 @@ describe('Client TypeRT API', () => { const direct = directDescriptor() const context = contextDescriptor() - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/direct-duplicates', descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], - })).toThrow('repeats direct method') - expect(() => ctx.api.mount({ + })).rejects.toThrow('repeats direct method') + await expect(ctx.remote.$mount({ package: '@fixture/scoped-duplicates', descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], - })).toThrow('repeats scoped method') + })).rejects.toThrow('repeats scoped method') - const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] }) - expect(() => ctx.api.mount({ + const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] }) + await expect(ctx.remote.$mount({ package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], - })).toThrow('direct method goals/create is already mounted') + })).rejects.toThrow('direct method goals/create is already mounted') await disposeDirect() - const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] }) - expect(() => ctx.api.mount({ + const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] }) + await expect(ctx.remote.$mount({ package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], - })).toThrow('scoped method goals/rename is already mounted') - expect(() => ctx.api.mount({ + })).rejects.toThrow('scoped method goals/rename is already mounted') + await expect(ctx.remote.$mount({ package: '@fixture/service-method-conflict', descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], - })).toThrow('conflicts with its namespace service') - const scopedService = ctx.get('goals') as unknown as object + })).rejects.toThrow('conflicts with its namespace service') + const scopedService = ctx.get('remote.goals') as unknown as object Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined }) - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/service-own-property-conflict', descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], - })).toThrow('conflicts with its namespace service') + })).rejects.toThrow('conflicts with its namespace service') Reflect.deleteProperty(scopedService, 'custom') await disposeScoped() - expect(() => ctx.api.mount({ + const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' }) + await expect(ctx.remote.$mount({ package: '@fixture/context-property-conflict', descriptors: [{ ...context, namespace: 'typert' }], - })).toThrow('conflicts with an existing Context property') + })).rejects.toThrow('conflicts with an existing Remote namespace') + await disposeRemoteTypert() - const disposeMultipleScoped = ctx.api.mount({ + const disposeMultipleScoped = await ctx.remote.$mount({ package: '@fixture/multiple-scoped', descriptors: [directDescriptor(), contextDescriptor()], }) - await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenLastCalledWith( '/api', 'goals/rename', @@ -286,41 +285,6 @@ describe('Client TypeRT API', () => { await disposeMultipleScoped() }) - it('rolls back direct projection when scoped installation fails', async () => { - const ctx = await bench(vi.fn()) - const disposeScoped = ctx.api.mount({ - package: '@fixture/scoped-base', - descriptors: [contextDescriptor()], - }) - const defineProperty = Object.defineProperty - let createDefinitions = 0 - const definePropertySpy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { - // The direct projection defines `create` first; fail the following scoped projection. - if (key === 'create' && ++createDefinitions === 2) throw new Error('simulated scoped installation failure') - return defineProperty(target, key, attributes) - }) - - try { - expect(() => ctx.api.mount({ - package: '@fixture/failing-install', - descriptors: [directDescriptor()], - })).toThrow('simulated scoped installation failure') - } finally { - definePropertySpy.mockRestore() - } - - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals') !== undefined).toBe(true) - expect(ctx.typert.remotes.list()).toHaveLength(1) - - const disposeRetry = ctx.api.mount({ - package: '@fixture/retry', - descriptors: [directDescriptor()], - }) - await disposeRetry() - await disposeScoped() - }) - it('rolls back earlier descriptors when a later descriptor fails to install', async () => { const ctx = await bench(vi.fn()) const { scope: _scope, ...first } = directDescriptor() @@ -335,17 +299,17 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) - .toThrow('fixture later-descriptor failure') + await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) + .rejects.toThrow('fixture later-descriptor failure') } finally { spy.mockRestore() } - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) - expect(ctx.api.goals.create).toBeTypeOf('function') - expect((ctx.api.goals as unknown as Record).archive).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) + expect(ctx.remote.goals.create).toBeTypeOf('function') + expect((ctx.remote.goals as unknown as Record).archive).toBeTypeOf('function') await retry() }) @@ -353,7 +317,7 @@ describe('Client TypeRT API', () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() const context = contextDescriptor() - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/weak-parameter', descriptors: [{ ...direct, @@ -361,19 +325,19 @@ describe('Client TypeRT API', () => { ? { ...parameter, codec: { mode: 'src-json' } } : parameter), }], - })).toThrow('has no strict codec') - expect(() => ctx.api.mount({ + })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/weak-context', descriptors: [{ ...context, invocation: { ...context.invocation, codec: { mode: 'src-json' } }, } as InvocationDescriptor], - })).toThrow('has no strict codec') - expect(() => ctx.api.mount({ + })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/malformed-scope', descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }], - })).toThrow('scope must select its only lookup parameter') - expect(() => ctx.api.mount({ + })).rejects.toThrow('scope must select its only lookup parameter') + await expect(ctx.remote.$mount({ package: '@fixture/ambiguous-scope', descriptors: [{ ...direct, @@ -382,7 +346,7 @@ describe('Client TypeRT API', () => { codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, }], }], - })).toThrow('scope must select its only lookup parameter') + })).rejects.toThrow('scope must select its only lookup parameter') }) it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { @@ -390,27 +354,29 @@ describe('Client TypeRT API', () => { .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) const descriptor = directDescriptor() - const dispose = ctx.api.mount({ + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [descriptor, contextDescriptor()], }) - const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise - const goals = (ctx as FixtureContext).goals + const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise + const goals = (ctx as FixtureContext).remote.goals const rename = goals.rename as unknown as (...args: unknown[]) => Promise await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) .rejects.toThrow('got 4') await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') - await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) + await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' })) + .rejects.toThrow('expected 2 business argument(s)') + await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' })) .rejects.toThrow('no Client Context binder') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' ctx.set('connection', undefined) - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') await dispose() }) @@ -427,14 +393,14 @@ describe('Client TypeRT API', () => { id: '@fixture/goals#goals/archive', method: 'archive', } - const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] }) - const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' }) + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] }) + const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' }) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) await expect(invocation).rejects.toThrow('withdrawn during invocation') - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) it('preserves a __proto__ wire parameter as an own named argument', async () => { @@ -453,9 +419,9 @@ describe('Client TypeRT API', () => { codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() }, }], } - const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] }) + const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] }) - const method = (ctx.api.goals as unknown as Record Promise>).prototype + const method = (ctx.remote.goals as unknown as Record Promise>).prototype await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) const payload = call.mock.calls[0]?.[2] as { readonly args: Record } expect(Object.getPrototypeOf(payload.args)).toBeNull() @@ -464,23 +430,23 @@ describe('Client TypeRT API', () => { await dispose() }) - it('rolls back Remote registration when concrete method installation fails', async () => { + it('rolls back Remote registration when namespace Service startup fails', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { - if (key === 'goals') throw new Error('fixture installation failure') + if (key === Service.tracker) throw new Error('fixture namespace startup failure') return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) - .toThrow('fixture installation failure') + await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) + .rejects.toThrow('fixture namespace startup failure') await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) } finally { spy.mockRestore() } - const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) - expect(ctx.api.goals.create).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) + expect(ctx.remote.goals.create).toBeTypeOf('function') await retry() }) @@ -492,16 +458,21 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] })) - .toThrow('fixture direct method installation failure') + await expect(ctx.remote.$mount({ + package: '@fixture/direct-method-failure', + descriptors: [directDescriptor()], + })).rejects.toThrow('fixture direct method installation failure') } finally { spy.mockRestore() } - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()] }) - expect(ctx.api.goals.create).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ + package: '@fixture/direct-method-retry', + descriptors: [directDescriptor()], + }) + expect(ctx.remote.goals.create).toBeTypeOf('function') await retry() }) @@ -513,41 +484,41 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) - .toThrow('fixture scoped installation failure') + await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) + .rejects.toThrow('fixture scoped installation failure') } finally { spy.mockRestore() } - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) - expect((ctx.get('goals') as unknown as Record).rename).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) + expect((ctx.get('remote.goals') as unknown as Record).rename).toBeTypeOf('function') await retry() }) it('unregisters an empty scoped namespace so another provider can claim its name', async () => { const ctx = await bench(vi.fn()) - const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) - expect(ctx.get('goals')).toBeDefined() + const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) + expect(ctx.get('remote.goals')).toBeDefined() await dispose() - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() const replacement = { owner: 'replacement' } - const disposeReplacement = ctx.reflect.provide('goals', replacement) - expect(ctx.get('goals')).toBe(replacement) + const disposeReplacement = ctx.reflect.provide('remote.goals', replacement) + expect(ctx.get('remote.goals')).toBe(replacement) await disposeReplacement() }) it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) - ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) let failure: unknown try { - await ctx.api.goals.create('agent-1', { objective: 'ship' }) + await ctx.remote.goals.create('agent-1', { objective: 'ship' }) } catch (error) { failure = error } diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c3c13a8049..82947331c5 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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/api/remotes/README.md -README.md: cf54a56a849246d4efdca09cadd42e157064bdee -README.zh.md: 5cd7ef21c926440ca4df6d88ee4adfe87defcc3f +README.md: 7f6a2114d900413d972584c0f1c141b7f835ba36 +README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index cf54a56a84..7f6a2114d9 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. +Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. `createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. -The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientApi` interface through Cordis and does not import the concrete Gateway. +The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. -This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.api` contract. +This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. ## Model Experience diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 5cd7ef21c9..cce263747d 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 +为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 `createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 -当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、具体的根级方法和作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 接口,不导入具体 Gateway。 +当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。 -本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用其 Client face。 +本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。 ## 模型体验 diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 1bc36b62ee..ebd342300e 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -2,25 +2,25 @@ import type { Context } from 'cordis' import goalsRemote from '@deepseek-ai/dsh-goal/remote' -import type { TypeRTClientApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' -export type { TypeRTClientApi as ClientApi } from '@deepseek-ai/dsh-type-meta' +export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' declare module 'cordis' { interface Context { - /** Generated direct Remote namespaces selected by this Client assembly. */ - api: TypeRTClientApi + /** Generated Remote namespaces selected by this Client assembly. */ + remote: TypeRTClientRemote } } -/** Required service: the typed Client API contribution mount. */ -export const inject = ['api'] +/** Required service: the typed Client Remote contribution mount. */ +export const inject = ['remote'] /** * Mount the Host capabilities explicitly selected for this Client assembly. * @param ctx - Client Cordis root carrying the typed API service. */ -export function apply(ctx: Context): void { - ctx.api.mount(goalsRemote) +export function apply(ctx: Context): Promise<() => Promise> { + return ctx.remote.$mount(goalsRemote) } diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index b8f6c81e98..af584cba7f 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -143,18 +143,18 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { let invalidRejected = false try { - await client.api.goals.create(rootAgent.id, { objective: 1 }) + await client.remote.goals.create(rootAgent.id, { objective: 1 }) } catch { invalidRejected = true } - const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' }) - const rootEdit = await client.api.goals.edit( + const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' }) + const rootEdit = await client.remote.goals.edit( rootAgent.id, rootResult.ref, { objective: 'edited root goal' }, ) const agentContext = client.extend({ builtAgentId: scopedAgent.id }) - const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) + const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) const result = { invalidRejected, rootResult, diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index ba4fd8ede7..1154d10feb 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,7 +18,12 @@ import { Context as CordisContext } from 'cordis' import type { Context, Fiber } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote, TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' + +/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ +export type AgentContext = Omit & { + readonly remote: TypeRTClientRemote & TypeRTRemoteContextApi<'agent'> +} /** Context tag written by {@link createScope}. */ const kScope = Symbol('dsh.client.scope') @@ -30,7 +35,7 @@ export interface AgentScopeHandle { * through it (passing it as the dispatch subject routes to this agent's * tagged listeners plus every untagged one). */ - ctx: Context & TypeRTRemoteContextApi<'agent'> + ctx: AgentContext /** Backing fiber (dispose tears down every scope-owned registration). */ fiber: Fiber } @@ -55,7 +60,7 @@ export function createScope(ctx: Context, key: SessionId): AgentScopeHandle { const tag = scopeOf(listenerCtx) return tag === undefined || tag === key }, - }) as Context & TypeRTRemoteContextApi<'agent'> + }) as AgentContext return { fiber, ctx: scoped, diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 8e9c530720..2af2ef51c8 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -11,8 +11,8 @@ import type { Context } from 'cordis' import type { RpcResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' +import type { AgentContext } from '../agents/scope.ts' import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { SessionBinding, SessionListState, SessionProvideDescriptor, @@ -20,8 +20,7 @@ import type { import type { SessionFace } from './session.ts' import type { ObservableSnapshot } from './store.ts' -/** Client Cordis Context carrying one Agent identity and its generated Remote namespaces. */ -export type AgentContext = Context & TypeRTRemoteContextApi<'agent'> +export type { AgentContext } from '../agents/scope.ts' /** The sessions-service face injected as `ctx.sessions`. */ export interface ISessions { diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index a9d2bb0d7d..b772e315a3 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -179,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the typed Remote API, wire handle, and Client TypeRT registry. */ -export const inject = ['api', 'connection', 'typert'] +/** Required services: the Remote root and Goal namespace, wire handle, and Client TypeRT registry. */ +export const inject = ['remote', 'remote.goals', 'connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 5635793122..e9b387fb00 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -38,7 +38,8 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) - ctx.reflect.provide('api', {}) + ctx.reflect.provide('remote', {}) + ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 5ab644682a..703c5b1728 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -32,7 +32,8 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) - ctx.reflect.provide('api', {}) + ctx.reflect.provide('remote', {}) + ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index f30f14ed48..55853ef4bd 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/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/client/ui-goal/README.md -README.md: b99aaf624a7d669879ba668938ee455e3cdc68ad -README.zh.md: 3d823d013066bc912398f61c85553887e05ca3b4 +README.md: a53fb3a89eaee364cb025ca728ca42ce934887b0 +README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index b99aaf624a..a53fb3a89e 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.api.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 3d823d0130..1ad9f50aee 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.api.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.remote.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 `/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index bea4f67df2..2c041e0eae 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -9,7 +9,7 @@ * Goal creation stays on the /goal host command. */ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -// Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary. +// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -36,7 +36,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ -export const inject = ['slots', 'sessions', 'api', 'locale'] +export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale'] /** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ async function settle(invoke: () => Promise): Promise { @@ -94,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.edit(sessionId, ref, { objective })) + return settle(() => ctx.remote.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.pause(sessionId, ref)) + return settle(() => ctx.remote.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.resume(sessionId, ref)) + return settle(() => ctx.remote.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.clear(sessionId, ref)) + return settle(() => ctx.remote.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index f900682712..756968136e 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -10,7 +10,7 @@ * plugin fiber (HMR safety). The node half and the invariant companion are * exercised over the same Context. */ -import { Context } from 'cordis' +import { Context, Service } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' @@ -71,8 +71,17 @@ async function bench(options: { clear: answer(`${prefix}/clear`, ref), }) let activeGoals: ReturnType | undefined = goals('goals') - ctx.provide('api', { - get goals() { return activeGoals }, + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + ctx.provide('remote.goals', { + get edit() { return activeGoals?.edit }, + get pause() { return activeGoals?.pause }, + get resume() { return activeGoals?.resume }, + get clear() { return activeGoals?.clear }, }) await ctx.plugin(SlotsService).await() ctx.slots.register({ diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 27bdac2fac..eaaf680cc6 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -593,8 +593,8 @@ 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' }) +declare const ctx: { remote: TypeRTRemoteNamespaceMap } +const navigated: Promise = ctx.remote.goals.create('agent-1', { title: 'navigate' }) void contribution void created void cancellable @@ -643,7 +643,7 @@ void navigated readFile: path => ts.sys.readFile(path), realpath: path => ts.sys.realpath?.(path) ?? path, }) - const navigation = 'ctx.api.goals.create' + const navigation = 'ctx.remote.goals.create' const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1 const definitions = languageService.getDefinitionAtPosition(consumerPath, position) const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath) @@ -672,8 +672,8 @@ function assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot: string): const consumerPath = join(consumerRoot, 'consumer-without-remote.ts') writeFileSync(consumerPath, ` import type { TypeRTRemoteNamespaceMap } from '@deepseek-ai/dsh-type-meta' -declare const ctx: { api: TypeRTRemoteNamespaceMap } -ctx.api.goals.create('agent-1', { title: 'must not compile' }) +declare const ctx: { remote: TypeRTRemoteNamespaceMap } +ctx.remote.goals.create('agent-1', { title: 'must not compile' }) `) const configPath = join(consumerRoot, 'tsconfig.consumer-without-remote.json') writeFileSync(configPath, JSON.stringify({ diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 2f687f985f..774c6d3b32 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,7 +41,7 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, - TypeRTClientApi, + TypeRTClientRemote, TypeRTClientContextBinder, TypeRTCodec, TypeRTContext, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index ed309b7857..5e7c20cd7c 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -77,7 +77,7 @@ export type TypeRTRemoteContextApi = { TypeRTRemoteContextNamespace } -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ export interface TypeRTRemoteNamespaceMap {} /** Awaitable disposer returned by Cordis-owned TypeRT registrations. */ @@ -176,14 +176,14 @@ export interface TypeRTRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -export interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +export interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } /**