docs(agent-note): propose Remote event delivery via ctx.remote.$on
An allowlist in api/remotes drives verbatim forwarding of Host cordis events that bind no AgentScope, wrapped in one host/remote-event frame on the existing host stream. Consumers subscribe through ctx.remote.$on, whose listener type is the owner package's own Events declaration, so the payload contract holds by construction rather than through a restated table.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
# Agent Note: Remote event delivery (ctx.remote.$on)
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-08-10-remote-event-delivery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[TypeRT Gateway targeted method calls](2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy.
|
||||
|
||||
The Host owns a family of pure invalidation events — "a registry changed, refetch it" — whose payloads are already JSON and whose emission never binds an AgentScope: `commands/change`, `credentials/updated`, `settings/document-updated`. Reaching one UI subscriber takes four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emits it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edits five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them states a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge.
|
||||
|
||||
That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol.
|
||||
|
||||
## Proposal
|
||||
|
||||
Add one one-way subscription verb to the consumer Remote surface, `ctx.remote.$on(event, listener)`, driven by an allowlist and forwarding verbatim:
|
||||
|
||||
- `packages/api/remotes/src/types.ts` holds the allowlist of forwardable Host events, and it is the single control point over what a consumer may subscribe to. That file is listed in the `files` of **both** of this package's faces, so the Host forwarding loop and the consumer key surface read one declaration.
|
||||
- The wire event name **is** the Host cordis event name (`settings/document-updated`) with no `host/` prefix, and the payload **is** the Host argument list, element for element, with no projection, redaction, or renaming.
|
||||
- The carrier reuses the existing host stream: `HostFrame` gains one wrapper variant, `host/remote-event`. No new downlink.
|
||||
- Event **signatures** get no second table. Each owner package moves its cordis `Events` declaration into its client-safe, type-only `./types` export, so both faces read the same declaration and `$on`'s listener type is `Events[Event]` itself. "Verbatim" then holds by construction rather than by proof.
|
||||
- Only cordis's *type shape* is borrowed, not its event system: delivery semantics, the subscription registry, and failure containment belong to TypeRT.
|
||||
|
||||
When an `Events` entry's signature reaches a Host-only symbol (a Service, `Agent`, a Context), the answer is to **split the code until the entry lands cleanly in `./types`** — never a declaration half-left in `index.ts`, and never a structurally equivalent shadow type in `./types`. None of the three packages needed that here: their entries reach only `SettingsNamespace`, `SettingsUpdateSource`, and `CredentialRef`, all pure types.
|
||||
|
||||
This change migrates the three **pure passthrough** events and deletes their `HostFrame` variants. Everything with derivation stays untouched: `host/models-changed` (a fan-in of `llm/adapters-updated` with provider/agent-default namespace filtering), `host/workspace-changed`/`-removed`/`host/archived-sessions-changed` (view derivation plus per-connection dedup state), and `host/session-added`/`-removed`/`host/session-status`/`host/agent-error` (live-object projection or frame-time derived fields).
|
||||
|
||||
`skills/change`, `tools/change`, and `system-prompt/change` have the same shape but **no consumer today**; under "require a current owner and need" they stay out of the allowlist and are recorded here only as the extension seat.
|
||||
|
||||
### Consumer contract (dsh-type-meta)
|
||||
|
||||
type-meta gains one **shape predicate**, one **selection seat**, and **one** member on `TypeRTClientRemote`. No runtime code:
|
||||
|
||||
```ts
|
||||
/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */
|
||||
export type TypeRTForwardableEvent = {
|
||||
[Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
|
||||
? ReturnType<Events[Event]> extends void ? Event : never
|
||||
: never
|
||||
}[keyof Events]
|
||||
|
||||
/** The Host assembly's forwarding selection; api/remotes' allowlist fills it, no other package does. */
|
||||
export interface TypeRTRemoteEventSelection {}
|
||||
|
||||
/** `$on`'s legal keys: selected, and present in the current compilation face. */
|
||||
export type TypeRTRemoteEvent = Extract<keyof Events, keyof TypeRTRemoteEventSelection>
|
||||
```
|
||||
|
||||
```ts
|
||||
/** Subscribe to one forwarded Host event; the returned disposer belongs to the calling fiber. */
|
||||
$on<Event extends TypeRTRemoteEvent>(event: Event, listener: Events[Event]): () => void
|
||||
```
|
||||
|
||||
`Events` resolves per program: the full Host vocabulary in the Host program, whatever the Client face can see in the Client program. The same predicate therefore holds on both sides without dragging Host declarations into the Client.
|
||||
|
||||
**The consumer surface has `$on` and no `$dispatch`.** The port that hands a decoded frame to the subscription table stays out of the developer-visible contract, and it cannot be a module-level function reaching across Client plugins: the client bundle purity gate (`packages/client/tsdown.client.ts`) admits value imports only from `CLIENT_EXTERNALS`, the `INLINE_SAFE` wire layer, and generated `/remote` contributions — and inlining around it would copy `ClientRemoteService` into the runtime bundle, making `instanceof` permanently false.
|
||||
|
||||
The port is therefore **one internal Client cordis event**, declared in `dsh-type-meta` (a single-face package both sides already depend on, so no new dependency):
|
||||
|
||||
```ts
|
||||
'remote/host-event'(event: string, args: readonly unknown[]): void
|
||||
```
|
||||
|
||||
client/runtime — the owner of the host frame sink — emits it; `ClientRemoteService` is the only subscriber and turns it into `$on` callbacks through a private `dispatch`. This is the repository's existing cross-plugin plumbing shape: `connection/reset` is declared and emitted by runtime, subscribed by `ui-command`, and pinned by `runtime/tests/wire-events.spec.ts`. The `event` parameter is `string`, not `TypeRTRemoteEvent`: this is a wire boundary, and a name nobody subscribed to is dropped silently.
|
||||
|
||||
Delivery shares no implementation with the cordis event system: one-way only, no waterfall/bail/parallel/serial modes and no `@mode` concept (`ReturnType extends void` is the static expression of that rule), no `this` binding, no `EventOptions`, `prepend`, or priority. Listeners run in registration order, and one that throws is contained and logged — it must never take down the frame pump (the same posture `ConnectionController` already applies to its sinks).
|
||||
|
||||
### The allowlist: one file both faces read
|
||||
|
||||
`packages/api/remotes/src/types.ts` is listed in the `files` of both `tsconfig.host.json` and `tsconfig.client.json`, and is the allowlist's single home:
|
||||
|
||||
```ts
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'commands/change',
|
||||
'credentials/updated',
|
||||
'settings/document-updated',
|
||||
] as const
|
||||
|
||||
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
|
||||
}
|
||||
```
|
||||
|
||||
Forwarding one more event is therefore **one line in that array**: the type projection, `$on`'s key surface, and the Host forwarding loop all derive from it. `ctx.remote.$on('slots/changed', …)` (a Client-local event) and `$on('skills/change', …)` (declared but unselected) are both **compile errors**.
|
||||
|
||||
The Host face adds one shape assertion, binding the Host event vocabulary to that same array:
|
||||
|
||||
```ts
|
||||
API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[]
|
||||
```
|
||||
|
||||
It is an expression statement rather than a named constant, which `noUnusedLocals` would reject (the underscore prefix exempts parameters only). It enforces three things: the **name is real** (the predicate is keyed on `keyof Events`), the event **binds no Scope** (`goal/changed` and kin have a `ThisParameterType` other than `unknown` and drop out — the static expression of "no AgentScope dependency"), and the event is **one-way** (a non-`void` return, i.e. a waterfall/bail shape, drops out).
|
||||
|
||||
**"Verbatim" is proved nowhere because it holds by construction**: `$on`'s listener type comes from the one cordis `Events` declaration in the owner package's `./types`, and Host forwarding reads that same declaration. There is no second declaration that could drift.
|
||||
|
||||
JSON-safety is a runtime concern: before forwarding, apiproxy validates each argument with `dsh-session`'s `isJsonValue` and **throws loudly** when one fails, because that is an allowlist composition mistake rather than untrusted input.
|
||||
|
||||
### Wire contract (apiproxy)
|
||||
|
||||
```ts
|
||||
| { type: 'host/remote-event'; event: string; args: JsonValue[] }
|
||||
```
|
||||
|
||||
The zod branch keeps `args: z.array(z.unknown())`: the frame arrives from `JSON.parse`, so every element is already a JSON value, and the structural contract belongs to the owner package's `Events` declaration — the same posture the existing `session/projection` frame takes with its `value`.
|
||||
|
||||
`events.host()` subscribes by allowlist when the stream opens (each stream owns its disposers, so no broadcast set is needed). **The registration position is part of the contract**: this block must sit *before* the `settings/document-updated` listener. Cordis fires in registration order, and `host/models-changed` is an invalidation frame *derived* from that same Host event; placing the forwarded frame after the derived one flips the relative order of two frames from one emit compared with the previous behavior (two config cases observe it).
|
||||
|
||||
Cordis keys `on` by literal event name, so subscribing from a runtime list erases the handler type once. That is the only type assertion this change introduces; its safety rests on the allowlist predicate and the `isJsonValue` check.
|
||||
|
||||
`api/events.ts` is a wire contract file the browser side also compiles, so every type it references must come from an owner package's **client-safe, type-only subpath**, never the package root. Evidence: importing one type from `@deepseek-ai/dsh-session` root drags the root's `declare module 'cordis' { interface Context { sessions: SessionStore } }` into the Client compilation face and overrides the Client's `ctx.sessions: ISessions`, producing 18 errors in the unrelated `ui-slash` and `ui-conversation`. `JsonValue` therefore needs a re-export from `dsh-session/src/types.ts`.
|
||||
|
||||
### The apps/web browser e2e belong to the Host face
|
||||
|
||||
The `apps/web/tests/**` e2e type-check in the root **`tsconfig.host.json`**: they boot a real harness in-process and read `ctx.apiProxy`, the Host `SessionStore`'s `get`/`create`/`flush`, and `ctx.sessionProjectionCache`. **Driving a browser at runtime does not make a file part of the Client program** — moving them into the Client aggregate immediately produces 21 errors, because one program cannot hold both faces' merges for the same Context key.
|
||||
|
||||
That yields a discipline this design depends on: **when those tests import a value or a type from a Client package, they pull that package's whole project — and every project it references — into the Host build graph**. Four consumers (`ui-settings-general`, `ui-models`, `ui-permission`, `ui-command`) reference `api/remotes`' Client face, and that face cannot compile until Host tsdown has generated `@deepseek-ai/dsh-goal/remote`. The result is a build-order deadlock: Host tsc needs the Client face, which needs the generated artifact, which Host tsdown produces after Host tsc.
|
||||
|
||||
This change therefore **mirrors** the few Client-owned symbols on the test side (`scaffold.ts` exports the mirrored welcome-notice constants; the two chat e2e keep importing `dsh-client-runtime/client` because the `runtime` project is already in the Host graph), which lets those four consumers leave the Host graph. The 15 Client project references in `apps/cli/tsconfig.json` then lose their owner-map role and are deleted as a group. Each mirrored value matches its source verbatim; a drift shows up as a missed selector or an unsuppressed notice, both loud failures.
|
||||
|
||||
### Change inventory
|
||||
|
||||
| Location | Change |
|
||||
|---|---|
|
||||
| `dsh-type-meta` | `src/types.ts` gains `TypeRTForwardableEvent`, `TypeRTRemoteEventSelection`, `TypeRTRemoteEvent`, and the `'remote/host-event'` declaration; `TypeRTClientRemote` gains `$on`. Types only, no runtime |
|
||||
| `api/gateway` Client half | `ClientRemoteService` implements `$on` (subscription table, `ctx.effect` ownership for the calling fiber, registration-order delivery with listener failures contained) and subscribes to `'remote/host-event'`; `dispatch` stays private |
|
||||
| `api/remotes` | New `src/types.ts` (allowlist, type projection, selection seat) listed in both faces' `files`; a `./types` export with `lib/types/**/*.js` added to `files`; the Host face adds the shape assertion and `import type {}` for the three owner `./types`; the Client half re-exports those three plus `@deepseek-ai/dsh-api-gateway/client`; `./invariant` asserts the runtime relation for allowlisted events (`thisArg === null` and `mode === 'emit'`) |
|
||||
| Root `tsconfig.base.json` | Three `paths` entries (`dsh-settings/types`, `dsh-credentials/types`, `dsh-api-remotes/types`), all pointing at the **source** plane |
|
||||
| `dsh-commands` / `dsh-settings` / `dsh-credentials` | The `interface Events` sub-block moves into each package's client-safe `./types` (settings and credentials create that export, moving the brands and pure types with it; `index` keeps re-exporting them and keeps the constructors; `files` gains `lib/types/**/*.js`) |
|
||||
| `host/apiproxy` | `HostFrame` gains `host/remote-event` and loses `host/commands-changed`/`-settings-changed`/`-credentials-changed` with their zod branches; `events.host()` subscribes by allowlist ahead of the `settings/document-updated` listener and validates through `assertJsonArgs`; the `settings/document-updated` listener stays to keep feeding `host/models-changed` |
|
||||
| `dsh-session` | `src/types.ts` re-exports `JsonValue` so wire contract files can use the client-safe subpath |
|
||||
| `client/runtime` | The bridge's three `ctx.emit` branches collapse into `ctx.emit('remote/host-event', frame.event, frame.args)`; the `Events` merge drops `commands/changed`, `settings/changed`, and `credentials/changed` (`models/changed` stays) |
|
||||
| Five consumers | ui-command / ui-models / ui-settings-general / ui-permission / ui-agent-preset subscribe through `ctx.remote.$on(...)`, following `ui-goal`'s precedent for the type-only facade import and the `'remote'` injection |
|
||||
| `client/connection` | The fixture's `emitHost` produces `host/remote-event` |
|
||||
| `apps/web/tests` + `apps/cli` | Client symbols mirrored on the test side (see above); `apps/cli/tsconfig.json` drops its 15 Client project references |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Open a general downlink channel for Remote events** (the push counterpart of `ctx.connection.rpc`, a third WebSocket). This best matches "Connection owns the carrier, the Gateway never touches transport", but it means a new stream in the Host downlink, `WebApiClient`, `ConnectionController`, the fixture, and the web e2e — a cost out of proportion to this change. Reusing the host stream costs a temporary tenancy inside a legacy frame union; when that stream moves, the wrapper moves with it and the consumer contract does not change.
|
||||
|
||||
**Declare a separate `TypeRTRemoteEventMap` in type-meta and let owner packages merge into it.** The consumer key set would equal exactly "events declared remotely deliverable", but every signature would be written a second time outside cordis `Events`, requiring a bidirectional `extends` proof to stop the two from drifting, plus a new type-meta dependency for three owner packages. Sharing the one `Events` declaration makes that equivalence structural, so the table is not created.
|
||||
|
||||
**Have the typert generator project Host `Events` declarations** (codec, `.d.ts`, declaration map, like `/remote`). The generator already analyzes Host events, but it cannot see projection or redaction intent, and it would change the generator and the build surface. Verbatim forwarding needs no projection.
|
||||
|
||||
**Give forwardable events a payload projection function** (a `{ name, project, zod }` forwarding table). This would cover `models-changed`'s fan-in and workspace view derivation in one step, at the cost of hand-aligning projection logic with payload types — the central table the method side just removed.
|
||||
|
||||
**Move the apps/web browser e2e into the Client aggregate.** "Client tests belong to the Client face" looks right and fails immediately with 21 errors: those tests use Host services, and in the Client program `ctx.sessions` is `ISessions`.
|
||||
|
||||
**Split `directory-picker-browse`/`-native` into Host and Client faces** so no Client package reaches the Host graph. The direction is right — they are genuinely unsplit dual-half packages — but it is a separate concern from this capability seam and lands in another owner's packages; recorded as its own follow-up.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Emitting the three Host events puts one `host/remote-event` frame each on the real host stream, with `event` the Host name and `args` equal element for element (a real composition test).
|
||||
- The allowlist rejects three candidate classes at the type level: a name that is not an event, a Scope-bound event (`goal/changed`), and an event whose return is not `void`.
|
||||
- `$on`'s key surface equals the allowlist: `$on('slots/changed', …)` and `$on('skills/change', …)` must both fail to compile.
|
||||
- `TypeRTClientRemote` has **no** `$dispatch`: the developer-visible contract is `$on` plus the existing `$mount` and generated namespaces.
|
||||
- A non-JSON-safe argument makes `assertJsonArgs` throw rather than degrade silently; that function is unit-tested directly instead of driving a malformed emit through the event bus.
|
||||
- `ctx.remote.$on`'s disposer belongs to the calling fiber: disposing the fiber removes the subscription. One throwing listener affects neither its siblings nor later frames.
|
||||
- For one emit, the forwarded frame and the invalidation frame derived from the same Host event keep the pre-change relative order.
|
||||
- On the consumer side, `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace` — the brand survives the wire.
|
||||
- The three `HostFrame` variants, the three Client-side `Events` declarations, and the three bridge branches disappear in the same change; `host/models-changed` behavior is unchanged.
|
||||
- `pnpm run build` passes.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Tenancy inside a legacy frame union.** The new contract temporarily lives in apiproxy's `HostFrame`, so a reader may assume apiproxy owns Remote events. The frame's JSDoc names `api-remotes` as the allowlist owner, and apiproxy's README records the tenancy under known limitations.
|
||||
- **The shared file breaks api/remotes' face-disjointness contract.** `src/types.ts` belongs to both projects, so each emits an identical declaration into the shared `lib/types`. Content is byte-identical and the `.tsbuildinfo` files stay separate, so this is harmless in practice — but the README's build-boundary section must state the exception and its cause (the `paths` entry points at source).
|
||||
- **Any Client plugin can `ctx.emit('remote/host-event', …)`** and synthesize a Host event. This is the same exposure `connection/reset` already has for a fabricated reconnect; the Client is one trust domain. Tests pin the event-to-`$on` conversion and do not pretend the port authenticates its source.
|
||||
- **The allowlist's shape assertion is currently commented out** in `packages/api/remotes/src/index.ts`, together with the allowlist import and the three owner `./types` type-only imports it needs. The three static guarantees described above are therefore inactive right now: adding a Scope-bound or misspelled name would not fail to compile. Restoring it does not change the build graph (those four consumers already left the Host graph) and is required before the pull request.
|
||||
- **Mirrored test values can drift.** Nothing mechanically checks the Client constants mirrored in `apps/web/tests` against their source; the safety net is only that a drift misses a selector. A grep-level gate forbidding `@deepseek-ai/dsh-client-*` imports under `apps/web/tests` would close this and is not part of this change.
|
||||
- **The dynamic subscription erases a handler type.** Subscribing by allowlist requires one erasure at `ctx.on(name, …)`; if the predicate is later relaxed, that assertion loses its static backing.
|
||||
- **Capabilities given up.** No projected or redacted payloads, no Scope-bound events (`agentCtx.remote.$on`), and no replay on reconnect — these are pure invalidation signals, and the existing `connection/reset` already covers refetching after a reconnect. The mux stream's session events, answerable frames, and snapshot baselines stay out of scope.
|
||||
- **Client packages remain in the Host graph.** Twelve projects (`connection`, `runtime`, `ui-slots`, and kin) still reach it through the unsplit `directory-picker-browse`/`-native` pair and `api/gateway → client/connection`. They compile and no longer implicate api/remotes' Client face, so they do not block this change; the root fix is the follow-up above.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Agent Note: Remote 事件投递(ctx.remote.$on)
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-08-10-remote-event-delivery.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[TypeRT Remote 方法调用](2026-08-02-typert-remote-method-calls.zh.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。
|
||||
|
||||
Host 上一族「注册表变了,重新拉一次」的纯失效事件(`commands/change`、`credentials/updated`、`settings/document-updated`)既不依赖 AgentScope、载荷也本来就是 JSON,却要穿过四跳才能到达一个 UI 订阅者:host cordis 事件 → apiproxy 手写 `HostFrame` 变体 + zod → client/runtime 手写桥 `ctx.emit(...)` → 消费者 `ctx.on(...)`。每加一个这类事件要改 5 处(帧联合、zod 联合、host 流监听、client 桥、client 侧重复的 `Events` 声明),而这 5 处没有一处是在陈述新事实——事件名、载荷类型、发射时机全都由 owner 包早已在 cordis `Events` 里声明过。
|
||||
|
||||
那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。
|
||||
|
||||
## 提案
|
||||
|
||||
给消费端 Remote 面补一个单向事件订阅动词 `ctx.remote.$on(event, listener)`;**名单驱动、原样转发**:
|
||||
|
||||
- `packages/api/remotes/src/types.ts` 持有一份可转发 host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。该文件**同时列进本包 host 与 client 两个 face 的 `files`**,两侧读同一份。
|
||||
- wire 上的事件名 **就是 host cordis 事件原名**(`settings/document-updated`),不加 `host/` 前缀;载荷 **就是 host 的实参列表**,逐元素原样过 JSON,无投影、无脱敏、无改名。
|
||||
- 载体**寄生现有 host 流**:`HostFrame` 加一个包裹帧 `host/remote-event`,不新开下行通道。
|
||||
- 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 类型就是 `Events[Event]` 本身。「原样」不需要证明,是构造性成立的。
|
||||
- 但**只借 cordis 的类型形状,不接 cordis 的事件系统**:投递语义、注册表、异常处置全归 TypeRT 自己。
|
||||
|
||||
一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。本次三个包都不需要拆:它们的条目只够到 `SettingsNamespace`、`SettingsUpdateSource`、`CredentialRef`,全是纯类型。
|
||||
|
||||
本次只迁**纯透传**的三条并删除对应 `HostFrame` 变体;带派生逻辑的一律不动:`host/models-changed`(`llm/adapters-updated` 与 provider/agent-default 命名空间过滤的 fan-in)、`host/workspace-changed`/`-removed`/`host/archived-sessions-changed`(需 view 派生 + 每连接 dedup 状态)、`host/session-added`/`-removed`/`host/session-status`/`host/agent-error`(需活对象投影或帧时派生字段)。
|
||||
|
||||
`skills/change`、`tools/change`、`system-prompt/change` 是同形状的纯失效事件但目前**没有任何消费者**,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。
|
||||
|
||||
### 消费端契约(dsh-type-meta)
|
||||
|
||||
type-meta 加一个**形状谓词**、一个**选择座位**和 `TypeRTClientRemote` 的**一个**成员;零运行时代码:
|
||||
|
||||
```ts
|
||||
/** 形状上可以单向远程投递的 cordis 事件名:不绑 Scope,且返回 void。 */
|
||||
export type TypeRTForwardableEvent = {
|
||||
[Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
|
||||
? ReturnType<Events[Event]> extends void ? Event : never
|
||||
: never
|
||||
}[keyof Events]
|
||||
|
||||
/** Host 装配声明的转发选择;由 api/remotes 的名单一次性填满,其他包不填。 */
|
||||
export interface TypeRTRemoteEventSelection {}
|
||||
|
||||
/** `$on` 的合法键:被选中且当前编译面确实存在的事件。 */
|
||||
export type TypeRTRemoteEvent = Extract<keyof Events, keyof TypeRTRemoteEventSelection>
|
||||
```
|
||||
|
||||
```ts
|
||||
/** 订阅一条被转发的 host 事件;返回的 disposer 归调用方 fiber。 */
|
||||
$on<Event extends TypeRTRemoteEvent>(event: Event, listener: Events[Event]): () => void
|
||||
```
|
||||
|
||||
`Events` 按程序解析:host 程序里是 host 事件全集,client 程序里是 client 编译面看得见的那些——同一个谓词在两侧各自成立,不需要把 host 声明拖进 client。
|
||||
|
||||
**消费端只有 `$on`,没有 `$dispatch`。** 帧到订阅表的投递口不进开发者可见契约,也**不能**是一个跨插件的模块级函数:client bundle 纯度门禁(`packages/client/tsdown.client.ts`)只放行 `CLIENT_EXTERNALS`、`INLINE_SAFE` 那层 wire 契约与 `/remote` 生成物三类值导入,而靠 inline 绕过会把 `ClientRemoteService` 复制一份进 runtime bundle、令 `instanceof` 恒假。
|
||||
|
||||
投递口因此是**一条客户端内部 cordis 事件**,声明在 `dsh-type-meta`(两侧共用的单 face 包,runtime 本来就依赖它,所以零新增依赖):
|
||||
|
||||
```ts
|
||||
'remote/host-event'(event: string, args: readonly unknown[]): void
|
||||
```
|
||||
|
||||
持有 host 帧 sink 的 client/runtime 发射它,`ClientRemoteService` 是唯一订阅者并转成 `$on` 回调(`dispatch` 是私有方法,不进 `TypeRTClientRemote`)。这是仓内既有的跨插件 plumbing 形态——`connection/reset` 就是 runtime 声明+发射、`ui-command` 订阅,并有 `runtime/tests/wire-events.spec.ts` 钉住。`event` 形参是 `string` 而非 `TypeRTRemoteEvent`:这是 wire 边界,收到无人订阅的名字即静默丢弃。
|
||||
|
||||
投递语义与 cordis 事件系统不共用实现:只有单向投递,没有 waterfall / bail / parallel / serial 模式,也没有 `@mode` 概念(`ReturnType extends void` 是这条纪律的静态表达);不绑 `this`;没有 `EventOptions`、`prepend`、优先级;按注册顺序逐个调用,单个 listener 抛错就地隔离并记日志——它绝不能拖垮帧泵(沿用 `ConnectionController` 对 sink 异常的既有处置)。
|
||||
|
||||
### 名单:两个 face 共读的一个文件
|
||||
|
||||
`packages/api/remotes/src/types.ts` 同时列进 `tsconfig.host.json` 与 `tsconfig.client.json` 的 `files`,是名单的**唯一家**:
|
||||
|
||||
```ts
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'commands/change',
|
||||
'credentials/updated',
|
||||
'settings/document-updated',
|
||||
] as const
|
||||
|
||||
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
|
||||
}
|
||||
```
|
||||
|
||||
于是**加一个事件只改这一行数组**:类型投影、`$on` 的键面、host 的转发循环全部从它派生。`ctx.remote.$on('slots/changed', …)`(client 本地事件)或 `$on('skills/change', …)`(名单没开)都是**编译错误**。
|
||||
|
||||
host 半再加一处形状断言,把 host 事件词汇的约束落到同一份名单上:
|
||||
|
||||
```ts
|
||||
API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[]
|
||||
```
|
||||
|
||||
写成表达式语句而不是命名常量:后者会被 `noUnusedLocals` 判为未使用(下划线前缀只豁免参数)。它卡住三件事:**名字合法**(谓词以 `keyof Events` 为基)、**不绑 Scope**(`goal/changed` 那族的 `ThisParameterType` 不是 `unknown`,被排除——「不依赖 AgentScope」的静态表达)、**单向**(非 `void` 返回的 waterfall/bail 形状被排除)。
|
||||
|
||||
**「原样」不在任何地方证明,而是构造性成立**:`$on` 的 listener 类型取自 owner 包 `./types` 里那一份 cordis `Events` 声明,host 转发读的是同一份,不存在可以彼此偏离的第二份声明。
|
||||
|
||||
载荷 JSON-safe 交给运行时:apiproxy 转发前用 `dsh-session` 的 `isJsonValue` 逐元素校验,不合格**抛错 fail loud**(这是名单配置错误,不是外部输入)。
|
||||
|
||||
### 线协议(apiproxy)
|
||||
|
||||
```ts
|
||||
| { type: 'host/remote-event'; event: string; args: JsonValue[] }
|
||||
```
|
||||
|
||||
zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必然已是 JSON 值,结构契约由 owner 包的 `Events` 声明承担——与既有 `session/projection` 帧的 `value` 同 posture。
|
||||
|
||||
`events.host()` 打开时按名单挂监听(host 流每条自持 disposers,无需新增广播集合)。**注册位置是契约的一部分**:这段必须挂在 `settings/document-updated` 监听**之前**。cordis 按注册序触发,而 `host/models-changed` 是由同一条 host 事件**派生**出来的失效帧——转发帧排到派生帧之后会让同一次 emit 的两帧顺序相对改动前颠倒(已被两条 config 用例实测到)。规则:**转发帧必须先于由它派生的失效帧**。
|
||||
|
||||
cordis `on` 的键是字面量泛型,按动态名单订阅必须在此擦除一次 handler 类型;这是本变更唯一的类型断言点,其安全性由名单谓词与 `isJsonValue` 校验共同承担。
|
||||
|
||||
`api/events.ts` 是浏览器侧也要编译的 wire 契约文件,所以它引用的每个类型都必须走 owner 包的 **client-safe type-only 子路径**,绝不能走包根出口。实证:从 `@deepseek-ai/dsh-session` 根引一个类型,就把根出口的 `declare module 'cordis' { interface Context { sessions: SessionStore } }` 拖进 client 编译面、把 client 的 `ctx.sessions: ISessions` 顶掉,在完全无关的 `ui-slash` / `ui-conversation` 里炸出 18 条错。`JsonValue` 因此需要 `dsh-session/src/types.ts` 补一条 re-export。
|
||||
|
||||
### apps/web 的 browser e2e 属于 Host 面
|
||||
|
||||
`apps/web/tests/**` 那批 e2e 在**根 `tsconfig.host.json`** 做类型检查:它们在进程内起真 harness、直接摸 `ctx.apiProxy`、host `SessionStore.get/create/flush`、`ctx.sessionProjectionCache`。**运行时用浏览器 ≠ 类型上属于 client 程序**——把它们搬进 client 聚合会立刻报 21 条错,因为一个 program 装不下两个 face 对同一个 Context key 的合并。
|
||||
|
||||
由此得到一条对本设计要紧的连带纪律:**这些测试从客户端包 import 值或类型,会把该包的整个 project——以及它引用的每个 project——拖进 Host 构建图**。`ui-settings-general`/`ui-models`/`ui-permission`/`ui-command` 四个消费者 references `api/remotes` 的 client face,而该 face 必须等 host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 才能编译,于是形成构建期死锁:host tsc → api/remotes client face → `goal/remote` → host tsdown → 排在 host tsc 之后。
|
||||
|
||||
本次的处置是在测试侧**镜像**所需的客户端符号(`scaffold.ts` 导出镜像后的 welcome-notice 常量,两个 chat e2e 直接引 `dsh-client-runtime/client` 因为 `runtime` 工程本来就在 host 图里),从而让那 4 个消费者离开 host 图;`apps/cli/tsconfig.json` 里 15 条 client 工程引用随之失去 owner-map 职责,一并删除。镜像值与源逐字一致,漂移的表现是选择器失配或通知未被抑制,都是响亮失败。
|
||||
|
||||
### 改动清单
|
||||
|
||||
| 位置 | 改动 |
|
||||
|---|---|
|
||||
| `dsh-type-meta` | `src/types.ts` 加 `TypeRTForwardableEvent`、`TypeRTRemoteEventSelection`、`TypeRTRemoteEvent` 与 `'remote/host-event'` 声明;`TypeRTClientRemote` 增 `$on`。纯类型,零运行时 |
|
||||
| `api/gateway` client 半 | `ClientRemoteService` 实现 `$on`(订阅表、`ctx.effect` 归属调用方 fiber、按注册顺序派发并隔离 listener 异常)+ 订阅 `'remote/host-event'`,`dispatch` 保持私有 |
|
||||
| `api/remotes` | 新增 `src/types.ts`(名单 + 类型投影 + 选择座位),双列进两个 face 的 `files`;`./types` 出口 + `files` 补 `lib/types/**/*.js`;host 半加形状断言并 `import type {}` 三个 owner 包的 `./types`;client 半 `export type {}` 那三个 `./types` 与 `@deepseek-ai/dsh-api-gateway/client`;`./invariant` 断言名单内事件的运行期关系(`thisArg === null` + `mode === 'emit'`) |
|
||||
| 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 |
|
||||
| `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) |
|
||||
| `host/apiproxy` | `HostFrame` 增 `host/remote-event`、删 `host/commands-changed`/`-settings-changed`/`-credentials-changed` 三变体及其 zod;`events.host()` 按名单挂监听(位置在 `settings/document-updated` 之前)+ `assertJsonArgs`;`settings/document-updated` 监听保留以继续喂 `host/models-changed` |
|
||||
| `dsh-session` | `src/types.ts` 补 `export type { JsonValue }`,让 wire 契约文件能走 client-safe 子路径 |
|
||||
| `client/runtime` | 桥里三条 `ctx.emit` 换成一行 `ctx.emit('remote/host-event', frame.event, frame.args)`;`Events` 声明删 `commands/changed`/`settings/changed`/`credentials/changed`(`models/changed` 保留) |
|
||||
| 5 个消费者 | ui-command / ui-models / ui-settings-general / ui-permission / ui-agent-preset 改订 `ctx.remote.$on(...)`;照 `ui-goal` 先例 type-only 引 `@deepseek-ai/dsh-api-remotes/client` 并把 `'remote'` 加进 `inject` |
|
||||
| `client/connection` | fixture 的 `emitHost` 造 `host/remote-event` |
|
||||
| `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 |
|
||||
|
||||
## 备选方案
|
||||
|
||||
**给 Remote 事件新开一条通用下行通道**(`ctx.connection.rpc` 的推送对偶,第三条 WebSocket)。最符合「Connection 独占载体、Gateway 不碰传输」;但要同时改 host 下行、`WebApiClient`、`ConnectionController`、fixture 与 web e2e 各一条流,代价与本次收益不匹配。寄生 host 流的代价是新契约暂时寄居在 legacy 帧联合里——host 流将来整体搬家时它随之搬走,消费端契约不变。
|
||||
|
||||
**在 type-meta 立一张独立的 `TypeRTRemoteEventMap`,让 owner 包 declare-merge 进去**。消费端键集会精确等于「被声明为可远程投递的事件」;代价是每条事件的签名要在 cordis `Events` 之外**再写一遍**,于是需要一条双向 `extends` 的等价性证明来防漂移,还要给三个 owner 包新增 type-meta 依赖。共用同一份 `Events` 声明让等价性变成构造性成立,这张表因此不立。
|
||||
|
||||
**让 typert generator 从 host `Events` 声明生成事件投影**(codec + `.d.ts` + 声明映射,与 `/remote` 同族)。generator 已经在分析 host 事件;但它拿不到投影与脱敏语义,且要动生成器与构建面。原样转发这条路本就不需要投影。
|
||||
|
||||
**给可转发事件加载荷投影函数**(`{ 事件名, 投影, zod }` 转发表)。能一举覆盖 `models-changed` 的 fan-in 与 workspace 的 view 派生;代价是投影逻辑与载荷类型手工对齐,回到方法侧刚刚消灭的中心表形态。
|
||||
|
||||
**把 apps/web 的 browser e2e 搬进 client 聚合**。看似「客户端测试归客户端面」,实测立刻 21 条错:它们用 host 服务,而 client 程序里 `ctx.sessions` 是 `ISessions`。已否。
|
||||
|
||||
**给 `directory-picker-browse`/`-native` 做 host/client 双 face 切分**,从根上让客户端包不进 host 图。方向正确(它们确实是未切分的双半包),但与本单的 capability seam 是两件事,且改动落在别人属地——记为独立后续单。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- host emit 三条事件后,真实 host 流各出一帧 `host/remote-event`,`event` 为 host 原名、`args` 与实参逐元素相等(真组合测试)。
|
||||
- 名单在类型层拒绝三类候选:不存在的事件名、绑 Scope 的事件(`goal/changed`)、非 `void` 返回的事件。
|
||||
- `$on` 的键面等于名单:`$on('slots/changed', …)` 与 `$on('skills/change', …)` 都必须编译失败。
|
||||
- `TypeRTClientRemote` 上**不存在** `$dispatch`:开发者可见契约只有 `$on`(加既有 `$mount` 与生成的 namespace)。
|
||||
- 名单内事件发射非 JSON-safe 实参时,`assertJsonArgs` 抛错而非静默降级(对该函数直接单测,不从事件总线造畸形 emit)。
|
||||
- `ctx.remote.$on` 的 disposer 归属调用方 fiber:处置 fiber 后订阅消失。一个 listener 抛错不影响同事件其余 listener,也不中断后续帧投递。
|
||||
- 转发帧与由同一条 host 事件派生的失效帧在同一次 emit 里的顺序与改动前逐帧一致。
|
||||
- 消费端 `$on('settings/document-updated', …)` 的 `ns` 形参解析为 `SettingsNamespace`(brand 未丢)。
|
||||
- 三条 `HostFrame` 变体、client 侧三条 `Events` 声明、client 手写桥的三条分支在同一 PR 内消失;`host/models-changed` 行为不变。
|
||||
- `pnpm run build` 全量通过。
|
||||
|
||||
## 风险
|
||||
|
||||
- **寄生 legacy 帧联合**:新契约暂时住在 apiproxy 的 `HostFrame` 里,读者会误以为 Remote 事件归 apiproxy 拥有。缓解=帧注释指明名单归 `api-remotes`,并在 apiproxy README 的已知欠账里记这条寄居关系。
|
||||
- **共享文件破了 api/remotes 的 face 互斥契约**:`src/types.ts` 同时属于两个 project,两侧各自 emit 一份同名声明到共用的 `lib/types`。内容逐字相同、`.tsbuildinfo` 各自独立,实际无害,但 README 的 Build boundary 节必须写明这条例外及其成因(paths 指向 src)。
|
||||
- **任一 client 插件都能 `ctx.emit('remote/host-event', …)` 伪造一条 host 事件**:与 `connection/reset` 可被伪造成重连同一量级(client 是单一信任域)。测试只钉「事件到 `$on` 的转换」,不假装它有来源鉴别。
|
||||
- **名单的形状断言当前处于注释态**(`packages/api/remotes/src/index.ts`,连同它所需的名单 import 与三条 owner `./types` 的 `import type {}`),因此本节描述的三条静态保证暂未生效:此刻往名单里塞一个 scoped 事件或拼错的名字不会有编译错误。恢复它对构建图无影响(那四个消费者已不在 host 图里),是 PR 前必做项。
|
||||
- **测试侧的镜像会漂移**:`apps/web/tests` 里镜像的客户端常量与源之间没有机械校验,只能靠「漂移即选择器失配」这种响亮失败兜底。理想上该加一条 grep 级门禁禁止 `apps/web/tests` 引入 `@deepseek-ai/dsh-client-*`,本轮未加。
|
||||
- **动态订阅的类型擦除**:按名单 `ctx.on(name, …)` 必须擦一次 handler 类型;若名单谓词将来被放宽,这处断言就不再有静态支撑。
|
||||
- **放弃的能力**:不支持带载荷投影/脱敏的事件、不支持 Scope 化事件(`agentCtx.remote.$on`)、不支持重连重放(纯失效信号,重连后的重新拉取由既有 `connection/reset` 覆盖)。mux 流的 session 事件、可答帧与快照基线不在范围内。
|
||||
- **host 图里仍有客户端包**:`connection`、`runtime`、`ui-slots` 等 12 个工程经 `directory-picker-browse|native`(未切分的双半包)与 `api/gateway → client/connection` 仍在 host 构建图内。它们当前都能编译、且不再牵连 api/remotes 的 client face,所以不阻塞;根治留给上面那条独立后续单。
|
||||
Reference in New Issue
Block a user