|
|
@@ -1,4 +1,4 @@
|
|
|
-# Agent Note: Remote event delivery (ctx.remote.$on)
|
|
|
+# Agent Note: Remote event delivery (`ctx.remote.$on`)
|
|
|
|
|
|
Status: implemented
|
|
|
|
|
|
@@ -6,39 +6,51 @@ English | [中文](2026-08-10-remote-event-delivery.zh.md)
|
|
|
|
|
|
## Problem
|
|
|
|
|
|
-[Typert Gateway targeted method calls](../../implemented/architecture/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.
|
|
|
+[Typert Remote method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) initially cover targeted calls with one result per request and deliberately leave Session streams and stateful interactions elsewhere. Host-to-consumer events need a delivery mechanism that is not owned by the API Proxy domain.
|
|
|
|
|
|
-The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took 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-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge.
|
|
|
+The Host owns one-way events such as `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. They do not depend on AgentScope, and their payloads are already JSON. Requiring every event to cross a handwritten API Proxy frame, a handwritten Client Runtime bridge, and a Client event alias adds no fact beyond the owner event declaration.
|
|
|
|
|
|
-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.
|
|
|
+That duplicate declaration is also lossy: the Client side restates an event as `settings/changed(ns: string)`, flattening a branded type to bare `string`, contrary to the Remote-method rule that consumer types point to the business package's one canonical symbol.
|
|
|
|
|
|
## Decision
|
|
|
|
|
|
-The consumer Remote surface carries one one-way subscription verb, `ctx.remote.$on(event, listener)`, driven by an allowlist and forwarding verbatim:
|
|
|
+The consumer Remote surface has one event-subscription verb, `ctx.remote.$on(event, listener)`, with allowlist-driven, verbatim forwarding:
|
|
|
|
|
|
-- `packages/api/remotes/src/remote-events.ts` holds the allowlist of forwardable Host events, and it is the single control point over what a consumer may subscribe to. `src/types.ts` beside it derives the type projection and fills the selection seat, staying type-only per the package convention. Both files are 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.
|
|
|
+- `packages/api/remotes/src/remote-events.ts` owns one list of forwardable Host events with explicit `emit`/`waterfall` modes. It is also the sole control point for what consumers may subscribe to. Adjacent `src/types.ts` derives the type projection and fills the selection seat while remaining type-only. Both files appear in the `files` of the package's Host and Client faces, so both read one declaration.
|
|
|
+- The event name on the wire is the original Host Cordis name (`settings/document-updated`) without a `host/` prefix. The payload is the Host argument list, element for element through JSON, without projection, redaction, or renaming.
|
|
|
+- `api/remotes` registers the Host source with API Gateway. Gateway reserves internal logical endpoint `$events` on the existing `/api/remote.mux`, adding no physical connection and giving API Proxy no event interpretation. Waterfall results return through HTTP unary endpoint `$events/result`.
|
|
|
+- Event signatures have no second table. Owner packages place their Cordis `Events` declarations in Client-safe, type-only `./types` exports so both faces read the same declaration. `$on` listener parameters, result, and `next()` derive from `Events[Event]`; verbatim correspondence holds by construction.
|
|
|
+- Only Cordis's type declarations are shared. Delivery semantics, registration, and failure handling 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 five packages needs that here: their entries reach only `SettingsNamespace`, `SettingsUpdateSource`, `CredentialRef`, and `SessionId`, all pure types. The agent-presets package renames its previous vocabulary module to `preset.ts`, leaving the exported `types.ts` dedicated to the client-safe event declaration.
|
|
|
+When an `Events` member reaches a Host-only symbol such as a Service, `Agent`, or Context, the code is split until the declaration can live cleanly in `./types`. A declaration is never split between `index.ts` and `types.ts`, and `types.ts` does not invent a structurally equivalent shadow type. Every current owner exposes its selected event declaration from a Client-safe type export.
|
|
|
|
|
|
-All five events ride this path, and their dedicated `HostFrame` variants or Client aliases are gone. Model consumers subscribe directly to both owner inputs, `llm/adapters-updated` and `settings/document-updated`; preset-derived consumers subscribe to `agent-preset/selected`. Frames that actually project or deduplicate data stay dedicated: `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).
|
|
|
+All allowlisted events use this path, and dedicated frames and Client aliases are removed. Model consumers subscribe directly to `llm/adapters-updated` and `settings/document-updated`; preset consumers subscribe to `agent-preset/selected`; stateless Session and dynamic-Cordis notifications use `emit`; Approval and Question use Agent-scoped `waterfall`. Data that needs a baseline, projection, or deduplication retains a dedicated Remote stream.
|
|
|
|
|
|
-`skills/change`, `tools/change`, and `system-prompt/change` have the same shape but **no shipped consumer**; under "require a current owner and need" they stay out of the allowlist and are recorded here only as the extension seat.
|
|
|
+`skills/change`, `tools/change`, and `system-prompt/change` have the same pure invalidation form but no shipped consumer. The rule that every abstraction needs a current owner and need keeps them outside the allowlist; they remain only an extension point recorded here.
|
|
|
|
|
|
-### Consumer contract (dsh-typert-protocol)
|
|
|
+### Consumer contract (`dsh-typert-protocol`)
|
|
|
|
|
|
-type-meta gains one **shape predicate**, one **selection seat**, and **one** member on `TypertClientRemote`. No runtime code:
|
|
|
+Type metadata adds event-form predicates, mode entries, a selection seat, and one member of `TypertClientRemote`, with no runtime code:
|
|
|
|
|
|
-```ts
|
|
|
+```ts ignore-check
|
|
|
import type { Events } from '@deepseek-ai/cordis'
|
|
|
|
|
|
-/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */
|
|
|
+type TypertForwardingMode<Event extends keyof Events> =
|
|
|
+ unknown extends ThisParameterType<Events[Event]>
|
|
|
+ ? TypertEventResult<Event> extends void ? 'emit' : never
|
|
|
+ : TypertWaterfallEvent<Event> extends never ? never : 'waterfall'
|
|
|
+
|
|
|
+/** Cordis event names that can cross the Remote Event carrier without a second signature. */
|
|
|
export type TypertForwardableEvent = {
|
|
|
- [Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
|
|
|
- ? ReturnType<Events[Event]> extends void ? Event : never
|
|
|
+ [Event in keyof Events]: TypertForwardingMode<Event> extends never ? never : Event
|
|
|
+}[keyof Events]
|
|
|
+
|
|
|
+/** Event and dispatch mode accepted by the Remote Event source. */
|
|
|
+export type TypertForwardableEventEntry = {
|
|
|
+ [Event in keyof Events]: TypertForwardingMode<Event> extends infer Mode
|
|
|
+ ? Mode extends 'emit' | 'waterfall'
|
|
|
+ ? { readonly event: Event; readonly mode: Mode }
|
|
|
+ : never
|
|
|
: never
|
|
|
}[keyof Events]
|
|
|
|
|
|
@@ -51,126 +63,136 @@ export type TypertRemoteEvent = Extract<keyof Events, keyof TypertRemoteEventSel
|
|
|
|
|
|
```ts ignore-check
|
|
|
/** Subscribe to one forwarded Host event; the returned disposer belongs to the calling fiber. */
|
|
|
-$on<Event extends TypertRemoteEvent>(event: Event, listener: Events[Event]): () => void
|
|
|
+$on<Event extends TypertRemoteEvent>(event: Event, listener: TypertClientEventListener<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.
|
|
|
+`Events` resolves per program: the complete Host event vocabulary in a Host program and only declarations visible to the Client compilation face in a Client program. The same predicate therefore holds on both sides without bringing Host declarations into the Client.
|
|
|
|
|
|
-**The surface separates the consumer verb from the carrier handoff**: consumers subscribe with `$on`, and whoever owns the Host frame sink hands each decoded frame over with `$dispatch`. 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 the implicit `PLATFORM_MODULES` plus `PRELOADED_CLIENT_EXTERNALS` baseline, the package's `dsh.client.external` requests, the `INLINE_SAFE` wire layer, and generated `/remote` contributions. Inlining around it would copy `ClientRemoteService` into the runtime bundle, making `instanceof` permanently false. A cordis service method is the collaboration shape that gate prescribes:
|
|
|
+**The contract exposes only the consumer verb.** `ClientRemoteService` registers the one internal `$events` pump as a Connection generation source when it activates, independently of whether any `$on` subscription exists. Browsers open `$events` through the shared Remote mux; in-process compositions open the same logical stream through `connection.rpc.open`. Decoding, exact item validation, and Cordis dispatch are private Gateway Client implementation. `TypertClientRemote` exposes no producer operation, so a business plugin cannot synthesize a Host event.
|
|
|
|
|
|
-```ts ignore-check
|
|
|
-$dispatch(event: string, args: readonly unknown[]): void
|
|
|
-```
|
|
|
+Each time the Host opens `$events`, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening `{ type: 'ready' }` before iterating the event source. `ConnectionController` waits for that ready item and `host.describe` in parallel and publishes `connected` only after both succeed, so baseline reads cannot race ahead of incremental listeners.
|
|
|
|
|
|
-client/runtime — the owner of the host frame sink — calls it directly, so the frame reaches the subscription table without an intermediate event to relay it. The `event` parameter is `string`, not `TypertRemoteEvent`: this is a wire boundary, and a name nobody subscribed to is dropped silently.
|
|
|
+A physical mux disconnect ends the logical stream with `RemoteStreamCarrierError`. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation's `hostDescription` and reopens `$events` and `host.describe` after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat `$on` as a reliable journal.
|
|
|
|
|
|
-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 Client dispatches on a Cordis key private to each Remote instance. Ordinary `emit` uses `parallel()` and contains listener failures; Agent-scoped `waterfall` uses `waterfall()` on the resolved Agent Context and allows a result, rejection, or `next()` delegation. Both registration kinds belong to the calling fiber, and Host events do not trigger same-named Client-local events.
|
|
|
|
|
|
-### The allowlist: one declaration both faces read
|
|
|
+### The allowlist: one declaration read by both faces
|
|
|
|
|
|
-`packages/api/remotes/src/remote-events.ts` is listed in the `files` of both `tsconfig.host.json` and `tsconfig.client.json`, and is the allowlist's single home; `src/types.ts` derives its type face:
|
|
|
+`packages/api/remotes/src/remote-events.ts` appears in both `tsconfig.host.json` and `tsconfig.client.json` and is the allowlist's sole home. `src/types.ts` derives the type face:
|
|
|
|
|
|
-```ts
|
|
|
+```ts ignore-check
|
|
|
// remote-events.ts — the value
|
|
|
export const API_REMOTE_FORWARDED_EVENTS = [
|
|
|
- 'agent-preset/selected',
|
|
|
- 'commands/change',
|
|
|
- 'credentials/reference-updated',
|
|
|
- 'llm/adapters-updated',
|
|
|
- 'settings/document-updated',
|
|
|
-] as const
|
|
|
+ { event: 'agent-preset/selected', mode: 'emit' },
|
|
|
+ { event: 'approval/request', mode: 'waterfall' },
|
|
|
+ ...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })),
|
|
|
+ { event: 'commands/change', mode: 'emit' },
|
|
|
+ { event: 'credentials/reference-updated', mode: 'emit' },
|
|
|
+ { event: 'cordis/request-run', mode: 'emit' },
|
|
|
+ { event: 'cordis/request-run-resolved', mode: 'emit' },
|
|
|
+ { event: 'cordis/dynamic-package', mode: 'emit' },
|
|
|
+ { event: 'cordis/dynamic-retract', mode: 'emit' },
|
|
|
+ { event: 'cordis/inspect-query', mode: 'emit' },
|
|
|
+ { event: 'cordis/inspect-query-resolved', mode: 'emit' },
|
|
|
+ { event: 'llm/adapters-updated', mode: 'emit' },
|
|
|
+ { event: 'settings/document-updated', mode: 'emit' },
|
|
|
+ { event: 'user-questions/request', mode: 'waterfall' },
|
|
|
+] as const satisfies readonly TypertForwardableEventEntry[]
|
|
|
|
|
|
// types.ts — the type face, derived
|
|
|
-export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
|
|
|
+export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event']
|
|
|
|
|
|
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
|
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**.
|
|
|
+Adding an event is therefore one array entry: type projection, the `$on` key set, Host dispatch mode, and the forwarding loop all derive from it. `ctx.remote.$on('slots/changed', …)` for a Client-local event and `$on('skills/change', …)` for a declared but unselected event are compile errors.
|
|
|
|
|
|
-The Host face adds one shape assertion, binding the Host event vocabulary to that same array:
|
|
|
+The declaration's trailing `satisfies` applies Host event-vocabulary and mode constraints to the same allowlist:
|
|
|
|
|
|
```ts ignore-check
|
|
|
-API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[]
|
|
|
+API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEventEntry[]
|
|
|
```
|
|
|
|
|
|
-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).
|
|
|
+It enforces three properties: the name exists because the predicate is keyed by `keyof Events`; the selected mode matches the signature; and the signature is either an unscoped `void` notification or a waterfall with top-level Agent scope, a same-result `next()`, and a Promise return. Other Scope, bail, parallel, and serial forms are excluded.
|
|
|
|
|
|
-**"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.
|
|
|
+Verbatim correspondence is not proved separately because it holds by construction. `$on`'s listener type and Host forwarding both read the owner package's one Cordis `Events` declaration, so no second declaration can 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.
|
|
|
+JSON safety remains a runtime concern. Before queueing, the API Remotes Host source checks every argument with `dsh-session`'s `isJsonValue` and fails loudly when one is invalid, because this is an allowlist composition error rather than untrusted input.
|
|
|
|
|
|
-### Wire contract (apiproxy)
|
|
|
+### Wire protocol (API Gateway Remote mux)
|
|
|
|
|
|
```ts ignore-check
|
|
|
-| { type: 'host/remote-event'; event: string; args: JsonValue[] }
|
|
|
+ready { type, clientId }
|
|
|
+emit { type, event, args }
|
|
|
+waterfall { type, event, eventId, agentId, request }
|
|
|
+cancel { type, eventId }
|
|
|
```
|
|
|
|
|
|
-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`.
|
|
|
+The Client opens internal logical stream `$events` with payload `{ args: {} }`. Gateway rejects extra parameters, a missing Host source, and duplicate source registration. Withdrawing a source aborts every stream opened by that registration. Each Client stream owns an independent queue and allowlist listener set in `api/remotes`, so disconnecting one Client neither consumes nor withdraws another Client's events.
|
|
|
|
|
|
-`events.host()` subscribes by allowlist when the stream opens. Each stream owns its disposers, so no broadcast set or derived invalidation listener is needed.
|
|
|
+The Client requires an opening `ready` item with a non-empty `clientId`; every later item is checked for exact fields by discriminant. An ordinary `emit` with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use `eventId` to correlate `$events/result` and `agentId` to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields.
|
|
|
|
|
|
-`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-input-trigger` and `ui-conversation`. `JsonValue` therefore needs a re-export from `dsh-session/src/types.ts`.
|
|
|
+`$events` is an internal Gateway endpoint. It does not enter a generated Typert Remote descriptor or become `ctx.remote.<namespace>`. Application selection exists only in the API Remotes allowlist and Host source; Gateway owns registration, payload validation, and physical transport only.
|
|
|
|
|
|
-### The apps/web browser e2e belong to the Host face
|
|
|
+### The `apps/web` browser e2e belongs 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.
|
|
|
+The `apps/web/tests/**` e2e files typecheck in root `tsconfig.host.json`: they boot a real harness in process and directly access `ctx.apiProxy`, Host `SessionStore.get/create/flush`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate 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-settings-models`, `ui-permission`, `ui-commands`) 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 implies one build rule needed by the design: importing a value or type from a Client package in those tests brings that package's whole project and all its project references into the Host build graph. Four consumers (`ui-settings-general`, `ui-settings-models`, `ui-permission`, and `ui-commands`) reference API Remotes' Client face, which cannot compile until Host tsdown generates `@deepseek-ai/dsh-goal/remote`. That forms a build-order cycle: Host tsc needs API Remotes Client, which needs generated `goal/remote`, which Host tsdown emits after Host tsc.
|
|
|
|
|
|
-The few Client-owned symbols are therefore **mirrored** 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` lost their owner-map role and are gone. Each mirrored value matches its source verbatim; a drift shows up as a missed selector or an unsuppressed notice, both loud failures.
|
|
|
+The few required Client symbols are mirrored on the test side: `scaffold.ts` exports the mirrored welcome-notice constants, while the two chat e2e files import `dsh-client-runtime/client` directly because the Runtime project already belongs to the Host graph. This removes those four consumers from the Host graph, and the 15 Client project references in `apps/cli/tsconfig.json` no longer serve an owner-map role. Each mirror is byte-identical to its source; drift produces a selector mismatch or an unsuppressed notice and fails loudly.
|
|
|
|
|
|
### Change inventory
|
|
|
|
|
|
| Location | Change |
|
|
|
|---|---|
|
|
|
-| `dsh-typert-protocol` | `src/types.ts` gains `TypertForwardableEvent`, `TypertRemoteEventSelection`, and `TypertRemoteEvent`; `TypertClientRemote` gains `$on` and `$dispatch`. Types only, no runtime |
|
|
|
-| `api/gateway` Client half | `ClientRemoteService` implements `$on` (subscriptions addressed by registration, `ctx.effect` ownership for the calling fiber) and `$dispatch` (snapshot delivery in registration order, containing a listener that throws or rejects) |
|
|
|
-| `api/remotes` | New `src/remote-events.ts` (the allowlist value) and `src/types.ts` (type projection, selection seat), both 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 five owner `./types`; the Client half re-exports those five plus `@deepseek-ai/dsh-api-gateway/client` |
|
|
|
-| Root `tsconfig.base.json` | Client-safe `paths` entries for settings, credentials, llm, agent-presets, and api-remotes types point at the **source** plane |
|
|
|
-| `dsh-commands` / `dsh-settings` / `dsh-credentials` / `dsh-llm` / `dsh-agent-presets` | Each forwarded `interface Events` member lives in the owner's client-safe `./types`; agent-presets moves its previous domain vocabulary to `preset.ts` so the exported file itself remains `types.ts` |
|
|
|
-| `host/apiproxy` | `HostFrame` gains `host/remote-event` and loses the five dedicated passthrough or invalidation variants with their zod branches; `events.host()` subscribes by allowlist and validates through `assertJsonArgs` |
|
|
|
-| `dsh-session` | `src/types.ts` re-exports `JsonValue` so wire contract files can use the client-safe subpath |
|
|
|
-| `client/runtime` | The five Client-event bridge branches collapse into `ctx.remote.$dispatch(frame.event, frame.args)`, adding a `remote` injection and deleting their duplicated `Events` declarations |
|
|
|
-| Seven consumers | ui-commands / ui-model-selection / ui-settings-models / ui-settings-general / ui-permission / ui-agent-preset / ui-skill 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 |
|
|
|
+| `dsh-typert-protocol` | `src/types.ts` provides forwardable-mode derivation, selection, and Client-listener projection; `TypertClientRemote` exposes only `$on`. Types only, no runtime |
|
|
|
+| `api/gateway` | Host provides one Remote event source, `$events`, pending-waterfall coordination, and `$events/result`; Client registers the private pump as the Connection generation source and owns frame validation and Cordis dispatch |
|
|
|
+| `api/remotes` | `src/remote-events.ts` (mode-bearing allowlist value) and `src/types.ts` (key projection and selection) belong to both faces; Host registers each Client source and validates JSON before queueing; Client continues to compose generated Remote contributions |
|
|
|
+| Root `tsconfig.base.json` | Adds source-plane `paths` entries for `dsh-settings/types`, `dsh-credentials/types`, and `dsh-api-remotes/types` |
|
|
|
+| `dsh-commands` / `dsh-settings` / `dsh-credentials` | Moves each `interface Events` member to the owner's Client-safe `./types`; settings and credentials add that export, move brands and pure types with it, retain constructors in index, and include `lib/types/**/*.js` in published files |
|
|
|
+| `host/apiproxy` | Contains no `HostFrame`, `events.host()`, or other Host downlink carrier; API Proxy does not participate in Host events or Connection generation |
|
|
|
+| `dsh-session` | Exposes `isJsonValue` for validation of every event argument by the API Remotes Host source |
|
|
|
+| `client/runtime` | Removes the bridge from Host frames to the Remote subscription table; it only publishes `connection/reset` after a Connection generation is established |
|
|
|
+| Consumers | Client plugins subscribe directly through `ctx.remote.$on(...)`, import owner event declarations type-only, and inject `'remote'` |
|
|
|
+| `client/connection` | Provides the one generation-source registration point; `ConnectionController` combines `$events` ready with `host.describe`, and the fixture emits events from the same source |
|
|
|
+| `apps/web/tests` + `apps/cli` | Mirrors Client symbols on the test side as described above and removes 15 Client project references from `apps/cli/tsconfig.json` |
|
|
|
|
|
|
## 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.
|
|
|
+**Continue using API Proxy's Host downlink.** This reuses Connection generation and `connection/reset` but leaves the Remote event allowlist, queue, schema, and Client Runtime bridge in API Proxy and prevents domain transports from sharing the lifecycle of other Remote streams. With API Gateway's resident `/api/remote.mux`, `$events` adds only one internal logical stream and belongs naturally in Gateway.
|
|
|
|
|
|
-**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.
|
|
|
+**Open a third physical WebSocket or duplex stream for Remote events.** An independent channel could own connection state but would duplicate authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect backoff already provided by Gateway mux. Internal `$events` retains an independent logical stream, while waterfall results reuse HTTP unary calls.
|
|
|
|
|
|
-**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.
|
|
|
+**Declare a separate `TypertRemoteEventMap` in type metadata and let owner packages declaration-merge into it.** The consumer key set would exactly equal remotely deliverable events, but every signature would be written again outside Cordis `Events`, requiring a bidirectional equivalence proof and new type-metadata dependencies for owner packages. Sharing one `Events` declaration makes equivalence structural, so the second map is not created.
|
|
|
|
|
|
-**Give forwardable events a payload projection function** (a `{ name, project, zod }` forwarding table). This could fold the two model-directory inputs into one derived invalidation and also cover workspace view derivation, at the cost of hand-aligning projection logic with payload types — the central table the method side just removed.
|
|
|
+**Have the Typert generator project Host `Events` declarations.** The generator already analyzes Host events, but it cannot infer projection or redaction intent and would expand the generator and build surface. Verbatim forwarding needs no projection.
|
|
|
|
|
|
-**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`.
|
|
|
+**Give forwardable events a payload projection function.** A `{ event, project, zod }` table could combine model-directory inputs and derive Workspace views, but would manually align projection logic with payload types and recreate the central table removed from Remote methods.
|
|
|
|
|
|
-**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 the change lands in another owner's packages and buys only a cleaner build graph; once this design mirrors the Client symbols on the test side, it no longer needs the split. **Assessed and declined.**
|
|
|
+**Move the `apps/web` browser e2e into the Client aggregate.** The intuition that browser tests belong to the Client face fails with 21 errors because the tests use Host services while the Client program's `ctx.sessions` is `ISessions`.
|
|
|
|
|
|
-## Verification
|
|
|
+**Split `directory-picker-browse`/`-native` into Host and Client faces.** This would remove Client packages from the Host graph, but changes another owner's packages for only a cleaner build graph. Mirroring the required Client symbols on the test side removes the need for that split.
|
|
|
|
|
|
-What pins this behavior:
|
|
|
+## Verification
|
|
|
|
|
|
-- A real composition test puts one `host/remote-event` frame on the real host stream per Host emit, with `event` the Host name and `args` equal element for element.
|
|
|
-- Type-level negatives reject three candidate classes: a name that is not an event, a Scope-bound event (`goal/changed`), and an event whose return is not `void`. `$on('slots/changed', …)` (Client-local) and `$on('skills/change', …)` (declared but unselected) both fail to compile, so `$on`'s key surface equals the allowlist.
|
|
|
-- On the consumer side, `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace`: the brand survives the wire.
|
|
|
-- `$on`'s disposer belongs to the calling fiber, and two registrations of one function object retire independently — a table keyed on listener identity would collapse them, so subscriptions are addressed by registration.
|
|
|
-- Delivery contains a listener that throws AND one that rejects a returned promise: the declared return is `void`, so nobody awaits an async listener, and its rejection would otherwise escape this containment entirely. Delivery iterates a snapshot, so subscribing or disposing mid-frame cannot change who receives that frame.
|
|
|
-- `assertJsonArgs` is unit-tested directly rather than by driving a malformed emit through the event bus: a typed `ctx.emit` cannot construct one, since every allowlisted event has a statically JSON-safe payload.
|
|
|
-- The five dedicated `HostFrame` variants, five Client-side aliases, and their bridge branches are absent. The model directories observe both owner inputs, while command, skill, and session-row consumers observe the preset owner's committed-selection event.
|
|
|
+- A real Host-source composition test proves that two Client streams each receive `{ event, args }`, disconnecting one does not affect the other, and non-JSON arguments fail loudly without poisoning later valid delivery.
|
|
|
+- Type negatives reject unselected events, non-`void` unscoped events, non-Agent-scoped waterfalls, and allowlist modes that disagree with signatures. `$on('slots/changed', …)` and `$on('skills/change', …)` both fail to compile, so `$on`'s key set equals the allowlist.
|
|
|
+- Consumer `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace`, preserving the brand across the wire.
|
|
|
+- A `$on` disposer belongs to the calling fiber, and registering the same function object twice produces independently removable registrations; subscriptions are addressed by registration rather than listener identity.
|
|
|
+- Ordinary notifications contain both a throwing listener and a listener returning a rejected Promise. Waterfall tests pin Client result, `next()`, rejection, cancellation, first claim across multiple Clients, and reconnect replay of a pending request.
|
|
|
+- Gateway tests cover missing, duplicate, and withdrawn sources; payload rejection; ready-before-event ordering; and browser and in-process carriers. Client tests cover generation-source registration, description/increment readiness order, reopen after physical failure, Host errors and unexpected completion, non-ready opening items, malformed event items, `$events/result` failure, and disposal quiescence.
|
|
|
+- `host/remote-event`, public `$dispatch`, the Client Runtime bridge, and API Proxy's allowlist dependency are absent; consumers observe owner events directly.
|
|
|
|
|
|
## Consequences
|
|
|
|
|
|
-- **Tenancy inside a legacy frame union.** The contract 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. When the host stream moves off that package, the wrapper moves with it and the consumer contract does not change.
|
|
|
-- **Two files break api/remotes' face-disjointness contract.** `src/remote-events.ts` and `src/types.ts` belong 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; the README's build-boundary section states the exception and its cause (the `paths` entry points at source).
|
|
|
-- **The carrier handoff is developer-visible.** Any Client plugin holding `ctx.remote` can call `$dispatch` and synthesize a forwarded event. That exposure predates the verb — `ctx.emit` was equally reachable while an internal event relayed the frame — and matches what `connection/reset` already allows for a fabricated reconnect; the Client is one trust domain. Tests pin the handoff-to-`$on` conversion and do not pretend the port authenticates its caller.
|
|
|
-- **A malformed argument fails in the emitter's containment, not at load.** `assertJsonArgs` throws inside the forwarding listener, so the emitting seam's listener containment logs it and drops that frame: loud in the Host log rather than at load or at the emit point.
|
|
|
-- **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. The rule lives in `apps/web/tests/README.md` and is held by review — a grep-level gate was considered and deliberately skipped.
|
|
|
-- **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 `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 did not block this change; splitting those packages would remove a few but was assessed and declined. The two chat e2e importing `dsh-client-runtime/client` rely on `runtime` already being in that graph — incidental, not a guarantee.
|
|
|
-- **The invariant companion holds no runtime check.** An earlier revision asserted the dispatch shape (`thisArg === null`, `mode === 'emit'`) over the live event bus, which coupled the companion to the allowlist value and made rolldown hoist it into a third bundle chunk the mechanical publication list does not carry. The Host face's `TypertForwardableEvent` assertion already refuses both deviations at compile time, so the companion is an explained empty installer.
|
|
|
+- **Gateway has one non-generated endpoint.** `$events` has no business namespace and does not enter the Typert descriptor. It is the internal connection point between Gateway and API Remotes and defines the Client Connection generation lifetime. Strict empty-payload validation, opening-ready validation, and single-source registration prevent it from becoming another handwritten business API.
|
|
|
+- **Two files break API Remotes' face-disjointness rule.** `src/remote-events.ts` and `src/types.ts` belong to both projects and emit identical declarations into shared `lib/types`. Their content is byte-identical and `.tsbuildinfo` files remain separate, so this is safe in practice; the README records why source-plane `paths` require the exception.
|
|
|
+- **Producer operations remain private.** Business plugins can call only `$on`. Host-source registration and Client dispatch are absent from `TypertClientRemote`; test doubles drive subscriptions through their own `emit` operations rather than impersonating a production API.
|
|
|
+- **Malformed arguments fail at emit.** An API Remotes listener throws before queueing, so Host `ctx.emit` immediately observes an allowlist composition error and the queue can still deliver subsequent valid events.
|
|
|
+- **Test-side mirrors can drift.** No mechanism compares mirrored Client constants under `apps/web/tests` with their source. Drift instead produces a selector mismatch. `apps/web/tests/README.md` records the review rule; a grep-level gate is deliberately omitted.
|
|
|
+- **Capabilities deliberately omitted.** Payload projection and redaction are unsupported, scopes other than Agent are unsupported, and ordinary notifications are not replayed. Recoverable state needs a query, cursor, or opening baseline; a waterfall is replayed only while its original Host invocation remains pending.
|
|
|
+- **Some Client packages remain in the Host graph.** Twelve projects, including `connection`, `runtime`, and `ui-slots`, remain reachable through unsplit `directory-picker-browse`/`-native` and `api/gateway → client/connection`. They compile and no longer pull in API Remotes' Client face, so this change does not split them. Direct `dsh-client-runtime/client` imports in two chat e2e files rely on Runtime's current presence in that graph rather than a general guarantee.
|
|
|
+- **The invariant companion intentionally has no runtime check.** A prior revision asserted delivery form on the live event bus, coupling the companion to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face `TypertForwardableEventEntry` assertion already rejects those mismatches at compile time, so the companion is an explained empty installer.
|