diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index 0e4340ea02..7606190f02 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.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-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: 1706cc525bd7707874353a6658ec2f52597044a9 -2026-08-10-remote-event-delivery.zh.md: ac79642e90791892870c8920e02867faea180b07 +2026-08-10-remote-event-delivery.md: 3eaa26fb92c40b3a20cab11d4522bcf68b8a207f +2026-08-10-remote-event-delivery.zh.md: d41589f1b800da98342210d2e429a2b67b6c61f2 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index 1706cc525b..3eaa26fb92 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -195,3 +195,7 @@ The few required Client symbols are mirrored on the test side: `scaffold.ts` exp - **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 package intentionally publishes no invariant companion.** A prior revision asserted delivery form on the live event bus, coupling diagnostics 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, and the package README records why no independent runtime relation remains. + +## Related + +- [Remote duplex stream](2026-09-19-remote-duplex-stream.md): every Remote stream carries a Client uplink on the same logical stream; the duplex `$events` listed under its Deferred section replaces the unary `$events/result` path once it lands. diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index ac79642e90..d41589f1b8 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -197,3 +197,7 @@ Client 要求首项是带非空 `clientId` 与 `host.home` 的 `ready`;后续 - **放弃的能力**:不支持投影或脱敏载荷,不支持 Agent 以外的 Scope,也不为普通通知提供重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;waterfall 只重放仍处于同一次 Host 调用生命周期内的 pending request。 - **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。 - **本包不发布 invariant companion**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让诊断逻辑与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。Host 面的 `TypertForwardableEventEntry` 断言已在编译期拒绝这些偏离,包 README 也记录了不再存在独立运行时关系的原因。 + +## 相关 + +- [Remote 双工流](2026-09-19-remote-duplex-stream.zh.md):所有 Remote 流在同一逻辑流上承载客户端上行;其「后续」列出的双工 `$events` 落地后取代 unary `$events/result` 路径。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index c27ef04676..9947e91ff2 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.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-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9 -2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a +2026-09-11-node-office-kit.md: b8e94bf6b392edf7931346400fda9ca5d3416188 +2026-09-11-node-office-kit.zh.md: 41b84f26c8150db90f09e4ae2b01abb3dddfdc6d diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index 6cab947d7c..b8e94bf6b3 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -14,7 +14,7 @@ The [office-to-pdf capability](../../../../packages/document/README.md) delegate The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills. -The service, Remote methods, and Client registration accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. +The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The Client preview registers DOC, DOCX, PPT, and PPTX; XLS and XLSX use the unsupported-preview state and can open in the system default application without requesting conversion. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index 8ed1a81922..41b84f26c8 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -14,7 +14,7 @@ Status: implemented [平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。 -服务、Remote 方法和 Client 注册接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 +服务与 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client 预览注册 DOC、DOCX、PPT 和 PPTX;XLS 与 XLSX 使用不支持预览的空状态,可由系统默认应用打开,并且不请求转换。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml new file mode 100644 index 0000000000..d5903d0fc2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-09-19-remote-duplex-stream.md +2026-09-19-remote-duplex-stream.md: 0f1f6a575f6871d37822c5829fe4531261b8e95b +2026-09-19-remote-duplex-stream.zh.md: d3982aa433aa3ce84b945bb5831cef8064472853 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md new file mode 100644 index 0000000000..0f1f6a575f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md @@ -0,0 +1,597 @@ +# Agent Note: Remote duplex stream: one kind of stream, an uplink channel, and an invocation context + +Status: implemented + +English | [中文](2026-09-19-remote-duplex-stream.zh.md) + +## Problem + +### Current state + +`dsh-api-gateway` multiplexes every Typert Remote stream over one WebSocket at the fixed path `/api/remote.mux` (`packages/api/gateway/src/stream-protocol.ts`). A Host method decorated with `@Remote({ mode: 'stream' })` that returns an `Iterable` or `AsyncIterable` becomes one Host-to-Client logical stream; the optional final `signal: AbortSignal` is the only reserved parameter, never enters the wire arguments, and is appended by the Gateway after the decoded business parameters (`packages/api/gateway/src/index.ts`). The generated method the Client receives returns a bare `AsyncIterable`. + +There are only five wire frames: + +| Direction | Frame | Definition | +| --- | --- | --- | +| Client to Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` | +| Client to Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` | +| Host to Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | +| Host to Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` | +| Host to Client | `{ type: 'end', streamId }` | `stream-protocol.ts` | + +The Client can send only open and cancel. The parser `parseRemoteStreamClientMessage` (`stream-protocol.ts`) throws on any other frame, and a Host connection that receives one closes the whole socket with 1008 (`stream-server.ts`). In short, the Gateway today supports only "a backend iterable sent to the frontend". Every feature that needs the frontend to send data to the backend continuously takes its own detour. + +A Host method also has no notion of who initiated this call: `InvokeRemoteRequest = { namespace, method, args, signal }` (`packages/api/gateway/src/types.ts`) has no caller slot, `RemoteStreamOpener` has the signature `(endpoint, payload, signal)` (`stream-server.ts`), and the WebSocket checks the cookie once, at the handshake. + +### Three unrelated uplink mechanisms + +| Feature | Uplink path | Location | Cost | +| --- | --- | --- | --- | +| Web terminal keystrokes | One unary RPC `terminal.write(agent, id, attachmentId, data)` per xterm `onData`; the Client serializes them through a promise chain and caps its own byte budget | `packages/api/terminal-controller/src/client/model.ts`; Host `src/index.ts` | One HTTP round trip and one authentication per keystroke; two carriers correlated by hand through `attachmentId`; `inputFull` when the budget is exhausted | +| File upload | A `Blob` or `ReadableStream` goes to the native `POST /api/session/uploadFileBinary` outside the Remote layer, with `duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`; route `src/index.ts` | Bypasses Typert descriptors; no type projection | +| Answers to approvals and questions | The Host sends waterfall frames down the `$events` stream, the Client answers through a separate unary RPC `$events/result`, correlated by `clientId` plus `eventId` | `packages/api/gateway/src/client/remote-events.ts`; Host `index.ts` | A second correlation key; `index.ts` records the race this creates | + +These three mechanisms solve the same problem: the Client wants to write into a logical stream that is already open. + +### Concrete costs + +- **Aligning two carriers.** The terminal uses `attachmentId` to correlate the `follow` stream with the `write` RPC and to decide who owns input. Approvals use `clientId` to correlate the `$events` stream with the `$events/result` RPC. +- **No ordering guarantee.** A unary RPC travels over HTTP and is unordered relative to the WebSocket downlink; the terminal can preserve order only by serializing writes on the Client. +- **Separate authentication and lookup per uplink.** Every `write(agent, id, attachmentId, data)` resolves `agent`, looks up the terminal, and validates the attachment. +- **No EOF.** The Client cannot express "I am done writing"; a half-close needs yet another RPC. +- **Cancellation does not cover both halves.** Cancelling the downlink stream does not cancel queued uplink writes, and vice versa. +- **Backpressure implemented three times.** The downlink pulls the iterator at the pace of the socket write callback (`stream-server.ts`); the terminal uses a bounded follower that makes a slow consumer fail explicitly (`terminal-controller/src/stream.ts`); the uplink relies on the Client capping `maxInputBytes` itself. +- **The documentation is already stale.** `docs/api-gateway.md:160` still says Remote handles one request and one result, a sentence that lags the stream mode the README records. + +### Relation to background jobs + +The process-shaped duplex stream face of background jobs (archived as a draft PR on branch `worktree/job-stdio-stream-pair-archive`) models a job as a pair of mirrored stream faces; for the browser to attach to it, one Remote stream must carry both a downlink and an uplink. It is one motivation for this Note but not in its scope: whether jobs need a dedicated read-write stream is an architecture question for the jobs line itself. This Note covers only the transport layer. + +### Goals + +1. The transport layer has one kind of stream, duplex by nature: downlink `item` / `end` / `error`, uplink `item` / `end`, one `streamId`, one generation. +2. The uplink type and the downlink type are written in the same place: the method's return type. +3. A Host method takes its uplink from the invocation context; the invocation context also carries the caller (the Peer). +4. The Client receives a handle that can read, write, and close, not a bare `AsyncIterable`. +5. Upper-layer protocols (snapshot, journal, any business-defined stream) stay out of the transport layer. + +## Decision + +### In one sentence + +Every Remote stream accepts a Client uplink. The method's return type `RemoteStream` declares the downlink item type and the uplink item type together; the generator derives two codecs from it. A Host method takes the uplink iterator through `this.ctx.invocation.uplink()` and learns the caller through `this.ctx.invocation.peer`. The generated Client method returns a `RemoteStreamHandle`: `for await` reads the downlink, `send` / `end` write the uplink, `dispose` closes. Reopening after disconnection, cursor resumption, and baseline validation all belong to upper-layer protocols. + +``` + Client Host + ───────────────────────────────── ─────────────────────────────────────────── + const s = remote.job.attach(req, signal) + │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control) + │ │ prepareInvocation + │ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() } + │ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service) + │ s.end() → end { streamId } ───────▶ inbox.end() │ method(...args, signal) + │ ◀─────── item { streamId, o1 } │ for await (v of this.ctx.invocation.uplink()) … + │ for await (o of s) … ◀─────── item { streamId, o2 } │ yield o … + │ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal aborts; uplink.return() + │ ◀─────── end | error +``` + +### Terms + +| Term | Meaning | +| --- | --- | +| Logical stream | One call identified by one `streamId`, spanning `open` to the terminal frame | +| Generation | One physical lifetime of a logical stream. The transport layer knows exactly one generation; disconnection is failure, and reopening belongs to the upper layer | +| Downlink | The Host-to-Client `item` sequence, terminated by `end` or `error` | +| Uplink | The Client-to-Host `item` sequence, terminated by `end` (half-close) or ended together with `cancel` | +| Half-close | One direction has ended while the other remains open | +| Item | One business value in one direction; every item is validated by the codec independently | +| inbox | The bounded uplink queue the Host keeps per logical stream | +| Peer | The party the connection layer admitted, one `PeerScope`; this Host has exactly one Peer, the operator | +| Invocation context | `RemoteInvocation`: the request, receiving service, Peer, cancellation signal, and uplink entry of this call | + +### Type signatures + +```text +// @deepseek-ai/dsh-typert-protocol +/** + * One Remote stream. Host face: the method returns it, and at runtime it is AsyncIterable. + * On the Client face the generated method returns RemoteStreamHandle; each name has exactly one meaning. + * In is the item type the Client may send uplink; the default never means the method reads no uplink. + */ +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } + +// Host +@Remote({ mode: 'stream' }) +async *attach(request: JobAttachRequest, signal: AbortSignal): RemoteStream { + const uplink = this.ctx.invocation.uplink() + void (async () => { for await (const frame of uplink) … })() + yield … +} + +// Generated Client signature: same parameters, returns the handle +attach(request: JobAttachRequest, signal?: AbortSignal): RemoteStreamHandle + +// Client usage +const stream = remote.job.attach(req, signal) +for await (const frame of stream) … +stream.send(frame) // typed as JobInputFrame +stream.end() +stream.dispose() +``` + +`RemoteStream` is an alias rather than an interface, deliberately: if the transport layer later takes a Node stream form, only the alias definition and the adapters at both ends change; method signatures, descriptors, and upper-layer protocols stay untouched. + +### Host face + +```text +/** The context of this Remote call. A method reads it through this.ctx.invocation. */ +export interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving service. */ + readonly service: string + /** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ + readonly signal: AbortSignal + /** + * The uplink items of this call. Can be taken once; the second call throws. With an uplink codec + * in the descriptor each item is decoded to In; without one, items are delivered as unknown with + * only a JSON-safety check. + * The iteration ends after the Client's end; when the method ends the downlink, the Gateway calls + * its return() and drops unconsumed items. + * The generic In is only the caller's type assertion; runtime decodes per the descriptor and does not cross-check. + */ + uplink(): AsyncIterable +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** The Remote call this Context was derived for; undefined on a Context not derived from a Remote call. */ + readonly invocation: RemoteInvocation | undefined + } +} +``` + +Rules: + +1. Any `mode: 'stream'` method may call `this.ctx.invocation.uplink()`; a unary method may too, but its stream ends when the method returns, so uplink items are readable only while the method runs. +2. While `uplink()` has not been called, uplink items accumulate in the inbox until the limit is exceeded (see Backpressure) or the stream ends and they are dropped. This is deliberate: a method that does not read the uplink need not know the uplink exists. +3. When `In` defaults to `never`, the descriptor has no uplink codec; the Client handle's `send` has type `never`, so nothing can be sent at compile time. If frames still arrive at runtime (from a hand-built client), they are delivered as `unknown`. +4. `this.ctx.invocation` is `undefined` on a Context not derived from a Remote call; a method that reads it handles the optional as TypeScript requires, or asserts it on the method's first line. The accessor for that property is registered once when the `TypertRemoteService` base class is constructed, so any composition that installs a Remote service can read it. + +#### How the invocation context reaches the method + +The mechanism relies on two existing Cordis behaviors: + +- `ctx.extend(meta)` is `Object.create(parent)` plus own properties (`vendor/cordis/src/context.ts:99-107`): no registration, no fiber, nothing to release, so deriving one per call costs nothing. +- `ctx.get(service)` returns the `createTraceable(ctx, service)` proxy (`vendor/cordis/src/reflect.ts:233-234`, `utils.ts:117-125`). Reading the `ctx` property returns the accessing Context directly (`utils.ts:175`); reading a method wraps it in `createShadowMethod`, whose `apply` replaces `this` with a shadow receiver whose `ctx` is `ctx.extend({ [symbols.shadow]: origin })` (`utils.ts:149-163`). +- The Gateway therefore writes `receiverContext.extend({ invocation }).get(descriptor.service)` to obtain a per-call view and calls `Reflect.apply(method, callReceiver, args)`; inside the method body, `this.ctx.invocation` reads this call through the prototype chain. The deferred execution of an async generator body is unaffected, because `this` was bound to the shadow object at call time. + +### PeerScope + +```text +/** The opaque identity of one Peer. */ +export type PeerId = Branded<'PeerId'> + +/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */ +export interface PeerScope { + readonly id: PeerId + readonly ctx: Context + dispose(): Promise +} +``` + +This Host has exactly one Peer: the operator. `@deepseek-ai/dsh-client-connection` creates it with `createScope(connectionCtx, peer)` when the service applies, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, and `dispose()` runs with the Connection's release and quiets the fiber. It is exposed as `connection.operator`. + +| Member | Semantics | +| --- | --- | +| `connection.operator: PeerScope` | The only Peer; lives and dies with the Connection | +| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: operator }`. Future admitters plug in here | + +The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. When `InvokeRemoteRequest.peer?` or the `peer` parameter of `RemoteStreamOpener` is absent, the answer is the operator, so existing in-process callers change nothing; in a composition without a Connection, the operator stand-in the Gateway builds for itself is likewise created through `createScope`, with the same `dispose()` contract. Looking up a Peer by id, associating a carrier with a second Peer, and Peer open and close events all wait until the first consumer that needs multiple Peers appears. + +The Peer carries no access model: who the Peer is and what it may do are attached by business plugins on their own through `peer.ctx`. + +### Client face + +```text +/** The handle a generated method returns on the Client face. A different name from the Host's RemoteStream, exported from the same entry point. */ +export interface RemoteStreamHandle extends AsyncIterable { + /** Send one uplink item. Throws on a value that is not lossless JSON, after termination, or after end. */ + send(item: In): void + /** Half-close the uplink: sends the end frame. Idempotent. */ + end(): void + /** Cancel the whole logical stream: sends the cancel frame (when no terminal frame has arrived); the downlink iterator ends quietly. */ + dispose(): void +} +``` + +- The handle represents one generation. When the carrier is lost, `for await` fails with `RemoteStreamCarrierError` and the handle ends there; whether to reopen, and with which arguments, belongs to the upper-layer protocol. +- `send` is synchronous: it runs the `isRemoteJsonValue` check before enqueuing and throws synchronously on a value that is not lossless JSON. The browser's `WebSocket.send` has no write callback, so throttling sends would be meaningless; the Host-side inbox limit is the only backpressure point. +- When a terminal frame (`end` / `error`) arrives, the mux client stops the pump and closes the uplink queue immediately on the receive path, so later `send` / `end` throw without waiting for the consumer's next read. +- `dispose()` calls `return()` on the carrier iterator, so the `cancel` frame is sent even when nobody is reading the downlink; afterwards the handle's `next()` ends immediately and buffered items are dropped. A consumer that `break`s out of `for await` early is equivalent to `dispose()`. +- The `$stream` supervisor, `RemoteSnapshotStream`, and `RemoteJournalStream` are upper-layer tools outside the transport layer. The object their `open` factories receive is the handle itself, with unchanged `for await` behavior; exposing `send` to these upper-layer protocols is deferred. + +### Wire frames + +```text +export type RemoteStreamClientMessage = + | { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown } + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // new: uplink item + | { readonly type: 'end'; readonly streamId: string } // new: uplink half-close + | { readonly type: 'cancel'; readonly streamId: string } + +/** Frames sent by the Host are unchanged. */ +export type RemoteStreamServerMessage = + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: 'end'; readonly streamId: string } +``` + +Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optional `value`, and `value` must be a lossless JSON value (`isRemoteJsonValue`); `end` has exactly the keys `type` and `streamId`. Any other frame is a frame-format violation, and the socket closes with 1008. No binary frames are introduced. + +### Logical stream state machine + +#### Host side + +``` + open frame + (none) ───────────────────▶ opening ──── opener resolves ────▶ running + │ │ + │ item frame: inbox.push │ item frame: inbox.push + │ end frame: inbox.end │ end frame: inbox.end (uplink half-close) + │ │ method yield: sends item frame + │ cancel frame / socket close ────▶│ cancel frame / socket close: control.abort + ▼ ▼ + aborted ◀───────────────────────── finished (sends end or error frame) +``` + +`item` frames that arrive during `opening` enter the inbox: `receive()` is synchronous and creates the `ActiveStream` and its inbox while handling the `open` frame, whereas the opener is asynchronous. Items the Client sends immediately after `open` are not lost. + +#### Client side + +``` + call generated method ──▶ await socket ──▶ send open ──▶ handle usable + │ send(v): sends item; end(): sends end + │ downlink: inbox.next() → yield; end → ends; error → throws + │ downlink terminates first: send/end throw + │ dispose or caller signal abort: sends cancel (when no terminal frame has arrived) +``` + +### Half-close and termination + +| Uplink | Downlink | Result | +| --- | --- | --- | +| Client `end` | Still open | The Host's `uplink()` iteration ends; the method keeps producing. This is the stdin EOF form | +| Still open | The Host method finishes and sends `end` | The Client handle terminates, and later `send` / `end` throw; the Host calls `return()` on the `uplink` iterator and drops unconsumed items | +| Still open | The Host method throws and sends `error` | Same as above; the Client downlink fails with `RemoteError` | +| Client `dispose` or signal abort | Any | Sends `cancel`; the Host runs `control.abort()`, `cancellableStream` closes `uplink` first and then calls `return()` on the method iterator; no terminal frame is sent | +| Socket closes | Any | The Host aborts every stream and awaits `done`; on the Client every stream fails with `RemoteStreamCarrierError` | +| `item` received after `end` | Any | The stream fails with a `gateway/protocol` error frame and aborts; the socket stays open | +| `item` / `end` with an unknown `streamId` | Any | Ignored, like `cancel`: after the Host ends a stream and deletes its id, uplink frames still in flight from the Client are normal and must not take down the other streams on the same socket | + +### Ordering guarantees + +Frames on one socket arrive in total order: a logical stream's `open`, `item`…, `end` reach the Host in send order, and uplink items enter `uplink()` in the order of `send`. There is no cross-direction ordering guarantee between uplink and downlink; a protocol that needs request-response pairing carries its own sequence numbers. + +### Backpressure + +WebSocket has no per-stream window like HTTP/2: one connection is one serial write chain, and a slow stream holds up the other streams on the same connection. This property of the downlink is unchanged. For the uplink: + +**A bounded inbox plus whole-stream failure.** The Host meters each logical stream's inbox in UTF-8 bytes of its frames, with the limit set by the Gateway config `streamInboxBytes` (default 262144) and applied to every stream. On overflow the whole logical stream fails with `gateway/uplink-overflow`: the downlink breaks with it, an `error` frame goes to the Client, and the `uplink()` iterator ends with the same error. No data is discarded; a method that does not read its uplink while the Client keeps sending is a usage error, and the failure exposes it explicitly. + +Credit frames wait until a consumer that needs sustained high-volume uplink appears; the inbox limit is the initial credit, and adding them is incremental. + +### Security and validation + +| Item | Rule | +| --- | --- | +| Authentication | Same as today: `connection.requestRejection(req)` at upgrade; descriptor and lookup-parameter validation at open. Uplink items belong to an already authenticated stream and are not authenticated again | +| Peer | Admitted at upgrade as one `PeerScope`; `invocation.peer` of every stream on that socket is that Peer | +| Item validation | With an uplink codec, every item is decoded strictly, and a failure fails the whole stream with the existing `gateway/input-invalid` (details `{ endpoint, field: 'uplink' }`); without a codec, only JSON-safety validation applies | +| Frame size | A single frame keeps the `maxPayload` of `ws`; the inbox limit bounds the accumulated total | + +### In-process and worker carriers + +| Carrier | Change | +| --- | --- | +| `ClientConnectionRpc.open` (`packages/client/connection/src/rpc.ts:245-250`) and `RpcStreamOpen` (`client/rpc.ts:21-25`) | Append an optional `uplink?: AsyncIterable`; in process there are no frames and no inbox, the caller's iterator is the source of `uplink()` directly, and backpressure is the pace of the iterator itself | +| Worker tunnel (`packages/experimental/webworker-runtime`) | The page adds `stream-uplink-item` / `stream-uplink-end` frames; `serveStream` assembles them into an `uplink` handed to `seams.openStream`. Same-origin trusted boundary: no inbox limit, and items after `end` are dropped | +| `remote-mock` (`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable`; `rpc.open` passes it through | + +An in-process carrier with no `peer` means the operator. + +### Relation to `$events` + +`$events` is a stream on a reserved endpoint; with uplink frames, waterfall answers can become uplink items on the same `streamId`, `clientId` becomes redundant because `streamId` already identifies the generation, and `$events/result` disappears together with the race at `index.ts:527-529`. Out of scope for this Note; see Deferred. + +## Type and wire definitions + +### typert protocol (`packages/typert/protocol/src`) + +```text +// types.ts +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +export type PeerId = Branded<'PeerId'> +export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } +export interface RemoteInvocation { … } // see Host face +declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } + +export interface InvocationDescriptor { + // existing fields unchanged; mode still has only 'stream' + readonly mode?: 'stream' + /** Uplink item codec, generated from In of the return type RemoteStream; absent when In is never. */ + readonly uplink?: { readonly codec: TypertCodec } + readonly cancellation?: { readonly parameter: 'signal' } + readonly result: TypertCodec +} +``` + +The `Remote` decorator, `RemoteMethodOptions`, and `RemoteMethodMarker` recognize only `mode: 'stream'`. + +### typert generator (`packages/typert/generator/src`) + +- `model.ts`: `InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`. +- `analyzer.ts` `remoteResultType`: for `mode: 'stream'`, the accepted return-type wrappers are `Iterable`, `AsyncIterable`, and `RemoteStream`. `RemoteStream` is recognized the same way as the standard library's `AsyncIterable`: by symbol name plus declaring file (`types.ts` of `@deepseek-ai/dsh-typert-protocol`). The first type argument is the downlink item; when the second is present and is not `never`, an `uplink` boundary is generated under the key `${endpoint}:uplink`. +- `emitter.ts`: the descriptor literal emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle`. +- The parameter loop recognizes no parameter named `uplink`. + +### Two names, one entry point + +A Host method declares a stream with `RemoteStream`; the Client holds a `RemoteStreamHandle`. Both are exported from the main entry point of `@deepseek-ai/dsh-typert-protocol`, and the handle interface has no Host dependency. The generated Client contract writes the return type as `RemoteStreamHandle`. One name has one meaning: the `RemoteStream` that Client code obtains from the main entry point is always the declaration type and is never confused with the handle. + +### Gateway Host (`packages/api/gateway/src`) + +```text +// types.ts +export interface InvokeRemoteRequest { + readonly namespace: string + readonly method: string + readonly args: Readonly> + /** Uplink items; absent is equivalent to an iterator that ends immediately. */ + readonly uplink?: AsyncIterable + /** The Peer that initiated the call; absent means the operator. */ + readonly peer?: PeerScope + readonly signal?: AbortSignal +} + +export interface TypertGatewayWireStream { + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + failure: (error: unknown) => RemoteStreamFailure +} + +// stream-server.ts +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise> + +// index.ts Config addition +/** Uplink inbox limit per logical stream, in UTF-8 bytes of frames (default 262144). */ +readonly streamInboxBytes?: number +``` + +`prepareInvocation`: + +```text +const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))) +const signal = request.signal ?? NEVER_ABORTED_SIGNAL +const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control), +) +const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object +if (descriptor.cancellation !== undefined) args.push(signal) +``` + +`GatewayInvocation.uplink()` returns the decoder on the first call and throws on the second. `UplinkDecoder` is a hand-written iterator: with a codec it runs `decode(codec, value, endpoint, 'uplink')` per item and, on failure, calls `control.abort(failure)` and throws; without a codec it applies only the `isRemoteJsonValue` check. The `finally` of `cancellableStream` closes the decoder first (`invocation.close()`) and then awaits the method iterator's `return()`, so a method blocked on `uplink.next()` can exit. The mode branches are the existing two, unary and stream. SRC fallback: the descriptor has no `uplink` codec, and `uplink()` delivers `unknown`. + +### Gateway Host mux (`stream-server.ts`) + +```text +interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise } + +/** Bounded uplink queue; iterated as the source of uplink(). */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + constructor(maxBytes: number, onOverflow: (error: RemoteError) => void) + push(value: unknown, frameBytes: number): void // push after ended fails the stream with gateway/protocol; overflow calls onOverflow + end(): void // idempotent + fail(error: unknown): void // ends the iterator with an error on cancel or carrier closure + next() / return() // hand-written: next races failure, return releases immediately +} +``` + +`receive(text)` dispatch: + +``` +open : exists → throw (socket 1008); otherwise create { control, inbox, done } and start the pump +item : missing → ignore; otherwise inbox.push(value, bytes) +end : missing → ignore; otherwise inbox.end() +cancel : missing → ignore; otherwise control.abort(new Error('Remote stream cancelled')) +``` + +`pump` passes the inbox and the socket's Peer to the opener and calls `inbox.fail(...)` in its `finally`. An abort whose reason is a `RemoteError` is sent by the pump as an `error` frame (a failure raised by the Gateway or the mux); an abort whose reason is a plain `Error` (cancel, socket closure) sends no terminal frame. + +### Gateway Client (`packages/api/gateway/src/client`) + +- `prepareInvocation` strips no uplink parameter; the positional parameters are the business parameters plus the optional `signal`. +- `invokeStream` returns a `RemoteStreamHandle`: it holds an uplink queue internally, `send` enqueues and a pump sends the `item` frames, `end` sends the `end` frame, and `dispose` aborts the generation signal. The downlink is still the iterator that `openRemoteStream` returns. +- `RemoteStreamMuxClient.open(endpoint, payload, signal, uplink?)`: after sending the `open` frame it starts the uplink pump, which sends one `item` per item and `end` when the source ends; when the downlink terminates or the signal aborts, it stops the pump and calls the source's `return()` (without awaiting, so a keyboard-style generator cannot hang it); when the source throws, it calls `inbox.fail(error)`, the downlink fails with that error, and `finally` sends `cancel` under the existing rules. The pump checks `this.socket === socket`, so an old generation never sends frames on a new socket. +- In-process carrier: `connection.rpc.open(channel, endpoint, payload, signal, uplink)`. + +## Placement + +| Package | Carries | +| --- | --- | +| `dsh-typert-protocol` (`types.ts`, `index.ts`) | `RemoteStream`, `RemoteStreamHandle`, `PeerId`, `PeerScope`, `RemoteInvocation`, the `ctx.invocation` declaration merge, `InvocationDescriptor.uplink`; the decorator recognizes only `mode: 'stream'`; `TypertRemoteService` registers the `invocation` accessor when constructed | +| `dsh-typert-registry` | Load-time validation that `uplink.codec` is a valid strict codec | +| `dsh-typert-generator` (`model.ts`, `analyzer.ts`, `emitter.ts`) | `remoteResultType` recognizes `RemoteStream` and generates the `uplink` boundary; the descriptor emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle`; the parameter loop accepts only business parameters and a final `signal` | +| `dsh-client-connection` (`operator-peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The operator `PeerScope`; `connection.operator` and `admit`; the `/api` and upgrade routes obtain the Peer through `admit`; the optional `uplink` of `rpc.open` | +| `dsh-api-gateway` Host (`stream-protocol.ts`, `stream-server.ts`, `types.ts`, `index.ts`) | `item` / `end` frames; `UplinkInbox`; `Config.streamInboxBytes`; `GatewayInvocation` and `extend({ invocation })`; `UplinkDecoder`; `handleUpgrade(req, socket, head, peer)`; `operatorPeer()` | +| `dsh-api-gateway` Client (`client/index.ts`, `client/stream-client.ts`) | `invokeStream` returns the handle; the uplink pump is driven by the handle's queue | +| `dsh-webworker-runtime`, `dsh-remote-mock` | The worker tunnel's two uplink frames and the `serveStream` assembly; `StreamHandle.uplink`; the direct proxy returns the real handle | +| Documentation | The READMEs of gateway, the api group, connection, protocol, and generator; `docs/api-gateway`; the type-equiv blocks of `docs/subsystems/typert`; the config and Cordis catalogs | + +## Key sequences + +### Open, uplink, half-close, downlink end + +``` +Client caller mux client mux server gateway Host method + │ attach(req) │ │ │ │ + │───────────────────▶│ open { id, ep, payload } ──────────────────▶│ create inbox │ + │ s.send(v1) │ item { id, v1 } ───────────────────────────▶│ inbox.push(v1) │ + │ │ │ │ prepareInvocation │ + │ │ │ │ extend({ invocation }) │ + │ │ │ │── method(req, signal) ─▶│ + │ │ │ │ │ uplink() → v1 + │ s.send(v2) │ item { id, v2 } ───────────────────────────▶│ inbox.push(v2) │ → v2 + │ s.end() │ end { id } ────────────────────────────────▶│ inbox.end() │ iteration ends + │◀── yield o1 ───────│◀────────────────────── item { id, o1 } ─────│◀── yield o1 ────────────│ + │◀── ends ───────────│◀────────────────────── end { id } ──────────│◀── method returns ──────│ gateway calls uplink.return() +``` + +### Client dispose + +``` +Client caller mux client mux server gateway Host method + │ s.dispose() │ │ │ │ + │───────────────────▶│ stop pump; source return() (not awaited) │ │ + │◀── iterator ends ──│ cancel { id } ─────────────────────────────▶│ control.abort() │ + │ │ │ │ cancellableStream: invocation.close() closes uplink + │ │ │ │ iterator.return() ─────▶│ finally cleanup + │ │ │ no terminal frame │ │ +``` + +### inbox overflow + +``` +Client caller mux client mux server gateway / Host method + │ s.send(vN) │ item { id, vN } ─────▶│ inbox.push: bytes > streamInboxBytes │ + │ │ │ onOverflow → control.abort(RemoteError uplink-overflow) + │ │ │ │ uplink() ends with that error; method iterator return() + │◀── throw ──────────│◀──────────────────────│ error { id, uplink-overflow }: the pump sends an error frame because the reason is a RemoteError +``` + +## Boundary matrix + +| Event | opening | running | Uplink ended | Downlink terminated | +| --- | --- | --- | --- | --- | +| `item` received | Buffered in the inbox | Into the inbox | The stream fails with `gateway/protocol` | Ignored | +| `end` received | inbox.end | inbox.end | Idempotent | Ignored | +| `cancel` received | abort | abort | abort | Ignored | +| inbox overflow | The stream fails with `uplink-overflow` | Same | Impossible | Impossible | +| Decode failure | Impossible | The stream fails with `input-invalid` | Same | Impossible | +| Host method finishes | Impossible | Sends `end`; `uplink.return()` | Sends `end` | Already sent | +| Host method throws | Sends `error` | Sends `error`; `uplink.return()` | Sends `error` | Already sent | +| Socket closes | abort, await done | Same | Same | Nothing | +| Client `send` | Queued, sent after open | Sent | Throws | Throws | +| Client `end` | Recorded, sent after open | Sent | Idempotent | Ignored | +| Client-side downlink ends first | Impossible | Stop the pump; source `return()`; no `end` sent | Nothing | Nothing | + +## Error codes + +| Code | When | +| --- | --- | +| `gateway/input-invalid` | Existing: a parameter codec fails to decode; new: an uplink item fails to decode | +| `gateway/uplink-overflow` | New: the inbox exceeds `streamInboxBytes` | +| `gateway/protocol` | New: an `item` after `end` | +| `gateway/cancelled` | Existing: `signal` aborted | + +New codes are registered in `packages/api/gateway/src/remote-error-codes.ts` and in the README's error-code section. + +## Minimal echo method + +`packages/api/gateway/tests/echo-stream.host.spec.ts` runs the same method through the WebSocket carrier and through the in-process carrier: + +```text +class EchoService extends TypertRemoteService { + constructor(ctx: Context) { super(ctx, 'echo', { namespace: 'echo' }) } + + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + for await (const item of this.ctx.invocation!.uplink()) { + signal.throwIfAborted() + yield `${prefix}${item}` + } + } +} + +// Client +const stream = remote.echo.echo('> ') +stream.send('a'); stream.send('b'); stream.end() +const replies: string[] = [] +for await (const reply of stream) replies.push(reply) // ['> a', '> b'] +``` + +## Alternatives considered + +**A separate `mode: 'duplex'` plus a reserved parameter `uplink: AsyncIterable` alongside `signal`.** Special logic everywhere: the analyzer must recognize a second reserved parameter and its position rules, the generated signature gains a parameter, load time must validate `duplex ⟺ uplink`, and because the mux does not know the method mode, the Gateway must reject uplink frames on behalf of `'stream'` methods. Duplex is an inherent property of the transport-layer stream, not a mode of the method: the carrier already has `streamId`, `open`, and `cancel`; the uplink adds just two more frames, which any stream can receive and which are simply not sent when unused. + +**Declaring the uplink type in a decorator option, `@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`.** A string reference to a type name is not checked by the compiler and breaks on rename. The return-type alias puts both types in one compiler-visible place. + +**Passing a schema at runtime: `uplink(schema)`.** Bypasses the generator, the Client loses type projection, and it splits from the "strict codec per item" system. The codec still comes from the generator; the generic in the method is only an assertion. + +**Having the analyzer cross-check the hand-written generic of `uplink()` against the return type.** A business-specific check whose mistakes the caller bears, consistent with the other hand-written assertions in the repository; not added. + +**A transport-layer handle with reopening, cursors, and a `reopen` hook.** Reopening after disconnection belongs to the upper-layer protocol: a job resumes through an `opened` frame carrying `from` and a client that remembers `next` and calls again; snapshot and journal each have their own anchors and classification. The transport layer knows one generation. + +**Putting the Client's `send` on the `AsyncIterable` the generated method returns.** Hanging methods on an `AsyncIterable` is unconventional; the handle type `RemoteStreamHandle` extends it explicitly. + +**Copying the Access mechanism.** Access control is business semantics, served by `vouch` and the `remote/invoke` waterfall; this Note only needs "whose stream is this", and `PeerScope` suffices. + +**Keeping unary RPC for the uplink.** One HTTP round trip, one authentication, and one lookup per uplink; two carriers with no ordering relation that only Client-side serialization can order; no EOF; cancellation that does not cover both halves; a hand-made correlation key per feature. + +**A native HTTP request body stream.** A one-shot request body with no association to the downlink stream, bypassing Typert descriptors, and browser support for full-duplex fetch cannot be relied on. + +**One dedicated WebSocket per duplex stream.** Loses the authentication, heartbeat, and multiplexing shared with the existing downlink streams, and every feature manages its own socket lifecycle. + +**WebTransport or HTTP/2 bidirectional streams.** They have per-stream flow control, but support on the browser and Node sides, proxy traversal, and the Electron carrier are all immature; adding two frames to the existing mux is orders of magnitude cheaper than replacing the transport layer. + +## Consequences + +- **Bought**: one stream with two directions, so interactive scenarios such as terminal keystrokes, approval answers, and background-job stdin no longer each build their own "one stream plus one unary"; a Host method knows its caller; the uplink and downlink types are declared in one place, the return type; uplink items come from the browser and are validated strictly per item at the Host, while downlink items are Host-produced typed values that pass straight through; the generated output and wire frames of existing `AsyncIterable` methods and unary methods are completely unchanged. +- **`this.ctx.invocation` is `undefined` outside a Remote call**, so a method that reads it must handle the optional; a service method invoked only directly in process correctly reads `undefined`. +- **The inbox limit applies to every stream**: a method that does not read its uplink fails as a whole when it receives many uplink frames. This is a deliberate explicit failure, recorded in the README. +- **The operator is the only Peer**, and `admit` is the only admission point; the first consumer that needs a second Peer must add opening, association, and events in `dsh-client-connection`. +- **No cross-direction ordering guarantee between uplink and downlink**; a protocol that needs request-response pairing carries its own sequence numbers. +- **Upper-layer protocols do not yet expose `send`**: consumers of `$stream`, snapshot, and journal use only the downlink today; the object they receive is already the handle. +- **The worker tunnel behaves differently from the WebSocket carrier**: no inbox limit, and items after `end` are dropped, because page to worker is a same-origin trusted boundary. + +## Testing + +- `packages/api/gateway/tests/echo-stream.host.spec.ts`: the same echo method passes through the WebSocket carrier and through the in-process carrier. +- Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`. +- Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives. +- typert: the analyzer's handling of `RemoteStream`, `RemoteStream`, `never`, and the existing `AsyncIterable`; decorator options; registry load-time validation. +- connection: the three outcomes of `admit`, 401, 403, and the operator; the operator reaching handlers through the bridge and the shared handler; release with the Connection. +- remote-mock and the worker tunnel: uplink pass-through; the direct proxy returning the real handle. +- Existing stream and unary method tests pass without changed expectations. + +## Deferred + +- `$stream` / snapshot / journal expose the handle's `send` to upper-layer protocols. +- Web terminal: one `attach` stream replaces `follow` plus `write` plus `resize`, and `attachmentId` retires. +- Duplex `$events`: waterfall answers become uplink items on the same `streamId`, and `$events/result` and `clientId` retire. +- Credit frames: wait for a consumer that needs sustained high-volume uplink; the inbox limit is the initial credit. +- The `streamInboxBytes` default of 262144 is far more than keyboard and stdin need; if the first consumer is terminal paste, it may be aligned with the `maxInputBytes` of `terminal-controller`. +- A Host-initiated half-close frame (the Host stops reading the uplink but keeps writing the downlink): no consumer; not added. +- `uplink()` on unary methods: it can read items that arrive while the method runs, semantically valid but of doubtful use; allowed, noted in the README. + +## Related + +- [Remote event delivery](2026-08-10-remote-event-delivery.md): the unary `$events/result` answer path; the duplex `$events` item under Deferred partially supersedes it once it lands. +- [Session history and event transport](2026-08-18-session-history-and-event-transport.md): the `$stream()` supervisor and the journal / snapshot protocols above it, which this note leaves unchanged. +- [Web sidebar terminal](../feature/2026-09-09-web-sidebar-terminal.md): the unary terminal `write` and `attachmentId`; the `attach` stream under Deferred replaces it once it lands. diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md new file mode 100644 index 0000000000..d3982aa433 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -0,0 +1,597 @@ +# Agent Note: Remote 双工流:一种流、上行通道与调用上下文 + +Status: implemented + +[English](2026-09-19-remote-duplex-stream.md) | 中文 + +## 问题 + +### 现状 + +`dsh-api-gateway` 在一条 WebSocket 上复用全部 Typert Remote 流,路径固定为 `/api/remote.mux`(`packages/api/gateway/src/stream-protocol.ts`)。一个 Host 方法用 `@Remote({ mode: 'stream' })` 修饰并返回 `Iterable` 或 `AsyncIterable`,就成为一条 Host 到 Client 的逻辑流;可选的末位 `signal: AbortSignal` 是唯一的保留参数,不进线路参数,由网关在解码后的业务参数之后追加(`packages/api/gateway/src/index.ts`)。客户端拿到的生成方法返回一个裸 `AsyncIterable`。 + +线路帧只有五种: + +| 方向 | 帧 | 定义 | +| --- | --- | --- | +| Client 到 Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` | +| Client 到 Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` | +| Host 到 Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | +| Host 到 Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` | +| Host 到 Client | `{ type: 'end', streamId }` | `stream-protocol.ts` | + +客户端能发的只有打开与取消。解析器 `parseRemoteStreamClientMessage`(`stream-protocol.ts`)对其他任何帧抛错,Host 连接收到后以 1008 关闭整个 socket(`stream-server.ts`)。结论:网关今天只支持"后端 iterable 发到前端"。需要前端向后端持续发数据的功能,各自绕路。 + +Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteRequest = { namespace, method, args, signal }`(`packages/api/gateway/src/types.ts`)没有调用者槽位,`RemoteStreamOpener` 的签名是 `(endpoint, payload, signal)`(`stream-server.ts`),WebSocket 只在握手时校一次 cookie。 + +### 三套各自为政的上行机制 + +| 功能 | 上行方式 | 位置 | 代价 | +| --- | --- | --- | --- | +| Web 终端按键 | 每次 xterm `onData` 一个 unary RPC `terminal.write(agent, id, attachmentId, data)`,客户端用 promise 链串行化并自限字节预算 | `packages/api/terminal-controller/src/client/model.ts`;Host `src/index.ts` | 一次按键一次 HTTP 往返与鉴权;两条载体靠 `attachmentId` 手工关联;满了报 `inputFull` | +| 文件上传 | `Blob` 或 `ReadableStream` 走 Remote 层之外的原生 `POST /api/session/uploadFileBinary`,`duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`;路由 `src/index.ts` | 不经 Typert 描述符,没有类型投影 | +| 审批与提问的回答 | Host 在 `$events` 流上下发 waterfall 帧,Client 用一个独立的 unary RPC `$events/result` 回答,靠 `clientId` 加 `eventId` 关联 | `packages/api/gateway/src/client/remote-events.ts`;Host `index.ts` | 第二套关联键;`index.ts` 记录了由此产生的竞态 | + +这三套机制解决的是同一个问题:一条已经打开的逻辑流,客户端想往里写。 + +### 具体代价 + +- **两条载体对齐**。终端要用 `attachmentId` 把 `follow` 流与 `write` RPC 关联起来,并用它决定谁拥有输入权。审批要用 `clientId` 把 `$events` 流与 `$events/result` RPC 关联起来。 +- **没有顺序保证**。unary RPC 走 HTTP,与 WebSocket 下行互不排序;终端只能在客户端串行化写入来自保顺序。 +- **每次上行单独鉴权与查找**。`write(agent, id, attachmentId, data)` 每次解析 `agent`、查终端、校验 attachment。 +- **没有 EOF**。客户端无法表达"我这边写完了",半关闭要再加一个 RPC。 +- **取消不覆盖两半**。取消下行流不会取消排队中的上行写;反之亦然。 +- **背压各写一套**。下行按 socket 写回调节拍拉迭代器(`stream-server.ts`);终端用有界 follower 让慢消费者显式失败(`terminal-controller/src/stream.ts`);上行靠客户端自限 `maxInputBytes`。 +- **文档已经过时**。`docs/api-gateway.md:160` 仍写着 Remote 只处理一请求一结果,这一句已经落后于 README 记录的 stream 模式。 + +### 与后台 job 的关系 + +后台 job 的进程形双工流面(已存档为草稿 PR,分支 `worktree/job-stdio-stream-pair-archive`)把 job 建成一对镜像的流面,浏览器要接它就需要一条既下行又上行的 Remote 流。它是本 Note 的动机之一,但不是本 Note 的范围:job 要不要一种专用的读写流,是 jobs 那条线自己的架构问题。本 Note 只覆盖传输层。 + +### 目标 + +1. 传输层只有一种流,天生双工:下行 `item` / `end` / `error`,上行 `item` / `end`,一个 `streamId`,一个代际。 +2. 上行类型与下行类型写在同一个位置:方法的返回类型。 +3. Host 方法通过调用上下文取上行;调用上下文同时携带调用者(Peer)。 +4. 客户端拿到的不再是裸 `AsyncIterable`,而是一个能读、能写、能关的句柄。 +5. 上层协议(snapshot、journal、任何业务自定义的流)不进传输层。 + +## 决定 + +### 一句话 + +所有 Remote 流都允许客户端上行。方法的返回类型 `RemoteStream` 同时声明下行项与上行项的类型;生成器从中生成两个 codec。Host 方法通过 `this.ctx.invocation.uplink()` 取上行迭代器,通过 `this.ctx.invocation.peer` 知道调用者。客户端生成方法返回 `RemoteStreamHandle`:`for await` 读下行,`send` / `end` 写上行,`dispose` 关。断线重开、游标续传、基线校验都是上层协议的事。 + +``` + Client Host + ───────────────────────────────── ─────────────────────────────────────────── + const s = remote.job.attach(req, signal) + │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control) + │ │ prepareInvocation + │ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() } + │ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service) + │ s.end() → end { streamId } ───────▶ inbox.end() │ method(...args, signal) + │ ◀─────── item { streamId, o1 } │ for await (v of this.ctx.invocation.uplink()) … + │ for await (o of s) … ◀─────── item { streamId, o2 } │ yield o … + │ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal aborts; uplink.return() + │ ◀─────── end | error +``` + +### 术语 + +| 术语 | 含义 | +| --- | --- | +| 逻辑流 | 一个 `streamId` 标识的一次调用,跨越 `open` 到终止帧 | +| 代际 | 一条逻辑流的一次物理生命期。传输层只认一个代际;断线即失败,重开是上层的事 | +| 下行 | Host 到 Client 的 `item` 序列,以 `end` 或 `error` 终止 | +| 上行 | Client 到 Host 的 `item` 序列,以 `end` 终止(半关闭),或随 `cancel` 一起结束 | +| 半关闭 | 一个方向已结束而另一个方向仍开放 | +| 项 | 一个方向上的一个业务值;每项独立经 codec 校验 | +| inbox | Host 侧每逻辑流一个的有界上行队列 | +| Peer | 连接层被接纳的一方,一个 `PeerScope`;本 Host 只有操作者一个 Peer | +| 调用上下文 | `RemoteInvocation`:本次调用的请求、接收服务、Peer、取消信号与上行入口 | + +### 类型签名 + +```text +// @deepseek-ai/dsh-typert-protocol +/** + * One Remote stream. Host face: the method returns it, and at runtime it is AsyncIterable. + * On the Client face the generated method returns RemoteStreamHandle; each name has exactly one meaning. + * In is the item type the Client may send uplink; the default never means the method reads no uplink. + */ +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } + +// Host +@Remote({ mode: 'stream' }) +async *attach(request: JobAttachRequest, signal: AbortSignal): RemoteStream { + const uplink = this.ctx.invocation.uplink() + void (async () => { for await (const frame of uplink) … })() + yield … +} + +// Generated Client signature: same parameters, returns the handle +attach(request: JobAttachRequest, signal?: AbortSignal): RemoteStreamHandle + +// Client usage +const stream = remote.job.attach(req, signal) +for await (const frame of stream) … +stream.send(frame) // typed as JobInputFrame +stream.end() +stream.dispose() +``` + +`RemoteStream` 是别名而不是接口,是刻意的:将来若把传输层做成 Node 流形态,只换别名的定义与两端的适配,方法签名、描述符、上层协议都不动。 + +### Host 面 + +```text +/** The context of this Remote call. A method reads it through this.ctx.invocation. */ +export interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving service. */ + readonly service: string + /** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ + readonly signal: AbortSignal + /** + * The uplink items of this call. Can be taken once; the second call throws. With an uplink codec + * in the descriptor each item is decoded to In; without one, items are delivered as unknown with + * only a JSON-safety check. + * The iteration ends after the Client's end; when the method ends the downlink, the Gateway calls + * its return() and drops unconsumed items. + * The generic In is only the caller's type assertion; runtime decodes per the descriptor and does not cross-check. + */ + uplink(): AsyncIterable +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** The Remote call this Context was derived for; undefined on a Context not derived from a Remote call. */ + readonly invocation: RemoteInvocation | undefined + } +} +``` + +规则: + +1. 任何 `mode: 'stream'` 方法都可以调用 `this.ctx.invocation.uplink()`;unary 方法也可以,但它的流在方法返回时结束,上行项只在方法运行期间可读。 +2. `uplink()` 未被调用时,上行项在 inbox 里攒着,直到超限(见背压)或流结束被丢弃。这是刻意的:不读上行的方法不用知道上行存在。 +3. `In` 缺省为 `never` 时描述符没有 uplink codec;客户端句柄的 `send` 类型为 `never`,编译期就不能发。运行时若仍有帧到达(手工构造的客户端),按 `unknown` 交付。 +4. `this.ctx.invocation` 在非 Remote 调用的 Context 上是 `undefined`;方法内读它时按 TS 的可选处理,或在方法第一行断言。该属性的 accessor 由 `TypertRemoteService` 基类构造时注册一次,任何装有 Remote 服务的组合都能读它。 + +#### 调用上下文如何到达方法 + +机制依赖 Cordis 的两条既有行为: + +- `ctx.extend(meta)` 是 `Object.create(parent)` 加自有属性(`vendor/cordis/src/context.ts:99-107`),无注册、无 fiber、不需释放,每次调用派生一个是零成本的。 +- `ctx.get(service)` 返回 `createTraceable(ctx, service)` 代理(`vendor/cordis/src/reflect.ts:233-234`,`utils.ts:117-125`)。读 `ctx` 属性直接返回访问方的 Context(`utils.ts:175`);读方法时用 `createShadowMethod` 包一层,`apply` 时把 `this` 换成 shadow receiver,shadow 上的 `ctx` 是 `ctx.extend({ [symbols.shadow]: origin })`(`utils.ts:149-163`)。 +- 网关因此写 `receiverContext.extend({ invocation }).get(descriptor.service)` 取一个调用视图,`Reflect.apply(method, callReceiver, args)`;方法体里 `this.ctx.invocation` 沿原型链读到本次调用。async generator 体延后执行不受影响,因为 `this` 在调用时已绑定为 shadow 对象。 + +### PeerScope + +```text +/** The opaque identity of one Peer. */ +export type PeerId = Branded<'PeerId'> + +/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */ +export interface PeerScope { + readonly id: PeerId + readonly ctx: Context + dispose(): Promise +} +``` + +本 Host 只有一个 Peer:操作者。`@deepseek-ai/dsh-client-connection` 在服务 apply 时用 `createScope(connectionCtx, peer)` 建它,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,`dispose()` 随 connection 释放让 fiber 静默。它以 `connection.operator` 暴露。 + +| 成员 | 语义 | +| --- | --- | +| `connection.operator: PeerScope` | 唯一的 Peer,随 connection 生灭 | +| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: operator }`。将来的接纳方在这里接入 | + +网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer?` 与 `RemoteStreamOpener` 的 `peer` 形参缺席时按操作者作答,现有进程内调用方零改动;没有 connection 的组合里网关自建的操作者替身同样经 `createScope` 建立,`dispose()` 契约一致。按 id 查 Peer、把载体关联到第二个 Peer、Peer 的开关事件,都等第一个需要多 Peer 的消费者出现时再加。 + +Peer 不携带访问模型:Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 自行附加。 + +### Client 面 + +```text +/** The handle a generated method returns on the Client face. A different name from the Host's RemoteStream, exported from the same entry point. */ +export interface RemoteStreamHandle extends AsyncIterable { + /** Send one uplink item. Throws on a value that is not lossless JSON, after termination, or after end. */ + send(item: In): void + /** Half-close the uplink: sends the end frame. Idempotent. */ + end(): void + /** Cancel the whole logical stream: sends the cancel frame (when no terminal frame has arrived); the downlink iterator ends quietly. */ + dispose(): void +} +``` + +- 句柄代表一个代际。载体丢失时 `for await` 以 `RemoteStreamCarrierError` 失败,句柄到此结束;要不要重开、带什么参数重开,是上层协议的事。 +- `send` 是同步的:入队前做 `isRemoteJsonValue` 校验,非无损 JSON 值同步抛错。浏览器 `WebSocket.send` 没有写回调,发送节流没有意义;Host 侧的 inbox 上限是唯一的背压点。 +- 收到终止帧(`end` / `error`)时,mux 客户端在收帧路径上立即停泵并关闭上行队列,之后 `send` / `end` 抛错,不等消费者下一次读。 +- `dispose()` 调用载体迭代器的 `return()`,让 `cancel` 帧在无人读下行时也发出;之后句柄的 `next()` 直接结束,缓冲项丢弃。消费者提前 `break` 出 `for await` 等价于 `dispose()`。 +- `$stream` 监督器、`RemoteSnapshotStream`、`RemoteJournalStream` 是上层工具,不在传输层内。它们的 `open` 工厂拿到的对象现在就是句柄,`for await` 行为不变;把 `send` 透出到这些上层协议是后续的事。 + +### 线路帧 + +```text +export type RemoteStreamClientMessage = + | { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown } + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // new: uplink item + | { readonly type: 'end'; readonly streamId: string } // new: uplink half-close + | { readonly type: 'cancel'; readonly streamId: string } + +/** Frames sent by the Host are unchanged. */ +export type RemoteStreamServerMessage = + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: 'end'; readonly streamId: string } +``` + +解析规则:`item` 精确键 `type` `streamId` 加可选 `value`,`value` 须为无损 JSON 值(`isRemoteJsonValue`);`end` 精确键 `type` `streamId`。其余形状是帧格式违规,socket 以 1008 关闭。不引入二进制帧。 + +### 逻辑流状态机 + +#### Host 侧 + +``` + open frame + (none) ───────────────────▶ opening ──── opener resolves ────▶ running + │ │ + │ item frame: inbox.push │ item frame: inbox.push + │ end frame: inbox.end │ end frame: inbox.end (uplink half-close) + │ │ method yield: sends item frame + │ cancel frame / socket close ────▶│ cancel frame / socket close: control.abort + ▼ ▼ + aborted ◀───────────────────────── finished (sends end or error frame) +``` + +`opening` 期间到达的 `item` 帧进入 inbox:`receive()` 是同步的,`open` 帧处理时就创建 `ActiveStream` 与 inbox,而 opener 是异步的。客户端在 `open` 之后立刻发的项不会丢。 + +#### Client 侧 + +``` + call generated method ──▶ await socket ──▶ send open ──▶ handle usable + │ send(v): sends item; end(): sends end + │ downlink: inbox.next() → yield; end → ends; error → throws + │ downlink terminates first: send/end throw + │ dispose or caller signal abort: sends cancel (when no terminal frame has arrived) +``` + +### 半关闭与结束语义 + +| 上行 | 下行 | 结果 | +| --- | --- | --- | +| 客户端 `end` | 仍开放 | Host 的 `uplink()` 迭代结束;方法继续产出。这是 stdin EOF 的形状 | +| 仍开放 | Host 方法结束,发 `end` | 客户端句柄终止,之后 `send` / `end` 抛错;Host 调用 `uplink` 迭代器的 `return()`,未消费项丢弃 | +| 仍开放 | Host 方法抛错,发 `error` | 同上;客户端下行以 `RemoteError` 失败 | +| 客户端 `dispose` 或 signal 中止 | 任意 | 发 `cancel`;Host `control.abort()`,`cancellableStream` 先关 `uplink`,再调方法迭代器的 `return()`;不发终止帧 | +| socket 关闭 | 任意 | Host 中止全部流并等待 `done`;客户端每条流以 `RemoteStreamCarrierError` 失败 | +| `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 | +| 未知 `streamId` 的 `item` / `end` | 任意 | 忽略,与 `cancel` 相同:Host 结束流并删除 id 后,客户端在途的上行帧属正常现象,不能连累同一 socket 上的其他流 | + +### 顺序保证 + +同一条 socket 上的帧全序到达:一条逻辑流的 `open`、`item`…、`end` 按发送顺序到达 Host;上行项进入 `uplink()` 的顺序就是 `send` 的顺序。上行与下行之间没有跨方向顺序保证;需要请求应答配对的协议自带序号。 + +### 背压 + +WebSocket 没有 HTTP/2 那样的每流窗口,一个连接一条串行写链,慢流会拖住同连接的其他流。下行的这一性质不变。上行: + +**有界 inbox 加整流失败。** Host 每条逻辑流的 inbox 按帧的 UTF-8 字节计量,上限为网关配置 `streamInboxBytes`(默认 262144),对所有流生效。超限时整条逻辑流以 `gateway/uplink-overflow` 失败:下行一起断,向客户端发 `error` 帧,`uplink()` 迭代器以同一错误结束。数据不丢弃;一个方法不读上行而客户端持续发,是用法错误,由失败显式暴露。 + +credit 帧留待需要持续大流量上行的消费者出现时再加;inbox 上限就是初始授信,加法是增量。 + +### 安全与校验 + +| 项 | 规则 | +| --- | --- | +| 鉴权 | 与今天相同:升级时 `connection.requestRejection(req)`;打开时描述符与查找参数校验。上行项属于一条已鉴权的流,不重复鉴权 | +| Peer | 升级时接纳为一个 `PeerScope`,该 socket 上所有流的 `invocation.peer` 都是它 | +| 项校验 | 有 uplink codec 时逐项严格解码,失败即整条流以既有的 `gateway/input-invalid` 失败(details `{ endpoint, field: 'uplink' }`);无 codec 时只做 JSON 安全校验 | +| 帧大小 | 单帧沿用 `ws` 的 `maxPayload`;inbox 上限限制累计量 | + +### 进程内与 worker 载体 + +| 载体 | 改为 | +| --- | --- | +| `ClientConnectionRpc.open`(`packages/client/connection/src/rpc.ts:245-250`)与 `RpcStreamOpen`(`client/rpc.ts:21-25`) | 追加可选 `uplink?: AsyncIterable`;进程内没有帧也没有 inbox,调用方迭代器直接作为 `uplink()` 的源,背压就是迭代器本身的节拍 | +| worker 隧道(`packages/experimental/webworker-runtime`) | 页面新增 `stream-uplink-item` / `stream-uplink-end` 帧;`serveStream` 把它们组装成 `uplink` 交给 `seams.openStream`。同源可信边界:没有 inbox 上限,`end` 之后的项丢弃 | +| `remote-mock`(`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable`;`rpc.open` 透传 | + +进程内载体的 `peer` 缺席即操作者。 + +### 与 `$events` 的关系 + +`$events` 是一条保留端点的流;有了上行帧,waterfall 的回答可以成为同一 `streamId` 上的上行项,`clientId` 因 `streamId` 已标识代际而多余,`$events/result` 与 `index.ts:527-529` 的竞态一起消失。不在本 Note 范围,见「后续」。 + +## 类型与线路定义 + +### typert protocol(`packages/typert/protocol/src`) + +```text +// types.ts +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +export type PeerId = Branded<'PeerId'> +export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } +export interface RemoteInvocation { … } // see Host face +declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } + +export interface InvocationDescriptor { + // existing fields unchanged; mode still has only 'stream' + readonly mode?: 'stream' + /** Uplink item codec, generated from In of the return type RemoteStream; absent when In is never. */ + readonly uplink?: { readonly codec: TypertCodec } + readonly cancellation?: { readonly parameter: 'signal' } + readonly result: TypertCodec +} +``` + +`Remote` 修饰器、`RemoteMethodOptions`、`RemoteMethodMarker` 恢复为只认 `mode: 'stream'`。 + +### typert generator(`packages/typert/generator/src`) + +- `model.ts`:`InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`。 +- `analyzer.ts` `remoteResultType`:对 `mode: 'stream'`,接受的返回类型包装器为 `Iterable`、`AsyncIterable`、`RemoteStream`。识别 `RemoteStream` 的方式与识别标准库 `AsyncIterable` 相同:符号名加声明所在文件(`@deepseek-ai/dsh-typert-protocol` 的 `types.ts`)。第一个类型参数是下行项,第二个存在且不是 `never` 时生成 `uplink` boundary,键名 `${endpoint}:uplink`。 +- `emitter.ts`:描述符字面量输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle`。 +- 参数循环不再识别任何名为 `uplink` 的参数。 + +### 两个名字,一个入口 + +Host 方法用 `RemoteStream` 声明一条流;Client 持有的是 `RemoteStreamHandle`。两者都从 `@deepseek-ai/dsh-typert-protocol` 主入口导出,句柄接口没有任何 Host 依赖。生成的 Client 契约把返回类型写成 `RemoteStreamHandle`。一个名字只有一个含义:Client 代码从主入口拿到的 `RemoteStream` 永远是声明类型,不会与句柄混淆。 + +### 网关 Host(`packages/api/gateway/src`) + +```text +// types.ts +export interface InvokeRemoteRequest { + readonly namespace: string + readonly method: string + readonly args: Readonly> + /** Uplink items; absent is equivalent to an iterator that ends immediately. */ + readonly uplink?: AsyncIterable + /** The Peer that initiated the call; absent means the operator. */ + readonly peer?: PeerScope + readonly signal?: AbortSignal +} + +export interface TypertGatewayWireStream { + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + failure: (error: unknown) => RemoteStreamFailure +} + +// stream-server.ts +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise> + +// index.ts Config addition +/** Uplink inbox limit per logical stream, in UTF-8 bytes of frames (default 262144). */ +readonly streamInboxBytes?: number +``` + +`prepareInvocation`: + +```text +const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))) +const signal = request.signal ?? NEVER_ABORTED_SIGNAL +const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control), +) +const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object +if (descriptor.cancellation !== undefined) args.push(signal) +``` + +`GatewayInvocation.uplink()` 第一次调用返回解码器,第二次抛错。`UplinkDecoder` 是手写迭代器:有 codec 时逐项 `decode(codec, value, endpoint, 'uplink')`,失败即 `control.abort(failure)` 并抛出;无 codec 时只做 `isRemoteJsonValue` 校验。`cancellableStream` 的 `finally` 先关闭解码器(`invocation.close()`)再等待方法迭代器的 `return()`,阻塞在 `uplink.next()` 上的方法得以退出。unary 与 stream 的模式分支恢复为今天的两分支。SRC 回退:描述符无 `uplink` codec,`uplink()` 交付 `unknown`。 + +### 网关 Host mux(`stream-server.ts`) + +```text +interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise } + +/** Bounded uplink queue; iterated as the source of uplink(). */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + constructor(maxBytes: number, onOverflow: (error: RemoteError) => void) + push(value: unknown, frameBytes: number): void // push after ended fails the stream with gateway/protocol; overflow calls onOverflow + end(): void // idempotent + fail(error: unknown): void // ends the iterator with an error on cancel or carrier closure + next() / return() // hand-written: next races failure, return releases immediately +} +``` + +`receive(text)` 分派: + +``` +open : exists → throw (socket 1008); otherwise create { control, inbox, done } and start the pump +item : missing → ignore; otherwise inbox.push(value, bytes) +end : missing → ignore; otherwise inbox.end() +cancel : missing → ignore; otherwise control.abort(new Error('Remote stream cancelled')) +``` + +`pump` 把 inbox 与该 socket 的 Peer 传给 opener;`finally` 里 `inbox.fail(...)`。以 `RemoteError` 为 reason 的中止由 pump 作为 `error` 帧发出(网关或 mux 发起的失败);以普通 `Error` 为 reason 的中止(cancel、socket 关闭)不发终止帧。 + +### 网关 Client(`packages/api/gateway/src/client`) + +- `prepareInvocation` 不再剥离任何上行参数;位置参数仍是业务参数加可选 `signal`。 +- `invokeStream` 返回 `RemoteStreamHandle`:内部持有一个上行队列,`send` 入队并由泵发 `item` 帧,`end` 发 `end` 帧,`dispose` 中止代际 signal。下行仍是 `openRemoteStream` 返回的迭代器。 +- `RemoteStreamMuxClient.open(endpoint, payload, signal, uplink?)`:在发出 `open` 帧后启动上行泵,逐项发 `item`,源结束发 `end`;下行终止或 signal 中止时停泵并调用源的 `return()`(不等待,避免键盘类生成器卡死);源抛错时 `inbox.fail(error)`,下行以该错误失败,`finally` 按既有规则发 `cancel`。泵检查 `this.socket === socket`,旧代际不往新 socket 上发帧。 +- 进程内载体:`connection.rpc.open(channel, endpoint, payload, signal, uplink)`。 + +## 落点 + +| 包 | 承载 | +| --- | --- | +| `dsh-typert-protocol`(`types.ts`、`index.ts`) | `RemoteStream`、`RemoteStreamHandle`、`PeerId`、`PeerScope`、`RemoteInvocation`、`ctx.invocation` 声明合并、`InvocationDescriptor.uplink`;修饰器只认 `mode: 'stream'`;`TypertRemoteService` 构造时注册 `invocation` accessor | +| `dsh-typert-registry` | 加载校验 `uplink.codec` 为有效的严格 codec | +| `dsh-typert-generator`(`model.ts`、`analyzer.ts`、`emitter.ts`) | `remoteResultType` 识别 `RemoteStream` 并生成 `uplink` boundary;描述符输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle`;参数循环只认业务参数与末位 `signal` | +| `dsh-client-connection`(`operator-peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | 操作者 `PeerScope`;`connection.operator` 与 `admit`;`/api` 与升级路由经 `admit` 取 Peer;`rpc.open` 的可选 `uplink` | +| `dsh-api-gateway` Host(`stream-protocol.ts`、`stream-server.ts`、`types.ts`、`index.ts`) | `item` / `end` 帧;`UplinkInbox`;`Config.streamInboxBytes`;`GatewayInvocation` 与 `extend({ invocation })`;`UplinkDecoder`;`handleUpgrade(req, socket, head, peer)`;`operatorPeer()` | +| `dsh-api-gateway` Client(`client/index.ts`、`client/stream-client.ts`) | `invokeStream` 返回句柄;上行泵由句柄的队列驱动 | +| `dsh-webworker-runtime`、`dsh-remote-mock` | worker 隧道的两种上行帧与 `serveStream` 组装;`StreamHandle.uplink`;直接代理返回真句柄 | +| 文档 | gateway、api 组、connection、protocol、generator 的 README;`docs/api-gateway`;`docs/subsystems/typert` 的 type-equiv 块;config 与 Cordis 目录 | + +## 关键流程时序 + +### 打开、上行、半关闭、下行结束 + +``` +Client caller mux client mux server gateway Host method + │ attach(req) │ │ │ │ + │───────────────────▶│ open { id, ep, payload } ──────────────────▶│ create inbox │ + │ s.send(v1) │ item { id, v1 } ───────────────────────────▶│ inbox.push(v1) │ + │ │ │ │ prepareInvocation │ + │ │ │ │ extend({ invocation }) │ + │ │ │ │── method(req, signal) ─▶│ + │ │ │ │ │ uplink() → v1 + │ s.send(v2) │ item { id, v2 } ───────────────────────────▶│ inbox.push(v2) │ → v2 + │ s.end() │ end { id } ────────────────────────────────▶│ inbox.end() │ iteration ends + │◀── yield o1 ───────│◀────────────────────── item { id, o1 } ─────│◀── yield o1 ────────────│ + │◀── ends ───────────│◀────────────────────── end { id } ──────────│◀── method returns ──────│ gateway calls uplink.return() +``` + +### 客户端 dispose + +``` +Client caller mux client mux server gateway Host method + │ s.dispose() │ │ │ │ + │───────────────────▶│ stop pump; source return() (not awaited) │ │ + │◀── iterator ends ──│ cancel { id } ─────────────────────────────▶│ control.abort() │ + │ │ │ │ cancellableStream: invocation.close() closes uplink + │ │ │ │ iterator.return() ─────▶│ finally cleanup + │ │ │ no terminal frame │ │ +``` + +### inbox 超限 + +``` +Client caller mux client mux server gateway / Host method + │ s.send(vN) │ item { id, vN } ─────▶│ inbox.push: bytes > streamInboxBytes │ + │ │ │ onOverflow → control.abort(RemoteError uplink-overflow) + │ │ │ │ uplink() ends with that error; method iterator return() + │◀── throw ──────────│◀──────────────────────│ error { id, uplink-overflow }: the pump sends an error frame because the reason is a RemoteError +``` + +## 边界矩阵 + +| 事件 | opening | running | 上行已 end | 下行已终止 | +| --- | --- | --- | --- | --- | +| 收到 `item` | 入 inbox 缓冲 | 入 inbox | 流以 `gateway/protocol` 失败 | 忽略 | +| 收到 `end` | inbox.end | inbox.end | 幂等 | 忽略 | +| 收到 `cancel` | abort | abort | abort | 忽略 | +| inbox 超限 | 流以 `uplink-overflow` 失败 | 同 | 不可能 | 不可能 | +| 解码失败 | 不可能 | 流以 `input-invalid` 失败 | 同 | 不可能 | +| Host 方法结束 | 不可能 | 发 `end`;`uplink.return()` | 发 `end` | 已发 | +| Host 方法抛错 | 发 `error` | 发 `error`;`uplink.return()` | 发 `error` | 已发 | +| socket 关闭 | abort,等 done | 同 | 同 | 无事 | +| Client `send` | 入队,open 后发 | 发 | 抛错 | 抛错 | +| Client `end` | 记录,open 后发 | 发 | 幂等 | 忽略 | +| Client 侧下行先结束 | 不可能 | 停泵;源 `return()`;不发 `end` | 无事 | 无事 | + +## 错误码 + +| 错误码 | 何时 | +| --- | --- | +| `gateway/input-invalid` | 既有:参数 codec 解码失败;新增:上行项解码失败 | +| `gateway/uplink-overflow` | 新增:inbox 超过 `streamInboxBytes` | +| `gateway/protocol` | 新增:`end` 之后的 `item` | +| `gateway/cancelled` | 既有:`signal` 中止 | + +新码登记在 `packages/api/gateway/src/remote-error-codes.ts` 与 README 的错误码一节。 + +## 最小 echo 方法 + +`packages/api/gateway/tests/echo-stream.host.spec.ts` 让同一个方法分别穿过 WebSocket 载体与进程内载体: + +```text +class EchoService extends TypertRemoteService { + constructor(ctx: Context) { super(ctx, 'echo', { namespace: 'echo' }) } + + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + for await (const item of this.ctx.invocation!.uplink()) { + signal.throwIfAborted() + yield `${prefix}${item}` + } + } +} + +// Client +const stream = remote.echo.echo('> ') +stream.send('a'); stream.send('b'); stream.end() +const replies: string[] = [] +for await (const reply of stream) replies.push(reply) // ['> a', '> b'] +``` + +## 考虑过的替代方案 + +**独立的 `mode: 'duplex'` 加一个与 `signal` 同级的保留参数 `uplink: AsyncIterable`。** 每一处都是特殊逻辑:analyzer 要认第二个保留参数及其位置规则,生成签名多一个参数,加载期要校验 `duplex ⟺ uplink`,mux 不知道方法模式所以网关要替 `'stream'` 方法拒绝上行帧。双工是传输层流的固有性质,不是方法的一种模式:载体本来就有 `streamId`、`open`、`cancel`,上行只是再加两种帧,任何流都能收,不用就不发。 + +**用装饰器选项声明上行类型,`@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`。** 字符串引用类型名不受编译器约束,重命名即断。返回类型别名把两个类型写在同一个编译器可见的位置。 + +**运行时传 schema:`uplink(schema)`。** 不经生成器,客户端失去类型投影,与"每项严格 codec"的体系分裂。codec 的来源仍是生成器,方法里的泛型只是断言。 + +**analyzer 交叉校验 `uplink()` 手填的泛型与返回类型一致。** 业务特化的校验,写错后果由调用方承担,与仓库其他处手填断言一致;不加。 + +**传输层句柄带重开、游标、`reopen` 钩子。** 断线重开是上层协议的事:job 的续传是 `opened` 帧带 `from`、客户端记住 `next` 再调一次;snapshot 与 journal 各有自己的锚点与分类。传输层只认一个代际。 + +**客户端 `send` 放在生成方法返回的 `AsyncIterable` 上。** `AsyncIterable` 上挂方法不常规;句柄类型 `RemoteStreamHandle` 显式扩展它。 + +**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。 + +**继续用 unary RPC 做上行。** 每次上行一次 HTTP 往返、一次鉴权、一次查找;两条载体没有顺序关系,只能靠客户端串行化;没有 EOF;取消不覆盖两半;每个功能各造一套关联键。 + +**原生 HTTP 请求体流。** 一次性请求体,与下行流没有关联,不经 Typert 描述符,浏览器对全双工 fetch 的支持不可依赖。 + +**每条双工流一个独立 WebSocket。** 失去与现有下行流共享的鉴权、心跳与多路复用,每个功能要自己管一条 socket 的生命周期。 + +**WebTransport 或 HTTP/2 双向流。** 有每流流控,但浏览器与 Node 两侧的支持、代理穿透与 Electron 载体都不成熟;在现有 mux 上加两种帧比换传输层便宜几个量级。 + +## 后果 + +- **买到的**:一条流两个方向,终端按键、审批回答、后台任务 stdin 这类交互式场景不再各造一套"一条流加一个 unary";Host 方法知道调用者;上行类型与下行类型在返回类型里一处声明,上行项来自浏览器,在 Host 逐项严格校验,下行项是 Host 自产的类型值,直接透传;既有 `AsyncIterable` 方法与 unary 方法的生成物与线路帧完全不变。 +- **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。 +- **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。 +- **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。 +- **上行与下行之间没有跨方向顺序保证**;需要请求应答配对的协议自带序号。 +- **上层协议尚未透出 `send`**:`$stream`、snapshot、journal 的消费者今天只用下行;它们拿到的对象已经是句柄。 +- **worker 隧道与 WebSocket 载体行为不同**:没有 inbox 上限,`end` 之后的项丢弃,因为页面到 worker 是同源可信边界。 + +## 测试 + +- `packages/api/gateway/tests/echo-stream.host.spec.ts`:同一个 echo 方法分别穿过 WebSocket 载体与进程内载体。 +- 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。 +- 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。 +- typert:analyzer 对 `RemoteStream`、`RemoteStream`、`never` 与既有 `AsyncIterable` 的处理;修饰器选项;registry 加载校验。 +- connection:`admit` 的 401、403 与操作者三种结果;操作者经桥与共享处理器到达;随 connection 释放。 +- remote-mock、worker 隧道:上行透传;直接代理返回真句柄。 +- 既有 stream 与 unary 方法的测试不改期望值即通过。 + +## 后续 + +- `$stream` / snapshot / journal 把句柄的 `send` 透出给上层协议。 +- Web 终端:一条 `attach` 流取代 `follow` 加 `write` 加 `resize`,`attachmentId` 退场。 +- 双工 `$events`:waterfall 的回答成为同一 `streamId` 上的上行项,`$events/result` 与 `clientId` 退场。 +- credit 帧:等需要持续大流量上行的消费者出现;inbox 上限就是初始授信。 +- `streamInboxBytes` 默认值 262144 对键盘与 stdin 远超需要;若第一个消费者是终端粘贴,可能与 `terminal-controller` 的 `maxInputBytes` 对齐。 +- Host 发起的半关闭帧(Host 不再读上行但继续写下行):没有消费者,不加。 +- unary 方法的 `uplink()`:能读到方法运行期间到达的项,语义成立但用处存疑;允许,README 注明。 + +## 相关 + +- [Remote 事件投递](2026-08-10-remote-event-delivery.zh.md):`$events/result` 的 unary 回答路径;「后续」里的双工 `$events` 落地后部分取代它。 +- [会话历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md):`$stream()` 监督器与其上的 journal / snapshot 协议,本 Note 不改它们。 +- [Web 侧边栏终端](../feature/2026-09-09-web-sidebar-terminal.zh.md):终端按键的 unary `write` 与 `attachmentId`;「后续」里的 `attach` 流落地后取代它。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.i18n.yaml new file mode 100644 index 0000000000..802ab740bd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/bug-fix/2026-09-20-session-open-read-coordination.md +2026-09-20-session-open-read-coordination.md: a4907df1d7dd38e4c532c38fb6ff10362a4a3898 +2026-09-20-session-open-read-coordination.zh.md: 759a45d8c52a62ea7a0b30ceed4003f8804a42c3 diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md new file mode 100644 index 0000000000..a4907df1d7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md @@ -0,0 +1,88 @@ +# Agent Note: Session opening cache identity and read ordering + +Status: implemented + +English | [中文](2026-09-20-session-open-read-coordination.zh.md) + +## Problem + +Opening a historical Session can make `session.follow`, composer catalog prewarming, and RPCs with Agent parameters access the same cold log concurrently. `skills/list` requests a complete observation itself; `commands/list`, `goals/get`, `fileReferences/list`, and `sessionReferenceResolver/candidates` can initiate resume through the Agent lookup during parameter resolution. Auxiliary requests that do not wait for the history opening snapshot compete with the main conversation for reading, parsing, and projection computation instead of reusing completed results. + +Reuse of completed observations has a separate problem: Cordis can return different proxy objects for accesses to the same service. Comparing the object references returned by `ctx.get('sessionPersistence')` can mistake one underlying service for different instances. Removing instance identity checks entirely is also unsafe because persistence revisions are comparable only within the corresponding service instance and Session identity. + +Subagent navigation combines two needs: displaying a conversation at a known address, and discovering its parent or descendant catalogs. Refreshing parent projections before navigating through an existing parent-child address adds prerequisite work and can reject an address that the Host could validate and open when the Client catalog cache is absent. + +## Decision + +### Identify the cache producer with a service-owned Symbol + +`SessionPersistence.identity` is a `Symbol('sessionPersistence')` created once per service instance. Proxy access preserves that value, while a replacement service instance receives a different value. The prepared observation cache looks up the Session id and compares both `persistence.identity` and `stat().revision`; the same instance and revision can reuse a completed preparation. + +This identity exists only within the process and does not enter Session headers, events, or persisted files. Existing cache capacity, leases, live precedence, and revision invalidation remain unchanged. This fix does not merge Promises for unfinished cold reads; stable identity prevents false invalidation after completion, not duplicate concurrent work across all callers. + +### Auxiliary RPCs wait for the current conversation's initial open to succeed + +The Client uses the existing `sessions.using()` at the actual RPC call site, rather than only delaying prewarm hooks. Menu candidates, explicit reference queries, and later catalog refreshes therefore use the same waiting entry point. Each operation first checks that its target already has a Client binding, so background prewarming does not reopen a closed Session. + +| Client consumer | RPC after waiting | Temporary reference source | +| --- | --- | --- | +| Skill catalog | `skills/list` | `skillCatalog` | +| Command catalog | `commands/list` | `commandCatalog` | +| Goal activation state | `goals/get` | `goalActivation` | +| `@` file and Session candidates | `fileReferences/list`, `sessionReferenceResolver/candidates` | Shared `referenceCandidates` | + +`sessions.using()` waits for `reference.ready`, then for the operation's returned Promise, and finally releases its reference. `ready` means that the initial `Session.open()` attempt has settled, not that it succeeded; these consumers also require `openState === 'open'` and send no auxiliary RPC after a failed open. The Goal reader additionally checks that its captured binding has not been replaced and logs rejected reads without clearing existing display state. + +Skills retain the shared catalog request's own cancellation signal; `@` queries use the current candidate request's signal for the history wait and both RPCs. Unquoted `@` queries still fetch files and Sessions concurrently after waiting, while quoted paths still query files only. Commands and Goals gain no new cancellation protocol. These references retain Client data and follow streams, grant no new Host Agent authority, and do not prohibit normal Agent resume after the opening snapshot. + +### Conversations use follow; catalog branches retain explicit reads + +The main conversation's opening `follow` snapshot already carries that Session's complete projection baseline. Later changes to shared projection values notify subscribers, so switching Sessions or opening a menu does not require another read of the same baseline. + +- Main-view navigation does not separately refresh the selected Session's projections; restoring a saved subagent address does not prewarm its parent. +- The Sidebar retains the target child directly from its complete parent-child address, without making a parent-catalog read a prerequisite for opening the conversation. +- The header dropdown's `changeOpen` only changes presentation state and does not refresh `rootSessionId`. Child expansion and failed-read retries retain explicit refreshes under the UI callback name `refreshProjection`; the underlying `sessions.refreshProjections` API is not renamed. +- Team navigation uses the existing Lead and roster member ids to open `{ parentSessionId, childSessionId, mode: 'continuable' }` directly. It neither refreshes the parent catalog nor requires the target to be present in the Client catalog; member-role and main-view ownership checks, plus the UI restrictions on provisioning and failed members, remain unchanged. + +A known address does not waive authorization checks. When opening child history, the Host still checks the target's own header parent and origin, plus the mode and descriptor ownership in its own `subagent` projection. Later continuation remains subject to the existing direct-parent authorization rules. + +## Implementation scope + +- Session formats, migrations, the Host observation's `all | none` policy, and the response fields of `session.projections` are unchanged. +- There is no global cold-read singleflight, and Agent lookups are not universally replaced with read-only lookups. +- `SessionManager.handleConnected()` still refreshes previously requested catalogs in bulk; this decision does not guarantee one cold read on reconnect. +- Tool-side `listDescendants` can still read child Sessions on cache misses, and expanding a child node in the Client can still cold-read that node. Ordinary main-session opening is not equivalent to every descendant-enumeration scenario. +- `@` Session candidates still enumerate headers and obtain names from live projections or projection caches, falling back to ids on misses. Existing ranking, the default limit of 50, and direct-subagent grouping remain unchanged; discovery does not recursively traverse `subagentCatalog`. + +## Alternatives considered + +**Remove persistence-instance comparison.** Rejected. A matching Session id and revision cannot establish that two persistence services provide the same data; replacing a service must still invalidate old preparations. + +**Unwrap Cordis proxies in callers or cache service objects per Context.** Rejected. The cache needs the producer's identity, not the identity of an object returned by a particular service access. A per-instance Symbol expresses that requirement without propagating proxy implementation details. + +**Add shared singleflight for every cold read.** Outside this implementation. It could merge more concurrent calls, but would require separate rules for independent waiter cancellation, leases, and task ownership during service replacement. It also would not replace the ordering requirement that auxiliary RPCs must not resume an Agent ahead of the opening snapshot. + +**Delay only warm hooks, or merely await the extra projection request.** Rejected. Explicit menu queries could still bypass warm hooks, and serializing a redundant projection request does not remove its work. The wait belongs at the actual auxiliary RPC entry point; reads already supplied by follow are removed. + +**Keep Team's parent-catalog presence check.** Rejected. The Team roster already provides member identity, and the existing Host history entry point can validate an explicit address. Requiring a Client catalog cache hit for navigation mistakes data that has not loaded for a child that does not exist. + +## Consequences + +An ordinary main Session completes its own history and projection read before auxiliary operations reuse the completed observation or attached state. The Client's direct-child list consumes the main Session's catalog without reading each child's log merely to display its row. Catalog and conversation opening are separate operations, so navigating to a known child no longer adds parent-projection prewarming. + +Auxiliary catalogs become available after the conversation's opening snapshot, and operations retain temporary Client references until they finish. This wait covers only the current Client generation's initial open, not readiness after every underlying follow replacement. Reconnect and exceptional retry scenarios still require their own verification. + +A missing header root catalog remains a specific coverage gap: if it belongs to an unopened parent and no other source supplies the catalog, opening the dropdown alone does not fetch it, and the current UI may continue to display a loading notice. This implementation adds neither a new parent-catalog loading entry point nor a shared unknown-state interaction protocol. + +## Verification and known gaps + +During local navigation to a long historical main Session, the user observed repeated `readColdSessionLog` calls converge to one using temporary timers and call stacks, and verified direct teammate navigation. This result describes that operation's cold-read helper invocation count, not every underlying file I/O, every reconnect scenario, or a single unit of end-to-end cost. Private Session content and identities from diagnosis are not test fixtures. + +Focused TypeScript builds and bundles were run for affected packages during development; the complete change has no gate, behavior-test, or assembled Web snapshot verification result. Existing tests have only partial callback renames and still contain old fixture names, root-menu-opening refresh expectations, Sidebar parent-refresh prerequisites, and Team rejection of missing catalogs. These are known verification gaps, not passing checks. + +## Relationship to existing records + +- [Session observations and projection-owned Client state](../architecture/2026-08-25-session-observations-and-projection-owned-client-state.md) retains ownership of read cuts and shared values. This record adds cache-producer identity and opening-snapshot consumption order; the older claim of sharing unfinished cold loads does not match the current reader and is not evidence that this change implements singleflight. +- [Client Session references, reference sources, and UI state](../architecture/2026-09-15-client-session-references.md) retains ownership of `retain`, `ready`, `using`, and explicit-address semantics. This record limits temporary references to four classes of auxiliary reads as independent asynchronous operations, rather than adding references to every UI action. +- [Web subagent catalogs consume shared projections](../simplification/2026-09-08-web-subagent-catalog-projections.md) retains ownership of the generic projection store, explicit catalog reads, and control updates. This implementation applies its rule that opening a conversation uses the follow baseline, without replacing the catalog mechanism. +- [Web subagent catalogs and human continuation](../feature/2026-07-27-web-subagent-conversations.md) retains ownership of presentation and continuation authorization. This record changes only prerequisite navigation reads and root-menu refresh timing, not the child's own Host validation; the older description of interactive loading for missing root catalogs must be reconciled with the coverage gap recorded here. diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.zh.md b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.zh.md new file mode 100644 index 0000000000..759a45d8c5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.zh.md @@ -0,0 +1,88 @@ +# Agent Note: 会话首帧读取的缓存身份与调用时序 + +Status: implemented + +[English](2026-09-20-session-open-read-coordination.md) | 中文 + +## 问题 + +打开历史会话时,`session.follow`、输入框目录预热和依赖 Agent 参数的 RPC 可能同时访问同一份冷日志。`skills/list` 自行请求完整 observation;`commands/list`、`goals/get`、`fileReferences/list` 与 `sessionReferenceResolver/candidates` 则可以在参数解析阶段经 Agent lookup 发起恢复。辅助请求不等待历史首帧,会与主对话争用读取、解析和 projection 计算,而不是复用已经完成的结果。 + +完成后的 observation 复用还有一个独立问题:Cordis 对同一服务的访问可以返回不同代理对象。以 `ctx.get('sessionPersistence')` 返回对象的引用相等性判断服务身份,会把同一底层服务误判成另一个实例。另一方面,彻底去掉实例身份检查也不安全,因为持久化 revision 只在对应服务实例和 Session 身份内可比较。 + +子会话导航混合了两种需求:展示一个已知地址的对话,以及发现该会话的父目录或后代目录。已有父子地址的导航再次刷新 parent projection,既增加前置工作,也会在目录缓存缺席时拒绝一个本可由 Host 校验并打开的地址。 + +## 决策 + +### 用服务自身的 Symbol 标识缓存生产者 + +`SessionPersistence.identity` 是每个服务实例创建一次的 `Symbol('sessionPersistence')`。代理访问保留这个值,服务被替换后新实例获得不同值。Prepared observation cache 按 Session id 查找,并同时比较 `persistence.identity` 与 `stat().revision`;相同实例、相同 revision 可以复用已完成的 preparation。 + +该身份只存在于进程内,不进入 Session header、事件或持久化文件。现有缓存容量、lease、live 优先级和 revision 失效规则不变。这个修复不增加尚未完成的冷读取之间的 Promise 合并;稳定身份解决的是完成后的误失效,不是所有调用方的并发去重。 + +### 辅助 RPC 等待当前对话的首次打开成功 + +Client 在实际 RPC 发送位置使用既有 `sessions.using()`,不只延迟预热钩子。菜单候选、显式引用查询和后续目录刷新因而经过相同的等待入口。每个操作先确认目标已经有 Client binding,不因后台预热重新打开一个已经关闭的会话。 + +| Client 消费方 | 等待后的 RPC | 临时引用来源 | +| --- | --- | --- | +| Skill 目录 | `skills/list` | `skillCatalog` | +| Command 目录 | `commands/list` | `commandCatalog` | +| Goal 激活状态 | `goals/get` | `goalActivation` | +| `@` 文件与会话候选 | `fileReferences/list`、`sessionReferenceResolver/candidates` | 共用 `referenceCandidates` | + +`sessions.using()` 先等待 `reference.ready`,再等待操作返回的 Promise,最后释放自己的引用。`ready` 表示首次 `Session.open()` 尝试已结算,不保证成功;这些消费方还检查 `openState === 'open'`,失败时不发送辅助 RPC。Goal 读方还确认捕获的 binding 未被替代,并记录读取拒绝而不清除已有展示状态。 + +Skill 保留共享目录请求自己的取消信号;`@` 查询将当前候选请求的信号用于历史等待和两个 RPC。未加引号的 `@` 在等待后仍并行查询文件和会话,加引号的路径仍只查询文件。Command 与 Goal 不新增取消协议。这里的引用持有 Client 数据和跟随流,不赋予 Host Agent 新权限,也不禁止首帧之后发生正常 Agent 恢复。 + +### 对话使用 follow,目录分支保留显式读取 + +主对话的 `follow` 首帧已经携带该 Session 的完整 projection baseline。后续共享 projection 值变化会通知订阅者,不需要在切换会话或打开菜单时再次读取相同 baseline。 + +- 主视图切换不再额外刷新所选 Session 的 projection;恢复保存的子会话地址不再预先刷新其 parent。 +- 侧栏从完整父子地址直接保留目标子会话,不把父目录读取作为打开对话的前提。 +- Header 下拉菜单的 `changeOpen` 只改变展示状态,不刷新 `rootSessionId`。展开子节点和失败重试仍调用显式刷新,UI 回调命名为 `refreshProjection`;底层 `sessions.refreshProjections` API 不改名。 +- Team 点击使用已有 lead 与 roster 成员 id,直接打开 `{ parentSessionId, childSessionId, mode: 'continuable' }`。它不刷新 parent catalog,也不要求目标预先出现在 Client catalog 中;成员角色、主视图持有检查及 UI 对 provisioning、failed 成员的限制保持不变。 + +已知地址不等于免除授权校验。Host 在打开子会话历史时,仍检查目标自身 header 的 parent、origin,以及自身 `subagent` projection 的 mode 和描述符归属。后续 continuation 仍受直接 parent 的既有授权规则约束。 + +## 实现边界 + +- 没有修改 Session 格式、迁移、Host observation 的 `all | none` 策略或 `session.projections` 返回字段。 +- 没有实现全局 cold-read singleflight,也没有把所有 Agent lookup 改成只读 lookup。 +- 没有删除 `SessionManager.handleConnected()` 对此前请求目录的批量刷新;本决策不保证重连只有一次冷读。 +- 工具侧 `listDescendants` 的缓存缺失仍可能读取子会话;前端展开一个子节点也仍可能冷读该节点。普通主会话首开不等于所有后代枚举场景。 +- `@` 会话候选仍枚举 header,并从 live projection 或 projection cache 取名称;缓存缺失回退到 id。原有排序、默认 50 条上限和直接 subagent 分组规则不变,没有改成沿 `subagentCatalog` 递归发现。 + +## 考虑过的替代方案 + +**取消 persistence 实例比较。** 不采用。同一个 Session id 和 revision 不能证明两个持久化服务提供同一份数据;服务替换仍必须使旧 preparation 失效。 + +**在调用方解包 Cordis 代理或按 Context 缓存服务对象。** 不采用。缓存需要的是生产者身份,而不是某次服务访问返回对象的身份;每实例 Symbol 足够表达这个要求,不需要传播代理实现细节。 + +**为所有冷读增加统一 singleflight。** 未纳入本次实现。它可以合并更多并发调用,但需要另外定义独立等待方取消、lease 和服务替换期间的任务归属;它也不能代替“辅助 RPC 不抢在首帧前恢复 Agent”的时序要求。 + +**只延迟 warm,或只给额外 projection 请求补 await。** 不采用。显式菜单查询仍能绕过 warm;串行执行一个重复 projection 请求也没有删除重复工作。等待放在实际辅助 RPC 入口,已有 follow 能提供的读取直接取消。 + +**保留 Team 的父目录存在性检查。** 不采用。Team roster 已给出成员身份,显式地址可以由现有 Host 历史入口验证;把 Client catalog 缓存命中作为导航前提,会把数据尚未加载误当成子会话不存在。 + +## 后果 + +普通主会话先完成自己的历史和 projection 读取,辅助操作再复用已完成的 observation 或已挂载状态。前端直接子代列表消费主会话的 catalog,不为显示每一行读取对应子会话正文。目录与对话的打开入口分开,导航到一个已知子会话不再附带 parent projection 预热。 + +代价是辅助目录首次可用时间晚于对话首帧;操作还会持有临时 Client 引用直到结束。该等待只对应当前 Client generation 的首次打开,不是每次底层 follow 重建的就绪屏障。所有重连和异常重试场景仍需各自验证。 + +Header 根目录缺失仍是明确的覆盖缺口:若它属于未打开的 parent,且没有其他来源提供 catalog,仅打开下拉菜单不会补读;现有 UI 仍可能显示加载提示。本次没有增加新的父目录加载入口,也没有实现统一的 unknown 交互协议。 + +## 验证与已知缺口 + +用户在本地长历史主会话切换流程中,通过临时 `readColdSessionLog` 计时和调用栈观察到重复调用收敛为一次,并验证了 teammate 直接导航。这个结果只描述该操作的冷读辅助函数调用次数,不代表所有底层文件 I/O、全部重连场景或端到端性能都只有一次成本。诊断期间的私有 Session 内容和身份不作为测试 fixture。 + +开发期对相关包执行过定向 TypeScript 构建与打包;尚无这批完整变更的门禁、行为测试或组装 Web 快照验证结果。现有测试只完成部分回调改名,仍有旧 fixture 名称、打开根菜单即刷新、侧栏先刷新 parent,以及 Team 拒绝缺失 catalog 的预期需要调整。这些是已知验证缺口,不是已经通过的检查。 + +## 与既有记录的关系 + +- [Session observation 与 projection 所有的客户端状态](../architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md)继续拥有读取切面和共享值的职责划分。本记录补充缓存生产者身份与首帧消费顺序;旧记录中“包括未完成冷加载的共享”描述与当前 reader 实现不一致,不能作为本次 singleflight 已实现的依据。 +- [Client Session 引用、引用来源与 UI 状态](../architecture/2026-09-15-client-session-references.zh.md)继续拥有 `retain`、`ready`、`using` 与显式地址语义。本记录限定四类辅助读取作为独立异步操作持有临时引用,不把额外引用扩展到所有 UI 动作。 +- [Web 子代理目录消费共享 projection](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md)继续拥有通用 projection store、显式目录读取和 control 更新。本次实现落实“打开会话使用 follow baseline”的规则,不替换整个目录机制。 +- [Web subagent 目录与用户继续交互](../feature/2026-07-27-web-subagent-conversations.zh.md)继续拥有展示和 continuation 授权。本记录只调整导航前置读取及根菜单刷新时机,不取消子会话自身的 Host 校验;旧记录对缺失根目录交互加载的描述需与这里的覆盖缺口一起核对。 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index cfbf30d80a..76ed3e8786 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.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/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: 5ed920d744f847cc97b08645592f0be221f18b8d -2026-09-09-web-sidebar-terminal.zh.md: c3ceb0214c2f4db42b2fefb8b65619e198d9dc7e +2026-09-09-web-sidebar-terminal.md: 09be4a142f615750799e01106eac7a9b52cbad48 +2026-09-09-web-sidebar-terminal.zh.md: c2ac1468756ee08c713855979119662390ffeadb diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index 5ed920d744..09be4a142f 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -49,3 +49,7 @@ A kept-open terminal retains a process and bounded screen memory. Reload restore The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals. The [two-hour unattended-terminal reclamation](2026-09-14-unattended-browser-terminal-reclamation.md) adds an idle grace period after all frontend holders disconnect, preserving busy or uncertain work. + +## Related + +- [Remote duplex stream](../architecture/2026-09-19-remote-duplex-stream.md): the transport-layer uplink under which one `attach` stream replaces the unary `write` and `resize` calls and the `attachmentId` correlation; that terminal change is listed under its Deferred section. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index c3ceb0214c..c2ac146875 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -49,3 +49,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。 [无人连接终端的两小时回收](2026-09-14-unattended-browser-terminal-reclamation.zh.md)在全部前端持有者断开后设置空闲宽限期,保留忙碌或状态不确定的工作。 + +## 相关 + +- [Remote 双工流](../architecture/2026-09-19-remote-duplex-stream.zh.md):传输层上行,让一条 `attach` 流取代 unary `write`、`resize` 与 `attachmentId` 关联;该终端改动列在其「后续」中。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 8c41184009..d78cf7b464 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -260,7 +260,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + const restoredAdd = await addTab.count() expect(restoredFilesClose).toBe(1) expect(restoredAdd).toBe(1) - const preview = column.locator('[data-document-preview]') + const preview = column.locator('[data-textpreview-url]') const openFile = openPreviewFile.bind(undefined, column, filesTab, preview) const viewer = preview.locator('[data-document-viewer-menu]') const body = preview.locator('[data-textpreview-body]') @@ -696,7 +696,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + const officeMenus: number[] = [] const configurationGuide = 'Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.' - for (const extension of ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) { + for (const extension of ['doc', 'docx', 'ppt', 'pptx']) { await openFile(`unavailable.${extension}`) expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0) await preview.getByText(configurationGuide, { exact: true }).waitFor({ timeout: 15_000 }) @@ -707,12 +707,23 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await successShot(page, 'office-unavailable') sections.push([ '## Office unavailable', '', - `- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`, + `- DOC, DOCX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`, `- Guidance: ${configurationGuide}`, '- Binary text shown: false', '- Plain-text option and viewer picker: hidden', ].join('\n')) + const spreadsheetStates: string[] = [] + for (const extension of ['xls', 'xlsx']) { + await openFile(`unavailable.${extension}`) + const spreadsheet = column.locator('[data-textpreview-state="unsupported"]') + await spreadsheet.waitFor({ timeout: 15_000 }) + expect(await spreadsheet.getByText('Preview is not available for this file type yet.', { exact: true }).count()).toBe(1) + await spreadsheet.locator('[data-open-path-unpreviewable]').waitFor({ timeout: 15_000 }) + spreadsheetStates.push(`${extension.toUpperCase()}: unsupported / Open in default app`) + } + sections.push(['## Spreadsheet preview', '', `- ${spreadsheetStates.join('\n- ')}`].join('\n')) + await openFile('notes.unknown') const plainLines = preview.locator('[data-textpreview-line]') await expect.poll(() => plainLines.count()).toBe(2) @@ -810,7 +821,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await column.locator('[data-files-reload]').click() const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) - const preview = column.locator('[data-document-preview]') + const preview = column.locator('[data-textpreview-url]') await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click() expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0) const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true }) @@ -885,30 +896,36 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { `- Document top inset: ${topInset}px`, ].join('\n'), MODE) await successShot(page, 'office-docx') - for (const extension of ['doc', 'xls', 'xlsx', 'ppt', 'pptx']) { + for (const extension of ['doc', 'ppt', 'pptx']) { await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 }) await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档') - if (['doc', 'xls', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0) + if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0) await successShot(page, `office-${extension}`) } - expect(convert).toHaveBeenCalledTimes(6) + expect(convert).toHaveBeenCalledTimes(4) + for (const extension of ['xls', 'xlsx']) { + await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) + await preview.getByText('Preview is not available for this file type yet.', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await preview.getByRole('img', { name: 'PDF page 1', exact: true }).count()).toBe(0) + } + expect(convert).toHaveBeenCalledTimes(4) await openPreviewFile(column, filesTab, preview, 'chinese.docx') await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) await preview.getByRole('button', { name: 'Read the file again', exact: true }).click() - await expect.poll(() => convert.mock.calls.length).toBe(7) + await expect.poll(() => convert.mock.calls.length).toBe(5) await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) await openPreviewFile(column, filesTab, preview, 'renamed.docx') await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 }) expect(await preview.locator('[data-textpreview-line]').count()).toBe(0) await successShot(page, 'office-invalid') - expect(convert).toHaveBeenCalledTimes(8) - for (const extension of ['doc', 'xls', 'ppt']) { + expect(convert).toHaveBeenCalledTimes(6) + for (const extension of ['doc', 'ppt']) { await openPreviewFile(column, filesTab, preview, `renamed.${extension}`) await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 }) expect(await preview.locator('[data-textpreview-line]').count()).toBe(0) } - expect(convert).toHaveBeenCalledTimes(11) + expect(convert).toHaveBeenCalledTimes(8) expect(tripwire.pageErrors).toEqual([]) } finally { convert.mockRestore() } }) diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 41d6462e11..1337d351d5 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -26,3 +26,4 @@ - switch "Developer tools" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" + - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index 41d6462e11..1337d351d5 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -26,3 +26,4 @@ - switch "Developer tools" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" + - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 9a3776bcb7..74db4241cd 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -26,3 +26,4 @@ - switch "开发者工具" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" + - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md new file mode 100644 index 0000000000..6009227833 --- /dev/null +++ b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md @@ -0,0 +1,18 @@ +- tree "Sessions": + - treeitem "{{workspace}}" [expanded] + - treeitem "New Session" [selected] + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} Session actions for {{workspace}} Archive session Pin session": + - text: {{workspace}} + - button "Session actions for {{workspace}}" + - button "Archive session" + - button "Pin session" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - button "Show 6 more sessions" diff --git a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md index 8896ca5c5c..8778baac12 100644 --- a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md +++ b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md @@ -9,4 +9,4 @@ - treeitem "{{workspace}} 1min" - treeitem "{{workspace}} 1min" - treeitem "{{workspace}} 1min" - - button "Show 1 more sessions" + - button "Show 11 more sessions" diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index c5e0d10fb6..0c6e79308a 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -29,6 +29,8 @@ const PLUGIN_INSTANCES_EXPECTED = join(SNAPSHOT_DIR, 'plugin-instances.expected. const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md') const PLUGIN_ROW_SELECTOR = '[data-plugin-scope="preset"] [data-plugin-entry="tool-subagent"]' const MODE = webSnapshotMode() +const { version } = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { version: string } +const versionCapture = { replacements: [[version, '{{version}}']] as const } describe('web e2e: settings modal and General preferences', () => { let scaffold: WebScaffold @@ -93,8 +95,9 @@ describe('web e2e: settings modal and General preferences', () => { await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) await page.unroute('**/api/settings/openSettingsDocument') + await dialog.getByText(`当前版本:${version}`, { exact: true }).waitFor() // Golden of the freshly opened dialog (default zh, General active). - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, versionCapture) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) // Section switch: aria-current moves (the Models page itself has its own scenario file). await dialog.getByRole('button', { name: '模型' }).click() @@ -647,6 +650,7 @@ describe('web e2e: settings modal and General preferences', () => { // ...and the attribute follows that switch, in the assembled app. await expect.poll(() => page.evaluate(() => document.documentElement.lang), { timeout: 5_000 }).toBe('en') expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') + await enDialog.getByText(`Current version: ${version}`, { exact: true }).waitFor() await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) @@ -756,7 +760,8 @@ describe('web e2e: settings modal and General preferences', () => { // Golden of the English fallback dialog — the visible output this change // produces. The zh golden above covers the detected-locale surface, so // the pair pins both directions of the resolution. - const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd) + await dialog.getByText(`Current version: ${version}`, { exact: true }).waitFor() + const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd, versionCapture) await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE) expect(frTripwire.pageErrors).toEqual([]) expect(frTripwire.warnings).toEqual([]) @@ -780,7 +785,7 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' }) await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor() expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0) - const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd) + const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd, versionCapture) await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE) expect(browserConsole.pageErrors).toEqual([]) expect(browserConsole.warnings).toEqual([]) diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts index 21bdd58871..e23e0e9a80 100644 --- a/apps/web/tests/workspace-new-session-folding.e2e.ts +++ b/apps/web/tests/workspace-new-session-folding.e2e.ts @@ -22,7 +22,7 @@ const EXPECTED_DIR = fileURLToPath(new URL('./expected/workspace-new-session-fol const SIDEBAR_EXPECTED = join(EXPECTED_DIR, 'sidebar.expected.md') const SEED = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() -const EXISTING_SESSION_COUNT = 6 +const EXISTING_SESSION_COUNT = 16 describe('web e2e: blank New Session folding quota', () => { let scaffold: WebScaffold @@ -72,7 +72,7 @@ describe('web e2e: blank New Session folding quota', () => { await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 15_000 }).toBe(7) expect(await sidebar.getByText('New Session', { exact: true }).count()).toBe(1) expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(6) - const showMore = sidebar.getByRole('button', { name: 'Show 1 more sessions' }) + const showMore = sidebar.getByRole('button', { name: 'Show 11 more sessions' }) await showMore.waitFor({ timeout: 15_000 }) await compareOrRefreshGolden( SIDEBAR_EXPECTED, @@ -81,11 +81,20 @@ describe('web e2e: blank New Session folding quota', () => { ) await showMore.click() - await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(8) - expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(7) + await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(12) + expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(11) + await compareOrRefreshGolden( + join(EXPECTED_DIR, 'first-batch.expected.md'), + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) + await sidebar.getByRole('button', { name: 'Show 6 more sessions' }).click() + await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(17) + await sidebar.getByRole('button', { name: 'Show 1 more sessions' }).click() + await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(18) await sidebar.getByRole('button', { name: 'Show less' }).click() await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(7) - await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md']) + await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md']) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 673807013b..018bb1abc0 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: aa8339219a563901d5d132f4f401325ca30a3d53 -api-gateway.zh.md: 99b2d9866d38e520880e5d13115c2e6370fca474 +api-gateway.md: 52e91c48104ac8a8a46ad27f2b329ca72d6abc06 +api-gateway.zh.md: 9f062eaa6e6618354afa9ed1101e9cf6ed67d465 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index aa8339219a..52e91c4810 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -2,7 +2,7 @@ English | [中文](api-gateway.zh.md) -This is the current-state reference for the Typert API Gateway. It describes how business services declare unary Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. +This is the current-state reference for the Typert API Gateway. It describes how business services declare unary and stream Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. ## Programming model @@ -55,6 +55,8 @@ export class GoalService extends TypertRemoteService { 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`. +`@Remote({ mode: 'stream' })` marks a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream`: the Gateway delivers each yielded item, a value the Host method produced, over its multiplexed `/api/remote.mux` WebSocket or an in-process carrier, and the generated Client method returns a `RemoteStreamHandle` that iterates the items and exposes `send`, `end`, and `dispose` for the Client-to-Host uplink of the same logical stream. The second type argument of `RemoteStream` declares the uplink item type; the Gateway validates each item the Client sends, because it arrives from the browser, with the generated `In` codec before the Host method reads it through `this.ctx.invocation.uplink()`. The uplink enters neither `args` nor the parameter list; the [Gateway README](../packages/api/gateway/README.md) owns the frame, half-close, cancellation, and inbox-bound contracts. + 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()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. 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. `@RemoteScope` generates only the scoped invocation interface. ```ts ignore-check @@ -156,7 +158,7 @@ The running Client watcher consumes these generated files when it rebundles. If ## Boundaries -Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. +Remote handles unary method calls with one request and one result, and stream methods whose items flow Host → Client while the Client's items reach the running Host method through `this.ctx.invocation.uplink()`. Session event streams, pagination, incremental reduce, projection, and entity substreams still require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. A feature that needs a streamed or browser-native response registers an exact Connection Fetch route instead of defining a Remote method. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 99b2d9866d..9f062eaa6e 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -2,7 +2,7 @@ [English](api-gateway.md) | 中文 -本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 +本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元与流式 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 ## 编程模型 @@ -55,6 +55,8 @@ export class GoalService extends TypertRemoteService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 +`@Remote({ mode: 'stream' })` 标记返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法:Gateway 把每个产出的项(Host 方法产出的值)经其多路复用的 `/api/remote.mux` WebSocket 或进程内载体投递,Client 生成的方法返回一个 `RemoteStreamHandle`,它迭代这些项,并为同一条逻辑流上从 Client 到 Host 的上行提供 `send`、`end` 与 `dispose`。`RemoteStream` 的第二个类型参数声明上行项类型;Gateway 在 Host 方法通过 `this.ctx.invocation.uplink()` 读取之前,用生成的 `In` codec 校验 Client 发送的每一项——它们来自浏览器。上行既不进入 `args` 也不进入参数列表;帧、半关闭、取消与 inbox 上限的约定由 [Gateway README](../packages/api/gateway/README.zh.md) 拥有。 + Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子服务;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层运行时,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypertContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用接口。 ```ts ignore-check @@ -156,7 +158,7 @@ pnpm run build:lib ## 边界 -Remote 只处理有单个请求与单个结果的一元方法调用。会话事件流、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 +Remote 处理单请求单结果的一元方法调用,以及项沿 Host → Client 方向流动、而 Client 的项经 `this.ctx.invocation.uplink()` 到达正在运行的 Host 方法的流方法。会话事件流、分页、增量 reduce、projection 和实体子流仍需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。需要流式或浏览器原生响应的功能注册精确的 Connection Fetch 路由,而不定义 Remote 方法。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8e4853c16c..b9fe4f9f65 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 460d7a69837cdc43fe089b164b4d889b7e696650 -config-catalog.zh.md: 46b6a74511c89e806416770318a8244a94a45964 +config-catalog.md: 2927b510976631695978f46ed3c012641b4d73a9 +config-catalog.zh.md: 6d57dbe759fee5eaea9e755731fcf22666e8f0f3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 460d7a6983..2927b51097 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -194,10 +194,12 @@ Requires: `typert` export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number } ``` -Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts) @@ -428,7 +430,7 @@ export interface ConnectionRecoveryConfig { } ``` -Source: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 46b6a74511..6d57dbe759 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -196,10 +196,12 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.zh.md) export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number } ``` -来源:[`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts) @@ -430,7 +432,7 @@ export interface ConnectionRecoveryConfig { } ``` -来源: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts) +来源: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f4e736efa4..88b786a588 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: 0fa3e7ffaae73a714bcffc6b7f8cd69e24f5e0c4 -event-producer-consumer.zh.md: 9f14a99ad80959be9963821100a6228700eca2b8 +event-producer-consumer.md: 619103ab6f813fc9ea915cc4f6bf5b6ae585f8cb +event-producer-consumer.zh.md: 00a13ca2aa785dd253263f154627e6dd8accacc6 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0fa3e7ffaa..619103ab6f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:62`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:65`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 9f14a99ad8..00a13ca2aa 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,7 +32,7 @@ | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:62`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:65`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7c3f9e3842..dcaee90607 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: fdf66c998a1c108cbb32f273c1b40f6a1e4e08b9 -module-graph.zh.md: 980c7a342aff850a41d33c720a9bdec81a98c114 +module-graph.md: 684f82c87648bbd6fa8a1e7f45405a2c2d4d9885 +module-graph.zh.md: cf3bca4f97c840a052399bd3765e7cf11270c396 diff --git a/docs/module-graph.md b/docs/module-graph.md index fdf66c998a..684f82c876 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -414,16 +414,11 @@ flowchart TD pkg_credentials --> pkg_invariants pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_client_connection - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_lsp --> pkg_brand @@ -433,6 +428,7 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy + pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope pkg_system_prompt --> pkg_invariants @@ -451,6 +447,7 @@ flowchart TD pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_connection --> pkg_scope pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants @@ -468,9 +465,15 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session + pkg_api_gateway --> pkg_client_connection pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_app_boot --> pkg_system_prompt + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session @@ -1322,14 +1325,12 @@ flowchart TD | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | -| [`api-gateway`](../packages/api/gateway) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | -| [`client-connection`](../packages/client/connection) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | @@ -1401,7 +1402,6 @@ flowchart TD | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | -| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | @@ -1412,15 +1412,14 @@ flowchart TD | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1431,6 +1430,7 @@ flowchart TD | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | @@ -1440,7 +1440,10 @@ flowchart TD | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 980c7a342a..cf3bca4f97 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -416,16 +416,11 @@ flowchart TD pkg_credentials --> pkg_invariants pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_client_connection - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_lsp --> pkg_brand @@ -435,6 +430,7 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy + pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope pkg_system_prompt --> pkg_invariants @@ -453,6 +449,7 @@ flowchart TD pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_connection --> pkg_scope pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants @@ -470,9 +467,15 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session + pkg_api_gateway --> pkg_client_connection pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_app_boot --> pkg_system_prompt + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session @@ -1324,14 +1327,12 @@ flowchart TD | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | -| [`api-gateway`](../packages/api/gateway) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | -| [`client-connection`](../packages/client/connection) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | @@ -1403,7 +1404,6 @@ flowchart TD | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | -| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | @@ -1414,15 +1414,14 @@ flowchart TD | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1433,6 +1432,7 @@ flowchart TD | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | @@ -1442,7 +1442,10 @@ flowchart TD | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index fb6146eda0..2f43dc6d54 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/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/subsystems/typert.md -typert.md: 954c3dfb7e66b100132c6d4604c28f06fba04916 -typert.zh.md: dd2becdc43871ddbecdfb45909e076435ab5a447 +typert.md: a8192b319215957b8d373ec01f6f29d73e165e45 +typert.zh.md: 08d1e8f466ef1e516c04426165b8180bd9881c83 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 954c3dfb7e..a8192b3192 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -85,7 +85,7 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + /** Absent for unary calls; stream calls deliver every yielded item as the Host produced it. */ readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: @@ -105,6 +105,16 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** + * Client-to-Host items of the same logical stream, generated from the `In` + * type argument of the method's `RemoteStream` return type; absent + * when `In` is `never`. The method reads the items through + * `RemoteInvocation.uplink()`, so nothing enters the parameter list. + */ + readonly uplink?: { + /** Codec validating every uplink item before `uplink()` delivers it. */ + readonly codec: TypertCodec + } /** Transport cancellation injected after business parameters instead of entering wire args. */ readonly cancellation?: { /** Reserved final Host method parameter. */ @@ -117,6 +127,106 @@ interface InvocationDescriptor { } ``` +The Host alias `RemoteStream` names both directions of one stream, and the receiving method reads its call context as `this.ctx.invocation`: + +```ts type-equiv +/** + * One Remote stream as a Host method returns it: the items it yields to the + * Client, iterated as a plain `AsyncIterable`. `In` is the type of the + * items the Client may send back on the same logical stream, read through + * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The + * default `never` declares a method that reads none, and its descriptor + * carries no uplink codec. A generated Client stream method returns the same + * stream as a `RemoteStreamHandle`. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +``` + +```ts type-equiv +/** + * One open Remote stream as the Client holds it: the downlink items as an + * `AsyncIterable`, plus the uplink and cancellation of the same logical + * stream. A generated Client stream method returns it, and calling that + * method opens the stream: a holder that neither iterates nor disposes the + * handle keeps the Host stream alive. A handle stands for one generation: + * when the carrier is lost, iteration fails with the carrier error and the + * handle is finished. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +interface RemoteStreamHandle extends AsyncIterable { + /** + * Send one uplink item. Items sent before the stream has opened are queued + * and sent once the `open` frame is on the wire. A top-level `undefined` + * travels as an `item` frame without `value`. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} when the item is not a lossless JSON value, when `end()` + * was called, or once the stream has terminated. + */ + send(item: In): void + /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ + end(): void + /** + * Cancel the logical stream: send `cancel` unless a terminal frame has + * arrived, and end the downlink iterator quietly. Breaking out of + * `for await` early does the same. + */ + dispose(): void +} +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * scope that owns connection-lifetime registrations and is disposed with the + * Peer. Who the Peer is and what it may do are not recorded here. + */ +interface PeerScope { + readonly id: PeerId + readonly ctx: Context + /** + * Tear down every registration made through `ctx`. + * @returns settles once the scope has quiesced; racing calls share one completion. + */ + dispose(): Promise +} +``` + +```ts type-equiv +/** + * The context of one Remote call, reachable inside the receiving method as + * `this.ctx.invocation`. The Gateway derives the receiver from a Context that + * carries it, so no parameter is injected and nothing crosses the wire. + */ +interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving Service. */ + readonly service: string + /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ + readonly signal: AbortSignal + /** + * The Client's uplink items for this call. Available once; a second call + * throws. With an uplink codec on the descriptor every item is decoded to + * `In`; without one items arrive as `unknown` after a JSON-safety check. + * Iteration ends when the Client ends its uplink; when the method finishes + * its downlink the Gateway calls the iterator's `return()` and unread items + * are dropped. `In` is the caller's assertion: the runtime decodes by the + * descriptor and does not cross-check it. + * @template In - item type the caller reads; the descriptor codec decides what arrives. + * @returns the single-consumer uplink iterable. + */ + uplink(): AsyncIterable +} +``` + ## Typert registry `ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, and scoped Context providers. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. @@ -151,6 +261,13 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ + readonly uplink?: AsyncIterable + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -172,10 +289,12 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' | 'gateway/service-unavailable' | 'gateway/signature-invalid' + | 'gateway/uplink-overflow' ``` ```ts type-equiv @@ -202,7 +321,7 @@ interface TypertGateway { invoke(request: InvokeRemoteRequest): Promise /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> @@ -334,7 +453,7 @@ async invoke(request: InvokeRemoteRequest): Promise /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index dd2becdc43..08d1e8f466 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -85,7 +85,7 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + /** Absent for unary calls; stream calls deliver every yielded item as the Host produced it. */ readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: @@ -105,6 +105,16 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** + * Client-to-Host items of the same logical stream, generated from the `In` + * type argument of the method's `RemoteStream` return type; absent + * when `In` is `never`. The method reads the items through + * `RemoteInvocation.uplink()`, so nothing enters the parameter list. + */ + readonly uplink?: { + /** Codec validating every uplink item before `uplink()` delivers it. */ + readonly codec: TypertCodec + } /** Transport cancellation injected after business parameters instead of entering wire args. */ readonly cancellation?: { /** Reserved final Host method parameter. */ @@ -117,6 +127,106 @@ interface InvocationDescriptor { } ``` +Host 别名 `RemoteStream` 同时命名一条流的两个方向,接收方法通过 `this.ctx.invocation` 读取本次调用的上下文: + +```ts type-equiv +/** + * One Remote stream as a Host method returns it: the items it yields to the + * Client, iterated as a plain `AsyncIterable`. `In` is the type of the + * items the Client may send back on the same logical stream, read through + * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The + * default `never` declares a method that reads none, and its descriptor + * carries no uplink codec. A generated Client stream method returns the same + * stream as a `RemoteStreamHandle`. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +``` + +```ts type-equiv +/** + * One open Remote stream as the Client holds it: the downlink items as an + * `AsyncIterable`, plus the uplink and cancellation of the same logical + * stream. A generated Client stream method returns it, and calling that + * method opens the stream: a holder that neither iterates nor disposes the + * handle keeps the Host stream alive. A handle stands for one generation: + * when the carrier is lost, iteration fails with the carrier error and the + * handle is finished. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +interface RemoteStreamHandle extends AsyncIterable { + /** + * Send one uplink item. Items sent before the stream has opened are queued + * and sent once the `open` frame is on the wire. A top-level `undefined` + * travels as an `item` frame without `value`. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} when the item is not a lossless JSON value, when `end()` + * was called, or once the stream has terminated. + */ + send(item: In): void + /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ + end(): void + /** + * Cancel the logical stream: send `cancel` unless a terminal frame has + * arrived, and end the downlink iterator quietly. Breaking out of + * `for await` early does the same. + */ + dispose(): void +} +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * scope that owns connection-lifetime registrations and is disposed with the + * Peer. Who the Peer is and what it may do are not recorded here. + */ +interface PeerScope { + readonly id: PeerId + readonly ctx: Context + /** + * Tear down every registration made through `ctx`. + * @returns settles once the scope has quiesced; racing calls share one completion. + */ + dispose(): Promise +} +``` + +```ts type-equiv +/** + * The context of one Remote call, reachable inside the receiving method as + * `this.ctx.invocation`. The Gateway derives the receiver from a Context that + * carries it, so no parameter is injected and nothing crosses the wire. + */ +interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving Service. */ + readonly service: string + /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ + readonly signal: AbortSignal + /** + * The Client's uplink items for this call. Available once; a second call + * throws. With an uplink codec on the descriptor every item is decoded to + * `In`; without one items arrive as `unknown` after a JSON-safety check. + * Iteration ends when the Client ends its uplink; when the method finishes + * its downlink the Gateway calls the iterator's `return()` and unread items + * are dropped. `In` is the caller's assertion: the runtime decodes by the + * descriptor and does not cross-check it. + * @template In - item type the caller reads; the descriptor codec decides what arrives. + * @returns the single-consumer uplink iterable. + */ + uplink(): AsyncIterable +} +``` + ## Typert 注册表 `ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与作用域上下文提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 @@ -151,6 +261,13 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ + readonly uplink?: AsyncIterable + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -172,10 +289,12 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' | 'gateway/service-unavailable' | 'gateway/signature-invalid' + | 'gateway/uplink-overflow' ``` ```ts type-equiv @@ -202,7 +321,7 @@ interface TypertGateway { invoke(request: InvokeRemoteRequest): Promise /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> @@ -334,7 +453,7 @@ async invoke(request: InvokeRemoteRequest): Promise /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 558b700088..0024bc7600 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.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/subsystems/web-server.md -web-server.md: e74d51da21682c0271c42b0a4584558e41b52b26 -web-server.zh.md: da89262e7055dbf4d6ee044def4cb5a08ce4dfe4 +web-server.md: 7b4d3a17a66ce4c3736b6d5d3eadf887622057c0 +web-server.zh.md: 5a95bbaff4aae20c6df05529f4154e42ccefaa3d diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index e74d51da21..7b4d3a17a6 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -64,7 +64,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.connection` — `HostConnectionHandle` -Host `ctx.connection` shape consumed by transport-independent adapters. +Host `ctx.connection` members consumed by transport-independent adapters. ```ts cordis-catalog /** @@ -82,6 +82,14 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection +/** + * Admit one request: it passes {@link requestRejection} and speaks for the + * operator, or it is refused with that status. + * @param request - request headers from the HTTP or upgrade request. + * @returns the operator Peer, or the rejection status. + */ +admit(request: ConnectionTrustRequest): PeerAdmission + /** * Authenticate one frontend index request, owning a token redirect or 401. * @param request - root or configured-index HTTP request. diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index da89262e70..5a95bbaff4 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -64,7 +64,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.connection` — `HostConnectionHandle` -Host `ctx.connection` shape consumed by transport-independent adapters. +Host `ctx.connection` members consumed by transport-independent adapters. ```ts cordis-catalog /** @@ -82,6 +82,14 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection +/** + * Admit one request: it passes {@link requestRejection} and speaks for the + * operator, or it is refused with that status. + * @param request - request headers from the HTTP or upgrade request. + * @returns the operator Peer, or the rejection status. + */ +admit(request: ConnectionTrustRequest): PeerAdmission + /** * Authenticate one frontend index request, owning a token redirect or 401. * @param request - root or configured-index HTTP request. diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 86d8d25086..0de9905f67 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: f1aefa7d4564539f94202e869bacb85857f939d5 -README.zh.md: 870bbd32552166fdbb06221baaf54b9a130c6069 +README.md: 79271f6f5f7a05ef4f6d5da63815dfe1ee5da3ef +README.zh.md: d98a0ef73a1b14a5eea98084cfb4ef224a7747a9 diff --git a/packages/api/README.md b/packages/api/README.md index f1aefa7d45..79271f6f5f 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, and receive the results or forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it. +The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, receive results or streams, send uplink items on an open stream, and observe forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it. ## Table of Contents @@ -27,14 +27,14 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | Package | Role | ctx key | |---|---|---| | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | -| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with their Client uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | | [`terminal-controller/`](terminal-controller/README.md) | Session-owned interactive shells, screen recovery and browser terminal control. | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | -Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. +Remote calls run Client → Host over the application's shared Connection; stream items flow Host → Client on the Gateway mux, and a stream's uplink items flow Client → Host on the same logical stream. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. ----- diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 870bbd3255..d98a0ef73a 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,并接收结果或转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 +`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,可接收结果或流、在已打开的流上发送上行项,并观察转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 ## 目录 @@ -27,14 +27,14 @@ kind: "package-group" | 包 | 职责 | ctx key | |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | -| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、多路复用流与转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、带 Client 上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | | [`terminal-controller/`](terminal-controller/README.zh.md) | Session 拥有的交互式 shell、屏幕恢复和浏览器终端控制。 | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供方。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | -Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 +Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上;流项沿 Host → Client 方向经 Gateway mux 传输,流的上行项则沿 Client → Host 方向走同一条逻辑流。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 ----- diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 366e9297fe..5f05ce5e1e 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: 1460f5a27062ffcac09c68358dc7b186241a1baa -README.zh.md: 79ed379faad8498cfdf249a0accb437f6d5f98e3 +README.md: 996e68fd2b99989523b01272c693d4039fdc9f44 +README.zh.md: 23b83fbf2c7559434dfd47d6b06994a8d390d1ac diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 1460f5a270..996e68fd2b 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -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. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams. +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. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams, each carrying a Client-to-Host uplink on the same logical stream. ## Table of Contents @@ -28,12 +28,14 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. -The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. +The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `streamInboxBytes`, `gateway/protocol` for an uplink item after `end`, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. 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. A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. + Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. @@ -43,7 +45,7 @@ The browser carrier accepts the shell-owned stream origin described by [Connecti `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 Client-supplied fields without strict generated codecs fail before methods become callable. -Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. +Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a `RemoteStreamHandle` from `dsh-typert-protocol` and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. `send(item)` queues an uplink item, sent after the `open` frame; `end()` half-closes the uplink; `dispose()` sends `cancel` unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates `send` throws and `end` is ignored. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically. @@ -79,6 +81,7 @@ No direct effect; invoked business Services own any model-visible result. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. - `websocketHeartbeatIntervalMs` is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it. +- Uplinks have no flow control beyond the bounded Host inbox: a Client that sends faster than the method reads, or that sends to a method never taking its uplink, fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 79ed379faa..23b83fbf2c 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流。 +为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流,每条流都在同一条逻辑流上携带从 Client 到 Host 的上行(uplink)。 ## 目录 @@ -28,12 +28,14 @@ kind: "package-reference" 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及上行各自负责的故障:缓冲帧超过 `streamInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 + Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 @@ -43,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。 -每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `dsh-typert-protocol` 的 `RemoteStreamHandle`,并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。 @@ -79,6 +81,7 @@ Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 - lookup 解析器按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 - `websocketHeartbeatIntervalMs` 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 +- 上行除了有界的 Host inbox 之外没有流控:Client 发送快于方法读取,或发给从未取用 uplink 的方法时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 4680b06f39..e2359db6be 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -58,7 +58,8 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 01784c9d68..43992cb60e 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,7 +5,7 @@ */ import { Service } from '@deepseek-ai/cordis' -import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, isRemoteUplinkItem, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts' import type { Context } from '@deepseek-ai/cordis' import type { @@ -17,12 +17,14 @@ import type { TypertClientRemote, RemoteFailure, RemoteResult, + RemoteStreamHandle, TypertCodec, TypertDisposer, TypertRemoteContribution, TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' import { + ClientUplinkQueue, RemoteStreamCarrierError, RemoteStreamMuxClient, } from './stream-client.ts' @@ -219,13 +221,14 @@ class ClientRemoteService extends Service implements ClientRemote { endpoint: string, payload: unknown, signal: AbortSignal, + uplink?: AsyncIterable, noConnection = `client api: ${endpoint} has no active Connection`, ): AsyncIterable { const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(noConnection) - const local = connection.rpc.open?.('/api', endpoint, payload, signal) + const local = connection.rpc.open?.('/api', endpoint, payload, signal, uplink) return local === undefined - ? this.streams.open(endpoint, payload, signal) + ? this.streams.open(endpoint, payload, signal, uplink) : normalizeConnectionStream(local) } @@ -387,7 +390,7 @@ class ClientRemoteService extends Service implements ClientRemote { scoped: ScopedMethod | undefined, callerCtx: Context, values: readonly unknown[], - ): Promise> | AsyncIterable { + ): Promise> | RemoteStreamHandle { if (scoped !== undefined) { const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) const identity = adapter?.identity(callerCtx) @@ -418,8 +421,8 @@ class ClientRemoteService extends Service implements ClientRemote { callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, - ): Promise> | AsyncIterable { - if (descriptor.mode === 'stream') { + ): Promise> | RemoteStreamHandle { + if (descriptor.mode !== undefined) { return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity) } return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity) @@ -453,22 +456,27 @@ class ClientRemoteService extends Service implements ClientRemote { } } - private async *invokeStream( + /** Open the logical stream now and hand back its handle; `send()` before the first read queues behind the `open` frame. */ + private invokeStream( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, token: MountToken, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, - ): AsyncGenerator { + ): RemoteStreamHandle { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(withdrawn(endpoint).error.message) const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) - const stream = this.openRemoteStream(endpoint, { args: prepared.args }, prepared.signal) - for await (const value of stream) { - if (!mountActive(token)) throw new Error(withdrawn(endpoint).error.message) - yield value - } + const generation = new AbortController() + const uplink = new ClientUplinkQueue(endpoint) + const downlink = this.openRemoteStream( + endpoint, + { args: prepared.args }, + AbortSignal.any([prepared.signal, generation.signal]), + uplink, + ) + return new ClientStreamHandle(endpoint, downlink, uplink, generation, token) } private prepareInvocation( @@ -521,12 +529,94 @@ class ClientRemoteService extends Service implements ClientRemote { } } +/** + * The handle a generated stream method returns: one generation of one logical + * stream. The downlink is iterated once; `send`/`end` feed the uplink queue the + * carrier pump drains; `dispose` aborts the generation and returns the carrier + * iterator, which sends `cancel` unless a terminal frame arrived, drops what was + * buffered, and ends the iteration quietly. + */ +class ClientStreamHandle implements RemoteStreamHandle { + private readonly downlink: AsyncIterator + private primed: Promise> | undefined + private consumed = false + private disposed = false + + constructor( + private readonly endpoint: string, + downlink: AsyncIterable, + private readonly uplink: ClientUplinkQueue, + private readonly generation: AbortController, + private readonly token: MountToken, + ) { + this.downlink = downlink[Symbol.asyncIterator]() + // The carrier opens the logical stream on the first pull; pulling now puts + // the `open` frame on the wire before any `send()`. The first read is kept + // for the consumer, and a failure waits for it instead of surfacing here; + // it also terminates the stream, so the queue closes and `send()` throws. + this.primed = this.downlink.next() + void this.primed.catch(() => { this.uplink.close() }) + } + + send(item: unknown): void { + if (!isRemoteUplinkItem(item)) throw new Error(`client api: ${this.endpoint} uplink item is not a lossless JSON value`) + this.uplink.push(item) + } + + end(): void { + this.uplink.end() + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.uplink.close() + this.generation.abort(new Error(`client api: ${this.endpoint} stream disposed`)) + // Returning the carrier iterator reaches its `finally` even while nobody + // reads the downlink, so `cancel` goes out now and the pump stops. + void Promise.resolve(this.downlink.return?.()).catch(() => undefined) + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.consumed) throw new Error(`client api: ${this.endpoint} stream has one consumer`) + this.consumed = true + const iteration = this.iterate() + return { + next: () => iteration.next(), + // A return before the first read ends a generator that never started, so its `finally` would not dispose. + return: (value?: unknown) => { + this.dispose() + return iteration.return(value) + }, + } + } + + private async *iterate(): AsyncGenerator { + try { + while (true) { + const next = await (this.primed ?? this.downlink.next()) + this.primed = undefined + if (this.disposed || next.done === true) return + if (!mountActive(this.token)) throw new Error(withdrawn(this.endpoint).error.message) + yield next.value + } + } catch (error) { + if (this.disposed) return + throw error + } finally { + // Ending early is a dispose; a terminated stream accepts no more uplink. + this.dispose() + await this.downlink.return?.() + } + } +} + type InvokeRemote = ( direct: DirectMethod | undefined, scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], -) => Promise> | AsyncIterable +) => Promise> | RemoteStreamHandle class RemoteNamespaceService extends Service { private readonly methods = new Map() @@ -698,6 +788,7 @@ function requireStrictInputs(descriptor: InvocationDescriptor): void { for (const parameter of descriptor.parameters) { requireStrictCodec(parameter.codec, endpoint, parameter.wire) } + if (descriptor.uplink !== undefined) requireStrictCodec(descriptor.uplink.codec, endpoint, 'uplink') if (descriptor.invocation.kind === 'context') { requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire) } diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts index d799648b98..eabda54e37 100644 --- a/packages/api/gateway/src/client/remote-events.ts +++ b/packages/api/gateway/src/client/remote-events.ts @@ -11,7 +11,7 @@ import type { TypertOwnedValue, TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' -import { isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol' +import { isRemoteJsonValue, isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { REMOTE_EVENT_RESULT_ENDPOINT, @@ -20,7 +20,6 @@ import { isRemoteEventAgentId, isRemoteEventClientId, isRemoteEventId, - isRemoteJsonValue, projectRemoteEventRejection, type RemoteEventClientId, type RemoteEventDownlinkFrame, diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 13e107ade8..7a580c5fac 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -28,6 +28,21 @@ interface SocketWaiter { reject(error: unknown): void } +interface UplinkPump { + /** Settles once the pump has stopped sending. */ + readonly done: Promise + /** Interrupt the pump, including a read blocked on the caller's iterator, and release the iterator. */ + stop(): void +} + +/** One open logical stream: its downlink frames and, once the `open` frame is out, its uplink pump. */ +interface LogicalStream { + readonly inbox: StreamInbox + pump: UplinkPump | undefined +} + +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } + /** * Keep one physical WebSocket and share it among independently cancellable * Remote streams. A carrier that supplies an in-process stream opener never @@ -38,7 +53,7 @@ export class RemoteStreamMuxClient { private cancelCandidate: ((error: Error) => void) | undefined private keepAlive: Promise | undefined private revision = 0 - private readonly streams = new Map() + private readonly streams = new Map() private readonly waiters = new Set() private running = false private disposed = false @@ -77,16 +92,20 @@ export class RemoteStreamMuxClient { * @param endpoint - Typert Remote stream endpoint. * @param payload - endpoint request encoded on the wire. * @param signal - cancellation for this logical stream. + * @param uplink - the Client's items: each is sent as an `item` frame, its end as `end`; its `return()` + * runs when the stream finishes, and its failure cancels the stream and fails the downlink. * @returns Host items until completion, cancellation, or failure. */ async *open( endpoint: string, payload: unknown, signal: AbortSignal, + uplink?: AsyncIterable, ): AsyncGenerator { signal.throwIfAborted() const streamId = randomUUID() const inbox = new StreamInbox() + const stream: LogicalStream = { inbox, pump: undefined } let carrier: WebSocket | undefined let opened = false let terminal = false @@ -96,9 +115,10 @@ export class RemoteStreamMuxClient { const socket = await this.waitForSocket(signal) signal.throwIfAborted() carrier = socket - this.streams.set(streamId, inbox) + this.streams.set(streamId, stream) this.send(socket, { type: 'open', streamId, endpoint, payload }) opened = true + if (uplink !== undefined) stream.pump = this.pumpUplink(socket, streamId, uplink, signal, inbox) while (true) { const frame = await inbox.next() signal.throwIfAborted() @@ -115,9 +135,65 @@ export class RemoteStreamMuxClient { } finally { signal.removeEventListener('abort', abort) this.streams.delete(streamId) + stream.pump?.stop() if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { this.send(carrier, { type: 'cancel', streamId }) } + // The old generation's pump has stopped before a supervisor reopens the next one. + if (stream.pump !== undefined) await stream.pump.done + } + } + + /** + * Send the caller's uplink items on this generation's socket. `stop()` + * interrupts a pump blocked on `uplink.next()` and releases the iterator: a + * handle's queue closes at once, so `send()` throws from then on, and any + * other iterator's `return()` is invoked without being awaited because a + * generator blocked in `next()` only completes it once it yields. + */ + private pumpUplink( + socket: WebSocket, + streamId: string, + uplink: AsyncIterable, + signal: AbortSignal, + inbox: StreamInbox, + ): UplinkPump { + const stopped = Promise.withResolvers>() + const interruption: IteratorReturnResult = { value: undefined, done: true } + const uplinkIterator = uplink[Symbol.asyncIterator]() + const state = { stopping: false, exhausted: false, released: false } + const release = (): void => { + if (state.released || state.exhausted) return + state.released = true + if (uplink instanceof ClientUplinkQueue) uplink.close() + void Promise.resolve().then(() => uplinkIterator.return?.()).catch(() => undefined) + } + const done = (async (): Promise => { + try { + while (true) { + const next = await Promise.race([uplinkIterator.next(), stopped.promise]) + if (state.stopping || next === interruption || signal.aborted || this.socket !== socket) return + if (next.done === true) { + state.exhausted = true + break + } + this.send(socket, { type: 'item', streamId, value: next.value }) + } + this.send(socket, { type: 'end', streamId }) + } catch (error) { + inbox.fail(error) + } finally { + release() + } + })() + return { + done, + stop: () => { + if (state.stopping) return + state.stopping = true + stopped.resolve(interruption) + release() + }, } } @@ -226,7 +302,11 @@ export class RemoteStreamMuxClient { try { if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages') const frame = parseRemoteStreamServerMessage(data) - this.streams.get(frame.streamId)?.push(frame) + const stream = this.streams.get(frame.streamId) + if (stream === undefined) return + stream.inbox.push(frame) + // A terminal frame ends the uplink now, not on the consumer's next read. + if (frame.type !== 'item') stream.pump?.stop() } catch (error) { const failure = new RemoteStreamCarrierError('api gateway: invalid Remote stream frame', { cause: error }) this.failAll(failure) @@ -266,7 +346,10 @@ export class RemoteStreamMuxClient { } private failAll(error: unknown): void { - for (const stream of this.streams.values()) stream.fail(error) + for (const stream of this.streams.values()) { + stream.inbox.fail(error) + stream.pump?.stop() + } } private send(socket: WebSocket, message: RemoteStreamClientMessage): void { @@ -303,6 +386,83 @@ class StreamInbox { } } +/** + * Uplink items a stream handle queues for its carrier: the mux pump or the + * in-process Host decoder iterates it as the stream's uplink. `end()` is the + * Client half-close; `close()` marks the stream terminated, after which + * `push()` throws. One consumer reads it, one read at a time. + */ +export class ClientUplinkQueue implements AsyncIterable, AsyncIterator { + private readonly items = new Deque() + private ended = false + private closed = false + private wake: (() => void) | undefined + + /** @param endpoint - canonical Remote endpoint named by failures. */ + constructor(private readonly endpoint: string) {} + + /** + * Queue one item for the carrier. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} after `end()` or once the stream has terminated. + */ + push(item: unknown): void { + if (this.closed) throw new Error(`client api: ${this.endpoint} stream has terminated`) + if (this.ended) throw new Error(`client api: ${this.endpoint} uplink was ended`) + this.items.pushBack(item) + this.signal() + } + + /** Half-close: the carrier reads the queued items, then `end`. Idempotent; ignored after termination. */ + end(): void { + if (this.ended || this.closed) return + this.ended = true + this.signal() + } + + /** The carrier stopped reading: the logical stream terminated or was disposed. Idempotent. */ + close(): void { + if (this.closed) return + this.closed = true + this.items.clear() + this.signal() + } + + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + /** + * Take the next queued item, waiting for one; ends after `end()` or `close()`. + * @returns the next item, or the end of the uplink. + * @throws {Error} when a read is already pending. + */ + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE + if (this.items.size > 0) return { value: this.items.popFront(), done: false } + if (this.ended) return UPLINK_DONE + if (this.wake !== undefined) throw new Error(`client api: ${this.endpoint} uplink has one pending read`) + await new Promise((resolve) => { this.wake = resolve }) + } + } + + /** + * The carrier is done with the uplink: close it. + * @returns the end of the uplink. + */ + return(): Promise> { + this.close() + return Promise.resolve(UPLINK_DONE) + } + + private signal(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } +} + function remoteStreamUrl(): string { // The mux route is registered absolute; a page resolves its document-relative // form against its own document base. A shell-owned Host on another origin diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 1fe2438876..02b4c7fbbb 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' -import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import { OperatorPeer, type ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -15,10 +15,13 @@ import z from '@deepseek-ai/schemastery' export type { TypertGatewayFaultDetails } from './remote-error-codes.ts' import { RemoteError, + isRemoteJsonValue, remoteErrorOf, remoteMethods, type InvocationDescriptor, type InvocationParameterDescriptor, + type PeerScope, + type RemoteInvocation, type TypertCodec, type TypertGatewayBinding, } from '@deepseek-ai/dsh-typert-protocol' @@ -43,7 +46,6 @@ import { REMOTE_EVENT_RESULT_ENDPOINT, REMOTE_STREAM_MUX_PATH, isRemoteEventAgentId, - isRemoteJsonValue, parseRemoteEventResult, projectRemoteEventRequest, restoreRemoteEventRejection, @@ -84,9 +86,22 @@ interface ResolvedBinding { interface PreparedInvocation { readonly endpoint: string readonly descriptor: InvocationDescriptor + /** Service view bound to a Context carrying `invocation`, so the method reads it as `this.ctx.invocation`. */ readonly receiver: object readonly args: readonly unknown[] readonly method: (...args: never[]) => unknown + readonly invocation: GatewayInvocation +} + +/** Carrier inputs `GatewayInvocation.uplink()` decodes on first use. */ +interface UplinkSource { + /** Carrier items; an immediately ended iterable when the carrier has none. */ + readonly source: AsyncIterable + /** Descriptor codec, or the JSON-safety codec when the descriptor declares no uplink. */ + readonly codec: TypertCodec + readonly endpoint: string + /** Fail the logical stream with a Remote failure as the reason. */ + readonly abort: (reason: unknown) => void } interface RegisteredRemoteEventSource { @@ -114,15 +129,24 @@ type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2_000 +const DEFAULT_STREAM_INBOX_BYTES = 262_144 +const EMPTY_ASYNC_ITERABLE: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }), +} +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } +const SRC_JSON_CODEC: TypertCodec = { mode: 'src-json' } /** Gateway transport configuration. */ export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number } interface ResolvedConfig extends Config { readonly websocketHeartbeatIntervalMs: number + readonly streamInboxBytes: number } /** @@ -171,15 +195,18 @@ export class TypertGatewayService extends Service implements TypertGateway { static Config: z = z.object({ websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), + streamInboxBytes: z.number().step(1).min(1).default(DEFAULT_STREAM_INBOX_BYTES), }) /** Carrier adapter shared by the WebSocket mux and local Host transports. */ readonly wireStream: TypertGatewayWireStream = { - open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + open: (endpoint, payload, uplink, peer, signal) => + this.openWireStream(endpoint, payload, uplink, peer, signal, new AbortController()), failure: error => rpcError(error), } private srcClaims: ReadonlySet | undefined + private inProcessOperator: PeerScope | undefined private remoteEvents: RegisteredRemoteEventSource | undefined private readonly remoteEventClients = new Map() private readonly pendingRemoteEvents = new Map() @@ -199,25 +226,27 @@ export class TypertGatewayService extends Service implements TypertGateway { connectionCtx.connection.rpc.intercept( '/api', endpoint => this.claimsEndpoint(endpoint), - (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), + (endpoint, payload, signal, peer) => this.dispatchRpc(endpoint, payload, signal, peer), ) }) ctx.inject(['connection', 'webServer'], (webCtx) => { const mux = new RemoteStreamMuxServer( - (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + (endpoint, payload, uplink, peer, control) => + this.openWireStream(endpoint, payload, uplink, peer, control.signal, control), this.wireStream.failure, resolved.websocketHeartbeatIntervalMs, + resolved.streamInboxBytes, ) webCtx.effect(() => { const route: WebUpgradeRoute = { path: REMOTE_STREAM_MUX_PATH, handler: (req, socket, head) => { - const rejection = webCtx.connection.requestRejection(req) - if (rejection !== undefined) { - rejectRemoteStreamUpgrade(socket, rejection) + const admission = webCtx.connection.admit(req) + if ('rejection' in admission) { + rejectRemoteStreamUpgrade(socket, admission.rejection) return } - mux.handleUpgrade(req, socket, head) + mux.handleUpgrade(req, socket, head, admission.peer) }, } const unregister = webCtx.webServer.registerUpgrade(route) @@ -296,8 +325,8 @@ export class TypertGatewayService extends Service implements TypertGateway { * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { - const prepared = await this.prepareInvocation(request) - if (prepared.descriptor.mode === 'stream') { + const prepared = await this.prepareInvocation(request, new AbortController()) + if (prepared.descriptor.mode !== undefined) { throw new TypertGatewayError( 'gateway/signature-invalid', prepared.endpoint, @@ -310,17 +339,29 @@ export class TypertGatewayService extends Service implements TypertGateway { } catch (error) { if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) throw error + } finally { + // A unary call's uplink is readable only while the method runs. + await prepared.invocation.close() } } /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> { - const prepared = await this.prepareInvocation(request) - if (prepared.descriptor.mode !== 'stream') { + return this.openStream(request, new AbortController()) + } + + /** + * `control` belongs to the logical stream: a rejected uplink item aborts it + * with the Remote failure as the reason so the carrier delivers that failure. + */ + private async openStream(request: InvokeRemoteRequest, control: AbortController): Promise> { + const prepared = await this.prepareInvocation(request, control) + if (prepared.descriptor.mode === undefined) { + await prepared.invocation.close() throw new TypertGatewayError( 'gateway/signature-invalid', prepared.endpoint, @@ -331,10 +372,12 @@ export class TypertGatewayService extends Service implements TypertGateway { try { source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { - if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) + await prepared.invocation.close() + if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error) throw error } if (!isIterable(source)) { + await prepared.invocation.close() throw new TypertGatewayError( 'gateway/result-invalid', prepared.endpoint, @@ -342,17 +385,14 @@ export class TypertGatewayService extends Service implements TypertGateway { { field: 'result' }, ) } - return cancellableStream( - source, - prepared.endpoint, - request.signal ?? NEVER_ABORTED_SIGNAL, - ) + return cancellableStream(source, prepared.endpoint, prepared.invocation) } private async dispatchRpc( endpoint: string, payload: unknown, signal: AbortSignal, + peer: PeerScope, ): Promise { if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { try { @@ -367,18 +407,36 @@ export class TypertGatewayService extends Service implements TypertGateway { return rpcFailure(error) } } - return this.invokeRpc(endpoint, payload, signal) + return this.invokeRpc(endpoint, payload, signal, peer) } private async openWireStream( endpoint: string, payload: unknown, + uplink: AsyncIterable, + peer: PeerScope | undefined, signal: AbortSignal, + control: AbortController, ): Promise> { if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { + // A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox. + releaseUplink(uplink) return this.openRemoteEvents(payload, signal) } - return this.stream(remoteRequest(endpoint, payload, signal)) + return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink }, control) + } + + /** + * The Peer an in-process carrier speaks for when it names none: the + * operator's Peer when Connection is mounted, otherwise an operator scope the + * Gateway owns for its own lifetime. + * @returns the operator Peer. + */ + private operatorPeer(): PeerScope { + const connection = this.ctx.get('connection') + if (connection !== undefined) return connection.operator + this.inProcessOperator ??= new OperatorPeer(this.ctx) + return this.inProcessOperator } private async *openRemoteEvents( @@ -582,9 +640,14 @@ export class TypertGatewayService extends Service implements TypertGateway { for (const client of [...this.remoteEventClients.values()]) client.queue.end() } - private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { + private async invokeRpc( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer: PeerScope, + ): Promise { try { - const value = await this.invoke(remoteRequest(endpoint, payload, signal)) + const value = await this.invoke(remoteRequest(endpoint, payload, signal, peer)) // A void or explicitly absent business result carries no `value` field; // JSON has no `undefined`, and the envelope's optional slot is the one // representation of absence that both args and results already use. @@ -594,7 +657,11 @@ export class TypertGatewayService extends Service implements TypertGateway { } } - private async prepareInvocation(request: InvokeRemoteRequest): Promise { + /** `control` fails the logical stream when an uplink item is rejected; unary calls hand over an inert one. */ + private async prepareInvocation( + request: InvokeRemoteRequest, + control: AbortController, + ): Promise { const endpoint = endpointOf(request.namespace, request.method) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) @@ -610,9 +677,27 @@ export class TypertGatewayService extends Service implements TypertGateway { validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))) - if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL) + const signal = methodSignal(request, control) + const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + { + source: request.uplink ?? EMPTY_ASYNC_ITERABLE, + codec: descriptor.uplink?.codec ?? SRC_JSON_CODEC, + endpoint, + abort: (reason) => { control.abort(reason) }, + }, + ) + if (descriptor.cancellation !== undefined) args.push(signal) + // The method runs on a Service view bound to a Context carrying this call: + // Cordis rebinds `this.ctx` to the accessing Context, so `this.ctx.invocation` + // is this call and nothing travels through the parameter list. The view + // resolves the Service the plain read above already found. + const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object const implementation = descriptor.implementation ?? descriptor.method - const method: unknown = Reflect.get(receiver, implementation) + const method: unknown = Reflect.get(callReceiver, implementation) if (typeof method !== 'function') { throw new TypertGatewayError( 'gateway/method-unavailable', @@ -620,7 +705,14 @@ export class TypertGatewayService extends Service implements TypertGateway { `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, ) } - return { endpoint, descriptor, receiver, args, method: method as (...args: never[]) => unknown } + return { + endpoint, + descriptor, + receiver: callReceiver, + args, + method: method as (...args: never[]) => unknown, + invocation, + } } private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { @@ -933,7 +1025,12 @@ function parseRemoteEventResultPayload(payload: unknown): ReturnType | AsyncIterable { @@ -959,8 +1069,9 @@ function isIterable(value: unknown): value is Iterable | AsyncIterable< async function *cancellableStream( source: Iterable | AsyncIterable, endpoint: string, - signal: AbortSignal, + invocation: GatewayInvocation, ): AsyncGenerator { + const { signal } = invocation const asyncFactory: unknown = Reflect.get(source, Symbol.asyncIterator) const syncFactory: unknown = Reflect.get(source, Symbol.iterator) const iterator = typeof asyncFactory === 'function' @@ -969,11 +1080,11 @@ async function *cancellableStream( let rejectAbort: ((error: unknown) => void) | undefined const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) const onAbort = (): void => { - rejectAbort?.(remoteCancelled(endpoint, signal.reason)) + rejectAbort?.(streamAbortFailure(endpoint, signal.reason)) } signal.addEventListener('abort', onAbort, { once: true }) try { - if (signal.aborted) throw remoteCancelled(endpoint, signal.reason) + if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason) while (true) { const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) if (next.done === true) return @@ -981,15 +1092,163 @@ async function *cancellableStream( } } finally { signal.removeEventListener('abort', onAbort) + // The uplink closes first so a method blocked on `uplink.next()` unwinds + // before its iterator is asked to return. + await invocation.close() await iterator.return?.() } } +/** + * The failure a stream surfaces for its abort: a Remote failure used as the + * reason is the Gateway or carrier failing the stream itself (a rejected, + * overflowing, or misplaced uplink item); any other abort is a cancellation. + */ +function streamAbortFailure(endpoint: string, reason: unknown): unknown { + return remoteErrorOf(reason) === undefined ? remoteCancelled(endpoint, reason) : reason +} + /** Carrier-signal cancellation as the shared failure vocabulary expresses it. */ function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway/cancelled'> { return new RemoteError('gateway/cancelled', `Remote invocation "${endpoint}" was aborted`, {}, { cause }) } +/** + * The iterable `invocation.uplink()` returns. Each uplink item passes the + * descriptor codec, or the JSON-safety check when the descriptor declares no + * uplink, before delivery; a rejected item fails the whole logical stream. + * Iteration ends when the Client half-closes or the downlink finishes, and + * fails when the stream is cancelled, so a method blocked on the uplink always + * wakes. + */ +class UplinkDecoder implements AsyncIterable, AsyncIterator { + private readonly source: AsyncIterator + private readonly interrupted = Promise.withResolvers>() + private readonly onAbort = (): void => { + this.interrupted.reject(streamAbortFailure(this.endpoint, this.signal.reason)) + } + private closed = false + + constructor( + uplink: AsyncIterable, + private readonly codec: TypertCodec, + private readonly endpoint: string, + private readonly signal: AbortSignal, + private readonly abort: (reason: unknown) => void, + ) { + this.source = uplink[Symbol.asyncIterator]() + // The rejection settles the race inside next(); an abort with no read in + // flight must not surface as an unhandled rejection. + void this.interrupted.promise.catch(() => undefined) + signal.addEventListener('abort', this.onAbort, { once: true }) + } + + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + async next(): Promise> { + // A cancelled stream reports the cancellation on every read, closed or not. + if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason) + if (this.closed) return UPLINK_DONE + const next = await Promise.race([this.source.next(), this.interrupted.promise]) + if (next.done === true) { + this.finish() + return UPLINK_DONE + } + let value: unknown + try { + // A top-level `undefined` is the absent `value` of its frame: without a codec it is the item itself. + value = next.value === undefined && this.codec.mode === 'src-json' + ? undefined + : decode(this.codec, next.value, this.endpoint, 'uplink') + } catch (failure) { + // The codec failure (`gateway/input-invalid`, field `uplink`) fails the whole logical stream. + this.abort(failure) + throw failure + } + return { value, done: false } + } + + /** Downlink finished or the method stopped reading: unread uplink items are dropped. */ + return(): Promise> { + if (!this.closed) { + this.finish() + // The carrier owns the source iterator; a generator blocked in next() + // completes this return once it yields, so it is not awaited here. + void Promise.resolve().then(() => this.source.return?.()).catch(() => undefined) + } + return Promise.resolve(UPLINK_DONE) + } + + private finish(): void { + this.closed = true + this.signal.removeEventListener('abort', this.onAbort) + this.interrupted.resolve(UPLINK_DONE) + } +} + +/** + * The context of one Remote call as the receiving method reads it through + * `this.ctx.invocation`. The uplink is decoded on first use and released when + * the call's downlink finishes. + */ +class GatewayInvocation implements RemoteInvocation { + private decoder: UplinkDecoder | undefined + private taken = false + + /** + * @param request - decoded endpoint and wire arguments. + * @param service - Cordis service key of the receiver. + * @param peer - Peer the call speaks for. + * @param signal - the signal the method observes. + * @param uplink - carrier items and the codec that decodes them. + */ + constructor( + readonly request: RemoteInvocation['request'], + readonly service: string, + readonly peer: PeerScope, + readonly signal: AbortSignal, + private readonly uplink_: UplinkSource, + ) {} + + uplink(): AsyncIterable { + if (this.taken) { + throw new Error(`typert gateway: ${this.uplink_.endpoint}: invocation.uplink() is available once per call`) + } + this.taken = true + const { source, codec, endpoint, abort } = this.uplink_ + this.decoder = new UplinkDecoder(source, codec, endpoint, this.signal, abort) + // The descriptor codec decides what arrives; `In` is the caller's assertion. + return this.decoder as AsyncIterable + } + + /** + * The downlink finished: release the uplink. Unread items are dropped, and a + * carrier iterable the method never took is returned so it stops producing; + * a later `uplink()` throws like a second one would. + * @returns settles once a taken uplink has closed. + */ + async close(): Promise { + if (this.decoder !== undefined) { + await this.decoder.return() + return + } + this.taken = true + releaseUplink(this.uplink_.source) + } +} + +/** + * Return a carrier uplink nobody will read, so it drops later items instead of + * buffering them. The carrier owns the iterator and `return()` is not awaited: + * a generator blocked in `next()` completes it only once it yields. + * @param source - the carrier's uplink iterable. + */ +function releaseUplink(source: AsyncIterable): void { + void Promise.resolve().then(() => source[Symbol.asyncIterator]().return?.()).catch(() => undefined) +} + function rpcFailure(error: unknown): ConnectionRpcResult { const remote = remoteErrorOf(error) if (remote !== undefined) { diff --git a/packages/api/gateway/src/remote-error-codes.ts b/packages/api/gateway/src/remote-error-codes.ts index 22f94749f5..5e560557cc 100644 --- a/packages/api/gateway/src/remote-error-codes.ts +++ b/packages/api/gateway/src/remote-error-codes.ts @@ -27,9 +27,11 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'gateway/lookup-not-found': TypertGatewayFaultDetails 'gateway/lookup-unavailable': TypertGatewayFaultDetails 'gateway/method-unavailable': TypertGatewayFaultDetails + 'gateway/protocol': TypertGatewayFaultDetails 'gateway/provider-mismatch': TypertGatewayFaultDetails 'gateway/result-invalid': TypertGatewayFaultDetails 'gateway/service-unavailable': TypertGatewayFaultDetails 'gateway/signature-invalid': TypertGatewayFaultDetails + 'gateway/uplink-overflow': TypertGatewayFaultDetails } } diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts index 5b77944a50..610fc07780 100644 --- a/packages/api/gateway/src/stream-protocol.ts +++ b/packages/api/gateway/src/stream-protocol.ts @@ -1,6 +1,7 @@ /** Wire messages for Gateway-owned Remote streams and event-result RPCs. */ import type { Branded } from '@deepseek-ai/dsh-brand' +import { isRemoteJsonValue } from '@deepseek-ai/dsh-typert-protocol' /** Exact WebSocket route carrying every Typert Remote stream. */ export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux' @@ -203,15 +204,6 @@ export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Er return error } -/** - * Test whether a value crosses JSON transport without coercion or omission. - * @param value - candidate boundary value. - * @returns whether the value is losslessly JSON-compatible. - */ -export function isRemoteJsonValue(value: unknown): boolean { - return visitJsonValue(value, new Set()) -} - /** * Recognize a non-empty Remote Event correlation id at a wire boundary. * @param value - untrusted wire value. @@ -239,7 +231,7 @@ export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentI return typeof value === 'string' && value.length > 0 } -/** One logical stream request sent from the browser. */ +/** One logical stream request sent from the browser: open, one uplink item, uplink half-close, or cancel. */ export type RemoteStreamClientMessage = | { readonly type: 'open' @@ -247,6 +239,8 @@ export type RemoteStreamClientMessage = readonly endpoint: string readonly payload: unknown } + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'end'; readonly streamId: string } | { readonly type: 'cancel'; readonly streamId: string } /** Carrier-safe failure delivered by the Host. */ @@ -269,7 +263,15 @@ export type RemoteStreamServerMessage = */ export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage { return parseMessage(text, (value) => { - if (value.type === 'cancel' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) { + if ((value.type === 'cancel' || value.type === 'end') + && exactKeys(value, ['type', 'streamId']) + && validId(value.streamId)) { + return value as RemoteStreamClientMessage + } + if (value.type === 'item' + && (exactKeys(value, ['type', 'streamId']) || exactKeys(value, ['type', 'streamId', 'value'])) + && validId(value.streamId) + && (!Object.hasOwn(value, 'value') || isRemoteJsonValue(value.value))) { return value as RemoteStreamClientMessage } if (value.type === 'open' @@ -377,31 +379,3 @@ function stringProperty(value: object | undefined, key: string): string | undefi const candidate: unknown = Reflect.get(value, key) return typeof candidate === 'string' ? candidate : undefined } - -function visitJsonValue(value: unknown, ancestors: Set): boolean { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return true - if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0) - if (typeof value !== 'object') return false - if (ancestors.has(value)) return false - ancestors.add(value) - try { - if (Array.isArray(value)) { - if (Object.getPrototypeOf(value) !== Array.prototype - || Reflect.ownKeys(value).length !== value.length + 1) return false - for (let index = 0; index < value.length; index++) { - if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false - } - return true - } - const prototype: unknown = Object.getPrototypeOf(value) - if (prototype !== Object.prototype && prototype !== null) return false - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') return false - const descriptor = Object.getOwnPropertyDescriptor(value, key) - if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false - } - return true - } finally { - ancestors.delete(value) - } -} diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 3a36e97ecd..9454f03f2d 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -2,18 +2,36 @@ import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' +import { Deque } from '@deepseek-ai/dsh-deque' +import { RemoteError, remoteErrorOf, type PeerScope } from '@deepseek-ai/dsh-typert-protocol' import WebSocket, { WebSocketServer, type RawData } from 'ws' import { parseRemoteStreamClientMessage, + type RemoteStreamClientMessage, type RemoteStreamFailure, type RemoteStreamServerMessage, } from './stream-protocol.ts' -/** Open one validated Remote stream for a decoded wire request. */ +/** + * Open one validated Remote stream for a decoded wire request on behalf of one + * Peer. `uplink` carries the Client's items; `control` cancels the logical + * stream, and aborting it with a Remote failure as the reason delivers that + * failure to the Client. + */ export type RemoteStreamOpener = ( endpoint: string, payload: unknown, - signal: AbortSignal, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise> + +/** The opener one socket uses: its Peer is fixed at upgrade time. */ +type BoundStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + control: AbortController, ) => Promise> /** Convert an invocation or carrier failure to a stable wire value. */ @@ -32,28 +50,40 @@ export class RemoteStreamMuxServer { * @param open - Gateway stream dispatcher. * @param failure - Gateway error-to-wire mapper. * @param heartbeatIntervalMs - interval between WebSocket Ping control frames. + * @param streamInboxBytes - buffered uplink frame bytes one logical stream may hold before it fails. */ constructor( private readonly open: RemoteStreamOpener, private readonly failure: RemoteStreamFailureMapper, private readonly heartbeatIntervalMs: number, + private readonly streamInboxBytes: number, ) {} /** - * Upgrade one trusted request and begin serving its logical streams. + * Upgrade one admitted request and begin serving its logical streams. Every + * stream the socket opens speaks for the Peer admitted at upgrade, and the + * socket closes when that Peer's scope is disposed. * @param req - authenticated HTTP upgrade request. * @param socket - carrier socket transferred to the WebSocket server. * @param head - bytes already read after the HTTP upgrade headers. + * @param peer - Peer the upgrade was admitted as. */ - handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer, peer: PeerScope): void { this.server.handleUpgrade(req, socket, head, (websocket) => { + const release = bindPeer(websocket, peer) + if (release === undefined) return this.missedHeartbeats.set(websocket, 0) websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) }) this.startHeartbeat() - const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) + const bound: BoundStreamOpener = (endpoint, payload, uplink, control) => + this.open(endpoint, payload, uplink, peer, control) + const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes) const done = connection.run() this.connections.add(done) - void done.then(() => { this.connections.delete(done) }) + void done.then(() => { + this.connections.delete(done) + void release() + }) }) } @@ -96,6 +126,9 @@ export class RemoteStreamMuxServer { interface ActiveStream { readonly abort: AbortController + readonly inbox: UplinkInbox + /** Cancel the logical stream and end any Host read still waiting on its uplink. */ + readonly stop: (reason: Error) => void done: Promise } @@ -105,8 +138,9 @@ class RemoteStreamMuxConnection { constructor( private readonly socket: WebSocket, - private readonly open: RemoteStreamOpener, + private readonly open: BoundStreamOpener, private readonly failure: RemoteStreamFailureMapper, + private readonly streamInboxBytes: number, ) {} async run(): Promise { @@ -127,22 +161,58 @@ class RemoteStreamMuxConnection { }) await closed const active = [...this.streams.values()] - for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed')) + for (const stream of active) stream.stop(new Error('Remote stream socket closed')) await Promise.all(active.map(stream => stream.done)) } + /** + * Dispatch one frame. `item`, `end`, and `cancel` for a stream this connection + * no longer owns are dropped: a finished stream leaves the table while the + * Client's in-flight frames are still arriving. A duplicate `open` is the one + * protocol violation that closes the socket. + */ private receive(text: string): void { const message = parseRemoteStreamClientMessage(text) - if (message.type === 'cancel') { - this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled')) - return + switch (message.type) { + case 'open': { + this.openStream(message) + return + } + case 'item': { + this.streams.get(message.streamId)?.inbox.push(message.value, Buffer.byteLength(text, 'utf8')) + return + } + case 'end': { + this.streams.get(message.streamId)?.inbox.end() + return + } + case 'cancel': { + this.streams.get(message.streamId)?.stop(new Error('Remote stream cancelled')) + return + } + /* v8 ignore next 4 -- parseRemoteStreamClientMessage admits only the four frame types above. */ + default: { + const unknown: never = message + throw new Error(`api gateway: unknown Remote stream client message ${JSON.stringify(unknown)}`) + } } + } + + private openStream(message: Extract): void { if (this.streams.has(message.streamId)) { throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`) } const abort = new AbortController() + // Created before the opener resolves so items the Client sends right + // after `open` wait in the inbox instead of being lost. + const inbox = new UplinkInbox(this.streamInboxBytes, message.endpoint, (error) => { abort.abort(error) }) const active: ActiveStream = { abort, + inbox, + stop: (reason) => { + abort.abort(reason) + inbox.fail(reason) + }, done: Promise.resolve(), } this.streams.set(message.streamId, active) @@ -158,22 +228,46 @@ class RemoteStreamMuxConnection { payload: unknown, active: ActiveStream, ): Promise { + let outcome: { readonly failed: false } | { readonly failed: true; readonly error: unknown } try { - const source = await this.open(endpoint, payload, active.abort.signal) + const source = await this.open(endpoint, payload, active.inbox, active.abort) for await (const value of source) { await this.send({ type: 'item', streamId, value }) } - if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId }) + outcome = { failed: false } } catch (error) { - if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) { - try { - await this.send({ type: 'error', streamId, error: this.failure(error) }) - } catch { - // A terminal frame that cannot be encoded or written leaves the - // logical stream ambiguous, so fail the physical generation. - this.socket.close(1011, 'Remote stream failure could not be delivered') - } - } + outcome = { failed: true, error } + } + // The downlink has settled; later uplink frames cannot change the outcome. + active.inbox.fail(new Error('Remote stream ended')) + if (active.abort.signal.aborted) { + // A Remote failure as the abort reason is the Gateway or this mux failing + // the stream (a rejected or overflowing uplink item, or an item after + // end); the Client is still waiting for that terminal frame. Any other + // abort is a cancellation. + const reason: unknown = active.abort.signal.reason + if (remoteErrorOf(reason) !== undefined) await this.sendFailure(streamId, reason) + return + } + if (outcome.failed) { + await this.sendFailure(streamId, outcome.error) + return + } + try { + await this.send({ type: 'end', streamId }) + } catch (error) { + await this.sendFailure(streamId, error) + } + } + + private async sendFailure(streamId: string, error: unknown): Promise { + if (this.socket.readyState !== WebSocket.OPEN) return + try { + await this.send({ type: 'error', streamId, error: this.failure(error) }) + } catch { + // A terminal frame that cannot be encoded or written leaves the + // logical stream ambiguous, so fail the physical generation. + this.socket.close(1011, 'Remote stream failure could not be delivered') } } @@ -199,6 +293,132 @@ class RemoteStreamMuxConnection { } } +interface UplinkEntry { + readonly value: unknown + readonly bytes: number +} + +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } + +/** + * Bounded single-consumer uplink queue of one logical stream, the source the + * Host method reads through `invocation.uplink()`. Buffered frame bytes are + * capped: overflow, and an item after the Client's `end`, fail the queue and + * report a Remote failure that the connection uses to fail the logical stream. + */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + private readonly queue = new Deque() + private bytes = 0 + private ended = false + private closed = false + private taken = false + private failure: Error | undefined + private wake: (() => void) | undefined + + constructor( + private readonly maxBytes: number, + private readonly endpoint: string, + private readonly onViolation: (error: RemoteError<'gateway/protocol' | 'gateway/uplink-overflow'>) => void, + ) {} + + push(value: unknown, frameBytes: number): void { + if (this.failure !== undefined || this.closed) return + if (this.ended) { + this.violate(new RemoteError( + 'gateway/protocol', + 'api gateway: Remote stream uplink item after end', + { endpoint: this.endpoint }, + )) + return + } + if (this.bytes + frameBytes > this.maxBytes) { + this.violate(new RemoteError( + 'gateway/uplink-overflow', + `api gateway: Remote stream uplink exceeded ${String(this.maxBytes)} buffered bytes`, + { endpoint: this.endpoint }, + )) + return + } + this.queue.pushBack({ value, bytes: frameBytes }) + this.bytes += frameBytes + this.signal() + } + + /** Client half-close; idempotent. */ + end(): void { + if (this.ended) return + this.ended = true + this.signal() + } + + /** End the consumer's next read with `error`; idempotent, drops buffered items. */ + fail(error: Error): void { + if (this.failure !== undefined) return + this.failure = error + this.queue.clear() + this.bytes = 0 + this.signal() + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.taken) throw new Error('api gateway: Remote stream uplink inbox already has a consumer') + this.taken = true + return this + } + + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE + const entry = this.queue.popFront() + if (entry !== undefined) { + this.bytes -= entry.bytes + return { value: entry.value, done: false } + } + if (this.failure !== undefined) throw this.failure + if (this.ended) return UPLINK_DONE + if (this.wake !== undefined) throw new Error('api gateway: Remote stream uplink inbox has one pending read') + await new Promise((resolve) => { this.wake = resolve }) + } + } + + /** Consumer stopped reading: later items are dropped, a pending read ends. */ + return(): Promise> { + this.closed = true + this.queue.clear() + this.bytes = 0 + this.signal() + return Promise.resolve(UPLINK_DONE) + } + + private violate(error: RemoteError<'gateway/protocol' | 'gateway/uplink-overflow'>): void { + this.fail(error) + this.onViolation(error) + } + + private signal(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } +} + +/** + * Close the socket when the Peer's scope is disposed. A scope that is already + * disposed leaves no Peer for the socket to speak for, so the socket closes now. + * @returns the registration's disposer, or `undefined` when the socket was closed. + */ +function bindPeer(websocket: WebSocket, peer: PeerScope): (() => unknown) | undefined { + try { + return peer.ctx.effect( + () => () => { websocket.close(1001, 'peer left') }, + 'api-gateway: Remote stream socket bound to its Peer', + ) + } catch { + websocket.close(1001, 'peer left') + return undefined + } +} + function rawText(data: RawData): string { if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index f325d46e1a..cae8c9dcaf 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -4,6 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ @@ -14,6 +15,13 @@ export interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ + readonly uplink?: AsyncIterable + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -78,12 +86,17 @@ export interface TypertGatewayWireStream { * Open one logical stream from its wire endpoint and payload. * @param endpoint - canonical Remote endpoint or Gateway-owned stream name. * @param payload - decoded carrier payload. + * @param uplink - Client-to-Host items of the logical stream; a Gateway-owned endpoint returns its iterator + * as soon as it opens, so the carrier drops those items instead of buffering them. + * @param peer - Peer the stream speaks for; `undefined` means the operator's in-process carrier. * @param signal - logical-stream cancellation. * @returns validated stream values. */ readonly open: ( endpoint: string, payload: unknown, + uplink: AsyncIterable, + peer: PeerScope | undefined, signal: AbortSignal, ) => Promise> @@ -114,10 +127,12 @@ export type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' | 'gateway/service-unavailable' | 'gateway/signature-invalid' + | 'gateway/uplink-overflow' /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { @@ -145,7 +160,7 @@ export interface TypertGateway { /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> diff --git a/packages/api/gateway/tests/browser-credentials.ts b/packages/api/gateway/tests/browser-credentials.ts index 8661983ea8..9bced52acc 100644 --- a/packages/api/gateway/tests/browser-credentials.ts +++ b/packages/api/gateway/tests/browser-credentials.ts @@ -1,5 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' +const browserCookies = new WeakMap() + /** Provide an in-memory credential-record owner for a mounted Connection plugin. */ export function provideBrowserCredentials(ctx: Context): void { const records = new Map() @@ -15,3 +17,28 @@ export function provideBrowserCredentials(ctx: Context): void { }, } as never) } + +/** + * Exchange a test Host's process token for its WebSocket/HTTP Cookie header. + * @param ctx - Host root with the Web server and Connection mounted. + * @returns the `cookie` header value, cached per root. + */ +export function browserCookie(ctx: Context): string { + const existing = browserCookies.get(ctx) + if (existing !== undefined) return existing + const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` + const target = new URL(ctx.connection.authenticatedUrl(origin)) + let setCookie: string | undefined + ctx.connection.authorizeIndex({ + method: 'GET', + url: `${target.pathname}${target.search}`, + headers: { host: target.host }, + }, { + writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, + end() {}, + }) + if (setCookie === undefined) throw new Error('gateway fixture did not receive a browser cookie') + const cookie = setCookie.split(';', 1)[0]! + browserCookies.set(ctx, cookie) + return cookie +} diff --git a/packages/api/gateway/tests/echo-stream.host.spec.ts b/packages/api/gateway/tests/echo-stream.host.spec.ts new file mode 100644 index 0000000000..d56b529002 --- /dev/null +++ b/packages/api/gateway/tests/echo-stream.host.spec.ts @@ -0,0 +1,96 @@ +import { once } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket, { type RawData } from 'ws' +import { Context } from '@deepseek-ai/cordis' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { Remote, TypertRemoteService, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts' + +/** The smallest Remote stream that reads its uplink: every carrier has to serve it alike. */ +class EchoService extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'echo', { namespace: 'echo' }) + } + + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + const invocation = this.ctx.invocation + if (invocation === undefined) throw new Error('echo ran outside a Remote call') + for await (const item of invocation.uplink()) { + signal.throwIfAborted() + yield `${prefix}${item}` + } + } +} + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('the echo stream on each carrier', () => { + it('echoes uplink items through the in-process carrier', async () => { + const ctx = await setup(false) + const replies = await ctx.typertGateway.stream({ + namespace: 'echo', method: 'echo', args: { prefix: '> ' }, uplink: items(['a', 'b']), + }) + await expect(collect(replies)).resolves.toEqual(['> a', '> b']) + }) + + it('echoes uplink frames through the WebSocket carrier', async () => { + const ctx = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: unknown[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data))) }) + socket.send(JSON.stringify({ type: 'open', streamId: 'echo', endpoint: 'echo/echo', payload: { args: { prefix: '> ' } } })) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'a' })) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'b' })) + socket.send(JSON.stringify({ type: 'end', streamId: 'echo' })) + await vi.waitFor(() => { + expect(frames).toEqual([ + { type: 'item', streamId: 'echo', value: '> a' }, + { type: 'item', streamId: 'echo', value: '> b' }, + { type: 'end', streamId: 'echo' }, + ]) + }) + socket.close() + await once(socket, 'close') + }) +}) + +async function setup(transport: boolean): Promise { + const ctx = new Context() + roots.push(ctx) + if (transport) { + await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 }) + provideBrowserCredentials(ctx) + } + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGatewayService, {}) + if (transport) await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + await ctx.plugin(EchoService) + return ctx +} + +async function *items(values: readonly T[]): AsyncIterable { + for (const value of values) yield value +} + +async function collect(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 904dfb84a9..7ed908f620 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -2,14 +2,18 @@ import { randomUUID } from 'node:crypto' import { once } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket, { type RawData } from 'ws' -import { Context, Service, symbols } from '@deepseek-ai/cordis' +import { Context, symbols } from '@deepseek-ai/cordis' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' import WebServer from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { - bindTypertRemote, Remote, + remoteErrorOf, + TypertRemoteService, type InvocationDescriptor, + type PeerScope, + type RemoteInvocation, + type RemoteStream, type TypertContextMap, type TypertContextWire, RemoteError, @@ -22,7 +26,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'fixture/broken': { readonly count: bigint } } } -import { provideBrowserCredentials } from './browser-credentials.ts' +import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts' import TypertGatewayService, { TypertGatewayError, type Config as GatewayConfig, @@ -42,35 +46,15 @@ vi.mock('node:crypto', async (importOriginal) => { }) const randomUuid = vi.mocked(randomUUID) -const browserCookies = new WeakMap() const REMOTE_HOST = { home: '/home/fixture' } as const type AgentWireId = TypertContextWire const agentId = (value: string): AgentWireId => value as AgentWireId -/** Exchange this test Host's process token for its WebSocket/HTTP Cookie header. */ -function browserCookie(ctx: Context): string { - const existing = browserCookies.get(ctx) - if (existing !== undefined) return existing - const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` - const target = new URL(ctx.connection.authenticatedUrl(origin)) - let setCookie: string | undefined - ctx.connection.authorizeIndex({ - method: 'GET', - url: `${target.pathname}${target.search}`, - headers: { host: target.host }, - }, { - writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, - end() {}, - }) - if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie') - const cookie = setCookie.split(';', 1)[0]! - browserCookies.set(ctx, cookie) - return cookie -} - -class FeedService extends Service { - readonly typertRemote = bindTypertRemote(this, 'feed') +class FeedService extends TypertRemoteService { readonly signals: AbortSignal[] = [] + readonly peeked: unknown[] = [] + readonly peers: PeerScope[] = [] + leftover: AsyncIterator | undefined returns = 0 constructor(ctx: Context) { @@ -133,9 +117,114 @@ class FeedService extends Service { throw new RemoteError('fixture/broken', 'fixture emitted invalid details', { count: 1n }) } + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + this.signals.push(signal) + const invocation = this.invocation() + this.peers.push(invocation.peer) + try { + for await (const item of invocation.uplink()) yield `${prefix}${item}` + } finally { + this.returns += 1 + } + } + + /** Hides the uplink failure so the test proves the Gateway still fails the stream. */ + @Remote({ mode: 'stream' }) + async *swallow(signal: AbortSignal): RemoteStream { + try { + for await (const item of this.invocation().uplink()) yield item + } catch { + // The fixture keeps yielding after the rejected item on purpose. + } + yield 'swallowed' + await abortOf(signal) + } + + /** Never takes its uplink. */ + @Remote({ mode: 'stream' }) + async *ignore(prefix: string): RemoteStream { + yield `${prefix}:one` + yield `${prefix}:two` + } + + /** Keeps the downlink open without reading, and retains the uplink iterator for post-stream reads. */ + @Remote({ mode: 'stream' }) + async *hold(signal: AbortSignal): RemoteStream { + this.leftover = this.invocation().uplink()[Symbol.asyncIterator]() + yield 'held' + await abortOf(signal) + } + + /** Reads before yielding so cancellation races the pending read, then reads once more after the abort. */ + @Remote({ mode: 'stream' }) + async *peek(signal: AbortSignal): RemoteStream { + const iterator = this.invocation().uplink()[Symbol.asyncIterator]() + const pending = iterator.next().then(result => result, (error: unknown) => error) + yield 'ready' + await abortOf(signal) + this.peeked.push(await pending, await iterator.next().then(result => result, (error: unknown) => error)) + } + + /** Yields once, then blocks on the uplink until the Gateway closes it. */ + @Remote({ mode: 'stream' }) + async *drain(): RemoteStream { + yield 'ready' + for await (const item of this.invocation().uplink()) yield item + } + + /** Reads the uplink to its end and then again: an ended uplink reports done on every later read. */ + @Remote({ mode: 'stream' }) + async *rereads(): RemoteStream { + const uplink = this.invocation().uplink() + for await (const item of uplink) yield item + for await (const item of uplink) yield `again:${item}` + yield 'done' + } + + /** Reports the call context; its descriptor declares no uplink codec, so items arrive as `unknown`. */ + @Remote({ mode: 'stream' }) + async *context(label: string): RemoteStream { + const invocation = this.invocation() + this.peers.push(invocation.peer) + const { namespace, method, args } = invocation.request + yield `${namespace}/${method}:${invocation.service}:${JSON.stringify(args)}:${label}` + for await (const item of invocation.uplink()) yield `raw:${JSON.stringify(item)}` + try { + invocation.uplink() + } catch (error) { + yield error instanceof Error ? error.message : String(error) + } + } + + /** SRC-discovered: no descriptor, so uplink items arrive as `unknown`. */ + @Remote({ mode: 'stream' }) + async *srcEcho(prefix: string, signal: AbortSignal): RemoteStream { + void signal + for await (const item of this.invocation().uplink()) yield `${prefix}${String(item)}` + } + unary(label: string): string { return label } + + /** Reads the uplink inside a unary call: items arrive only while the method runs. */ + async unaryUplink(): Promise { + const items: string[] = [] + for await (const item of this.invocation().uplink()) items.push(item) + return items + } + + /** What `ctx.invocation` reads on the Service's own Context, outside any Remote call. */ + invocationOutsideCall(): RemoteInvocation | undefined { + return this.ctx.invocation + } + + private invocation(): RemoteInvocation { + const invocation = this.ctx.invocation + if (invocation === undefined) throw new Error('fixture method ran outside a Remote call') + return invocation + } } const roots: Context[] = [] @@ -218,13 +307,16 @@ afterEach(async () => { }) describe('Typert Remote streams', () => { - it('validates the WebSocket heartbeat timer range', () => { - expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000 }) - expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })) - .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }) + it('validates the WebSocket heartbeat timer range and the stream inbox bound', () => { + expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144 }) + expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 })) + .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 }) for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) { expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow() } + for (const streamInboxBytes of [0, 1.5]) { + expect(() => TypertGatewayService.Config({ streamInboxBytes })).toThrow() + } }) it('opens decoded carrier payloads through the in-process wire adapter', async () => { @@ -232,10 +324,294 @@ describe('Typert Remote streams', () => { const source = await ctx.typertGateway.wireStream.open( 'feed/sync', { args: { label: 'wire' } }, + toAsync([]), + undefined, new AbortController().signal, ) - await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two']) + + const echoed = await ctx.typertGateway.wireStream.open( + 'feed/echo', + { args: { prefix: 'wire:' } }, + toAsync(['a', 'b']), + undefined, + new AbortController().signal, + ) + await expect(collect(echoed)).resolves.toEqual(['wire:a', 'wire:b']) + }) + + it('delivers uplink items through the in-process carrier and ends on half-close', async () => { + const { ctx, service } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: '> ' }, uplink: toAsync(['a', 'b']), + }))).resolves.toEqual(['> a', '> b']) + expect(service.returns).toBe(1) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: 'x' }, + }))).resolves.toEqual([]) + await expect(ctx.typertGateway.invoke({ + namespace: 'feed', method: 'echo', args: { prefix: 'x' }, + })).rejects.toMatchObject({ + code: 'gateway/signature-invalid', + message: 'typert gateway: feed/echo: stream Remote methods must be opened through the stream carrier', + }) + + // The caller's iterator is released even when its own return() rejects. + const returned = vi.fn(async (): Promise> => { + throw new Error('fixture release failure') + }) + const unread: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: returned, + }), + } + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'ignore', args: { prefix: 'i' }, uplink: unread, + }))).resolves.toEqual(['i:one', 'i:two']) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + }) + + it('fails a stream whose uplink item fails its codec, even when the method swallows the failure', async () => { + const { ctx, service } = await setup(false) + const rejected = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['ok', 1]), + }) + const iterator = rejected[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ok' }) + await expect(iterator.next()).rejects.toMatchObject({ + code: 'gateway/input-invalid', + details: { endpoint: 'feed/echo', field: 'uplink' }, + }) + const signal = service.signals.at(-1) + expect(signal?.aborted).toBe(true) + expect(remoteErrorOf(signal?.reason)?.code).toBe('gateway/input-invalid') + expect(service.returns).toBe(1) + + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'swallow', args: {}, uplink: toAsync([1]), + }))).rejects.toMatchObject({ code: 'gateway/input-invalid' }) + }) + + it('ends a pending uplink read on cancellation and reports later reads as cancelled', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'peek', args: {}, uplink: neverYielding(), signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ready' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/peek" was aborted') + await vi.waitFor(() => { expect(service.peeked).toHaveLength(2) }) + expect(service.peeked.map(outcome => remoteErrorOf(outcome)?.code)) + .toEqual(['gateway/cancelled', 'gateway/cancelled']) + }) + + it('drops unread uplink items once the method finishes', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'hold', args: {}, uplink: toAsync(['unread']), signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted') + const leftover = service.leftover + if (leftover === undefined) throw new Error('fixture did not retain its uplink iterator') + // The unread item is gone; a cancelled stream reports the cancellation on every later read. + await expect(leftover.next()).rejects.toMatchObject({ code: 'gateway/cancelled' }) + await expect(leftover.return?.()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('exposes the call as ctx.invocation, delivers codec-less uplink items as unknown, and hands the uplink out once', async () => { + const { ctx, service } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'ctx' }, uplink: toAsync([1, { nested: true }]), + }))).resolves.toEqual([ + 'feed/context:feed:{"label":"ctx"}:ctx', + 'raw:1', + 'raw:{"nested":true}', + 'typert gateway: feed/context: invocation.uplink() is available once per call', + ]) + expect(service.peers.at(-1)?.id).toMatch(/^[0-9a-f-]{36}$/) + expect(service.invocationOutsideCall()).toBeUndefined() + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'again' }, + }))).resolves.toHaveLength(2) + expect(service.peers.at(-1)).toBe(service.peers.at(-2)) + await expect(service.peers.at(-1)?.dispose()).resolves.toBeUndefined() + + // Items sent to a method that never takes its uplink wait in the carrier and are dropped when the downlink ends. + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'sync', args: { label: 'closed' }, uplink: toAsync(['unread']), + }))).resolves.toEqual(['closed:one', 'closed:two']) + + // Without a codec an item still has to be a JSON-safe value. + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'bad' }, uplink: toAsync([1n]), + }))).rejects.toMatchObject({ + code: 'gateway/input-invalid', + details: { endpoint: 'feed/context', field: 'uplink' }, + }) + }) + + it('releases a taken uplink source even when its return() rejects', async () => { + const { ctx } = await setup(false) + const abort = new AbortController() + const returned = vi.fn(async (): Promise> => { + throw new Error('fixture release failure') + }) + const stuck: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: returned, + }), + } + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'hold', args: {}, uplink: stuck, signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted') + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + }) + + it('closes the uplink before returning a method blocked on it', async () => { + const { ctx } = await setup(false) + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'drain', args: {}, uplink: neverYielding(), + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ready' }) + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('reports done on every read after the uplink ended', async () => { + const { ctx } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'rereads', args: {}, uplink: toAsync(['a', 'b']), + }))).resolves.toEqual(['a', 'b', 'done']) + }) + + it('accepts a top-level undefined uplink item where no codec applies and rejects it where one does', async () => { + const { ctx } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'absent' }, uplink: toAsync([undefined, 'x']), + }))).resolves.toEqual([ + 'feed/context:feed:{"label":"absent"}:absent', + 'raw:undefined', + 'raw:"x"', + 'typert gateway: feed/context: invocation.uplink() is available once per call', + ]) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync([undefined]), + }))).rejects.toMatchObject({ code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } }) + }) + + it('releases the uplink when a unary method is opened through the stream carrier', async () => { + const { ctx } = await setup(false) + const returned = vi.fn(async (): Promise> => ({ value: undefined, done: true })) + const unread: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), + } + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'unary', args: { label: 'a' }, uplink: unread, + })).rejects.toMatchObject({ code: 'gateway/signature-invalid' }) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + }) + + it('lets a unary method read uplink items while it runs', async () => { + const { ctx } = await setup(false) + await expect(ctx.typertGateway.invoke({ + namespace: 'feed', method: 'unaryUplink', args: {}, uplink: toAsync(['a', 'b']), + })).resolves.toEqual(['a', 'b']) + }) + + it('derives SRC descriptors whose uplink arrives without a codec', async () => { + const { ctx } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'srcEcho', args: { prefix: 's:' }, uplink: toAsync(['a', 2]), + }))).resolves.toEqual(['s:a', 's:2']) + }) + + it('echoes uplink frames over the WebSocket carrier and fails misused uplinks per stream', async () => { + const { ctx, service } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'echo', 'feed/echo', { prefix: '> ' }) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'a' })) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'b' })) + socket.send(JSON.stringify({ type: 'end', streamId: 'echo' })) + await vi.waitFor(() => { + expect(frames.filter(frame => frame.streamId === 'echo')).toEqual([ + { type: 'item', streamId: 'echo', value: '> a' }, + { type: 'item', streamId: 'echo', value: '> b' }, + { type: 'end', streamId: 'echo' }, + ]) + }) + expect(service.returns).toBe(1) + expect(service.peers.at(-1)).toBe(ctx.connection.operator) + + sendOpen(socket, 'rejected', 'feed/echo', { prefix: '' }) + socket.send(JSON.stringify({ type: 'item', streamId: 'rejected', value: 1 })) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'rejected')).toMatchObject({ + type: 'error', + error: { code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } }, + }) + }) + + // An item frame without value is a top-level undefined item. + sendOpen(socket, 'absent', 'feed/context', { label: 'w' }) + socket.send(JSON.stringify({ type: 'item', streamId: 'absent' })) + await vi.waitFor(() => { + expect(frames).toContainEqual({ type: 'item', streamId: 'absent', value: 'raw:undefined' }) + }) + + sendOpen(socket, 'late', 'feed/hold', {}) + await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'late', value: 'held' }) }) + socket.send(JSON.stringify({ type: 'end', streamId: 'late' })) + socket.send(JSON.stringify({ type: 'item', streamId: 'late', value: 'after end' })) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'late' && frame.type === 'error')).toMatchObject({ + error: { code: 'gateway/protocol', details: { endpoint: 'feed/hold' } }, + }) + }) + expect(socket.readyState).toBe(WebSocket.OPEN) + socket.close() + await once(socket, 'close') + }) + + it('fails a stream whose buffered uplink exceeds the configured inbox bytes', async () => { + const { ctx } = await setup(true, { streamInboxBytes: 64 }) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'big', 'feed/hold', {}) + await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'big', value: 'held' }) }) + socket.send(JSON.stringify({ type: 'item', streamId: 'big', value: 'x'.repeat(128) })) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'big' && frame.type === 'error')).toMatchObject({ + error: { code: 'gateway/uplink-overflow', details: { endpoint: 'feed/hold' } }, + }) + }) + socket.close() + await once(socket, 'close') }) it('passes Iterable and AsyncIterable items through and returns the iterator on cancellation', async () => { @@ -371,6 +747,44 @@ describe('Typert Remote streams', () => { expect(service.signals[1]?.aborted).toBe(true) }) + it('keeps uplink frames to a Gateway-owned stream out of the bounded inbox', async () => { + const { ctx } = await setup(true, { streamInboxBytes: 64 }) + const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => (async function *() { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + })() + ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) + + // An in-process carrier's uplink is returned as soon as the Gateway-owned stream opens. + const returned = vi.fn(async (): Promise> => ({ value: undefined, done: true })) + const idle: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), + } + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, undefined, new AbortController().signal) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + await events[Symbol.asyncIterator]().return?.() + + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + sendOpen(socket, 'events', '$events', {}) + await vi.waitFor(() => { expect(frames.filter(frame => frame.streamId === 'events')).toHaveLength(1) }) + for (let index = 0; index < 4; index += 1) { + socket.send(JSON.stringify({ type: 'item', streamId: 'events', value: 'x'.repeat(40) })) + } + // A Remote stream opened afterwards on the same socket shows the flood was processed and harmed nothing. + sendOpen(socket, 'after', 'feed/sync', { label: 'after' }) + await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'end', streamId: 'after' }) }) + expect(frames.filter(frame => frame.streamId === 'events').map(frame => frame.type)).toEqual(['item']) + socket.close() + await once(socket, 'close') + }) + it('carries the registered Remote event source and withdraws its active stream', async () => { const { ctx } = await setup(true) let sourceSignal: AbortSignal | undefined @@ -603,6 +1017,8 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event Client id before opening the second generation', async () => { const { ctx } = await setup(true) + // Connection minted the operator Peer id above; only the event Client ids are counted below. + randomUuid.mockClear() const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType @@ -956,6 +1372,7 @@ function descriptors(): InvocationDescriptor[] { source: 'json' as const, codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', create: () => z.string() }, } + const prefix = { ...label, name: 'prefix', wire: 'prefix' } const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({ id: `@fixture/feed#feed/${method}`, service: 'feed', @@ -966,7 +1383,20 @@ function descriptors(): InvocationDescriptor[] { parameters, result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => schema }, }) + const withUplink = (method: string, parameters: InvocationDescriptor['parameters'], cancellable = true): InvocationDescriptor => ({ + ...stream(method, parameters, z.string()), + uplink: { codec: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() } }, + ...(cancellable ? { cancellation: { parameter: 'signal' } } : {}), + }) return [ + withUplink('echo', [prefix]), + withUplink('swallow', []), + withUplink('ignore', [prefix], false), + withUplink('hold', []), + withUplink('peek', []), + withUplink('drain', [], false), + withUplink('rereads', [], false), + stream('context', [label], z.string()), { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } }, stream('sync', [label], z.string()), stream('invalid', [], z.string()), @@ -984,6 +1414,15 @@ function descriptors(): InvocationDescriptor[] { parameters: [label], result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() }, }, + { + id: '@fixture/feed#feed/unaryUplink', + service: 'feed', + namespace: 'feed', + method: 'unaryUplink', + invocation: { kind: 'direct' }, + parameters: [], + result: { mode: 'strict', typeSymbol: '@fixture/feed#Items', create: () => z.array(z.string()) }, + }, ] } @@ -1089,3 +1528,18 @@ async function collect(source: AsyncIterable): Promise { for await (const value of source) values.push(value) return values } + +async function *toAsync(values: readonly T[]): AsyncIterable { + for (const value of values) yield value +} + +function neverYielding(): AsyncIterable { + return { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}) }) } +} + +function abortOf(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 1a1102e5b5..ddd6a846b8 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -14,6 +14,7 @@ import { import type { InvocationDescriptor, RemoteResult, + RemoteStreamHandle, TypertContextMap, TypertContextWire, TypertContext, @@ -25,9 +26,11 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' import { apply, inject, RemoteStream } from '../src/client/index.ts' import { + ClientUplinkQueue, RemoteStreamCarrierError, RemoteStreamMuxClient, } from '../src/client/stream-client.ts' +import { parseRemoteStreamClientMessage } from '../src/stream-protocol.ts' type FixtureApprovalOutcome = 'allowed' | 'unavailable' const fixtureContextTag = Symbol('fixture-context-tag') @@ -93,6 +96,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { ) => Promise> 'probe/maybe': (value: string | null | undefined) => Promise> 'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable + 'probe/attach': (topic: string, signal?: AbortSignal) => RemoteStreamHandle } interface TypertRemoteScopeMap { @@ -224,6 +228,28 @@ function streamDescriptor(): InvocationDescriptor { } } +function attachDescriptor(): InvocationDescriptor { + return { + id: '@fixture/probe#probe/attach', + service: 'probe', + namespace: 'probe', + method: 'attach', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'topic', + wire: 'topic', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#Topic', create: () => z.string().min(1) }, + }], + uplink: { + codec: { mode: 'strict', typeSymbol: '@fixture#AttachInput', create: () => z.string() }, + }, + cancellation: { parameter: 'signal' }, + result: { mode: 'strict', typeSymbol: '@fixture#AttachItem', create: () => z.string().min(1) }, + } +} + type WebSocketGlobal = { WebSocket?: typeof WebSocket } class FakeWebSocket extends EventTarget { @@ -2226,20 +2252,6 @@ describe('Client Typert API', () => { it('guards stream iteration across mount and Connection withdrawal', async () => { const call = vi.fn() const ctx = await bench(call) - const firstDispose = await ctx.remote.$mount({ - package: '@fixture/stream-first', descriptors: [streamDescriptor()], - }) - const withdrawn = ctx.remote.probe.watch('withdrawn')[Symbol.asyncIterator]() - await firstDispose() - await expect(withdrawn.next()).rejects.toThrow('Remote method probe/watch is no longer mounted') - - const secondDispose = await ctx.remote.$mount({ - package: '@fixture/stream-second', descriptors: [streamDescriptor()], - }) - ctx.set('connection', undefined) - await expect(ctx.remote.probe.watch('offline')[Symbol.asyncIterator]().next()) - .rejects.toThrow('probe/watch has no active Connection') - let release!: () => void const released = new Promise((resolve) => { release = resolve }) let markStarted!: () => void @@ -2252,12 +2264,26 @@ describe('Client Typert API', () => { ctx.set('connection', { rpc: { call, open: () => source() }, } as unknown as ConnectionHandle) + const firstDispose = await ctx.remote.$mount({ + package: '@fixture/stream-first', descriptors: [streamDescriptor()], + }) + // The handle opens its stream when the method is called; the mount is withdrawn before the first item arrives. const active = ctx.remote.probe.watch('active')[Symbol.asyncIterator]() const pending = active.next() await started - await secondDispose() + await firstDispose() release() await expect(pending).rejects.toThrow('Remote method probe/watch is no longer mounted') + + const secondDispose = await ctx.remote.$mount({ + package: '@fixture/stream-second', descriptors: [streamDescriptor()], + }) + ctx.set('connection', undefined) + expect(() => ctx.remote.probe.watch('offline')).toThrow('probe/watch has no active Connection') + // A method function read before the withdrawal keeps its mount token and refuses afterwards. + const retained = ctx.remote.probe.watch + await secondDispose() + expect(() => retained('withdrawn')).toThrow('Remote method probe/watch is no longer mounted') }) it('publishes a namespace only after every contributed method is installed', async () => { @@ -2416,6 +2442,364 @@ describe('Client Typert API', () => { else Object.defineProperty(globalThis, 'document', documentDescriptor) } }) + + it('hands the handle uplink to the in-process carrier and echoes it through remote-mock', async () => { + const mock = RemoteMock.create().stream('probe/attach', async (args, stream) => { + const [{ topic }] = args as [{ readonly topic: string }] + for await (const item of stream.uplink) stream.push(`${topic}:${String(item)}`) + stream.end() + }) + const call = vi.fn() + const { ctx, client } = await benchFiber(call, 'in-process', mock.rpc.open) + await expect(ctx.remote.$mount({ + package: '@fixture/weak-uplink', + descriptors: [{ ...attachDescriptor(), uplink: { codec: { mode: 'src-json' } } }], + })).rejects.toThrow('client api: generated Remote probe/attach field "uplink" has no strict codec') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const alpha = ctx.remote.probe.attach('alpha') + alpha.send('a') + alpha.send('b') + alpha.end() + alpha.end() + await expect(drainStream(alpha)).resolves.toEqual(['alpha:a', 'alpha:b']) + expect(() => { alpha.send('late') }).toThrow('client api: probe/attach stream has terminated') + const beta = ctx.remote.probe.attach('beta', new AbortController().signal) + beta.send('c') + expect(() => { beta.end(); beta.send('d') }).toThrow('client api: probe/attach uplink was ended') + await expect(drainStream(beta)).resolves.toEqual(['beta:c']) + expect(mock.log.streams('probe/attach').map(open => open.args)).toEqual([[{ topic: 'alpha' }], [{ topic: 'beta' }]]) + + // Arity is a runtime contract, so the topic is deliberately omitted through Reflect. + expect(() => { Reflect.apply(ctx.remote.probe.attach, ctx.remote.probe, []) }).toThrow( + 'client api: probe/attach expected 1 business argument(s) plus an optional AbortSignal, got 0', + ) + expect(call).not.toHaveBeenCalled() + } finally { + await dispose() + await client.dispose() + } + }) + + it('sends handle uplink items as wire frames, queued until the open frame, and half-closes on end', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + handle.send('a') + handle.send('b') + handle.end() + const stream = handle[Symbol.asyncIterator]() + const first = stream.next() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(4) }) + const frames = socket.sent.map(text => parseRemoteStreamClientMessage(text)) + const opened = frames[0] + if (opened?.type !== 'open') throw new Error('fixture expected the open frame first') + expect(opened).toMatchObject({ endpoint: 'probe/attach', payload: { args: { topic: 'alpha' } } }) + expect(frames.slice(1)).toEqual([ + { type: 'item', streamId: opened.streamId, value: 'a' }, + { type: 'item', streamId: opened.streamId, value: 'b' }, + { type: 'end', streamId: opened.streamId }, + ]) + + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:a' }) + await expect(first).resolves.toEqual({ done: false, value: 'alpha:a' }) + const ended = stream.next() + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(ended).resolves.toEqual({ done: true, value: undefined }) + expect(socket.sent).toHaveLength(4) + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + handle.end() + expect(socket.sent).toHaveLength(4) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('terminates the handle uplink when the downlink ends first', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const stream = handle[Symbol.asyncIterator]() + const pending = stream.next() + handle.send('first') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(() => { handle.send('second') }).toThrow('client api: probe/attach stream has terminated') + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item']) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('sends cancel and ends the downlink quietly when the handle is disposed', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const pending = handle[Symbol.asyncIterator]().next() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + handle.dispose() + handle.dispose() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + expect(() => { handle.send('after dispose') }).toThrow('client api: probe/attach stream has terminated') + + // Breaking out of the iteration early is the same as dispose(). + const second = ctx.remote.probe.attach('beta') + const iterator = second[Symbol.asyncIterator]() + const opened = iterator.next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) }) + const betaId = (JSON.parse(socket.sent[2]!) as { streamId: string }).streamId + socket.receive({ type: 'item', streamId: betaId, value: 'beta:1' }) + await expect(opened).resolves.toEqual({ done: false, value: 'beta:1' }) + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)) + .toEqual(['open', 'cancel', 'open', 'cancel']) + expect(() => { second.send('late') }).toThrow('client api: probe/attach stream has terminated') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('sends cancel on dispose while nobody reads, and drops what was buffered', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const iterator = handle[Symbol.asyncIterator]() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:1' }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'alpha:1' }) + // No read is pending: the cancel still goes out on dispose, and the buffered item is dropped. + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:2' }) + handle.dispose() + await vi.waitFor(() => { + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('ends the uplink the moment a terminal frame arrives, before the consumer reads it', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + handle.send('a') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'end', streamId: opened.streamId }) + expect(() => { handle.send('after end') }).toThrow('client api: probe/attach stream has terminated') + handle.end() + await expect(drainStream(handle)).resolves.toEqual([]) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item']) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('ignores server frames for a stream it no longer tracks', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const alpha = ctx.remote.probe.attach('alpha') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + const alphaId = (JSON.parse(socket.sent[0]!) as { streamId: string }).streamId + const beta = ctx.remote.probe.attach('beta') + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const betaId = (JSON.parse(socket.sent[1]!) as { streamId: string }).streamId + alpha.dispose() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) }) + // The Host's end for alpha crosses the cancel on the wire, so no stream owns that id any more; a + // never-opened id is just as unknown. Neither frame disturbs beta or the socket. + socket.receive({ type: 'end', streamId: alphaId }) + socket.receive({ type: 'item', streamId: 'never-opened', value: 'stray' }) + socket.receive({ type: 'item', streamId: betaId, value: 'beta:1' }) + socket.receive({ type: 'end', streamId: betaId }) + await expect(drainStream(beta)).resolves.toEqual(['beta:1']) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'open', 'cancel']) + expect(socket.closedWith).toEqual([]) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('swallows a carrier iterator whose return() rejects on dispose', async () => { + // One item, then silence: dispose reaches the carrier's return() while the stream is suspended at that item. + const next = vi.fn<() => Promise>>() + .mockResolvedValueOnce({ value: 'alpha:1', done: false }) + .mockReturnValue(new Promise>(() => {})) + const returned = vi.fn<() => Promise>>() + .mockRejectedValueOnce(new Error('carrier return failed')) + .mockResolvedValue({ value: undefined, done: true }) + const open: NonNullable = () => ({ + [Symbol.asyncIterator]: () => ({ next, return: returned }), + }) + const { ctx, client } = await benchFiber(vi.fn(), 'in-process', open) + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const iterator = handle[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'alpha:1' }) + handle.dispose() + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + // The rejection is swallowed (an unhandled rejection would fail this test) and the iteration ends quietly. + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + } finally { + await dispose() + await client.dispose() + } + }) + + it('sends a top-level undefined item as an item frame without value', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle: RemoteStreamHandle = ctx.remote.probe.attach('alpha') + handle.send(undefined) + expect(() => { handle.send({ nested: undefined }) }).toThrow('is not a lossless JSON value') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + const item = JSON.parse(socket.sent[1]!) as Record + expect(item).toEqual({ type: 'item', streamId: opened.streamId }) + expect(Object.keys(item)).toEqual(['type', 'streamId']) + handle.dispose() + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('disposes on an iterator return that precedes the first read', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + await expect(handle[Symbol.asyncIterator]().return?.()).resolves.toEqual({ done: true, value: undefined }) + await vi.waitFor(() => { + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + }) + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('closes the uplink when the stream fails to open and keeps the failure for the reader', async () => { + const open: NonNullable = () => ({ + [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(new Error('handshake failed')) }), + }) + const { ctx, client } = await benchFiber(vi.fn(), 'in-process', open) + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + await vi.waitFor(() => { + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + }) + await expect(drainStream(handle)).rejects.toThrow('handshake failed') + } finally { + await dispose() + await client.dispose() + } + }) + + it('refuses an uplink item that is not a lossless JSON value', async () => { + const mock = RemoteMock.create().stream('probe/attach', async (args, stream) => { + const [{ topic }] = args as [{ readonly topic: string }] + for await (const item of stream.uplink) stream.push(`${topic}:${String(item)}`) + stream.end() + }) + const { ctx, client } = await benchFiber(vi.fn(), 'in-process', mock.rpc.open) + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + // Widened to the loosest caller `send` accepts: the check is on the value, not on the generated type. + const handle: RemoteStreamHandle = ctx.remote.probe.attach('alpha') + expect(() => { handle.send(1n) }).toThrow('client api: probe/attach uplink item is not a lossless JSON value') + expect(() => { handle.send(Number.NaN) }).toThrow('is not a lossless JSON value') + handle.send('ok') + handle.end() + await expect(drainStream(handle)).resolves.toEqual(['alpha:ok']) + } finally { + await dispose() + await client.dispose() + } + }) + + it('lets one uplink read wait at a time', async () => { + const queue = new ClientUplinkQueue('probe/attach') + const first = queue.next() + await expect(queue.next()).rejects.toThrow('client api: probe/attach uplink has one pending read') + queue.push('x') + await expect(first).resolves.toEqual({ value: 'x', done: false }) + queue.end() + await expect(queue.next()).resolves.toEqual({ value: undefined, done: true }) + expect(() => { queue.push('late') }).toThrow('client api: probe/attach uplink was ended') + await expect(queue.return()).resolves.toEqual({ value: undefined, done: true }) + expect(() => { queue.push('closed') }).toThrow('client api: probe/attach stream has terminated') + }) + + it('sends cancel and fails the downlink when the caller signal aborts', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const abort = new AbortController() + const handle = ctx.remote.probe.attach('alpha', abort.signal) + const pending = handle[Symbol.asyncIterator]().next() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + const reason = new Error('caller left') + abort.abort(reason) + await expect(pending).rejects.toBe(reason) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + expect(() => { handle.send('after abort') }).toThrow('client api: probe/attach stream has terminated') + expect(() => handle[Symbol.asyncIterator]().next()).toThrow('client api: probe/attach stream has one consumer') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) }) describe('Remote stream client carrier lifecycle', () => { @@ -2433,6 +2817,40 @@ describe('Remote stream client carrier lifecycle', () => { }) }) + it('fails the downlink and cancels when an uplink iterable throws, and swallows a rejecting uplink return()', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + client.start() + const socket = FakeWebSocket.sockets[0]! + const exploding = (async function* (): AsyncGenerator { + yield 'a' + throw new Error('uplink exploded') + })() + const pending = client.open('probe/attach', {}, new AbortController().signal, exploding)[Symbol.asyncIterator]().next() + await expect(pending).rejects.toThrow('uplink exploded') + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item', 'cancel']) + + const returned = vi.fn(async (): Promise> => { + throw new Error('fixture release failure') + }) + const stuck: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: returned, + }), + } + const abort = new AbortController() + const second = client.open('probe/attach', {}, abort.signal, stuck)[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(4) }) + const reason = new Error('caller left') + abort.abort(reason) + await expect(second).rejects.toBe(reason) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item', 'cancel', 'open', 'cancel']) + await client.close() + }) + }) + it('requires the transport owner to start the physical carrier', async () => { const client = new RemoteStreamMuxClient() await expect(client.open('feed/follow', {}, new AbortController().signal) @@ -2737,3 +3155,9 @@ async function withFakeWebSocket( else Object.defineProperty(globalThis, 'location', locationDescriptor) } } + +async function drainStream(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index d88de09fc3..94f75e9138 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { z } from 'zod' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' -import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' +import type { HostConnectionHandle, PeerId, PeerScope } from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { bindTypertRemote, @@ -112,9 +112,12 @@ class FakeConnectionService extends Service { channel: string | undefined matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined + /** The operator Peer every call this fake dispatches speaks for. */ + readonly operator: PeerScope constructor(ctx: Context) { super(ctx, 'connection') + this.operator = { id: 'fake-operator' as PeerId, ctx, dispose: () => Promise.resolve() } } get rpc() { diff --git a/packages/api/gateway/tests/remote-event-protocol.host.spec.ts b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts index 09259ea43f..87dac480ce 100644 --- a/packages/api/gateway/tests/remote-event-protocol.host.spec.ts +++ b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { isRemoteJsonValue } from '@deepseek-ai/dsh-typert-protocol' import { - isRemoteJsonValue, parseRemoteEventResult, parseRemoteStreamClientMessage, projectRemoteEventRequest, diff --git a/packages/api/gateway/tests/stream-protocol.host.spec.ts b/packages/api/gateway/tests/stream-protocol.host.spec.ts index f353dce901..c5803e8dd3 100644 --- a/packages/api/gateway/tests/stream-protocol.host.spec.ts +++ b/packages/api/gateway/tests/stream-protocol.host.spec.ts @@ -14,6 +14,15 @@ describe('Remote stream wire protocol', () => { expect(parseRemoteStreamClientMessage(JSON.stringify({ type: 'cancel', streamId: 'stream-1', }))).toEqual({ type: 'cancel', streamId: 'stream-1' }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', value: { line: 'ls\n' }, + }))).toEqual({ type: 'item', streamId: 'stream-1', value: { line: 'ls\n' } }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', + }))).toEqual({ type: 'item', streamId: 'stream-1' }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'end', streamId: 'stream-1', + }))).toEqual({ type: 'end', streamId: 'stream-1' }) }) it.each([ @@ -21,12 +30,22 @@ describe('Remote stream wire protocol', () => { { type: 'open', streamId: 'stream-1', endpoint: '', payload: {} }, { type: 'open', streamId: 'stream-1', endpoint: 'feed/follow' }, { type: 'cancel', streamId: 'stream-1', extra: true }, + { type: 'item', streamId: '', value: 'line' }, + { type: 'item', streamId: 'stream-1', value: 'line', extra: true }, + { type: 'item', streamId: 'stream-1', endpoint: 'feed/follow' }, + { type: 'end', streamId: '' }, + { type: 'end', streamId: 'stream-1', value: 'line' }, { type: 'unknown', streamId: 'stream-1' }, ])('rejects an invalid client message: %j', (message) => { expect(() => parseRemoteStreamClientMessage(JSON.stringify(message))) .toThrow('api gateway: invalid Remote stream client message') }) + it('rejects an uplink item whose value is not lossless JSON', () => { + expect(() => parseRemoteStreamClientMessage('{"type":"item","streamId":"stream-1","value":-0}')) + .toThrow('api gateway: invalid Remote stream client message') + }) + it('accepts every server message variant', () => { expect(parseRemoteStreamServerMessage(JSON.stringify({ type: 'item', streamId: 'stream-1', value: null, diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index bd631619a2..469aa25b0c 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -1,5 +1,7 @@ import { once } from 'node:events' import { createServer, type Server } from 'node:http' +import { Context } from '@deepseek-ai/cordis' +import { remoteErrorOf, type PeerId, type PeerScope } from '@deepseek-ai/dsh-typert-protocol' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' import { @@ -26,7 +28,7 @@ afterEach(async () => { describe('Remote stream mux server carrier lifecycle', () => { it('sends WebSocket Ping control frames without application messages', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) const messages = vi.fn() @@ -51,7 +53,7 @@ describe('Remote stream mux server carrier lifecycle', () => { }) it('requires two missed heartbeats before terminating an unresponsive socket', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) serverSocket.removeAllListeners('pong') @@ -66,7 +68,7 @@ describe('Remote stream mux server carrier lifecycle', () => { }) it('keeps the socket when a delayed Pong arrives before the final check', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url, false) const serverSocket = acceptedSocket(entry.mux) const terminated = vi.spyOn(serverSocket, 'terminate') @@ -92,7 +94,7 @@ describe('Remote stream mux server carrier lifecycle', () => { }) it('rejects binary, malformed, and duplicate logical-stream messages', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal)) const binary = await connect(entry.url) const binaryClosed = once(binary, 'close') @@ -115,16 +117,16 @@ describe('Remote stream mux server carrier lifecycle', () => { expect(duplicateEvent[0]).toBe(1008) expect(String(duplicateEvent[1])).toBe('invalid Remote stream request') - const noInput = await connect(entry.url) - noInput.send(openFrame('no-input')) - noInput.send(JSON.stringify({ type: 'input', streamId: 'no-input', value: 'unexpected' })) - const noInputEvent = await once(noInput, 'close') - expect(noInputEvent[0]).toBe(1008) - expect(String(noInputEvent[1])).toBe('invalid Remote stream request') + const unknownType = await connect(entry.url) + unknownType.send(openFrame('unknown-type')) + unknownType.send(JSON.stringify({ type: 'input', streamId: 'unknown-type', value: 'unexpected' })) + const unknownTypeEvent = await once(unknownType, 'close') + expect(unknownTypeEvent[0]).toBe(1008) + expect(String(unknownTypeEvent[1])).toBe('invalid Remote stream request') }) it('accepts all ws text representations and terminates a carrier error', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal)) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) const cancel = JSON.stringify({ type: 'cancel', streamId: 'absent' }) @@ -142,16 +144,12 @@ describe('Remote stream mux server carrier lifecycle', () => { const didOpen = new Promise((resolve) => { opened = resolve }) let returned!: () => void const didReturn = new Promise((resolve) => { returned = resolve }) - const entry = await startMux(async (_endpoint, _payload, signal) => { + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => { opened() - return cleanlyCancelled(signal, returned) + return cleanlyCancelled(control.signal, returned) }) const client = await connect(entry.url) - const frames: unknown[] = [] - client.on('message', (data) => { - if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame') - frames.push(JSON.parse(data.toString('utf8')) as unknown) - }) + const frames = collectFrames(client) client.send(openFrame('cancelled')) await didOpen client.send(JSON.stringify({ type: 'cancel', streamId: 'cancelled' })) @@ -171,13 +169,7 @@ describe('Remote stream mux server carrier lifecycle', () => { const client = await connect(entry.url) client.send(openFrame('write-failure')) await didOpen - const serverSocket = acceptedSocket(entry.mux) - const mutable = serverSocket as { - send(data: unknown, callback: (error?: Error) => void): void - } - mutable.send = (_data, callback): void => { - callback(new Error('fixture ws write failure')) - } + failWrites(acceptedSocket(entry.mux)) const closed = once(client, 'close') release() @@ -186,6 +178,43 @@ describe('Remote stream mux server carrier lifecycle', () => { expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') }) + it('closes the carrier when the end frame cannot be written', async () => { + const entry = await startMux(async () => (async function *(): AsyncIterable {})()) + const client = await connect(entry.url) + failWrites(acceptedSocket(entry.mux)) + + const closed = once(client, 'close') + client.send(openFrame('empty')) + const closeEvent = await closed + expect(closeEvent[0]).toBe(1011) + expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') + }) + + it('leaves a failed stream silent once its socket is no longer open', async () => { + const opened = Promise.withResolvers() + const entry = await startMux(async () => { + opened.resolve(undefined) + throw new Error('fixture opener failure') + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + const serverSocket = acceptedSocket(entry.mux) + Object.defineProperty(serverSocket, 'readyState', { configurable: true, value: WebSocket.CLOSING }) + try { + client.send(openFrame('silent')) + await opened.promise + // The failure path awaits nothing before it inspects the socket, so one + // macrotask hop drains every microtask it can schedule. + await new Promise((resolve) => { setImmediate(resolve) }) + expect(frames).toEqual([]) + } finally { + Reflect.deleteProperty(serverSocket, 'readyState') + } + expect(serverSocket.readyState).toBe(WebSocket.OPEN) + client.close() + await once(client, 'close') + }) + it('contains an item produced after its socket closes', async () => { let release!: () => void const released = new Promise((resolve) => { release = resolve }) @@ -210,9 +239,9 @@ describe('Remote stream mux server carrier lifecycle', () => { const didOpen = new Promise((resolve) => { opened = resolve }) let returned!: () => void const didReturn = new Promise((resolve) => { returned = resolve }) - const entry = await startMux(async (_endpoint, _payload, signal) => { + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => { opened() - return cleanlyCancelled(signal, returned) + return cleanlyCancelled(control.signal, returned) }) const client = await connect(entry.url) client.send(openFrame('active')) @@ -228,16 +257,254 @@ describe('Remote stream mux server carrier lifecycle', () => { }) }) -const mapFailure: RemoteStreamFailureMapper = error => ({ - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, +describe('Remote stream mux server Peer binding', () => { + it('closes an upgrade at once when the admitted Peer scope is already disposed', async () => { + const peer = await fixturePeer() + await peer.dispose() + const opened = vi.fn() + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => { + opened() + return waitForAbort(control.signal) + }, 2_000, 262_144, peer) + const client = new WebSocket(entry.url) + const closeEvent = await once(client, 'close') + expect(closeEvent[0]).toBe(1001) + expect(String(closeEvent[1])).toBe('peer left') + expect(opened).not.toHaveBeenCalled() + }) + + it('hands the admitted Peer to every opener and closes the socket when its scope is disposed', async () => { + const peer = await fixturePeer() + const seen: PeerScope[] = [] + const entry = await startMux(async (_endpoint, _payload, _uplink, opened, control) => { + seen.push(opened) + return waitForAbort(control.signal) + }, 2_000, 262_144, peer) + const client = await connect(entry.url) + client.send(openFrame('bound')) + await vi.waitFor(() => { expect(seen).toHaveLength(1) }) + expect(seen[0]).toBe(peer) + + const closed = once(client, 'close') + await peer.dispose() + const closeEvent = await closed + expect(closeEvent[0]).toBe(1001) + expect(String(closeEvent[1])).toBe('peer left') + }) }) -async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): Promise { - const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs) +describe('Remote stream mux server uplink', () => { + it('buffers uplink items sent before the opener resolves and half-closes on end', async () => { + const release = Promise.withResolvers() + const entry = await startMux(async (_endpoint, _payload, uplink) => { + await release.promise + return echoUplink(uplink) + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(openFrame('echo')) + client.send(itemFrame('echo', 'one')) + client.send(itemFrame('echo')) + await vi.waitFor(() => { expect(received.count).toBe(3) }) + expect(frames).toEqual([]) + + release.resolve(undefined) + await vi.waitFor(() => { + expect(frames).toEqual([ + { type: 'item', streamId: 'echo', value: 'one' }, + { type: 'item', streamId: 'echo' }, + ]) + }) + client.send(endFrame('echo')) + await vi.waitFor(() => { expect(frames.at(-1)).toEqual({ type: 'end', streamId: 'echo' }) }) + expect(frames).toHaveLength(3) + client.close() + await once(client, 'close') + }) + + it('fails a stream on an item after end and keeps the socket open', async () => { + const entry = await startMux(async (endpoint, _payload, uplink, _peer, control) => + endpoint === 'fixture/echo' ? echoUplink(uplink) : waitForAbort(control.signal)) + const client = await connect(entry.url) + const frames = collectFrames(client) + client.send(openFrame('held')) + client.send(endFrame('held')) + client.send(endFrame('held')) + client.send(itemFrame('held', 'late')) + await vi.waitFor(() => { + expect(frames).toEqual([{ + type: 'error', + streamId: 'held', + error: { + code: 'gateway/protocol', + message: 'api gateway: Remote stream uplink item after end', + details: { endpoint: 'fixture/follow' }, + }, + }]) + }) + + expect(client.readyState).toBe(WebSocket.OPEN) + client.send(openFrame('again', 'fixture/echo')) + client.send(itemFrame('again', 'still served')) + await vi.waitFor(() => { + expect(frames.at(-1)).toEqual({ type: 'item', streamId: 'again', value: 'still served' }) + }) + client.close() + await once(client, 'close') + }) + + it('fails a stream whose buffered uplink exceeds the configured bytes and drops later items', async () => { + const release = Promise.withResolvers() + const entry = await startMux(async () => (async function *(): AsyncIterable { + await release.promise + })(), 2_000, 48) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(openFrame('big')) + client.send(itemFrame('big', 'x'.repeat(64))) + client.send(itemFrame('big', 'dropped')) + client.send(endFrame('big')) + await vi.waitFor(() => { expect(received.count).toBe(4) }) + expect(frames).toEqual([]) + + release.resolve(undefined) + await vi.waitFor(() => { + expect(frames).toEqual([{ + type: 'error', + streamId: 'big', + error: { + code: 'gateway/uplink-overflow', + message: 'api gateway: Remote stream uplink exceeded 48 buffered bytes', + details: { endpoint: 'fixture/follow' }, + }, + }]) + }) + client.close() + await once(client, 'close') + }) + + it('drops uplink frames for a stream it no longer owns and keeps the socket open', async () => { + const entry = await startMux(async (endpoint, _payload, uplink, _peer, control) => + endpoint === 'fixture/echo' ? echoUplink(uplink) : waitForAbort(control.signal)) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(itemFrame('absent', 1)) + client.send(endFrame('absent')) + client.send(openFrame('live', 'fixture/echo')) + client.send(itemFrame('live', 'served')) + await vi.waitFor(() => { expect(received.count).toBe(4) }) + await vi.waitFor(() => { expect(frames).toEqual([{ type: 'item', streamId: 'live', value: 'served' }]) }) + expect(client.readyState).toBe(WebSocket.OPEN) + client.close() + await once(client, 'close') + }) + + it('ends a pending uplink read when the socket closes', async () => { + const read = Promise.withResolvers() + const opened = Promise.withResolvers() + const entry = await startMux(async (_endpoint, _payload, uplink, _peer, control) => { + uplink[Symbol.asyncIterator]().next().then(read.resolve, read.resolve) + opened.resolve(undefined) + return waitForAbort(control.signal) + }) + const client = await connect(entry.url) + client.send(openFrame('pending')) + await opened.promise + client.close() + await once(client, 'close') + await expect(read.promise).resolves.toMatchObject({ message: 'Remote stream socket closed' }) + }) + + it('drops uplink items after the Host stops reading', async () => { + const release = Promise.withResolvers() + const returned = Promise.withResolvers() + const entry = await startMux(async (_endpoint, _payload, uplink) => { + const iterator = uplink[Symbol.asyncIterator]() + const first: IteratorResult = await iterator.next() + await iterator.return?.() + returned.resolve(undefined) + return (async function *(): AsyncIterable { + await release.promise + yield { first: first.value, afterReturn: (await iterator.next()).done } + })() + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(openFrame('closed')) + client.send(itemFrame('closed', 'one')) + await returned.promise + client.send(itemFrame('closed', 'two')) + client.send(endFrame('closed')) + client.send(itemFrame('closed', 'three')) + await vi.waitFor(() => { expect(received.count).toBe(5) }) + + release.resolve(undefined) + await vi.waitFor(() => { + expect(frames).toEqual([ + { type: 'item', streamId: 'closed', value: { first: 'one', afterReturn: true } }, + { type: 'end', streamId: 'closed' }, + ]) + }) + client.close() + await once(client, 'close') + }) + + it('reports a second uplink consumer and a second pending read as stream failures', async () => { + const entry = await startMux(async (endpoint, _payload, uplink) => { + const iterator = uplink[Symbol.asyncIterator]() + if (endpoint === 'fixture/twice') uplink[Symbol.asyncIterator]() + void iterator.next().catch(() => undefined) + await iterator.next() + return echoUplink(uplink) + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + client.send(openFrame('twice', 'fixture/twice')) + client.send(openFrame('pending', 'fixture/pending')) + await vi.waitFor(() => { + expect(frames).toEqual(expect.arrayContaining([ + { + type: 'error', + streamId: 'twice', + error: { code: 'internal', message: 'api gateway: Remote stream uplink inbox already has a consumer', details: {} }, + }, + { + type: 'error', + streamId: 'pending', + error: { code: 'internal', message: 'api gateway: Remote stream uplink inbox has one pending read', details: {} }, + }, + ])) + }) + expect(frames).toHaveLength(2) + client.close() + await once(client, 'close') + }) +}) + +const mapFailure: RemoteStreamFailureMapper = (error) => { + const remote = remoteErrorOf(error) + if (remote !== undefined) return { code: remote.code, message: remote.message, details: remote.details } + return { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + } +} + +async function startMux( + open: RemoteStreamOpener, + heartbeatIntervalMs = 2_000, + streamInboxBytes = 262_144, + peer?: PeerScope, +): Promise { + const admitted = peer ?? await fixturePeer() + const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs, streamInboxBytes) const http = createServer() - http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) + http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head, admitted) }) await new Promise((resolve, reject) => { http.once('error', reject) http.listen(0, '127.0.0.1', () => { @@ -252,6 +519,18 @@ async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): return entry } +/** A Peer whose scope is a plain Cordis fiber, so `peer.ctx.effect` and `dispose()` behave as the registry's do. */ +async function fixturePeer(): Promise { + const root = new Context() + const fiber = root.plugin(() => {}) + await fiber + return { + id: 'fixture-peer' as PeerId, + ctx: fiber.ctx, + dispose: async () => { await fiber.dispose() }, + } +} + async function connect(url: string, autoPong = true): Promise { const socket = new WebSocket(url, { autoPong }) await once(socket, 'open') @@ -265,8 +544,45 @@ function acceptedSocket(mux: RemoteStreamMuxServer): WebSocket { return socket } -function openFrame(streamId: string): string { - return JSON.stringify({ type: 'open', streamId, endpoint: 'fixture/follow', payload: {} }) +function collectFrames(client: WebSocket): Record[] { + const frames: Record[] = [] + client.on('message', (data) => { + if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame') + frames.push(JSON.parse(data.toString('utf8')) as Record) + }) + return frames +} + +/** Count the text frames the mux has already dispatched, so a test can wait for the Host to hold them. */ +function countMessages(serverSocket: WebSocket): { count: number } { + const received = { count: 0 } + serverSocket.on('message', () => { received.count += 1 }) + return received +} + +function failWrites(serverSocket: WebSocket): void { + const mutable = serverSocket as { + send(data: unknown, callback: (error?: Error) => void): void + } + mutable.send = (_data, callback): void => { + callback(new Error('fixture ws write failure')) + } +} + +function openFrame(streamId: string, endpoint = 'fixture/follow'): string { + return JSON.stringify({ type: 'open', streamId, endpoint, payload: {} }) +} + +function itemFrame(streamId: string, value?: unknown): string { + return JSON.stringify(value === undefined ? { type: 'item', streamId } : { type: 'item', streamId, value }) +} + +function endFrame(streamId: string): string { + return JSON.stringify({ type: 'end', streamId }) +} + +async function *echoUplink(uplink: AsyncIterable): AsyncIterable { + for await (const value of uplink) yield value } async function *waitForAbort(signal: AbortSignal): AsyncIterable { diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 52da57850d..fc53358241 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -7,7 +7,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { LlmAttemptId } from '@deepseek-ai/dsh-llm' import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types' -import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { ok, streamHandle, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { ClientSessions, SessionCreateError, SessionForkError } from '../src/client/sessions/service.ts' import { scopeOf } from '../src/client/scope.ts' @@ -551,7 +551,7 @@ describe('Agent scope disposal lifecycle', () => { if (signal === undefined) throw new Error('fixture requires a signal') followSignal = signal let opened = false - return { + return streamHandle({ [Symbol.asyncIterator]: () => ({ next: () => { if (!opened) { @@ -588,7 +588,7 @@ describe('Agent scope disposal lifecycle', () => { }) }, }), - } + }) }) const readiness = b.ctx.plugin(() => undefined) await readiness @@ -625,7 +625,7 @@ describe('Agent scope disposal lifecycle', () => { const closeGate = Promise.withResolvers() closeGates.set(sessionId, closeGate) let opened = false - return { + return streamHandle({ [Symbol.asyncIterator]: () => ({ next: () => { if (!opened) { @@ -655,7 +655,7 @@ describe('Agent scope disposal lifecycle', () => { }) }, }), - } + }) }) const readiness = b.ctx.plugin(() => undefined) await readiness diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index ece1a9ff99..45e6c2b039 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -5,9 +5,10 @@ import { type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import { LlmAttemptId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult, RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import { createSessionControlStream, SessionEventStream, @@ -106,7 +107,11 @@ class ScriptedSessionRemote implements SessionTransportRemote { private readonly holdControl = true, ) {} - async *follow(request: SessionFollowRequest, signal = new AbortController().signal): AsyncIterable { + follow(request: SessionFollowRequest, signal = new AbortController().signal): RemoteStreamHandle { + return streamHandle(this.followFrames(request, signal)) + } + + private async *followFrames(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { const generation = this.generations.shift() if (generation === undefined) throw new Error('no scripted Session generation') this.followRequests.push(request) @@ -128,7 +133,11 @@ class ScriptedSessionRemote implements SessionTransportRemote { return Promise.resolve(result) } - async *control(signal = new AbortController().signal): AsyncIterable { + control(signal = new AbortController().signal): RemoteStreamHandle { + return streamHandle(this.controlSequence(signal)) + } + + private async *controlSequence(signal: AbortSignal): AsyncIterable { for (const frame of this.controlFrames) yield frame if (this.holdControl && !signal.aborted) { await new Promise((resolve) => { diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 35e9e26ae2..8862def77e 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -73,6 +73,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts index eb2b64a92a..2c34b9c54b 100644 --- a/packages/api/terminal-controller/tests/model.client.spec.ts +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -4,6 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { streamMethod } from '@deepseek-ai/dsh-remote-mock' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { TerminalView, type TerminalRemote } from '../src/client/model.ts' @@ -31,22 +32,22 @@ function failure(message: string): RemoteResult { function fixture(prepareStream?: (stream: RemoteStream) => void) { const remote: TerminalRemote = { - retain: vi.fn(async function* (_session, _id, signal) { + retain: vi.fn(streamMethod(async function* (_session, _id, signal) { yield { type: 'retained' } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }), + })), shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), - follow: vi.fn(async function* (_sessionId, id, attachmentId, signal) { + follow: vi.fn(streamMethod(async function* (_sessionId, id, attachmentId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id, controllerId: attachmentId } } await untilAborted(signal) - }), + })), } const options: RemoteStreamOptions[] = [] const streams: { dispose(): Promise; restart(): void }[] = [] @@ -103,7 +104,7 @@ it('does not let a late input failure downgrade a newer connection', async () => it('waits for render acknowledgement before publishing subsequent output', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId, signal) { const frames: TerminalFrame[] = [ { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }, { type: 'output', sequence: 1, data: 'first' }, @@ -111,7 +112,7 @@ it('waits for render acknowledgement before publishing subsequent output', async ] for (const frame of frames) yield frame await untilAborted(signal) - }) + })) await mount(model) await expect.poll(() => model.state.getSnapshot().render?.frame.type).toBe('snapshot') acknowledge(model) @@ -209,7 +210,7 @@ it.each([ ['repeated snapshot', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'snapshot', sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], ] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* () { yield* frames }) + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* () { yield* frames })) await mount(model) if (frames[0]?.type === 'snapshot') { await expect.poll(() => model.state.getSnapshot().render).toBeDefined() @@ -221,10 +222,10 @@ it.each([ it('retains an exited screen and closes controls when its stream ends', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId) { yield { type: 'snapshot', sequence: 0, screen: 'last screen', info: { ...info, id, controllerId } } yield { type: 'state', info: { ...info, id, controllerId, state: 'exited', exitCode: 3 } } - }) + })) await connected(model) await expect.poll(() => model.state.getSnapshot().phase).toBe('closed') expect(model.state.getSnapshot()).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 3 }, render: { frame: { screen: 'last screen' } } }) @@ -232,9 +233,9 @@ it('retains an exited screen and closes controls when its stream ends', async () it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId) { + vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* (_sessionId, id, controllerId) { yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } } - }) + })) await connected(model) await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded') model.connect() @@ -243,11 +244,11 @@ it('reports an attachment ending while the shell is still running and permits ma it('publishes remote control transfer without replacing the retained screen', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } yield { type: 'state', info: { ...info, id } } await untilAborted(signal) - }) + })) await connected(model) await expect.poll(() => model.state.getSnapshot().writable).toBe(false) expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) @@ -298,10 +299,10 @@ it('ignores an already delivered screen when its attachment is replaced before t it('ignores an already settled stream error after a newer attachment has become writable', async () => { const barrier = deliveryBarrier() const { model, remote } = fixture(barrier.prepare) - vi.mocked(remote.follow).mockImplementationOnce(async function* () { + vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* () { yield* [] throw new Error('retired attachment failed') - }) + })) await mount(model) await barrier.ready model.connect() @@ -338,12 +339,12 @@ it.each(['write', 'resize'] as const)('keeps a fresh attachment writable when a const { model, remote, generation } = fixture() generation.set({ id: 1, host: { home: '/home/fixture' } }) const disconnected = Promise.withResolvers() - vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* (_sessionId, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'before disconnect', info: { ...info, id, controllerId } } await Promise.race([disconnected.promise, untilAborted(signal)]) if (signal?.aborted) return throw new RemoteStreamCarrierError('connection lost') - }) + })) await connected(model) const pending = Promise.withResolvers>() if (operation === 'write') { @@ -516,12 +517,12 @@ it.each(['write', 'resize'] as const)('keeps the output connection when %s loses const transfer = Promise.withResolvers() const response = Promise.withResolvers>() cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) }) - vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } } await transfer.promise yield { type: 'state', info: { ...info, id } } await untilAborted(signal) - }) + })) await connected(model) vi.mocked(remote[operation]).mockReturnValueOnce(response.promise) if (operation === 'write') model.write('before transfer') @@ -542,12 +543,12 @@ it('keeps an exited screen when a pending input is refused after the exit state const exit = Promise.withResolvers() const response = Promise.withResolvers>() cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) }) - vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } } await exit.promise yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } } await untilAborted(signal) - }) + })) await connected(model) vi.mocked(remote.write).mockReturnValueOnce(response.promise) model.write('exit race') diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index b8ed76b366..690dd9bf0d 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -4,6 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { streamMethod } from '@deepseek-ai/dsh-remote-mock' import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -29,25 +30,25 @@ function storage() { function fixture() { const remote: TerminalRemote = { - retain: vi.fn(async function* (_session, _id, signal) { + retain: vi.fn(streamMethod(async function* (_session, _id, signal) { yield { type: 'retained' } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }), + })), shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_session, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), - follow: vi.fn(async function* (_session, id, controllerId, signal) { + follow: vi.fn(streamMethod(async function* (_session, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }), + })), } const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } function view() { @@ -489,7 +490,7 @@ it('waits for both active and detached stream finalizers during plugin disposal const finished = [Promise.withResolvers(), Promise.withResolvers()] cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) }) let index = 0 - vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + vi.mocked(h.remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { const current = index++ try { yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } } @@ -502,7 +503,7 @@ it('waits for both active and detached stream finalizers during plugin disposal await release[current]!.promise finished[current]!.resolve(undefined) } - }) + })) const model = service.view(sessionId, 'tab', 'tab') model.mount() await model.refresh() @@ -625,14 +626,14 @@ it('waits for the window hold acknowledgement before restoring an output attachm const h = fixture() vi.mocked(h.remote.list).mockResolvedValue(success([info])) const acknowledge = Promise.withResolvers() - vi.mocked(h.remote.retain).mockImplementation(async function* (_session, _id, signal) { + vi.mocked(h.remote.retain).mockImplementation(streamMethod(async function* (_session, _id, signal) { await acknowledge.promise yield { type: 'retained' } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }) + })) const { service } = await h.service() service.retainTabs([{ sessionId, tabId: 'restored', contentId: 'restored' }]) const model = service.view(sessionId, 'restored', 'restored') diff --git a/packages/api/terminal-controller/tests/retention.client.spec.ts b/packages/api/terminal-controller/tests/retention.client.spec.ts index a6c0f88ed1..11a87f3fe1 100644 --- a/packages/api/terminal-controller/tests/retention.client.spec.ts +++ b/packages/api/terminal-controller/tests/retention.client.spec.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import { streamMethod } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TerminalRemote } from '../src/client/model.ts' import { TerminalWindowHold } from '../src/client/retention.ts' @@ -9,9 +10,14 @@ import type { TerminalRetentionFrame, WebTerminalId } from '../src/types.ts' const holds: TerminalWindowHold[] = [] afterEach(async () => { await Promise.all(holds.splice(0).map(hold => hold.dispose())) }) -function hold(retain: TerminalRemote['retain']) { +function hold(retain: Parameters>[0]) { const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } - const held = new TerminalWindowHold(gateway, { retain } as TerminalRemote, 'session' as SessionId, 'terminal' as WebTerminalId) + const held = new TerminalWindowHold( + gateway, + { retain: streamMethod(retain) } as TerminalRemote, + 'session' as SessionId, + 'terminal' as WebTerminalId, + ) holds.push(held) return held } diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index b126805183..35e659759a 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -22,7 +22,8 @@ import type { WorkspaceId, WorkspaceView, } from '../src/types.ts' -import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type RemoteFailure, type RemoteResult, type RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' +import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' const sid = (id: string): SessionId => id as SessionId @@ -146,7 +147,9 @@ class FakeWorkspaceRemote implements WorkspaceRemote { return this.onUnpinSession(request) } - async *follow(_signal?: AbortSignal): AsyncGenerator {} + follow(_signal?: AbortSignal): RemoteStreamHandle { + return streamHandle((async function* () {})()) + } private record(method: string, request: unknown): void { this.calls.push({ method, request }) diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index a8b036b554..73df728df4 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -66,6 +66,7 @@ "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/api/workspace-files/tests/fake-remote.client.ts b/packages/api/workspace-files/tests/fake-remote.client.ts index c310dc433f..ecb6aedf2b 100644 --- a/packages/api/workspace-files/tests/fake-remote.client.ts +++ b/packages/api/workspace-files/tests/fake-remote.client.ts @@ -3,8 +3,9 @@ * opened `changes` generation, and a supervisor that runs one generation and * classifies its end the way the real one does. */ -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult, RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceFileWatchFrame, WorkspaceFileStat } from '../src/types.ts' import type { SupervisedStream, SupervisedStreamOptions, WorkspaceFilesRemote } from '../src/client/remote.ts' @@ -225,7 +226,7 @@ export class FakeRemote implements WorkspaceFilesRemote { this.statWaiters.delete(index) if (this.closed) resolve(this.closedStat()) }), - changes: (sessionId: SessionId, path: string, signal?: AbortSignal): AsyncIterable => { + changes: (sessionId: SessionId, path: string, signal?: AbortSignal): RemoteStreamHandle => { this.calls.push('changes') if (signal === undefined) throw new Error('the feed must hand its signal to the Host stream') const source = new Source(signal) @@ -235,7 +236,7 @@ export class FakeRemote implements WorkspaceFilesRemote { for (const waiter of this.watchWaiters.get(index) ?? []) waiter(watch) this.watchWaiters.delete(index) if (this.autoReady) source.push({ kind: 'ready' }) - return source + return streamHandle(source) }, } } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 3077a60252..ddf0603df0 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 17bcb9940286b91433273cef2fcda3147249133b -README.zh.md: 6a090709414007ddb7fe7ca3fdd8c29b737a39dc +README.md: 6c49836b0e581666db0df503b175132ed1e6295b +README.zh.md: 5843efd2299526cf00b0c6331668c304d6042f56 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 17bcb99402..6c49836b0e 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -27,7 +27,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con A static desktop page can provide `__DSH_TRANSPORT__.streamBaseUrl` for the HTTP origin of its owned Host. The Gateway uses that origin for its WebSocket while HTTP transport remains independently selected. The desktop carrier owns authentication; setting the origin alone grants no access. -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams, including a stream's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- @@ -40,6 +40,8 @@ The cookie signing secret is the owner-scoped `client-connection/browser-session Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). +Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract. + Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. WebSocket stream ownership remains with API Gateway. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 6a09070941..5843efd229 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。 -浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括流的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- @@ -40,6 +40,8 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi 认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 +每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。 + 通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。WebSocket 流仍由 API Gateway 负责。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index ea8af1e498..8b95205a05 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -44,7 +44,8 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -52,6 +53,8 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" } } diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index fef3b234bb..bf4427b41c 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -17,11 +17,12 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ */ export type RpcFetch = (input: string | URL, init: RequestInit) => Promise -/** Worker-local opener for decoded Gateway Remote streams. */ +/** Worker-local opener for decoded Gateway Remote streams; `uplink` carries the Client's items for the stream. */ export type RpcStreamOpen = ( endpoint: string, payload: unknown, signal: AbortSignal, + uplink?: AsyncIterable, ) => AsyncIterable /** @@ -64,12 +65,12 @@ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStrea return full.result }, ...openStream === undefined ? {} : { - open(channel, endpoint, payload, signal) { + open(channel, endpoint, payload, signal, uplink) { assertTarget(channel, endpoint) if (channel !== '/api') { throw new Error(`connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`) } - return openStream(endpoint, payload, signal) + return openStream(endpoint, payload, signal, uplink) }, }, } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 7a4457e375..c76ce937c8 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -14,6 +14,7 @@ import { HostConnectionService } from './rpc-host.ts' import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type ConnectionRecoveryConfig } from './recovery-config.ts' export type { + PeerAdmission, ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, @@ -33,7 +34,9 @@ export type { RpcMessage, ServerResponse, } from './rpc.ts' +export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' export { RpcId, transportError } from './rpc.ts' +export { OperatorPeer } from './operator-peer.ts' export { clientRequestSchema, rpcErrorSchema, @@ -141,10 +144,10 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise { - const rejection = connection.requestRejection(req) - if (rejection !== undefined) { - res.writeHead(rejection) - res.end(rejection === 401 ? 'unauthorized' : 'forbidden') + const admission = connection.admit(req) + if ('rejection' in admission) { + res.writeHead(admission.rejection) + res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden') return } await webCtx.waterfall('connection/request', req, res, () => bridge(req, res, fetchHandler, maxRequestBodyBytes)) diff --git a/packages/client/connection/src/operator-peer.ts b/packages/client/connection/src/operator-peer.ts new file mode 100644 index 0000000000..83325f78e7 --- /dev/null +++ b/packages/client/connection/src/operator-peer.ts @@ -0,0 +1,33 @@ +/** + * The operator Peer: the one party this Host answers to. Connection owns it + * for its own lifetime, admits every request as it, and hands it to each + * Remote call as `invocation.peer`. + * @module @deepseek-ai/dsh-client-connection/src/operator-peer + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import { createScope, type Scope } from '@deepseek-ai/dsh-scope' +import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol' + +/** + * The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)` + * returns it and events dispatched with `scopeTarget(subject, peer)` reach + * listeners registered through `peer.ctx` and nobody else. + */ +export class OperatorPeer implements PeerScope { + readonly id: PeerId = randomUUID() as PeerId + readonly ctx: Context + private readonly scope: Scope + + /** @param owner - Connection plugin context the scope fiber hangs under. */ + constructor(owner: Context) { + this.scope = createScope(owner, this) + this.ctx = this.scope.ctx + } + + /** Tear down every connection-lifetime registration; racing calls share one completion. */ + dispose(): Promise { + return this.scope.dispose() + } +} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index d74734e132..ba6f2736d6 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -2,6 +2,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import { RpcId, type ClientRequest, @@ -12,7 +13,9 @@ import { bridge } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' +import { OperatorPeer } from './operator-peer.ts' import type { + PeerAdmission, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionFetchRoute, @@ -58,6 +61,8 @@ declare module '@deepseek-ai/cordis' { /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { + /** The operator Peer every admitted request speaks for. */ + readonly operator: PeerScope private readonly interceptors = new Map() private readonly fetchRoutes = new Map() @@ -73,6 +78,8 @@ export class HostConnectionService extends Service implements HostConnectionHand private readonly browserAuth: BrowserAuth, ) { super(ctx, 'connection') + this.operator = new OperatorPeer(ctx) + ctx.effect(() => () => this.operator.dispose(), 'client-connection: operator Peer') } /** Generic channel registry scoped to the Context reading this service. */ @@ -99,6 +106,12 @@ export class HostConnectionService extends Service implements HostConnectionHand return this.browserAuth.isAuthenticated(request) ? undefined : 401 } + /** A request that passes the fence and authentication speaks for the operator. */ + admit(request: ConnectionTrustRequest): PeerAdmission { + const rejection = this.requestRejection(request) + return rejection === undefined ? { peer: this.operator } : { rejection } + } + /** Authenticate an index request through the process-token exchange or cookie. */ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean { return this.browserAuth.authorizeIndex(request, response) @@ -161,15 +174,15 @@ export class HostConnectionService extends Service implements HostConnectionHand handler: ConnectionRpcHandler, ): () => Promise { assertChannel(channel) - const fetchHandler = rpcFetchHandler(channel, handler) + const fetchHandler = rpcFetchHandler(channel, handler, this.operator) const route: WebRoute = { kind: 'prefix', path: channel, handler: async (req, res) => { - const rejection = this.requestRejection(req) - if (rejection !== undefined) { - res.writeHead(rejection) - res.end(rejection === 401 ? 'unauthorized' : 'forbidden') + const admission = this.admit(req) + if ('rejection' in admission) { + res.writeHead(admission.rejection) + res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden') return } await bridge(req, res, fetchHandler) @@ -192,7 +205,7 @@ export class HostConnectionService extends Service implements HostConnectionHand } const interceptor: ConnectionRpcInterceptor = { matches, - fetchHandler: rpcFetchHandler(channel, handler), + fetchHandler: rpcFetchHandler(channel, handler, this.operator), } return owner.effect(() => { if (this.interceptors.has(channel)) { @@ -209,6 +222,7 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, + peer: PeerScope, ): ConnectionFetchHandler { return { requestBodyMode: () => 'buffered', @@ -244,7 +258,7 @@ function rpcFetchHandler( } try { - const result = await handler(endpoint, message.payload, request.signal) + const result = await handler(endpoint, message.payload, request.signal, peer) return fullResponse(message.rpcId, result) } catch (error) { return new Response(`handler failure: ${String(error)}`, { status: 500 }) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 5d4a4db957..e77562acfd 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,6 +1,7 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' /** Correlation id minted by a caller and echoed by the Connection response. */ export type RpcId = Branded<'rpc-id'> @@ -96,11 +97,20 @@ export interface ConnectionIndexResponse { end(body?: string): unknown } -/** Handler invoked after Connection has decoded the transport envelope. */ +/** Outcome of admitting one request: the operator Peer it speaks for, or the status refusing it. */ +export type PeerAdmission = + | { readonly peer: PeerScope } + | { readonly rejection: 401 | 403 } + +/** + * Handler invoked after Connection has decoded the transport envelope. + * `peer` is the Peer the request was admitted as: the operator. + */ export type ConnectionRpcHandler = ( endpoint: string, payload: unknown, signal: AbortSignal, + peer: PeerScope, ) => Promise> /** Synchronous ownership test for one endpoint on a shared RPC channel. */ @@ -161,12 +171,14 @@ export interface HostConnectionRpc { ): () => Promise } -/** Host `ctx.connection` shape consumed by transport-independent adapters. */ +/** Host `ctx.connection` members consumed by transport-independent adapters. */ export interface HostConnectionHandle { /** Generic RPC channel registry. */ readonly rpc: HostConnectionRpc /** Exact Fetch routes for streaming or browser-native responses. */ readonly fetch: HostConnectionFetch + /** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */ + readonly operator: PeerScope /** * Compose exact Fetch routes and the shared-channel RPC interceptor. @@ -183,6 +195,14 @@ export interface HostConnectionHandle { */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection + /** + * Admit one request: it passes {@link requestRejection} and speaks for the + * operator, or it is refused with that status. + * @param request - request headers from the HTTP or upgrade request. + * @returns the operator Peer, or the rejection status. + */ + admit(request: ConnectionTrustRequest): PeerAdmission + /** * Authenticate one frontend index request, owning a token redirect or 401. * @param request - root or configured-index HTTP request. @@ -240,6 +260,7 @@ export interface ClientConnectionRpc { * @param endpoint - channel-relative endpoint such as `session/follow`. * @param payload - channel-owned request payload. * @param signal - caller cancellation for this logical stream. + * @param uplink - Client uplink items the Host method reads through `invocation.uplink()`. * @returns decoded stream values from the in-process carrier. */ readonly open?: ( @@ -247,5 +268,6 @@ export interface ClientConnectionRpc { endpoint: string, payload: unknown, signal: AbortSignal, + uplink?: AsyncIterable, ) => AsyncIterable } diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index 8ff914bef4..4588382d9e 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -495,9 +495,10 @@ describe('connection client apply', () => { it('exposes a worker-local Gateway stream through connection.rpc.open', async () => { ;(globalThis as Win).location = { hostname: 'preview.example' } const openStream = vi.fn>( - (endpoint, payload, signal) => (async function *(): AsyncGenerator { + (endpoint, payload, signal, uplink) => (async function *(): AsyncGenerator { signal.throwIfAborted() yield { endpoint, payload } + if (uplink !== undefined) yield* uplink })(), ) ;(globalThis as Win).__DSH_TRANSPORT__ = { @@ -521,7 +522,14 @@ describe('connection client apply', () => { 'session/follow', { args: { sessionId: 'session-1' } }, abort.signal, + undefined, ) + + const uplink = (async function *(): AsyncGenerator { yield 'typed' })() + const echoed = [] + for await (const value of open('/api', 'job/attach', { args: {} }, abort.signal, uplink)) echoed.push(value) + expect(echoed).toEqual([{ endpoint: 'job/attach', payload: { args: {} } }, 'typed']) + expect(openStream).toHaveBeenLastCalledWith('job/attach', { args: {} }, abort.signal, uplink) expect(handle.isLoopback).toBe(true) expect(() => open('/rpc', 'session/follow', {}, abort.signal)) .toThrow('worker-local streams require the /api channel') diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index b2d4ce11d5..51a0bbbf67 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -8,7 +8,16 @@ import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { IndexInjection, WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, RpcId, apply, inject, type ClientRequest, type ConnectionConfig, type HostConnectionHandle } from '../src/index.ts' +import { + API_PATH, + RpcId, + apply, + inject, + type ClientRequest, + type ConnectionConfig, + type HostConnectionHandle, + type PeerScope, +} from '../src/index.ts' import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' import { provideBrowserCredentials } from './browser-credentials.ts' @@ -469,6 +478,47 @@ describe('connection node half', () => { await fiber.dispose() }) + it('admits every trusted, authenticated request as the operator Peer and hands each call that Peer', async () => { + const { connection, routes, dispose } = await mounted({ trustedHosts: ['harness.example'] }) + const peers: PeerScope[] = [] + const remove = connection.rpc.intercept( + '/api', + () => true, + async (_endpoint, _payload, _signal, peer) => { + peers.push(peer) + return { ok: true, value: null } + }, + ) + expect(connection.admit(fakeRequest({ host: 'other.example' }))).toEqual({ rejection: 403 }) + expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080' }))).toEqual({ rejection: 401 }) + const cookie = browserCookie(connection, '127.0.0.1:3080') + expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080', cookie }))).toEqual({ peer: connection.operator }) + + const route = routes.find(candidate => candidate.path === API_PATH)! + const request: ClientRequest = { + type: 'client-request', + rpcId: RpcId('rpc-peer'), + method: 'goals/create', + payload: { args: {} }, + } + const answered = fakeResponse() + await route.handler(fakePost({ host: '127.0.0.1:3080', cookie }, '/api/goals/create', request), answered.response) + expect(JSON.parse(String(answered.state.body))).toMatchObject({ result: { ok: true, value: null } }) + // A shell-owned carrier dispatches the shared handler without the bridge and speaks for the operator too. + const direct = await connection.createSharedFetchHandler('/api').fetch(new Request('http://127.0.0.1:3080/api/goals/create', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(request), + })) + expect(direct.status).toBe(200) + expect(peers).toEqual([connection.operator, connection.operator]) + + // Racing disposals share one completion, and the scope goes with the Connection. + await Promise.all([connection.operator.dispose(), connection.operator.dispose()]) + await remove() + await dispose() + }) + it('applies the configured trust fence and JSON envelope checks to generic channels', async () => { const ctx = new Context() const routes: WebRoute[] = [] diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index ffc18472c2..a9c558d29b 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -35,6 +35,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../typert/protocol" + }, { "path": "../../util/brand" } diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index f58ca85848..c291ff868d 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -12,6 +12,7 @@ "src/http-bridge.ts", "src/index.ts", "src/loopback-hostname.ts", + "src/operator-peer.ts", "src/recovery-config.ts", "src/rpc-host.ts", "src/rpc-schema.ts", @@ -28,14 +29,20 @@ "path": "../../attachment/attachment" }, { - "path": "../../credentials/credentials" + "path": "../../core/scope" }, { "path": "../../core/session" }, + { + "path": "../../credentials/credentials" + }, { "path": "../../host/webserver" }, + { + "path": "../../typert/protocol" + }, { "path": "../../util/brand" } diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index eb6201046e..579f9d857b 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/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-commands/README.md -README.md: f8cba29179f9960042a4a9e67e86496591a76d08 -README.zh.md: 1cea3ae7aea857f2b2d219abf0a7192c2f222792 +README.md: 7fada747562ff15a96997d8bffe9aeb6f9a18fb5 +README.zh.md: 2f532cbd0f3d33e304074644cc6cb02b7151d3e1 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index f8cba29179..7fada74756 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -49,6 +49,8 @@ When the composer submits with images or generic files, only a host command decl `src/client/contract.ts` defines contribution and decoration registration, plus `dismiss(name)`, which closes that command's open popups and confirmations: dismissal aborts pending option loads, prevents their late results from reopening the popup, and preserves composer drafts. `CommandDirectory` owns the per-session wire cache and resolves typed commands through `resolution.ts`; that module owns first-party identity matching and localized input spellings. `matchSpace` reads the ready cache synchronously, while `matchEnter` waits for readiness and rejects on warmup failure or cancellation. Forwarded catalog and connection events invalidate the cache. After a matched Host execution, this browser emits `command/executed`; other clients observe only the durable command events. `PopupSelectController` owns popup state, and `PopupSelectView` occupies the input overlay. `presentation.ts` owns row labels, icons, and sections; its helpers and the resolution helpers stay internal to the plugin. +Each command-directory fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `commands.list`. A temporary `commandCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC, so background catalog refreshes do not reopen closed Sessions. + ----- diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 1cea3ae7ae..2f532cbd0f 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -49,6 +49,8 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen `src/client/contract.ts` 定义贡献项和装饰的注册接口,以及 `dismiss(name)`:它关闭该命令已打开的弹窗与确认对话框,中止待完成的选项加载,阻止晚到结果重新打开弹窗,并保留 composer 草稿。`CommandDirectory` 负责会话级协议缓存,并通过 `resolution.ts` 解析输入命令;该模块负责内置命令标识匹配和本地化输入写法。`matchSpace` 同步读取就绪缓存,`matchEnter` 等待缓存就绪,预热失败或取消时拒绝。转发的目录和连接事件使缓存失效。宿主执行匹配的命令后,本浏览器发布 `command/executed`,其他客户端只观察持久命令事件。`PopupSelectController` 负责弹窗状态,`PopupSelectView` 占据输入浮层。`presentation.ts` 负责行标题、图标和分节,展示与解析辅助函数均留在插件内部。 +每次命令目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `commands.list`。临时的 `commandCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC,因此后台目录刷新不会重新打开已关闭的会话。 + ----- diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 316474f354..7ae8c42c8b 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -9,6 +9,8 @@ * `rankByName`). A host/contribution name collision fails loud. Every * execute addresses the session's agent by sessionId — sessions are always * agent-backed. + * Catalog RPCs retain an existing Client Session through completion and + * wait for its initial history open to succeed before contacting the Host. */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' @@ -34,6 +36,13 @@ import { builtinRowFace, sectionRows } from './presentation.ts' import { claimToken } from './resolution.ts' import type { TokenSegment } from './popup.ts' +declare module '@deepseek-ai/dsh-api-session-controller/client' { + interface SessionReferenceSourceMap { + /** A command-catalog fetch waiting for initial history and its RPC result. */ + commandCatalog: unknown + } +} + declare module '@deepseek-ai/cordis' { interface Events { /** @@ -86,10 +95,20 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (locale === undefined) throw new Error('ui-commands: locale service unavailable') this.t = locale.bind('command') this.directory = new CommandDirectory(async (sessionId) => { - if (this.sessions().subagentAddress(sessionId) !== undefined) return [] - const result = await ctx.remote.commands.list(sessionId) - if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`) - return result.value + const sessions = this.sessions() + if (sessions.subagentAddress(sessionId) !== undefined) return [] + if (sessions.binding(sessionId) === undefined) { + throw new Error(`command catalog requires a retained session "${sessionId}"`) + } + return sessions.using(sessionId, { source: 'commandCatalog' }, async (reference) => { + const state = reference.binding.session.getSnapshot() + if (state.openState !== 'open') { + throw state.openError ?? new Error(`session "${sessionId}" is not open`) + } + const result = await ctx.remote.commands.list(sessionId) + if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`) + return result.value + }) }) const inputTriggers = ctx.get('inputTriggers') if (inputTriggers === undefined) throw new Error('ui-commands: slash service unavailable') diff --git a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts index a53a161b14..7adee98b3e 100644 --- a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts @@ -8,7 +8,7 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, onTestFinished } from 'vitest' -import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import { TestSessions } from '@deepseek-ai/dsh-client-test-runtime' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -28,21 +28,16 @@ async function bench() { return () => { sources.delete(`${src.trigger} ${src.name}`) } }, }) - const scopes = new Map() - const bindings = new Map() - ctx.provide('sessions', { - scope: (id: SessionId) => scopes.get(id), - scopeOf: (c: Context) => scopeOf(c), - sessionOf: (c: Context) => bindings.get(scopeOf(c)!)?.session, - binding: (id: SessionId) => bindings.get(id), - subagentAddress: (id: SessionId) => id === sid('child') - ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } - : undefined, + const sessions = new TestSessions(async (action) => { await action() }, ctx) + onTestFinished(async () => { + await sessions.disposeScopes() + await ctx.fiber.dispose() }) + ctx.provide('sessions', sessions) + await sessions.add({ id: 's1' }) + await sessions.add({ id: 'child' }) + sessions.retainFor(ctx, sid('s1')) + sessions.retainFor(ctx, { parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable' }) const commandsRemote = { list: () => Promise.resolve({ ok: true as const, value: [] }) } // The service subscribes its cache-invalidation events on construction, so // the Remote face needs `$on` even where this spec dispatches none. @@ -57,10 +52,8 @@ async function bench() { await fiber.await() const mint = (key: string) => { const id = sid(key) - const handle = createScope(ctx, id) - scopes.set(id, handle.ctx) - bindings.set(id, { sessionId: id, session: { sessionId: id }, ctx: handle.ctx }) - return handle + const binding = sessions.binding(id) ?? sessions.retainFor(ctx, id).binding + return { ctx: binding.ctx, fiber: binding.ctx.fiber } } return { ctx, fiber, sources, slots: ctx.slots, mint } } diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index f49ea80052..fcde326c59 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -1,6 +1,6 @@ /** * CommandUiRuntime tests on a real cordis Context with fake slash/connection - * faces and real session scopes (createScope): session-keyed candidate + * faces and retained TestSessions: session-keyed candidate * synthesis (host catalog by sessionId + contributions by availability, * collision fail-loud), the dispatch decision table cell by cell, matchSpace * hot-key policy, matchEnter strong-wait / reject, the sessionId execute @@ -11,9 +11,10 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' -import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import { scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, TestRemote, TestSessions } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime' import { IconGoalOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts' @@ -44,6 +45,8 @@ interface BenchOptions { execute?: (payload: { sessionId: SessionId; line: string }) => Promise translate?: (namespace: string, key: string, params?: Record) => string addressed?: SessionId + initialOpen?: SessionFixture['initialOpen'] + snapshot?: SessionFixture['snapshot'] } /** @@ -105,18 +108,30 @@ async function bench(opts: BenchOptions = {}) { opts.translate?.(ns, key, params) ?? `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`, }) - // Real scope tags behind a fake sessions face. - const scopes = new Map } }>() - const bindings = new Map() - const removeSessions = ctx.provide('sessions', { - scope: (id: SessionId) => scopes.get(id)?.ctx, - scopeOf: (c: Context) => scopeOf(c), - sessionOf: (scopeCtx: Context) => [...bindings.values()].find(binding => binding.ctx === scopeCtx)?.session, - binding: (id: SessionId) => bindings.get(id), - subagentAddress: (id: SessionId) => id === opts.addressed - ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } - : undefined, + const sessions = new TestSessions(async (action) => { await action() }, ctx) + onTestFinished(async () => { + await sessions.disposeScopes() + await ctx.fiber.dispose() }) + const removeSessions = ctx.provide('sessions', sessions) + for (const id of new Set(['s1', 's2', 'other', 'ghost', ...opts.addressed === undefined ? [] : [opts.addressed]])) { + await sessions.add({ + id, + ...opts.initialOpen === undefined ? {} : { initialOpen: opts.initialOpen }, + ...opts.snapshot === undefined ? {} : { snapshot: opts.snapshot }, + }) + } + const mint = (key: string) => { + const id = sid(key) + const target = id === opts.addressed + ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } + : id + const binding = sessions.binding(id) ?? sessions.retainFor(ctx, target).binding + return { ctx: binding.ctx, fiber: binding.ctx.fiber } + } + mint('s1') + mint('s2') + if (opts.addressed !== undefined) mint(opts.addressed) const remote = Object.assign(new TestRemote(ctx), { commands: commandsRemote }) ctx.provide('remote.commands', commandsRemote) const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = [] @@ -142,24 +157,13 @@ async function bench(opts: BenchOptions = {}) { const command = ctx.get('commandUi') as CommandUiRuntime const source = registered.get('/ command') if (source === undefined) throw new Error('command source not registered') - const mint = (key: string) => { - const id = sid(key) - const handle = createScope(ctx, id) - const binding = { sessionId: id, session: { sessionId: id }, ctx: handle.ctx } - scopes.set(id, handle) - bindings.set(id, binding) - handle.ctx.effect(() => () => { - if (bindings.get(id) === binding) bindings.delete(id) - }) - return handle - } /** Warm one session's catalog through the source's own candidate pull. */ const warm = async (session: ClientSessionContext) => { await source.candidates(session, { query: '', position: 'leading', drilled: false, signal: new AbortController().signal }) } return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, focuses, remote, - removeSessions, removeConversation, + removeSessions, removeConversation, sessions, } } @@ -215,15 +219,51 @@ describe('registration', () => { it('the warm hook prewarms the session key: one pull per session, no duplicate over pending', async () => { const { source, listCalls } = await bench() source.warm!(proj('s1')) - expect(listCalls).toEqual([{ sessionId: sid('s1') }]) + await vi.waitFor(() => { expect(listCalls).toEqual([{ sessionId: sid('s1') }]) }) source.warm!(proj('s2')) - expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }]) + await vi.waitFor(() => { expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }]) }) source.warm!(proj('s1')) // s1 already pending → no duplicate pull expect(listCalls).toHaveLength(2) }) }) describe('candidates', () => { + it('waits for initial history and retains the Session until the catalog RPC settles', async () => { + const opened = Promise.withResolvers() + const response = Promise.withResolvers<{ commands: CommandDescriptor[] }>() + const b = await bench({ initialOpen: () => opened.promise, commands: () => response.promise }) + try { + const pending = b.source.candidates(proj('s1'), req('')) + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().retainedBy.commandCatalog).toBe(1) + expect(b.listCalls).toEqual([]) + opened.resolve(undefined) + await vi.waitFor(() => { expect(b.listCalls).toHaveLength(1) }) + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(2) + response.resolve({ commands: S1_CMDS }) + await pending + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1) + } finally { + opened.resolve(undefined) + response.resolve({ commands: S1_CMDS }) + } + }) + + it.each([true, false])('refuses an unsuccessful history open (reported error: %s)', async (reported) => { + const error = new RemoteError('gateway/internal', 'history unavailable', {}) + const b = await bench({ snapshot: { openState: 'error', openError: reported ? error : null } }) + await expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow(reported ? 'history unavailable' : 'is not open') + expect(b.listCalls).toEqual([]) + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1) + }) + + it('does not reopen a Session released before catalog lookup', async () => { + const b = await bench() + await b.mint('s1').fiber.dispose() + await expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow('requires a retained session') + expect(b.sessions.binding(sid('s1'))).toBeUndefined() + expect(b.listCalls).toEqual([]) + }) + it('does not fetch Agent-bound commands for an addressed child', async () => { const b = await bench({ addressed: sid('child') }) await expect(b.warm(proj('child'))).resolves.toBeUndefined() @@ -677,6 +717,7 @@ describe('matchEnter (enter column)', () => { commands: () => new Promise((resolve) => { release = resolve }), }) const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { attachments: 0 }) + await vi.waitFor(() => { expect(release).toBeTypeOf('function') }) release({ commands: S1_CMDS }) const outcome = await wait if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') @@ -906,10 +947,12 @@ describe('detached admission notices', () => { }) it('a torn-down scope drops the failure notice', async () => { - const { source, warm, notices } = await bench({ + const { source, warm, notices, mint } = await bench({ execute: () => Promise.reject(new Error('orphan failure')), }) - await warm(proj('ghost')) // never minted: scopeFor misses + const scope = mint('ghost') + await warm(proj('ghost')) + await scope.fiber.dispose() menuPick(source, 'plan', proj('ghost')) await flush() expect(notices).toEqual([]) @@ -1122,6 +1165,7 @@ describe('directory invalidation events', () => { ctx.emit('connection/reset') // Hard reset: silent until the rewarm lands. expect(source.matchSpace!(proj('s2'), '/attach')).toBeUndefined() + await vi.waitFor(() => { expect(release).toBeTypeOf('function') }) release({ commands: S2_CMDS }) await new Promise(resolve => setTimeout(resolve, 0)) expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined() diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 36f5e15909..c00ca00404 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: 7e6e0502db9720f788711fa3e98cab1581cc8738 -README.zh.md: 7e3fb604469ec73238f0843fdc95e6ea175c1d03 +README.md: ac23ac87c5b1bfe3eb849679c30a2ed283f33455 +README.zh.md: bc9971a5e8917bf636de34a742530b113e4e4453 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 7e6e0502db..ac23ac87c5 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -45,6 +45,8 @@ A rejected mutation surfaces the Remote error inline on the strip; loading, abse The durable goal arrives through `useProjection('goal')` (seeded by the history tail page and updated by `session/projection` frames). The inject face carries a registrant-private activation hook source plus the four mutation verbs. That source starts only while the framework hook observes it, reads `ctx.remote.goals.get`, subscribes to `goal/activation-changed`, and refreshes on running-state or connection resets. Live-event epochs invalidate in-flight reads, so a stale HTTP result cannot overwrite a newer activation edge; running refreshes retain the last known activation until the read resolves. The strip owns no domain store or cross-plugin cache. Each mutation reads the CAS ref from the session's current projected value at call time, and the RPC's compare-and-set is the staleness guard. The strip single-flights mutations synchronously because a pending render cannot fence same-frame clicks. The command-input projection is a separate Conversation Definition that builds a `command-input` Chat Node before the generic command result Node; it never creates `user/message` or a model turn. +Activation reads hold a temporary `goalActivation` Client reference and send `goals.get` only after that Session's initial history open succeeds. A stale binding or failed open sends no RPC. Rejected reads are logged without changing the projected goal or last known activation; the temporary reference is released when the read settles. + ----- diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 7e3fb60446..bc9971a5e8 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -45,6 +45,8 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激 持久 goal 经 `useProjection('goal')` 到达(由历史尾页播种、`session/projection` 帧更新)。注入面携带注册方私有的激活钩子源与四个变更动词。该源仅在框架钩子观察它时启动;启动后会读取 `ctx.remote.goals.get`、订阅 `goal/activation-changed`,并在 running 状态或连接重置时刷新。实时事件 epoch 会让在途读取失效,因此较旧的 HTTP 响应不能覆盖较新的 activation 变化;running 刷新会保留最后一次已知 activation,直到读取完成。条带不持有领域存储或跨插件缓存。每个变更在调用时从会话当前投影值读取 CAS ref,比较并交换(RPC 的 CAS)就是陈旧性护栏。由于 React 的 pending 渲染无法拦住同一帧内的点击,条带会同步为变更建立 single-flight 防护。指令输入投影是独立的 Conversation Definition,在通用命令结果 Node 之前构建 `command-input` Chat Node;它绝不创建 `user/message` 或模型轮次。 +激活状态读取持有临时的 `goalActivation` 客户端引用,仅在该会话首次历史打开成功后发送 `goals.get`。binding 已失效或打开失败时不发送 RPC。读取被拒绝时记录错误,不改变投影中的目标或最后一次已知激活状态;读取结束时释放临时引用。 + ----- diff --git a/packages/client/ui-goal/src/client/activation-source.ts b/packages/client/ui-goal/src/client/activation-source.ts index 23c65740a0..f1e74c8668 100644 --- a/packages/client/ui-goal/src/client/activation-source.ts +++ b/packages/client/ui-goal/src/client/activation-source.ts @@ -13,7 +13,7 @@ export interface GoalActivationDeps { readonly projection: HostObservable /** Session snapshot; running flips trigger a fresh authoritative read. */ readonly session: HostObservable<{ readonly running: boolean }> - /** Read the current live goal at call time. */ + /** Read live activation after initial history; rejected reads leave the current snapshot unchanged. */ readonly getGoal: () => Promise> /** Subscribe to activation edges after the transport delivers them. */ readonly subscribeActivation: (listener: (goal: GoalActivationChanged['goal']) => void) => () => void @@ -69,6 +69,8 @@ export function createGoalActivationSource(deps: GoalActivationDeps): HostObserv return } publish({ id: goal.id, revision: goal.revision, activation: goal.activation }) + }, (error: unknown) => { + console.warn('[ui-goal] goal activation read failed:', error) }) } diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 93e1c996bf..e6feba92f5 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -4,6 +4,8 @@ * `useProjection('goal')`. A registrant-private activation hook source owns * the live Remote read and event subscription; the inject face carries that * hook plus the four mutation verbs through the generated Goal Remote API. + * Activation reads retain the Client Session through completion and wait + * for its initial history open to succeed before contacting the Host. * This plugin does not create goals; deployments may expose /goal separately. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' @@ -38,6 +40,13 @@ export type { } from './slots.ts' export type { GoalKey } from './locales.ts' +declare module '@deepseek-ai/dsh-api-session-controller/client' { + interface SessionReferenceSourceMap { + /** A live goal read waiting for initial history and its RPC result. */ + goalActivation: unknown + } +} + declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { /** The goal strip's copy. */ @@ -91,7 +100,18 @@ export function apply(ctx: ClientContext): void { const goalActivation = createGoalActivationSource({ projection: binding.session.projections.faceOf('goal') as HostObservable, session: binding.session, - getGoal: () => ctx.remote.goals.get(sessionId), + getGoal: async () => { + if (sessions.binding(sessionId) !== binding) { + throw new Error(`ui-goal: session "${sessionId}" is unavailable`) + } + return sessions.using(sessionId, { source: 'goalActivation' }, async (reference) => { + const state = reference.binding.session.getSnapshot() + if (state.openState !== 'open') { + throw state.openError ?? new Error(`session "${sessionId}" is not open`) + } + return ctx.remote.goals.get(sessionId) + }) + }, subscribeActivation: listener => ctx.remote.$on('goal/activation-changed', (event) => { if (event.sessionId === sessionId) listener(event.goal) }), diff --git a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx index 6181f3621b..bfa9828985 100644 --- a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx @@ -10,7 +10,7 @@ * plugin fiber (HMR safety), and the node half stays inert. */ import { Context, Service } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -18,7 +18,8 @@ import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { GoalActivation, GoalId, GoalProjection, GoalView } from '@deepseek-ai/dsh-goal/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError, TestSessions } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { GoalActivationSnapshot, GoalBarActions, GoalBarInjected } from '../src/client/slots.ts' @@ -52,25 +53,25 @@ async function bench(options: { projection?: GoalProjection | null | undefined activation?: GoalActivation failWith?: RemoteFailure + initialOpen?: SessionFixture['initialOpen'] + snapshot?: SessionFixture['snapshot'] } = {}) { const ctx = new Context() const calls: { method: string; args: unknown[] }[] = [] - const sessions = { - binding: (id: SessionId) => id === sid('missing') ? undefined : ({ - sessionId: id, - session: { - getSnapshot: () => ({ running: false }), - subscribe: () => () => {}, - projections: { faceOf: (key: string) => ({ - getSnapshot: () => (key === 'goal' ? options.projection : undefined), - subscribe: () => () => {}, - }) }, - }, - ctx, - }), - } + const sessions = new TestSessions(async (action) => { await action() }, ctx) + onTestFinished(async () => { + await sessions.disposeScopes() + await ctx.fiber.dispose() + }) + await sessions.add({ + id: 's1', + ...options.initialOpen === undefined ? {} : { initialOpen: options.initialOpen }, + ...options.snapshot === undefined ? {} : { snapshot: options.snapshot }, + }) + await sessions.setProjection('s1', 'goal', options.projection) + const owner = sessions.retainFor(ctx, sid('s1')) ctx.provide('sessions', sessions) - const conversationEvents = new UiConversation(ctx, sessions as never).events + const conversationEvents = new UiConversation(ctx, sessions).events function answer(method: string, value: T) { return (...args: unknown[]) => { calls.push({ method, args }) @@ -145,6 +146,8 @@ async function bench(options: { ctx, fiber, calls, + sessions, + owner, emitActivation: remote.emitActivation.bind(remote), definitions: () => conversationEvents.entries(), remountGoals: () => { activeGoals = goals('remounted-goals') }, @@ -163,6 +166,65 @@ async function bench(options: { } describe('ui-goal browser plugin', () => { + it('waits for initial history before reading activation and releases the read reference', async () => { + const opened = Promise.withResolvers() + const b = await bench({ projection: makeProjection(), activation: 'disarmed', initialOpen: () => opened.promise }) + await b.fiber.await() + const source = b.entry()!.inject!(sid('s1')).hooks.goalActivation + const dispose = source.subscribe(() => {}) + try { + expect(b.calls).toEqual([]) + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().retainedBy.goalActivation).toBe(1) + opened.resolve(undefined) + await waitFor(() => { expect(source.getSnapshot().activation).toBe('disarmed') }) + expect(b.calls).toEqual([{ method: 'goals/get', args: [sid('s1')] }]) + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1) + } finally { + opened.resolve(undefined) + dispose() + } + }) + + it.each([true, false])('keeps projected goal state after an unsuccessful history open (reported error: %s)', async (reported) => { + const error = new RemoteError('gateway/internal', 'history unavailable', {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const b = await bench({ projection: makeProjection(), snapshot: { openState: 'error', openError: reported ? error : null } }) + await b.fiber.await() + const source = b.entry()!.inject!(sid('s1')).hooks.goalActivation + const dispose = source.subscribe(() => {}) + try { + await waitFor(() => { expect(warn).toHaveBeenCalledOnce() }) + expect(warn).toHaveBeenCalledWith('[ui-goal] goal activation read failed:', expect.objectContaining({ + message: reported ? 'history unavailable' : 'session "s1" is not open', + })) + expect(source.getSnapshot()).toEqual({ id: GOAL_ID, revision: 3 }) + expect(b.calls).toEqual([]) + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1) + } finally { + dispose() + } + }) + + it('refuses activation reads captured from a replaced Session binding', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const b = await bench({ projection: makeProjection() }) + await b.fiber.await() + const source = b.entry()!.inject!(sid('s1')).hooks.goalActivation + b.owner.release() + b.sessions.retainFor(b.ctx, sid('s1')) + const dispose = source.subscribe(() => {}) + try { + await waitFor(() => { expect(warn).toHaveBeenCalledWith('[ui-goal] goal activation read failed:', expect.objectContaining({ + message: 'ui-goal: session "s1" is unavailable', + })) }) + expect(b.calls).toEqual([]) + } finally { + dispose() + } + }) + it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => { const b = await bench() await b.fiber.await() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index ead71f86ef..9b64707375 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 7ee357f9f405db0c14662b69e5edac1cd84d4424 -README.zh.md: a36172a3857d007c2695f44ee4b6729e1026355c +README.md: 205f942f6199a718eb360f6dec6f5bb9425cc4e5 +README.zh.md: 577453d1c583f4981c26ca5dc702eded004a67b4 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7ee357f9f4..205f942f61 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -44,7 +44,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | -| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader. `aria-hidden`, so the render site owns the name. | +| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. | | `Modal` | Centered dialog over a page mask. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index a36172a385..577453d1c5 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -44,7 +44,7 @@ kind: "package-library" | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | -| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading。它是 `aria-hidden` 的,名称由渲染点提供。 | +| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 | | `Modal` | 页面遮罩之上的居中对话框。 | diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css index 6a77a4e127..186e54eb57 100644 --- a/packages/client/ui-primitives/src/StateDot.module.css +++ b/packages/client/ui-primitives/src/StateDot.module.css @@ -32,7 +32,8 @@ } /* Ongoing is the sole non-dot state: a quiet full ring sits behind a centered - * breathing arc while the complete glyph rotates continuously. */ + * breathing arc while the complete glyph rotates continuously. Both animations + * share one 1.5s period so pinning their start times aligns them together. */ .spinner { flex: none; color: var(--dsw-alias-label-tertiary); diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index 05d869acfd..b0d84b89ec 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -7,6 +7,20 @@ import css from './StateDot.module.css' */ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' | 'idle' +/** + * Pin the loader's CSS animations to document time zero. A CSS animation starts + * when its element is inserted, so loaders mounted at different moments rotate + * out of phase; one shared start time keeps every visible loader in step. + * @param element - the mounted loader, or null on unmount. + */ +function syncSpinner(element: SVGSVGElement | null): void { + if (element === null) return + // jsdom (the unit lane) implements no Web Animations despite lib.dom's + // non-optional typing; the optional call leaves that lane unsynced. + const spinner = element as { getAnimations?: SVGSVGElement['getAnimations'] } + for (const animation of spinner.getAnimations?.({ subtree: true }) ?? []) animation.startTime = 0 +} + /** * Render a state dot. * @param props.state - which of `done`, `warning`, `ongoing`, `error`, or `idle` to show. @@ -23,6 +37,7 @@ export function StateDot({ state, size, className }: { if (state === 'ongoing') { return ( { expect(bad('paused')).toBe('paused') }) }) + +describe('StateDot ongoing phase', () => { + it('pins every loader animation to document time zero on mount', () => { + const animations = [{ startTime: 42 }, { startTime: 7 }] + const getAnimations = vi.fn(() => animations) + const proto = SVGElement.prototype as { getAnimations?: () => { startTime: number }[] } + proto.getAnimations = getAnimations + try { + const { unmount } = render() + expect(getAnimations).toHaveBeenCalledWith({ subtree: true }) + expect(animations.map(animation => animation.startTime)).toEqual([0, 0]) + unmount() + expect(getAnimations).toHaveBeenCalledTimes(1) + } finally { + delete proto.getAnimations + } + }) +}) diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index dbd1bdcb5c..a70fa7f3f4 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-reference/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-reference/README.md -README.md: 6b5046f1c6300f5ec02d3737617019874762d47a -README.zh.md: 99ba6fda18a73f5686f5e02403e48d5e556f9c3c +README.md: 57b5ae2440cf3dd4512cf44361b334dc7f3a58c9 +README.zh.md: fa5af63c939f2bb71f371262dbbf137180c63f8e diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 6b5046f1c6..57b5ae2440 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -53,6 +53,8 @@ The source keeps candidate encoding internal to the registration effect: the `/c For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files, direct subagents of the current Session, and other Sessions. Rows use the resolver's display title, which prefers a subagent's creation label while ordinary Sessions retain their projected title, and render under locale-owned group headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same relative-time bucket that list uses, so one session reads the same age on both surfaces; a session the list does not carry falls back to the candidate's creation time. A drilled query publishes a breadcrumb from the workspace root to the current directory; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome. +Candidate requests require an existing retained Client Session and share one temporary `referenceCandidates` reference. Both discovery calls wait for that Session's initial history open to succeed; an unretained Session or failed open prevents both RPCs. The candidate request's cancellation signal covers the history wait and both calls, and the temporary reference is released when the lookup settles. + ### Serialization File picks preserve the natural text defined by the shared `@path` grammar as the hidden serialized and clipboard form. Session picks use the canonical `@[label](dsh-session:…)` mention; serialization never reconstructs identity from the visible title. diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index 99ba6fda18..fa5af63c93 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -53,6 +53,8 @@ Web 用户需要从同一个 `@` 补全菜单提及文件、文件夹或会话 对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再按文件、当前 Session 的直接 subagent、其他 Session 的确定性顺序排列。各行使用 resolver 的显示标题;subagent 优先使用创建标签,普通 Session 保持投影标题,并渲染在 locale 管理的分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。 +候选请求要求客户端已持有该会话,并共用一个临时的 `referenceCandidates` 引用。两个发现调用都等待该会话首次历史打开成功;未持有会话或打开失败时,均不发送 RPC。候选请求的取消信号同时覆盖历史等待和两个调用,查询结束时释放临时引用。 + ### 序列化 文件选择把共享 `@path` 语法所定义的自然文本保留为隐藏的序列化与剪贴板形式。会话选择使用规范的 `@[label](dsh-session:…)` mention;序列化永远不会根据可见标题重建身份。 diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index bf2104a1b3..6204478f75 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -2,6 +2,8 @@ * Unified Web `@` reference source. File and session discovery run through * the cancellable generated Remote namespaces in parallel with deterministic * ordering and labels. + * Candidate requests retain an existing Client Session through completion + * and wait for its initial history open to succeed before contacting the Host. * * Rows carry only what distinguishes them: a file names its parent directory * (nothing at the workspace root), a directory listing names none because its @@ -28,6 +30,13 @@ import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session- import { abbreviateHomePath, fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' import { en, NS, zh, type ReferenceKey } from './locales.ts' +declare module '@deepseek-ai/dsh-api-session-controller/client' { + interface SessionReferenceSourceMap { + /** File and Session candidates waiting for initial history and their RPC results. */ + referenceCandidates: unknown + } +} + /** Required services: the trigger registry, the Remote namespaces, and the copy. */ export const inject = [ 'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences', @@ -47,13 +56,27 @@ export function apply(ctx: ClientContext): void { name: 'reference', showGroupTitle: false, async candidates(session: ClientSessionContext, { query, quoted, drilled, signal }) { - const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal) - .then(result => result.ok ? result.value : []) - const sessionLookup = quoted === true - ? Promise.resolve([] as SessionReferenceMentionCandidate[]) - : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal) - .then(result => result.ok ? result.value : []) - const [fileItems, sessionItems] = await Promise.all([fileLookup, sessionLookup]) + if (sessions.binding(session.sessionId) === undefined) { + throw new Error(`reference candidates require a retained session "${session.sessionId}"`) + } + const [fileItems, sessionItems] = await sessions.using( + session.sessionId, + { source: 'referenceCandidates', signal }, + async (reference) => { + signal.throwIfAborted() + const state = reference.binding.session.getSnapshot() + if (state.openState !== 'open') { + throw state.openError ?? new Error(`session "${session.sessionId}" is not open`) + } + const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal) + .then(result => result.ok ? result.value : []) + const sessionLookup = quoted === true + ? Promise.resolve([] as SessionReferenceMentionCandidate[]) + : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal) + .then(result => result.ok ? result.value : []) + return Promise.all([fileLookup, sessionLookup]) + }, + ) if (signal.aborted) return [] // The header already names the directory being listed; rows repeat it only // when there is no header to carry it. diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index ff8ae73411..b0cb5ebeef 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -4,10 +4,11 @@ * round-trip, and registration lifecycle. */ import { Context, Service } from '@deepseek-ai/cordis' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, TestSessions } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime' import type { CandidateRequest, ClientSessionContext, InputTriggerCandidate, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -80,7 +81,8 @@ async function bench( parentId?: SessionId projectionValues?: { title?: string | null } }> = {}, -): Promise<{ ctx: Context; fiber: ReturnType; source: InputTriggerSource }> { + opening: Pick = {}, +) { const ctx = new Context() ctx.provide('sidebarRight', { openResource: vi.fn() }) let source: InputTriggerSource | undefined @@ -101,11 +103,22 @@ async function bench( ctx.provide('remote.fileReferences', { list: files }) ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } }) + const sessionStore = new TestSessions(async (action) => { await action() }, ctx) + onTestFinished(async () => { + await sessionStore.disposeScopes() + await ctx.fiber.dispose() + }) + ctx.provide('sessions', sessionStore) + const summary = listed[session.sessionId] + await sessionStore.add({ id: session.sessionId, ...opening, ...summary === undefined ? {} : { summary } }) + for (const [id, summary] of Object.entries(listed)) { + if (id !== session.sessionId) await sessionStore.add({ id, summary }) + } + const owner = sessionStore.retainFor(ctx, session.sessionId) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() if (source === undefined) throw new Error('reference source was not registered') - return { ctx, fiber, source } + return { ctx, fiber, source, sessions: sessionStore, owner } } describe('apply', () => { @@ -150,6 +163,68 @@ describe('apply', () => { }) describe('candidates', () => { + it('waits for initial history before both lookups and releases its reference after they settle', async () => { + const opened = Promise.withResolvers() + const response = Promise.withResolvers>() + const files = vi.fn(() => response.promise) + const lookup = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const b = await bench(files, lookup, {}, { initialOpen: () => opened.promise }) + try { + const pending = b.source.candidates(session, request('')) + expect(b.sessions.retainInfo(session.sessionId).getSnapshot().retainedBy.referenceCandidates).toBe(1) + expect(files).not.toHaveBeenCalled() + expect(lookup).not.toHaveBeenCalled() + opened.resolve(undefined) + await vi.waitFor(() => { expect(files).toHaveBeenCalledOnce() }) + expect(lookup).toHaveBeenCalledOnce() + expect(b.sessions.retainInfo(session.sessionId).getSnapshot().referenceCount).toBe(2) + response.resolve({ ok: true, value: [] }) + await expect(pending).resolves.toEqual([]) + expect(b.sessions.retainInfo(session.sessionId).getSnapshot().referenceCount).toBe(1) + } finally { + opened.resolve(undefined) + response.resolve({ ok: true, value: [] }) + } + }) + + it.each([true, false])('refuses an unsuccessful history open (reported error: %s)', async (reported) => { + const error = new RemoteError('gateway/internal', 'history unavailable', {}) + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const lookup = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const b = await bench(files, lookup, {}, { snapshot: { openState: 'error', openError: reported ? error : null } }) + await expect(b.source.candidates(session, request(''))).rejects.toThrow(reported ? 'history unavailable' : 'is not open') + expect(files).not.toHaveBeenCalled() + expect(lookup).not.toHaveBeenCalled() + expect(b.sessions.retainInfo(session.sessionId).getSnapshot().referenceCount).toBe(1) + }) + + it('does not reopen a released Session for candidates', async () => { + const b = await bench() + b.owner.release() + await expect(b.source.candidates(session, request(''))).rejects.toThrow('require a retained session') + expect(b.sessions.binding(session.sessionId)).toBeUndefined() + }) + + it('cancels a query waiting for history without starting either lookup', async () => { + const opened = Promise.withResolvers() + const controller = new AbortController() + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const lookup = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const b = await bench(files, lookup, {}, { initialOpen: () => opened.promise }) + try { + const pending = b.source.candidates(session, request('', { signal: controller.signal })) + const reason = new Error('query cancelled') + const rejected = expect(pending).rejects.toBe(reason) + controller.abort(reason) + await rejected + expect(files).not.toHaveBeenCalled() + expect(lookup).not.toHaveBeenCalled() + expect(b.sessions.retainInfo(session.sessionId).getSnapshot().referenceCount).toBe(1) + } finally { + opened.resolve(undefined) + } + }) + it('starts both Remote lookups together and renders files before sessions with stable labels', async () => { let releaseFiles!: () => void let releaseSessions!: () => void @@ -194,7 +269,7 @@ describe('candidates', () => { })) const { source } = await bench(files, sessions, { source: { updatedAt: UPDATED_AT } }) const pending = source.candidates(session, request('re')) - expect(files).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { expect(files).toHaveBeenCalledTimes(1) }) expect(sessions).toHaveBeenCalledTimes(1) releaseSessions() releaseFiles() @@ -269,9 +344,13 @@ describe('candidates', () => { it('drops a completed result when the query signal was superseded', async () => { const controller = new AbortController() - const { source } = await bench() + const gate = Promise.withResolvers>() + const files = vi.fn(() => gate.promise) + const { source } = await bench(files) const pending = source.candidates(session, request('', { signal: controller.signal })) + await vi.waitFor(() => { expect(files).toHaveBeenCalledOnce() }) controller.abort() + gate.resolve({ ok: true, value: [] }) await expect(pending).resolves.toEqual([]) }) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 5ddaacb419..c5b1574510 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: 2337b7db8f5b8d2713d17f83092d948657a5acdb -README.zh.md: 2fd25f3d1b44c400b4400397e94d641766d9ae62 +README.md: e06ecf5f36234f634cbff708f592fbe83c9f175e +README.zh.md: 6eb54b49a7a7f1a55a6f9389c57f736533715185 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 2337b7db8f..e06ecf5f36 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -33,9 +33,11 @@ In Desktop, the account-row update control shows availability, progress, verific ### The General section +The current release version appears at the bottom of General Settings in Web and Desktop, using the build’s `DSH_CLIENT_VERSION` metadata and the active language. Partial builds without version metadata omit the row. + The Developer tools switch controls the shared preference described by [ui-settings](../ui-settings/README.md#use-this-package). It is available in both Web and desktop, follows accepted changes immediately, and disables duplicate input while a write settles. A failed write displays localized retry guidance. -The General section holds the built-in Developer tools row and rows registered into `settings.general.item` by feature packages. Each registrant owns its row copy and behavior. The Appearance row, for example, lives in ui-theme. +The General section holds the built-in Developer tools and Current version rows alongside rows registered into `settings.general.item` by feature packages. Each registrant owns its row copy and behavior. The Appearance row, for example, lives in ui-theme. ### Opening the configuration file @@ -53,7 +55,7 @@ The onboarding ledger projects in ascending order and mounts exactly one step at
Implementation internals — click to expand -The shell owns the chrome and the projections; it contributes the Developer tools row, while feature registrants own their additional content and copy. +The shell owns the chrome and the projections; it contributes the Developer tools and Current version rows, while feature registrants own their additional content and copy. ### Ledger projections @@ -104,7 +106,7 @@ None; this package neither assembles nor sends a provider request. These limits define what the shell itself provides versus what features must supply; they are current package constraints. -- **Additional General rows require their feature plugins** — the shell supplies Developer tools; feature plugins supply the remaining preferences. +- **Additional General rows require their feature plugins** — the shell supplies Developer tools and Current version; feature plugins supply the remaining preferences. - **The Windows caption badge keeps a side-opening bubble** — `DesktopUpdateBadge` occupies `sidebar.toggle.badge` in the caption and requests `side="right"`, so the Desktop-owned menu text can cover its bubble while the sidebar is collapsed on Windows; the sidebar toggle and New Session bubbles open below the caption instead (#4688). diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 2fd25f3d1b..6eb54b49a7 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -33,9 +33,11 @@ kind: "package-reference" ### 「通用」分区 +Web 与桌面端的通用设置底部显示当前发布版本,使用构建注入的 `DSH_CLIENT_VERSION` 元数据和当前语言。缺少版本元数据的局部构建不显示该行。 + 开发者工具开关控制 [ui-settings](../ui-settings/README.zh.md#use-this-package) 定义的共享偏好。Web 和桌面端均提供此开关,立即跟随已接受的变更,并在写入完成前禁用重复输入。写入失败时显示本地化的重试提示。 -「通用」分区承载内置的开发者工具行和功能包注册进 `settings.general.item` 的行。每个注册方拥有自己的行文案与行为。例如「外观」行位于 ui-theme。 +「通用」分区承载内置的开发者工具行与当前版本行,以及功能包注册进 `settings.general.item` 的行。每个注册方拥有自己的行文案与行为。例如「外观」行位于 ui-theme。 ### 打开配置文件 @@ -53,7 +55,7 @@ kind: "package-reference"
实现细节——点击展开 -外壳拥有界面框架与投影,并提供开发者工具行;功能注册方拥有其余内容与文案。 +外壳拥有界面框架与投影,并提供开发者工具行与当前版本行;功能注册方拥有其余内容与文案。 ### 账本投影 @@ -104,7 +106,7 @@ kind: "package-reference" 这些限制说明外壳自身提供什么、功能包必须提供什么;它们是当前包约束。 -- **额外的通用设置行需要相应功能插件**:外壳提供开发者工具;功能插件提供其他偏好。 +- **额外的通用设置行需要相应功能插件**:外壳提供开发者工具与当前版本;功能插件提供其他偏好。 - **Windows 顶栏徽标的气泡仍向右展开**:`DesktopUpdateBadge` 占用顶栏的 `sidebar.toggle.badge` 且请求 `side="right"`,侧栏收起时其气泡会被 Desktop 菜单文字遮挡;侧栏开关与新建会话的气泡则改在顶栏下方展开(#4688)。 diff --git a/packages/client/ui-settings-general/src/client/CurrentVersionRow.module.css b/packages/client/ui-settings-general/src/client/CurrentVersionRow.module.css new file mode 100644 index 0000000000..66f159a3dd --- /dev/null +++ b/packages/client/ui-settings-general/src/client/CurrentVersionRow.module.css @@ -0,0 +1,7 @@ +.row { + padding: 16px 0; + color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 22px; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx b/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx new file mode 100644 index 0000000000..e3c8cb702f --- /dev/null +++ b/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx @@ -0,0 +1,14 @@ +/** Installed release version in General Settings for Web and Desktop. */ +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './CurrentVersionRow.module.css' + +/** + * Render the version embedded by the client build; partial builds without metadata omit the row. + * @param props - runtime share and localized copy. + * @returns the current release label, or nothing when build metadata is absent. + */ +export function CurrentVersionRow({ t }: PropsRuntime<'settings.general.item'> & PropsLocale<'settings'>) { + const version = process.env.DSH_CLIENT_VERSION + if (version === undefined) return null + return
{t('general.currentVersion', { version })}
+} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 442ebc0c14..427229d47c 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -29,6 +29,7 @@ import type { DesktopUpdateBridge } from './desktop-update-bridge.ts' import { DesktopUpdateSource } from './desktop-update-source.ts' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' +import { CurrentVersionRow } from './CurrentVersionRow.tsx' import { DeveloperToolsRow, type DeveloperToolsRowInjected } from './DeveloperToolsRow.tsx' import { SettingsDocumentAction } from './SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx' @@ -76,6 +77,10 @@ export function apply(ctx: ClientContext): void { setEnabled: enabled => ctx.settingsScope.developerTools.setEnabled(enabled), }), }, DeveloperToolsRow)) + // Last row: every feature-registered preference row orders below 100. + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', id: 'current-version', order: 100, locale: NS, + }, CurrentVersionRow)) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-general: dictionaries') const connection = ctx.get('connection') as ConnectionHandle const carrier = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; updates?: DesktopUpdateBridge } }).dshDesktop diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index e906f1714c..9608a28152 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -26,6 +26,7 @@ export const zh = { 'openDocument': '打开配置文件', 'openDocument.error': '无法打开配置文件', 'general.nav': '通用设置', + 'general.currentVersion': '当前版本:{version}', 'developerTools.title': '开发者工具', 'developerTools.error': '保存失败,请重试', 'developerTools.description': '显示用于调试和排查问题的工具与信息', @@ -65,6 +66,7 @@ export const en = { 'openDocument': 'Open configuration file', 'openDocument.error': 'Could not open configuration file', 'general.nav': 'General', + 'general.currentVersion': 'Current version: {version}', 'developerTools.title': 'Developer tools', 'developerTools.error': 'Could not save. Please try again.', 'developerTools.description': 'Show tools and information for debugging and troubleshooting', diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 1d11e8ea30..ab2e3c5415 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -102,7 +102,7 @@ describe('ui-settings-general apply', () => { expect(generalLabel(c)).toBe('通用设置') expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) // The shared developer-tool control belongs to General; onboarding remains feature-owned. - expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools']) + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools', 'current-version']) expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([]) const developerRow = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'developer-tools')! const developer = (developerRow.inject as unknown as () => DeveloperToolsRowInjected)() @@ -213,7 +213,7 @@ describe('ui-settings-general apply', () => { expect(ownEntries(c, name)[0]).not.toBe(before[index]) }) expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) - expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools']) + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools', 'current-version']) // The recovered registrations still ride the locale path. const english = localeView('en', 1) const chinese = localeView('zh', 2) @@ -237,6 +237,7 @@ describe('ui-settings-general apply', () => { await c.unload(SELF) await c.flush() for (const [name] of SEATS) expect(ownEntries(c, name)).toHaveLength(0) + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([]) expect(c.ctx.slots.spec('settings.general.item')).toBeUndefined() }) }) diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 89c78fe1af..fab06a77e7 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -22,11 +22,12 @@ function derivedDocumentStore(remote: object) { const ctx = { remote } as never return new SettingsDocumentStore(ctx, new SettingsDescribeMirror(ctx)) } -import { en } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' +import { CurrentVersionRow } from '../src/client/CurrentVersionRow.tsx' import { DesktopUpdateBadge } from '../src/client/DesktopUpdateIndicator.tsx' import type { DesktopUpdateView } from '../src/client/desktop-update-bridge.ts' -afterEach(cleanup) +afterEach(() => { cleanup(); vi.unstubAllEnvs() }) // The seat's key domain is settings ∪ common; the stub answers from the // package dictionary and falls back to the key like the real chain. @@ -222,3 +223,26 @@ it('reports a failed developer-tool write and allows retry', async () => { await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) expect(screen.queryByRole('alert')).toBeNull() }) + +describe('current version', () => { + it.each([ + ['Current version: 1.2.3-rc.4', en], + ['当前版本:1.2.3-rc.4', zh], + ])('renders the localized release label %s', (expected, dictionary) => { + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4') + const translate: TriggerContentProps['t'] = (key, params) => { + let text = (dictionary as Record)[key] ?? key + for (const [name, value] of Object.entries(params ?? {})) text = text.replace(`{${name}}`, String(value)) + return text + } + render() + expect(screen.getByText(expected)).toBeTruthy() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('omits the row when a partial build has no version metadata', () => { + vi.stubEnv('DSH_CLIENT_VERSION', undefined) + const view = render() + expect(view.container.textContent).toBe('') + }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 6f0e375dcb..141c7e0474 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/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-sidebar-documentpreview/README.md -README.md: e7360434961ce480d2e97cb2ef41685b8cd8e14f -README.zh.md: 7a60e3a94b931ed50e37214a9c2c63ed5ce6cbbb +README.md: 2797d6fd6be8f429043c8de1413f90e64bda3876 +README.zh.md: a04571ed6d76ca812aa0a70ce6f3c403ad80947d diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index e736043496..2797d6fd6b 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, and common images receive complete bytes; unknown extensions use plain text. Office documents convert locally to PDF. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. +Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, and common images receive complete bytes; unknown extensions use plain text. Word and PowerPoint documents convert locally to PDF; Excel workbooks use the unsupported-preview state. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. ## Table of Contents @@ -65,7 +65,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin ## Office preview -Open `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. The [Host provider](../../document/office-to-pdf/README.md) performs local conversion; invalid files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance. +Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. `.xls` and `.xlsx` do not register a Sidebar renderer: they show the unsupported-preview message without requesting conversion and offer the system default application when the Host reports a desktop. The [Host provider](../../document/office-to-pdf/README.md) retains its spreadsheet conversion API for other consumers. Invalid supported files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance. The [Web bundle](../../bundle/web-app/README.md) mounts this package as `ui-sidebar-documentpreview`. Configure its transient Office cache through that entry's `office` settings; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-ui-sidebar-documentpreview) defines accepted values. Settings are embedded in each served page; reload the browser page after changing YAML. @@ -108,7 +108,7 @@ No direct effect; what the user reads here never enters a model request. - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. -- **Office conversion limits.** The preview does not launch native Office editors or download an engine. Binary `.doc`, `.xls`, and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). +- **Office conversion limits.** The preview does not launch native Office editors or download an engine. Excel workbooks are not previewed and instead offer the system default application on desktop Hosts. Binary `.doc` and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap. - **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; images fit the pane's width and never scroll horizontally, and HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 7a60e3a94b..a04571ed6d 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Office 文档在本地转换为 PDF。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 +在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;Excel 工作簿使用不支持预览的空态。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 ## 目录 @@ -65,7 +65,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 ## Office 预览 -将 `.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换;无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。 +将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。`.xls` 与 `.xlsx` 不注册 Sidebar 渲染器:它们不请求转换,直接显示不支持预览的提示;Host 报告存在桌面时,还提供用系统默认应用打开的控件。[Host 提供方](../../document/office-to-pdf/README.zh.md)仍为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。 [Web bundle](../../bundle/web-app/README.zh.md) 以 `ui-sidebar-documentpreview` 挂载本包。通过该条目的 `office` 设置配置临时 Office 缓存;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-ui-sidebar-documentpreview)定义可接受的值。设置注入到每个页面;修改 YAML 后重新加载浏览器页面。 @@ -108,7 +108,7 @@ Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Offi - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 -- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc`、`.xls` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 +- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。Excel 工作簿不提供预览,在桌面 Host 上改为提供用系统默认应用打开的控件。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 - **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。 - **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts index 66b24e0db1..0672b5f1ed 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts @@ -31,7 +31,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ export function apply(ctx: Context, config: Config['office']): void { const id = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office' - const extensions = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'] + const extensions = ['doc', 'docx', 'ppt', 'pptx'] ctx.effect(() => ctx.locale.register('sidebarOffice', { zh, en })) const t = ctx.locale.bind('sidebarOffice') const unavailable: ReadOfficeDocument = (_file, signal) => { diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts index 22bd4da75b..98a980f49d 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts @@ -81,17 +81,18 @@ async function harness(config: Partial = {}, missing?: 'remote } } -it.each(['remote', 'render', 'files'] as const)('keeps Office registration and guidance when %s is absent', async (missing) => { +it.each(['remote', 'render', 'files'] as const)('keeps Word and PowerPoint registration and guidance when %s is absent', async (missing) => { const h = await harness(undefined, missing) try { expect(h.locale.register).toHaveBeenCalledWith('sidebarOffice', { zh, en }) - for (const path of ['a.DOC', 'b.DOCX', 'c.XLS', 'd.xlsx', 'e.PPT', 'f.pptx']) { - expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) + for (const path of ['a.DOC', 'b.DOCX', 'c.PPT', 'd.pptx']) { + expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'ppt', 'pptx']) expect(h.registry.candidates(path)[0]!.title()).toBe(en.title) expect(h.registry.candidates(path)[0]!.loading).toBe('renderer') expect(h.registry.candidates(path)[0]).not.toHaveProperty('read') await expect(h.read(undefined, path)).rejects.toThrow(en.unavailable) } + for (const path of ['sheet.XLS', 'sheet.xlsx']) expect(h.registry.candidates(path)).toEqual([]) expect(h.render).not.toHaveBeenCalled() } finally { await h.close() } expect(h.registry.getSnapshot()).toEqual([]) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 83f8c75b1c..5885d41e4e 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: b2687d59fc6566d9ea8a0c0d4d5b16eea38282b5 -README.zh.md: 7e6c8a7ecee2fe10d5826b91c108733bb4d3746f +README.md: fdd8cb7fddb54cf73d897bb278086428d0e41214 +README.zh.md: 2090711d5f91bf1efba2f8e19d8d361f9ffb99d9 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index b2687d59fc..fdd8cb7fdd 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -51,6 +51,8 @@ The source implements no adjudication hooks and no reference codec: the pick lan Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, the forwarded `agent-preset/selected` owner event drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. The list RPC rides the plugin's root-context connection captured at registration; draft chip visuals derive from the `lexicon` scan. +Each catalog fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `skills/list`. A temporary `skillCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC; preset invalidation, connection reset, and plugin disposal also cancel a pending history wait. + ### Registration The `/client` exports are the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect. The tool row registers the `skill` wire name in ui-tool's keyed `tool.call.toolview` slot. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 7e6c8a7ece..2090711d5f 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -51,6 +51,8 @@ source 不实现任何裁决钩子,也没有引用 codec:pick 落下字面 目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,转发的 owner 事件 `agent-preset/selected` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。列表 RPC 使用插件注册时捕获的根上下文连接;草稿 chip 视觉由 `lexicon` 扫描派生。 +每次目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `skills/list`。临时的 `skillCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC;预设失效、连接重置和插件释放也会取消正在等待的历史打开。 + ### 注册 `/client` 导出接口只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。工具行把 `skill` wire 名称注册进 ui-tool 的 keyed `tool.call.toolview` slot。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 65f2dee986..2b6cd12ea2 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -25,6 +25,8 @@ * not kill the prewarm other consumers will hit, so it carries its own * abort (fired only on invalidation/teardown) while a candidates caller * with an aborted signal just returns early. + * Catalog RPCs retain an existing Client Session through completion and + * wait for its initial history open to succeed before contacting the Host. * * This browser half also owns the `skill` keyed toolview: a replay-stable * accent row derived only from each logged call/result slice. @@ -45,6 +47,13 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { SkillRow } from './SkillRow.tsx' import { en, NS, zh, type SkillKey } from './locales.ts' +declare module '@deepseek-ai/dsh-api-session-controller/client' { + interface SessionReferenceSourceMap { + /** A skill-catalog fetch waiting for initial history and its RPC result. */ + skillCatalog: unknown + } +} + declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { /** The dedicated skill tool row's copy. */ @@ -100,10 +109,20 @@ export function apply(ctx: ClientContext): void { if (existing !== undefined) return existing const abort = new AbortController() const promise = (async () => { - const result = await skills.list({ sessionId }, abort.signal) - abort.signal.throwIfAborted() - if (!result.ok) throw new Error(`skills/list failed: ${result.error.code}: ${result.error.message}`) - return result.value.skills + if (sessions.binding(sessionId) === undefined) { + throw new Error(`skill catalog requires a retained session "${sessionId}"`) + } + return sessions.using(sessionId, { source: 'skillCatalog', signal: abort.signal }, async (reference) => { + abort.signal.throwIfAborted() + const state = reference.binding.session.getSnapshot() + if (state.openState !== 'open') { + throw state.openError ?? new Error(`session "${sessionId}" is not open`) + } + const result = await skills.list({ sessionId }, abort.signal) + abort.signal.throwIfAborted() + if (!result.ok) throw new Error(`skills/list failed: ${result.error.code}: ${result.error.message}`) + return result.value.skills + }) })() const entry: CatalogFetch = { promise, abort } fetches.set(sessionId, entry) diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 189d961bcf..d45088b225 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -18,7 +18,8 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, TestRemote, TestSessions } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionFixture } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { ClientSessionContext, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { apply, inject } from '../src/client/index.ts' @@ -60,24 +61,30 @@ function providePresentation(ctx: Context): PresentationCapture { } /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */ -async function bench(list: ListFn, addressed?: SessionId) { +async function bench(list: ListFn, addressed?: SessionId, opening: Pick = {}) { const ctx = new Context() const openResource = vi.fn() ctx.provide('sidebarRight', { openResource }) let captured: InputTriggerSource | undefined ctx.provide('inputTriggers', { registerSource: (src: InputTriggerSource) => { captured = src; return () => {} } }) - ctx.provide('sessions', { - list: { getSnapshot: () => ({ byId: {} }) }, - subagentAddress: (id: SessionId) => id === addressed - ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } - : undefined, + const sessions = new TestSessions(async (action) => { await action() }, ctx) + ctx.provide('sessions', sessions) + onTestFinished(async () => { + await sessions.disposeScopes() + await ctx.fiber.dispose() }) + for (const id of ['s1', 's2', 'preview', 'first', 'second', 'child']) { + await sessions.add({ id, ...opening }) + sessions.retainFor(ctx, id === addressed + ? { parentSessionId: sid('parent'), childSessionId: addressed, mode: 'continuable' } + : sid(id)) + } const remote = new TestRemote(ctx, { skills: { list } }) providePresentation(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) onTestFinished(async () => { await fiber.dispose() }) await fiber.await() - return { ctx, source: captured!, remote, fiber, openResource } + return { ctx, source: captured!, remote, fiber, openResource, sessions } } const CATALOG: SkillRow[] = [ @@ -174,6 +181,60 @@ describe('apply', () => { }) describe('candidates: sessionId addressing', () => { + it('waits for history and retains the Session until the shared skill RPC settles', async () => { + const opened = Promise.withResolvers() + const response = Promise.withResolvers() + const list = vi.fn(() => response.promise) + const b = await bench(list, undefined, { initialOpen: () => opened.promise }) + try { + const pending = b.source.candidates(proj('s1'), req('')) + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().retainedBy.skillCatalog).toBe(1) + expect(list).not.toHaveBeenCalled() + opened.resolve(undefined) + await vi.waitFor(() => { expect(list).toHaveBeenCalledOnce() }) + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(2) + response.resolve({ ok: true, value: { skills: CATALOG } }) + await pending + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1) + } finally { + opened.resolve(undefined) + response.resolve({ ok: true, value: { skills: CATALOG } }) + } + }) + + it.each([true, false])('refuses an unsuccessful history open (reported error: %s)', async (reported) => { + const error = new RemoteError('gateway/internal', 'history unavailable', {}) + const list = vi.fn(listOk(CATALOG)) + const b = await bench(list, undefined, { snapshot: { openState: 'error', openError: reported ? error : null } }) + await expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow(reported ? 'history unavailable' : 'is not open') + expect(list).not.toHaveBeenCalled() + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1) + }) + + it('does not reopen a released Session for skill discovery', async () => { + const list = vi.fn(listOk(CATALOG)) + const b = await bench(list) + await b.sessions.binding(sid('s1'))!.ctx.fiber.dispose() + await expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow('requires a retained session') + expect(list).not.toHaveBeenCalled() + expect(b.sessions.binding(sid('s1'))).toBeUndefined() + }) + + it('releases the shared read when the plugin unloads before history is ready', async () => { + const opened = Promise.withResolvers() + const list = vi.fn(listOk(CATALOG)) + const b = await bench(list, undefined, { initialOpen: () => opened.promise }) + try { + const pending = expect(b.source.candidates(proj('s1'), req(''))).rejects.toThrow() + await b.fiber.dispose() + await pending + expect(list).not.toHaveBeenCalled() + expect(b.sessions.retainInfo(sid('s1')).getSnapshot().referenceCount).toBe(1) + } finally { + opened.resolve(undefined) + } + }) + it('lists via {sessionId} and ranks case-insensitive subsequence matches with prefixes first', async () => { const { list, payloads } = countingList() const { source } = await bench(list) @@ -406,7 +467,7 @@ describe('reference preview', () => { expect(source.openReference!(session, { ref: '/review' })).toBe(true) const candidates = source.candidates(session, req('')) expect(openResource).not.toHaveBeenCalled() - expect(list).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { expect(list).toHaveBeenCalledTimes(1) }) gate.resolve({ ok: true, value: { skills: rows } }) await candidates expect(openResource).toHaveBeenCalledExactlyOnceWith('dsh-resource://file/session/preview//skills/review/SKILL.md') diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 9bab3901c6..834e8fe5e5 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: d0dc10f17b2b83d7a605ee622ea027698ebeac77 -README.zh.md: 1019fd3e7d6fbd8b259c6e94dd830c47ebb2058e +README.md: d1451ea0deecd3d6e39f4ce8a43abdcd3a8603e8 +README.zh.md: e50985cd6934c881beecf4d9ff631af37e3a9297 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index d0dc10f17b..d1451ea0de 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -27,7 +27,7 @@ Use this package to browse every subagent conversation beneath a parent session, The session header keeps the current session title as the lineage breadcrumb and appends a `/` count trigger when its direct catalog has entries or a read has failed. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. -This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource refreshes the direct-parent catalog before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. +This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource retains the child `SessionReference` directly from its address without refreshing the parent catalog, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. ### Browsing the tree @@ -55,6 +55,8 @@ The catalog and composer behavior are specified by the [Web subagent conversatio The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts; Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row loads its initial catalog when needed. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. A row remains expandable while its catalog is absent, loading, or failed, and becomes a known leaf after a ready empty catalog. +Opening a catalog dropdown does not request its root catalog. Child-catalog expansion and failed-read retries call `refreshProjection`; shared projection-value changes update the display automatically. + Breadcrumb addresses derive from the Provider-bound Session address and loaded parent catalogs, including never-selected ancestors. ### Duration, completion, and tokens diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 1019fd3e7d..e50985cd69 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 会话页头保留当前会话 title 作为谱系面包屑,并在直接目录有子项或读取失败时追加 `/` 数量触发器。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 -本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源先刷新直接 parent 目录,再保留 child 的 `SessionReference`,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 +本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源直接根据地址保留 child 的 `SessionReference`,不刷新 parent 目录,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 ### 浏览目录 @@ -55,6 +55,8 @@ kind: "package-reference" 页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量;活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行会按需加载初始目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。 +打开目录下拉菜单不会请求其根目录。展开子节点目录或重试失败读取时调用 `refreshProjection`;共享投影值的变化会自动更新显示。 + 面包屑地址从 Provider 所绑定的 Session 地址和已加载的 parent 目录推导,也包括从未选中过的祖先。 ### 耗时、完成状态与 token diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 75e130bbaa..332ff6bbe9 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -25,7 +25,7 @@ type Catalogs = Readonly> export interface SubagentCatalogInjected { openChild: (address: SubagentAddress) => void openChildAside: (address: SubagentAddress) => void - refresh: (parentSessionId: SessionId) => void + refreshProjection: (parentSessionId: SessionId) => void } /** Full props for the session-header lineage renderer. */ @@ -42,7 +42,7 @@ interface CatalogRowsProps { level: number openChild: (address: SubagentAddress) => void openChildAside: (address: SubagentAddress) => void - refresh: (parentSessionId: SessionId) => void + refreshProjection: (parentSessionId: SessionId) => void toggleBranch: (childSessionId: SessionId) => void closeCatalog: () => void } @@ -200,7 +200,7 @@ function isKnownLeaf(catalog: SubagentCatalogSnapshot | undefined): boolean { /** Render one catalog level and recurse only through explicitly expanded rows. */ function CatalogRows({ parentSessionId, currentSessionId, catalog, catalogs, summaries, expanded, level, - openChild, openChildAside, refresh, toggleBranch, closeCatalog, t, + openChild, openChildAside, refreshProjection, toggleBranch, closeCatalog, t, }: CatalogRowsProps & { t: TranslateNS }) { const [now, setNow] = useState(() => Date.now()) const running = catalog.entries.some(entry => entry.activity === 'running') @@ -222,7 +222,7 @@ function CatalogRows({ )} @@ -580,7 +584,7 @@ function SessionTree({ label={t('section.sessions')} rowKeys={rowKeys} ready={list.phase === 'ready' && workspaceReady && !nativeDragActive} - resetKey={JSON.stringify([animationResetKey, expandedSessionGroups])} + resetKey={JSON.stringify([animationResetKey, sessionLimits])} > {groups.length === 0 && (
{t('empty.none')}
diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 21b6510a07..4d7110b1a6 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -700,6 +700,155 @@ describe('WorkspaceBrowser', () => { expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() }) + it('keeps running sessions outside every batch quota and preserves their order after collapse', () => { + const idle = Array.from({ length: 12 }, (_, index) => summary(`idle-${index + 1}`, 12 - index)) + const first = summary('running-first', 14, { running: true }) + const last = summary('running-last', 0, { running: true }) + const blank = summary('blank', 15, { blank: true }) + const items = [blank, first, ...idle, last] + mount({ + useSessions: hook(sessionState(items, { main: blank.id })), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + const expectRows = (count: number) => { + expect(screen.getAllByRole('treeitem').map(row => + within(row).getByText(/^(alpha|新会话|running-(first|last)|idle-\d+)$/u).textContent)).toEqual([ + 'alpha', '新会话', 'running-first', ...idle.slice(0, count).map(item => item.displayTitle), 'running-last', + ]) + } + expectRows(5) + fireEvent.click(screen.getByRole('button', { name: '展开其余 7 个会话' })) + expectRows(10) + fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' })) + expectRows(12) + fireEvent.click(screen.getByRole('button', { name: '收起' })) + expectRows(5) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha')) + expectRows(5) + }) + + it('reveals a hidden session when live running status starts and folds it when the run ends', () => { + const items = Array.from({ length: 12 }, (_, index) => summary(`session-${index + 1}`, 12 - index)) + const b = mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('session-12')).toBeNull() + rerender(b, { + useSessionStatus: hook(new Map([[sid('session-12'), { + running: true, pendingInteraction: undefined, completionUnread: false, + }]])), + }) + expect(screen.getByText('session-12')).toBeTruthy() + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 6 个会话' })).toBeTruthy() + rerender(b, { + useSessionStatus: hook(new Map([[sid('session-12'), { + running: false, pendingInteraction: undefined, completionUnread: true, + }]])), + }) + expect(screen.queryByText('session-12')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 7 个会话' })).toBeTruthy() + }) + + it('keeps a parent with a running child visible outside the idle-session quota', () => { + const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index)) + const child = summary('child', 0, { origin: 'subagent', running: true }) + mount({ + useSessions: hook(sessionState([...items, child], { + projectionsBySession: { + [sid('session-7')]: { + state: 'ready', error: null, + values: { subagentCatalog: [{ id: child.id, mode: 'continuable', label: 'child', createdAt: 1 }] }, + }, + }, + })), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.getByText('session-7')).toBeTruthy() + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.queryByText('child')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 1 个会话' })).toBeTruthy() + }) + + it.each([false, true])('expands 17 ordinary sessions five at a time and resets after the final partial batch (blank: %s)', (withBlank) => { + const ordinary = Array.from({ length: 17 }, (_, index) => summary(`session-${index + 1}`, 17 - index)) + const blank = summary('blank', 18, { blank: true }) + const items = withBlank ? [blank, ...ordinary] : ordinary + mount({ + useSessions: hook(sessionState(items, withBlank ? { main: blank.id } : {})), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + if (!withBlank) fireEvent.click(screen.getByText('alpha')) + const expectVisible = (count: number) => { + for (const [index, item] of ordinary.entries()) { + expect(screen.queryByText(item.displayTitle) !== null).toBe(index < count) + } + expect(screen.getAllByRole('treeitem')).toHaveLength(count + 1 + Number(withBlank)) + expect(screen.queryByText('新会话') !== null).toBe(withBlank) + } + expectVisible(5) + const labels: string[] = [] + for (const [remaining, visible] of [[12, 10], [7, 15], [2, 17]] as const) { + const overflow = screen.getByRole('button', { name: `展开其余 ${remaining} 个会话` }) + labels.push(overflow.textContent ?? '') + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + fireEvent.click(overflow) + expectVisible(visible) + } + expect(screen.queryByRole('button', { name: /展开其余/ })).toBeNull() + const collapse = screen.getByRole('button', { name: '收起' }) + labels.push(collapse.textContent ?? '') + expect(labels).toMatchInlineSnapshot(` + [ + "展开其余 12 个会话", + "展开其余 7 个会话", + "展开其余 2 个会话", + "收起", + ] + `) + fireEvent.click(collapse) + expectVisible(5) + fireEvent.click(screen.getByRole('button', { name: '展开其余 12 个会话' })) + expectVisible(10) + expect(screen.getByRole('button', { name: '展开其余 7 个会话' })).toBeTruthy() + }) + + it('keeps each Workspace batch limit independent when another Workspace expands or collapses', () => { + const alpha = Array.from({ length: 17 }, (_, index) => summary(`alpha-${index + 1}`, 17 - index)) + const beta = Array.from({ length: 11 }, (_, index) => summary(`beta-${index + 1}`, 11 - index)) + mount({ + useSessions: hook(sessionState([...alpha, ...beta])), + useWorkspaces: hook(workspaceState([ + workspace('alpha', alpha.map(item => item.id)), + workspace('beta', beta.map(item => item.id)), + ])), + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('beta')) + fireEvent.click(screen.getByRole('button', { name: '展开其余 12 个会话' })) + expect(screen.getByText('alpha-10')).toBeTruthy() + expect(screen.queryByText('alpha-11')).toBeNull() + expect(screen.queryByText('beta-6')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '展开其余 6 个会话' })) + expect(screen.getByText('beta-10')).toBeTruthy() + expect(screen.queryByText('beta-11')).toBeNull() + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('alpha-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 12 个会话' })).toBeTruthy() + expect(screen.getByText('beta-10')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '展开其余 1 个会话' })) + expect(screen.getByText('beta-11')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '收起' })) + expect(screen.queryByText('beta-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 6 个会话' })).toBeTruthy() + expect(screen.getByRole('button', { name: '展开其余 12 个会话' })).toBeTruthy() + }) + it('keeps the blank New Session outside the five-row folding quota', () => { const ordinary = Array.from({ length: 6 }, (_, index) => summary(`session-${index + 1}`, 6 - index)) const blank = summary('blank', 7, { blank: true }) @@ -756,6 +905,36 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('session-6')).toBeNull() }) + it('reorders a newly exposed batch row while preserving the blank and hidden tail', () => { + const ordinary = Array.from({ length: 12 }, (_, index) => summary(`session-${index + 1}`, 12 - index)) + const blank = summary('blank', 13, { blank: true }) + const b = mount({ + useSessions: hook(sessionState([blank, ...ordinary], { main: blank.id })), + useWorkspaces: hook(workspaceState([workspace('alpha', [blank.id, ...ordinary.map(item => item.id)])])), + }) + fireEvent.click(screen.getByRole('button', { name: '展开其余 7 个会话' })) + const source = screen.getByText('session-10').closest('[role="treeitem"]') as HTMLElement + const target = screen.getByText('session-6').closest('[role="treeitem"]') as HTMLElement + target.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(target, 'drop', 105) + + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual([ + 'blank', 'session-1', 'session-2', 'session-3', 'session-4', 'session-5', + 'session-10', 'session-6', 'session-7', 'session-8', 'session-9', 'session-11', 'session-12', + ]) + expect(screen.getAllByRole('treeitem').slice(1).map(row => row.querySelector('[class*="title"]')?.textContent)).toEqual([ + '新会话', 'session-1', 'session-2', 'session-3', 'session-4', 'session-5', + 'session-10', 'session-6', 'session-7', 'session-8', 'session-9', + ]) + expect(screen.queryByText('session-11')).toBeNull() + expect(screen.queryByText('session-12')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() + }) + it('discards manual positions on recency selection and switches to Manual on drag', async () => { const initial = sessionState([summary('one', 3), summary('two', 2)]) const b = mount({ @@ -1299,6 +1478,30 @@ describe('WorkspaceBrowser', () => { expect(b.store.getSnapshot().groupExpansion).not.toHaveProperty('stale') }) + it('reveals every session when a search result is beyond the initial five-row quota', () => { + const items = Array.from({ length: 17 }, (_, index) => summary(`session-${index + 1}`, 17 - index)) + const b = mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + const input = screen.getByPlaceholderText('搜索会话名称') + fireEvent.change(input, { target: { value: 'session-11' } }) + fireEvent.click(screen.getByRole('treeitem')) + + expect(b.props.open).toHaveBeenCalledWith(sid('session-11')) + expect(input.value).toBe('') + for (const item of items) expect(screen.getByText(item.displayTitle)).toBeTruthy() + expect(screen.queryByRole('button', { name: /展开其余/ })).toBeNull() + expect(scrollIntoView.mock.instances.at(-1)).toBe(screen.getByText('session-11').closest('[role="treeitem"]')) + fireEvent.click(screen.getByRole('button', { name: '收起' })) + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.queryByText('session-11')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '展开其余 12 个会话' })) + expect(screen.getByText('session-10')).toBeTruthy() + expect(screen.queryByText('session-11')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 7 个会话' })).toBeTruthy() + }) + it('keeps the bounded group projection when the revealed result is already within it', () => { const sessions = sessionState([ summary('target', 6, { displayTitle: 'Needle session' }), diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index a8e10756e4..30c5a0a345 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -300,7 +300,7 @@ describe('UiWorkspaceService', () => { b.uiWorkspace.openSession(sid('target')) expect(b.selectPanel).toHaveBeenCalledWith(null) expect(b.sessions.retain).toHaveBeenCalledWith(sid('target'), { source: 'mainView' }) - expect(b.sessions.refreshProjections).toHaveBeenCalledWith(sid('target')) + expect(b.sessions.refreshProjections).not.toHaveBeenCalled() }) it('keeps the current panel when retaining the target fails', () => { @@ -827,10 +827,7 @@ describe('UiWorkspaceService', () => { }) expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(address, { source: 'mainView' }) - expect(b.sessions.refreshProjections.mock.calls).toEqual([ - [address.parentSessionId], - [address.childSessionId], - ]) + expect(b.sessions.refreshProjections).not.toHaveBeenCalled() }) it('persists a catalog-resolved address after string subagent navigation', () => { diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 196e093462..914a388b83 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/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/experimental/client-ui-agent-team/README.md -README.md: 34e89cfe92db9656cf2fc948e154627c0d5f9407 -README.zh.md: e01cbbb534282fb2c6731579fc753954bcde314b +README.md: 6d45e618d937cfadcec238c6d0d7dcf3e8747501 +README.zh.md: 879b1784bbe3d2ef6f3f66ab66b9dda7c820a0ed diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 34e89cfe92..6d45e618d9 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate refreshes the existing direct-child catalog and opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Manage the task board diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index e01cbbb534..879b1784bb 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查并导航 roster -打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统刷新既有直接 child catalog,并打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 管理任务板 diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index f0a25a790e..a32e2dc24e 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -44,7 +44,7 @@ export interface TeamActionInjected { writeScopes?: string[] owner?: string }) => Promise - openTeammate: (sessionId: SessionId, member: TeamRosterMember) => Promise + openTeammate: (sessionId: SessionId, member: TeamRosterMember) => void } /** Full props of the Team conversation-header action. */ @@ -312,7 +312,11 @@ export function TeamAction({ disabled={member.role === 'lead' || member.status === 'failed' || member.status === 'provisioning'} title={member.role === 'teammate' ? t('open') : undefined} onClick={() => { - void openTeammate(sessionId, member).catch((reason: unknown) => { setError(String(reason)) }) + try { + openTeammate(sessionId, member) + } catch (reason) { + setError(String(reason)) + } }} > diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index 7108c67e8d..235aa9b7ac 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -51,14 +51,10 @@ function registerUi(ctx: ClientContext): void { ...owner === undefined ? {} : { owner }, }) }, - async openTeammate(sessionId: SessionId, member: TeamRosterMember): Promise { + openTeammate(sessionId: SessionId, member: TeamRosterMember): void { if (member.role !== 'teammate') return const parentSessionId = leadSessionId(sessionId) - await sessions.refreshProjections(parentSessionId) if ((sessions.retainInfo(sessionId).getSnapshot().retainedBy.mainView ?? 0) === 0) return - const catalog = sessions.list.getSnapshot().projectionsBySession[parentSessionId] - const child = catalog?.values.subagentCatalog?.find(entry => entry.id === member.id) - if (child === undefined) return ctx.uiWorkspace.openSession({ parentSessionId, childSessionId: member.id, diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index 5619b5dbd7..74a7e9a729 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -24,7 +24,6 @@ async function bench(options: { conflict?: boolean registrationFailure?: boolean remoteFailure?: 'view' | 'update' - refreshGate?: Promise catalog?: 'missing' | 'empty' } = {}) { const ctx = new Context() @@ -110,7 +109,7 @@ async function bench(options: { : undefined, refreshProjections: (id: SessionId) => { navigation.push(['refresh', id]) - return options.refreshGate ?? Promise.resolve() + return Promise.resolve() }, retainInfo: (id: SessionId) => ({ getSnapshot: () => ({ @@ -185,7 +184,7 @@ describe('ui-team browser plugin', () => { ]) expect(b.calls.at(-1)?.args[1]).toMatchObject({ owner: 'worker' }) - await actions.openTeammate(SESSION, { + actions.openTeammate(SESSION, { id: SESSION, name: 'lead', role: 'lead', @@ -238,7 +237,7 @@ describe('ui-team browser plugin', () => { }) }) - it('refreshes the descriptor catalog before opening a continuable teammate address', async () => { + it('opens a continuable teammate address without refreshing the parent catalog', async () => { const b = await bench() const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() const member: TeamRosterMember = { @@ -248,9 +247,8 @@ describe('ui-team browser plugin', () => { status: 'inactive', diagnostics: [], } - await actions.openTeammate(SESSION, member) + actions.openTeammate(SESSION, member) expect(b.navigation).toEqual([ - ['refresh', SESSION], ['open', { parentSessionId: SESSION, childSessionId: CHILD, @@ -263,7 +261,7 @@ describe('ui-team browser plugin', () => { const b = await bench({ addressed: true }) const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() await actions.load(CHILD) - await actions.openTeammate(CHILD, { + actions.openTeammate(CHILD, { id: CHILD, name: 'worker', role: 'teammate', @@ -272,7 +270,6 @@ describe('ui-team browser plugin', () => { }) expect(b.calls[0]).toEqual({ method: 'agentTeams/view', args: [SESSION] }) expect(b.navigation).toEqual([ - ['refresh', SESSION], ['open', { parentSessionId: SESSION, childSessionId: CHILD, @@ -281,36 +278,34 @@ describe('ui-team browser plugin', () => { ]) }) - it('does not open a teammate after navigation switches during catalog refresh', async () => { - const refresh = Promise.withResolvers() - const b = await bench({ refreshGate: refresh.promise }) + it('does not open a teammate from a conversation outside the main view', async () => { + const b = await bench() const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - const opening = actions.openTeammate(SESSION, { + b.select('other-session' as SessionId) + actions.openTeammate(SESSION, { id: CHILD, name: 'worker', role: 'teammate', status: 'inactive', diagnostics: [], }) - expect(b.navigation).toEqual([['refresh', SESSION]]) - b.select('other-session' as SessionId) - refresh.resolve(undefined) - await opening - expect(b.navigation).toEqual([['refresh', SESSION]]) + expect(b.navigation).toEqual([]) }) - it('does not open a teammate missing from the parent catalog', async () => { + it('opens a teammate when the parent catalog is missing or empty', async () => { for (const catalog of ['missing', 'empty'] as const) { const b = await bench({ catalog }) const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - await actions.openTeammate(SESSION, { + actions.openTeammate(SESSION, { id: CHILD, name: 'worker', role: 'teammate', status: 'inactive', diagnostics: [], }) - expect(b.navigation).toEqual([['refresh', SESSION]]) + expect(b.navigation).toEqual([ + ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], + ]) await b.fiber.dispose() } }) diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 52136ffc1e..f29533fd7c 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -84,7 +84,7 @@ function actions(overrides: Partial = {}): TeamActionInjecte ok: true, value: { ok: true, value: { ...task, revision: 2 } }, }), - openTeammate: () => Promise.resolve(), + openTeammate: () => {}, ...overrides, } } @@ -119,7 +119,7 @@ describe('TeamAction', () => { }) it('loads roster/task diagnostics on open and navigates a healthy teammate', async () => { - const openTeammate = vi.fn(() => Promise.resolve()) + const openTeammate = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) const worker = await screen.findByRole('button', { name: /worker/u }) @@ -495,7 +495,7 @@ describe('TeamAction', () => { ], } const load = vi.fn(() => Promise.resolve({ ok: true as const, value: richView })) - const openTeammate = vi.fn(() => Promise.reject(new Error('navigation failed'))) + const openTeammate = vi.fn(() => { throw new Error('navigation failed') }) render() fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) expect(await screen.findByText('provider failed')).toBeTruthy() diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index d6fa266c66..96ccbc1151 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md -README.md: ce98accac7abb2241f79b16e596e68e1d1fbdd95 -README.zh.md: cc3cf583bb77ed0d3e9611f8d8543f39b9ea9d18 +README.md: f4e412e25e70fe74d66018bf3a0cb66a550603e4 +README.zh.md: 5ae13f0ae49961cfe67d5b567999f500fe7f6f7a diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index ce98accac7..f4e412e25e 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -30,7 +30,7 @@ Three build artifacts and one source-owned process layer: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The `node:util` JSON-data comparator compares own enumerable properties, preserving Node admission decisions for recorded fields such as `__proto__`. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file, and `FileHandle.chmod()` updates the opened file identity; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount. The callback `fs.realpath.native` alias resolves within the mounted VFS and supports `util.promisify`. - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. -- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. +- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. A Remote stream's uplink crosses as `stream-uplink-item` / `stream-uplink-end` frames into an unbounded `TunnelUplink` that drops items arriving after `end`: the page and its worker are one trusted same-origin boundary, so the Host inbox cap does not apply. Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The [packer](../webworker-packer/README.md) prepares current-generation successors from committed historical logs in Node; those source logs remain byte-identical in the overlay. The fixture generator owns the acceptance test's current projection cache. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index cc3cf583bb..5ae13f0ae4 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -30,7 +30,7 @@ kind: "package-library" - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务 seam 交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。`node:util` 的 JSON 数据比较器检查可枚举的自有属性,使 `__proto__` 等记录字段的准入判定与 Node 一致。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同,`FileHandle.chmod()` 则更新打开文件身份的权限;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。回调式 `fs.realpath.native` 别名在已挂载的 VFS 内解析路径,并支持 `util.promisify`。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 -- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 +- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。Remote 流的上行以 `stream-uplink-item` / `stream-uplink-end` 帧进入没有上限的 `TunnelUplink`,`end` 之后到达的项被丢弃:页面与其 worker 是同一个可信的同源边界,Host 的 inbox 上限在此不适用。 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。[打包器](../webworker-packer/README.zh.md)在 Node 中根据已提交的历史日志准备当前代后继文件;这些源日志在 overlay 中逐字节保持不变。fixture 生成器负责验收测试的当前投影缓存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index a569e48276..e6a2a18537 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -13,6 +13,8 @@ import type { TunnelRequestId, TunnelStreamEndFrame, TunnelStreamErrorFrame, + TunnelStreamUplinkEndFrame, + TunnelStreamUplinkItemFrame, TunnelStreamItemFrame, TunnelStreamOpenFrame, } from '../transport/frames.ts' @@ -33,6 +35,19 @@ interface PendingUnary { type LogicalStreamFrame = TunnelStreamItemFrame | TunnelStreamEndFrame | TunnelStreamErrorFrame +interface UplinkPump { + /** Settles once the pump has stopped posting frames. */ + readonly done: Promise + /** Interrupt the pump, including a read blocked on the caller's iterator, and return that iterator at once. */ + stop(): void +} + +/** One open logical stream: its downlink inbox and, once the open frame is posted, its uplink pump. */ +interface LogicalStream { + readonly inbox: LogicalStreamInbox + pump: UplinkPump | undefined +} + interface TunnelStreamFailureMarker { readonly kind: 'remote' | 'carrier' readonly code?: string @@ -143,7 +158,7 @@ export class WorkerTunnel { private nextId = 1 private readonly unary = new Map() private readonly bodyStreams = new Map>() - private readonly logicalStreams = new Map() + private readonly logicalStreams = new Map() /** * In-flight request descriptions, so a refusal names what was refused. * @@ -178,7 +193,7 @@ export class WorkerTunnel { kind: 'carrier', message: `web-preview tunnel: worker failed: ${event.message}`, }, { cause: reason }) - for (const inbox of this.logicalStreams.values()) inbox.fail(failure) + for (const { inbox } of this.logicalStreams.values()) inbox.fail(failure) this.logicalStreams.clear() for (const release of this.releases.values()) release() this.releases.clear() @@ -234,17 +249,19 @@ export class WorkerTunnel { * @param endpoint - canonical Gateway Remote endpoint. * @param payload - decoded endpoint payload. * @param signal - logical-stream cancellation. + * @param uplink - the stream's uplink, posted as `stream-uplink-item` frames and closed with `stream-uplink-end`. * @returns decoded stream values from the worker Host. */ - async *open(endpoint: string, payload: unknown, signal: AbortSignal): AsyncGenerator { + async *open(endpoint: string, payload: unknown, signal: AbortSignal, uplink?: AsyncIterable): AsyncGenerator { signal.throwIfAborted() const id = this.nextId++ const inbox = new LogicalStreamInbox() + const stream: LogicalStream = { inbox, pump: undefined } let opened = false let terminal = false const onAbort = (): void => { inbox.fail(signal.reason) } signal.addEventListener('abort', onAbort, { once: true }) - this.logicalStreams.set(id, inbox) + this.logicalStreams.set(id, stream) this.inFlight.set(id, `STREAM ${endpoint}`) try { const frame: TunnelStreamOpenFrame = { t: 'stream-open', id, endpoint, payload } @@ -257,6 +274,7 @@ export class WorkerTunnel { message: `web-preview tunnel: failed to open Remote stream ${endpoint}`, }, { cause }) } + if (uplink !== undefined) stream.pump = this.pumpUplink(id, uplink, signal, inbox) while (true) { const response = await inbox.next() signal.throwIfAborted() @@ -272,10 +290,67 @@ export class WorkerTunnel { signal.removeEventListener('abort', onAbort) this.logicalStreams.delete(id) this.inFlight.delete(id) + stream.pump?.stop() if (opened && !terminal) this.abortWorkerOperation(id) + if (stream.pump !== undefined) await stream.pump.done } } + /** + * Post the caller's uplink items for one logical stream. A failing uplink + * fails the downlink, and the enclosing `open` then aborts the worker side. + * `stop()` interrupts a pump blocked on `uplink.next()` and returns the + * caller's iterator at once, so a handle's queue closes and `send()` throws + * from then on; a generator blocked in `next()` completes that return only + * once it yields, so it is not awaited. + */ + private pumpUplink( + id: TunnelRequestId, + uplink: AsyncIterable, + signal: AbortSignal, + inbox: LogicalStreamInbox, + ): UplinkPump { + const interrupt = Promise.withResolvers>() + const iterator = uplink[Symbol.asyncIterator]() + const state = { active: true, released: false } + const release = (): void => { + if (state.released) return + state.released = true + void Promise.resolve(iterator.return?.()).catch(() => undefined) + } + const done = this.forwardUplink(id, iterator, interrupt.promise, () => state.active && !signal.aborted) + .then((exhausted) => { state.released ||= exhausted }, (error: unknown) => { inbox.fail(error) }) + .then(release) + return { + done, + stop: () => { + state.active = false + interrupt.resolve({ value: undefined, done: true }) + release() + }, + } + } + + /** + * Post items until the caller's iterator ends, the pump is stopped, or the page aborts the stream. + * @returns whether the caller's iterator was exhausted and the uplink end posted. + */ + private async forwardUplink( + id: TunnelRequestId, + iterator: AsyncIterator, + interrupt: Promise>, + isActive: () => boolean, + ): Promise { + for (;;) { + const next = await Promise.race([iterator.next(), interrupt]) + if (!isActive()) return false + if (next.done === true) break + this.worker.postMessage({ t: 'stream-uplink-item', id, value: next.value } satisfies TunnelStreamUplinkItemFrame) + } + this.worker.postMessage({ t: 'stream-uplink-end', id } satisfies TunnelStreamUplinkEndFrame) + return true + } + /** * Read the pre-cordis boot payload (the injection table). * @returns The payload the page applies before the client tree loads. @@ -472,7 +547,11 @@ export class WorkerTunnel { case 'stream-item': case 'stream-end': case 'stream-error': { - this.logicalStreams.get(frame.id)?.push(frame) + const stream = this.logicalStreams.get(frame.id) + if (stream === undefined) return + stream.inbox.push(frame) + // A terminal frame ends the uplink now, not on the consumer's next read. + if (frame.t !== 'stream-item') stream.pump?.stop() return } default: { diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index 467f915902..1fff274cfd 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -27,7 +27,12 @@ export { interface ClientTransportGlobal { __DSH_TRANSPORT__?: { fetch: TunnelFetch - openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable + openStream: ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + uplink?: AsyncIterable, + ) => AsyncIterable loadBundle: (url: string) => Promise /** The page spawned the worker the Host runs in, so the page owns it. */ ownsHost: boolean @@ -151,7 +156,7 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn const payload = await tunnel.bootPayload() ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = { fetch: (input, init) => tunnel.fetch(input, init), - openStream: (endpoint, payload, signal) => tunnel.open(endpoint, payload, signal), + openStream: (endpoint, payload, signal, uplink) => tunnel.open(endpoint, payload, signal, uplink), loadBundle: (url: string) => tunnel.loadBundle(url), // The host lives in a worker this page spawned: the page owns it, so // the privileged surface stays reachable off loopback authorities. diff --git a/packages/experimental/webworker-runtime/src/index.ts b/packages/experimental/webworker-runtime/src/index.ts index fd3e3f563b..9bb306433c 100644 --- a/packages/experimental/webworker-runtime/src/index.ts +++ b/packages/experimental/webworker-runtime/src/index.ts @@ -11,8 +11,8 @@ export { type TunnelAbortFrame, type TunnelInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId, type TunnelResponseChunkFrame, type TunnelResponseEndFrame, type TunnelResponseErrorFrame, type TunnelResponseFrame, type TunnelResponseHeadFrame, - type TunnelStreamEndFrame, type TunnelStreamErrorFrame, type TunnelStreamItemFrame, - type TunnelStreamOpenFrame, + type TunnelStreamEndFrame, type TunnelStreamErrorFrame, type TunnelStreamUplinkEndFrame, + type TunnelStreamUplinkItemFrame, type TunnelStreamItemFrame, type TunnelStreamOpenFrame, } from './transport/frames.ts' export { DEFAULT_CONDITIONS, requireActiveModuleLoader, setActiveModuleLoader, WorkerModuleLoader, diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index 01d399d4e8..744338be1d 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -25,6 +25,19 @@ export interface TunnelStreamOpenFrame { readonly payload: unknown } +/** One uplink item for an open Remote stream; `value` absent and `undefined` are equivalent. */ +export interface TunnelStreamUplinkItemFrame { + readonly t: 'stream-uplink-item' + readonly id: TunnelRequestId + readonly value?: unknown +} + +/** Page-side half-close of a Remote stream's uplink. */ +export interface TunnelStreamUplinkEndFrame { + readonly t: 'stream-uplink-end' + readonly id: TunnelRequestId +} + /** Page-side cancellation of an in-flight request or stream. */ export interface TunnelAbortFrame { readonly t: 'abort' @@ -47,6 +60,8 @@ export type TunnelInboundFrame = | TunnelInitFrame | TunnelRequestFrame | TunnelStreamOpenFrame + | TunnelStreamUplinkItemFrame + | TunnelStreamUplinkEndFrame | TunnelAbortFrame /** Complete response for unary requests and static files. */ @@ -153,6 +168,8 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { throw new Error(`webworker tunnel: frame has no usable id: ${JSON.stringify(frame.id)}`) } if (frame.t === 'abort') return { t: 'abort', id } + if (frame.t === 'stream-uplink-end') return { t: 'stream-uplink-end', id } + if (frame.t === 'stream-uplink-item') return { t: 'stream-uplink-item', id, value: frame.value } if (frame.t === 'stream-open') { if (typeof frame.endpoint !== 'string' || frame.endpoint.length === 0) { throw new Error(`webworker tunnel: stream ${String(id)} needs a non-empty endpoint`) diff --git a/packages/experimental/webworker-runtime/src/transport/tunnel.ts b/packages/experimental/webworker-runtime/src/transport/tunnel.ts index dc6608f975..b94f927a9d 100644 --- a/packages/experimental/webworker-runtime/src/transport/tunnel.ts +++ b/packages/experimental/webworker-runtime/src/transport/tunnel.ts @@ -91,10 +91,11 @@ export interface TunnelSeams { readonly directFetch: (request: Request) => Promise /** Boot payload for `GET /__boot__`: the structured index injection table. */ readonly bootPayload: () => unknown - /** Open one decoded Gateway Remote stream without another network carrier. */ + /** Open one decoded Gateway Remote stream without another network carrier; `uplink` carries the page's items. */ readonly openStream: ( endpoint: string, payload: unknown, + uplink: AsyncIterable, signal: AbortSignal, ) => Promise> /** Convert a Gateway stream failure to stable Client fields. */ @@ -173,6 +174,54 @@ class BufferedSink { } } +/** Uplink items of one worker-local logical stream, read once by the Host method as its `uplink`. */ +class TunnelUplink implements AsyncIterable, AsyncIterator { + private readonly items: unknown[] = [] + private ended = false + private closed = false + private wake: (() => void) | undefined + + push(value: unknown): void { + if (this.ended || this.closed) return + this.items.push(value) + this.signal() + } + + /** Page half-close or stream end; buffered items still drain. */ + end(): void { + this.ended = true + this.signal() + } + + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + async next(): Promise> { + while (true) { + if (this.closed) return { value: undefined, done: true } + if (this.items.length > 0) return { value: this.items.shift(), done: false } + if (this.ended) return { value: undefined, done: true } + if (this.wake !== undefined) throw new Error('webworker tunnel: stream uplink has one pending read') + await new Promise((resolve) => { this.wake = resolve }) + } + } + + /** The Host stopped reading: buffered and later items are dropped. */ + return(): Promise> { + this.closed = true + this.items.length = 0 + this.signal() + return Promise.resolve({ value: undefined, done: true }) + } + + private signal(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } +} + /** One tunnel per worker; wire {@link TunnelServer.handleMessage} to `onmessage` first. */ export class TunnelServer { private readonly port: TunnelPort @@ -181,6 +230,8 @@ export class TunnelServer { private readonly unaryApiLane: 'route' | 'direct' private readonly queue: QueuedFrame[] = [] private readonly inFlight = new Map() + /** Uplinks of accepted `stream-open` frames, buffering items that arrive before or while the stream serves. */ + private readonly uplinks = new Map() private seams: TunnelSeams | undefined private failure: string | undefined private listener: RequestListener | undefined @@ -203,9 +254,18 @@ export class TunnelServer { // one reaching a live server is a client double-connect. throw new Error('webworker tunnel: duplicate init frame; the tunnel is already open') } + if (frame.t === 'stream-uplink-item') { + this.uplinks.get(frame.id)?.push(frame.value) + return + } + if (frame.t === 'stream-uplink-end') { + this.uplinks.get(frame.id)?.end() + return + } if (frame.t === 'abort') { this.inFlight.get(frame.id)?.abort() this.inFlight.delete(frame.id) + this.dropUplink(frame.id) // A request still parked in the boot queue must not run after its // caller gave up; serve() would otherwise execute it post-boot. const queued = this.queue.findIndex(request => request.id === frame.id) @@ -213,6 +273,7 @@ export class TunnelServer { return } if (this.failure !== undefined) { this.refuse(frame, this.failure); return } + if (frame.t === 'stream-open') this.uplinks.set(frame.id, new TunnelUplink()) if (this.seams === undefined) { this.queue.push(frame) return @@ -247,6 +308,7 @@ export class TunnelServer { private refuse(frame: QueuedFrame, message: string): void { if (frame.t === 'stream-open') { + this.dropUplink(frame.id) this.send({ t: 'stream-error', id: frame.id, @@ -276,10 +338,11 @@ export class TunnelServer { return } const seams = this.seams + const uplink = this.uplinks.get(frame.id) ?? new TunnelUplink() const controller = new AbortController() this.inFlight.set(frame.id, { abort: () => { controller.abort() } }) try { - const source = await seams.openStream(frame.endpoint, frame.payload, controller.signal) + const source = await seams.openStream(frame.endpoint, frame.payload, uplink, controller.signal) for await (const value of source) { if (controller.signal.aborted) return this.send({ t: 'stream-item', id: frame.id, value }) @@ -296,9 +359,17 @@ export class TunnelServer { } } finally { this.inFlight.delete(frame.id) + this.dropUplink(frame.id) } } + /** The stream is over: settle any Host read still waiting on its uplink and stop buffering. */ + private dropUplink(id: TunnelRequestId): void { + const uplink = this.uplinks.get(id) + this.uplinks.delete(id) + uplink?.end() + } + private sinkFor(id: TunnelRequestId): ResponseSink { const send = this.send.bind(this) const inFlight = this.inFlight diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index 70b70ac9ed..3c96174f8d 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -256,7 +256,9 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { tunnel.serve({ directFetch: (request: Request) => handler.fetch(request), bootPayload: () => readBootPayload(ctx), - openStream: typertGateway.wireStream.open, + // The worker tree serves its own page: every stream speaks for the operator. + openStream: (endpoint, payload, uplink, signal) => + typertGateway.wireStream.open(endpoint, payload, uplink, undefined, signal), streamFailure: typertGateway.wireStream.failure, }) } catch (reason) { diff --git a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts index 4113bfd25b..0ee1b9f94f 100644 --- a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts @@ -21,6 +21,17 @@ describe('tunnel init frame', () => { }) }) +describe('tunnel uplink frames', () => { + it('parses uplink items with and without a value and the uplink end', () => { + expect(parseInboundFrame({ t: 'stream-uplink-item', id: 7, value: { line: 'ls' } })) + .toEqual({ t: 'stream-uplink-item', id: 7, value: { line: 'ls' } }) + expect(parseInboundFrame({ t: 'stream-uplink-item', id: 'seven' })) + .toEqual({ t: 'stream-uplink-item', id: 'seven', value: undefined }) + expect(parseInboundFrame({ t: 'stream-uplink-end', id: 7 })).toEqual({ t: 'stream-uplink-end', id: 7 }) + expect(() => parseInboundFrame({ t: 'stream-uplink-end' })).toThrow('frame has no usable id') + }) +}) + describe('tunnel request bodies', () => { it('accepts ArrayBuffer, Blob, and ReadableStream bodies and rejects other values', () => { const bytes = Uint8Array.of(1, 2).buffer diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index c309e99e09..2c5ae1545d 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -276,3 +276,92 @@ function stubWorker(): { dshRemoteStreamFailure: { kind: 'carrier' }, }) } + +// A stream posts each uplink item, then the uplink end, under the stream's id. +{ + const { worker, sent, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const uplink = (async function *(): AsyncGenerator { + yield 'a' + yield 'b' + })() + const stream = tunnel.open('job/attach', { args: {} }, new AbortController().signal, uplink)[Symbol.asyncIterator]() + const first = stream.next() + await settled(() => sent.length === 4) + check('a stream posts uplink items and the uplink end', sent, [ + { t: 'stream-open', id: 1, endpoint: 'job/attach', payload: { args: {} } }, + { t: 'stream-uplink-item', id: 1, value: 'a' }, + { t: 'stream-uplink-item', id: 1, value: 'b' }, + { t: 'stream-uplink-end', id: 1 }, + ]) + deliver({ t: 'stream-item', id: 1, value: 'echo:a' }) + check('the downlink still yields Host items', await first, { value: 'echo:a', done: false }) + const ended = stream.next() + deliver({ t: 'stream-end', id: 1 }) + check('a duplex stream ends without an abort frame', [await ended, sent.length], [{ done: true, value: undefined }, 4]) +} + +// A failing uplink fails the downlink and aborts the worker side of the stream. +{ + const { worker, sent } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const uplink = (async function *(): AsyncGenerator { + yield 'a' + throw new Error('uplink exploded') + })() + const failure = await tunnel.open('job/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]().next() + .then(() => 'resolved', (error: unknown) => (error as Error).message) + check('an uplink failure fails the downlink', failure, 'uplink exploded') + check('an uplink failure aborts the worker stream', sent.map(frame => frame.t), ['stream-open', 'stream-uplink-item', 'abort']) +} + +// The downlink ending first stops the uplink pump and releases the caller's iterator. +{ + const { worker, sent, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + let released = false + const uplink: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: async (): Promise> => { + released = true + return { value: undefined, done: true } + }, + }), + } + const stream = tunnel.open('job/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]() + const ended = stream.next() + deliver({ t: 'stream-end', id: 1 }) + check('the downlink ends while the uplink is idle', await ended, { done: true, value: undefined }) + await settled(() => released) + check('the caller iterator is released without an uplink end', [released, sent.map(frame => frame.t)], [true, ['stream-open']]) +} + +// A terminal frame releases the caller's uplink iterator in the receive path, before the consumer reads it. +{ + const { worker, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + let released = false + const uplink: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: async (): Promise> => { + released = true + return { value: undefined, done: true } + }, + }), + } + const stream = tunnel.open('job/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]() + const ended = stream.next() + deliver({ t: 'stream-end', id: 1 }) + check('a terminal frame releases the uplink before the consumer reads', released, true) + check('the downlink then ends', await ended, { done: true, value: undefined }) +} + +/** Wait, in small hops, for a condition that a concurrent pump settles. */ +async function settled(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 200 && !condition(); attempt += 1) { + await new Promise((resolve) => { setTimeout(resolve, 1) }) + } + if (!condition()) throw new Error('fixture condition did not settle') +} diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts index 7ca4ba6260..68c956e467 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts @@ -137,8 +137,8 @@ describe('worker tunnel logical streams', () => { }) expect(frames).toEqual([]) - server.serve(seams(async (endpoint, payload, signal) => { - seen.push(endpoint, payload, signal) + server.serve(seams(async (endpoint, payload, uplink, signal) => { + seen.push(endpoint, payload, uplink, signal) return (async function *(): AsyncGenerator { yield { type: 'baseline' } yield { type: 'event', seq: 1 } @@ -155,15 +155,103 @@ describe('worker tunnel logical streams', () => { expect(seen).toEqual([ 'session/follow', { args: { sessionId: 'session-1' } }, + expect.any(Object), expect.any(AbortSignal), ]) }) + it('buffers uplink items until the Host reads them and ends the uplink on half-close', async () => { + const { server, frames } = harness() + server.handleMessage({ t: 'stream-open', id: 6, endpoint: 'job/attach', payload: { args: {} } }) + server.handleMessage({ t: 'stream-uplink-item', id: 6, value: 'a' }) + server.serve(seams(async (_endpoint, _payload, uplink) => (async function *(): AsyncGenerator { + for await (const item of uplink) yield `echo:${String(item)}` + })())) + await vi.waitFor(() => { expect(frames).toEqual([{ t: 'stream-item', id: 6, value: 'echo:a' }]) }) + + server.handleMessage({ t: 'stream-uplink-item', id: 6, value: 'b' }) + server.handleMessage({ t: 'stream-uplink-item', id: 99, value: 'no such stream' }) + server.handleMessage({ t: 'stream-uplink-end', id: 99 }) + server.handleMessage({ t: 'stream-uplink-end', id: 6 }) + await vi.waitFor(() => { + expect(frames).toEqual([ + { t: 'stream-item', id: 6, value: 'echo:a' }, + { t: 'stream-item', id: 6, value: 'echo:b' }, + { t: 'stream-end', id: 6 }, + ]) + }) + }) + + it('drops uplink items once the Host stops reading them', async () => { + const { server, frames } = harness() + const outcome = Promise.withResolvers() + server.serve(seams(async (_endpoint, _payload, uplink) => { + const iterator = uplink[Symbol.asyncIterator]() + return (async function *(): AsyncGenerator { + yield 'ready' + const first = await iterator.next() + await iterator.return?.() + outcome.resolve({ first, afterReturn: await iterator.next() }) + })() + })) + server.handleMessage({ t: 'stream-open', id: 7, endpoint: 'job/attach', payload: {} }) + await vi.waitFor(() => { expect(frames).toContainEqual({ t: 'stream-item', id: 7, value: 'ready' }) }) + server.handleMessage({ t: 'stream-uplink-item', id: 7, value: 'one' }) + server.handleMessage({ t: 'stream-uplink-item', id: 7, value: 'dropped' }) + await expect(outcome.promise).resolves.toEqual({ + first: { value: 'one', done: false }, + afterReturn: { value: undefined, done: true }, + }) + await vi.waitFor(() => { expect(frames).toContainEqual({ t: 'stream-end', id: 7 }) }) + }) + + it('ends a pending uplink read when the page aborts the stream', async () => { + const { server } = harness() + const read = Promise.withResolvers() + const opened = Promise.withResolvers() + server.serve(seams(async (_endpoint, _payload, uplink, signal) => { + uplink[Symbol.asyncIterator]().next().then(read.resolve, read.resolve) + opened.resolve(undefined) + return (async function *(): AsyncGenerator { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + })() + })) + server.handleMessage({ t: 'stream-open', id: 8, endpoint: 'job/attach', payload: {} }) + await opened.promise + server.handleMessage({ t: 'abort', id: 8 }) + await expect(read.promise).resolves.toEqual({ value: undefined, done: true }) + }) + + it('fails a stream whose Host reads the uplink twice concurrently', async () => { + const { server, frames } = harness() + server.serve(seams(async (_endpoint, _payload, uplink) => { + const iterator = uplink[Symbol.asyncIterator]() + void iterator.next() + await iterator.next() + return (async function *(): AsyncGenerator {})() + })) + server.handleMessage({ t: 'stream-open', id: 9, endpoint: 'job/attach', payload: {} }) + await vi.waitFor(() => { + expect(frames).toEqual([{ + t: 'stream-error', + id: 9, + failure: { + kind: 'remote', + code: 'fixture-stream-failed', + message: 'webworker tunnel: stream uplink has one pending read', + details: { fixture: true }, + }, + }]) + }) + }) + it('cancels one logical stream without emitting a terminal frame', async () => { const { server, frames } = harness() const opened = Promise.withResolvers() const stopped = Promise.withResolvers() - server.serve(seams(async (_endpoint, _payload, signal) => { + server.serve(seams(async (_endpoint, _payload, _uplink, signal) => { opened.resolve(signal) return (async function *(): AsyncGenerator { yield 'ready' diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index df7a9d8900..d92592d81a 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -515,7 +515,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ClientConnectionRpc', - declaration: 'export interface ClientConnectionRpc {\n call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise>;\n readonly open?: (channel: string, endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable;\n}', + declaration: 'export interface ClientConnectionRpc {\n call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise>;\n readonly open?: (channel: string, endpoint: string, payload: unknown, signal: AbortSignal, uplink?: AsyncIterable) => AsyncIterable;\n}', }, { name: 'ClientRemote', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index b540e06b33..e49ab33f28 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1994,6 +1994,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-conversation EnterBehaviorRow id \'composer-enter\'', 'client-ui-permission-presets PermissionRow id \'permission\'', 'client-ui-settings-general DeveloperToolsRow id \'developer-tools\'', + 'client-ui-settings-general CurrentVersionRow id \'current-version\'', 'client-ui-theme AppearanceRow id \'appearance\'', 'client-ui-theme FontSizeRow id \'font-size\'', ], diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 69e75dd00b..af5e96f411 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -735,8 +735,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'connection', - summary: 'Host `ctx.connection` shape consumed by transport-independent adapters.', - description: 'Host `ctx.connection` shape consumed by transport-independent adapters.', + summary: 'Host `ctx.connection` members consumed by transport-independent adapters.', + description: 'Host `ctx.connection` members consumed by transport-independent adapters.', methods: [ { signature: 'readonly rpc: HostConnectionRpc', @@ -748,6 +748,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Exact Fetch routes for streaming or browser-native responses.', parameters: [], }, + { + signature: 'readonly operator: PeerScope', + description: 'The operator Peer every admitted request speaks for; its scope lives as long as Connection.', + parameters: [], + }, { signature: 'createSharedFetchHandler(channel: \'/api\'): ConnectionFetchHandler', description: 'Compose exact Fetch routes and the shared-channel RPC interceptor.', @@ -760,6 +765,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'request headers from the HTTP or upgrade request.' }], returns: 'rejection status, or undefined when the route may accept the request.', }, + { + signature: 'admit(request: ConnectionTrustRequest): PeerAdmission', + description: 'Admit one request: it passes requestRejection and speaks for the operator, or it is refused with that status.', + parameters: [{ name: 'request', description: 'request headers from the HTTP or upgrade request.' }], + returns: 'the operator Peer, or the rejection status.', + }, { signature: 'authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean', description: 'Authenticate one frontend index request, owning a token redirect or 401.', @@ -1837,6 +1848,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Durable append-only session storage addressed through per-session handles.', description: 'Durable append-only session storage addressed through per-session handles.\n\nStorage semantics shared by every backend: events are contiguous from seq 0 and never rewritten; a torn physical tail is never returned to a reader and is truncated by the write path before its first append; reads validate current-format records only and refuse unknown vocabulary fail-closed. `append` persists best-effort; `flush` — per handle or service-wide — is the durability barrier.\n\nVisibility: a created session is observable through `stat`/`list`/`open` in this process from the moment `create` resolves, even while a backend defers physical materialization (a pure optimization); other processes see the session only once it materializes, and a session that never materialized before a crash never existed. `SessionHandle.flush` forces materialization.\n\nFreshness: once an `append` or `flush` resolves, reads started afterwards on this backend instance observe at least that prefix.', methods: [ + { + signature: 'readonly identity: symbol = Symbol(\'sessionPersistence\')', + description: 'Process-local instance identity, stable through Context proxies and distinct after service replacement.', + parameters: [], + }, { signature: 'abstract create(header: SessionHeader, options?: SessionPersistenceCreateOptions): Promise', description: 'Create a new stored session and take its write ownership.', @@ -3068,7 +3084,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Resolve strict generated definitions or conservative SRC markers against current Cordis Services and Typert providers.', methods: [ { - signature: 'readonly wireStream: TypertGatewayWireStream = { open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), failure: error => rpcError(error), }', + signature: 'readonly wireStream: TypertGatewayWireStream = { open: (endpoint, payload, uplink, peer, signal) => this.openWireStream(endpoint, payload, uplink, peer, signal, new AbortController()), failure: error => rpcError(error), }', description: 'Carrier adapter shared by the WebSocket mux and local Host transports.', parameters: [], }, @@ -3088,7 +3104,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'async stream(request: InvokeRemoteRequest): Promise>', description: 'Open one live stream Remote method without assuming a physical carrier.', - parameters: [{ name: 'request', description: 'decoded endpoint and named wire arguments.' }], + parameters: [{ name: 'request', description: 'decoded endpoint, named wire arguments, and the Client uplink when the carrier has one.' }], returns: 'a cancellation-aware iterable over the business results.', }, ], @@ -4409,7 +4425,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionRpcHandler', - declaration: 'export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise>;', + declaration: 'export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope) => Promise>;', }, { name: 'ConnectionRpcResult', @@ -4925,7 +4941,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationDescriptor', - declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly uplink?: {\n readonly codec: TypertCodec;\n };\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', }, { name: 'InvocationParameterDescriptor', @@ -4937,7 +4953,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvokeRemoteRequest', - declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly signal?: AbortSignal;\n}', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly uplink?: AsyncIterable;\n readonly peer?: PeerScope;\n readonly signal?: AbortSignal;\n}', }, { name: 'JobDoneListener', @@ -5315,6 +5331,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PackageResult', declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n}', }, + { + name: 'PeerAdmission', + declaration: 'export type PeerAdmission = {\n readonly peer: PeerScope;\n} | {\n readonly rejection: 401 | 403;\n};', + }, + { + name: 'PeerId', + declaration: 'export type PeerId = Branded<\'PeerId\'>;', + }, + { + name: 'PeerScope', + declaration: 'export interface PeerScope {\n readonly id: PeerId;\n readonly ctx: Context;\n dispose(): Promise;\n}', + }, { name: 'PermissionCatalog', declaration: 'export interface PermissionCatalog {\n options: PresetOption[];\n}', @@ -6933,7 +6961,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertGatewayWireStream', - declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', + declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', }, { name: 'TypertMemberModel', diff --git a/packages/session-query/session-query/src/observation.ts b/packages/session-query/session-query/src/observation.ts index 8c08108775..69ddf1f19c 100644 --- a/packages/session-query/session-query/src/observation.ts +++ b/packages/session-query/session-query/src/observation.ts @@ -54,8 +54,8 @@ export interface SessionObservationOptions { * instance still reports the same revision. */ interface PreparedEntry { - /** The persistence instance whose `stat` produced {@link revision}; revisions from another instance are incomparable. */ - readonly persistence: SessionPersistence + /** Stable service identity whose `stat` produced this revision; proxy references are not instance identities. */ + readonly persistenceIdentity: symbol /** Durable revision observed by `stat` immediately before the log read. */ readonly revision: SessionPersistenceRevision /** Unpublished Session restored from the balanced log; never entered into the store. */ @@ -109,7 +109,7 @@ export class SessionObservationReader { const snapshot = await this.statSource(persistence, sessionId, signal) const attachedDuringStat = this.ctx.sessions.get(sessionId) if (attachedDuringStat !== undefined) return this.live(attachedDuringStat, projectionMode) - let entry = this.cachedEntry(persistence, sessionId, snapshot.revision) + let entry = this.cachedEntry(persistence.identity, sessionId, snapshot.revision) if (entry === undefined) { const loaded = await this.loadSource(persistence, sessionId, signal) throwIfObservationAborted(signal) @@ -138,7 +138,7 @@ export class SessionObservationReader { ) } entry = { - persistence, + persistenceIdentity: persistence.identity, revision: snapshot.revision, session, events: Object.freeze(seed), @@ -201,12 +201,12 @@ export class SessionObservationReader { /** Return a still-valid cached entry and mark it most recently used. */ private cachedEntry( - persistence: SessionPersistence, + persistenceIdentity: symbol, sessionId: SessionId, revision: SessionPersistenceRevision, ): PreparedEntry | undefined { const cached = this.cache.get(sessionId) - if (cached === undefined || cached.persistence !== persistence || cached.revision !== revision) { + if (cached === undefined || cached.persistenceIdentity !== persistenceIdentity || cached.revision !== revision) { return undefined } this.cache.delete(sessionId) diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index f17ec2e777..80b344c4e9 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -133,6 +133,9 @@ declare module '@deepseek-ai/cordis' { * on this backend instance observe at least that prefix. */ export abstract class SessionPersistence extends Service { + /** Process-local instance identity, stable through Context proxies and distinct after service replacement. */ + readonly identity: symbol = Symbol('sessionPersistence') + constructor(ctx: Context) { super(ctx, 'sessionPersistence') } diff --git a/packages/test-support/remote-mock/README.i18n.yaml b/packages/test-support/remote-mock/README.i18n.yaml index b34a83ebed..63dbc05d7e 100644 --- a/packages/test-support/remote-mock/README.i18n.yaml +++ b/packages/test-support/remote-mock/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/test-support/remote-mock/README.md -README.md: 82979e37052aecee6ec716702f634d25c969f5ad -README.zh.md: 201ca011410dd5a4d65b6ebdfb0c47f23742b0d3 +README.md: 7f76ce6217d7cc6f86923aa1ee4f7257ad30926e +README.zh.md: b79849fe3b23b5135feff7095468f65809b0d506 diff --git a/packages/test-support/remote-mock/README.md b/packages/test-support/remote-mock/README.md index 82979e3705..7f76ce6217 100644 --- a/packages/test-support/remote-mock/README.md +++ b/packages/test-support/remote-mock/README.md @@ -73,7 +73,9 @@ mock.streams.fail('session/follow', new Error('gone')) await mock.streams.drained('session/follow') ``` -A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`. +A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`. `StreamHandle.uplink` is what the script reads as the Client's uplink: through `rpc.open` it is the carrier's iterable; for a direct `mock.remote..(...)` call it is fed by the returned handle, which is the `RemoteStreamHandle` a generated method returns (iterate it for the downlink, `send()` and `end()` feed the uplink, `dispose()` cancels the stream); `send()` rejects a non-lossless JSON item as the real handle does, and the uplink it owns closes once the stream settles, is cancelled, or the consumer leaves, so a later `send()` throws. The mock function is called with the method's own arguments only, so the uplink never appears in call assertions or logged args. + +A fake that stands in for a generated stream method returns the `RemoteStreamHandle` the generated method does. `streamHandle(source)` types an `AsyncIterable` as that handle with inert `send`, `end`, and `dispose`; `streamMethod(generator)` lifts an async generator function written for the method's arguments into the method's own signature, for `vi.fn()` and `mockImplementation`. ### Connect a client @@ -108,7 +110,7 @@ A failed stream rejects the consumer's next read with the given `Error`. Consume | [`src/index.ts`](src/index.ts) | Public face re-exports | | [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`: default responses, native mocks, Connection dispatch, controlled streams and missing-response checks; `ok` | | [`src/remote-proxy.ts`](src/remote-proxy.ts) | Namespace/method lookup and generated-map mock types | -| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` scripts and `MockStream` (handle + `AsyncIterable`) | +| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` scripts, `streamHandle` / `streamMethod` fake typing, and `MockStream` (handle + `AsyncIterable`) | | [`src/log.ts`](src/log.ts) | Log store with the shared `seq` counter | | — | No runtime invariant companion is published; this test-support library owns no production event stream or mutable process state, and its behavior is exercised by its package tests. | @@ -130,7 +132,7 @@ None; this package neither assembles nor sends a provider request. - **In-process carrier only** — `rpc` serves a Connection instance in the same realm; no HTTP or WebSocket carrier for browser-lane specs is provided. -- **Values cross by reference** — answers and stream items reach the client unserialized, so a non-JSON value that the real wire would reject passes through unchanged. +- **Values cross by reference** — answers and downlink items reach the client unserialized, so a non-JSON downlink value that the real wire would reject passes through unchanged; only the handle's `send()` applies the real uplink check. - **Values are not checked** — a unary answer must be the result the caller reads (`{ ok, value }` or `{ ok: false, error }`); the mock passes it through unchanged and does not check those fields. - **No payload matching** — rules match on endpoint only; discriminate on business arguments inside a handler. - **Native stream overrides own their iterable** — an override returning its own iterable bypasses scripted-stream logs, `requests`, `opened`, `drained`, and `push` / `end` / `fail`; the caller also owns cancellation. Native call assertions still work. Use a registered stream script when a scenario needs those controls. diff --git a/packages/test-support/remote-mock/README.zh.md b/packages/test-support/remote-mock/README.zh.md index 201ca01141..b79849fe3b 100644 --- a/packages/test-support/remote-mock/README.zh.md +++ b/packages/test-support/remote-mock/README.zh.md @@ -73,7 +73,9 @@ mock.streams.fail('session/follow', new Error('gone')) await mock.streams.drained('session/follow') ``` -失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。 +失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。`StreamHandle.uplink` 是脚本读到的 Client 上行:经 `rpc.open` 打开时它是载体传入的 iterable;直接调用 `mock.remote..(...)` 时它由返回的句柄喂入——该句柄就是生成方法返回的 `RemoteStreamHandle`(迭代它读下行,`send()` 与 `end()` 喂上行,`dispose()` 取消流);`send()` 像真实句柄一样拒绝非无损 JSON 项,它拥有的上行在流结束、取消或消费方离开时关闭,之后的 `send()` 抛错。mock 函数只以方法自己的参数被调用,上行不会出现在调用断言或记录的 args 里。 + +替代生成流方法的假实现返回生成方法所返回的 `RemoteStreamHandle`。`streamHandle(source)` 把一个 `AsyncIterable` 标注为该句柄,其 `send`、`end`、`dispose` 为空操作;`streamMethod(generator)` 把按方法参数编写的 async generator 函数提升为方法自己的签名,供 `vi.fn()` 与 `mockImplementation` 使用。 ### 接上客户端 @@ -108,7 +110,7 @@ await mock.streams.drained('session/follow') | [`src/index.ts`](src/index.ts) | 公开面转出 | | [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`:默认响应、原生 mock、Connection 分发、受控流与缺失响应检查;`ok` | | [`src/remote-proxy.ts`](src/remote-proxy.ts) | 命名空间/方法查找与生成映射的 mock 类型 | -| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` 脚本与 `MockStream`(句柄 + `AsyncIterable`) | +| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` 脚本、`streamHandle` / `streamMethod` 假实现标注,以及 `MockStream`(句柄 + `AsyncIterable`) | | [`src/log.ts`](src/log.ts) | 带共享 `seq` 计数器的日志 | | — | 不发布运行时不变量伴生件;本测试支持库不拥有任何生产事件流或可变进程状态,其行为由本包测试覆盖。 | @@ -130,7 +132,7 @@ await mock.streams.drained('session/follow') - **仅进程内载体**——`rpc` 服务同一 realm 中的 Connection 实例;不提供给浏览器车道测试用的 HTTP 或 WebSocket 载体。 -- **值按引用传递**——应答与流项都未经序列化就到达客户端,真实线路会拒绝的非 JSON 值在这里原样通过。 +- **值按引用传递**——应答与下行项都未经序列化就到达客户端,真实线路会拒绝的非 JSON 下行值在这里原样通过;只有句柄的 `send()` 施加真实的上行校验。 - **不校验值**——一元应答必须是调用方读取的结果(`{ ok, value }` 或 `{ ok: false, error }`);mock 原样传递它,不检查这些字段。 - **不做 payload 匹配**——规则只按端点匹配;在 handler 内按业务参数判别。 - **原生流覆盖自行管理 iterable**——覆盖返回自有 iterable 时,不参与脚本流日志、`requests`、`opened`、`drained` 以及 `push` / `end` / `fail`;调用方也负责取消。原生调用断言仍然有效。需要这些控制能力的场景应使用已登记的流脚本。 diff --git a/packages/test-support/remote-mock/package.json b/packages/test-support/remote-mock/package.json index c9b683d880..af878fcb05 100644 --- a/packages/test-support/remote-mock/package.json +++ b/packages/test-support/remote-mock/package.json @@ -26,7 +26,8 @@ "@vitest/spy": "^4.1.8" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/test-support/remote-mock/src/index.ts b/packages/test-support/remote-mock/src/index.ts index d7c1850c2e..9ac425a88b 100644 --- a/packages/test-support/remote-mock/src/index.ts +++ b/packages/test-support/remote-mock/src/index.ts @@ -10,7 +10,8 @@ */ export { RemoteMock, ok } from './remote-mock.ts' export type { OpenStreams, RemoteMockOptions, RemoteTable, StreamFilter, UnaryRuleFn } from './remote-mock.ts' -export { frames, openStream } from './streams.ts' +export { frames, openStream, streamHandle, streamMethod } from './streams.ts' export type { StreamHandle, StreamScript } from './streams.ts' +export { MockClientStream } from './streams.ts' export type { LoggedCall, LoggedMiss, LoggedStream, MockLog } from './log.ts' export type { MockedRemote } from './remote-proxy.ts' diff --git a/packages/test-support/remote-mock/src/remote-mock.ts b/packages/test-support/remote-mock/src/remote-mock.ts index fca6a7a09c..e35ffdb2e2 100644 --- a/packages/test-support/remote-mock/src/remote-mock.ts +++ b/packages/test-support/remote-mock/src/remote-mock.ts @@ -3,7 +3,7 @@ import type { ClientConnectionRpc, ConnectionRpcResult } from '@deepseek-ai/dsh-client-connection/client' import { fn, type Mock } from '@vitest/spy' import { MockLogStore, type MockLog } from './log.ts' -import { MockStream, toError, type StreamScript } from './streams.ts' +import { HandleUplink, MockClientStream, MockStream, toError, type StreamScript } from './streams.ts' import { createRemoteProxy, type MockedRemote } from './remote-proxy.ts' /** A unary handler receives the caller's positional arguments, without its trailing `AbortSignal`. */ @@ -116,6 +116,8 @@ export class RemoteMock { private readonly logStore = new MockLogStore() private readonly unaryMocks = new Map>() private readonly streamMocks = new Map>() + /** The carrier's uplink for the open in progress through {@link open}; a direct call has none. */ + private carrierUplink: AsyncIterable | undefined private readonly proxy = createRemoteProxy(endpoint => this.modeOf(endpoint) === 'stream' ? this.streamMock(endpoint) : this.unaryMock(endpoint)) @@ -156,7 +158,7 @@ export class RemoteMock { // The carrier contract names the result envelope; the registered value is taken as that envelope unchecked. return value as ConnectionRpcResult }, - open: (_channel, endpoint, payload, signal) => this.open(endpoint, argsOf(endpoint, payload), signal), + open: (_channel, endpoint, payload, signal, uplink) => this.open(endpoint, argsOf(endpoint, payload), signal, uplink), } private constructor() {} @@ -277,16 +279,30 @@ export class RemoteMock { } /** - * Open through the endpoint's native mock; its default runs the registered script as a controlled stream. - * A native override returns its own iterable: the caller owns consumption and cancellation, outside `OpenStreams`. + * Open through the endpoint's native mock, which is called with the generated + * signature's arguments only; its default runs the registered script as a + * controlled stream. A native override returns its own iterable: the caller + * owns consumption and cancellation, outside `OpenStreams`. * @param endpoint - endpoint. * @param args - positional args. * @param signal - consumer cancellation. + * @param uplink - the carrier's uplink for this open, which the script reads as `StreamHandle.uplink`; + * absent for a direct call, whose returned handle owns the uplink. * @returns the controlled script stream or the native override's caller-owned iterable. * @throws {Error} when the default runs without a registered script (logged as unmatched). */ - open(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable { - return this.streamMock(endpoint)(...args, signal) as AsyncIterable + open( + endpoint: string, + args: readonly unknown[], + signal: AbortSignal, + uplink?: AsyncIterable, + ): AsyncIterable { + this.carrierUplink = uplink + try { + return this.streamMock(endpoint)(...args, signal) as AsyncIterable + } finally { + this.carrierUplink = undefined + } } private streamMock(endpoint: string): Mock { @@ -295,24 +311,37 @@ export class RemoteMock { mock = fn((...values: readonly unknown[]) => { const args = [...values] const signal = args.at(-1) instanceof AbortSignal ? args.pop() as AbortSignal : new AbortController().signal - return this.openScript(endpoint, args, signal) + return this.openScript(endpoint, args, signal, this.carrierUplink) }) this.streamMocks.set(endpoint, mock) } return mock } - private openScript(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable { + private openScript( + endpoint: string, + args: readonly unknown[], + signal: AbortSignal, + carrierUplink: AsyncIterable | undefined, + ): MockClientStream { const script = this.scripts.get(endpoint) if (script === undefined) { this.logStore.miss(endpoint, 'stream') throw new Error(this.noRuleMessage(endpoint)) } - const stream = new MockStream(this.logStore.stream(endpoint, args), signal) + let uplink: AsyncIterable + let owned: HandleUplink | undefined + if (carrierUplink === undefined) { + owned = new HandleUplink() + uplink = owned + } else { + uplink = carrierUplink + } + const stream = new MockStream(this.logStore.stream(endpoint, args), signal, uplink, () => { owned?.close() }) this.live.push(stream) this.wakeOpened(endpoint) stream.run(script, args) - return stream + return new MockClientStream(stream, owned) } /** Throw when any request found no rule, naming the endpoints and the registered ones. */ diff --git a/packages/test-support/remote-mock/src/streams.ts b/packages/test-support/remote-mock/src/streams.ts index 0090087720..5c2093f136 100644 --- a/packages/test-support/remote-mock/src/streams.ts +++ b/packages/test-support/remote-mock/src/streams.ts @@ -1,7 +1,40 @@ /** Stream scripts and the pushable, abort-aware stream a script drives. */ +import { isRemoteUplinkItem, type RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import type { StreamRecord } from './log.ts' +/** + * Type a fake stream method's result as the handle a generated Client method + * returns. The uplink members are inert: a fake built on it drives the downlink only. + * @param source - downlink items the fake yields. + * @returns a handle that iterates `source`. + */ +export function streamHandle(source: AsyncIterable): RemoteStreamHandle { + return { + [Symbol.asyncIterator]: () => source[Symbol.asyncIterator](), + send: () => undefined, + end: () => undefined, + dispose: () => undefined, + } +} + +/** Downlink item type of one generated stream method. */ +type StreamItem = Method extends (...args: never[]) => RemoteStreamHandle ? Out : never + +/** + * Lift a fake stream method written as an async generator function into the + * signature of the generated method it stands in for. + * @param method - fake taking the method's arguments and yielding its downlink items. + * @returns the fake returning {@link streamHandle} of each call. + */ +export function streamMethod RemoteStreamHandle>( + method: (...args: Parameters) => AsyncIterable>, +): Method { + const lifted = (...args: Parameters): RemoteStreamHandle, unknown> => + streamHandle(method(...args)) + return lifted as Method +} + /** Test-side controls over one open stream. */ export interface StreamHandle { /** Queue one item for the consumer. */ @@ -12,6 +45,12 @@ export interface StreamHandle { fail(error: Error): void /** Aborts when the opening signal aborts or the consumer returns early. */ readonly signal: AbortSignal + /** + * Uplink items for the script: the carrier's iterable when the open came + * through `rpc.open`, otherwise what the returned handle's `send()` and + * `end()` feed. + */ + readonly uplink: AsyncIterable } /** @@ -66,8 +105,15 @@ export class MockStream implements StreamHandle, AsyncIterable { /** * @param record - log entry this stream updates. * @param sourceSignal - cancellation from the caller that opened the stream. + * @param uplink - the uplink the script reads. + * @param release - closes the uplink this stream owns; called once, when the stream settles, is cancelled, or the consumer leaves. */ - constructor(readonly record: StreamRecord, private readonly sourceSignal: AbortSignal) { + constructor( + readonly record: StreamRecord, + private readonly sourceSignal: AbortSignal, + readonly uplink: AsyncIterable, + private readonly release: () => void, + ) { if (sourceSignal.aborted) this.cancel(sourceSignal.reason) else sourceSignal.addEventListener('abort', this.onAbort, { once: true }) } @@ -92,6 +138,11 @@ export class MockStream implements StreamHandle, AsyncIterable { this.settle({ kind: 'fail', error }, 'failed') } + /** The consumer left: cancel the stream and drop what it left. */ + dispose(): void { + this.cancel(new Error(`remote-mock: ${this.record.endpoint} stream disposed`)) + } + /** * Start `script` on this stream. * @param script - the registered script. @@ -151,6 +202,7 @@ export class MockStream implements StreamHandle, AsyncIterable { this.record.state = state this.settled = settled this.sourceSignal.removeEventListener('abort', this.onAbort) + this.release() this.wakeDrained() // A pending read exists only while the queue is empty: push() resolves it directly instead of queueing. const waiting = this.waiting @@ -167,6 +219,7 @@ export class MockStream implements StreamHandle, AsyncIterable { this.waiting = undefined this.queue.length = 0 this.cancellation.abort(reason) + this.release() waiting?.resolve({ value: undefined, done: true }) } // Whether cancelled or left after the producer settled, the consumer pulls nothing more: drop what it left. @@ -186,6 +239,96 @@ export class MockStream implements StreamHandle, AsyncIterable { } } +/** + * The uplink a direct handle owns: `send()` pushes, `end()` half-closes, and + * `close()` ends it and drops unread items once the stream settles, is + * cancelled, or the consumer leaves. The script reads it as `StreamHandle.uplink`. + */ +export class HandleUplink implements AsyncIterable { + private readonly items: unknown[] = [] + private done = false + private waiter: ((result: IteratorResult) => void) | undefined + + /** + * Queues one uplink item for the script, or completes the read waiting for it. + * @param item - the item the handle's `send()` carried. + * @throws {Error} after `end()`. + */ + push(item: unknown): void { + if (this.done) throw new Error('remote-mock: uplink was ended') + const waiter = this.waiter + this.waiter = undefined + if (waiter === undefined) this.items.push(item) + else waiter({ value: item, done: false }) + } + + /** Half-closes the uplink: pending and later reads complete as done; repeated calls are no-ops. */ + end(): void { + if (this.done) return + this.done = true + this.waiter?.({ value: undefined, done: true }) + this.waiter = undefined + } + + /** Ends the uplink and drops unread items; the owning stream calls it on settle or cancel, the script's iterator on `return()`. */ + close(): void { + this.items.length = 0 + this.end() + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + if (this.items.length > 0) return Promise.resolve({ value: this.items.shift(), done: false }) + if (this.done) return Promise.resolve({ value: undefined, done: true }) + return new Promise((resolve) => { this.waiter = resolve }) + }, + return: () => { + this.close() + return Promise.resolve({ value: undefined, done: true }) + }, + } + } +} + +/** + * The handle a direct `mock.remote` stream call returns, as a generated method + * would: iterate it for the script's downlink, `send()` and `end()` feed + * `StreamHandle.uplink`, `dispose()` cancels the stream. `send()` applies the + * real handle's check, so a non-lossless JSON item throws and a top-level + * `undefined` passes. A stream opened through `rpc.open` reads the carrier's + * uplink instead, and this handle owns none. + */ +export class MockClientStream implements RemoteStreamHandle { + /** + * @param stream - the script-side stream. + * @param uplink - the uplink this handle feeds; absent when the carrier supplied one. + */ + constructor(private readonly stream: MockStream, private readonly uplink: HandleUplink | undefined) {} + + send(item: unknown): void { + if (this.uplink === undefined) { + throw new Error(`remote-mock: ${this.stream.record.endpoint} uplink belongs to the carrier that opened the stream`) + } + if (!isRemoteUplinkItem(item)) { + throw new Error(`remote-mock: ${this.stream.record.endpoint} uplink item is not a lossless JSON value`) + } + this.uplink.push(item) + } + + end(): void { + this.uplink?.end() + } + + dispose(): void { + this.stream.dispose() + } + + [Symbol.asyncIterator](): AsyncIterator { + return this.stream[Symbol.asyncIterator]() + } +} + /** * The `Error` a thrown value stands for: itself, or a new Error carrying its string form. * @param reason - thrown value. diff --git a/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts b/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts index b1880d313f..081408f4b8 100644 --- a/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts +++ b/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts @@ -14,6 +14,7 @@ const artifactPath = resolve(import.meta.dirname, '__remote_proxy_artifact.d.ts' const protocolPath = resolve(root, 'packages/typert/protocol/src/types.ts') const proxyPath = resolve(import.meta.dirname, '../src/remote-proxy.ts') const protocolEntry = resolve(import.meta.dirname, '__remote_protocol.d.ts') +const brandEntry = resolve(root, 'packages/util/brand/src/index.ts') const cordisEntry = resolve(import.meta.dirname, '__remote_cordis.d.ts') function modulePath(path: string): string { @@ -40,6 +41,7 @@ function compile(source: string, artifact?: string) { types: ['node'], paths: { '@deepseek-ai/cordis': [cordisEntry], + '@deepseek-ai/dsh-brand': [brandEntry], '@deepseek-ai/dsh-typert-protocol': [protocolEntry], '@deepseek-ai/fixture/remote': [artifactPath], }, diff --git a/packages/test-support/remote-mock/tests/proxy.client.spec.ts b/packages/test-support/remote-mock/tests/proxy.client.spec.ts index ba068d70d2..b089522c48 100644 --- a/packages/test-support/remote-mock/tests/proxy.client.spec.ts +++ b/packages/test-support/remote-mock/tests/proxy.client.spec.ts @@ -1,6 +1,6 @@ /** Native namespace mocks shared by local callers and the Connection carrier. */ import { describe, expect, it, onTestFinished, vi } from 'vitest' -import { RemoteMock, frames, ok, openStream, type StreamHandle } from '../src/index.ts' +import { RemoteMock, frames, ok, openStream, streamHandle, type StreamHandle } from '../src/index.ts' const describeValue = (hasDocument: boolean) => ok({ writable: true, hasDocument, namespaces: [] }) const baseline = { type: 'baseline' as const, value: { queues: {}, jobs: {}, projections: {} } } @@ -230,8 +230,9 @@ describe('RemoteMock.remote stream proxies', () => { consumed = true yield baseline } - const native = response() - onTestFinished(async () => { await native.return(undefined) }) + const generator = response() + const native = streamHandle(generator) + onTestFinished(async () => { await generator.return(undefined) }) control.mockReturnValueOnce(native) const overridden = mock.open('session/control', [], signal) expect(overridden).toBe(native) diff --git a/packages/test-support/remote-mock/tests/rpc.client.spec.ts b/packages/test-support/remote-mock/tests/rpc.client.spec.ts index 2eb0a620bc..772a5fa6d8 100644 --- a/packages/test-support/remote-mock/tests/rpc.client.spec.ts +++ b/packages/test-support/remote-mock/tests/rpc.client.spec.ts @@ -1,8 +1,13 @@ /** The Connection carrier face: payload forms, streams, unmatched rejections, and abort. */ import { describe, expect, it } from 'vitest' -import { RemoteMock, ok, openStream } from '../src/index.ts' +import { RemoteMock, ok, openStream, type MockClientStream } from '../src/index.ts' const idle = (): AbortSignal => new AbortController().signal +const drain = async (source: AsyncIterable): Promise => { + const items: unknown[] = [] + for await (const item of source) items.push(item) + return items +} describe('RemoteMock.rpc', () => { it('calls and opens by endpoint, taking the proxies\' positional args or the Gateway\'s single object', async () => { @@ -18,6 +23,84 @@ describe('RemoteMock.rpc', () => { expect(mock.log.streams('session/control').map(open => open.args)).toEqual([[{ since: 1 }]]) }) + it('hands the uplink to the script and keeps it out of the logged args', async () => { + const mock = RemoteMock.create().stream('job/attach', async (args, stream) => { + for await (const item of stream.uplink) stream.push(`${String(args[0])}:${String(item)}`) + stream.end() + }) + const uplink = (values: readonly string[]): AsyncIterable => (async function *() { yield* values })() + + await expect(drain(mock.rpc.open!('/api', 'job/attach', { args: ['job-1'] }, idle(), uplink(['a', 'b'])))) + .resolves.toEqual(['job-1:a', 'job-1:b']) + await expect(drain(mock.open('job/attach', ['job-2'], idle(), uplink(['c'])))).resolves.toEqual(['job-2:c']) + // A direct open returns the handle a generated method would: its send/end feed the script's uplink. + const direct = mock.open('job/attach', ['job-3'], idle()) as MockClientStream + direct.send('d') + direct.end() + direct.end() + await expect(drain(direct)).resolves.toEqual(['job-3:d']) + expect(() => { direct.send('late') }).toThrow('remote-mock: uplink was ended') + const disposed = mock.open('job/attach', ['job-4'], idle()) as MockClientStream + disposed.dispose() + await expect(drain(disposed)).resolves.toEqual([]) + expect(mock.log.streams('job/attach').map(open => [open.args, open.state])) + .toEqual([[['job-1'], 'ended'], [['job-2'], 'ended'], [['job-3'], 'ended'], [['job-4'], 'cancelled']]) + const carried = mock.open('job/attach', ['job-5'], idle(), uplink([])) as MockClientStream + expect(() => { carried.send('x') }).toThrow('remote-mock: job/attach uplink belongs to the carrier that opened the stream') + await expect(drain(carried)).resolves.toEqual([]) + }) + + it('queues items sent before the script reads and drops the rest once it stops reading', async () => { + const mock = RemoteMock.create().stream('job/attach', async (args, stream) => { + await Promise.resolve() + for await (const item of stream.uplink) { + stream.push(`${String(args[0])}:${String(item)}`) + break + } + stream.end() + }) + const direct = mock.open('job/attach', ['job-6'], idle()) as MockClientStream + direct.send('first') + direct.send('second') + await expect(drain(direct)).resolves.toEqual(['job-6:first']) + expect(() => { direct.send('late') }).toThrow('remote-mock: uplink was ended') + }) + + it('applies the real handle checks to send and closes the owned uplink with the stream', async () => { + const mock = RemoteMock.create() + .stream('job/attach', async (args, stream) => { + for await (const item of stream.uplink) stream.push(`${String(args[0])}:${String(item)}`) + stream.end() + }) + .stream('job/quiet', (args, stream) => { + stream.push(String(args[0])) + stream.end() + }) + const absent = mock.open('job/attach', ['job-7'], idle()) as MockClientStream + absent.send(undefined) + expect(() => { absent.send(1n) }).toThrow('remote-mock: job/attach uplink item is not a lossless JSON value') + absent.end() + await expect(drain(absent)).resolves.toEqual(['job-7:undefined']) + // A script that ends the downlink without reading the uplink closes the owned uplink. + const quiet = mock.open('job/quiet', ['job-8'], idle()) as MockClientStream + await expect(drain(quiet)).resolves.toEqual(['job-8']) + expect(() => { quiet.send('late') }).toThrow('remote-mock: uplink was ended') + // A consumer that leaves early closes it too, and a script blocked on the uplink wakes. + const unblocked = Promise.withResolvers() + const blocked = RemoteMock.create().stream('job/attach', async (args, stream) => { + stream.push(`${String(args[0])}:first`) + for await (const item of stream.uplink) stream.push(String(item)) + unblocked.resolve(undefined) + }) + const left = blocked.open('job/attach', ['job-9'], idle()) as MockClientStream + for await (const item of left) { + expect(item).toBe('job-9:first') + break + } + await unblocked.promise + expect(() => { left.send('late') }).toThrow('remote-mock: uplink was ended') + }) + it('rejects malformed payloads and unmatched endpoints, logging the miss', async () => { const mock = RemoteMock.create() await expect(mock.rpc.call('/api', 'a/b', 'bare')).rejects.toThrow('remote-mock: payload of a/b must be { args: unknown[] | object }') diff --git a/packages/test-support/remote-mock/tests/streams.client.spec.ts b/packages/test-support/remote-mock/tests/streams.client.spec.ts index ec41ba902f..8dbdf2b018 100644 --- a/packages/test-support/remote-mock/tests/streams.client.spec.ts +++ b/packages/test-support/remote-mock/tests/streams.client.spec.ts @@ -1,6 +1,7 @@ /** Stream scripts, live stream control, cancellation, and the built-in `$events` opening. */ import { describe, expect, it } from 'vitest' -import { RemoteMock, frames, openStream } from '../src/index.ts' +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteMock, frames, openStream, streamHandle, streamMethod } from '../src/index.ts' const idle = (): AbortSignal => new AbortController().signal @@ -184,6 +185,24 @@ describe('RemoteMock streams', () => { expect(mock.modeOf('s/declared')).toBe('stream') }) + it('types a fake stream as the generated handle, with inert uplink members', async () => { + const handle = streamHandle((async function* () { + yield 'a' + yield 'b' + })()) + handle.send('ignored') + handle.end() + handle.dispose() + await expect(drain(handle)).resolves.toEqual(['a', 'b']) + + const method = streamMethod<(prefix: string, signal?: AbortSignal) => RemoteStreamHandle>( + async function* (prefix, signal) { + yield `${prefix}:${String(signal?.aborted ?? 'none')}` + }, + ) + await expect(drain(method('hi'))).resolves.toEqual(['hi:none']) + }) + it('waits for opens with opened(), and answers $events with one ready frame per generation', async () => { const mock = RemoteMock.create({ host: { home: '/home/me' } }) const second = mock.streams.opened('$events', 2) diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index 26eafdb018..c5841ecac6 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/generator/README.md -README.md: 399e4fe622a785d68452ffeae79e106246941fa1 -README.zh.md: 1fd19fd76ab804013a8741cb90522e9fd3f608d3 +README.md: aa7fd3d6e068ea6532d55de04970ab3e28b74285 +README.zh.md: e911feac821deda1d0876c26c575dcff5cd2a741 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index 399e4fe622..aa7fd3d6e0 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -79,7 +79,7 @@ The generator is built on one separation: extraction and emission are decoupled ### Analysis and faces -Host and Client are independent TypeScript programs. Direct project references establish compiler-face membership, while `dsh.client` package subpaths establish runtime-face contribution; `package.json#exports` marks every cross-package public boundary, and imports or re-exports are the only cross-face edges. A relative import that resolves inside the referencing package is followed through that module's re-exports until a package specifier appears, so package-local forwarding modules keep their original declaration references; a relative import that resolves into another package fails. `check` mode fails on syntax or semantic diagnostics, missing public annotations, private cross-package references, and reachable declaration merges the model cannot retain losslessly; `write` mode inserts checker-derived annotations and returns a clean check-mode model. Types owned by NPM dependencies remain `external` references instead of being expanded. +Host and Client are independent TypeScript programs. Direct project references establish compiler-face membership, while `dsh.client` package subpaths establish runtime-face contribution; `package.json#exports` marks every cross-package public boundary, and imports or re-exports are the only cross-face edges. A relative import that resolves inside the referencing package is followed through that module's re-exports until a package specifier appears, so package-local forwarding modules keep their original declaration references; a relative import that resolves into another package fails. `check` mode fails on syntax or semantic diagnostics, missing public annotations, private cross-package references, and reachable declaration merges the model cannot retain losslessly; `write` mode inserts checker-derived annotations and returns a clean check-mode model. Types owned by NPM dependencies remain `external` references instead of being expanded. A stream method's return type may be `Iterable`, `AsyncIterable`, or the protocol's `RemoteStream`, recognized by symbol and declaring package like the standard-library wrappers; a second type argument other than `never` yields the descriptor's uplink codec, and the generated Client signature returns `RemoteStreamHandle`. ### Emission and publication contract diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index 1fd19fd76a..e911feac82 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -79,7 +79,7 @@ files: ### 分析与 face -Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定编译器 face 的成员归属,`dsh.client` 包子路径则确定运行时 face 的贡献;`package.json#exports` 划定所有跨包公开边界,跨 face 的边只能来自导入或重新导出。解析到本包内部模块的相对导入会沿该模块的重新导出继续追踪,直到出现包说明符,因此包内转发模块保留原始声明引用;解析到其他包的相对导入会失败。`check` 模式遇到语法或语义诊断、缺失的公开类型标注、跨包私有引用,以及模型无法无损保留的可达声明合并时都会失败;`write` 模式插入类型检查器推导出的标注,并返回无诊断的 check 模式模型。NPM 依赖拥有的类型继续以 `external` 引用表示,不会被展开。 +Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定编译器 face 的成员归属,`dsh.client` 包子路径则确定运行时 face 的贡献;`package.json#exports` 划定所有跨包公开边界,跨 face 的边只能来自导入或重新导出。解析到本包内部模块的相对导入会沿该模块的重新导出继续追踪,直到出现包说明符,因此包内转发模块保留原始声明引用;解析到其他包的相对导入会失败。`check` 模式遇到语法或语义诊断、缺失的公开类型标注、跨包私有引用,以及模型无法无损保留的可达声明合并时都会失败;`write` 模式插入类型检查器推导出的标注,并返回无诊断的 check 模式模型。NPM 依赖拥有的类型继续以 `external` 引用表示,不会被展开。流方法的返回类型可以是 `Iterable`、`AsyncIterable` 或协议包的 `RemoteStream`,后者与标准库包装器一样按符号名与声明所在包识别;第二个类型参数不为 `never` 时产出描述符的上行编解码器,生成的 Client 签名则返回 `RemoteStreamHandle`。 ### 生成与发布约定 diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index eebf7fbc29..7388d6c6b1 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1198,7 +1198,17 @@ class FaceAnalyzer { } const mode = invocation.kind === 'direct' ? invocation.mode : undefined - const resultType = this.remoteResultType(method, mode) + const { result: resultType, uplink: uplinkType } = this.remoteResultType(method, mode) + const uplink: InvocationModel['uplink'] = uplinkType === undefined + ? undefined + : { + boundary: this.remoteBoundary( + uplinkType, + `${registration.name}#${binding.namespace}/${exportedMethod}:uplink`, + false, + 'undefined', + ), + } return { id: `${registration.name}#${binding.namespace}/${exportedMethod}`, service: binding.service, @@ -1209,6 +1219,7 @@ class FaceAnalyzer { invocation: receiver, ...(scope === undefined ? {} : { scope }), parameters, + ...(uplink === undefined ? {} : { uplink }), ...(cancellation === undefined ? {} : { cancellation }), result: this.remoteBoundary( resultType, @@ -1337,12 +1348,11 @@ class FaceAnalyzer { } const [property] = argument.properties if (property === undefined) this.fail(argument, 'Remote() options must contain exactly mode: "stream"') - if (!ts.isPropertyAssignment(property) - || memberName(property.name) !== 'mode' - || stringLiteralValue(property.initializer) !== 'stream') { - this.fail(property, 'Remote() options must contain exactly mode: "stream"') - } - marker = { kind: 'direct', mode: 'stream' } + const mode = ts.isPropertyAssignment(property) && memberName(property.name) === 'mode' + ? stringLiteralValue(property.initializer) + : undefined + if (mode !== 'stream') this.fail(property, 'Remote() options must contain exactly mode: "stream"') + marker = { kind: 'direct', mode } } } else if (ts.isCallExpression(expression) && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { @@ -1368,27 +1378,43 @@ class FaceAnalyzer { return found } - private remoteResultType(method: ts.MethodDeclaration, mode?: 'stream'): ts.TypeNode { + /** + * The item types a Remote method's authored return type declares. Unary + * methods unwrap `Promise`; stream methods unwrap `Iterable`, + * `AsyncIterable`, or the protocol's `RemoteStream`, whose + * second type argument is the uplink item type unless it is `never`. + */ + private remoteResultType( + method: ts.MethodDeclaration, + mode?: 'stream', + ): { readonly result: ts.TypeNode; readonly uplink?: ts.TypeNode } { const authored = this.requiredType(method, method.type, 'return') if (ts.isTypeReferenceNode(authored)) { const symbol = this.checker.getSymbolAtLocation(authored.typeName) const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol) - const resultType = authored.typeArguments?.[0] - const wrappers = mode === 'stream' ? ['Iterable', 'AsyncIterable'] : ['Promise'] const declaration = resolved === undefined ? undefined : preferredDeclaration(resolved) - if (resolved !== undefined - && wrappers.includes(resolved.name) - && resultType !== undefined - && authored.typeArguments?.length === 1 - && declaration !== undefined - && isStandardLibraryFile(declaration.getSourceFile().fileName)) { - return resultType + const [result, uplink] = authored.typeArguments ?? [] + const arity = authored.typeArguments?.length ?? 0 + if (resolved !== undefined && declaration !== undefined && result !== undefined) { + const standard = isStandardLibraryFile(declaration.getSourceFile().fileName) + const wrappers = mode === undefined ? ['Promise'] : ['Iterable', 'AsyncIterable'] + if (standard && wrappers.includes(resolved.name) && arity === 1) return { result } + if (mode !== undefined + && resolved.name === 'RemoteStream' + && this.isTypeMetaSymbol(authored.typeName, 'RemoteStream') + && arity <= 2) { + return uplink === undefined || this.isNeverType(uplink) ? { result } : { result, uplink } + } } } - if (mode === 'stream') { - this.fail(method, 'stream Remote methods must return Iterable or AsyncIterable') + if (mode !== undefined) { + this.fail(method, 'stream Remote methods must return Iterable, AsyncIterable, or RemoteStream') } - return authored + return { result: authored } + } + + private isNeverType(type: ts.TypeNode): boolean { + return (this.checker.getTypeFromTypeNode(type).flags & ts.TypeFlags.Never) !== 0 } private isGlobalAbortSignal(type: ts.TypeNode): boolean { diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 018184d5b5..f7c1beaf71 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -317,6 +317,14 @@ export class FaceModelEmitter { lines.push(' },') }) lines.push(' ],') + if (invocation.uplink !== undefined) { + lines.push(' uplink: {') + lines.push(` codec: ${indent(strictCodec( + invocation.uplink.boundary, + schemas.boundary(uplinkBoundaryKey(invocation)), + ), 4).trimStart()},`) + lines.push(' },') + } if (invocation.cancellation !== undefined) { lines.push(" cancellation: { parameter: 'signal' },") } @@ -341,10 +349,13 @@ export class FaceModelEmitter { }) grouped.set(imported.specifier, values) } + // A stream method's Client signature returns the handle; `RemoteStream` names + // what the Host method returns. const lines = [ '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', 'import type {', ' RemoteResult,', + ...packageModel.invocations.some(invocation => invocation.mode === 'stream') ? [' RemoteStreamHandle,'] : [], ' TypertRemoteContribution,', '} from \'@deepseek-ai/dsh-typert-protocol\'', ] @@ -469,7 +480,10 @@ export class FaceModelEmitter { if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) if (invocation.mode === 'stream') { - return `(${parameters.join(', ')}) => AsyncIterable<${result}>` + const uplink = invocation.uplink === undefined + ? 'never' + : this.renderer.renderType(invocation.uplink.boundary.type, referenceNames) + return `(${parameters.join(', ')}) => RemoteStreamHandle<${result}, ${uplink}>` } // The unary Client Remote face delivers the carrier's outcome, so every // generated consumer signature resolves to a result the caller reads. @@ -852,6 +866,9 @@ function invocationBoundaryRoots(invocations: readonly InvocationModel[]): Bound invocation.parameters.forEach((parameter, index) => { result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType }) }) + if (invocation.uplink !== undefined) { + result.push({ key: uplinkBoundaryKey(invocation), type: invocation.uplink.boundary.codecType }) + } result.push({ key: resultBoundaryKey(invocation), type: invocation.result.codecType }) } return result @@ -861,6 +878,10 @@ function contextBoundaryKey(invocation: InvocationModel): string { return `${invocation.id}:context` } +function uplinkBoundaryKey(invocation: InvocationModel): string { + return `${invocation.id}:uplink` +} + function parameterBoundaryKey(invocation: InvocationModel, index: number): string { return `${invocation.id}:parameter:${String(index)}` } @@ -894,6 +915,7 @@ function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImpor for (const invocation of invocations) { if (invocation.invocation.kind === 'context') add(invocation.invocation.boundary) for (const parameter of invocation.parameters) add(parameter.boundary) + if (invocation.uplink !== undefined) add(invocation.uplink.boundary) add(invocation.result) } return [...imports.values()].sort((left, right) => @@ -901,7 +923,7 @@ function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImpor } function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): ReadonlyMap { - const used = new Set(['TypertRemoteContribution', 'TYPERT_REMOTE']) + const used = new Set(['RemoteStreamHandle', 'TypertRemoteContribution', 'TYPERT_REMOTE']) const names = new Map() for (const imported of imports) { const base = safeIdentifier(imported.name) diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 477c4368c4..ba88936e26 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -145,6 +145,10 @@ export interface InvocationModel { readonly wire: string } readonly parameters: readonly InvocationParameterModel[] + /** Per-item boundary of the Client uplink, from the `In` type argument of `RemoteStream`; absent when the method reads none. */ + readonly uplink?: { + readonly boundary: RemoteBoundaryModel + } readonly cancellation?: { readonly parameter: 'signal' } diff --git a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts index 37c741915c..5bd9db5755 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts @@ -36,6 +36,14 @@ declare module '@deepseek-ai/dsh-typert-protocol' { readonly descriptors: readonly unknown[] } + export type RemoteStream = AsyncIterable & { readonly uplinkItem?: In } + + export interface RemoteStreamHandle extends AsyncIterable { + send(item: In): void + end(): void + dispose(): void + } + export abstract class TypertRemoteService { readonly typertRemote: { readonly service: TypertRemoteService diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 635e12d334..e291df692c 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -27,6 +27,9 @@ interface RuntimeDescriptor { readonly acceptsUndefined?: true readonly codec: { readonly create: () => RuntimeSchema } }[] + readonly uplink?: { + readonly codec: { readonly create: () => RuntimeSchema } + } readonly result: { readonly create: () => RuntimeSchema } } @@ -142,7 +145,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { "'agent:goals/rename': (request: RenameGoalRequest) => Promise>", ) expect(artifact?.remote?.dts).toContain( - "'goals/watch': (agentId: AgentId, signal?: AbortSignal) => AsyncIterable", + "'goals/watch': (agentId: AgentId, signal?: AbortSignal) => RemoteStreamHandle", ) const remoteJs = artifact?.remote?.js @@ -219,6 +222,105 @@ export type {`, expect(labelled?.parameters[1]?.codec.create().safeParse(7).success).toBe(false) }) + it('models RemoteStream return types with an uplink boundary and renders them on consumers', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source + .replace( + "import { TypertRemoteService, Remote, RemoteScope } from '@deepseek-ai/dsh-typert-protocol'", + "import { TypertRemoteService, Remote, RemoteScope, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol'", + ) + .replace( + " @Remote({ mode: 'stream' })\n async *watch", + ` @Remote({ mode: 'stream' }) + async *attach(agent: Agent, signal: AbortSignal): RemoteStream { + signal.throwIfAborted() + yield { ref: agent.id } + } + + @Remote({ mode: 'stream' }) + async *tail(): RemoteStream { + yield 'tail' + } + + @Remote({ mode: 'stream' }) + async *silent(): RemoteStream { + yield 'silent' + } + + @Remote({ mode: 'stream' }) + async *watch`, + )) + + const model = remotePackage(root) + const attach = model.invocations.find(invocation => invocation.method === 'attach') + expect(attach).toMatchObject({ + id: '@fixture/remote#goals/attach', + mode: 'stream', + invocation: { kind: 'direct' }, + scope: { context: 'agent', wire: 'agentId' }, + parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'agent' }], + uplink: { boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' } }, + cancellation: { parameter: 'signal' }, + result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, + }) + for (const method of ['tail', 'silent']) { + const modeled = model.invocations.find(invocation => invocation.method === method) + expect(modeled).toMatchObject({ mode: 'stream', parameters: [] }) + expect(modeled?.uplink).toBeUndefined() + expect(modeled?.cancellation).toBeUndefined() + } + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain( + " RemoteStreamHandle,\n TypertRemoteContribution,\n} from '@deepseek-ai/dsh-typert-protocol'", + ) + expect(artifact?.remote?.dts).toContain("declare module '@deepseek-ai/dsh-typert-protocol' {") + expect(artifact?.remote?.dts).toContain( + "'goals/attach': (agentId: AgentId, signal?: AbortSignal) => RemoteStreamHandle", + ) + expect(artifact?.remote?.dts).toContain( + "'agent:goals/attach': (signal?: AbortSignal) => RemoteStreamHandle", + ) + expect(artifact?.remote?.dts).toContain("'goals/tail': () => RemoteStreamHandle") + expect(artifact?.remote?.dts).toContain("'goals/silent': () => RemoteStreamHandle") + + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('RemoteStream fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const descriptor = generated.TYPERT_REMOTE.descriptors.find(candidate => candidate.id.endsWith('/attach')) + expect(descriptor?.mode).toBe('stream') + expect(descriptor?.uplink?.codec.create().safeParse({ title: 'ship' }).success).toBe(true) + expect(descriptor?.uplink?.codec.create().safeParse({ title: 1 }).success).toBe(false) + expect(descriptor?.cancellation).toEqual({ parameter: 'signal' }) + expect(generated.TYPERT_REMOTE.descriptors.find(candidate => candidate.id.endsWith('/tail'))?.uplink).toBeUndefined() + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) + }) + + it.each([ + { + name: 'a stream method returning a Promise', + mode: 'stream', + method: 'async attach(agent: Agent, signal: AbortSignal): Promise', + message: 'stream Remote methods must return Iterable, AsyncIterable, or RemoteStream', + }, + { + name: 'an unknown Remote mode', + mode: 'duplex', + method: 'async *attach(agent: Agent, signal: AbortSignal): AsyncIterable', + message: 'Remote\\(\\) options must contain exactly mode: "stream"', + }, + ])('rejects $name', ({ mode, method, message }) => { + const root = copyFixture() + const decorator = mode === 'unary' ? '@Remote' : `@Remote({ mode: '${mode}' })` + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + " @Remote({ mode: 'stream' })\n async *watch", + ` ${decorator}\n ${method} {\n throw new Error('fixture never runs')\n }\n\n @Remote({ mode: 'stream' })\n async *watch`, + )) + + expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) + }) + it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { const root = copyFixture() editFile(root, 'packages/remote/src/types.ts', source => `${source} @@ -653,6 +755,7 @@ function assertRemoteConsumerTypechecks( import remote from '@fixture/remote/remote' import type { RemoteResult, + RemoteStreamHandle, TypertRemoteContribution, TypertRemoteScopeMap, TypertRemoteMap, @@ -664,10 +767,12 @@ const contribution: TypertRemoteContribution = remote declare const create: TypertRemoteMap['goals/create'] declare const createScoped: TypertRemoteScopeMap['agent:goals/create'] declare const rename: TypertRemoteScopeMap['agent:goals/rename'] +declare const watch: TypertRemoteMap['goals/watch'] 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' }) +const watched: RemoteStreamHandle = watch('agent-1') declare const ctx: { remote: TypertRemoteNamespaceMap } const navigated: Promise> = ctx.remote.goals.create('agent-1', { title: 'navigate' }) void contribution @@ -675,6 +780,7 @@ void created void cancellable void createdScoped void renamed +void watched void navigated ` writeFileSync(consumerPath, consumerSource) diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index fc20a25c24..8a68e37489 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/protocol/README.md -README.md: e41fa9fa305d8f55ce55d3eace647ad5afcea0e3 -README.zh.md: e784e771016ebec0913e0ea847258fe653ae3bd0 +README.md: e96c53fb2c8ad671fce1bb25c235c06517fc3d6b +README.zh.md: e9cb0f8496683c12ea009530a6cf97bdd023ee24 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index e41fa9fa30..e96c53fb2c 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -44,6 +44,8 @@ export class GoalService extends TypertRemoteService { Generation turns the method into a wire endpoint under the service's namespace; Clients call it as a typed method through `ctx.remote` (see the [API Gateway reference](../../../docs/api-gateway.md)). A method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter — the signal is injected, never a JSON parameter or lookup field. +A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `bindTypertRemote()` binding in a tree, which every `TypertRemoteService` constructor makes, registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through. + ### Associating Host objects and Contexts with wire identities Complex Host objects cannot cross the wire directly. A business package declares the association through the merge-extensible `TypertLookupMap` and `TypertContextMap`. A Host Context adapter owns the stable wire declaration and resolves wire identities to live Contexts. A Client Context adapter maps in both directions because scoped calls originate from a Client Context and forwarded Host events resolve their explicit wire identity there. Host composition may override its synchronous or asynchronous resolver. A resolver that refuses on policy grounds throws `RemoteError` with its own code, which reaches the caller unchanged. @@ -89,7 +91,7 @@ The package keeps strict reflection in the compiler: decorator initializers reta ### Protocol maps and descriptors -The merge-extensible protocol maps keep static associations in the type system, while runtime providers register resolution with `ctx.typert`; the map names and shapes live in [`src/types.ts`](src/types.ts). `InvocationDescriptor` is the shared runtime form consumed by the registry, the Gateway, and the Client Remote, covering direct and Context receivers, JSON and lookup parameters, scope projections, cancellation, and result codecs. +The merge-extensible protocol maps keep static associations in the type system, while runtime providers register resolution with `ctx.typert`; the map names and shapes live in [`src/types.ts`](src/types.ts). `InvocationDescriptor` is the shared runtime form consumed by the registry, the Gateway, and the Client Remote, covering direct and Context receivers, JSON and lookup parameters, scope projections, the uplink codec, cancellation, and result codecs. ### Wire identity grammar @@ -100,8 +102,9 @@ Every namespace, method, lookup, and Context segment must satisfy `isTypertRemot | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation | +| [`src/json-value.ts`](src/json-value.ts) | `isRemoteJsonValue` and `isRemoteUplinkItem`, the lossless JSON checks every carrier shares | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` and the structural `remoteErrorOf` recognizer | -| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | +| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `RemoteStream`, `RemoteStreamHandle`, `PeerScope`, `RemoteInvocation`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | | — | No runtime invariant companion is published; decorators retain private immutable declarations and bindings are frozen values with no independent event stream to cross-check. | diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index e784e77101..e9cb0f8496 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -44,6 +44,8 @@ export class GoalService extends TypertRemoteService { 生成会把方法变为服务命名空间下的 wire 端点;Client 通过 `ctx.remote` 以类型化方法调用它(见 [API Gateway 参考](../../../docs/api-gateway.zh.md))。方法把 `signal: AbortSignal` 声明为最后一个参数即可选择协作式取消——该信号是注入的,绝不会成为 JSON 参数或查找字段。 +流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个 `bindTypertRemote()` 绑定(每个 `TypertRemoteService` 构造函数都会建立一个)在根上注册该 accessor。生成的 Client 流方法返回 `RemoteStreamHandle`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,原样透传。 + ### 把 Host 对象与 Context 关联到 wire identity 复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host Context 适配器拥有稳定 wire 声明,并把 wire identity 解析为活跃 Context。Client Context 适配器需要双向映射,因为作用域调用从 Client Context 发起,而转发的 Host 事件要在 Client 侧解析其显式 wire identity。Host 组合可以覆盖其同步或异步解析器。因策略原因拒绝解析的解析器会抛出带有自身错误码的 `RemoteError`,该码原样到达调用方。 @@ -89,7 +91,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect ### 协议映射与描述符 -可合并扩展的协议映射在类型系统中保留静态关联,运行时提供方则向 `ctx.typert` 注册解析;映射的名称与形状见 [`src/types.ts`](src/types.ts)。`InvocationDescriptor` 是注册表、Gateway 与 Client Remote 共同消费的共享运行时形式,涵盖直接与 Context 接收者、JSON 与查找参数、作用域投影、取消与结果编解码器。 +可合并扩展的协议映射在类型系统中保留静态关联,运行时提供方则向 `ctx.typert` 注册解析;映射的名称与形状见 [`src/types.ts`](src/types.ts)。`InvocationDescriptor` 是注册表、Gateway 与 Client Remote 共同消费的共享运行时形式,涵盖直接与 Context 接收者、JSON 与查找参数、作用域投影、上行编解码器、取消与结果编解码器。 ### Wire 标识文法 @@ -100,8 +102,9 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验 | +| [`src/json-value.ts`](src/json-value.ts) | 各载体共享的无损 JSON 校验 `isRemoteJsonValue` 与 `isRemoteUplinkItem` | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` 与结构式识别函数 `remoteErrorOf` | -| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | +| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`RemoteStream`、`RemoteStreamHandle`、`PeerScope`、`RemoteInvocation`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | | — | 不发布运行时不变量伴生入口;decorator 只保留私有不可变声明,binding 也是冻结值,没有可供交叉核对的独立事件流。 | diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index bd0f34482d..0b998c9897 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -31,6 +31,9 @@ "lib/types/**/*.d.ts" ], "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:^" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 7c3d902b19..f94096beb3 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -5,12 +5,13 @@ * @module @deepseek-ai/dsh-typert-protocol */ -import { Service, type Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { TypertContextMap } from './types.ts' export { RemoteError, remoteErrorOf } from './remote-error.ts' export { TYPERT_OWNED_VALUE, isTypertOwnedValue, typertOwnedValue } from './owned-value.ts' export type { TypertOwnedValue } from './owned-value.ts' +export { isRemoteJsonValue, isRemoteUplinkItem } from './json-value.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ @@ -27,10 +28,15 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + PeerId, + PeerScope, RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure, + RemoteInvocation, RemoteResult, + RemoteStream, + RemoteStreamHandle, TypertClientEventListener, TypertClientRemote, TypertClientContextAdapter, @@ -100,7 +106,7 @@ export interface RemoteMethodMarker { /** Options for a non-unary Remote method. */ export interface RemoteMethodOptions { - /** Deliver each Iterable item over the shared logical-stream carrier. */ + /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ readonly mode: 'stream' } @@ -134,7 +140,10 @@ interface RemoteMethodDescriptorV1 { const REMOTE_METHOD_DESCRIPTOR = '@deepseek-ai/dsh-typert-protocol/remote-methods' /** - * Bind one visible Service field to a Cordis key and Remote namespace. + * Bind one visible Service field to a Cordis key and Remote namespace. A + * service that owns a Cordis Context also gives its tree `ctx.invocation`, + * `undefined` outside a Remote call, so no `TypertRemoteService` is needed for + * a Host composition to read it. * @param service - owning Service instance, normally `this`. * @param serviceKey - exact Cordis service key. * @param options - optional distinct wire namespace. @@ -148,6 +157,8 @@ export function bindTypertRemote( validateName('service key', serviceKey) const namespace = options.namespace ?? serviceKey validateName('namespace', namespace) + const ctx: unknown = Reflect.get(service, 'ctx') + if (ctx instanceof Context) provideInvocationAccessor(ctx) return Object.freeze({ service, serviceKey, namespace }) } @@ -168,6 +179,17 @@ export abstract class TypertRemoteService extends Service { } } +/** + * Make `ctx.invocation` read as `undefined` outside a Remote call instead of the + * reflect service's "cannot get property" error; a call-derived Context shadows + * the accessor with its own property. The first Remote Service constructed in a + * tree registers it on the root, where it outlives any one Service. + */ +function provideInvocationAccessor(ctx: Context): void { + if (Object.hasOwn(ctx.root.reflect.props, 'invocation')) return + ctx.root.accessor('invocation', { get: () => undefined }) +} + /** * Mark one public instance method as a direct Remote invocation. * @param _method - decorated method; retained only by the class itself. diff --git a/packages/typert/protocol/src/json-value.ts b/packages/typert/protocol/src/json-value.ts new file mode 100644 index 0000000000..323cc31ea0 --- /dev/null +++ b/packages/typert/protocol/src/json-value.ts @@ -0,0 +1,53 @@ +/** + * Lossless JSON checks every Remote carrier shares: the Client handle before it + * queues an uplink item, the Gateway at its wire and codec-less uplink + * boundaries, and the in-process mock. + */ + +/** + * Test whether a value crosses JSON transport without coercion or omission. + * @param value - candidate boundary value. + * @returns whether the value is losslessly JSON-compatible. + */ +export function isRemoteJsonValue(value: unknown): boolean { + return visitJsonValue(value, new Set()) +} + +/** + * Test whether a value may travel as one uplink item: a lossless JSON value, or + * a top-level `undefined`, which the wire carries as an `item` frame without + * `value`. Nested `undefined`, `NaN`, and infinities stay rejected. + * @param value - candidate uplink item. + * @returns whether the item crosses every carrier unchanged. + */ +export function isRemoteUplinkItem(value: unknown): boolean { + return value === undefined || isRemoteJsonValue(value) +} + +function visitJsonValue(value: unknown, ancestors: Set): boolean { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0) + if (typeof value !== 'object') return false + if (ancestors.has(value)) return false + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype + || Reflect.ownKeys(value).length !== value.length + 1) return false + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false + } + return true + } + const prototype: unknown = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return false + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return false + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false + } + return true + } finally { + ancestors.delete(value) + } +} diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 358babac9d..14917682b7 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -5,6 +5,7 @@ */ import type { Context, Events } from '@deepseek-ai/cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { TypertOwnedValue } from './owned-value.ts' declare const LOOKUP_HOST: unique symbol @@ -76,6 +77,52 @@ export type RemoteResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: RemoteFailure } +declare const STREAM_UPLINK: unique symbol + +/** + * One Remote stream as a Host method returns it: the items it yields to the + * Client, iterated as a plain `AsyncIterable`. `In` is the type of the + * items the Client may send back on the same logical stream, read through + * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The + * default `never` declares a method that reads none, and its descriptor + * carries no uplink codec. A generated Client stream method returns the same + * stream as a `RemoteStreamHandle`. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } + +/** + * One open Remote stream as the Client holds it: the downlink items as an + * `AsyncIterable`, plus the uplink and cancellation of the same logical + * stream. A generated Client stream method returns it, and calling that + * method opens the stream: a holder that neither iterates nor disposes the + * handle keeps the Host stream alive. A handle stands for one generation: + * when the carrier is lost, iteration fails with the carrier error and the + * handle is finished. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +export interface RemoteStreamHandle extends AsyncIterable { + /** + * Send one uplink item. Items sent before the stream has opened are queued + * and sent once the `open` frame is on the wire. A top-level `undefined` + * travels as an `item` frame without `value`. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} when the item is not a lossless JSON value, when `end()` + * was called, or once the stream has terminated. + */ + send(item: In): void + /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ + end(): void + /** + * Cancel the logical stream: send `cancel` unless a terminal frame has + * arrived, and end the downlink iterator quietly. Breaking out of + * `for await` early does the same. + */ + dispose(): void +} + /** Merge-extensible scoped Remote method signatures generated for consumers. */ export interface TypertRemoteScopeMap {} @@ -266,7 +313,7 @@ export interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + /** Absent for unary calls; stream calls deliver every yielded item as the Host produced it. */ readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: @@ -286,6 +333,16 @@ export interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** + * Client-to-Host items of the same logical stream, generated from the `In` + * type argument of the method's `RemoteStream` return type; absent + * when `In` is `never`. The method reads the items through + * `RemoteInvocation.uplink()`, so nothing enters the parameter list. + */ + readonly uplink?: { + /** Codec validating every uplink item before `uplink()` delivers it. */ + readonly codec: TypertCodec + } /** Transport cancellation injected after business parameters instead of entering wire args. */ readonly cancellation?: { /** Reserved final Host method parameter. */ @@ -297,6 +354,59 @@ export interface InvocationDescriptor { readonly sourceLocation?: InvocationSourceLocation } +/** + * Opaque identity of one Peer: a party admitted to this Host by the connection + * layer. "Peer" is a connection-layer word; the browser application keeps the + * word "Client". + */ +export type PeerId = Branded<'PeerId'> + +/** + * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * scope that owns connection-lifetime registrations and is disposed with the + * Peer. Who the Peer is and what it may do are not recorded here. + */ +export interface PeerScope { + readonly id: PeerId + readonly ctx: Context + /** + * Tear down every registration made through `ctx`. + * @returns settles once the scope has quiesced; racing calls share one completion. + */ + dispose(): Promise +} + +/** + * The context of one Remote call, reachable inside the receiving method as + * `this.ctx.invocation`. The Gateway derives the receiver from a Context that + * carries it, so no parameter is injected and nothing crosses the wire. + */ +export interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving Service. */ + readonly service: string + /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ + readonly signal: AbortSignal + /** + * The Client's uplink items for this call. Available once; a second call + * throws. With an uplink codec on the descriptor every item is decoded to + * `In`; without one items arrive as `unknown` after a JSON-safety check. + * Iteration ends when the Client ends its uplink; when the method finishes + * its downlink the Gateway calls the iterator's `return()` and unread items + * are dropped. `In` is the caller's assertion: the runtime decodes by the + * descriptor and does not cross-check it. + * @template In - item type the caller reads; the descriptor codec decides what arrives. + * @returns the single-consumer uplink iterable. + */ + uplink(): AsyncIterable +} + /** Generated Host contract selected explicitly by a Client assembly. */ export interface TypertRemoteContribution { /** npm package that owns the Remote methods. */ @@ -569,5 +679,11 @@ export interface TypertRegistryContract { declare module '@deepseek-ai/cordis' { interface Context { typert: TypertRegistryContract + /** + * The Remote call this Context was derived for, or `undefined` on a + * Context no Remote call derived. A Service method reads it as + * `this.ctx.invocation`. + */ + readonly invocation: RemoteInvocation | undefined } } diff --git a/packages/typert/protocol/tests/protocol.spec.ts b/packages/typert/protocol/tests/protocol.spec.ts index 09c85316e2..e8e42b0ac0 100644 --- a/packages/typert/protocol/tests/protocol.spec.ts +++ b/packages/typert/protocol/tests/protocol.spec.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, expectTypeOf, it } from 'vitest' import { bindTypertRemote, @@ -240,6 +240,63 @@ describe('typert-protocol Remote declarations', () => { expect(remoteMethods(prototypeLess)).toEqual([]) }) + it('records the stream mode on the Remote marker', () => { + class Jobs { + follow(): void {} + } + const initializers: Array<(this: Jobs) => void> = [] + Remote({ mode: 'stream' })(Reflect.get(Jobs.prototype, 'follow'), methodContext('follow', initializers)) + const jobs = new Jobs() + for (const initialize of initializers) initialize.call(jobs) + expect(remoteMethods(jobs)).toEqual([ + { method: 'follow', mode: 'stream', invocation: { kind: 'direct' } }, + ]) + }) + + it('registers the invocation accessor once per tree, on the root', async () => { + const root = new Context() + class First extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'first') + } + + outside(): unknown { + return this.ctx.invocation + } + } + class Second extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'second') + } + } + await root.plugin(First) + const second = root.plugin(Second) + await second + const child = root.plugin(() => {}) + await child + expect(child.ctx.invocation).toBeUndefined() + expect((root.get('first') as First).outside()).toBeUndefined() + // The accessor belongs to the root, so a Remote Service leaving does not take it along. + await second.dispose() + expect(child.ctx.invocation).toBeUndefined() + }) + + it('provides the invocation accessor for a plain Service bound with bindTypertRemote', async () => { + const root = new Context() + expect(Object.hasOwn(root.reflect.props, 'invocation')).toBe(false) + class Plain extends Service { + readonly typertRemote = bindTypertRemote(this, 'plain') + + constructor(ctx: Context) { + super(ctx, 'plain') + } + } + await root.plugin(Plain) + expect(Object.hasOwn(root.reflect.props, 'invocation')).toBe(true) + expect(root.invocation).toBeUndefined() + expect((root.get('plain') as Plain).typertRemote.namespace).toBe('plain') + }) + it('rejects malformed decorator calls and targets', () => { const method: (this: object) => void = function (this: object): void {} expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing') @@ -250,6 +307,9 @@ describe('typert-protocol Remote declarations', () => { expect(() => Remote('..')).toThrow('export name') expect(() => Remote({ mode: 'unary' } as unknown as { mode: 'stream' })).toThrow('exactly mode') expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('exactly mode') + const bogusMode: string = 'duplex' + expect(() => Remote({ mode: bogusMode } as { mode: 'stream' })) + .toThrow('Remote options must contain exactly mode: "stream"') expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') diff --git a/packages/typert/protocol/tsconfig.json b/packages/typert/protocol/tsconfig.json index 754725418e..342f636170 100644 --- a/packages/typert/protocol/tsconfig.json +++ b/packages/typert/protocol/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 73c772fdd0..93a370e34f 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -692,6 +692,11 @@ function validateInvocation(descriptor: InvocationDescriptor): void { if (cancellation !== undefined && cancellation.parameter !== 'signal') { throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`) } + const mode = descriptor.mode as string | undefined + if (mode !== undefined && mode !== 'stream') { + throw new Error(`typert: invocation "${descriptor.id}" mode must be "stream"`) + } + if (descriptor.uplink !== undefined) validateCodec(descriptor.uplink.codec, `${descriptor.id} uplink`) if (descriptor.scope !== undefined) { if (descriptor.invocation.kind !== 'direct') { throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 936ed5c279..9f9b9c54cb 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -500,9 +500,25 @@ describe('TypertRegistry', () => { } const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] }) await dispose() + const uplinkInvocation: InvocationDescriptor = { + ...strictInvocation, + id: '@fixture/remote#attach', + method: 'attach', + mode: 'stream', + uplink: { codec: strict }, + } + const disposeUplink = ctx.typert.remotes.register({ package: '@fixture/uplink', descriptors: [uplinkInvocation] }) + await disposeUplink() + const bogusMode: string = 'duplex' const malformed: readonly [InvocationDescriptor, string][] = [ [{ ...invocation(), id: '' }, 'invocation id'], + [{ ...invocation(), mode: bogusMode as 'stream' }, 'mode must be "stream"'], + [{ + ...invocation(), + mode: 'stream', + uplink: { codec: { mode: 'strict', typeSymbol: '', create: () => z.string() } }, + }, 'uplink type symbol'], [{ ...invocation(), namespace: 'bad/name' }, 'namespace'], [{ ...invocation(), implementation: 'bad/name' }, 'implementation method'], [{ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae17bcec90..b6e9d6331f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1285,6 +1285,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -1380,6 +1383,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy @@ -2393,9 +2399,15 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/client/file-upload: dependencies: @@ -11887,6 +11899,10 @@ importers: version: 4.4.3 packages/typert/protocol: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c3a8c6a239..f2203dfcde 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -157,6 +157,7 @@ export const SERVICE_PAGE: Record = { * to a model as `cordis_runtime_inspect what:"client"`). */ export const SERVICE_WALK_EXEMPTIONS: Record = { + invocation: 'not a service: per-call accessor (RemoteInvocation | undefined) the Gateway derives for each Remote call — packages/api/gateway/README.md owns the contract', webTerminals: 'client-side terminal view models — packages/api/terminal-controller/README.md owns the API', appReady: 'not a service: launcher-provided successful-startup signal — packages/boot/cmdline/README.md owns the launcher contract', appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', @@ -751,6 +752,8 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ConnectionFetchHandler: 'shared Fetch dispatch is owned by packages/client/connection/src/rpc.ts', ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts', ConnectionTrustRequest: 'transport authentication input is owned by packages/client/connection/src/rpc.ts', + PeerAdmission: 'Peer admission outcome is owned by packages/client/connection/src/rpc.ts', + PeerScope: 'Peer scope contract is owned by packages/typert/protocol/src/types.ts', ConnectionIndexRequest: 'frontend authentication request is owned by packages/client/connection/src/rpc.ts', ConnectionIndexResponse: 'frontend authentication response is owned by packages/client/connection/src/rpc.ts', Profile: 'resolved profile layers are owned by packages/boot/app-boot/README.md', diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index a013ed2097..56acc44135 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -200,7 +200,6 @@ "fc16f363fc891dac66e0be8c042da4b1d3f45c35af2d31a03f2214a6c3698379": 1 }, "packages/api/gateway/tests/stream-server.host.spec.ts": { - "53253a9a650abe4678a5eb411720231ef4a387ed2de78dde44054ff3d72efbbe": 1, "c7136804a6f7b829f525135643cb93cc8b3176b1d0036b2ebde5d7b668411658": 1, "dd244515f22ccbc14e5872126c3fc405ed6a63bb0390940976e234e3c06fcc2b": 1 }, @@ -824,9 +823,6 @@ "packages/client/ui-subagent/tests/browser-plugin.client.spec.ts": { "810a8e63d34628d25866491b3c6455a51b2d114261b67e62b42728b765370993": 1 }, - "packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx": { - "92d757187bb2dba986b160c2417c7b4a8b199784d441bfa0777d8077774b932b": 1 - }, "packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx": { "024f8748e8908a495117a23a7673046a871f0d8187f8fe561aceba1269aefa31": 1, "6f46259a9f4093d68b3fc7859c263f21b6318197b74aba368e8904321ab5c4f9": 1, diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 98b3bbb051..57903b0e03 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -55,8 +55,9 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = { /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ const PEER_REQUIRED_HOST_EXPORTS = { + '@deepseek-ai/dsh-client-connection': ['OperatorPeer'], '@deepseek-ai/dsh-subprocess': ['SubprocessExecutableNotFoundError'], - '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'], + '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget'], '@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'], '@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'], } as const satisfies HostDependencyExports diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5e4807e188..c334beadcc 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1916,6 +1916,26 @@ "symbol": "InvocationDescriptor", "source": "packages/typert/protocol/src/types.ts" }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "RemoteStream", + "source": "packages/typert/protocol/src/types.ts" + }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "RemoteStreamHandle", + "source": "packages/typert/protocol/src/types.ts" + }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "PeerScope", + "source": "packages/typert/protocol/src/types.ts" + }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "RemoteInvocation", + "source": "packages/typert/protocol/src/types.ts" + }, { "doc": "docs/subsystems/typert.md", "symbol": "TypertRegistryContract", diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 292dccbce9..2c4c147dd7 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -214,7 +214,7 @@ describe('package dependency scope', () => { expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-session/types']).toBeUndefined() expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-typert-protocol']).toBeUndefined() expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([ - 'carrierKeyOf', 'scopeOf', 'scopeTarget', + 'carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget', ]) expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-typert-protocol']).toBeUndefined() }) diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 5495962ea8..a787f8f52e 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -67,11 +67,16 @@ ## Office unavailable -- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: 0 | 0 | 0 | 0 | 0 | 0 +- DOC, DOCX, PPT, PPTX viewer menus: 0 | 0 | 0 | 0 - Guidance: Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness. - Binary text shown: false - Plain-text option and viewer picker: hidden +## Spreadsheet preview + +- XLS: unsupported / Open in default app +- XLSX: unsupported / Open in default app + ## Unknown suffix - Viewer menu hidden: true