From e5e4b027426fda61301a7de0ddfe93b93edb7800 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:59:47 +0800 Subject: [PATCH 01/11] feat(connection): register exact Fetch routes --- packages/client/connection/src/index.ts | 3 + packages/client/connection/src/rpc-host.ts | 57 ++++++++++++- packages/client/connection/src/rpc.ts | 25 ++++++ .../tests/fetch-routes.host.spec.ts | 80 +++++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/client/connection/tests/fetch-routes.host.spec.ts diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index c7203f38d8..c1c1f4d933 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -13,6 +13,8 @@ import { BrowserAuth } from './browser-auth.ts' import { HostConnectionService } from './rpc-host.ts' export type { + ConnectionFetchMethod, + ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, @@ -22,6 +24,7 @@ export type { ConnectionRpcResult, ConnectionTrustRequest, HostConnectionHandle, + HostConnectionFetch, HostConnectionRpc, } from './rpc.ts' export { HostConnectionService } from './rpc-host.ts' diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 4f1e78341b..92de25729f 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -17,6 +17,8 @@ import type { BrowserAuth } from './browser-auth.ts' import type { ConnectionIndexRequest, ConnectionIndexResponse, + ConnectionFetchRoute, + HostConnectionFetch, ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcResult, @@ -35,6 +37,11 @@ interface ConnectionRpcInterceptor { readonly fetchHandler: FetchHandler } +interface RegisteredFetchRoute { + readonly methods: ReadonlySet + readonly fetch: ConnectionFetchRoute['fetch'] +} + interface ConnectionServerResponse { readonly type: 'server-response' readonly rpcId: RpcIdType @@ -51,6 +58,7 @@ declare module '@deepseek-ai/cordis' { /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { private readonly interceptors = new Map() + private readonly fetchRoutes = new Map() /** * Provide the Host half over the active HTTP server. @@ -76,6 +84,14 @@ export class HostConnectionService extends Service implements HostConnectionHand } } + /** Exact Fetch-route registry scoped to the Context reading this service. */ + get fetch(): HostConnectionFetch { + const owner = this.ctx + return { + register: route => this.registerFetchRoute(owner, route), + } + } + /** Apply the configured Host/Origin fence, then browser authentication. */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection { if (!isTrustedApiRequest(request, this.trustedHosts)) return 403 @@ -104,7 +120,10 @@ export class HostConnectionService extends Service implements HostConnectionHand ): FetchHandler { return { fetch: (request) => { - const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + const pathname = new URL(request.url).pathname + const route = this.fetchRoutes.get(pathname) + if (route?.methods.has(request.method) === true) return route.fetch(request) + const endpoint = endpointFromPath(channel, pathname) const interceptor = this.interceptors.get(channel) if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { return fallback.fetch(request) @@ -114,6 +133,24 @@ export class HostConnectionService extends Service implements HostConnectionHand } } + private registerFetchRoute( + owner: Context, + route: ConnectionFetchRoute, + ): () => Promise { + assertFetchRoute(route) + const registered: RegisteredFetchRoute = { + methods: new Set(route.methods), + fetch: route.fetch, + } + return owner.effect(() => { + if (this.fetchRoutes.has(route.path)) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`) + } + this.fetchRoutes.set(route.path, registered) + return () => { this.fetchRoutes.delete(route.path) } + }, `client-connection: ${route.path} Fetch route`) + } + private register( owner: Context, channel: string, @@ -246,3 +283,21 @@ function assertChannel(channel: string): void { throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`) } } + +function assertFetchRoute(route: ConnectionFetchRoute): void { + if (endpointFromPath(API_PATH, route.path) === undefined) { + throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`) + } + if (route.methods.length === 0) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`) + } + const methods = new Set(route.methods) + if (methods.size !== route.methods.length) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`) + } + for (const method of methods) { + if (method !== 'GET' && method !== 'HEAD') { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} has unsupported method ${JSON.stringify(method)}`) + } + } +} diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 1f879963af..9cfb47ab1c 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -43,6 +43,29 @@ export type ConnectionRpcHandler = ( /** Synchronous ownership test for one endpoint on a shared RPC channel. */ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean +/** HTTP methods supported by exact Fetch routes on the shared API channel. */ +export type ConnectionFetchMethod = 'GET' | 'HEAD' + +/** One exact, transport-independent Fetch route owned by a Host feature. */ +export interface ConnectionFetchRoute { + /** Absolute path below `/api`; query parameters remain available on the request URL. */ + readonly path: string + /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */ + readonly methods: readonly ConnectionFetchMethod[] + /** Handle one request after the physical carrier has applied its trust and authentication policy. */ + readonly fetch: (request: Request) => Promise +} + +/** Host registry for exact Fetch routes that cannot use JSON Remote invocation. */ +export interface HostConnectionFetch { + /** + * Register one exact route on the shared API channel. + * @param route - path, methods, and Fetch-shaped implementation. + * @returns asynchronous disposer removing this exact contribution. + */ + register(route: ConnectionFetchRoute): () => Promise +} + /** Host registry for logical RPC channels carried by the current transport. */ export interface HostConnectionRpc { /** @@ -74,6 +97,8 @@ export interface HostConnectionRpc { export interface HostConnectionHandle { /** Generic RPC channel registry. */ readonly rpc: HostConnectionRpc + /** Exact Fetch routes for streaming or browser-native responses. */ + readonly fetch: HostConnectionFetch /** * Apply Connection's Host/Origin checks and browser authentication to diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts new file mode 100644 index 0000000000..7c83fed1b5 --- /dev/null +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -0,0 +1,80 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { BrowserAuth } from '../src/browser-auth.ts' +import { HostConnectionService } from '../src/rpc-host.ts' + +async function mounted(): Promise<{ + readonly connection: HostConnectionService + readonly dispose: () => Promise +}> { + const ctx = new Context() + const fiber = ctx.plugin((pluginCtx) => { + new HostConnectionService(pluginCtx, [], {} as BrowserAuth) + }) + await fiber.await() + return { + connection: ctx.get('connection') as HostConnectionService, + dispose: () => fiber.dispose(), + } +} + +describe('Connection exact Fetch routes', () => { + it('dispatches owned methods before the transitional fallback', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const route = vi.fn(async (request: Request) => + Response.json({ query: new URL(request.url).searchParams.get('sessionId') })) + const fallback = vi.fn(async () => new Response('fallback', { status: 418 })) + const dispose = connection.fetch.register({ + path: '/api/session.export', + methods: ['GET', 'HEAD'], + fetch: route, + }) + const shared = connection.createSharedFetchHandler('/api', { fetch: fallback }) + + const response = await shared.fetch(new Request( + 'http://host/api/session.export?sessionId=session-1', + )) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ query: 'session-1' }) + expect(route).toHaveBeenCalledOnce() + expect(fallback).not.toHaveBeenCalled() + + const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) + expect(post.status).toBe(418) + expect(fallback).toHaveBeenCalledOnce() + + await dispose() + const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) + expect(withdrawn.status).toBe(418) + expect(fallback).toHaveBeenCalledTimes(2) + await disposeFiber() + }) + + it('rejects invalid and duplicate registrations', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const fetch = async (): Promise => new Response() + + expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], fetch })) + .toThrow('invalid exact Fetch route') + expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], fetch })) + .toThrow('declares no methods') + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['GET', 'GET'], fetch, + })).toThrow('repeats a method') + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['POST' as 'GET'], fetch, + })).toThrow('unsupported method') + + const dispose = connection.fetch.register({ + path: '/api/session.export', methods: ['GET'], fetch, + }) + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['HEAD'], fetch, + })).toThrow('already registered') + await dispose() + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['HEAD'], fetch, + })).not.toThrow() + await disposeFiber() + }) +}) From 17c03bbbcca004ba029efd259e8e34197aa4fc75 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:01:23 +0800 Subject: [PATCH 02/11] feat(session-export): own the download route --- .../session-log-export/README.i18n.yaml | 4 +- .../session-log-export/README.md | 20 +- .../session-log-export/README.zh.md | 20 +- .../session-log-export/package.json | 16 +- .../session-log-export/src/archive.ts | 457 ++++++++++++++++++ .../session-log-export/src/index.ts | 141 +++++- .../session-log-export/src/invariant.ts | 5 +- .../tests/command.client.spec.ts | 3 + .../tests/loader-composition.client.spec.ts | 3 + .../tests/route.host.spec.ts | 105 ++++ .../session-log-export/tsconfig.client.json | 28 ++ .../session-log-export/tsconfig.host.json | 23 + .../session-log-export/tsconfig.json | 23 +- .../session-log-export/tsdown.config.ts | 6 +- pnpm-lock.yaml | 19 + tsconfig.client.json | 2 +- tsconfig.host.json | 1 + 17 files changed, 832 insertions(+), 44 deletions(-) create mode 100644 packages/session-query/session-log-export/src/archive.ts create mode 100644 packages/session-query/session-log-export/tests/route.host.spec.ts create mode 100644 packages/session-query/session-log-export/tsconfig.client.json create mode 100644 packages/session-query/session-log-export/tsconfig.host.json diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index ed1b3b512d..c8f260886d 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/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/session-query/session-log-export/README.md -README.md: 455d7a14d0dd8e20347bdd7f8c76f12b88425d26 -README.zh.md: 0c2a03b3460c499192784bddcac06a6400941d44 +README.md: a46bcdcd6fa6dda8997b3ed07f0ae789d8e3471d +README.zh.md: 348f6c09c8c5de4e8fe7f779b6d0fd4f662e8e34 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 455d7a14d0..a46bcdcd6f 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -1,5 +1,5 @@ --- -description: "Web Session-log export for users of the Web bundle: the Session Header download button and /export command, and what to expect from the download dialog." +description: "Web Session-log ZIP export: Host streaming, the authenticated download route, the Session Header action, and the /export command." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-log-export` gives the Web interface a way to download a session's full history: a `Session log` button in the Session Header and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. A small dialog reports preparation, download start, or failure, shared by the button and the command. The ZIP is built and streamed by `dsh-host-apiproxy`; this package adds only the browser-side button and command. The download is a browser download: the browser chooses the destination. Setup and usage come first; the implementation internals live in a collapsible developer section below. +`dsh-session-log-export` lets the Web interface download a session's full history: a `Session log` button in the Session Header and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. The package owns the Host archive stream, its authenticated Fetch route, and the browser controls and feedback. The browser chooses the download destination. Setup and usage come first; implementation details follow. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Use this package when the Web bundle should let users export a session log. It is mounted only by the Web bundle, beside the host API proxy, the command registry, and the conversation UI. The common path is: mount the plugin, then click `Session log` in the Session Header or type `/export` — the browser downloads `dsh-session-.zip`. +Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then click `Session log` in the Session Header or type `/export`; the browser downloads `dsh-session-.zip`. ### When to choose it @@ -38,7 +38,13 @@ Choose it for a Web deployment that needs user-facing session export with a visi name: '@deepseek-ai/dsh-session-log-export' ``` -The Web bundle mounts the package beside `dsh-host-apiproxy`, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`. +The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`. + +### Configuration + +| Field | Default | Meaning | +|---|---|---| +| `compressionLevel` | `6` | DEFLATE level from 0 through 9 for each ZIP entry. | ### Command contract @@ -67,13 +73,13 @@ This section explains how the package wires the export control and points at the ### Design split -The package has two halves. The host half ([`src/index.ts`](src/index.ts)) registers the `/export` command on `ctx.commands`; the browser half ([`src/client/index.ts`](src/client/index.ts)) provides a `SessionLogDownloadController`, contributes the Header button and shared modal to the `conversation.session.header.utilities` slot, and observes `command/executed` so a successful `/export` in the submitting browser starts the same download. Other tabs still render the durable command row without repeating the browser side effect. +The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` Fetch route to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download controller and UI, and observes `command/executed` so only the submitting browser starts a download. ### Download flow Both entry paths issue a `HEAD` preflight to `GET /api/session.export?...`, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session. -The host download endpoint is owned by [`dsh-host-apiproxy`](../../host/apiproxy/README.md): it flushes a live root session before `readRaw` and streams the ZIP; ZIP generation, raw JSONL/zstd reads, descendants, attachments, backpressure, and HTTP error semantics belong there. +The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, raw artifact and attachment reads, ZIP generation, and HTTP status semantics. @@ -84,7 +90,7 @@ The host download endpoint is owned by [`dsh-host-apiproxy`](../../host/apiproxy Read these pages when the package-level contract is not enough. They move from the Web control to the host endpoint and the surrounding command and session surfaces. -- [dsh-host-apiproxy](../../host/apiproxy/README.md) — the host-streamed ZIP download endpoint this package drives. +- [dsh-client-connection](../../client/connection/README.md) — the authenticated Fetch-route carrier used by the Host endpoint. - [Commands subsystem reference](../../../docs/subsystems/commands.md) — the human-command registry the `/export` command registers on. - [dsh-client-ui-commands](../../client/ui-commands/README.md) — the browser command surface that renders and acknowledges `/export`. - [Session Query package map](../README.md) — the retrieval family this package belongs to. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 0c2a03b346..348f6c09c8 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向 Web bundle 用户的会话日志导出:Session Header 下载按钮与 /export 命令,以及下载弹窗的预期行为。" +description: "Web 会话日志 ZIP 导出:Host 流式传输、认证下载路由、Session Header 操作与 /export 命令。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 中的 `Session log` 按钮与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。一个小弹窗报告准备中、开始下载或失败,按钮与命令共用该弹窗。ZIP 由 `dsh-host-apiproxy` 生成并流式传输;本包只提供浏览器侧的按钮与命令。下载是浏览器下载:目标位置由浏览器选择。设置与用法在前;实现内部细节放在下方可折叠的开发者章节中。 +`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 中的 `Session log` 按钮与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。本包拥有 Host 归档流、经过认证的 Fetch 路由以及浏览器控制和反馈。下载目标位置由浏览器选择。设置与用法在前,随后说明实现细节。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当 Web bundle 需要让用户导出会话日志时使用本包。它只由 Web bundle 挂载,与 Host API 代理、命令注册表和对话 UI 并列。常用路径是:挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`——浏览器下载 `dsh-session-.zip`。 +当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`;浏览器会下载 `dsh-session-.zip`。 ### 何时选择 @@ -38,7 +38,13 @@ kind: "package-reference" name: '@deepseek-ai/dsh-session-log-export' ``` -Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-commands` 和 `dsh-client-ui-conversation` 一起挂载。 +Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 和 `dsh-client-ui-conversation` 一起挂载。 + +### 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `compressionLevel` | `6` | 每个 ZIP 条目的 DEFLATE 级别,范围为 0 到 9。 | ### 命令约定 @@ -67,13 +73,13 @@ Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-co ### 设计拆分 -本包有两个半包。Host 半包([`src/index.ts`](src/index.ts))在 `ctx.commands` 上注册 `/export` 命令;浏览器半包([`src/client/index.ts`](src/client/index.ts))提供 `SessionLogDownloadController`,把 Header 按钮与共享弹窗贡献到 `conversation.session.header.utilities` slot,并观察 `command/executed`,使提交命令的浏览器在 `/export` 成功后启动同一下载。其他标签页仍渲染持久命令行,但不会重复浏览器副作用。 +本包有两个半包。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。 ### 下载流程 两条入口都会对 `GET /api/session.export?...` 发出 `HEAD` 预检,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。 -Host 下载端点由 [`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 拥有:它在 `readRaw` 前 flush 活动的根会话并流式传输 ZIP;ZIP 生成、原始 JSONL/zstd 读取、子会话、附件、背压与 HTTP 错误语义都属于那里。 +Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、原始产物与附件读取、ZIP 生成和 HTTP 状态语义。 @@ -84,7 +90,7 @@ Host 下载端点由 [`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 拥 当包级约定不够用时阅读以下页面。它们从 Web 控制逐步进入 Host 端点与周围的命令和会话表面。 -- [dsh-host-apiproxy](../../host/apiproxy/README.zh.md)——本包驱动的 Host 流式 ZIP 下载端点。 +- [dsh-client-connection](../../client/connection/README.zh.md)——Host 端点使用的认证 Fetch 路由载体。 - [命令子系统参考](../../../docs/subsystems/commands.zh.md)——`/export` 命令注册的用户命令注册表。 - [dsh-client-ui-commands](../../client/ui-commands/README.zh.md)——渲染并确认 `/export` 的浏览器命令表面。 - [会话查询包映射](../README.zh.md)——本包所属的检索能力家族。 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 8970f18b9b..9d7cd557e4 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -21,20 +21,31 @@ "files": ["lib/index.js", "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts"], "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "fflate": "^0.8.2" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", @@ -46,6 +57,9 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts new file mode 100644 index 0000000000..2f7d1fbd11 --- /dev/null +++ b/packages/session-query/session-log-export/src/archive.ts @@ -0,0 +1,457 @@ +/** + * Host-side session-log download: streams one ZIP archive whose files are the + * sessions' stored artifact text verbatim plus every referenced media object. + * The root artifact sits under its original base name (`session.jsonl`); each + * subagent descendant under `subagents//`; each image referenced + * by any included log under `media/.` (content-addressed, + * so one archive never duplicates a shared image). No manifest is written — + * every file is byte-identical to the backend's durable artifact or attachment + * store and self-describing through its own header line or media type. Before + * each live session's artifact read, the SessionStore flush barrier makes the + * current in-memory log durable; cold sessions need no barrier. Request abort + * and response-consumer cancellation share one producer signal and terminate + * the active compressor. + * Compression runs on the host with fflate's streaming Zip API, so the archive + * bytes are produced incrementally and the host never holds the whole archive + * in one buffer; production waits for consumer pull whenever the response queue + * reaches its byte high-water mark, so a slow consumer bounds accumulation to + * the fixed 64 KiB response queue plus one synchronous fflate push. + * @module + */ + +import { Zip, ZipDeflate } from 'fflate' +import type { Context } from '@deepseek-ai/cordis' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' +import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session' +import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' + +/** Valid fflate DEFLATE levels accepted by session-log export. */ +export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 + +/** Balanced default used when Session export configuration omits a compression level. */ +export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6 + +/** The services a session-log export needs (the live-session store is optional). */ +export interface SessionLogExportDeps { + readonly sessionQuery: SessionQueryEngine | undefined + readonly sessionPersistence: SessionPersistence | undefined + readonly attachments: AttachmentStore | undefined + readonly sessions: SessionStore | undefined +} + +/** The export services narrowed to the mounted ones streaming actually reads. */ +export interface SessionLogExportReady { + readonly sessionQuery: SessionQueryEngine + readonly sessionPersistence: SessionPersistence + readonly attachments: AttachmentStore + readonly sessions: SessionStore | undefined +} + +/** + * Resolve the persistence, session-query, and attachment services a log export needs. + * @param ctx - the composed host context. + * @returns the export services (absent when the deployment does not mount them). + */ +export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps { + return { + sessionQuery: ctx.get('sessionQuery'), + sessionPersistence: ctx.get('sessionPersistence'), + attachments: ctx.get('attachments'), + sessions: ctx.get('sessions'), + } +} + +/** + * Flush one currently live session through the store's authoritative durability + * barrier immediately before its raw artifact is read. A cold or absent id has + * no in-memory work to flush. + * @param deps - export services, including the optional live-session store. + * @param id - the session whose artifact is about to be read. + * @param signal - optional cancellation observed around the flush barrier. + */ +export async function flushLiveSessionLog( + deps: Pick, + id: SessionId, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted() + const sessions = deps.sessions + if (sessions === undefined) return + const session = sessions.get(id) + if (session === undefined) return + await sessions.flush(session) + signal?.throwIfAborted() +} + +/** One exported file: a stored artifact text or one referenced media object. */ +export type SessionLogZipEntry = + | { readonly path: string; readonly content: string } + | { readonly path: string; readonly data: Uint8Array } + +/** Zip extension for each accepted raster media type. */ +const MEDIA_TYPE_EXTENSIONS: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', +} + +/** + * The zip path for one media object: content-addressed by the opaque + * attachment id so shared images land once and the id in the log maps back to + * the archive entry without a manifest. + * @param ref - the durable reference from a session log. + * @returns the archive path. + */ +function mediaEntryPath(ref: ImageAttachmentRef): string { + return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}` +} + +/** + * Collect every image reference inside one content array, descending into + * nested tool results the way the live attachment route does. + * @param content - an event content array (or nested tool-result content). + * @param refs - the dedupe map being filled (keyed by attachment id). + */ +function collectImageRefs(content: unknown, refs: Map): void { + if (!Array.isArray(content)) return + const pending: unknown[] = [] + for (const item of content) pending.push(item) + while (pending.length > 0) { + const value = pending.pop() + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const block = value as { type?: unknown; attachment?: unknown; content?: unknown } + if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { + const ref = block.attachment as ImageAttachmentRef + refs.set(String(ref.attachmentId), ref) + } + if (Array.isArray(block.content)) { + for (const item of block.content) pending.push(item) + } + } +} + +/** + * Collect every image reference one session event carries, across the same + * carriers the live attachment route scans (direct content, message content, + * inserted messages, and completed assistant chunk blocks). + * @param event - one parsed JSONL event object. + * @param refs - the dedupe map being filled (keyed by attachment id). + */ +function collectEventImageRefs(event: unknown, refs: Map): void { + const data = (event as { data?: unknown }).data + if (typeof data !== 'object' || data === null) return + const carrier = data as { + content?: unknown + message?: { content?: unknown } + inserted?: Array<{ content?: unknown }> + chunk?: { type?: unknown; block?: unknown } + } + collectImageRefs(carrier.content, refs) + if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs) + if (carrier.inserted !== undefined) { + for (const message of carrier.inserted) collectImageRefs(message.content, refs) + } + if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs) +} + +/** + * Collect the distinct media references one stored artifact text names. + * Lines that fail to parse cannot reference media and are skipped (the + * artifact text itself is exported verbatim regardless). + * @param content - the stored artifact text. + * @returns the dedupe map keyed by attachment id. + */ +function imageRefsInArtifact(content: string): Map { + const refs = new Map() + for (const line of content.split('\n')) { + if (line === '') continue + let event: unknown + try { + event = JSON.parse(line) + } catch { + continue + } + collectEventImageRefs(event, refs) + } + return refs +} + +/** + * One safe zip path segment from an untrusted session id. Session ids are + * host-controlled, but the brand allows any non-empty string, so `../`, dot + * segments, and separator characters are neutralized before they can shape + * archive entries. Distinct ids may collapse onto one segment (id collision + * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept). + * @param id - the raw session id. + * @returns a filesystem-safe single path segment. + */ +function safeSessionIdSegment(id: string): string { + return id.replace(/[^A-Za-z0-9_-]/g, '_') +} + +/** + * The export archive filename for one root session. + * @param sessionId - the root session id (sanitized to one safe path segment). + * @returns the attachment filename for the session's export archive. + */ +export function sessionLogZipFilename(sessionId: string): string { + return `dsh-session-${safeSessionIdSegment(sessionId)}.zip` +} + +/** + * Yield the export entries in zip order: the preloaded root artifact first, + * then every subagent descendant in lineage order (each flushed when live, + * read from the persistence backend right before it is yielded, and dropped + * after the consumer moves on), then every distinct media object referenced by any of + * the included logs (read and verified from the attachment store, one archive + * entry per attachment id). The host holds at most one descendant's artifact + * text and one media object at a time beyond the root. + * @param deps - the mounted export services (the caller answered 500 before this runs). + * @param root - the already-read root artifact (read by the caller so the + * missing-session path can answer cleanly before streaming starts). + * @param sessionId - the root session id. + * @param includeDescendants - whether to include every subagent descendant. + * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads. + * @returns the export entries in zip order. + */ +export async function* sessionLogZipEntries( + deps: SessionLogExportReady, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + signal?: AbortSignal, +): AsyncGenerator { + const media = new Map() + const rememberMedia = (content: string): void => { + for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref) + } + rememberMedia(root.content) + yield { path: root.filename, content: root.content } + if (includeDescendants) { + const seen = new Set([sessionId]) + const collect = async function* ( + nodes: readonly SessionLineageNode[], + ): AsyncGenerator { + for (const node of nodes) { + signal?.throwIfAborted() + const id = node.session.header.id + if (seen.has(id)) continue + seen.add(id) + await flushLiveSessionLog(deps, id, signal) + const raw = await deps.sessionPersistence.readRaw(id, signal) + signal?.throwIfAborted() + if (raw === undefined) { + throw new Error(`subagent "${id}" has no stored log artifact`) + } + rememberMedia(raw.content) + yield { + path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`, + content: raw.content, + } + yield* collect(node.descendants) + } + } + const lineage = await deps.sessionQuery.traceSession(sessionId, signal) + signal?.throwIfAborted() + yield* collect(lineage.descendants) + } + for (const ref of media.values()) { + signal?.throwIfAborted() + const stored = await deps.attachments.readImage(ref, signal) + signal?.throwIfAborted() + yield { path: mediaEntryPath(ref), data: stored.data } + } +} + +/** How many code units of artifact text one zip push carries (bounded encode memory). */ +const PUSH_CHUNK_CODE_UNITS = 1 << 16 + +/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */ +const PUSH_CHUNK_BYTES = 1 << 16 + +/** Byte capacity retained by the response stream before ZIP production waits for pull. */ +const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16 + +/** One producer waiter released only when ReadableStream pull restores capacity. */ +class ResponseCapacityGate { + private releasePending: (() => void) | undefined + + /** + * Wait until the response queue has positive byte capacity or cancellation wins. + * @param controller - response controller whose desired size owns capacity. + * @param signal - combined request/consumer cancellation. + */ + async wait( + controller: ReadableStreamDefaultController, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted() + if (controller.desiredSize === null || controller.desiredSize > 0) return + await new Promise((resolve) => { + const release = (): void => { + this.releasePending = undefined + signal.removeEventListener('abort', release) + resolve() + } + this.releasePending = release + signal.addEventListener('abort', release, { once: true }) + }) + signal.throwIfAborted() + } + + /** Release the current producer waiter after a consumer pull. */ + pulled(): void { + this.releasePending?.() + } +} + +/** + * Push one media object's bytes into a deflate stream in bounded chunks, + * waiting for consumer capacity between chunks like the artifact path does. + * @param deflate - the zip entry's deflate stream. + * @param data - the stored image bytes. + * @param controller - response queue controller. + * @param capacity - pull-driven response-capacity gate. + * @param signal - cancellation; throws when aborted. + */ +async function pushBinaryChunks( + deflate: ZipDeflate, + data: Uint8Array, + controller: ReadableStreamDefaultController, + capacity: ResponseCapacityGate, + signal: AbortSignal, +): Promise { + let offset = 0 + do { + signal.throwIfAborted() + const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength) + const finalChunk = end >= data.byteLength + deflate.push(data.subarray(offset, end), finalChunk) + offset = end + await capacity.wait(controller, signal) + } while (offset < data.byteLength) +} + +/** + * Push one artifact's text into a deflate stream in bounded chunks, never + * splitting a surrogate pair across a chunk boundary (a lone high surrogate + * re-encodes as U+FFFD and would silently corrupt the exported artifact). + * @param deflate - the zip entry's deflate stream. + * @param content - the artifact text verbatim. + * @param controller - response queue controller. + * @param capacity - pull-driven response-capacity gate. + * @param signal - cancellation; throws when aborted. + */ +async function pushArtifactChunks( + deflate: ZipDeflate, + content: string, + controller: ReadableStreamDefaultController, + capacity: ResponseCapacityGate, + signal: AbortSignal, +): Promise { + const encoder = new TextEncoder() + let offset = 0 + let finalChunk: boolean + do { + signal.throwIfAborted() + let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length) + if (end < content.length && end - offset > 1) { + // Back off one code unit when the boundary lands inside a surrogate + // pair: the pair then starts the next chunk whole. + const last = content.charCodeAt(end - 1) + if (last >= 0xd800 && last <= 0xdbff) end -= 1 + } + finalChunk = end >= content.length + deflate.push(encoder.encode(content.slice(offset, end)), finalChunk) + offset = end + await capacity.wait(controller, signal) + } while (!finalChunk) +} + +/** + * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is + * read and validated by the caller before this is called (missing root or + * missing services answer cleanly before any byte is produced); each entry is + * then encoded and deflated in bounded chunks as it is produced, so the + * archive bytes arrive incrementally. A descendant that fails to read errors + * the stream (fail-loud, never silent under-export). + * @param deps - the mounted export services (the caller answered 500 before this runs). + * @param root - the already-read root artifact (first zip entry). + * @param sessionId - the root session id. + * @param includeDescendants - whether to include every subagent descendant. + * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry. + * @param signal - request cancellation combined with response-consumer cancellation. + * @returns the zip byte stream. + */ +export function streamSessionLogZip( + deps: SessionLogExportReady, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + compressionLevel: SessionLogCompressionLevel, + signal: AbortSignal, +): ReadableStream { + const consumerAbort = new AbortController() + const producerSignal = AbortSignal.any([signal, consumerAbort.signal]) + let zip: Zip | undefined + let zipTerminated = false + const capacity = new ResponseCapacityGate() + const terminateZip = (): void => { + if (zip === undefined || zipTerminated) return + zipTerminated = true + zip.terminate() + } + return new ReadableStream({ + start(controller) { + // fflate invokes the callback synchronously per compressed chunk, so a + // single push can enqueue ahead of a slow consumer; the capacity gate + // waits for pull between pushes once the byte queue is full, bounding + // accumulation to the queue high-water mark plus one synchronous push. + const archive = new Zip((error, data, final) => { + /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */ + if (error) { + controller.error(error) + return + } + /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */ + if (data.byteLength > 0) controller.enqueue(data) + if (final) controller.close() + }) + zip = archive + void (async () => { + try { + for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) { + const deflate = new ZipDeflate(entry.path, { level: compressionLevel }) + archive.add(deflate) + if ('content' in entry) { + await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal) + } else { + await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal) + } + } + archive.end() + } catch (error) { + // A mid-stream failure (missing descendant, cancellation, read + // error) must fail the download rather than ship a truncated archive. + /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */ + terminateZip() + controller.error(error instanceof Error ? error : new Error(String(error))) + } + })() + }, + pull() { + capacity.pulled() + }, + cancel(reason) { + consumerAbort.abort( + reason instanceof Error ? reason : new Error('session log export stream cancelled'), + ) + terminateZip() + }, + }, { + highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES, + size: chunk => chunk.byteLength, + }) +} diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index d26ffa9e77..4be729d3a0 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -1,10 +1,63 @@ -/** Web Session-log download command over the host endpoint owned by ApiProxy. */ +/** Session-log download command and Host-owned streaming route. */ import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-attachment' import type { CommandResult } from '@deepseek-ai/dsh-commands' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { + DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, + flushLiveSessionLog, + sessionLogExportDeps, + sessionLogZipFilename, + streamSessionLogZip, + type SessionLogCompressionLevel, + type SessionLogExportReady, +} from './archive.ts' + +export { + DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, + flushLiveSessionLog, + sessionLogExportDeps, + sessionLogZipEntries, + sessionLogZipFilename, + streamSessionLogZip, +} from './archive.ts' +export type { + SessionLogCompressionLevel, + SessionLogExportDeps, + SessionLogExportReady, + SessionLogZipEntry, +} from './archive.ts' export const name = 'session-log-download' -export const inject = ['commands'] +export const inject = ['commands', 'connection'] + +/** Stable browser download path retained across the transport migration. */ +export const SESSION_LOG_EXPORT_PATH = '/api/session.export' + +/** Session-log archive policy. */ +export interface Config { + /** DEFLATE level for each ZIP entry. @default 6 */ + readonly compressionLevel?: SessionLogCompressionLevel +} + +/** Validate Session-log archive configuration. */ +export const Config: Schema = Schema.object({ + compressionLevel: Schema.number().step(1).min(0).max(9) + .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as Schema, +}) + +interface SessionLogConnection { + readonly fetch: { + register(route: { + readonly path: string + readonly methods: readonly ('GET' | 'HEAD')[] + readonly fetch: (request: Request) => Promise + }): () => Promise + } +} const REQUESTED: CommandResult = { kind: 'success', @@ -12,10 +65,11 @@ const REQUESTED: CommandResult = { } /** - * Register the Web-only `/export` command that the browser download plugin observes. + * Register the Web-only `/export` command and authenticated ZIP download route. * @param ctx - Host context carrying the human-command registry. + * @param config - resolved compression policy. */ -export function apply(ctx: Context): void { +export function apply(ctx: Context, config: Config = {}): void { ctx.effect(() => ctx.commands.register({ name: 'export', description: 'Download this Session log as a ZIP archive', @@ -23,4 +77,83 @@ export function apply(ctx: Context): void { ? REQUESTED : { kind: 'error', text: 'The Web /export command does not accept a path.' }), }), 'session-log-download: command') + connectionOf(ctx).fetch.register({ + path: SESSION_LOG_EXPORT_PATH, + methods: ['GET', 'HEAD'], + fetch: request => sessionLogExportResponse( + ctx, + request, + config.compressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, + ), + }) +} + +function connectionOf(ctx: Context): SessionLogConnection { + return Reflect.get(ctx, 'connection') as SessionLogConnection +} + +async function sessionLogExportResponse( + ctx: Context, + request: Request, + compressionLevel: SessionLogCompressionLevel, +): Promise { + const url = new URL(request.url) + const query = Object.fromEntries(url.searchParams) + const sessionIdValue = query['sessionId'] + const descendantsValue = query['includeDescendants'] + if (sessionIdValue === undefined || sessionIdValue.length === 0 + || (descendantsValue !== undefined && descendantsValue !== 'true' && descendantsValue !== 'false')) { + return new Response('missing or invalid sessionId query parameter', { status: 400 }) + } + const sessionId = SessionId(sessionIdValue) + const deps = sessionLogExportDeps(ctx) + if (deps.sessionQuery === undefined + || deps.sessionPersistence === undefined + || deps.attachments === undefined) { + return new Response( + 'session log export is unavailable: missing session-query, session-persistence, or attachments service', + { status: 500 }, + ) + } + if (!deps.sessionPersistence.supportsRawArtifacts) { + return new Response( + 'session log export is unavailable: the persistence backend does not expose per-session raw artifacts', + { status: 501 }, + ) + } + const ready: SessionLogExportReady = { + sessionQuery: deps.sessionQuery, + sessionPersistence: deps.sessionPersistence, + attachments: deps.attachments, + sessions: deps.sessions, + } + let root: SessionRawArtifact | undefined + try { + await flushLiveSessionLog(deps, sessionId, request.signal) + root = await deps.sessionPersistence.readRaw(sessionId, request.signal) + request.signal.throwIfAborted() + } catch { + request.signal.throwIfAborted() + return new Response('session log export failed to prepare the stored artifact', { status: 500 }) + } + if (root === undefined) return new Response('session not found', { status: 404 }) + const response = new Response( + streamSessionLogZip( + ready, + root, + sessionId, + descendantsValue === 'true', + compressionLevel, + request.signal, + ), + { + headers: { + 'content-type': 'application/zip', + 'content-disposition': `attachment; filename="${sessionLogZipFilename(sessionId)}"`, + }, + }, + ) + if (request.method === 'GET') return response + await response.body?.cancel() + return new Response(null, { status: response.status, headers: response.headers }) } diff --git a/packages/session-query/session-log-export/src/invariant.ts b/packages/session-query/session-log-export/src/invariant.ts index d38ea70699..0af82953e1 100644 --- a/packages/session-query/session-log-export/src/invariant.ts +++ b/packages/session-query/session-log-export/src/invariant.ts @@ -9,7 +9,10 @@ const PACKAGE_NAME = '@deepseek-ai/dsh-session-log-export' export const name = 'session-export-invariant' export const inject = ['invariants'] -/** No runtime invariant: the command registry owns lifecycle pairing and ApiProxy owns ZIP integrity. */ +/** + * No runtime invariant: Connection and the command registry own both + * registrations, while each export reads authoritative Session services. + */ const install: InvariantInstaller = () => {} /** diff --git a/packages/session-query/session-log-export/tests/command.client.spec.ts b/packages/session-query/session-log-export/tests/command.client.spec.ts index 3fa60909f1..16936afbe6 100644 --- a/packages/session-query/session-log-export/tests/command.client.spec.ts +++ b/packages/session-query/session-log-export/tests/command.client.spec.ts @@ -13,6 +13,9 @@ describe('/export Web download command', () => { return () => { descriptor = undefined } }, } as never) + ctx.provide('connection', { + fetch: { register: () => () => Promise.resolve() }, + } as never) const fiber = await ctx.plugin(SessionLogDownload) expect(descriptor).toMatchObject({ diff --git a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts index 2a993f52ef..a1e58b11ae 100644 --- a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts +++ b/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts @@ -34,6 +34,9 @@ describe('session-log-download real Loader composition', () => { context = new Context() context.baseUrl = pathToFileURL(root).href + '/' + context.provide('connection', { + fetch: { register: () => () => Promise.resolve() }, + } as never) await context.plugin(Loader) context.loader.builtins.include = Include const modules = new Map([ diff --git a/packages/session-query/session-log-export/tests/route.host.spec.ts b/packages/session-query/session-log-export/tests/route.host.spec.ts new file mode 100644 index 0000000000..213ad852a6 --- /dev/null +++ b/packages/session-query/session-log-export/tests/route.host.spec.ts @@ -0,0 +1,105 @@ +import { Context } from '@deepseek-ai/cordis' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { strFromU8, unzipSync } from 'fflate' +import { describe, expect, it } from 'vitest' +import { + Config, + SESSION_LOG_EXPORT_PATH, + apply, + inject, +} from '../src/index.ts' + +const sid = (value: string): SessionId => value as SessionId + +function artifact(id: string): SessionRawArtifact { + const header: SessionHeader = { + version: 0, + id: sid(id), + createdAt: 1, + cwd: '/workspace', + delegationDepth: 0, + } + return { + meta: header, + filename: 'session.jsonl', + content: `${JSON.stringify({ type: 'session', ...header })}\n`, + } +} + +async function mounted(withServices: boolean): Promise<{ + readonly connection: HostConnectionService + readonly dispose: () => Promise +}> { + const ctx = new Context() + ctx.provide('commands', { register: () => () => {} } as never) + if (withServices) { + ctx.provide('sessionQuery', { + traceSession: async () => ({ descendants: [] }), + } as never) + ctx.provide('sessionPersistence', { + supportsRawArtifacts: true, + readRaw: async (id: SessionId) => artifact(String(id)), + } as never) + ctx.provide('attachments', { + readImage: async () => { throw new Error('fixture has no images') }, + } as never) + } + const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber + return { connection, dispose: () => fiber.dispose() } +} + +describe('Session log export Fetch route', () => { + it('registers one GET/HEAD route and removes it with the plugin fiber', async () => { + const { connection, dispose } = await mounted(true) + const fallback = { fetch: async () => new Response('fallback', { status: 418 }) } + const shared = connection.createSharedFetchHandler('/api', fallback) + + const response = await shared.fetch(new Request( + `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, + )) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + const files = unzipSync(new Uint8Array(await response.arrayBuffer())) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toContain('"id":"session-1"') + + const head = await shared.fetch(new Request( + `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, { method: 'HEAD' }, + )) + expect(head.status).toBe(200) + expect(head.body).toBeNull() + + await dispose() + expect((await shared.fetch(new Request( + `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, + ))).status).toBe(418) + }) + + it('validates the query before reporting missing export services', async () => { + const { connection, dispose } = await mounted(false) + const shared = connection.createSharedFetchHandler('/api', { + fetch: async () => new Response('fallback', { status: 418 }), + }) + expect((await shared.fetch(new Request(`http://host${SESSION_LOG_EXPORT_PATH}`))).status).toBe(400) + expect((await shared.fetch(new Request( + `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1&includeDescendants=1`, + ))).status).toBe(400) + expect((await shared.fetch(new Request( + `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, + ))).status).toBe(500) + await dispose() + }) + + it('validates the compression level', () => { + expect(Config({})).toEqual({ compressionLevel: 6 }) + expect(Config({ compressionLevel: 0 })).toEqual({ compressionLevel: 0 }) + expect(Config({ compressionLevel: 9 })).toEqual({ compressionLevel: 9 }) + for (const compressionLevel of [-1, 10, 1.5]) { + expect(() => Config({ compressionLevel } as never)).toThrow() + } + }) +}) diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json new file mode 100644 index 0000000000..6e3e746c4c --- /dev/null +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/Dialog.tsx", + "src/client/HeaderAction.tsx", + "src/client/controller.ts", + "src/client/index.ts", + "src/client/locales.ts", + "src/css-modules.d.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../client/locale" }, + { "path": "../../client/store" }, + { "path": "../../client/ui-commands" }, + { "path": "../../client/ui-conversation" }, + { "path": "../../client/ui-primitives" }, + { "path": "../../client/ui-renderer" }, + { "path": "../../client/ui-session" }, + { "path": "../../client/ui-slots" }, + { "path": "../../core/session" } + ] +} diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json new file mode 100644 index 0000000000..982a1360cb --- /dev/null +++ b/packages/session-query/session-log-export/tsconfig.host.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/archive.ts", + "src/index.ts", + "src/invariant.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../attachment/attachment" }, + { "path": "../../core/session" }, + { "path": "../../interaction/commands" }, + { "path": "../../runtime-diagnostics/invariants" }, + { "path": "../../session/session-persistence" }, + { "path": "../session-query" } + ] +} diff --git a/packages/session-query/session-log-export/tsconfig.json b/packages/session-query/session-log-export/tsconfig.json index 46ad790e94..2a0b0e33f7 100644 --- a/packages/session-query/session-log-export/tsconfig.json +++ b/packages/session-query/session-log-export/tsconfig.json @@ -1,24 +1,7 @@ { - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../interaction/commands" }, - { "path": "../../client/locale" }, - { "path": "../../client/store" }, - { "path": "../../core/session" }, - { "path": "../../client/ui-commands" }, - { "path": "../../client/ui-conversation" }, - { "path": "../../client/ui-primitives" }, - { "path": "../../client/ui-renderer" }, - { "path": "../../client/ui-session" }, - { "path": "../../client/ui-slots" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } ] } diff --git a/packages/session-query/session-log-export/tsdown.config.ts b/packages/session-query/session-log-export/tsdown.config.ts index dba3a1ab45..441876ff7b 100644 --- a/packages/session-query/session-log-export/tsdown.config.ts +++ b/packages/session-query/session-log-export/tsdown.config.ts @@ -1,3 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-session-log-export', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle( + '@deepseek-ai/dsh-session-log-export', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55ffa061ae..a021582cd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7047,6 +7047,13 @@ importers: version: link:../../subagent/subagent packages/session-query/session-log-export: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + fflate: + specifier: ^0.8.2 + version: 0.8.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7057,6 +7064,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../../client/locale @@ -7090,6 +7103,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../session-query '@types/react': specifier: ~18.3.1 version: 18.3.31 diff --git a/tsconfig.client.json b/tsconfig.client.json index bd8d98e4b8..8b70f8d2c7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -90,7 +90,7 @@ { "path": "./packages/client/ui-settings-plugins" }, { "path": "./packages/client/ui-user-questions" }, { "path": "./packages/client/ui-trajectory" }, - { "path": "./packages/session-query/session-log-export" }, + { "path": "./packages/session-query/session-log-export/tsconfig.client.json" }, { "path": "./packages/client/ui-theme" }, { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index d4cd66e776..a707fa8df7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -160,6 +160,7 @@ { "path": "./packages/session/session-stats" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, + { "path": "./packages/session-query/session-log-export/tsconfig.host.json" }, { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-file" }, { "path": "./packages/credentials/credentials" }, From e036aae7c00b4bf4d36f9059488d4fefc2e48fc9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:11:00 +0800 Subject: [PATCH 03/11] refactor(connection): carry Host facts with generations --- .../api/gateway/src/client/remote-events.ts | 27 +++++--- .../api/gateway/src/client/remote-stream.ts | 10 +-- packages/api/gateway/src/index.ts | 13 +++- packages/api/gateway/src/stream-protocol.ts | 8 +++ packages/api/gateway/src/types.ts | 7 +- .../tests/control-retry.client.spec.ts | 28 ++++---- .../gateway/tests/gateway-stream.host.spec.ts | 39 +++++------ .../api/gateway/tests/gateway.client.spec.ts | 9 ++- .../api/gateway/tests/gateway.host.spec.ts | 7 +- .../tests/journal-stream.client.spec.ts | 6 +- packages/api/remotes/src/index.ts | 3 +- .../remotes/tests/remote-events.host.spec.ts | 20 +++++- .../session-controller/src/client/index.ts | 2 +- .../tests/client-apply.client.spec.ts | 18 +++++ .../tests/fake-api.client.ts | 6 +- .../tests/transport.client.spec.ts | 6 +- .../tests/transport.client.spec.ts | 9 ++- .../connection/src/client/connection.ts | 49 +++++++++----- .../client/connection/src/client/fixture.ts | 3 +- .../client/connection/src/client/index.ts | 55 ++++++++++++++-- .../tests/client-apply.client.spec.ts | 2 +- .../tests/connection.client.spec.ts | 2 +- .../connection/tests/fake-api.client.ts | 10 ++- .../tests/generation.client.spec.ts | 65 +++++++++++++++++++ 24 files changed, 298 insertions(+), 106 deletions(-) create mode 100644 packages/client/connection/tests/generation.client.spec.ts diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts index 341c1f3f4d..9552478162 100644 --- a/packages/api/gateway/src/client/remote-events.ts +++ b/packages/api/gateway/src/client/remote-events.ts @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConnectionGenerationSource, + ConnectionHostInfo, ConnectionHandle, } from '@deepseek-ai/dsh-client-connection/client' import type { @@ -118,7 +119,10 @@ export class ClientRemoteEvents { } /** Run one Connection generation over the forwarded-event logical stream. */ - private async pumpEvents(signal: AbortSignal, ready: () => void): Promise { + private async pumpEvents( + signal: AbortSignal, + ready: (host: ConnectionHostInfo) => void, + ): Promise { let clientId: RemoteEventClientId | undefined const failed = new AbortController() const generationSignal = AbortSignal.any([signal, failed.signal]) @@ -134,8 +138,9 @@ export class ClientRemoteEvents { try { for await (const value of source) { if (clientId === undefined) { - clientId = parseRemoteEventReady(value) - ready() + const opening = parseRemoteEventReady(value) + clientId = opening.clientId + ready(opening.host) continue } const frame = parseRemoteEventFrame(value) @@ -251,15 +256,21 @@ export class ClientRemoteEvents { } } -/** Validate and return the Client identity from one generation's opening item. */ -function parseRemoteEventReady(value: unknown): RemoteEventClientId { +/** Validate and return one generation's Client identity and Host facts. */ +function parseRemoteEventReady(value: unknown): { + readonly clientId: RemoteEventClientId + readonly host: ConnectionHostInfo +} { if (!isRemoteEventRecord(value) - || !hasExactRemoteEventKeys(value, ['type', 'clientId']) + || !hasExactRemoteEventKeys(value, ['type', 'clientId', 'host']) || value.type !== 'ready' - || !isRemoteEventClientId(value.clientId)) { + || !isRemoteEventClientId(value.clientId) + || !isRemoteEventRecord(value.host) + || !hasExactRemoteEventKeys(value.host, ['home']) + || typeof value.host.home !== 'string') { throw new TypeError('client api: forwarded Remote event stream did not begin with ready') } - return value.clientId + return { clientId: value.clientId, host: { home: value.host.home } } } /** Validate one untrusted value from the Gateway-internal forwarded-event stream. */ diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts index 799a371ef5..71f1f546ae 100644 --- a/packages/api/gateway/src/client/remote-stream.ts +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -48,7 +48,7 @@ export class RemoteStream implements AsyncIterable> * @param options - domain stream opener, end classification, and diagnostics. */ constructor( - private readonly connection: Pick, + private readonly connection: Pick, private readonly options: RemoteStreamOptions, ) {} @@ -157,13 +157,13 @@ export class RemoteStream implements AsyncIterable> } async function waitForRemoteStreamRetry( - connection: Pick, + connection: Pick, error: RemoteStreamCarrierError, attempt: number, signal: AbortSignal, ): Promise { signal.throwIfAborted() - if (connection.hostDescription.getSnapshot() !== undefined) { + if (connection.generation.getSnapshot() !== undefined) { if (attempt === 1) return throw error } @@ -181,12 +181,12 @@ async function waitForRemoteStreamRetry( else reject(failure) } const inspect = (): void => { - if (connection.hostDescription.getSnapshot() !== undefined) finish() + if (connection.generation.getSnapshot() !== undefined) finish() } const aborted = (): void => { finish(new Error('Remote stream retry aborted', { cause: signal.reason })) } - const dispose = connection.hostDescription.subscribe(inspect) + const dispose = connection.generation.subscribe(inspect) subscription.dispose = dispose if (subscription.finished) dispose() signal.addEventListener('abort', aborted, { once: true }) diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index ec4eb1eaef..1d754b05a7 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -48,6 +48,7 @@ import { type RemoteEventCancellationFrame, type RemoteEventClientId, type RemoteEventEmitFrame, + type RemoteEventHostInfo, type RemoteEventId, type RemoteEventInvocationFrame, type RemoteEventReadyFrame, @@ -66,6 +67,7 @@ export type { TypertRemoteEventOutcome, TypertRemoteEventSource, } from './types.ts' +export type { RemoteEventHostInfo } from './stream-protocol.ts' interface GatewayErrorOptions { readonly cause?: unknown @@ -88,6 +90,7 @@ interface PreparedInvocation { interface RegisteredRemoteEventSource { readonly lifetime: AbortController readonly done: Promise + readonly host: RemoteEventHostInfo } interface RemoteEventClient { @@ -233,9 +236,13 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise { + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise { if (this.remoteEvents !== undefined) { throw new Error('typert gateway: forwarded Remote event source is already registered') } @@ -247,7 +254,7 @@ export class TypertGatewayService extends Service implements TypertGateway { this.remoteEvents = undefined lifetime.abort(error) }) - const registration: RegisteredRemoteEventSource = { lifetime, done } + const registration: RegisteredRemoteEventSource = { lifetime, done, host: { home: host.home } } this.remoteEvents = registration return async () => { if (this.remoteEvents === registration) { @@ -417,7 +424,7 @@ export class TypertGatewayService extends Service implements TypertGateway { this.remoteEventClients.set(clientId, client) for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) try { - yield { ...REMOTE_EVENT_STREAM_READY, clientId } + yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host } yield* client.queue.iterate(lifetime) } finally { this.removeRemoteEventClient(client) diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts index 2c6e8ebf70..142598863e 100644 --- a/packages/api/gateway/src/stream-protocol.ts +++ b/packages/api/gateway/src/stream-protocol.ts @@ -23,10 +23,18 @@ export type RemoteEventClientId = Branded<'RemoteEventClientId'> /** Opaque correlation id for one pending Host-to-Client Remote Event. */ export type RemoteEventId = Branded<'RemoteEventId'> +/** Stable Host facts published with every established Client event generation. */ +export interface RemoteEventHostInfo { + /** Host account home used only to abbreviate displayed filesystem paths. */ + readonly home: string +} + /** Opening item that binds later HTTP results to this active event stream. */ export interface RemoteEventReadyFrame { readonly type: 'ready' readonly clientId: RemoteEventClientId + /** Stable Host facts attached to this connection generation. */ + readonly host: RemoteEventHostInfo } /** Opaque Agent identity carried by one scoped Remote Event. */ diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index b41f35e905..9b4475cb9a 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 { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ export interface InvokeRemoteRequest { @@ -124,9 +125,13 @@ export interface TypertGateway { /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this exact source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index a278cc6acd..234251babb 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -5,23 +5,17 @@ import { RemoteStream, } from '../src/client/index.ts' -const DESCRIPTION = { - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, -} +const GENERATION = { id: 1, host: { home: '/home/fixture' } } function hostSource(initiallyAvailable: boolean): { - connection: Pick + connection: Pick publish(available: boolean): void } { - let current = initiallyAvailable ? DESCRIPTION : undefined + let current = initiallyAvailable ? GENERATION : undefined const listeners = new Set<() => void>() return { connection: { - hostDescription: { + generation: { getSnapshot: () => current, subscribe: (listener) => { listeners.add(listener) @@ -30,7 +24,7 @@ function hostSource(initiallyAvailable: boolean): { }, }, publish: (available) => { - current = available ? DESCRIPTION : undefined + current = available ? GENERATION : undefined for (const listener of listeners) listener() }, } @@ -67,7 +61,7 @@ function scripted(generations: Generation[], opened?: () => void) { } function supervisor( - connection: Pick, + connection: Pick, generations: Generation[], carrierFailed?: (error: RemoteStreamCarrierError) => void, ): RemoteStream { @@ -122,8 +116,8 @@ describe('RemoteStream', () => { let listener: (() => void) | undefined const subscribed = Promise.withResolvers() const connection = { - hostDescription: { - getSnapshot: () => available ? DESCRIPTION : undefined, + generation: { + getSnapshot: () => available ? GENERATION : undefined, subscribe: (value: () => void) => { listener = value subscribed.resolve(undefined) @@ -177,8 +171,8 @@ describe('RemoteStream', () => { let reads = 0 let disposed = 0 const connection = { - hostDescription: { - getSnapshot: () => reads++ === 0 ? undefined : DESCRIPTION, + generation: { + getSnapshot: () => reads++ === 0 ? undefined : GENERATION, subscribe: (listener: () => void) => { listener() return () => { disposed++ } @@ -261,7 +255,7 @@ describe('RemoteStream', () => { const holder: { stream?: RemoteStream } = {} let subscriptions = 0 const connection = { - hostDescription: { + generation: { getSnapshot: () => undefined, subscribe: () => { subscriptions++ diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index debb6763d5..c769192a4b 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -36,6 +36,7 @@ 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 @@ -386,8 +387,8 @@ describe('Typert Remote streams', () => { } })() } - const unregister = ctx.typertGateway.registerRemoteEvents(source) - expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + const unregister = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) + expect(() => { ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) }) .toThrow('forwarded Remote event source is already registered') const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { @@ -402,7 +403,7 @@ describe('Typert Remote streams', () => { const eventFrames = frames.filter(frame => frame.streamId === 'events') expect(eventFrames).toHaveLength(1) expect(eventFrames[0]).toMatchObject({ - type: 'item', streamId: 'events', value: { type: 'ready' }, + type: 'item', streamId: 'events', value: { type: 'ready', host: REMOTE_HOST }, }) expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') }) @@ -411,7 +412,7 @@ describe('Typert Remote streams', () => { const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2) expect(eventFrames).toHaveLength(2) expect(eventFrames[0]).toMatchObject({ - type: 'item', streamId: 'events', value: { type: 'ready' }, + type: 'item', streamId: 'events', value: { type: 'ready', host: REMOTE_HOST }, }) expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') expect(eventFrames[1]).toEqual({ @@ -429,9 +430,9 @@ describe('Typert Remote streams', () => { expect(frames).toContainEqual({ type: 'end', streamId: 'events' }) }) - const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source) + const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) await unregister() - expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + expect(() => { ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) }) .toThrow('forwarded Remote event source is already registered') await unregisterReplacement() socket.close() @@ -446,7 +447,7 @@ describe('Typert Remote streams', () => { await publish.promise yield pending.dispatch })() - const unregister = ctx.typertGateway.registerRemoteEvents(source) + const unregister = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) const rejected = expect(pending.outcome).rejects.toThrow( 'forwarded Remote event source was removed', ) @@ -479,7 +480,7 @@ describe('Typert Remote streams', () => { else signal.addEventListener('abort', () => { resolve() }, { once: true }) }) throw new Error('fixture source rejected during removal') - })()) + })(), REMOTE_HOST) const client = await openEventClient(ctx, 'events-removal') await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) @@ -496,7 +497,7 @@ describe('Typert Remote streams', () => { it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => { const { ctx } = await setup(false) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) for (const event of [42, ''] as const) { const invalidName = pendingInvocation(ctx) @@ -579,7 +580,7 @@ describe('Typert Remote streams', () => { return (async function* () { yield frame as unknown as TypertRemoteEventDispatch })() - }) + }, REMOTE_HOST) await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) const reason: unknown = sourceSignal?.reason if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error') @@ -591,7 +592,7 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event id before publishing the second waterfall', async () => { const { ctx } = await setup(false) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -626,7 +627,7 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event Client id before opening the second generation', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) @@ -645,7 +646,7 @@ describe('Typert Remote streams', () => { it('fans one scoped waterfall out and accepts the first Client result', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -699,7 +700,7 @@ describe('Typert Remote streams', () => { it('rejects the Host waterfall with the first Client listener rejection', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -739,7 +740,7 @@ describe('Typert Remote streams', () => { it('delegates to the Host only after every active Client returns next', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -772,7 +773,7 @@ describe('Typert Remote streams', () => { it('delivers a pending waterfall to the first Client that connects', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -805,7 +806,7 @@ describe('Typert Remote streams', () => { it('replays a pending event id to a replacement Client generation', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -839,7 +840,7 @@ describe('Typert Remote streams', () => { it('cancels pending deliveries when the Host signal or Context ends', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const signalAgent = ctx.extend() const contextFiber = ctx.plugin(() => {}) await contextFiber @@ -929,7 +930,7 @@ describe('Typert Remote streams', () => { const unregister = ctx.typertGateway.registerRemoteEvents(() => { sourceCalls += 1 return (async function *(): AsyncIterable {})() - }) + }, REMOTE_HOST) const invalidPayloads: readonly unknown[] = [ null, [], diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 92eeecb5e0..a77e4a22af 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -483,7 +483,7 @@ class RemoteEventCarrier { const abort = (): void => { connection.wake?.() } signal.addEventListener('abort', abort, { once: true }) try { - yield { type: 'ready', clientId } + yield { type: 'ready', clientId, host: { home: '/home/fixture' } } while (!signal.aborted) { while (connection.items.length > 0) { const item = connection.items.shift() as EventStreamItem @@ -1769,7 +1769,7 @@ describe('Client Typert API', () => { socket.receive({ type: 'item', streamId: opened.streamId, - value: { type: 'ready', clientId: 'browser-client' }, + value: { type: 'ready', clientId: 'browser-client', host: { home: '/home/browser' } }, }) await run.ready socket.receive({ @@ -1825,6 +1825,7 @@ describe('Client Typert API', () => { await vi.waitFor(() => { expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture') + expect(connection.generation.getSnapshot()?.host.home).toBe('/home/fixture') }) } finally { await ctx.fiber.dispose() @@ -1841,6 +1842,10 @@ describe('Client Typert API', () => { { type: 'ready' }, { type: 'ready', clientId: '' }, { type: 'ready', clientId: 'client', extra: true }, + { type: 'ready', clientId: 'client', host: null }, + { type: 'ready', clientId: 'client', host: {} }, + { type: 'ready', clientId: 'client', host: { home: 1 } }, + { type: 'ready', clientId: 'client', host: { home: '/home', extra: true } }, { type: 'emit', event: 'fixture/changed', args: ['too early'] }, ])('rejects malformed forwarded-event readiness item %#', async (opening) => { const open: NonNullable = () => (async function *() { diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index c0455bba10..ab1ee9d6b9 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -1084,11 +1084,14 @@ describe('TypertGatewayService', () => { if (signal.aborted) resolve() else signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - })()) + })(), { home: '/home/fixture' }) const carrier = new AbortController() const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal) const opening = await events.next() - expect(opening).toMatchObject({ done: false, value: { type: 'ready' } }) + expect(opening).toMatchObject({ + done: false, + value: { type: 'ready', host: { home: '/home/fixture' } }, + }) if (opening.done) throw new Error('Remote event stream ended before ready') const clientId: unknown = Reflect.get(opening.value as object, 'clientId') if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id') diff --git a/packages/api/gateway/tests/journal-stream.client.spec.ts b/packages/api/gateway/tests/journal-stream.client.spec.ts index 0d134f3d99..e5e02b2d28 100644 --- a/packages/api/gateway/tests/journal-stream.client.spec.ts +++ b/packages/api/gateway/tests/journal-stream.client.spec.ts @@ -42,10 +42,8 @@ interface Generation { type PageSource = Page | Promise | ((signal: AbortSignal) => Promise) const AVAILABLE_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {}, }, } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 4d0256e162..e775d3a5ae 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -1,5 +1,6 @@ /** Host BFF entry and Loader shell for the Remote contribution assembly. */ +import { homedir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' import type { TypertRemoteEventDispatch, @@ -35,7 +36,7 @@ export const inject = ['typertGateway'] /** Host plugin body registering this application's selected Cordis event source. */ export function apply(ctx: Context): void { ctx.effect( - () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx)), + () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx), { home: homedir() }), 'api-remotes: forwarded Cordis event source', ) } diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index eefe64e664..1d2e8c235d 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import type { + RemoteEventHostInfo, TypertRemoteEventInvocation, TypertRemoteEventSource, } from '@deepseek-ai/dsh-api-gateway' @@ -10,8 +11,12 @@ import { apply, inject } from '../src/index.ts' interface GatewayProbe { source: TypertRemoteEventSource | undefined + host: RemoteEventHostInfo | undefined removals: number - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise } async function setup(): Promise<{ @@ -22,12 +27,15 @@ async function setup(): Promise<{ const ctx = new Context() const gateway: GatewayProbe = { source: undefined, + host: undefined, removals: 0, - registerRemoteEvents(source) { + registerRemoteEvents(source, host) { gateway.source = source + gateway.host = host return async () => { if (gateway.source !== source) return gateway.source = undefined + gateway.host = undefined gateway.removals += 1 } }, @@ -71,6 +79,14 @@ function invocationOf(value: unknown): TypertRemoteEventInvocation { } describe('Remote event Host source', () => { + it('registers the Host home used by Client connection generations', async () => { + const { gateway, fiber } = await setup() + expect(gateway.host?.home).toBeTypeOf('string') + expect(gateway.host?.home.length).toBeGreaterThan(0) + await fiber.dispose() + expect(gateway.host).toBeUndefined() + }) + it('gives each Client stream an independent allowlisted event queue', async () => { const { ctx, gateway, fiber } = await setup() const firstAbort = new AbortController() diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index bca9f9d3d0..04b7cbb553 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -111,7 +111,7 @@ export function apply(ctx: Context): void { }) control.start() ctx.on('connection/reset', () => { sessions.handleConnected() }) - if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected() + if (connection.generation.getSnapshot() !== undefined) sessions.handleConnected() ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), resolve: sessionId => sessions.resolveAgentScope(sessionId), diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 876786a759..b6c7e40972 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -64,6 +64,24 @@ async function mount(initialHost?: HostDescription): Promise { return () => { hostListeners.delete(listener) } }, }, + generation: { + getSnapshot: () => host === undefined + ? undefined + : { id: 1, host: { home: host.home } }, + subscribe: (listener) => { + hostListeners.add(listener) + return () => { hostListeners.delete(listener) } + }, + }, + generation: { + getSnapshot: () => host === undefined + ? undefined + : { id: 1, host: { home: host.home } }, + subscribe: (listener) => { + hostListeners.add(listener) + return () => { hostListeners.delete(listener) } + }, + }, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), }, diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index f1369e4c8c..851ce01030 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -32,10 +32,8 @@ import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts' const AVAILABLE_STREAM_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/h' } }), subscribe: () => () => {}, }, } diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 1ad1d4e858..36afa47632 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -28,10 +28,8 @@ type SessionTransportRemote = Pick const ADDRESS: SessionAddress = { kind: 'session', sessionId: 'session-1' as never } const AVAILABLE_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {}, }, } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index d71cfba44e..14cf3a02db 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -36,17 +36,15 @@ import type { } from '../src/types.ts' const AVAILABLE_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {}, }, } function workspaceClient( remote: WorkspaceRemote, - connection: Pick = AVAILABLE_CONNECTION, + connection: Pick = AVAILABLE_CONNECTION, ) { return { workspace: remote, @@ -211,6 +209,7 @@ function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { }), subscribe: () => () => {}, }, + generation: AVAILABLE_CONNECTION.generation, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), }, diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index cad2de394d..ebcac74ea7 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,5 +1,19 @@ import type { HostDescription, IApiClient } from './api.ts' +/** Stable Host facts delivered by one established Remote event generation. */ +export interface ConnectionHostInfo { + /** Host account home used only to abbreviate displayed filesystem paths. */ + readonly home: string +} + +/** One successfully established Host generation. */ +export interface ConnectionGeneration { + /** Monotone generation number within this Client runtime. */ + readonly id: number + /** Host facts carried by this generation's opening frame. */ + readonly host: ConnectionHostInfo +} + /** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the * future `ctx.connection` plugin's Config). All fields optional; defaults below. */ export interface ConnectionConfig { @@ -39,7 +53,7 @@ export type ConnectionState = 'connected' | 'reconnecting' /** Connection-generation callbacks owned by API Gateway. */ export interface ConnectionSinks { /** After the generation source is ready and host.describe succeeds, first connect included. */ - onConnected?: (description: HostDescription) => void + onConnected?: (description: HostDescription, host: ConnectionHostInfo) => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ onStateChange?: (state: ConnectionState) => void @@ -55,7 +69,7 @@ export interface ConnectionSinks { */ export type ConnectionGenerationSource = ( signal: AbortSignal, - ready: () => void, + ready: (host: ConnectionHostInfo) => void, ) => Promise /** @@ -117,19 +131,20 @@ export class ConnectionController { this.current = ac let sourceReady = false - let resolveReady!: () => void + let resolveReady!: (host: ConnectionHostInfo) => void let rejectReady!: (error: Error) => void let rejectSourceLost!: (error: Error) => void - const ready = new Promise((resolve, reject) => { + const ready = new Promise((resolve, reject) => { resolveReady = resolve rejectReady = reject }) const sourceLost = new Promise((_resolve, reject) => { rejectSourceLost = reject }) - const reportReady = (): void => { + const reportReady = (host: ConnectionHostInfo): void => { + if (sourceReady) return sourceReady = true - resolveReady() + resolveReady(host) } const failed = new Promise((resolve) => { @@ -161,7 +176,7 @@ export class ConnectionController { // The source reports ready only after its incremental listeners exist; // describe may complete in parallel, but consumers see neither result // until both sides of the baseline-plus-increment handshake are ready. - const [description] = await Promise.race([ + const [description, host] = await Promise.race([ Promise.all([ this.api.host.describe({}, ac.signal), waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), @@ -178,7 +193,7 @@ export class ConnectionController { // A state sink may synchronously stop this controller. Do not publish // a description for a generation that no longer exists afterward. if (this.isGenerationActive(ac)) { - this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value) }) + this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value, host) }) } } catch { // Transport failure: treat as generation failure, fall through to the shared backoff. @@ -213,28 +228,28 @@ export class ConnectionController { } /** Await source readiness without letting a stalled carrier wedge startup forever. */ -function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { +function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { let settled = false const timeout = setTimeout(() => { - finish(new Error(`connection generation was not ready within ${String(timeoutMs)}ms`)) + finish({ error: new Error(`connection generation was not ready within ${String(timeoutMs)}ms`) }) }, timeoutMs) const aborted = (): void => { - finish(new Error('connection generation aborted', { cause: signal.reason })) + finish({ error: new Error('connection generation aborted', { cause: signal.reason }) }) } - const finish = (error?: Error): void => { + const finish = (outcome: { readonly value: T } | { readonly error: Error }): void => { if (settled) return settled = true clearTimeout(timeout) signal.removeEventListener('abort', aborted) - if (error === undefined) resolve() - else reject(error) + if ('error' in outcome) reject(outcome.error) + else resolve(outcome.value) } signal.addEventListener('abort', aborted, { once: true }) void ready.then( - () => { finish() }, + (value) => { finish({ value }) }, (error: unknown) => { - finish(error as Error) + finish({ error: error as Error }) }, ) }) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a2a7617a7e..6a5a1017c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -176,6 +176,7 @@ interface FixtureRemoteEventResult { interface FixtureRemoteEventReadyFrame { readonly type: 'ready' readonly clientId: string + readonly host: { readonly home: string } } interface FixtureProjectionFrame { @@ -3161,7 +3162,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running) }, 5000) try { - yield { type: 'ready', clientId } + yield { type: 'ready', clientId, host: { home: FIXTURE_HOME } } if (approvalPending) yield approvalInvocation() if (questionPending) yield questionInvocation() yield* conn.drain(signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a24d014496..a03ab1e5d7 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -7,9 +7,9 @@ import type { HostDescription, IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, + type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionSinks, - type ConnectionState, } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' @@ -45,7 +45,14 @@ export { // Connection loop types are public through ConnectionHandle.start; the // controller remains package-internal. -export type { ConnectionConfig, ConnectionGenerationSource, ConnectionSinks, ConnectionState } +export type { + ConnectionConfig, + ConnectionGeneration, + ConnectionGenerationSource, + ConnectionHostInfo, + ConnectionSinks, + ConnectionState, +} from './connection.ts' export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, } from '../rpc.ts' @@ -59,6 +66,14 @@ export interface HostDescriptionSource { subscribe(listener: () => void): () => void } +/** Observable identity and Host facts for the active connection generation. */ +export interface ConnectionGenerationState { + /** Active generation, or undefined before readiness and while reconnecting. */ + getSnapshot(): ConnectionGeneration | undefined + /** Subscribe to generation establishment, replacement, and loss. */ + subscribe(listener: () => void): () => void +} + /** Required services (none — this is the wire root). */ export const inject: string[] = [] @@ -113,6 +128,8 @@ export interface ConnectionHandle { readonly isLoopback: boolean /** Generation-scoped Host facts, including the account home and native path-open capability. */ readonly hostDescription: HostDescriptionSource + /** Current Remote event generation and the Host facts carried by its opening frame. */ + readonly generation: ConnectionGenerationState /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc /** @@ -151,6 +168,9 @@ export function apply(ctx: Context): void { const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined + let generationId = 0 + let generation: ConnectionGeneration | undefined + const generationListeners = new Set<() => void>() let description: HostDescription | undefined const descriptionListeners = new Set<() => void>() const publishDescription = (next: HostDescription | undefined): void => { @@ -164,10 +184,22 @@ export function apply(ctx: Context): void { } } } + const publishGeneration = (next: ConnectionGeneration | undefined): void => { + if (Object.is(generation, next)) return + generation = next + for (const listener of [...generationListeners]) { + try { + listener() + } catch (error) { + console.error('[connection] generation listener threw:', error) + } + } + } const releaseOwner = (current: ConnectionOwner): void => { if (owner !== current) return owner = undefined current.controller.stop() + publishGeneration(undefined) publishDescription(undefined) } const handle: ConnectionHandle = { @@ -180,6 +212,13 @@ export function apply(ctx: Context): void { return () => { descriptionListeners.delete(listener) } }, }, + generation: { + getSnapshot: () => generation, + subscribe: (listener) => { + generationListeners.add(listener) + return () => { generationListeners.delete(listener) } + }, + }, rpc, registerGenerationSource(source) { if (generationSource !== undefined) { @@ -201,17 +240,23 @@ export function apply(ctx: Context): void { const ownsGeneration = (): boolean => owner?.token === token const controller = new ConnectionController(api, source, { ...sinks, - onConnected: (next) => { + onConnected: (next, host) => { + const nextGeneration = { id: ++generationId, host } + publishGeneration(nextGeneration) + if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return publishDescription(next) // A description subscriber may synchronously stop the loop. In that // case publishDescription(undefined) has already retracted this // generation, so do not leak its stale connected notification to // the consumer sink afterward. if (!ownsGeneration() || !Object.is(description, next)) return - sinks.onConnected?.(next) + sinks.onConnected?.(next, host) }, onStateChange: (state) => { - if (state === 'reconnecting') publishDescription(undefined) + if (state === 'reconnecting') { + publishGeneration(undefined) + publishDescription(undefined) + } if (!ownsGeneration()) return sinks.onStateChange?.(state) }, diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index 443d398eba..fb5257e355 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -37,7 +37,7 @@ class GenerationProbe { } this.active.add(finish) signal.addEventListener('abort', finish, { once: true }) - ready() + ready({ home: '/h' }) if (signal.aborted) finish() }) diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 03f57f9273..40f9a5ec6c 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -192,7 +192,7 @@ describe('connection lifecycle', () => { const controller = new ConnectionController(api, (signal, ready) => { sourceCalls++ if (sourceCalls === 1) return fail() - ready() + ready({ home: '/h' }) return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index bc546ad185..639f52f2b9 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -95,7 +95,10 @@ export class FakeApiClient implements IApiClient { return response } - private async openGeneration(signal: AbortSignal, onOpen: () => void): Promise { + private async openGeneration( + signal: AbortSignal, + onOpen: (host: { readonly home: string }) => void, + ): Promise { const inbox: StreamItem[] = [] let wake: (() => void) | null = null const conn: StreamConn = { @@ -105,8 +108,9 @@ export class FakeApiClient implements IApiClient { }, } this.generationConns.push(conn) - if (this.holdGenerationReady) this.heldOpens.push(onOpen) - else if (!this.suppressGenerationReady) onOpen() + const ready = (): void => { onOpen({ home: '/h' }) } + if (this.holdGenerationReady) this.heldOpens.push(ready) + else if (!this.suppressGenerationReady) ready() try { while (!signal.aborted) { while (inbox.length > 0) { diff --git a/packages/client/connection/tests/generation.client.spec.ts b/packages/client/connection/tests/generation.client.spec.ts new file mode 100644 index 0000000000..0deace5dba --- /dev/null +++ b/packages/client/connection/tests/generation.client.spec.ts @@ -0,0 +1,65 @@ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + apply, + type ConnectionGenerationSource, + type ConnectionHandle, +} from '../src/client/index.ts' + +type BrowserGlobal = { + location?: { hostname: string; search: string } +} + +const contexts = new Set() + +afterEach(async () => { + vi.restoreAllMocks() + delete (globalThis as BrowserGlobal).location + await Promise.all([...contexts].map(async ctx => ctx.fiber.dispose())) + contexts.clear() +}) + +async function mount(): Promise { + ;(globalThis as BrowserGlobal).location = { hostname: 'localhost', search: '?fixture' } + const ctx = new Context() + contexts.add(ctx) + await ctx.plugin({ apply, inject: [] }) + const connection = ctx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error('fixture did not provide Connection') + return connection +} + +describe('Connection generation facts', () => { + it('publishes ready-frame Host facts and retracts them when the loop stops', async () => { + const connection = await mount() + const source: ConnectionGenerationSource = (signal, ready) => { + ready({ home: '/home/from-ready' }) + return new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + connection.registerGenerationSource(source) + const seen: Array = [] + const stopListening = connection.generation.subscribe(() => { + seen.push(connection.generation.getSnapshot()?.host.home) + }) + const loop = connection.start({}, { + backoffBaseMs: 1, + backoffFactor: 1, + backoffMaxMs: 1, + generationReadyTimeoutMs: 100, + }) + + await vi.waitFor(() => { + expect(connection.generation.getSnapshot()).toEqual({ + id: 1, + host: { home: '/home/from-ready' }, + }) + }) + loop.stop() + expect(connection.generation.getSnapshot()).toBeUndefined() + expect(seen).toEqual(['/home/from-ready', undefined]) + stopListening() + }) +}) From 40929d6e1ad983e8ebeed94e4639e1f4bbf22b64 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:49:26 +0800 Subject: [PATCH 04/11] refactor(client): replace host description consumers --- .../tests/agent-preset-authoring.overlay.yml | 6 +- apps/web/tests/produced-files.overlay.yml | 2 +- packages/api/session-controller/src/index.ts | 19 ++++- .../tests/client-apply.client.spec.ts | 55 ++++--------- .../tests/fake-api.client.ts | 21 +---- .../session-open-workspace-path.host.spec.ts | 33 ++++++++ .../session-controller/tests/test-remote.ts | 18 ++++- packages/api/settings-controller/src/index.ts | 9 +++ .../tests/settings-controller.host.spec.ts | 3 + .../tests/transport.client.spec.ts | 11 --- .../ui-agent-preset/src/client/index.ts | 6 +- .../src/client/section-store.ts | 7 +- .../tests/apply.client.spec.ts | 14 +--- .../tests/section-store.client.spec.ts | 40 +++------- packages/client/ui-deliverables/package.json | 4 + .../src/client/ProducedFiles.tsx | 16 ++-- .../ui-deliverables/src/client/index.ts | 35 +++++++- .../tests/produced-files.client.spec.tsx | 80 ++++++++++++++++--- packages/client/ui-deliverables/tsconfig.json | 6 ++ .../client/ui-reference/src/client/index.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 4 +- packages/client/ui-tool/src/client/apply.ts | 2 +- .../ui-tool/src/client/contract/slots.ts | 12 +-- packages/client/ui-tool/src/client/index.ts | 2 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 4 +- .../ui-tool/src/client/tool/ToolDetails.tsx | 6 +- .../tool/toolviews/ask-question-row.tsx | 2 +- .../tests/ask-question-row.client.spec.tsx | 4 +- .../tests/assembly-surfaces.client.spec.tsx | 3 +- .../tests/chat-code-subcalls.client.spec.tsx | 3 +- .../ui-tool/tests/read-card.client.spec.tsx | 4 +- .../tests/tool-call-tree.client.spec.tsx | 10 +-- .../tests/tool-details-render.client.tsx | 8 +- .../tests/toolview-slot.client.spec.tsx | 6 +- .../ui-workspace/src/client/contract/slots.ts | 4 +- .../client/ui-workspace/src/client/index.ts | 4 +- .../src/client/rows/WorkspaceBrowser.tsx | 4 +- .../ui-workspace/tests/apply.client.spec.ts | 4 +- .../tests/rename-assembly.client.spec.tsx | 2 +- .../tests/workspace-browser.client.spec.tsx | 6 +- 40 files changed, 283 insertions(+), 198 deletions(-) diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml index d39b5307ea..3791809a7e 100644 --- a/apps/web/tests/agent-preset-authoring.overlay.yml +++ b/apps/web/tests/agent-preset-authoring.overlay.yml @@ -1,14 +1,12 @@ # The authoring lane drives the location affordance. A real desktop open # would pop a file manager on the machine running the tests and the # capability itself is platform-detected (macOS yes, headless Linux CI no), -# so the gateway is pinned headless: `hasDocument` is false everywhere and +# so both native-open owners are pinned headless: `hasDocument` is false everywhere and # `openDocument` answers the directory as text — the same branch on every # host, and the one whose rendering a golden can hold. A patch replaces the # row's complete config, so the shipped routing defaults ride along. -- id: api-gateway +- id: session-controller config: - provider: deepseek-official - model: deepseek-v4-flash nativeOpen: false - id: settings-controller config: diff --git a/apps/web/tests/produced-files.overlay.yml b/apps/web/tests/produced-files.overlay.yml index ceac487918..4267b6d2cf 100644 --- a/apps/web/tests/produced-files.overlay.yml +++ b/apps/web/tests/produced-files.overlay.yml @@ -1,7 +1,7 @@ # The summary test asserts the native-folder action without launching it. Pin # the capability so headless Linux CI and desktop developer hosts expose the # same UI branch; platform opener behavior belongs to the Host unit tests. -- id: api-gateway +- id: session-controller config: nativeOpen: true - id: settings-controller diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b58cdfcbad..342dd977eb 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' -import { openNativePath } from '@deepseek-ai/dsh-native-command' +import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' @@ -67,12 +67,16 @@ declare module '@deepseek-ai/cordis' { export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean } /** Host integrations replaceable by direct unit tests. */ export interface SessionControllerInternals { /** Native default-application handoff. */ readonly openPath?: (path: string, signal: AbortSignal) => Promise + /** Native handoff availability probe. */ + readonly canOpenPath?: () => boolean } /** Host service backing the generated `ctx.remote.session` namespace. */ @@ -91,6 +95,7 @@ export class SessionController extends TypertRemoteService { static Config: z = z.object({ coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), + nativeOpen: z.boolean(), }) private readonly agents: ApiSessionAgentController @@ -99,6 +104,7 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly canOpenPath: () => boolean private readonly promotions = new Set>() /** @@ -122,6 +128,8 @@ export class SessionController extends TypertRemoteService { config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, ) this.openPath = internals.openPath ?? openNativePath + this.canOpenPath = internals.canOpenPath + ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) ctx.plugin(SessionSkillCatalog) @@ -242,6 +250,15 @@ export class SessionController extends TypertRemoteService { return buildModelCatalog(this.ctx) } + /** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ + @Remote + canOpenWorkspacePath(): boolean { + return this.canOpenPath() + } + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index b6c7e40972..2ecaff6804 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -1,8 +1,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import type { + ConnectionGeneration, ConnectionHandle, - HostDescription, } from '@deepseek-ai/dsh-client-connection/client' import { RemoteStreamCarrierError, @@ -16,13 +16,7 @@ import * as SessionClient from '../src/client/index.ts' import { ClientSessions } from '../src/client/sessions/service.ts' import { FakeApiClient, fakeRemote } from './fake-api.client.ts' -const DESCRIPTION: HostDescription = { - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, -} +const GENERATION: ConnectionGeneration = { id: 1, host: { home: '/home/fixture' } } const sid = (value: string): SessionId => value as SessionId @@ -34,7 +28,7 @@ interface Bench { readonly fiber: Fiber readonly sessions: ClientSessions dispatch(event: string, ...args: unknown[]): void - publishHost(description: HostDescription | undefined): void + publishGeneration(generation: ConnectionGeneration | undefined): void } const contexts = new Set() @@ -45,41 +39,22 @@ afterEach(async () => { contexts.clear() }) -async function mount(initialHost?: HostDescription): Promise { +async function mount(initialGeneration?: ConnectionGeneration): Promise { const ctx = new Context() contexts.add(ctx) await ctx.plugin(TypertRegistry) const api = new FakeApiClient() const remote = fakeRemote(api) const listeners = new Map>() - const hostListeners = new Set<() => void>() - let host = initialHost + const generationListeners = new Set<() => void>() + let generation = initialGeneration const connection: ConnectionHandle = { - api, isLoopback: true, - hostDescription: { - getSnapshot: () => host, - subscribe: (listener) => { - hostListeners.add(listener) - return () => { hostListeners.delete(listener) } - }, - }, generation: { - getSnapshot: () => host === undefined - ? undefined - : { id: 1, host: { home: host.home } }, + getSnapshot: () => generation, subscribe: (listener) => { - hostListeners.add(listener) - return () => { hostListeners.delete(listener) } - }, - }, - generation: { - getSnapshot: () => host === undefined - ? undefined - : { id: 1, host: { home: host.home } }, - subscribe: (listener) => { - hostListeners.add(listener) - return () => { hostListeners.delete(listener) } + generationListeners.add(listener) + return () => { generationListeners.delete(listener) } }, }, rpc: { @@ -115,9 +90,9 @@ async function mount(initialHost?: HostDescription): Promise { dispatch: (event, ...args) => { for (const listener of listeners.get(event) ?? []) listener(...args as never[]) }, - publishHost: (description) => { - host = description - for (const listener of [...hostListeners]) listener() + publishGeneration: (next) => { + generation = next + for (const listener of [...generationListeners]) listener() }, } } @@ -166,7 +141,7 @@ describe('Session Controller Client apply', () => { it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => { const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) - const bench = await mount(DESCRIPTION) + const bench = await mount(GENERATION) await flush() expect(accept).toHaveBeenCalledWith({ @@ -198,7 +173,7 @@ describe('Session Controller Client apply', () => { }) it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => { - const bench = await mount(DESCRIPTION) + const bench = await mount(GENERATION) await flush() expect(bench.sessions.list.getSnapshot().phase).toBe('ready') @@ -230,7 +205,7 @@ describe('Session Controller Client apply', () => { await flush() expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) - bench.publishHost(DESCRIPTION) + bench.publishGeneration(GENERATION) await flush() expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) }) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 851ce01030..cb6a635bef 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -1,8 +1,8 @@ -// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo +// Test-local programmable Remote fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. import type { - IApiClient, MessageId, + MessageId, RpcError, RpcResponse, SessionId, SessionSearchItem, SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, WorkspaceId, WorkspaceView, @@ -122,7 +122,7 @@ export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes { return api.sessionRemotes() } -export class FakeApiClient implements IApiClient { +export class FakeApiClient { /** Chronological call record: [method, payload]. */ readonly calls: { method: string; payload: unknown }[] = [] /** Session ids in physical follow-generation opening order. */ @@ -157,16 +157,6 @@ export class FakeApiClient implements IApiClient { onOpenWorkspacePath: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ opened: true as const })) - onDescribe: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - })) private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] private readonly workspaceConns: ValueStreamConn[] = [] @@ -191,10 +181,6 @@ export class FakeApiClient implements IApiClient { onSubagentInterrupt: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ accepted: true as const })) - readonly host: IApiClient['host'] = { - describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), - } - onWorkspaceCreate: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true })) @@ -223,6 +209,7 @@ export class FakeApiClient implements IApiClient { execute: () => Promise.resolve({ ok: true, value: undefined }), }, session: { + canOpenWorkspacePath: () => Promise.resolve(remoteOk(true)), list: payload => this.remoteResult('session.list', payload, this.onList(payload)), modelCatalog: () => Promise.resolve({ ok: true, diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index ee24947227..2c1feb292f 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -15,6 +15,39 @@ async function context(): Promise { } describe('session/openWorkspacePath', () => { + it('reports the deployment opener capability independently of a Session', async () => { + const ctx = await context() + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + canOpenPath: () => false, + }) + + await expect(remote.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) + }) + + it('derives opener availability from config, an injected opener, or the platform probe', async () => { + const configured = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + nativeOpen: false, + }) + await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) + + const injected = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath: () => Promise.resolve(), + }) + await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true }) + + const detected = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + }) + await expect(detected.canOpenWorkspacePath()).resolves.toMatchObject({ ok: true }) + }) + it('hands a Client-resolved workspace path to the Host opener unchanged', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 77e813b324..e2e6292bce 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -51,6 +51,7 @@ import type { /** Direct test face matching the generated `ctx.remote.session` unary methods. */ export interface TestSessionRemote { + canOpenWorkspacePath(): Promise> list(request: SessionListRequest, signal?: AbortSignal): Promise> search(request: SessionSearchRequest, signal?: AbortSignal): Promise> create(request: SessionCreateRequest): Promise> @@ -76,8 +77,10 @@ export interface TestSessionRemoteDefaults { readonly defaultModelSelection: () => AgentModelSelection readonly cwd: string readonly coldBlankProbeMaxBytes?: number + readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly canOpenPath?: () => boolean } const installed = new WeakMap() @@ -185,10 +188,16 @@ function installControllers( try { controller = new SessionController( ctx, - defaults.coldBlankProbeMaxBytes === undefined - ? {} - : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }, - defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + { + ...defaults.coldBlankProbeMaxBytes === undefined + ? {} + : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }, + ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen }, + }, + { + ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, + }, ) } finally { cwd.mockRestore() @@ -233,6 +242,7 @@ export function createSessionTestRemote( ): TestSessionRemote { const direct = createSessionTestController(ctx, defaults) return { + canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()), list: (request, signal = new AbortController().signal) => remoteResult( () => direct.list(request, signal), signal, diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index e822d7f489..5fa81d1518 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -128,6 +128,15 @@ export class SettingsController extends TypertRemoteService { } } + /** + * Report whether this deployment can open an authored Agent preset directory natively. + * @returns true when the matching open operation is available. + */ + @Remote + canOpenAgentPresetDirectory(): boolean { + return this.canOpenPath() + } + /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index aa8945b4fc..7195e6650f 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -82,6 +82,7 @@ describe('the settings Remote namespace a configuration page calls', () => { expect(controller.typertRemote.namespace).toBe('settings') expect(remoteMethods(controller)).toEqual([ { method: 'describe', invocation: { kind: 'direct' } }, + { method: 'canOpenAgentPresetDirectory', invocation: { kind: 'direct' } }, { method: 'update', invocation: { kind: 'direct' } }, { method: 'replace', invocation: { kind: 'direct' } }, { method: 'mutate', invocation: { kind: 'direct' } }, @@ -348,6 +349,7 @@ describe('the settings Remote namespace a configuration page calls', () => { } as never) const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const openable = new SettingsController(ctx, { nativeOpen: true }, { openPath }) + expect(openable.canOpenAgentPresetDirectory()).toBe(true) const signal = new AbortController().signal await expect(openable.openAgentPresetDirectory('mine', signal)) .resolves.toEqual({ opened: true }) @@ -360,6 +362,7 @@ describe('the settings Remote namespace a configuration page calls', () => { }), } as never) const reveal = new SettingsController(headless, { nativeOpen: false }) + expect(reveal.canOpenAgentPresetDirectory()).toBe(false) await expect(reveal.openAgentPresetDirectory('mine', new AbortController().signal)) .resolves.toEqual({ opened: false, path: '/presets/mine' }) }) diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 14cf3a02db..bdd01d33ea 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -197,18 +197,7 @@ async function waitFor(check: () => void): Promise { function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { const connection: ConnectionHandle = { - api: {} as ConnectionHandle['api'], isLoopback: true, - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, - }), - subscribe: () => () => {}, - }, generation: AVAILABLE_CONNECTION.generation, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 4019655d72..4926d7c001 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -11,7 +11,6 @@ * before-the-fact, while the header only reports what a session already runs. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the Session Controller service merge (ctx.sessions). import type {} from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -51,7 +50,7 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t /** Required services (cordis fiber inject). */ export const inject = [ - 'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', ] /** @@ -59,13 +58,12 @@ export const inject = [ * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - const { api } = ctx.get('connection') as ConnectionHandle const settingsWire = { settings: ctx.remote.settings } const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe()) // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() - const section = new AgentPresetSectionController(api, ctx.remote, () => { + const section = new AgentPresetSectionController(ctx.remote, () => { void controller.load() for (const read of rosterReaders) read() }) diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 70baab32ec..43db65686d 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -14,7 +14,7 @@ * more than the row it targeted. */ -import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' @@ -133,7 +133,6 @@ export class AgentPresetSectionController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) constructor( - private readonly api: Pick, private readonly remote: Pick, /** * Called after this page changes the roster DIRECTORY, so the other @@ -168,13 +167,13 @@ export class AgentPresetSectionController { // Issued together: one round trip decides the page, and a load that waited // for them in turn would hold the section in `loading` twice as long, // where a concurrent reload silently returns instead of refreshing. - const opener = this.api.host.describe({}) + const opener = this.remote.settings.canOpenAgentPresetDirectory() const roster = await beginRosterRead(this.remote, this.store) // A refused describe leaves the reveal-the-path path, which needs no opener. const described = await opener.catch(() => undefined) if (roster === undefined) return const { presets, authorable } = roster - const hasDocument = described?.result.ok === true && described.result.value.canOpenPath + const hasDocument = described?.ok === true && described.value if (presets.length === 0) { // Nothing to manage leaves nothing to keep a dialog open over. this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null }) diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index b7e73785e4..c2488de4af 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -74,6 +74,7 @@ async function bench() { // The row reads `describe` to learn whether this browser may write at all, // and its default write is the one op this spec records. const settings = { + canOpenAgentPresetDirectory: () => Promise.resolve({ ok: true as const, value: true }), describe: () => Promise.resolve({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] }, @@ -114,16 +115,7 @@ async function bench() { } ctx.provide('remote.agentPresets', agentPresets as never) Object.assign(remote, { agentPresets }) - ctx.provide('connection', { - api: { - host: { - describe: () => Promise.resolve({ - rpcId: 'r', - result: { ok: true as const, value: { canOpenPath: true } }, - }), - }, - }, - } as never) + ctx.provide('connection', { isLoopback: true } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote } } @@ -185,7 +177,7 @@ function sessionsDouble(state: { describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', ]) }) diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index df1ad1e201..0b732c4cf5 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' @@ -41,36 +41,16 @@ interface FakeOptions { authorable?: boolean /** Whether the host can open a preset directory on a desktop. */ hasDocument?: boolean - /** Reject `host.describe`, as a dead transport does. */ - throwDescribe?: boolean + /** Reject the opener capability read, as a dead transport does. */ + throwCapability?: boolean /** Hold `remove` until this resolves, to observe the in-flight state. */ holdRemove?: Promise } -const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } }) const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value }) const remoteFail = (message: string) => Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } }) -/** - * The carried wire face: the desktop opener, the default write, and the opener - * capability the page joins onto the roster. - * @param defaultId - the preset a session with no choice gets. - * @param options - failure injection and call recording. - * @returns the fake client. - */ -function fakeApi( - options: FakeOptions = {}, -): Pick { - return { - host: { - describe: () => (options.throwDescribe === true - ? Promise.reject(new Error('socket closed')) - : ok({ canOpenPath: options.hasDocument ?? true })), - }, - } as Pick -} - /** * The Remote namespace over an in-memory preset store: copies land, so the * roster the controller re-reads after a copy is the one the copy produced. @@ -143,6 +123,12 @@ function fakeRemote( }, }, settings: { + canOpenAgentPresetDirectory: () => { + record('canOpenAgentPresetDirectory', {}) + return options.throwCapability === true + ? Promise.reject(new Error('socket closed')) + : remoteOk(options.hasDocument ?? true) + }, update: (ns: string, patch: { default?: string }) => { record('settings.update', { ns, patch }) if (options.failSettings !== undefined) return remoteFail(options.failSettings) @@ -176,7 +162,6 @@ function harness(options: FakeOptions = {}) { let rosterChanges = 0 const wired = { ...options, calls: options.calls ?? calls } const controller = new AgentPresetSectionController( - fakeApi(wired), fakeRemote(presets, defaultId, wired), () => { rosterChanges += 1 }, ) @@ -191,11 +176,11 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft { describe('loading the roster', () => { it('still lists the roster when the opener capability cannot be read', async () => { - const { controller } = harness({ throwDescribe: true }) + const { controller } = harness({ throwCapability: true }) await controller.load() - // The two reads are independent: a refused `host.describe` costs the + // The two reads are independent: a refused capability query costs the // open-directory affordance, not the page. const state = controller.store.getSnapshot() expect(state.status).toBe('ready') @@ -572,7 +557,6 @@ describe('deleting', () => { await controller.load() presets.clear() const broken = new AgentPresetSectionController( - { host: {} } as unknown as Pick, { agentPresets: { list: () => Promise.reject(new Error('gone')), @@ -596,7 +580,7 @@ describe('a controller with no roster listener', () => { const presets = seed() const defaultId = { id: 'standard' } const alone = new AgentPresetSectionController( - fakeApi(), fakeRemote(presets, defaultId)) + fakeRemote(presets, defaultId)) await alone.load() alone.confirmDelete('mine') diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 30b6de4e7f..15dce11815 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-chat", @@ -47,6 +48,7 @@ }, "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", @@ -58,8 +60,10 @@ "@deepseek-ai/dsh-session": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index 6841e734e7..b3f0f04c0a 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,6 +1,5 @@ -import { useLayoutEffect, useRef, useState } from 'react' -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' @@ -44,9 +43,11 @@ export function fitProducedFiles( export interface ProducedFilesInjected { /** Whether the browser itself is connected over loopback. */ isLoopback: boolean + /** Load the opener capability when this row first reaches the page. */ + ensureWorkspacePathOpen(): void hooks: { - /** Current generation's Host description, bound by the slot renderer. */ - hostDescription: HostDescriptionSource + /** Current generation's Session workspace opener capability. */ + workspacePathOpen: HostObservable } } @@ -65,9 +66,10 @@ function moreLabel(t: ProducedFilesProps['t'], count: number): string { * @returns The produced-files row. */ export function ProducedFiles({ - matched: paths, openFile, isLoopback, useHostDescription, t, + matched: paths, openFile, isLoopback, ensureWorkspacePathOpen, useWorkspacePathOpen, t, }: ProducedFilesProps) { - const hostCanOpenPath = useHostDescription(description => description?.canOpenPath === true) + useEffect(() => { ensureWorkspacePathOpen() }, [ensureWorkspacePathOpen]) + const hostCanOpenPath = useWorkspacePathOpen(available => available === true) const canOpenPath = isLoopback && hostCanOpenPath const limit = Math.min(paths.length, SHOWN_LIMIT) const [shownCount, setShownCount] = useState(limit) diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 0e8767016e..3be17e7da4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -9,6 +9,8 @@ */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -30,7 +32,7 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' export { producedForClosing } from './turn-deliverables.ts' /** Required services for the tail-slot registration and its dictionaries. */ -export const inject = ['slots', 'locale', 'uiConversation', 'connection'] +export const inject = ['slots', 'locale', 'uiConversation', 'connection', 'remote', 'remote.session'] /** * Client plugin body: register the dictionaries and the turn-tail entry. @@ -38,6 +40,34 @@ export const inject = ['slots', 'locale', 'uiConversation', 'connection'] */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle + const workspacePathOpen = createSnapshotStore(undefined) + let requestedWorkspacePathOpen = false + let capabilityRevision = 0 + let pendingCapability: Promise | undefined + const loadWorkspacePathOpen = (): void => { + if (pendingCapability !== undefined) return + const revision = capabilityRevision + const pending = ctx.remote.session.canOpenWorkspacePath() + .then((result) => { + if (revision === capabilityRevision) workspacePathOpen.set(result.ok && result.value) + }, () => { + if (revision === capabilityRevision) workspacePathOpen.set(false) + }) + .finally(() => { + if (pendingCapability === pending) pendingCapability = undefined + }) + pendingCapability = pending + } + const ensureWorkspacePathOpen = (): void => { + requestedWorkspacePathOpen = true + if (workspacePathOpen.getSnapshot() === undefined) loadWorkspacePathOpen() + } + ctx.on('connection/reset', () => { + capabilityRevision++ + pendingCapability = undefined + workspacePathOpen.set(undefined) + if (requestedWorkspacePathOpen) loadWorkspacePathOpen() + }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -48,7 +78,8 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: () => ({ isLoopback: connection.isLoopback, - hooks: { hostDescription: connection.hostDescription }, + ensureWorkspacePathOpen, + hooks: { workspacePathOpen }, }), }, ProducedFiles), ) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 9117fa3925..5a0043971e 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -22,7 +22,7 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { - fitProducedFiles, ProducedFiles, type ProducedFilesProps, + fitProducedFiles, ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps, } from '../src/client/ProducedFiles.tsx' import { basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, @@ -403,13 +403,11 @@ describe('ProducedFiles row', () => { const capability = ( canOpenPath: boolean | undefined, isLoopback = true, - ): Pick => { - const description = canOpenPath === undefined - ? undefined - : { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath } + ): Pick => { return { isLoopback, - useHostDescription: selector => selector(description), + ensureWorkspacePathOpen: () => {}, + useWorkspacePathOpen: selector => selector(canOpenPath), } } @@ -580,14 +578,17 @@ describe('plugin registration', () => { name: 'root', children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) - const hostDescription = { getSnapshot: () => undefined, subscribe: () => () => {} } + const generation = { getSnapshot: () => undefined, subscribe: () => () => {} } ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription, + generation, } as never) // ui-theme's Appearance row binds a durable scope through these two. - ctx.provide('remote', { $on: () => () => {} } as never) + const session = { + canOpenWorkspacePath: () => Promise.resolve({ ok: true as const, value: true }), + } + ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote.session', session as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() @@ -595,7 +596,16 @@ describe('plugin registration', () => { await fiber.await() const [entry] = ctx.slots.entries('conversation.chat.turnTail') expect(entry).toBeDefined() - expect(entry?.inject?.()).toEqual({ isLoopback: false, hooks: { hostDescription } }) + const injected = entry?.inject?.() as unknown as ProducedFilesInjected + expect(injected.isLoopback).toBe(false) + expect(typeof injected.ensureWorkspacePathOpen).toBe('function') + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined() + ctx.emit('connection/reset') + injected.ensureWorkspacePathOpen() + await vi.waitFor(() => { + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) + }) + injected.ensureWorkspacePathOpen() // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. @@ -617,4 +627,52 @@ describe('plugin registration', () => { // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) + + it('queries the workspace opener lazily and replaces stale results after reconnect', async () => { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + new UiConversation(ctx, { binding: () => undefined } as never) + ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + } as never, () => null) + ctx.provide('connection', { + isLoopback: true, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) + const first = Promise.withResolvers<{ ok: true; value: boolean }>() + const second = Promise.withResolvers<{ ok: true; value: boolean }>() + const staleFailure = Promise.withResolvers<{ ok: true; value: boolean }>() + const capability = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + .mockReturnValueOnce(staleFailure.promise) + .mockRejectedValueOnce(new Error('offline')) + const session = { canOpenWorkspacePath: capability } + ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote.session', session as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const entry = ctx.slots.entries('conversation.chat.turnTail')[0] + const injected = entry?.inject?.() as unknown as ProducedFilesInjected + + injected.ensureWorkspacePathOpen() + injected.ensureWorkspacePathOpen() + expect(capability).toHaveBeenCalledOnce() + ctx.emit('connection/reset') + expect(capability).toHaveBeenCalledTimes(2) + first.resolve({ ok: true, value: false }) + await Promise.resolve() + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined() + second.resolve({ ok: true, value: true }) + await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) }) + + ctx.emit('connection/reset') + ctx.emit('connection/reset') + staleFailure.reject(new Error('stale offline')) + await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(false) }) + await fiber.dispose() + }) }) diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json index 51e6d50b5d..9c29b3efdc 100644 --- a/packages/client/ui-deliverables/tsconfig.json +++ b/packages/client/ui-deliverables/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -17,6 +20,9 @@ { "path": "../locale" }, + { + "path": "../store" + }, { "path": "../ui-conversation" }, diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index c82adcdbe4..09ecf5aaa9 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -64,7 +64,7 @@ export function apply(ctx: ClientContext): void { // when there is no header to carry it. const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined const now = Date.now() - const home = connection.hostDescription.getSnapshot()?.home + const home = connection.generation.getSnapshot()?.host.home const listed = sessions.list.getSnapshot().byId return [ ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)), 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 03e19b1b27..4565e170db 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -92,7 +92,7 @@ async function bench( ctx.provide('remote.fileReferences', { list: files }) ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('connection', { hostDescription: { getSnapshot: () => ({ home: HOME }) } }) + ctx.provide('connection', { generation: { getSnapshot: () => ({ id: 1, host: { home: HOME } }) } }) ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -124,7 +124,7 @@ describe('apply', () => { ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined } }) + ctx.provide('connection', { generation: { getSnapshot: () => undefined } }) ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } }) const ownFiber = ctx.plugin({ inject: [...inject], apply }) await ownFiber.await() diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index dce00a0f54..385dc58edf 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -24,7 +24,7 @@ export const inject = ['slots', 'connection'] */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle - const toolInject = () => ({ hooks: { hostDescription: connection.hostDescription } }) + const toolInject = () => ({ hooks: { connectionGeneration: connection.generation } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index c9206c8cd9..ce39262499 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,5 @@ /** Tool UI slot declarations and their composed component props. */ -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -47,10 +47,10 @@ export interface ToolCallOwnerProps { export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ -export type ToolHostDescriptionInjected = { +export type ToolConnectionGenerationInjected = { hooks: { - /** Current generation's Host description, bound by the slot renderer. */ - hostDescription: HostDescriptionSource + /** Current Connection generation, bound by the slot renderer. */ + connectionGeneration: ConnectionGenerationState } } @@ -58,9 +58,9 @@ export type ToolHostDescriptionInjected = { export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace /** Full props of the selected Tool output renderer in the details panel. */ export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index 2079d09a96..e27656cebb 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,5 +1,5 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' export type { - ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostDescriptionInjected, ToolTreeProps, + ToolCallOwnerProps, ToolCallViewProps, ToolConnectionGenerationInjected, ToolDetailsProps, ToolTreeProps, } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index ebb4578281..6a15997531 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,9 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostDescription, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useConnectionGeneration, t, }: ToolTreeProps) { - const home = useHostDescription(description => description?.home) + const home = useConnectionGeneration(generation => generation?.host.home) const block = node.data.root return ( ) { - const home = useHostDescription(description => description?.home) + block, cwd, useConnectionGeneration, t, +}: Pick) { + const home = useConnectionGeneration(generation => generation?.host.home) const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 44fca5b9e4..4775686284 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -140,7 +140,7 @@ type AskQuestionRowProps = ToolCallViewProps & PropsLocale<'conversation'> export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowProps) { const model = toolRowModel(toolName, block) // Composer verdicts settle the call as specific UserQuestionErrors - // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own + // (ask_user_question handler): 'ASK_CANCELLED' is the user's own // dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the // question was pending. Both name their verdict instead of the generic // failed shape, and the abort keeps the shared stopped (amber) semantics of diff --git a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx index 22f1e0d5e4..6904b7dfad 100644 --- a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx @@ -168,7 +168,7 @@ describe('AskQuestionRow', () => { }) it('user cancellation shows the original questions without raw JSON or an error body', () => { - // ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error. + // ASK_CANCELLED: the ask_user_question handler's cancel error. const view = render() expect(screen.getByText('已取消')).toBeTruthy() @@ -184,7 +184,7 @@ describe('AskQuestionRow', () => { }) it('a turn abort shows the original questions with stopped semantics', () => { - // ASK_ABORTED: the apiproxy ask handler's turn-abort settlement. + // ASK_ABORTED: the ask handler's turn-abort settlement. const view = render() expect(screen.getByText('已中断')).toBeTruthy() diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 63cea1a148..960c81f89b 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -72,9 +72,8 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) new TestRemote(runtime.ctx, { session: { diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index a23e003800..bb0b40b74d 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -121,9 +121,8 @@ async function bench(snapshot: ChatSnapshot) { ctx.provide('uiWorkspace', {} as never) new TestRemote(ctx, { session: { openWorkspacePath } }) ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 40b7042f30..d61b1fe6b7 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -361,9 +361,7 @@ describe('DetailsPanel Output section (read)', () => { it('abbreviates a leftover POSIX home path on the read card label', () => { const view = mount(snapshot({ nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })], - }), target, '/tmp/ws', { - version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false, - }) + }), target, '/tmp/ws', { id: 1, host: { home: '/Users/u' } }) expect(view.getByText('~/notes.md')).toBeTruthy() }) diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 67cf3a7962..852ffd9ef5 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -2,7 +2,7 @@ /** ToolCallTree-owned root/subcall markers and selection projection. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -23,7 +23,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ( function props( block: ToolResultNode, selectedCallId?: string, - description?: HostDescription, + generation?: ConnectionGeneration, owners?: ToolCallOwnerProps[], ): ToolTreeProps { const snapshot = {} as SessionSnapshot @@ -50,7 +50,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - useHostDescription: (selector => selector(description)) as ToolTreeProps['useHostDescription'], + useConnectionGeneration: (selector => selector(generation)) as ToolTreeProps['useConnectionGeneration'], t, } as unknown as ToolTreeProps } @@ -98,9 +98,7 @@ describe('ToolCallTree', () => { it('abbreviates a POSIX home path in the generic tool summary', () => { const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' }) - const view = render() + const view = render() expect(view.getByText('~/docs/a.ts')).toBeTruthy() }) }) diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index 96a08b57ac..b20b689724 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,5 +1,5 @@ /** Test adapter for the production conversation.details.tool registration. */ -import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client' import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' import type { @@ -144,12 +144,12 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. - * @param description - optional Host description so the details card can abbreviate home paths. + * @param generation - optional Connection generation carrying the Host home. * @returns a direct-test renderSlot implementation. */ export function renderToolDetails( t: TranslateNS<'conversation'>, - description?: HostDescription, + generation?: ConnectionGeneration, ): DetailsSlotProps['renderSlot'] { return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; @@ -158,7 +158,7 @@ export function renderToolDetails( return selector(description)} + useConnectionGeneration={selector => selector(generation)} t={t} /> } diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 719dbafb1a..4a6cedf03e 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -60,9 +60,8 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } })) new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) @@ -207,9 +206,8 @@ describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) new TestRemote(runtime.ctx, { session: { diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index f25270aad3..d7f6be2c8a 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -22,7 +22,7 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. @@ -90,7 +90,7 @@ export type DirectoryPickingHooks = PropsHooks workspaces.create(input), - hooks: { directoryFlow: browserFlowSource, hostDescription }, + hooks: { directoryFlow: browserFlowSource, connectionGeneration }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 2b2aba93ce..c8a5a15740 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -820,11 +820,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, - useHostDescription, + useConnectionGeneration, renderSlot, t, }: WorkspaceBrowserProps) { - const home = useHostDescription(description => description?.home) + const home = useConnectionGeneration(generation => generation?.host.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 3834358288..941c251f56 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -59,7 +59,7 @@ async function bench() { fork, } as never) ctx.provide('connection', { - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' })) const directoryPicker = { pick: pickDirectory } @@ -162,7 +162,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) - expect(browser.hooks.hostDescription.getSnapshot()).toBeUndefined() + expect(browser.hooks.connectionGeneration.getSnapshot()).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index cc698e7391..ea349efcc5 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -35,7 +35,7 @@ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() runtime.releaseWorkspaceSource() runtime.ctx.provide('connection', { - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) // The rename flow never picks a directory; the namespace only has to be there // for ui-workspace's inject to settle. 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 a623c3334d..22e4062abe 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -85,7 +85,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), - useHostDescription: selector => selector(undefined), + useConnectionGeneration: selector => selector(undefined), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, @@ -110,9 +110,7 @@ describe('WorkspaceBrowser', () => { path: '/home/u/Documents/project', title: 'Project', }])), - useHostDescription: selector => selector({ - version: '0', cwd: '/tmp', attachedSessions: 0, home: '/home/u', canOpenPath: false, - }), + useConnectionGeneration: selector => selector({ id: 1, host: { home: '/home/u' } }), }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) From 3b40a145552be1e15b83be87789a5258f25674a1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:50:19 +0800 Subject: [PATCH 05/11] test(session-export): assign Host compiler face --- apps/cli/tests/web-agent-presets.e2e.ts | 3 + .../session-log-export/package.json | 1 - .../session-log-export/src/index.ts | 19 +- .../tests/archive.host.spec.ts | 734 ++++++++++++++++++ ...nd.client.spec.ts => command.host.spec.ts} | 0 ....client.spec.ts => invariant.host.spec.ts} | 0 ...pec.ts => loader-composition.host.spec.ts} | 0 .../tests/route.host.spec.ts | 9 +- 8 files changed, 751 insertions(+), 15 deletions(-) create mode 100644 packages/session-query/session-log-export/tests/archive.host.spec.ts rename packages/session-query/session-log-export/tests/{command.client.spec.ts => command.host.spec.ts} (100%) rename packages/session-query/session-log-export/tests/{invariant.client.spec.ts => invariant.host.spec.ts} (100%) rename packages/session-query/session-log-export/tests/{loader-composition.client.spec.ts => loader-composition.host.spec.ts} (100%) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index a351cee4ee..7903cb6e9e 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -84,6 +84,9 @@ async function bootWeb( { id: 'skill-badge', disabled: false }, { id: 'modules', disabled: true }, { id: 'connection', disabled: true }, + // Export owns a Connection Fetch route, so this Host-only composition + // disables it with the transport service above. + { id: 'session-log-download', disabled: true }, // The always-on reload chain waits for the browser roster and bound port // disabled above. { id: 'client-hmr', disabled: true }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 9d7cd557e4..380357093c 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -59,7 +59,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 4be729d3a0..a9cc725c6b 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -80,11 +80,16 @@ export function apply(ctx: Context, config: Config = {}): void { connectionOf(ctx).fetch.register({ path: SESSION_LOG_EXPORT_PATH, methods: ['GET', 'HEAD'], - fetch: request => sessionLogExportResponse( - ctx, - request, - config.compressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, - ), + fetch: async (request) => { + const response = await sessionLogExportResponse( + ctx, + request, + config.compressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, + ) + if (request.method === 'GET') return response + await response.body?.cancel() + return new Response(null, { status: response.status, headers: response.headers }) + }, }) } @@ -153,7 +158,5 @@ async function sessionLogExportResponse( }, }, ) - if (request.method === 'GET') return response - await response.body?.cancel() - return new Response(null, { status: response.status, headers: response.headers }) + return response } diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts new file mode 100644 index 0000000000..c41846039a --- /dev/null +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -0,0 +1,734 @@ +/** + * session.export host path: the GET download endpoint streams a ZIP whose + * files are the stored artifacts verbatim (root + optional descendants), and + * the degenerate compositions fail loudly (missing services → 500, missing + * root → 404, missing descendant → errored stream). + */ + +import { randomBytes } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { unzipSync, strFromU8 } from 'fflate' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import * as SessionLogExport from '../src/index.ts' + +const sid = (id: string): SessionId => id as SessionId + +function header(id: string, parentSession?: SessionId): SessionHeader { + return { + version: 0, + id: sid(id), + createdAt: 1000, + cwd: '/proj', + ...parentSession === undefined ? {} : { parentSession }, + delegationDepth: parentSession === undefined ? 0 : 1, + } +} + +function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact { + return { + meta: header(id, parentSession), + filename: 'session.jsonl', + content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`, + } +} + +function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode { + return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants } +} + +/** One durable image object served by the fake attachment store. */ +function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') { + return { + ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef, + data: new Uint8Array([1, 2, 3, 4]), + } +} + +/** A user/message event line carrying one image reference. */ +function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string { + return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}` +} + +async function buildApi( + artifacts: Record, + descendants: SessionLineageNode[] = [], + services: { + query?: boolean + persistence?: boolean | 'throw' | 'unsupported' + attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise>) + sessions?: { + get(id: SessionId): { readonly id: SessionId } | undefined + flush(session: { readonly id: SessionId }): Promise + } + readRaw?: (id: SessionId, signal?: AbortSignal) => Promise + traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{ + target: { header: SessionHeader; live: boolean; persisted: boolean } + ancestors: readonly SessionLineageNode[] + complete: boolean + root: { header: SessionHeader; live: boolean; persisted: boolean } + descendants: readonly SessionLineageNode[] + }> + compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 + } = {}, +) { + const ctx = new Context() + ctx.provide('commands', { register: () => () => {} } as never) + const query = services.query ?? true + const persistence = services.persistence ?? true + if (query) { + ctx.provide('sessionQuery', { + traceSession: services.traceSession ?? (async () => ({ + target: { header: header('session-root'), live: false, persisted: true }, + ancestors: [], + complete: true, + root: { header: header('session-root'), live: false, persisted: true }, + descendants, + })), + } as never) + } + if (persistence) { + ctx.provide('sessionPersistence', { + supportsRawArtifacts: persistence !== 'unsupported', + readRaw: services.readRaw ?? (async (id: SessionId) => { + if (persistence === 'throw') throw new Error('/host/private/session.jsonl') + return artifacts[id] + }), + } as never) + } + if (services.attachments !== false) { + const readImage = typeof services.attachments === 'function' + ? services.attachments + : async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType) + ctx.provide('attachments', { + imageLimits: {} as never, + validateImage: async () => {}, + saveImage: async () => { throw new Error('export never saves images') }, + readImage, + } as never) + } + if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never) + const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) + const fiber = ctx.plugin(SessionLogExport, { + ...services.compressionLevel === undefined + ? {} + : { compressionLevel: services.compressionLevel }, + }) + await fiber.await() + const handler = connection.createSharedFetchHandler('/api') + return { + fetch: handler, + downloads: { + sessionLog: ( + request: { sessionId: SessionId; includeDescendants: boolean }, + signal: AbortSignal, + ): Promise => { + const url = new URL(`http://host${SessionLogExport.SESSION_LOG_EXPORT_PATH}`) + url.searchParams.set('sessionId', request.sessionId) + url.searchParams.set('includeDescendants', String(request.includeDescendants)) + return handler.fetch(new Request(url, { signal })) + }, + }, + } +} + +function toFetchHandler(api: Awaited>): { fetch(request: Request): Promise } { + return api.fetch +} + +async function responseBytes(response: Response): Promise { + return new Uint8Array(await response.arrayBuffer()) +} + +describe('session export compression config', () => { + it('defaults to level 6 and rejects values outside the integer 0-9 range', () => { + expect(SessionLogExport.Config({})).toEqual({ + compressionLevel: 6, + }) + expect(SessionLogExport.Config({ compressionLevel: 0 })) + .toEqual({ compressionLevel: 0 }) + expect(SessionLogExport.Config({ compressionLevel: 9 })) + .toEqual({ compressionLevel: 9 }) + for (const value of [-1, 10, 1.5]) { + expect(() => SessionLogExport.Config({ compressionLevel: value } as never)).toThrow() + } + }) +}) + +describe('session.export download endpoint', () => { + it('streams a ZIP with the root artifact verbatim under its original filename', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) + }) + + it('preflights root preparation through HEAD without streaming a body', async () => { + const readRaw = vi.fn(async () => artifact('session-root')) + const api = await buildApi({}, [], { readRaw }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') + expect(response.body).toBeNull() + expect(readRaw).toHaveBeenCalledOnce() + }) + + it('returns a bodyless preparation error from HEAD', async () => { + const api = await buildApi({}) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), + ) + + expect(response.status).toBe(404) + expect(response.body).toBeNull() + }) + + it('uses the resolved compression level for ZIP entries', async () => { + const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024)) + const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 }) + const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 }) + const stored = await storedApi.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const compressed = await compressedApi.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const storedBytes = await responseBytes(stored) + const compressedBytes = await responseBytes(compressed) + expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength) + expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('includes descendant artifacts under subagents// when requested', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'grandchild-a': artifact('grandchild-a', sid('child-a')), + }, [ + node('child-a', node('grandchild-a')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/grandchild-a/session.jsonl', + ]) + expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)) + .toBe(artifact('child-a').content) + }) + + it('flushes each live root and descendant immediately before reading its artifact', async () => { + const stored: Record = { + 'session-root': artifact('session-root', undefined, 'stale root'), + 'child-a': artifact('child-a', sid('session-root'), 'stale child'), + } + const durable: Record = { + 'session-root': artifact('session-root', undefined, 'durable root'), + 'child-a': artifact('child-a', sid('session-root'), 'durable child'), + } + const flushed: SessionId[] = [] + const api = await buildApi(stored, [node('child-a')], { + sessions: { + get: id => durable[id] === undefined ? undefined : { id }, + flush: async (session) => { + const artifactAfterFlush = durable[session.id] + if (artifactAfterFlush === undefined) throw new Error('unexpected session') + flushed.push(session.id) + stored[session.id] = artifactAfterFlush + return true + }, + }, + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(flushed).toEqual([sid('session-root'), sid('child-a')]) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root') + expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child') + }) + + it('reads a cold artifact without asking the live-session store to flush', async () => { + const flush = vi.fn(async () => true) + const root = artifact('session-root') + const api = await buildApi({ 'session-root': root }, [], { + sessions: { + get: () => undefined, + flush, + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const files = unzipSync(await responseBytes(response)) + expect(flush).not.toHaveBeenCalled() + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('answers 404 for a missing root session', async () => { + const api = await buildApi({}) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(404) + }) + + it('answers 501 when the persistence backend has no per-session raw artifacts', async () => { + const api = await buildApi({}, [], { persistence: 'unsupported' }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(501) + expect(await response.text()).toContain('does not expose per-session raw artifacts') + }) + + it('answers 400 when the sessionId query parameter is absent', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?includeDescendants=true'), + ) + expect(response.status).toBe(400) + }) + + it('answers 400 for an includeDescendants value other than true or false', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'), + ) + expect(response.status).toBe(400) + }) + + it('answers 500 when the deployment mounts no persistence or session-query service', async () => { + const api = await buildApi({}, [], { query: false, persistence: false }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + expect(await response.text()).toContain('session-query') + }) + + it('fails the whole export when a descendant has no stored artifact', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + }, [node('child-missing')]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + // The stream errors before completing, so the body read rejects rather + // than returning a truncated-but-valid archive. + await expect(response.arrayBuffer()).rejects.toThrow() + }) + + it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => { + // The push loop slices by 2^16 code units and must back off one unit when + // the boundary lands inside a surrogate pair; otherwise the pair re-encodes + // as U+FFFD and the exported artifact is silently corrupted. + const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('splits a long artifact on a plain code-unit boundary without backoff', async () => { + // A boundary that lands on a BMP character needs no surrogate backoff; the + // round trip must still be byte-identical across the multi-chunk push. + const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('waits for response pull capacity before reading the next archive entry', async () => { + const root = artifact('session-root', undefined, [ + imageEventLine('after-root'), + randomBytes(512 * 1024).toString('base64'), + ].join('\n')) + let imageReads = 0 + const api = await buildApi({ 'session-root': root }, [], { + attachments: async (ref) => { + imageReads += 1 + return storedImage(String(ref.attachmentId), ref.mediaType) + }, + }) + vi.useFakeTimers() + let response: Response | undefined + try { + response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + // Exhausting timer turns must not advance a producer whose byte queue is + // full; only a consumer pull can release it. + await vi.runAllTimersAsync() + expect(imageReads).toBe(0) + } finally { + vi.useRealTimers() + } + if (response === undefined) throw new Error('missing export response') + const files = unzipSync(await responseBytes(response)) + expect(imageReads).toBe(1) + expect(files['media/after-root.png']).toEqual(storedImage('after-root').data) + }) + + it('exports an empty artifact as an empty zip entry', async () => { + const root = { ...artifact('session-root'), content: '' } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') + }) + + it('exports a shared lineage node once (seen-set dedup)', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'child-b': artifact('child-b', sid('session-root')), + shared: artifact('shared', sid('child-a')), + }, [ + node('child-a', node('shared')), + node('child-b', node('shared')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/child-b/session.jsonl', + 'subagents/shared/session.jsonl', + ]) + }) + + it('answers 500 without leaking the backend error when the root artifact read fails', async () => { + const api = await buildApi({}, [], { query: true, persistence: 'throw' }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).toBe('session log export failed to prepare the stored artifact') + expect(body).not.toContain('/host/private/') + }) + + it('answers the private-error-safe 500 when the live root flush fails', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }, [], { + sessions: { + get: id => ({ id }), + flush: async () => { throw new Error('/host/private/flush-state') }, + }, + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).toBe('session log export failed to prepare the stored artifact') + expect(body).not.toContain('/host/private/') + }) + + it('forwards one request signal through root, lineage, and descendant reads', async () => { + const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = [] + const traces: AbortSignal[] = [] + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id, signal) => { + reads.push({ id, signal }) + return id === sid('session-root') + ? artifact('session-root') + : artifact('child-a', sid('session-root')) + }, + traceSession: async (_id, signal) => { + if (signal !== undefined) traces.push(signal) + return { + target: { header: header('session-root'), live: false, persisted: true }, + ancestors: [], + complete: true, + root: { header: header('session-root'), live: false, persisted: true }, + descendants: [node('child-a')], + } + }, + }) + const controller = new AbortController() + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + controller.signal, + ) + await response.arrayBuffer() + const rootSignal = reads[0]?.signal + if (rootSignal === undefined) throw new Error('missing root signal') + const producerSignal = traces[0] + if (producerSignal === undefined) throw new Error('missing lineage signal') + expect(reads[0]?.id).toBe(sid('session-root')) + expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal }) + const cancellation = new Error('request cancelled after response') + controller.abort(cancellation) + expect(rootSignal.aborted).toBe(true) + expect(rootSignal.reason).toBe(cancellation) + expect(producerSignal.aborted).toBe(true) + expect(producerSignal.reason).toBe(cancellation) + }) + + it('preserves request cancellation instead of translating it to HTTP 500', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const controller = new AbortController() + const cancellation = new Error('request cancelled') + controller.abort(cancellation) + await expect(api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + controller.signal, + )).rejects.toBe(cancellation) + }) + + it('aborts descendant work and terminates ZIP production when its reader cancels', async () => { + let reportDescendantStarted!: (signal: AbortSignal) => void + const descendantStarted = new Promise((resolve) => { + reportDescendantStarted = resolve + }) + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id, signal) => { + if (id === sid('session-root')) return artifact('session-root') + if (signal === undefined) throw new Error('missing descendant signal') + reportDescendantStarted(signal) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error) + }, { once: true }) + }) + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + new AbortController().signal, + ) + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('missing response body') + const descendantSignal = await descendantStarted + const cancellation = new Error('download consumer left') + await reader.cancel(cancellation) + expect(descendantSignal.aborted).toBe(true) + expect(descendantSignal.reason).toBe(cancellation) + }) + + it('aborts attachment reads when its reader cancels', async () => { + let reportAttachmentStarted!: (signal: AbortSignal) => void + const attachmentStarted = new Promise((resolve) => { + reportAttachmentStarted = resolve + }) + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('slow-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }, [], { + attachments: async (_ref, signal) => { + if (signal === undefined) throw new Error('missing attachment signal') + reportAttachmentStarted(signal) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error) + }, { once: true }) + }) + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('missing response body') + const attachmentSignal = await attachmentStarted + const cancellation = new Error('download consumer left during attachment read') + await reader.cancel(cancellation) + expect(attachmentSignal.aborted).toBe(true) + expect(attachmentSignal.reason).toBe(cancellation) + }) + + it('uses a stable Error reason when its reader cancels without one', async () => { + let reportDescendantStarted!: (signal: AbortSignal) => void + const descendantStarted = new Promise((resolve) => { + reportDescendantStarted = resolve + }) + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id, signal) => { + if (id === sid('session-root')) return artifact('session-root') + if (signal === undefined) throw new Error('missing descendant signal') + reportDescendantStarted(signal) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error) + }, { once: true }) + }) + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + new AbortController().signal, + ) + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('missing response body') + const descendantSignal = await descendantStarted + await reader.cancel() + expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled')) + }) + + it('normalizes a non-Error descendant failure before erroring the stream', async () => { + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id) => { + if (id === sid('session-root')) return artifact('session-root') + throw 'descendant read failed' + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + new AbortController().signal, + ) + await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed')) + }) + + it('includes media objects referenced by the root log under media/.', async () => { + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('img-1'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl']) + expect(files['media/img-1.png']).toEqual(storedImage('img-1').data) + }) + + it('collects media referenced from nested tool results', async () => { + const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}' + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + nested, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl']) + }) + + it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => { + const block = (id: string, mediaType: string) => + `{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}` + const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}` + const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}` + const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}` + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + wrapped, + inserted, + chunk, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'media/chunk-1.png', + 'media/inserted-1.gif', + 'media/wrapped-1.jpg', + 'session.jsonl', + ]) + }) + + it('deduplicates one media object referenced by several included logs', async () => { + const line = imageEventLine('shared-img') + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + line, + ].join('\n') + '\n') + const child = artifact('child-a', sid('session-root'), [ + '{"type":"session","version":0,"id":"child-a","createdAt":1000}', + line, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data) + expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png']) + }) + + it('includes descendant media only when descendants are requested', async () => { + const child = artifact('child-a', sid('session-root'), [ + '{"type":"session","version":0,"id":"child-a","createdAt":1000}', + imageEventLine('child-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')]) + const without = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl']) + const withDescendants = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([ + 'media/child-img.png', + 'session.jsonl', + 'subagents/child-a/session.jsonl', + ]) + }) + + it('fails the whole export when a referenced image cannot be read', async () => { + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('gone-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }, [], { + attachments: async () => { throw new Error('attachment bytes missing') }, + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing') + }) + + it('answers 500 when the deployment mounts no attachments service', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + expect(await response.text()).toContain('attachments') + }) +}) diff --git a/packages/session-query/session-log-export/tests/command.client.spec.ts b/packages/session-query/session-log-export/tests/command.host.spec.ts similarity index 100% rename from packages/session-query/session-log-export/tests/command.client.spec.ts rename to packages/session-query/session-log-export/tests/command.host.spec.ts diff --git a/packages/session-query/session-log-export/tests/invariant.client.spec.ts b/packages/session-query/session-log-export/tests/invariant.host.spec.ts similarity index 100% rename from packages/session-query/session-log-export/tests/invariant.client.spec.ts rename to packages/session-query/session-log-export/tests/invariant.host.spec.ts diff --git a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts similarity index 100% rename from packages/session-query/session-log-export/tests/loader-composition.client.spec.ts rename to packages/session-query/session-log-export/tests/loader-composition.host.spec.ts diff --git a/packages/session-query/session-log-export/tests/route.host.spec.ts b/packages/session-query/session-log-export/tests/route.host.spec.ts index 213ad852a6..44d4b6c476 100644 --- a/packages/session-query/session-log-export/tests/route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/route.host.spec.ts @@ -56,8 +56,7 @@ async function mounted(withServices: boolean): Promise<{ describe('Session log export Fetch route', () => { it('registers one GET/HEAD route and removes it with the plugin fiber', async () => { const { connection, dispose } = await mounted(true) - const fallback = { fetch: async () => new Response('fallback', { status: 418 }) } - const shared = connection.createSharedFetchHandler('/api', fallback) + const shared = connection.createSharedFetchHandler('/api') const response = await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, @@ -76,14 +75,12 @@ describe('Session log export Fetch route', () => { await dispose() expect((await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, - ))).status).toBe(418) + ))).status).toBe(404) }) it('validates the query before reporting missing export services', async () => { const { connection, dispose } = await mounted(false) - const shared = connection.createSharedFetchHandler('/api', { - fetch: async () => new Response('fallback', { status: 418 }), - }) + const shared = connection.createSharedFetchHandler('/api') expect((await shared.fetch(new Request(`http://host${SESSION_LOG_EXPORT_PATH}`))).status).toBe(400) expect((await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1&includeDescendants=1`, From e14d354e8392429c5e6213fffeffc8a4404867c4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:51:11 +0800 Subject: [PATCH 06/11] refactor(connection): own RPC transport contracts --- apps/cli/tests/web-auth.e2e.ts | 18 +- apps/web/tests/assembled-boot.ts | 2 +- apps/web/tests/built-boot.expected.e2e.ts | 2 +- .../command-image-envelope.expected.e2e.ts | 2 +- apps/web/tests/goal-bar.e2e.ts | 2 +- apps/web/tests/image-display.expected.e2e.ts | 2 +- .../tests/max-tokens-notice.expected.e2e.ts | 2 +- apps/web/tests/replay-round-trip.e2e.ts | 2 +- apps/web/tests/search-card.expected.e2e.ts | 2 +- apps/web/tests/submission-echo.e2e.ts | 2 +- apps/web/tests/todo-row.expected.e2e.ts | 2 +- .../trajectory-image-display.expected.e2e.ts | 2 +- .../api/gateway/tests/gateway.client.spec.ts | 3 +- packages/api/remotes/src/client/index.ts | 4 +- packages/client/connection/package.json | 9 +- packages/client/connection/src/client/api.ts | 47 ++-- .../connection/src/client/connection.ts | 26 +- .../client/connection/src/client/fixture.ts | 127 +++------ .../client/connection/src/client/index.ts | 67 +---- packages/client/connection/src/client/rpc.ts | 2 +- .../connection/src/client/web-api-client.ts | 10 - packages/client/connection/src/index.ts | 30 ++- packages/client/connection/src/invariant.ts | 4 +- packages/client/connection/src/rpc-host.ts | 27 +- packages/client/connection/src/rpc-schema.ts | 53 ++++ packages/client/connection/src/rpc.ts | 107 ++++++++ .../tests/client-apply.client.spec.ts | 117 ++++----- .../tests/connection.client.spec.ts | 245 ++++++++---------- .../connection/tests/fake-api.client.ts | 131 ---------- .../tests/fake-generation.client.ts | 80 ++++++ .../tests/fetch-routes.host.spec.ts | 17 +- .../tests/fixture-commands.client.spec.ts | 2 +- .../connection/tests/fixture.client.spec.ts | 82 ++---- .../connection/tests/node-half.host.spec.ts | 12 +- .../connection/tests/rpc-schema.host.spec.ts | 58 +++++ .../client/connection/tsconfig.client.json | 4 - packages/client/connection/tsconfig.host.json | 6 +- packages/client/tsdown.client.ts | 2 +- .../credentials/authorization/src/types.ts | 2 +- .../webworker-packer/src/rules.ts | 1 - .../webworker-packer/tsconfig.json | 3 + .../webworker-runtime/package.json | 4 +- .../src/client/api-client.ts | 31 --- .../webworker-runtime/src/client/index.ts | 4 - .../webworker-runtime/src/node/builtins.ts | 2 +- .../src/node/external_packages/ws.ts | 2 +- .../webworker-runtime/src/worker-host.ts | 40 +-- .../webworker-runtime/tsconfig.json | 2 +- .../src/client/api-catalog.ts | 28 +- .../src/client/slot-catalog.ts | 4 +- .../interaction/user-approval/src/types.ts | 2 +- scripts/client-bundle-purity.spec.ts | 7 +- 52 files changed, 645 insertions(+), 799 deletions(-) delete mode 100644 packages/client/connection/src/client/web-api-client.ts create mode 100644 packages/client/connection/src/rpc-schema.ts delete mode 100644 packages/client/connection/tests/fake-api.client.ts create mode 100644 packages/client/connection/tests/fake-generation.client.ts create mode 100644 packages/client/connection/tests/rpc-schema.host.spec.ts delete mode 100644 packages/experimental/webworker-runtime/src/client/api-client.ts diff --git a/apps/cli/tests/web-auth.e2e.ts b/apps/cli/tests/web-auth.e2e.ts index b22f611306..05889c8d32 100644 --- a/apps/cli/tests/web-auth.e2e.ts +++ b/apps/cli/tests/web-auth.e2e.ts @@ -119,19 +119,19 @@ async function stopWeb(running: RunningWeb): Promise { clearTimeout(forced) } -/** POST one real API Proxy envelope while controlling the wire Host header. */ -function describeHost(port: number, host: string, cookie?: string): Promise { +/** POST one real Remote envelope while controlling the wire Host header. */ +function describeSettings(port: number, host: string, cookie?: string): Promise { const body = JSON.stringify({ type: 'client-request', rpcId: 'web-auth-real-cli', - method: 'host.describe', - payload: {}, + method: 'settings/describe', + payload: { args: {} }, }) return new Promise((resolve, reject) => { const req = httpRequest({ hostname: '127.0.0.1', port, - path: '/api/host.describe', + path: '/api/settings/describe', method: 'POST', headers: { host, @@ -165,7 +165,7 @@ describe('dsh web authentication through the real CLI', () => { expect(firstUrl.pathname).toBe('/') expect(firstUrl.searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{43}$/u) - expect(await describeHost(port, `localhost:${String(port)}`)).toEqual({ + expect(await describeSettings(port, `localhost:${String(port)}`)).toEqual({ status: 401, body: 'unauthorized', }) @@ -180,13 +180,13 @@ describe('dsh web authentication through the real CLI', () => { expect(setCookie).not.toContain('Secure') const cookie = setCookie.split(';', 1)[0]! - const authenticated = await describeHost(port, firstUrl.host, cookie) + const authenticated = await describeSettings(port, firstUrl.host, cookie) expect(authenticated.status).toBe(200) const authenticatedBody = JSON.parse(authenticated.body) as unknown expect(authenticatedBody).toMatchObject({ type: 'server-response', rpcId: 'web-auth-real-cli', - result: { ok: true, value: { version: expect.any(String) as unknown } }, + result: { ok: true, value: { namespaces: expect.any(Array) as unknown } }, }) await stopWeb(first) @@ -194,7 +194,7 @@ describe('dsh web authentication through the real CLI', () => { second = await startWeb(root, dshHome, port) const secondUrl = new URL(second.launchUrl) expect(secondUrl.searchParams.get('token')).not.toBe(firstUrl.searchParams.get('token')) - expect((await describeHost(port, secondUrl.host, cookie)).status).toBe(200) + expect((await describeSettings(port, secondUrl.host, cookie)).status).toBe(200) const credentialMode = (await stat(join(dshHome, '.credentials.yaml'))).mode & 0o777 expect(credentialMode).toBe(0o600) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 05bfb45a4b..a18893d82b 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -1,6 +1,6 @@ // Shared scaffolding for the assembled-jsdom snapshots: the real built // workspace `lib/client.js` artifacts booted through AppWebEntry's -// ModuleLoader path (loadBundle) against the keyless FixtureApiClient +// ModuleLoader path (loadBundle) against the keyless fixture Connection RPC // transport. Every file that mounts this graph needs the same boot entry list, // the same bundle map, the same jsdom globals, and the same mount call, and // differs only in what it asserts afterwards, so the scaffolding lives here. diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index cbd5f294a4..275cda21d5 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -4,7 +4,7 @@ // reach a surface only the built bundles expose; this one asserts that the // graph assembles at all — staged activation across the immediately tier and // the inject layers, per-plugin CSS injection, and a rendered journey reaching -// chat content from the keyless FixtureApiClient transport. +// chat content from the keyless fixture Connection RPC. // // Component behavior remains owned by per-package suites (SlotTestRuntime // benches over src). This smoke additionally pins the resident interaction diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index e378072db1..47fb91069b 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // The command image-attachment envelope over the BUILT client graph (real -// bundles via AppWebEntry, keyless FixtureApiClient transport): an enter +// bundles via AppWebEntry, keyless fixture Connection RPC): an enter // submission carrying composer images resolves only through a command whose // descriptor declares `input.images`. A non-declaring command refuses with // one composer error banner and everything retained; a declaring command diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index b9c7695066..f9cd46b60c 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -1,5 +1,5 @@ // Keyless assembled-browser coverage for the goal bar over the shipped Web -// bundles and FixtureApiClient wire. The command creates a real projected +// bundles and the fixture Connection RPC. The command creates a real projected // goal in the fixture session; the golden pins the active strip, while the // clear gesture proves the acknowledged tombstone leaves neither stale chrome // nor a duplicate-mutation error. diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index 37be0f24ac..55dcedff13 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // Multimodal image surfaces over the BUILT client graph (the code-mode-fixture -// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC). // Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized diff --git a/apps/web/tests/max-tokens-notice.expected.e2e.ts b/apps/web/tests/max-tokens-notice.expected.e2e.ts index 3e35d13bb8..5634315a32 100644 --- a/apps/web/tests/max-tokens-notice.expected.e2e.ts +++ b/apps/web/tests/max-tokens-notice.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled max-tokens snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the -// keyless FixtureApiClient transport, opens the fixture session, and pins the +// keyless fixture Connection RPC, opens the fixture session, and pins the // surface its max-tokens turn (72) reaches — the turn-end notice row that a // provider output-cap truncation must render instead of ending silently. // diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 709fb83fca..8f2b62ad29 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -200,7 +200,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think')) // Interaction over the REAL wire-delivered transcript (the fixture-client - // tier pins the same gesture against FixtureApiClient; this one runs on + // tier pins the same gesture against the fixture Connection RPC; this one runs on // follow-stream-fed state). Runs after the golden capture so the committed // aria surface stays the untouched settled state. await expandTurnProcesses(page) diff --git a/apps/web/tests/search-card.expected.e2e.ts b/apps/web/tests/search-card.expected.e2e.ts index ec8da8ff86..b54d14e8e7 100644 --- a/apps/web/tests/search-card.expected.e2e.ts +++ b/apps/web/tests/search-card.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled search-card snapshot: boots the real built workspace client bundles // through AppWebEntry's ModuleLoader path against the keyless -// FixtureApiClient transport (no API key, no model round), opens the fixture +// fixture Connection RPC (no API key, no model round), opens the fixture // session, and pins the search card the `grep` turn (fixture turn 67) renders in // the assembled application. The built-boot smoke proves the graph boots but // intentionally carries no behavior assertions; this is the assembled-output check diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts index 3e53dc5df8..a5d2b5503f 100644 --- a/apps/web/tests/submission-echo.e2e.ts +++ b/apps/web/tests/submission-echo.e2e.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -// Local submission echo over the BUILT client graph (keyless FixtureApiClient +// Local submission echo over the BUILT client graph (keyless fixture Connection RPC // transport): a text-plus-image send paints its echo bubble synchronously on // the submit keystroke — before serialization, transport, or the fixture's // durable admission — with the composer already cleared and editable, and the diff --git a/apps/web/tests/todo-row.expected.e2e.ts b/apps/web/tests/todo-row.expected.e2e.ts index 5672dd09a0..21fa70d9bf 100644 --- a/apps/web/tests/todo-row.expected.e2e.ts +++ b/apps/web/tests/todo-row.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the -// keyless FixtureApiClient transport, opens the fixture session, and pins the +// keyless fixture Connection RPC, opens the fixture session, and pins the // two surfaces the fixture's parallel plan (turn 74, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // diff --git a/apps/web/tests/trajectory-image-display.expected.e2e.ts b/apps/web/tests/trajectory-image-display.expected.e2e.ts index 07ed9330b2..94607563d8 100644 --- a/apps/web/tests/trajectory-image-display.expected.e2e.ts +++ b/apps/web/tests/trajectory-image-display.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // Trajectory image surfaces over the BUILT client graph (the code-mode-fixture -// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC). // Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the Trajectory surfaces: // selecting the ledger record renders the shared ui-attachment gallery from diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index a77e4a22af..3122fbffd5 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -1809,7 +1809,7 @@ describe('Client Typert API', () => { }) }) - it('publishes the Fixture Host description after Remote events report ready', async () => { + it('publishes the Fixture Host facts after Remote events report ready', async () => { const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') Object.defineProperty(globalThis, 'location', { configurable: true, @@ -1824,7 +1824,6 @@ describe('Client Typert API', () => { if (connection === undefined) throw new Error('fixture Connection service is unavailable') await vi.waitFor(() => { - expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture') expect(connection.generation.getSnapshot()?.host.home).toBe('/home/fixture') }) } finally { diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index d40e9f65fc..d449efe704 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -54,8 +54,8 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' * the carrier's runtime values stay behind their own module edge. */ export type { - ConnectionHandle, ConnectionSinks, ContentBlock, IApiClient, - MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, + ConnectionHandle, ConnectionSinks, ContentBlock, + MessageId, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, StreamChunk, } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 2be6924acf..07a00ab2de 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-connection", - "description": "Wire consumer layer: HTTP client, generation lifecycle, and fixture API", + "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -38,7 +38,8 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "files": [ "lib/index.js", @@ -51,7 +52,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -65,7 +66,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 0ae8af9ddf..44e7595f34 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -1,43 +1,26 @@ -// Central contract re-export point: every legacy API contract import inside -// the Connection package goes through this browser-safe file. -// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer -// (zero Node deps, browser-safe); AbstractApiClient is the client boundary. -// NEVER import the package root: it drags bootHost/cordis into the browser bundle. -// The ./api and ./client subpath exports are the browser-safe channels. +/** Browser-safe Connection protocol and shared application value exports. */ export type { - ApiProxy, HostApi, - ResponseValue, - ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, -} from '@deepseek-ai/dsh-host-apiproxy/api' -export type { - RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, - ClientRequest, ServerResponse, RpcMessage, -} from '@deepseek-ai/dsh-host-apiproxy/api' -// transportError lives in the apiproxy api layer (beside RpcResult, its -// subject); re-exported here so connection consumers keep one contract -// entry point. -export { - RpcId, - transportError, -} from '@deepseek-ai/dsh-host-apiproxy/api' -export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' -export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' + ClientRequest, + RpcError, + RpcErrorCode, + RpcMessage, + RpcRequest, + RpcResponse, + RpcResult, + ServerResponse, +} from '../rpc.ts' +export { RpcId, transportError } from '../rpc.ts' export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types' export type { MessageId } from '@deepseek-ai/dsh-llm/brand' export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types' -/** Successful value returned by the connection-generation host handshake. */ -export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'> - -import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { RpcResponse, RpcResult } from '../rpc.ts' /** - * Unwrap a unary response: RpcResponse -> RpcResult (business code only - * cares about the result slot). - * @param response - the unary response. - * @returns its result slot. + * Return the business result carried by a narrow fixture response. + * @param response - fixture response to unwrap. + * @returns the response's business result. */ export function resultOf(response: RpcResponse): RpcResult { return response.result diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index ebcac74ea7..17e946c80e 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,5 +1,3 @@ -import type { HostDescription, IApiClient } from './api.ts' - /** Stable Host facts delivered by one established Remote event generation. */ export interface ConnectionHostInfo { /** Host account home used only to abbreviate displayed filesystem paths. */ @@ -52,8 +50,8 @@ export type ConnectionState = 'connected' | 'reconnecting' /** Connection-generation callbacks owned by API Gateway. */ export interface ConnectionSinks { - /** After the generation source is ready and host.describe succeeds, first connect included. */ - onConnected?: (description: HostDescription, host: ConnectionHostInfo) => void + /** After the generation source reports ready, first connect included. */ + onConnected?: (host: ConnectionHostInfo) => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ onStateChange?: (state: ConnectionState) => void @@ -86,7 +84,6 @@ export class ConnectionController { private readonly config: Required constructor( - private readonly api: IApiClient, private readonly source: ConnectionGenerationSource, private readonly sinks: ConnectionSinks = {}, config: ConnectionConfig = {}, @@ -173,27 +170,16 @@ export class ConnectionController { }) try { - // The source reports ready only after its incremental listeners exist; - // describe may complete in parallel, but consumers see neither result - // until both sides of the baseline-plus-increment handshake are ready. - const [description, host] = await Promise.race([ - Promise.all([ - this.api.host.describe({}, ac.signal), - waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), - ]), + const host = await Promise.race([ + waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), sourceLost, ]) - const descriptionResult = description.result - if (!descriptionResult.ok) { - throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`) - } if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake') this.attempt = 0 this.emitState('connected') - // A state sink may synchronously stop this controller. Do not publish - // a description for a generation that no longer exists afterward. + // A state sink may synchronously stop this controller. if (this.isGenerationActive(ac)) { - this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value, host) }) + this.callSink(() => { this.sinks.onConnected?.(host) }) } } catch { // Transport failure: treat as generation failure, fall through to the shared backoff. diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 6a5a1017c5..17b3a006c4 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -33,12 +33,7 @@ import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' import type { SettingsDescribeValue, SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' -import type { - ApiProxy, ClientRequest, - ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse, -} from './api.ts' -import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' -import { AbstractApiClient, RpcId } from './api.ts' +import type { RpcResult } from './api.ts' import { randomUuid } from './random-uuid.ts' import type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, @@ -46,6 +41,26 @@ import type { const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20 +interface ModelSelection { + readonly provider: string + readonly model: string + readonly reasoningEffort?: string +} + +interface ModelProviderGroup { + readonly id: string + readonly name: string + readonly models: readonly { + readonly id: string + readonly name: string + readonly description?: string + readonly reasoning?: { + readonly efforts: readonly { readonly id: string; readonly name: string; readonly description?: string }[] + readonly defaultEffort?: string + } + }[] +} + /* jscpd:ignore-start -- The standalone fixture mirrors host timing without importing a target implementation. */ function isFixtureTokenDelta(chunk: StreamChunk): boolean { switch (chunk.type) { @@ -324,11 +339,6 @@ interface FixtureWorkspace { updatedAt: string } -/** The fake carrier mints like a real one (business code never mints). */ -function rpcRequest

(payload: P): RpcRequest

{ - return { rpcId: RpcId(randomUuid()), payload } -} - function text(t: string): ContentBlock[] { return [{ type: 'text', text: t }] } @@ -1731,34 +1741,22 @@ class FxInbox implements StreamConn { } } -/** - * In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material). - * @param options - fixture branches for empty state and failure timing. - * @returns an ApiProxy backed entirely by in-memory state — no host process, no network. - */ -export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { - return createFixtureWorld(options).api -} - -/** Both fixture faces over one state graph. */ +/** Fixture RPC face over one in-memory state graph. */ export interface FixtureWorld { - /** Legacy unary/stream API the fixture still answers. */ - readonly api: ApiProxy /** Generic Remote caller for the endpoints business services own. */ readonly rpc: ClientConnectionRpc } /** - * Build both fixture faces so a caller can drive the Remote endpoints and the - * legacy API against one in-memory state graph. + * Build the fixture RPC face over one in-memory state graph. * @param options - fixture branches for empty state and failure timing. - * @returns the legacy API face and the Remote RPC face. + * @returns the Remote RPC face. */ export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld { return createFixtureWorld(options) } -/** Build the fixture's legacy API and Remote RPC faces over one state graph. */ +/** Build the fixture's Remote RPC face over one state graph. */ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. const sessions: FixtureSessionSummary[] = options.empty ? [] : [ @@ -1890,7 +1888,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { let fixtureDefaultPreset = 'standard' const nextTurn = new Map([[sid('fx-alpha'), 75]]) let nextSession = 1 - let attachedSessions = options.empty ? 0 : 1 // Workspace entities mirroring the host registry: the fixture sessions all // live under one workspace, whose account carries them in attach order. const wid = (raw: string): WorkspaceId => raw as WorkspaceId @@ -2015,10 +2012,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { for (const conn of followConns.get(sessionId) ?? []) conn.push(entry) } - /** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */ - function ok(request: RpcRequest

, value: T): Promise> { - return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } }) - } function sessionOk(value: T): Promise> { return Promise.resolve({ ok: true, value }) } @@ -2817,7 +2810,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } sessions.push(created) modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - attachedSessions += 1 const emitSession = (): void => { emitRemote('api-session/added', [created]) } @@ -3399,20 +3391,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } - const api: ApiProxy = { - host: { - describe: request => ok(request, { - version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, - }), - }, - // Satisfies the ApiProxy contract type only: the browser export button - // hands GET /api/session.export to the native download manager, so this - // stub is never reached through the fixture's dispatch. - downloads: { - sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), - }, - } - const rpc: ClientConnectionRpc = { call(channel, endpoint, payload, signal) { if (channel !== '/api') { @@ -3486,6 +3464,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'credentials/set': return Promise.resolve(credentialRemotes.set(args.ref as string)) case 'credentials/unset': return Promise.resolve(credentialRemotes.unset(args.ref as string)) case 'settings/describe': return Promise.resolve(settingsRemotes.describe()) + case 'settings/canOpenAgentPresetDirectory': return Promise.resolve({ ok: true, value: true }) case 'settings/openSettingsDocument': return Promise.resolve(settingsRemotes.openSettingsDocument()) case 'settings/openAgentPresetDirectory': return Promise.resolve( settingsRemotes.openAgentPresetDirectory(args.agentPreset as string), @@ -3504,6 +3483,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'session/openWorkspacePath': { return sessionOk({ opened: true as const }) } + case 'session/canOpenWorkspacePath': return Promise.resolve({ ok: true, value: true }) case 'session/modelCatalog': return Promise.resolve({ ok: true, value: { @@ -3610,58 +3590,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } }, } - return { api, rpc } + return { rpc } } /** - * Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it - * overrides the legacy protocol-level call virtual to dispatch - * straight into the in-memory ApiProxy while still minting rpcIds, fabricating - * the request/response envelopes, and feeding the same tap as a real carrier. TODO: delete when the fixture - * moves to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)). + * Build the browser fixture transport from the current page's query switches. + * @returns an in-memory Connection RPC transport. */ -export class FixtureApiClient extends AbstractApiClient { - private readonly api: ApiProxy - /** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */ - readonly rpc: ClientConnectionRpc - - constructor() { - super() - const world = createFixtureWorld(fixtureOptionsFromLocation()) - this.api = world.api - this.rpc = world.rpc - } - - protected doFetch(): Promise { - throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable') - } - - protected override async callUnary( - method: K, - payload: RequestPayload, - signal?: AbortSignal, - ): Promise>> { - void signal - const request = rpcRequest(payload) - const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload } - this.onEnvelope(full) - const response = await this.dispatch( - method, - request as RpcRequest, - ) as RpcResponse> - const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result } - this.onEnvelope(fullResponse) - return response - } - - /** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */ - private dispatch( - _method: keyof RpcMethodMap, - request: RpcRequest, - ): Promise> { - return this.api.host.describe(request) - } - +export function createFixtureConnectionRpc(): ClientConnectionRpc { + return createFixtureWorld(fixtureOptionsFromLocation()).rpc } /** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */ diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a03ab1e5d7..36b9025d64 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -3,7 +3,6 @@ * the shared API client, and lets API Gateway own the connection loop. */ import type { Context } from '@deepseek-ai/cordis' -import type { HostDescription, IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, @@ -11,8 +10,7 @@ import { type ConnectionGenerationSource, type ConnectionSinks, } from './connection.ts' -import { FixtureApiClient } from './fixture.ts' -import { WebApiClient } from './web-api-client.ts' +import { createFixtureConnectionRpc } from './fixture.ts' import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' @@ -28,18 +26,15 @@ declare module '@deepseek-ai/cordis' { } } -// ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- +// ---- Browser-safe protocol and shared value re-exports ---- export type { - ApiProxy, HostApi, - ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - MessageId, ModelReasoningEffort, ModelSelection, + MessageId, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, RpcMessage, - HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, + SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts' export { RpcId, - AbstractApiClient, transportError, } from './api.ts' @@ -58,14 +53,6 @@ export type { } from '../rpc.ts' export type { RpcFetch } from './rpc.ts' -/** Observable Host description published by each completed connection handshake. */ -export interface HostDescriptionSource { - /** Latest connected-generation description; absent before connect and while reconnecting. */ - getSnapshot(): HostDescription | undefined - /** Subscribe to description replacement and connection loss. */ - subscribe(listener: () => void): () => void -} - /** Observable identity and Host facts for the active connection generation. */ export interface ConnectionGenerationState { /** Active generation, or undefined before readiness and while reconnecting. */ @@ -84,8 +71,6 @@ export const inject: string[] = [] * provides both halves here instead of forking this plugin. */ export interface ClientTransportHooks { - /** Build the API carrier: unary calls plus the two downstream event streams. */ - createApiClient(): IApiClient /** Transport for generic unary RPC channels (the Typert gateway). */ fetch: RpcFetch /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */ @@ -118,16 +103,12 @@ interface ClientTransportGlobal { * Connection stays independent of downstream domain state. */ export interface ConnectionHandle { - /** Shared api client (fixture or real, decided at boot from the page URL). */ - readonly api: IApiClient /** * Whether the privileged surface is reachable: the page authority is * loopback, the transport declares the page owns the Host * ({@link ClientTransportHooks.ownsHost}), or the context is not a browser. */ readonly isLoopback: boolean - /** Generation-scoped Host facts, including the account home and native path-open capability. */ - readonly hostDescription: HostDescriptionSource /** Current Remote event generation and the Host facts carried by its opening frame. */ readonly generation: ConnectionGenerationState /** Generic logical RPC channels over the same Connection transport. */ @@ -162,28 +143,14 @@ interface ConnectionOwner { export function apply(ctx: Context): void { const pageLocation = typeof location === 'undefined' ? undefined : location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') - const fixtureClient = fixture ? new FixtureApiClient() : undefined + const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ - const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient() - const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined let generationId = 0 let generation: ConnectionGeneration | undefined const generationListeners = new Set<() => void>() - let description: HostDescription | undefined - const descriptionListeners = new Set<() => void>() - const publishDescription = (next: HostDescription | undefined): void => { - if (Object.is(description, next)) return - description = next - for (const listener of [...descriptionListeners]) { - try { - listener() - } catch (error) { - console.error('[connection] host-description listener threw:', error) - } - } - } const publishGeneration = (next: ConnectionGeneration | undefined): void => { if (Object.is(generation, next)) return generation = next @@ -200,18 +167,9 @@ export function apply(ctx: Context): void { owner = undefined current.controller.stop() publishGeneration(undefined) - publishDescription(undefined) } const handle: ConnectionHandle = { - api, isLoopback: transport?.ownsHost === true || pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), - hostDescription: { - getSnapshot: () => description, - subscribe: (listener) => { - descriptionListeners.add(listener) - return () => { descriptionListeners.delete(listener) } - }, - }, generation: { getSnapshot: () => generation, subscribe: (listener) => { @@ -238,24 +196,17 @@ export function apply(ctx: Context): void { if (source === undefined) throw new Error('connection: no generation source is registered') const token = {} const ownsGeneration = (): boolean => owner?.token === token - const controller = new ConnectionController(api, source, { + const controller = new ConnectionController(source, { ...sinks, - onConnected: (next, host) => { + onConnected: (host) => { const nextGeneration = { id: ++generationId, host } publishGeneration(nextGeneration) if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return - publishDescription(next) - // A description subscriber may synchronously stop the loop. In that - // case publishDescription(undefined) has already retracted this - // generation, so do not leak its stale connected notification to - // the consumer sink afterward. - if (!ownsGeneration() || !Object.is(description, next)) return - sinks.onConnected?.(next, host) + sinks.onConnected?.(host) }, onStateChange: (state) => { if (state === 'reconnecting') { publishGeneration(undefined) - publishDescription(undefined) } if (!ownsGeneration()) return sinks.onStateChange?.(state) diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index c7b609c01b..2c3f7d3e28 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -4,7 +4,7 @@ import { RpcId, type ClientRequest, type RpcId as RpcIdType, -} from '@deepseek-ai/dsh-host-apiproxy/api' +} from '../rpc.ts' import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts' import { randomUuid } from './random-uuid.ts' diff --git a/packages/client/connection/src/client/web-api-client.ts b/packages/client/connection/src/client/web-api-client.ts deleted file mode 100644 index 6716f252a5..0000000000 --- a/packages/client/connection/src/client/web-api-client.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Browser API carrier for unary HTTP calls. */ - -import { AbstractApiClient } from './api.ts' - -/** Browser platform subclass supplying fetch for unary calls. */ -export class WebApiClient extends AbstractApiClient { - protected doFetch(input: URL, init?: RequestInit): Promise { - return globalThis.fetch(input, init) - } -} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index c1c1f4d933..34cf79bd65 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -5,7 +5,6 @@ import type {} from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-credentials' // Activates the webServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' -import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts' import { assertTrustedAuthority } from './api-request-trust.ts' @@ -14,6 +13,7 @@ import { HostConnectionService } from './rpc-host.ts' export type { ConnectionFetchMethod, + ConnectionFetchHandler, ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, @@ -23,10 +23,22 @@ export type { ConnectionRequestRejection, ConnectionRpcResult, ConnectionTrustRequest, + ClientRequest, HostConnectionHandle, HostConnectionFetch, HostConnectionRpc, + RpcMessage, + ServerResponse, } from './rpc.ts' +export { RpcId, transportError } from './rpc.ts' +export { + clientRequestSchema, + rpcErrorSchema, + rpcIdSchema, + rpcMessageSchema, + rpcResultSchema, + serverResponseSchema, +} from './rpc-schema.ts' export { HostConnectionService } from './rpc-host.ts' export { API_PATH } from './api-path.ts' @@ -51,7 +63,7 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi } } -/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ +/** Services required before providing Connection. */ export const inject = ['webServer', 'credentials'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -92,19 +104,13 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise ctx.webServer.register(route), 'client-connection: /api route') - ctx.inject(['apiProxy'], (apiCtx) => { assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) }) + ctx.inject(['attachments'], (attachmentCtx) => { + assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes) + }) } diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index 5a187545b5..3b96a053eb 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -18,8 +18,8 @@ export const inject = ['invariants'] * No runtime invariant: browser-session verification reads the credential * record asynchronously at the request that authorizes work, while the * credentials companion owns record commit-event lifetime. Stream/reconnect - * sequencing is exercised directly by behavior specs, rpcId round-trip - * discipline belongs to apiproxy, and route register/dispose symmetry is + * sequencing and rpcId round-trip discipline are exercised directly by + * behavior specs, and route register/dispose symmetry is * audited by the webserver companion. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 92de25729f..a00277d813 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -3,13 +3,11 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { - clientRequestSchema, RpcId, type ClientRequest, - type RpcError, - type RpcErrorDetailsMap, type RpcId as RpcIdType, -} from '@deepseek-ai/dsh-host-apiproxy/api' +} from './rpc.ts' +import { clientRequestSchema } from './rpc-schema.ts' import { bridge, type FetchHandler } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' @@ -18,8 +16,10 @@ import type { ConnectionIndexRequest, ConnectionIndexResponse, ConnectionFetchRoute, + ConnectionFetchHandler, HostConnectionFetch, ConnectionRpcEndpointMatcher, + ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRpcResult, ConnectionRequestRejection, @@ -109,15 +109,13 @@ export class HostConnectionService extends Service implements HostConnectionHand } /** - * Compose one shared-channel Fetch handler from its interceptor and fallback. + * Compose one shared-channel Fetch handler from exact routes and its interceptor. * @param channel - shared channel mounted by Connection. - * @param fallback - handler for endpoints not claimed by the interceptor. - * @returns Fetch handler that selects exactly one target for each request. + * @returns Fetch handler that selects one owner or returns 404. */ createSharedFetchHandler( channel: '/api', - fallback: FetchHandler, - ): FetchHandler { + ): ConnectionFetchHandler { return { fetch: (request) => { const pathname = new URL(request.url).pathname @@ -126,7 +124,7 @@ export class HostConnectionService extends Service implements HostConnectionHand const endpoint = endpointFromPath(channel, pathname) const interceptor = this.interceptors.get(channel) if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { - return fallback.fetch(request) + return Promise.resolve(new Response('not found', { status: 404 })) } return interceptor.fetchHandler.fetch(request) }, @@ -248,7 +246,7 @@ function rpcFetchHandler( } } -function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response { +function invalidEnvelopeResponse(body: unknown, issues: readonly object[]): Response { const rawId = (body as { rpcId?: unknown } | null)?.rpcId const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID return errorResponse(rpcId, { @@ -269,7 +267,7 @@ function endpointFromPath(channel: string, pathname: string): string | undefined return endpoint } -function errorResponse(rpcId: RpcIdType, error: RpcError): Response { +function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response { return fullResponse(rpcId, { ok: false, error }) } @@ -295,9 +293,4 @@ function assertFetchRoute(route: ConnectionFetchRoute): void { if (methods.size !== route.methods.length) { throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`) } - for (const method of methods) { - if (method !== 'GET' && method !== 'HEAD') { - throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} has unsupported method ${JSON.stringify(method)}`) - } - } } diff --git a/packages/client/connection/src/rpc-schema.ts b/packages/client/connection/src/rpc-schema.ts new file mode 100644 index 0000000000..dc919a8187 --- /dev/null +++ b/packages/client/connection/src/rpc-schema.ts @@ -0,0 +1,53 @@ +/** Runtime validation for Connection RPC envelopes. */ + +import { z } from 'zod' +import type { ClientRequest, RpcId, RpcMessage, ServerResponse } from './rpc.ts' + +/** Correlation id after wire validation. */ +export const rpcIdSchema = z.string() as unknown as z.ZodType + +/** Generic endpoint failure carried in a response envelope. */ +export const rpcErrorSchema = z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.unknown()), +}) + +/** + * Build the result parser for one endpoint value parser. + * @param value - endpoint-owned success-value parser. + * @returns parser for either a success value or generic failure. + */ +export function rpcResultSchema(value: z.ZodType): z.ZodType<{ + readonly ok: true + readonly value: T +} | { + readonly ok: false + readonly error: z.infer +}> { + return z.union([ + z.object({ ok: z.literal(true), value }), + z.object({ ok: z.literal(false), error: rpcErrorSchema }), + ]) +} + +/** Client request envelope; endpoint payload validation belongs to its owner. */ +export const clientRequestSchema = z.object({ + type: z.literal('client-request'), + rpcId: rpcIdSchema, + method: z.string(), + payload: z.unknown(), +}) as z.ZodType + +/** Server response envelope; endpoint value validation belongs to its caller. */ +export const serverResponseSchema = z.object({ + type: z.literal('server-response'), + rpcId: rpcIdSchema, + result: rpcResultSchema(z.unknown().optional()), +}) as z.ZodType + +/** Either Connection RPC envelope direction. */ +export const rpcMessageSchema = z.discriminatedUnion('type', [ + clientRequestSchema as unknown as z.ZodObject, + serverResponseSchema as unknown as z.ZodObject, +]) as unknown as z.ZodType diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 9cfb47ab1c..6cbe86d837 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,5 +1,20 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Correlation id minted by a caller and echoed by the Connection response. */ +export type RpcId = Branded<'rpc-id'> + +/** + * Brand one validated string as a Connection correlation id. + * @param id - validated wire identity. + * @returns the same string with the correlation-id brand. + */ +export function RpcId(id: string): RpcId { + return id as RpcId +} + /** Carrier-neutral failure returned by one logical RPC endpoint. */ export interface ConnectionRpcFailure { readonly code: string @@ -12,6 +27,81 @@ export type ConnectionRpcResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: ConnectionRpcFailure } +/** Typed failure details used by Client Session adapters. */ +export interface RpcErrorDetailsMap { + 'bad-request': { issues: object[] } + 'cancelled': {} + 'session-not-found': { sessionId: SessionId } + 'invalid-time-zone': { value: string } + 'agent-preset-read-only': { agentPreset: string; reason: string } + 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } + 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } + 'agent-preset-invalid': { agentPreset: string; reason: string } + 'agent-busy': { reason: string } + 'internal': {} +} + +/** Error codes used by Client Session adapters. */ +export type RpcErrorCode = keyof RpcErrorDetailsMap + +/** Typed failure used by Client Session adapters. */ +export type RpcError = { + [Code in RpcErrorCode]: { + readonly code: Code + readonly message: string + readonly details: RpcErrorDetailsMap[Code] + } +}[RpcErrorCode] + +/** Historical short name for a generic Connection result. */ +export type RpcResult = ConnectionRpcResult + +/** + * Convert a rejected transport operation into a generic failure result. + * @param error - rejected transport value. + * @returns an `internal` failure preserving the available message. + */ +export function transportError(error: unknown): RpcResult { + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } +} + +/** Narrow request form used by direct fixture adapters. */ +export interface RpcRequest

{ + readonly rpcId: RpcId + readonly payload: P +} + +/** Narrow response form used by direct fixture adapters. */ +export interface RpcResponse { + readonly rpcId: RpcId + readonly result: RpcResult +} + +/** Full request envelope carried by Connection RPC transports. */ +export interface ClientRequest { + readonly type: 'client-request' + readonly rpcId: RpcId + readonly method: string + readonly payload: unknown +} + +/** Full response envelope carried by Connection RPC transports. */ +export interface ServerResponse { + readonly type: 'server-response' + readonly rpcId: RpcId + readonly result: ConnectionRpcResult +} + +/** Complete Connection RPC envelope union. */ +export type RpcMessage = ClientRequest | ServerResponse + /** HTTP request facts consumed by browser trust and authentication. */ export interface ConnectionTrustRequest { /** Request headers supplied by either the Fetch or node:http representation. */ @@ -100,6 +190,13 @@ export interface HostConnectionHandle { /** Exact Fetch routes for streaming or browser-native responses. */ readonly fetch: HostConnectionFetch + /** + * Compose exact Fetch routes and the shared-channel RPC interceptor. + * @param channel - shared channel mounted by Connection. + * @returns Fetch handler for trusted, authenticated requests. + */ + createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler + /** * Apply Connection's Host/Origin checks and browser authentication to * another Web route. @@ -124,6 +221,16 @@ export interface HostConnectionHandle { authenticatedUrl(baseUrl: string): string } +/** Transport-independent Fetch handler used by HTTP and worker carriers. */ +export interface ConnectionFetchHandler { + /** + * Dispatch one already-authenticated request. + * @param request - Fetch request below the shared channel. + * @returns the registered response or a 404 response. + */ + fetch(request: Request): Promise +} + /** Client caller for logical RPC channels carried by the current transport. */ export interface ClientConnectionRpc { /** diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index fb5257e355..e317c78f0d 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -10,8 +10,6 @@ import { type ConnectionGenerationSource, type ConnectionHandle, } from '../src/client/index.ts' -import { FixtureApiClient } from '../src/client/fixture.ts' -import { WebApiClient } from '../src/client/web-api-client.ts' type Win = { location?: { hostname: string; search: string; origin?: string } @@ -61,20 +59,22 @@ async function mount(): Promise { } describe('connection client apply', () => { - it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => { + it('treats a runtime without browser location as local', async () => { + delete (globalThis as Win).location + expect((await mount()).isLoopback).toBe(true) + }) + + it('mounts ctx.connection and identifies a loopback page', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() - expect(handle.api).toBeInstanceOf(WebApiClient) expect(handle.isLoopback).toBe(true) }) - it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => { + it('selects the fixture RPC transport under ?fixture', async () => { ;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' } - expect((await mount()).api).toBeInstanceOf(FixtureApiClient) - delete (globalThis as Win).location const handle = await mount() - expect(handle.api).toBeInstanceOf(WebApiClient) - expect(handle.isLoopback).toBe(true) + await expect(handle.rpc.call('/api', 'settings/describe', { args: {} })) + .resolves.toMatchObject({ ok: true }) }) it('reports non-loopback page authority through the connection handle', async () => { @@ -98,10 +98,10 @@ describe('connection client apply', () => { const loop = handle.start({}) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) unregisterSecond() - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() loop.stop() }) @@ -110,26 +110,26 @@ describe('connection client apply', () => { const handle = await mount() installGeneration(handle) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const descriptions: Array = [] - const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') }) - const stopDescription = handle.hostDescription.subscribe(() => { - descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath) + const generations: Array = [] + const stopThrowing = handle.generation.subscribe(() => { throw new Error('subscriber bug') }) + const stopGeneration = handle.generation.subscribe(() => { + generations.push(handle.generation.getSnapshot()?.host.home) }) - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() // config omitted: the `config ?? {}` default arm is part of the surface. let connected = 0 const loop = handle.start({ onConnected: () => { connected++ } }) expect(() => handle.start({})).toThrow(/already owned by another consumer/) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) loop.stop() // teardown must not throw; the fixture streams abort quietly - expect(handle.hostDescription.getSnapshot()).toBeUndefined() - expect(descriptions).toEqual([true, undefined]) + expect(handle.generation.getSnapshot()).toBeUndefined() + expect(generations).toEqual(['/h', undefined]) expect(connected).toBe(1) expect(errorSpy).toHaveBeenCalledTimes(2) stopThrowing() - stopDescription() + stopGeneration() errorSpy.mockRestore() }) @@ -140,87 +140,87 @@ describe('connection client apply', () => { const first = handle.start({}) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) first.stop() - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() const second = handle.start({}) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) first.stop() - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') second.stop() generation.end() }) - it('does not announce a generation synchronously stopped by a description subscriber', async () => { + it('does not announce a generation synchronously stopped by a generation subscriber', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() installGeneration(handle) const owner: { loop?: ReturnType } = {} - let sawDescription = false - const stopDescription = handle.hostDescription.subscribe(() => { - if (handle.hostDescription.getSnapshot() === undefined) return - sawDescription = true + let sawGeneration = false + const stopGeneration = handle.generation.subscribe(() => { + if (handle.generation.getSnapshot() === undefined) return + sawGeneration = true owner.loop?.stop() }) const connected = vi.fn() const loop = handle.start({ onConnected: connected }) owner.loop = loop try { - await vi.waitFor(() => { expect(sawDescription).toBe(true) }) - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + await vi.waitFor(() => { expect(sawGeneration).toBe(true) }) + expect(handle.generation.getSnapshot()).toBeUndefined() expect(connected).not.toHaveBeenCalled() } finally { - stopDescription() + stopGeneration() loop.stop() } }) - it('retracts the host description while reconnecting and republishes the next generation', async () => { + it('retracts the generation while reconnecting and publishes the next generation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const generation = installGeneration(handle) - const descriptions: Array = [] - const reconnectSnapshots: Array = [] - const stopDescription = handle.hostDescription.subscribe(() => { - descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath) + const generations: Array = [] + const reconnectSnapshots: Array = [] + const stopGeneration = handle.generation.subscribe(() => { + generations.push(handle.generation.getSnapshot()?.host.home) }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const loop = handle.start({ onStateChange: (state) => { if (state === 'reconnecting') { - reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath) + reconnectSnapshots.push(handle.generation.getSnapshot()?.host.home) } }, }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) try { await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) generation.end() await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) }) - await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) }) - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + await vi.waitFor(() => { expect(generations).toEqual(['/h', undefined, '/h']) }) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') } finally { - stopDescription() + stopGeneration() loop.stop() warnSpy.mockRestore() } }) - it('does not announce reconnecting after a description subscriber stops the loop', async () => { + it('does not announce reconnecting after a generation subscriber stops the loop', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const generation = installGeneration(handle) const owner: { loop?: ReturnType } = {} let stoppedOnRetraction = false - const stopDescription = handle.hostDescription.subscribe(() => { - if (handle.hostDescription.getSnapshot() !== undefined || owner.loop === undefined) return + const stopGeneration = handle.generation.subscribe(() => { + if (handle.generation.getSnapshot() !== undefined || owner.loop === undefined) return stoppedOnRetraction = true owner.loop.stop() }) @@ -232,38 +232,20 @@ describe('connection client apply', () => { owner.loop = loop try { await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) generation.end() await vi.waitFor(() => { expect(stoppedOnRetraction).toBe(true) }) - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() expect(states).toEqual(['connected']) } finally { - stopDescription() + stopGeneration() loop.stop() warnSpy.mockRestore() } }) - it('WebApiClient keeps unary calls on globalThis.fetch', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '' } - const handle = await mount() - const original = globalThis.fetch - const seen: string[] = [] - globalThis.fetch = (input: URL | RequestInfo) => { - seen.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) - return Promise.resolve(new Response('{}', { status: 200 })) - } - try { - // Schema rejection is fine — the transport hop is the assertion. - await (handle.api as WebApiClient).host.describe({}).catch(() => undefined) - } finally { - globalThis.fetch = original - } - expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true) - }) - it('carries RPC calls without requiring secure-context randomUUID', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } vi.stubGlobal('crypto', { @@ -311,7 +293,6 @@ describe('connection client apply', () => { })(), ) ;(globalThis as Win).__DSH_TRANSPORT__ = { - createApiClient: () => new FixtureApiClient(), fetch: vi.fn(), openStream, ownsHost: true, @@ -428,7 +409,7 @@ describe('connection client apply', () => { } }) - it('carries Goal Remotes over the same state as the client-only fixture API', async () => { + it('carries Goal Remotes over the client-only fixture state', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const created = await handle.rpc.call('/api', 'goals/create', { diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 40f9a5ec6c..9ac090e947 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -1,117 +1,52 @@ -/** - * ConnectionController: strict readiness handshake (describe + incremental - * source ready), generation - * abort on loss, backoff reconnection, state transitions, and sink-exception - * isolation. Real (short) timers — the timeout and backoff are configurable, - * so tests run them at millisecond scale. - */ +/** Connection generation readiness, loss, retry, and sink isolation. */ import { describe, expect, it, vi } from 'vitest' -import type { ConnectionState } from '../src/client/connection.ts' +import type { ConnectionGenerationSource, ConnectionState } from '../src/client/connection.ts' import { ConnectionController } from '../src/client/connection.ts' -import { FakeApiClient, deferred, ok } from './fake-api.client.ts' +import { FakeGenerationSource } from './fake-generation.client.ts' const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 } describe('connection lifecycle', () => { - it('announces connected after describe plus generation readiness', async () => { - const api = new FakeApiClient() - const descriptions: boolean[] = [] - let connected = 0 - const controller = new ConnectionController(api, api.generation, { - onConnected: (description) => { - connected++ - descriptions.push(description.canOpenPath) - }, + it('announces connected with the Host facts from generation readiness', async () => { + const source = new FakeGenerationSource() + const homes: string[] = [] + const controller = new ConnectionController(source.source, { + onConnected: (host) => { homes.push(host.home) }, }, FAST) controller.start() try { - await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.callsOf('host.describe')).toHaveLength(1) - expect(descriptions).toEqual([true]) + await vi.waitFor(() => { expect(homes).toEqual(['/h']) }) } finally { controller.stop() } }) it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - api.failStreams(new Error('stream torn')) - await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff - expect(api.openGenerationCount).toBe(1) + source.fail(new Error('stream torn')) + await vi.waitFor(() => { expect(connected).toBe(2) }) + expect(source.activeCount).toBe(1) } finally { controller.stop() warnSpy.mockRestore() } - // stop() aborts the live generation and no reconnect follows. - await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(0) }) await new Promise(resolve => setTimeout(resolve, 40)) - expect(api.openGenerationCount).toBe(0) - }) - - it('treats describe failure as generation failure and retries', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls++ - return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise - } - let connected = 0 - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) - controller.start() - try { - await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff - expect(connected).toBe(0) // never announced during the failed generation - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) - await vi.waitFor(() => { expect(connected).toBe(1) }) - } finally { - controller.stop() - warnSpy.mockRestore() - } - }) - - it('treats a host.describe business error as generation failure', async () => { - const api = new FakeApiClient() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls += 1 - if (describeCalls === 1) { - return Promise.resolve({ - rpcId: 'bad-describe' as never, - result: { - ok: false as const, - error: { code: 'internal' as const, message: 'not ready', details: {} }, - }, - }) - } - return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) - } - let connected = 0 - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) - controller.start() - try { - await vi.waitFor(() => { expect(describeCalls).toBe(2) }) - await vi.waitFor(() => { expect(connected).toBe(1) }) - } finally { - controller.stop() - warnSpy.mockRestore() - } + expect(source.activeCount).toBe(0) }) it('isolates a connected sink exception from the generation', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() let connected = 0 const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ throw new Error('business layer bug') @@ -120,7 +55,7 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.openGenerationCount).toBe(1) + expect(source.activeCount).toBe(1) expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error)) } finally { controller.stop() @@ -128,47 +63,75 @@ describe('connection lifecycle', () => { } }) - it('holds onConnected until the incremental source is ready after describe succeeds', async () => { - const api = new FakeApiClient() - api.holdGenerationReady = true + it('holds onConnected until the incremental source reports ready', async () => { + const source = new FakeGenerationSource() + source.holdReady = true let connected = 0 - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) await new Promise(resolve => setTimeout(resolve, 30)) - expect(connected).toBe(0) // describe alone must not announce - api.releaseGenerationReady() + expect(connected).toBe(0) + source.releaseReady() await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() } }) - it('rejects a generation whose source ends during readiness and retries', async () => { - const api = new FakeApiClient() - const firstDescribe = deferred>>() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls++ - return describeCalls === 1 - ? firstDescribe.promise - : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) + it('accepts only the first readiness report from one generation', async () => { + const homes: string[] = [] + const source: ConnectionGenerationSource = (signal, ready) => { + ready({ home: '/first' }) + ready({ home: '/duplicate' }) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) } + const controller = new ConnectionController(source, { + onConnected: (host) => { homes.push(host.home) }, + }, FAST) + controller.start() + try { + await vi.waitFor(() => { expect(homes).toEqual(['/first']) }) + } finally { + controller.stop() + } + }) + + it('does not announce readiness after a stop queued from the ready callback', async () => { + const owner: { controller?: ConnectionController } = {} + let sourceCalls = 0 + const connected = vi.fn() + const source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { + sourceCalls++ + ready({ home: '/h' }) + queueMicrotask(() => { owner.controller?.stop() }) + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + const controller = new ConnectionController(source, { onConnected: connected }, FAST) + owner.controller = controller + controller.start() + await vi.waitFor(() => { expect(sourceCalls).toBe(1) }) + expect(connected).not.toHaveBeenCalled() + }) + + it('rejects a generation whose source ends during readiness and retries', async () => { + const source = new FakeGenerationSource() + source.holdReady = true const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.openGenerationCount).toBe(1) }) - api.endStreams() - firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) - - await vi.waitFor(() => { expect(describeCalls).toBe(2) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) + source.holdReady = false + source.end() await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['reconnecting', 'connected']) } finally { @@ -181,22 +144,21 @@ describe('connection lifecycle', () => { { label: 'ends normally', fail: () => Promise.resolve() }, { label: 'rejects with a non-Error reason', - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error source normalization is the scenario. fail: () => Promise.reject('fixture offline'), }, ])('retries when the generation source $label before reporting ready', async ({ fail }) => { - const api = new FakeApiClient() let sourceCalls = 0 let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, (signal, ready) => { + const source: ConnectionGenerationSource = (signal, ready) => { sourceCalls++ if (sourceCalls === 1) return fail() ready({ home: '/h' }) return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - }, { onConnected: () => { connected++ } }, FAST) + } + const controller = new ConnectionController(source, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(sourceCalls).toBe(2) }) @@ -208,19 +170,19 @@ describe('connection lifecycle', () => { }) it('rejects and retries a generation whose source never reports ready', async () => { - const api = new FakeApiClient() - api.suppressGenerationReady = true + const source = new FakeGenerationSource() + source.suppressReady = true let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const controller = new ConnectionController( - api, - api.generation, + source.source, { onConnected: () => { connected++ } }, { ...FAST, generationReadyTimeoutMs: 20 }, ) controller.start() try { - await vi.waitFor(() => { expect(api.callsOf('host.describe').length).toBeGreaterThan(1) }) + await vi.waitFor(() => { expect(source.activeCount).toBeGreaterThan(0) }) + await new Promise(resolve => setTimeout(resolve, 45)) expect(connected).toBe(0) } finally { controller.stop() @@ -229,11 +191,11 @@ describe('connection lifecycle', () => { }) it('emits deduplicated connected/reconnecting state transitions', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) @@ -241,7 +203,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['connected']) - api.failStreams(new Error('torn')) + source.fail(new Error('torn')) await vi.waitFor(() => { expect(connected).toBe(2) }) expect(states).toEqual(['connected', 'reconnecting', 'connected']) } finally { @@ -251,10 +213,10 @@ describe('connection lifecycle', () => { }) it('does not announce a generation stopped synchronously by its connected state sink', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() const states: ConnectionState[] = [] let connected = 0 - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ }, onStateChange: (state) => { states.push(state) @@ -264,59 +226,58 @@ describe('connection lifecycle', () => { controller.start() await vi.waitFor(() => { expect(states).toEqual(['connected']) }) - await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(0) }) expect(connected).toBe(0) }) it('deduplicates consecutive reconnecting emissions across two straight failures', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls++ - return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise - } + let sourceCalls = 0 const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const source: ConnectionGenerationSource = (signal, ready) => { + sourceCalls++ + if (sourceCalls <= 2) return Promise.reject(new Error('down')) + ready({ home: '/h' }) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + const controller = new ConnectionController(source, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() try { - await vi.waitFor(() => { expect(describeCalls).toBe(3) }) - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) + await vi.waitFor(() => { expect(sourceCalls).toBe(3) }) await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission + expect(states).toEqual(['reconnecting', 'connected']) } finally { controller.stop() warnSpy.mockRestore() } }) - it('runs with no sinks at all (every callback slot optional)', async () => { - const api = new FakeApiClient() - const controller = new ConnectionController(api, api.generation, {}, FAST) + it('runs with no sinks at all', async () => { + const source = new FakeGenerationSource() + const controller = new ConnectionController(source.source, {}, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) - await new Promise(resolve => setTimeout(resolve, 20)) + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) } finally { controller.stop() } }) - it('start() is idempotent (one loop, one stream set)', async () => { - const api = new FakeApiClient() + it('start() is idempotent', async () => { + const source = new FakeGenerationSource() let connected = 0 - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST) controller.start() controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.openGenerationCount).toBe(1) - expect(api.callsOf('host.describe')).toHaveLength(1) + expect(source.activeCount).toBe(1) } finally { controller.stop() } diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts deleted file mode 100644 index 639f52f2b9..0000000000 --- a/packages/client/connection/tests/fake-api.client.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo -// data source on a real clock; behavior tests need per-case responses and -// deferred-controlled timing). The generation source is a hand pump. -import type { IApiClient, RpcResponse } from '../src/client/api.ts' -import type { ConnectionGenerationSource } from '../src/client/connection.ts' -import { RpcId } from '../src/client/api.ts' - -export interface Deferred { - promise: Promise - resolve(value: T): void - reject(error: unknown): void -} - -/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */ -export function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -let nextRpc = 0 - -export function ok(value: T): RpcResponse { - return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } } -} - - -type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown } - -interface StreamConn { - feed(item: StreamItem): void -} - -export class FakeApiClient implements IApiClient { - /** Chronological call record: [method, payload]. */ - readonly calls: { method: string; payload: unknown }[] = [] - - // Programmable slots (defaults answer OK-empty); reassign per case. - onDescribe: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - })) - - private readonly generationConns: StreamConn[] = [] - - readonly host: IApiClient['host'] = { - describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), - } - - /** When true, the source never reports ready. */ - suppressGenerationReady = false - - /** When true, ready callbacks remain parked until the test releases them. */ - holdGenerationReady = false - private heldOpens: (() => void)[] = [] - - releaseGenerationReady(): void { - const held = this.heldOpens - this.heldOpens = [] - for (const fire of held) fire() - } - - readonly generation: ConnectionGenerationSource = (signal, ready) => - this.openGeneration(signal, ready) - - /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ - endStreams(): void { - for (const conn of [...this.generationConns]) conn.feed({ kind: 'end' }) - } - - failStreams(error: unknown): void { - for (const conn of [...this.generationConns]) conn.feed({ kind: 'fail', error }) - } - - get openGenerationCount(): number { - return this.generationConns.length - } - - callsOf(method: string): unknown[] { - return this.calls.filter(c => c.method === method).map(c => c.payload) - } - - private record(method: string, payload: unknown, response: Promise): Promise { - this.calls.push({ method, payload }) - return response - } - - private async openGeneration( - signal: AbortSignal, - onOpen: (host: { readonly home: string }) => void, - ): Promise { - const inbox: StreamItem[] = [] - let wake: (() => void) | null = null - const conn: StreamConn = { - feed: (item) => { - inbox.push(item) - wake?.() - }, - } - this.generationConns.push(conn) - const ready = (): void => { onOpen({ home: '/h' }) } - if (this.holdGenerationReady) this.heldOpens.push(ready) - else if (!this.suppressGenerationReady) ready() - try { - while (!signal.aborted) { - while (inbox.length > 0) { - const item = inbox.shift() as StreamItem - if (item.kind === 'end') return - if (item.kind === 'fail') throw item.error - } - await new Promise((resolve) => { - wake = resolve - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - wake = null - } - } finally { - this.generationConns.splice(this.generationConns.indexOf(conn), 1) - } - } -} diff --git a/packages/client/connection/tests/fake-generation.client.ts b/packages/client/connection/tests/fake-generation.client.ts new file mode 100644 index 0000000000..83625bb55e --- /dev/null +++ b/packages/client/connection/tests/fake-generation.client.ts @@ -0,0 +1,80 @@ +/** Test-local programmable Connection generation source. */ +import type { ConnectionGenerationSource } from '../src/client/connection.ts' + +type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown } + +interface StreamConnection { + feed(item: StreamItem): void +} + +/** Hand-pumped generation source for Connection lifecycle tests. */ +export class FakeGenerationSource { + private readonly connections: StreamConnection[] = [] + + /** When true, the source never reports ready. */ + suppressReady = false + + /** When true, ready callbacks remain parked until the test releases them. */ + holdReady = false + + private heldReady: Array<() => void> = [] + + /** Open one generation. */ + readonly source: ConnectionGenerationSource = (signal, ready) => this.open(signal, ready) + + /** Release every generation currently parked before readiness. */ + releaseReady(): void { + const held = this.heldReady + this.heldReady = [] + for (const fire of held) fire() + } + + /** End every active generation normally. */ + end(): void { + for (const connection of [...this.connections]) connection.feed({ kind: 'end' }) + } + + /** Fail every active generation. */ + fail(error: unknown): void { + for (const connection of [...this.connections]) connection.feed({ kind: 'fail', error }) + } + + /** Number of currently active generations. */ + get activeCount(): number { + return this.connections.length + } + + private async open( + signal: AbortSignal, + onReady: (host: { readonly home: string }) => void, + ): Promise { + const inbox: StreamItem[] = [] + let wake: (() => void) | null = null + const connection: StreamConnection = { + feed: (item) => { + inbox.push(item) + wake?.() + }, + } + this.connections.push(connection) + const ready = (): void => { onReady({ home: '/h' }) } + if (this.holdReady) this.heldReady.push(ready) + else if (!this.suppressReady) ready() + try { + while (!signal.aborted) { + while (inbox.length > 0) { + const item = inbox.shift() as StreamItem + if (item.kind === 'end') return + if (item.kind === 'fail') throw item.error + } + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + wake = null + } + } finally { + this.connections.splice(this.connections.indexOf(connection), 1) + } + } +} diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts index 7c83fed1b5..d5dbee979e 100644 --- a/packages/client/connection/tests/fetch-routes.host.spec.ts +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -19,17 +19,16 @@ async function mounted(): Promise<{ } describe('Connection exact Fetch routes', () => { - it('dispatches owned methods before the transitional fallback', async () => { + it('dispatches owned methods and returns 404 for unclaimed requests', async () => { const { connection, dispose: disposeFiber } = await mounted() const route = vi.fn(async (request: Request) => Response.json({ query: new URL(request.url).searchParams.get('sessionId') })) - const fallback = vi.fn(async () => new Response('fallback', { status: 418 })) const dispose = connection.fetch.register({ path: '/api/session.export', methods: ['GET', 'HEAD'], fetch: route, }) - const shared = connection.createSharedFetchHandler('/api', { fetch: fallback }) + const shared = connection.createSharedFetchHandler('/api') const response = await shared.fetch(new Request( 'http://host/api/session.export?sessionId=session-1', @@ -37,16 +36,12 @@ describe('Connection exact Fetch routes', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ query: 'session-1' }) expect(route).toHaveBeenCalledOnce() - expect(fallback).not.toHaveBeenCalled() - const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) - expect(post.status).toBe(418) - expect(fallback).toHaveBeenCalledOnce() + expect(post.status).toBe(404) await dispose() const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) - expect(withdrawn.status).toBe(418) - expect(fallback).toHaveBeenCalledTimes(2) + expect(withdrawn.status).toBe(404) await disposeFiber() }) @@ -61,10 +56,6 @@ describe('Connection exact Fetch routes', () => { expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['GET', 'GET'], fetch, })).toThrow('repeats a method') - expect(() => connection.fetch.register({ - path: '/api/session.export', methods: ['POST' as 'GET'], fetch, - })).toThrow('unsupported method') - const dispose = connection.fetch.register({ path: '/api/session.export', methods: ['GET'], fetch, }) diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index 818e34d92a..ea5c3a764b 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -169,7 +169,7 @@ describe('createFixtureApi commands/skills', () => { }) }) -describe('FixtureApiClient command/skill dispatch', () => { +describe('fixture Connection command/skill dispatch', () => { it('routes the Remote command and skill rows through one state graph', async () => { const { rpc } = createFixtureFaces() const commands = await callRemote<{ name: string }[]>(rpc, 'commands/list', { agentId: sid('fx-alpha') }) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 8e41867de0..b7e76ec767 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -1,7 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { - ModelSelection, - RpcMessage, RpcRequest, RpcResponse, RpcResult, @@ -12,7 +10,7 @@ import { RpcId } from '../src/client/api.ts' import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import { - FixtureApiClient, + createFixtureConnectionRpc, createFixtureFaces, type FixtureOptions, } from '../src/client/fixture.ts' @@ -21,6 +19,7 @@ import type { } from '../src/rpc.ts' import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' import type { ModelCatalog } from '@deepseek-ai/dsh-api-session-controller/types' +import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types' const sid = (id: string): SessionId => id as SessionId type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } @@ -288,7 +287,7 @@ interface FixtureRemoteEventStream extends AsyncIterable } -type FixtureTestApi = ReturnType['api'] & { +type FixtureTestApi = { /** The directory-picking Remote namespace as the fixture serves it. */ readonly directoryPickerRemote: { pick: () => Promise> @@ -307,8 +306,8 @@ type FixtureTestApi = ReturnType['api'] & { /** Keep existing fixture assertions compact while driving only the new Session Remote endpoints. */ function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { - const { api, rpc } = createFixtureFaces(options) - return Object.assign(api, { + const { rpc } = createFixtureFaces(options) + return { directoryPickerRemote: { pick: () => rpc.call('/api', 'directoryPicker/pick', { args: {} }) as Promise>, @@ -327,7 +326,7 @@ function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { remoteEvents: (signal: AbortSignal) => openFixtureRemoteEvents(rpc, signal), answerRemoteEvent: (result: FixtureRemoteEventResult) => rpc.call('/api', '$events/result', { args: result }), - }) + } } /** The fixture's Credentials Remote endpoints over the shared RPC carrier. */ @@ -1103,16 +1102,6 @@ describe('createFixtureApi', () => { expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request']) }) - it('describe answers the fixture identity', async () => { - const api = createFixtureApi() - const response = await api.host.describe(req({})) - expect(response.result).toMatchObject({ - ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1, home: '/home/fixture' }, - }) - const empty = await createFixtureApi({ empty: true }).host.describe(req({})) - expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) - }) - it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { const api = createFixtureApi() const created = await api.directoryPickerRemote.createDirectory('/', 'srv') @@ -1613,38 +1602,16 @@ describe('createFixtureApi', () => { }) }) -describe('FixtureApiClient (protocol-level fake carrier)', () => { +describe('fixture Connection RPC', () => { afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) - it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => { - const client = new FixtureApiClient() - // Protected at compile time only; reach it directly to pin the tripwire message. - expect(() => (client as unknown as { doFetch(): Promise }).doFetch()).toThrow(/doFetch must be unreachable/) - }) - - it('mints request ids and taps unary request/response envelopes without touching doFetch', async () => { - const client = new FixtureApiClient() - const tapped: RpcMessage[] = [] - client.subscribeEnvelopes(batch => tapped.push(...batch)) - const response = await client.host.describe({}) - expect(response.result.ok).toBe(true) - await vi.waitFor(() => { - const kinds = tapped.map(m => m.type) - expect(kinds).toContain('client-request') - expect(kinds).toContain('server-response') - }) - const request = tapped.find(m => m.type === 'client-request') - const reply = tapped.find(m => m.type === 'server-response') - expect(request?.rpcId).toBe(reply?.rpcId) // echo discipline holds through the fake carrier - }) - - it('covers the whole unary dispatch table', async () => { - const client = new FixtureApiClient() - const sessions = createSessionClient(client.rpc) - const workspaces = createWorkspaceClient(client.rpc) + it('covers the migrated Remote dispatch table', async () => { + const rpc = createFixtureConnectionRpc() + const sessions = createSessionClient(rpc) + const workspaces = createWorkspaceClient(rpc) expect((await sessions.search( { query: 'fixture' }, new AbortController().signal, @@ -1655,8 +1622,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await sessions.history({ sessionId: id })).result.ok).toBe(true) expect((await sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await sessions.cancel({ sessionId: id })).result.ok).toBe(true) - expect((await client.host.describe({})).result.ok).toBe(true) - expect((await readWorkspaceBaseline(createWorkspaceRemote(client.rpc))).items).not.toHaveLength(0) + expect((await readWorkspaceBaseline(createWorkspaceRemote(rpc))).items).not.toHaveLength(0) const workspace = await workspaces.create({ path: '/tmp/fixture-workspaces/via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') @@ -1672,13 +1638,13 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) it('folds the goal lifecycle over the Goal Remotes', async () => { - const client = new FixtureApiClient() - const sessions = createSessionClient(client.rpc) + const rpc = createFixtureConnectionRpc() + const sessions = createSessionClient(rpc) const created = await sessions.create({}) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId const goal = (endpoint: string, args: Record) => - client.rpc.call('/api', endpoint, { args: { agentId: id, ...args } }) + rpc.call('/api', endpoint, { args: { agentId: id, ...args } }) // create → edit → pause → resume → complete → clear; each mutation advances the CAS // revision by one (state rides the projection frames). @@ -1717,17 +1683,17 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { vi.stubGlobal('location', { search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first', }) - const client = new FixtureApiClient() - const sessions = createSessionClient(client.rpc) - const workspaces = createWorkspaceClient(client.rpc) - const workspaceRemote = createWorkspaceRemote(client.rpc) + const rpc = createFixtureConnectionRpc() + const sessions = createSessionClient(rpc) + const workspaces = createWorkspaceClient(rpc) + const workspaceRemote = createWorkspaceRemote(rpc) await expect(sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) const made = await workspaces.create({ path: '/tmp/fixture-workspaces/query-workspace' }) if (!made.result.ok) throw new Error('workspace create failed') const hostAbort = new AbortController() const workspaceAbort = new AbortController() const hostFrames = collectValues( - openFixtureRemoteEvents(client.rpc, hostAbort.signal), + openFixtureRemoteEvents(rpc, hostAbort.signal), hostAbort, frames => frames.length === 1, ) @@ -1761,8 +1727,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { it('maps attach-failure and dropped-response query scenarios', async () => { vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' }) - const partial = new FixtureApiClient() - const partialResult = await createSessionClient(partial.rpc).create({ + const partial = createFixtureConnectionRpc() + const partialResult = await createSessionClient(partial).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-partial'), }) @@ -1772,8 +1738,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) - const dropped = new FixtureApiClient() - await expect(createSessionClient(dropped.rpc).create({ + const dropped = createFixtureConnectionRpc() + await expect(createSessionClient(dropped).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-dropped'), })).rejects.toThrow(/dropped session\.create response/) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 4f4fc17e39..268b37e75c 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -6,11 +6,9 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' -import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts' +import { API_PATH, RpcId, apply, inject, type ClientRequest, type HostConnectionHandle } from '../src/index.ts' import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' import { provideBrowserCredentials } from './browser-credentials.ts' @@ -94,7 +92,6 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ const upgrades: WebUpgradeRoute[] = [] provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) - ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() return { @@ -131,7 +128,6 @@ describe('connection node half', () => { ctx.provide('attachments', { imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 }, } as AttachmentStore) - ctx.provide('apiProxy', {} as ApiProxy) await expect(apply(ctx, { maxRequestBodyBytes: 1024 })) .rejects.toThrow(/must be at least .* aggregate image limit/) expect(routes).toHaveLength(0) @@ -143,7 +139,6 @@ describe('connection node half', () => { const ctx = new Context() provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) - ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) expect(routes).toHaveLength(0) @@ -243,7 +238,7 @@ describe('connection node half', () => { await dispose() }) - it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { + it('provides a disposable dedicated RPC channel', async () => { const ctx = new Context() const routes: WebRoute[] = [] provideBrowserCredentials(ctx) @@ -292,12 +287,11 @@ describe('connection node half', () => { expect(routes).toHaveLength(0) }) - it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => { + it('dispatches claimed /api endpoints and withdraws the claim', async () => { const ctx = new Context() const routes: WebRoute[] = [] provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) - ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() const connection = ctx.get('connection') as HostConnectionHandle diff --git a/packages/client/connection/tests/rpc-schema.host.spec.ts b/packages/client/connection/tests/rpc-schema.host.spec.ts new file mode 100644 index 0000000000..1d34e58ac3 --- /dev/null +++ b/packages/client/connection/tests/rpc-schema.host.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { RpcId, transportError } from '../src/rpc.ts' +import { + clientRequestSchema, + rpcErrorSchema, + rpcIdSchema, + rpcMessageSchema, + rpcResultSchema, + serverResponseSchema, +} from '../src/rpc-schema.ts' +import { z } from 'zod' + +describe('Connection RPC schema', () => { + it('brands any validated string correlation id', () => { + expect(RpcId('abc')).toBe('abc') + expect(rpcIdSchema.parse('')).toBe('') + expect(() => rpcIdSchema.parse(42)).toThrow() + }) + + it('folds transport exceptions into an internal failure', () => { + expect(transportError(new Error('wire down'))).toEqual({ + ok: false, + error: { code: 'internal', message: 'wire down', details: {} }, + }) + expect(transportError('raw')).toMatchObject({ + ok: false, + error: { code: 'internal', message: 'raw' }, + }) + }) + + it('validates generic failures and both result branches', () => { + expect(rpcErrorSchema.parse({ code: 'domain-failure', message: 'failed', details: { id: 'x' } })) + .toEqual({ code: 'domain-failure', message: 'failed', details: { id: 'x' } }) + expect(() => rpcErrorSchema.parse({ code: 1, message: 'failed', details: {} })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'failed', message: 'failed', details: [] })).toThrow() + + const schema = rpcResultSchema(z.object({ n: z.number() })) + expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } }) + expect(schema.parse({ ok: false, error: { code: 'failed', message: 'x', details: {} } })) + .toMatchObject({ ok: false }) + expect(() => schema.parse({ ok: true, error: {} })).toThrow() + }) + + it('validates both envelope directions and valueless success', () => { + const request = { type: 'client-request', rpcId: 'r1', method: 'settings/describe', payload: { args: {} } } + const response = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } } + expect(clientRequestSchema.parse(request).method).toBe('settings/describe') + expect(serverResponseSchema.parse(response).rpcId).toBe('r1') + for (const message of [request, response]) expect(rpcMessageSchema.parse(message)).toBeTruthy() + expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() + expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow() + expect(serverResponseSchema.parse({ + type: 'server-response', rpcId: 'r1', result: { ok: true }, + }).rpcId).toBe('r1') + }) +}) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 6d5533c9b7..5732fd31f1 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -13,7 +13,6 @@ "src/client/index.ts", "src/client/random-uuid.ts", "src/client/rpc.ts", - "src/client/web-api-client.ts", "src/loopback-hostname.ts", "src/rpc.ts" ], @@ -39,9 +38,6 @@ { "path": "../../settings/settings" }, - { - "path": "../../host/apiproxy" - }, { "path": "../../host/directory-picker" }, diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index ad9bacc704..baeeccf4a4 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -14,6 +14,7 @@ "src/invariant.ts", "src/loopback-hostname.ts", "src/rpc-host.ts", + "src/rpc-schema.ts", "src/rpc.ts" ], "references": [ @@ -24,13 +25,16 @@ "path": "../../credentials/credentials" }, { - "path": "../../host/apiproxy" + "path": "../../core/session" }, { "path": "../../host/webserver" }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index dfebafe874..984a6a6828 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/credentials/authorization/src/types.ts b/packages/credentials/authorization/src/types.ts index ea75db8d7c..32e55615c2 100644 --- a/packages/credentials/authorization/src/types.ts +++ b/packages/credentials/authorization/src/types.ts @@ -1,6 +1,6 @@ /** * Wire-safe authorization types, free of cordis/service imports so browser type - * chains (apiproxy api → client) can consume them without loading this + * chains can consume them without loading this * package's Context augmentation. * @module @deepseek-ai/dsh-authorization/types */ diff --git a/packages/experimental/webworker-packer/src/rules.ts b/packages/experimental/webworker-packer/src/rules.ts index d05330dbb2..7c579dda47 100644 --- a/packages/experimental/webworker-packer/src/rules.ts +++ b/packages/experimental/webworker-packer/src/rules.ts @@ -65,7 +65,6 @@ export const PAGE_ASSETS: readonly string[] = [ export const IMAGE_ENTRY_SEEDS: readonly string[] = [ '@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh-cmdline', - '@deepseek-ai/dsh-host-apiproxy', '@deepseek-ai/cordis', '@deepseek-ai/cordis-plugin-include', 'js-yaml', diff --git a/packages/experimental/webworker-packer/tsconfig.json b/packages/experimental/webworker-packer/tsconfig.json index 7039bfa53b..f31531c778 100644 --- a/packages/experimental/webworker-packer/tsconfig.json +++ b/packages/experimental/webworker-packer/tsconfig.json @@ -11,6 +11,9 @@ "src" ], "references": [ + { + "path": "../../../vendor/include" + }, { "path": "../webworker-runtime" }, diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 68643425f8..bfcff40db5 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -41,8 +41,8 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^" }, @@ -51,8 +51,8 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/experimental/webworker-runtime/src/client/api-client.ts b/packages/experimental/webworker-runtime/src/client/api-client.ts deleted file mode 100644 index 17bbc8f270..0000000000 --- a/packages/experimental/webworker-runtime/src/client/api-client.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Page-side unary API carrier over the postMessage tunnel. Gateway Remote - * streams use the tunnel's dedicated logical-stream frames instead of this - * fetch-shaped API path. - */ -import { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' -import type { WorkerTunnel } from './client.ts' - -/** API client whose requests travel the worker tunnel instead of the network. */ -export class WorkerApiClient extends AbstractApiClient { - private readonly tunnel: WorkerTunnel - - /** - * Bind the carrier to a tunnel. - * @param tunnel - page half of the worker tunnel. - */ - constructor(tunnel: WorkerTunnel) { - super() - this.tunnel = tunnel - } - - /** - * Send one request through the tunnel. - * @param input - request URL. - * @param init - fetch init; the tunnel honours method, headers, body, and signal. - * @returns the reconstructed response. - */ - protected doFetch(input: URL, init?: RequestInit): Promise { - return this.tunnel.fetch(input, init) - } -} diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index 8494e1c06a..7f4af6e1a9 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -10,12 +10,10 @@ */ import { IMAGE_FILE_NAME } from '../image-layout.ts' import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts' -import { WorkerApiClient } from './api-client.ts' import { WorkerTunnel, type TunnelFetch } from './client.ts' import { applyIndexInjections } from './apply-injections.ts' import { choosePreviewSource } from './source-chooser.ts' -export { WorkerApiClient } from './api-client.ts' export { WorkerTunnel, type TunnelFetch } from './client.ts' export { applyIndexInjections } from './apply-injections.ts' export { IMAGE_FILE_NAME } from '../image-layout.ts' @@ -27,7 +25,6 @@ export { /** Transport global the connection plugin reads instead of building an HTTP carrier. */ interface ClientTransportGlobal { __DSH_TRANSPORT__?: { - createApiClient: () => WorkerApiClient fetch: TunnelFetch openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable loadBundle: (url: string) => Promise @@ -147,7 +144,6 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn ) const payload = await tunnel.bootPayload() ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = { - createApiClient: () => new WorkerApiClient(tunnel), fetch: (input, init) => tunnel.fetch(input, init), openStream: (endpoint, payload, signal) => tunnel.open(endpoint, payload, signal), loadBundle: (url: string) => tunnel.loadBundle(url), diff --git a/packages/experimental/webworker-runtime/src/node/builtins.ts b/packages/experimental/webworker-runtime/src/node/builtins.ts index 5725ae0642..9f7947dc06 100644 --- a/packages/experimental/webworker-runtime/src/node/builtins.ts +++ b/packages/experimental/webworker-runtime/src/node/builtins.ts @@ -2,7 +2,7 @@ * The Node-compatibility table, in one place. Two consumers share it, and they * must resolve to the same module instances: * - the worker vite build aliases these specifiers for code bundled statically - * into the worker (vendored loader, apiproxy, …); + * into the worker (vendored loader, Connection, …); * - the worker module loader answers `require('node:fs')` from VFS-loaded * modules out of this table, before bare-name resolution. * Anything absent here fails loudly at resolution instead of resolving to an diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/ws.ts b/packages/experimental/webworker-runtime/src/node/external_packages/ws.ts index 380cae53d4..caa90d3b5f 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/ws.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/ws.ts @@ -1,6 +1,6 @@ /** * `ws` stub. `WebSocketDownlinks` constructs a `WebSocketServer` in a field - * initializer as soon as apiProxy is present, so the class must be constructible; + * initializer as soon as Connection is present, so the class must be constructible; * no method is ever reached because the fake HTTP server never emits `upgrade` * (the tunnel carries downstream events over the SSE branch instead). */ diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index 27ffb27214..70b70ac9ed 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -23,6 +23,7 @@ */ import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts' import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway' +import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' import type { AlsCausality } from './polyfill/async-context/als-runtime.ts' import { dirname, join } from './module-system/posix-path.ts' import { installProcessGlobal } from './node/globals/process.ts' @@ -242,19 +243,15 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { }) context = ctx - const apiProxy = ctx.get('apiProxy') - if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service') + const connection = ctx.get('connection') as HostConnectionHandle | undefined + if (connection === undefined) throw new Error('webworker host: the tree activated without a Connection service') const typertGateway = ctx.get('typertGateway') as TypertGateway | undefined if (typertGateway === undefined) { throw new Error('webworker host: the tree activated without a typertGateway service') } - const { toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy') as { - toFetchHandler: (api: unknown) => { fetch(request: Request): Promise } - } - const shared = ctx.get('connection') !== undefined - const handler = directFetchHandler(ctx, toFetchHandler(apiProxy)) + const handler = connection.createSharedFetchHandler('/api') const usage = loader.usage() - console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=${shared ? 'connection.createSharedFetchHandler (interceptors kept)' : 'api surface only'}, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) + console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=connection.createSharedFetchHandler, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) tunnel.serve({ directFetch: (request: Request) => handler.fetch(request), @@ -349,33 +346,6 @@ function requireLoweredImage(vfs: MemoryVfs, path: string): void { } } -/** - * Build the tunnel's direct API entry. - * - * The core API surface alone is not the whole `/api` channel: Typert RPC - * endpoints (`/api//`) are served by an interceptor the gateway - * registers on the Connection service, and answer 404 from the core routes. The - * Connection service composes both halves in `createSharedFetchHandler`, whose - * fallback — not the composition — carries network authentication and trust, so - * composing it here keeps every interceptor while leaving out the fences the - * worker-local direct lane exists to bypass. - * @param ctx - Booted host context. - * @param core - Fetch handler over the API surface. - * @returns Handler covering interceptors and the core surface. - */ -function directFetchHandler( - ctx: HostContext, - core: { fetch(request: Request): Promise }, -): { fetch(request: Request): Promise } { - const connection = ctx.get('connection') as { - createSharedFetchHandler( - channel: '/api', - fallback: { fetch(request: Request): Promise }, - ): { fetch(request: Request): Promise } - } | undefined - return connection?.createSharedFetchHandler('/api', core) ?? core -} - /** * The shipped preset root, as the application layer that owns the composition * supplies it. diff --git a/packages/experimental/webworker-runtime/tsconfig.json b/packages/experimental/webworker-runtime/tsconfig.json index 1c7b93807b..ab3ef04aee 100644 --- a/packages/experimental/webworker-runtime/tsconfig.json +++ b/packages/experimental/webworker-runtime/tsconfig.json @@ -27,7 +27,7 @@ "path": "../../client/modules" }, { - "path": "../../host/apiproxy" + "path": "../../client/connection/tsconfig.host.json" }, { "path": "../../host/webserver" 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 a4f182a645..858d359027 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -485,13 +485,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ConnectionConfig', declaration: 'export interface ConnectionConfig {\n backoffBaseMs?: number;\n backoffFactor?: number;\n backoffMaxMs?: number;\n generationReadyTimeoutMs?: number;\n}', }, + { + name: 'ConnectionGeneration', + declaration: 'export interface ConnectionGeneration {\n readonly id: number;\n readonly host: ConnectionHostInfo;\n}', + }, { name: 'ConnectionGenerationSource', - declaration: 'export type ConnectionGenerationSource = (signal: AbortSignal, ready: () => void) => Promise;', + declaration: 'export type ConnectionGenerationSource = (signal: AbortSignal, ready: (host: ConnectionHostInfo) => void) => Promise;', + }, + { + name: 'ConnectionGenerationState', + declaration: 'export interface ConnectionGenerationState {\n getSnapshot(): ConnectionGeneration | undefined;\n subscribe(listener: () => void): () => void;\n}', }, { name: 'ConnectionHandle', - declaration: 'export interface ConnectionHandle {\n readonly api: IApiClient;\n readonly isLoopback: boolean;\n readonly hostDescription: HostDescriptionSource;\n readonly rpc: ClientConnectionRpc;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): {\n stop(): void;\n };\n}', + declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly rpc: ClientConnectionRpc;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): {\n stop(): void;\n };\n}', + }, + { + name: 'ConnectionHostInfo', + declaration: 'export interface ConnectionHostInfo {\n readonly home: string;\n}', }, { name: 'ConnectionRpcFailure', @@ -503,7 +515,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionSinks', - declaration: 'export interface ConnectionSinks {\n onConnected?: (description: HostDescription) => void;\n onStateChange?: (state: ConnectionState) => void;\n}', + declaration: 'export interface ConnectionSinks {\n onConnected?: (host: ConnectionHostInfo) => void;\n onStateChange?: (state: ConnectionState) => void;\n}', }, { name: 'ConnectionState', @@ -525,14 +537,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'HooksSources', declaration: 'export type HooksSources = Record>;', }, - { - name: 'HostDescription', - declaration: 'export type HostDescription = import(\'@deepseek-ai/dsh-host-apiproxy/api\').ResponseValue<\'host.describe\'>;', - }, - { - name: 'HostDescriptionSource', - declaration: 'export interface HostDescriptionSource {\n getSnapshot(): HostDescription | undefined;\n subscribe(listener: () => void): () => void;\n}', - }, { name: 'HostObservable', declaration: 'export type HostObservable = ObservableSnapshot;', @@ -651,7 +655,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RemoteStream', - declaration: 'export class RemoteStream implements AsyncIterable> {\n constructor(private readonly connection: Pick, private readonly options: RemoteStreamOptions);\n get signal(): AbortSignal;\n restart(): void;\n dispose(): Promise;\n [Symbol.asyncIterator](): AsyncIterator>;\n}', + declaration: 'export class RemoteStream implements AsyncIterable> {\n constructor(private readonly connection: Pick, private readonly options: RemoteStreamOptions);\n get signal(): AbortSignal;\n restart(): void;\n dispose(): Promise;\n [Symbol.asyncIterator](): AsyncIterator>;\n}', }, { name: 'RemoteStreamCarrierError', 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 4891cd5537..cb8f1a7ebc 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2133,9 +2133,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [ '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /** Open a Tool argument path through the Host. */\n openFile: (path: string) => void\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', ], - ownerPropsReferences: [ - 'Wire', - ], + ownerPropsReferences: [], standardProps: [ 'useWorkspaces: SnapshotSelectorHook', 'useSessions: UseSessions', diff --git a/packages/interaction/user-approval/src/types.ts b/packages/interaction/user-approval/src/types.ts index 862d1739f0..c4b18260e9 100644 --- a/packages/interaction/user-approval/src/types.ts +++ b/packages/interaction/user-approval/src/types.ts @@ -1,6 +1,6 @@ /** * Wire-safe approval identifiers and outcome vocabulary, free of - * cordis/service imports so browser type chains (apiproxy api → client) can + * cordis/service imports so browser type chains can * consume them without loading this package's Context augmentation. * @module @deepseek-ai/dsh-user-approval/types */ diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 05b2c4044b..dd25b0dfcf 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -92,7 +92,6 @@ describe('client bundle purity gate', () => { }) it('lets inline-safe wire layers inline', () => { - expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull() expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull() expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull() @@ -218,10 +217,10 @@ describe('client bundle debug artifacts', () => { if (transform === undefined) throw new Error('client sourcemap path transform missing') const sourceMapPath = clientSourceMapPath('client/connection') - const workspaceSource = transform('../../../host/apiproxy/src/api/rpc.ts', sourceMapPath) - expect(workspaceSource).toBe('../../../packages/host/apiproxy/src/api/rpc.ts') + const workspaceSource = transform('../src/rpc.ts', sourceMapPath) + expect(workspaceSource).toBe('../../../packages/client/connection/src/rpc.ts') const resolved = new URL(workspaceSource, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-connection/client.js.map') - expect(resolved.pathname).toBe('/packages/host/apiproxy/src/api/rpc.ts') + expect(resolved.pathname).toBe('/packages/client/connection/src/rpc.ts') const dependencySource = '../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/index.js' expect(transform(dependencySource, sourceMapPath)).toBe(dependencySource) From 4f00a8b82af9145d9ee19d5201972ef92fb311da Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:51:58 +0800 Subject: [PATCH 07/11] refactor(api): remove ApiProxy package --- apps/cli/package.json | 1 - apps/cli/tsconfig.json | 3 - packages/bundle/web-app/cordis.patch.yml | 5 - packages/bundle/web-app/package.json | 1 - .../extensions/tool-cordis/src/api-catalog.ts | 60 +- packages/host/apiproxy/README.i18n.yaml | 6 - packages/host/apiproxy/README.md | 135 ---- packages/host/apiproxy/README.zh.md | 135 ---- packages/host/apiproxy/package.json | 74 -- packages/host/apiproxy/src/api-proxy.ts | 137 ---- .../host/apiproxy/src/api/downloads.schema.ts | 25 - packages/host/apiproxy/src/api/downloads.ts | 24 - packages/host/apiproxy/src/api/host.schema.ts | 21 - packages/host/apiproxy/src/api/host.ts | 30 - packages/host/apiproxy/src/api/ids.schema.ts | 7 - packages/host/apiproxy/src/api/index.ts | 44 -- packages/host/apiproxy/src/api/rpc-map.ts | 23 - packages/host/apiproxy/src/api/rpc.schema.ts | 83 -- packages/host/apiproxy/src/api/rpc.ts | 104 --- packages/host/apiproxy/src/fetch/client.ts | 204 ----- packages/host/apiproxy/src/fetch/handler.ts | 158 ---- packages/host/apiproxy/src/index.ts | 91 --- packages/host/apiproxy/src/invariant.ts | 32 - packages/host/apiproxy/src/session-export.ts | 457 ----------- .../apiproxy/tests/api-proxy-config.spec.ts | 236 ------ .../apiproxy/tests/api-proxy-host.spec.ts | 51 -- .../apiproxy/tests/client-handler.spec.ts | 228 ------ .../host/apiproxy/tests/fetch-carrier.spec.ts | 207 ----- .../host/apiproxy/tests/rpc-schemas.spec.ts | 96 --- .../apiproxy/tests/session-export.spec.ts | 708 ------------------ packages/host/apiproxy/tsconfig.json | 54 -- .../generator/tests/cordis-catalog.spec.ts | 3 - pnpm-lock.yaml | 88 +-- scripts/check-workspace-constraints.ts | 2 +- scripts/doc-typecheck-paths.ts | 2 +- scripts/gen-cordis-catalog.ts | 6 +- scripts/gen-doc-graphs.ts | 26 +- .../verify-package-readme-model-experience.ts | 1 - tsconfig.base.json | 9 +- tsconfig.client.json | 2 +- tsconfig.host.json | 1 - vitest.config.ts | 3 - 42 files changed, 54 insertions(+), 3529 deletions(-) delete mode 100644 packages/host/apiproxy/README.i18n.yaml delete mode 100644 packages/host/apiproxy/README.md delete mode 100644 packages/host/apiproxy/README.zh.md delete mode 100644 packages/host/apiproxy/package.json delete mode 100644 packages/host/apiproxy/src/api-proxy.ts delete mode 100644 packages/host/apiproxy/src/api/downloads.schema.ts delete mode 100644 packages/host/apiproxy/src/api/downloads.ts delete mode 100644 packages/host/apiproxy/src/api/host.schema.ts delete mode 100644 packages/host/apiproxy/src/api/host.ts delete mode 100644 packages/host/apiproxy/src/api/ids.schema.ts delete mode 100644 packages/host/apiproxy/src/api/index.ts delete mode 100644 packages/host/apiproxy/src/api/rpc-map.ts delete mode 100644 packages/host/apiproxy/src/api/rpc.schema.ts delete mode 100644 packages/host/apiproxy/src/api/rpc.ts delete mode 100644 packages/host/apiproxy/src/fetch/client.ts delete mode 100644 packages/host/apiproxy/src/fetch/handler.ts delete mode 100644 packages/host/apiproxy/src/index.ts delete mode 100644 packages/host/apiproxy/src/invariant.ts delete mode 100644 packages/host/apiproxy/src/session-export.ts delete mode 100644 packages/host/apiproxy/tests/api-proxy-config.spec.ts delete mode 100644 packages/host/apiproxy/tests/api-proxy-host.spec.ts delete mode 100644 packages/host/apiproxy/tests/client-handler.spec.ts delete mode 100644 packages/host/apiproxy/tests/fetch-carrier.spec.ts delete mode 100644 packages/host/apiproxy/tests/rpc-schemas.spec.ts delete mode 100644 packages/host/apiproxy/tests/session-export.spec.ts delete mode 100644 packages/host/apiproxy/tsconfig.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 5076da6592..a07102d212 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -110,7 +110,6 @@ "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-host-frontend-static": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 135838240c..7b0a769721 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -32,9 +32,6 @@ { "path": "../../packages/bundle/web-app" }, - { - "path": "../../packages/host/apiproxy" - }, { "path": "../../packages/host/webserver" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index d69e66753a..f63af2885a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -95,11 +95,6 @@ - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' - # The API gateway: the transport-agnostic dispatch face every client shape - # shares. The base layer's agent-default-model service owns the default model. - - id: api-gateway - name: '@deepseek-ai/dsh-host-apiproxy' - - id: cordis-host-runner name: '@deepseek-ai/dsh-cordis-host-runner' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 0f216073de..85a61a1d5b 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -94,7 +94,6 @@ "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-host-frontend-static": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 37d29ce704..4012db1069 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -429,18 +429,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, - { - key: 'apiProxy', - summary: 'Root interface of the unified API.', - description: 'Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row.', - methods: [ - { - signature: 'downloads: DownloadsApi', - description: 'Host-only download surfaces (GET, no wire envelope); absent from IApiClient.', - parameters: [], - }, - ], - }, { key: 'approval', summary: 'Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.', @@ -1382,6 +1370,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'provider-grouped models, the deployment default, and isolated provider failures.', }, + { + signature: '@Remote canOpenWorkspacePath(): boolean', + description: 'Report whether this deployment can hand a Session workspace path to a native desktop.', + parameters: [], + returns: 'true when the matching open operation is available.', + }, { signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise', description: 'Open one path prepared by a Session-aware caller on the Host desktop.', @@ -1966,6 +1960,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'provider writability, local-document presence, and one view per namespace.', throws: ['TypertRemoteFailure when no settings provider is mounted.'], }, + { + signature: '@Remote canOpenAgentPresetDirectory(): boolean', + description: 'Report whether this deployment can open an authored Agent preset directory natively.', + parameters: [], + returns: 'true when the matching open operation is available.', + }, { signature: '@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise', description: 'Merge a patch into one namespace\'s stored user section.', @@ -2610,9 +2610,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'registerRemoteEvents(source: TypertRemoteEventSource): () => Promise', + signature: 'registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise', description: 'Register the sole application-selected forwarded-event source.', - parameters: [{ name: 'source', description: 'stream factory installed by the Remote assembly.' }], + parameters: [{ name: 'source', description: 'stream factory installed by the Remote assembly.' }, { name: 'host', description: 'stable Host facts included in each Client generation\'s opening frame.' }], returns: 'disposer removing this source and cancelling its active streams.', }, { @@ -3934,10 +3934,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DomainTableSpec', declaration: 'export interface DomainTableSpec {\n readonly valueSchema: ZodType;\n readonly __key?: K;\n}', }, - { - name: 'DownloadsApi', - declaration: 'export interface DownloadsApi {\n sessionLog(request: {\n sessionId: SessionId;\n includeDescendants?: boolean;\n }, signal: AbortSignal): Promise;\n}', - }, { name: 'DshEnvironment', declaration: 'export type DshEnvironment = Readonly>;', @@ -4606,6 +4602,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RedactedSecret', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', }, + { + name: 'RemoteEventHostInfo', + declaration: 'export interface RemoteEventHostInfo {\n readonly home: string;\n}', + }, { name: 'ReplayEnvelope', declaration: 'export interface ReplayEnvelope {\n response: unknown;\n blocks?: readonly unknown[];\n}', @@ -4662,26 +4662,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, - { - name: 'RpcError', - declaration: 'export type RpcError = {\n [C in RpcErrorCode]: {\n code: C;\n message: string;\n details: RpcErrorDetailsMap[C];\n };\n}[RpcErrorCode];', - }, - { - name: 'RpcErrorCode', - declaration: 'export type RpcErrorCode = keyof RpcErrorDetailsMap;', - }, - { - name: 'RpcErrorDetailsMap', - declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'internal\': {};\n}', - }, - { - name: 'RpcId', - declaration: 'export type RpcId = Branded<\'rpc-id\'>;', - }, - { - name: 'RpcResult', - declaration: 'export type RpcResult = {\n ok: true;\n value: T;\n} | {\n ok: false;\n error: RpcError;\n};', - }, { name: 'RunnerFailureRule', declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', @@ -4758,10 +4738,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SendTeamMessageResult', declaration: 'export interface SendTeamMessageResult {\n readonly messageId: TeamMessageId;\n readonly status: \'accepted\' | \'queued\';\n}', }, - { - name: 'ServerResponse', - declaration: 'export interface ServerResponse {\n type: \'server-response\';\n rpcId: RpcId;\n result: RpcResult;\n}', - }, { name: 'Session', declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml deleted file mode 100644 index a339fcb16a..0000000000 --- a/packages/host/apiproxy/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 packages/host/apiproxy/README.md -README.md: 131ea9c510733740664ca8b46510650110bca234 -README.zh.md: 4578e89acc73f1a77648dce087da1bca6b364f16 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md deleted file mode 100644 index 131ea9c510..0000000000 --- a/packages/host/apiproxy/README.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -description: "Legacy HTTP transport for Host bootstrap metadata and streamed Session-log ZIP downloads while generated Typert Remotes own business operations." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-host-apiproxy - -English | [中文](README.zh.md) - -## Summary - -`dsh-host-apiproxy` carries the two Host operations that do not yet belong to a generated business Remote: the `host.describe` bootstrap snapshot and streamed Session-log ZIP downloads. Its browser-safe envelope and fetch adapters serve HTTP and in-process clients, while API Gateway carries all ordinary business operations. The shipped Web composition assembles both transports in [`dsh-web-app`](../../bundle/web-app/README.md). - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Compose this package when a GUI host needs bootstrap metadata and Session-log export: load `ApiProxyService`, wrap `ctx.apiProxy` in a carrier, and use generated Remotes for all other business calls. - -### Choosing a carrier - -`toFetchHandler(api)` turns the gateway into a pure WHATWG fetch function for an HTTP server (the shipped Web composition exposes it behind `/api/…` routes), while `InProcessApiClient` runs the same serialization and validation path in-process — the isomorphic point for callers and tests that need the full wire path without a network. - -```text -const client = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) -const response = await client.host.describe({}) -``` - -The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross-site simple requests can never run a side-effectful method blind. The browser carrier applies the same Host/Origin checks and signed-cookie authentication to every Host API method ([`dsh-client-connection`](../../client/connection/README.md)); individual Client features may still withhold native or persistent operations on non-loopback pages. - -### What the gateway exposes - -The unary map contains only `host.describe`; the direct download route is `GET` or `HEAD /api/session.export`. Session, workspace, settings, credentials, LLM, skill, file-reference, command, and interaction operations are generated Remotes owned by their business packages and assembled by [`dsh-api-remotes`](../../api/remotes/README.md). - -### Exporting sessions - -`GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP of the session's stored artifact text verbatim, every subagent descendant under `subagents//`, and each referenced image under `media/.`. `HEAD` runs the same root preparation without a body, so browsers detect pre-stream failures before handing the GET to the download manager. The response is chunked as it is produced, and `sessionExportCompressionLevel` (0–9, default 6) trades CPU and latency against archive size. Missing persistence, session-query, or attachment services answer 500, a backend without per-session raw artifacts 501, and a missing root session 404. - -### Configuration - -| Field | Default | Meaning | -|---|---|---| -| `nativeOpen` | platform-detected | Whether the deployment can hand paths to a native desktop opener | -| `sessionExportCompressionLevel` | `6` | DEFLATE level for every session-log ZIP entry, 0–9 | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-apiproxy) is the exhaustive source for every accepted field and its JSDoc. - ------ - - -## Understand the implementation - -

-Implementation internals — click to expand - -### Design concept - -The package is built on one separation: the API contract is channel-independent, and physical transports are carriers around it. Wire messages form a two-member discriminated union — `ClientRequest` (the POST `/api/` body) and `ServerResponse` (that POST's response body) — decoupled from the physical channel. Responses always echo the matching request's `rpcId` and never mint a new one. Business errors ride the `RpcResult` error branch with a closed `RpcErrorDetailsMap`; HTTP status expresses only the carrier. The layering and protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). - -### Source map - -| File | Role | -|---|---| -| [`src/api/`](src/api/) | Contract layer: domain interfaces, payload types, zod schemas, `RpcMethodMap` — zero Node dependencies | -| [`src/fetch/handler.ts`](src/fetch/handler.ts) | Host carrier: `toFetchHandler`, envelope parsing, unary dispatch, session export | -| [`src/fetch/client.ts`](src/fetch/client.ts) | Client carrier: `AbstractApiClient` plus platform subclasses, `InProcessApiClient` | -| [`src/api-proxy.ts`](src/api-proxy.ts) | Gateway implementation: `createApiProxy` over the composed host context | -| [`src/session-export.ts`](src/session-export.ts) | Session-log ZIP export: raw artifact reads, media collection, fflate streaming | - -### The gateway service - -`ApiProxyService` provides `ctx.apiProxy`, reports process metadata through `host.describe`, and delegates Session archive production to the persistence, query, attachment, and live Session services. The Host cwd is the default project directory. Product `dsh --profile headless` is a direct core entry point and does not mount this package. - -### Request flow - -A `host.describe` request enters the fetch carrier, which parses the envelope and payload, dispatches the method, and returns a response echoing the request's `rpcId`. Session export bypasses that envelope because its streamed ZIP body and HTTP status are the result. - -### What the gateway owns - -The package owns its legacy envelope, Host bootstrap snapshot, and archive download. API Gateway owns generated Remote dispatch and streams; business packages own their methods and result types. - -
- ------ - - -## Further Exploration - -Read these when the package-level contract is not enough. They move from the layering decision to the browser-side consumption architecture and the adjacent subsystems. - -- [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) — the layering model and the channel-independent message protocol. -- [Web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how the browser consumes the API. -- [Browser HTTP carrier](../../client/connection/README.md) — Host/Origin checks, signed-cookie authentication, and the routes the shipped Web composition registers. -- [Web-server subsystem](../../../docs/subsystems/web-server.md) — the HTTP server the carrier rides on. -- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-apiproxy) — every accepted config field and its source declaration. - ------ - - -## Model Experience - -None, as the wire contract and fetch carriers move already-composed messages and register nothing model-facing. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - - - - -These limits define where the gateway is a poor fit; they are current package constraints, not a task backlog. - -- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - - -### Dev Note - -
-Working context for maintainers — click to expand - -This Dev Note is working context for maintainers: open directions. It is explicitly non-authoritative — shipped behavior and limits live in the sections above. A protocol version field waits for an independently released client; a multi-user carrier must replace provider search diagnostics with public-safe text; per-connection picker adaptivity (native for a local browser, browse for a remote one) remains an undecided direction for the host surface. - -
diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md deleted file mode 100644 index 4578e89acc..0000000000 --- a/packages/host/apiproxy/README.zh.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -description: "Host 启动元数据与 Session 日志 ZIP 流下载的旧版 HTTP 载体;普通业务操作由生成的 Typert Remote 持有。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-host-apiproxy - -[English](README.md) | 中文 - -## 概述 - -`dsh-host-apiproxy` 承载尚不属于生成业务 Remote 的两项 Host 操作:`host.describe` 启动快照与流式 Session 日志 ZIP 下载。它的浏览器安全 envelope 与 fetch adapter 服务 HTTP 和进程内客户端,其余普通业务操作由 API Gateway 承载。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装两种传输。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -当 GUI Host 需要启动元数据与 Session 日志导出时组合本包:加载 `ApiProxyService`,把 `ctx.apiProxy` 包进一个载体,其他业务调用使用生成的 Remote。 - -### 选择载体 - -`toFetchHandler(api)` 把网关变成纯 WHATWG fetch 函数,供 HTTP 服务器使用(随发行版交付的 Web 组合把它暴露在 `/api/…` 路由之后);`InProcessApiClient` 则在进程内运行同一条序列化与校验路径——这是需要完整协议路径但不需要网络的调用方与测试的同构接点。 - -```text -const client = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) -const response = await client.host.describe({}) -``` - -HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站「简单请求」永远无法盲目执行有副作用的方法。浏览器载体对每个 Host API 方法实施相同的 Host/Origin 检查与签名 cookie 认证([`dsh-client-connection`](../../client/connection/README.zh.md));各 Client 功能仍可以在非 loopback 页面上拒绝原生操作或持久化操作。 - -### 网关暴露什么 - -一元映射只包含 `host.describe`;直接下载路由是 `GET` 或 `HEAD /api/session.export`。Session、workspace、settings、credentials、LLM、skill、file-reference、command 与 interaction 操作都是由各业务包持有、并由 [`dsh-api-remotes`](../../api/remotes/README.zh.md) 组装的生成 Remote。 - -### 导出会话 - -`GET /api/session.export?sessionId=…&includeDescendants=true` 流式输出一个 ZIP,其中每个会话的已存工件文本原样包含,每个子代理后代位于 `subagents//` 下,每张被引用的图片位于 `media/.` 下。`HEAD` 在无请求体的情况下运行同样的根准备,因此浏览器能在把 GET 交给下载管理器之前检测到流前失败。响应边生成边分块输出,`sessionExportCompressionLevel`(0–9,默认 6)在 CPU 与延迟之间权衡归档大小。缺少 persistence、session-query 或 attachment 服务时回答 500,后端没有按会话原始工件时回答 501,根会话缺失时回答 404。 - -### 配置 - -| 字段 | 默认值 | 含义 | -|---|---|---| -| `nativeOpen` | 平台探测 | 部署能否把路径交给原生桌面打开器 | -| `sessionExportCompressionLevel` | `6` | 每个会话日志 ZIP 条目的 DEFLATE 级别,0–9 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-apiproxy)是每个受支持字段及其 JSDoc 的穷尽式真源。 - ------ - - -## 理解实现 - -
-实现细节——点击展开 - -### 设计理念 - -本包建立在一个分离之上:API 约定与通道无关,物理传输只是围绕它的载体。协议消息构成一个二元可辨识联合——`ClientRequest`(POST `/api/` 的请求体)与 `ServerResponse`(该 POST 的响应体)——与物理通道解耦。响应始终回显对应请求的 `rpcId`,绝不签发新值。业务错误由 `RpcResult` 的错误分支承载,其 `RpcErrorDetailsMap` 封闭错误码集合;HTTP 状态只表达载体层结果。分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md) 中。 - -### 源码地图 - -| 文件 | 职责 | -|---|---| -| [`src/api/`](src/api/) | 约定层:领域接口、payload 类型、zod schema、`RpcMethodMap`——零 Node 依赖 | -| [`src/fetch/handler.ts`](src/fetch/handler.ts) | 宿主载体:`toFetchHandler`、信封解析、一元分发、会话导出 | -| [`src/fetch/client.ts`](src/fetch/client.ts) | 客户端载体:`AbstractApiClient` 及平台子类、`InProcessApiClient` | -| [`src/api-proxy.ts`](src/api-proxy.ts) | 网关实现:基于所组合宿主上下文的 `createApiProxy` | -| [`src/session-export.ts`](src/session-export.ts) | 会话日志 ZIP 导出:原始工件读取、媒体收集、fflate 流式输出 | - -### 网关服务 - -`ApiProxyService` 提供 `ctx.apiProxy`,通过 `host.describe` 报告进程元数据,并把 Session 归档生成委派给 persistence、query、attachment 与 live Session 服务。Host cwd 是默认项目目录。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 - -### 请求流 - -`host.describe` 请求进入 fetch 载体,载体解析 envelope 与 payload、分发方法,并返回回显请求 `rpcId` 的响应。Session 导出不使用该 envelope,因为其流式 ZIP body 与 HTTP 状态就是结果。 - -### 网关拥有什么 - -本包持有旧版 envelope、Host 启动快照与归档下载。API Gateway 持有生成的 Remote 分发与流;业务包持有各自的方法和结果类型。 - -
- ------ - - -## 进一步探索 - -当包级约定不够用时阅读以下内容。它们从分层决策进入浏览器侧消费架构与相邻子系统。 - -- [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)——分层模型与通道无关的消息协议。 -- [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器如何消费该 API。 -- [浏览器 HTTP 载体](../../client/connection/README.zh.md)——Host/Origin 检查、签名 cookie 认证,以及随发行版交付的 Web 组合注册的路由。 -- [Web 服务器子系统](../../../docs/subsystems/web-server.zh.md)——载体所搭乘的 HTTP 服务器。 -- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-apiproxy)——每个受支持配置字段及其源声明。 - ------ - - -## 模型体验 - -无。该协议约定与 fetch 载体只搬运已组装好的消息,不注册任何面向模型的内容。 - -#### KV Cache 影响 - -无;该包既不组装也不发送提供方请求。 - -## 已知限制与延期工作 - - - - -这些限制说明网关在何处不合适;它们是当前包约束,不是任务积压。 - -- **没有协议版本字段**——客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -本开发备注是维护者的工作上下文:开放方向。它明确不具权威性——已交付行为与限制见上文各节。协议版本字段等待独立发布的客户端;多用户载体必须把提供方搜索诊断替换为可安全公开的文本;按连接的自适应目录选择(本地浏览器用 native、远程浏览器用 browse)仍是宿主表面的一个未定方向。 - -
diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json deleted file mode 100644 index b1eecd9a6b..0000000000 --- a/packages/host/apiproxy/package.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-host-apiproxy", - "description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy", - "version": "0.1.1-rc.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/host/apiproxy" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json", - "./api": { - "types": "./lib/types/api/index.d.ts", - "default": "./lib/types/api/index.js" - }, - "./api/*": { - "types": "./lib/types/api/*.d.ts", - "default": "./lib/types/api/*.js" - }, - "./client": { - "types": "./lib/types/fetch/client.d.ts", - "default": "./lib/types/fetch/client.js" - } - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "dependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", - "fflate": "^0.8.2", - "zod": "^4.4.3" - }, - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^" - } -} diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts deleted file mode 100644 index 6e502172f7..0000000000 --- a/packages/host/apiproxy/src/api-proxy.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Host-side ApiProxy implementation. Signature discipline: unary takes the - * narrow RpcRequest

and echoes request.rpcId on the RpcResponse. - */ - -import { homedir } from 'node:os' -import type { Context } from '@deepseek-ai/cordis' -import type { ModelSelection } from '@deepseek-ai/dsh-agent' -import { canOpenNativePath } from '@deepseek-ai/dsh-native-command' -import type { ApiProxy } from './api/index.ts' -import { - DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, - flushLiveSessionLog, - sessionLogExportDeps, - sessionLogZipFilename, - streamSessionLogZip, - type SessionLogExportReady, - type SessionLogCompressionLevel, -} from './session-export.ts' -import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' -import type { RpcRequest, RpcResponse } from './api/rpc.ts' - -/** Wrap an ok result echoing the request's rpcId. */ -function ok(request: RpcRequest, value: T): RpcResponse { - return { rpcId: request.rpcId, result: { ok: true, value } } -} - -/** Deployment metadata and Host integrations consumed by the API implementation. */ -export interface ApiProxyDefaults { - /** Current deployment model selection reported by `host.describe`. */ - defaultModelSelection: () => ModelSelection - /** Project hint reported by `host.describe`; must match Session Controller's default cwd. */ - cwd: string - /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */ - sessionExportCompressionLevel?: SessionLogCompressionLevel - /** - * Whether `host.describe` reports that the Client may offer native path actions. - * Absent, platform detection decides ({@link canOpenNativePath}). - */ - canOpenPath?: () => boolean -} - -/** - * Implement ApiProxy over a composed host context. - * @param ctx - a context with the Host spine mounted. - * @param defaults - host routing and project-directory defaults. - * @returns the ApiProxy implementation. - */ -export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { - const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel - ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL - /** Whether this deployment can hand a path to a native opener at all. */ - function canOpenPaths(): boolean { - if (defaults.canOpenPath !== undefined) return defaults.canOpenPath() - return canOpenNativePath() - } - - return { - host: { - describe(request) { - // TODO(apiproxy-version): read the version from apps/cli/package.json. - const selection = defaults.defaultModelSelection() - return Promise.resolve(ok(request, { - version: '0.0.1', - // This must match the default cwd supplied to Session Controller so - // the UI's project hint names where a cwd-less create request lands. - cwd: defaults.cwd, - // Read live for the same reason: this is what the NEXT session will - // start from, so a saved default has to be what it reports. - provider: selection.provider, - model: selection.model, - attachedSessions: ctx.agents.list().length, - home: homedir(), - canOpenPath: canOpenPaths(), - })) - }, - - }, - - downloads: { - async sessionLog(request, signal) { - // Clean error path first: missing services answer 500 and a missing - // root artifact 404 before any zip byte is produced. The root content - // read here is reused as the first zip entry, so nothing is read twice. - const deps = sessionLogExportDeps(ctx) - if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) { - return new Response( - 'session log export is unavailable: missing session-query, session-persistence, or attachments service', - { status: 500 }, - ) - } - if (!deps.sessionPersistence.supportsRawArtifacts) { - return new Response( - 'session log export is unavailable: the persistence backend does not expose per-session raw artifacts', - { status: 501 }, - ) - } - const ready: SessionLogExportReady = { - sessionQuery: deps.sessionQuery, - sessionPersistence: deps.sessionPersistence, - attachments: deps.attachments, - sessions: deps.sessions, - } - let root: SessionRawArtifact | undefined - try { - await flushLiveSessionLog(deps, request.sessionId, signal) - root = await deps.sessionPersistence.readRaw(request.sessionId, signal) - signal.throwIfAborted() - } catch { - signal.throwIfAborted() - // Root preparation failure: answer 500 without echoing the error, - // which may carry absolute host paths into the browser error bar. - return new Response('session log export failed to prepare the stored artifact', { status: 500 }) - } - if (root === undefined) { - return new Response('session not found', { status: 404 }) - } - return new Response( - streamSessionLogZip( - ready, - root, - request.sessionId, - request.includeDescendants === true, - sessionExportCompressionLevel, - signal, - ), - { - headers: { - 'content-type': 'application/zip', - 'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`, - }, - }, - ) - }, - }, - } -} diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts deleted file mode 100644 index cd95cfbd45..0000000000 --- a/packages/host/apiproxy/src/api/downloads.schema.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * downloads domain zod schemas. The download surface has no wire - * envelope: the request arrives as query parameters (all strings), so its - * request schema parses the raw query-parameter object into the method's - * exact request shape. - */ - -import { z } from 'zod' -import type { DownloadsApi } from './downloads.ts' -import { sessionIdSchema } from './ids.schema.ts' - -/** - * session.export query params → the sessionLog request. `includeDescendants` - * accepts exactly `true`/`false`/absent; any other value is rejected (400) so - * a misspelled flag cannot silently under-export. - */ -export const sessionLogQuerySchema = z - .object({ - sessionId: sessionIdSchema, - includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(), - }) - .transform(query => ({ - sessionId: query.sessionId, - ...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}), - })) satisfies z.ZodType[0]> diff --git a/packages/host/apiproxy/src/api/downloads.ts b/packages/host/apiproxy/src/api/downloads.ts deleted file mode 100644 index fc8728e497..0000000000 --- a/packages/host/apiproxy/src/api/downloads.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * downloads domain contract: Host-only GET download surfaces with no wire - * envelope. Carrier routes answer these directly, and the browser - * `IApiClient` never exposes them. - */ - -import type { SessionId } from '@deepseek-ai/dsh-session/types' - -/** Host-only download surfaces (no wire envelope; absent from IApiClient). */ -export interface DownloadsApi { - /** - * Stream one session-log ZIP — the root artifact verbatim plus each subagent - * descendant's — as an attachment response. The carrier's GET route answers - * this directly; the browser never calls it. - * @param request - the root session id and whether to include descendants. - * @param signal - cancellation for the underlying reads. - * @returns the ZIP attachment response; missing services answer 500 and a - * missing root session 404 before any byte is produced. - */ - sessionLog( - request: { sessionId: SessionId; includeDescendants?: boolean }, - signal: AbortSignal, - ): Promise -} diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts deleted file mode 100644 index 5429b8cab0..0000000000 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * host domain zod schemas (names derived from map keys). - */ - -import { z } from 'zod' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' - -/** host.describe request payload (empty object literal). */ -export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> - -/** host.describe response value. */ -export const hostDescribeValueSchema = z.object({ - version: z.string(), - cwd: z.string(), - provider: z.string().optional(), - model: z.string().optional(), - attachedSessions: z.number().int().nonnegative(), - home: z.string(), - canOpenPath: z.boolean(), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts deleted file mode 100644 index b256afbc00..0000000000 --- a/packages/host/apiproxy/src/api/host.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * host domain contract. No protocol version: client and host ship - * together; introduce protocolVersion only when an independently released client appears. - */ - -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** Host-level unary methods. */ -export interface HostApi { - /** - * One-shot host snapshot. Empty payload uses the literal `{}` (extend in place when fields arrive). - * version = the host app's (apps/cli) package.json version; cwd = the host process working - * directory (root for session persistence and tool execution); provider/model = the defaults - * applied when a new agent doesn't specify them explicitly, absent when the host configures - * no explicit default (the adapter falls back internally); - * attachedSessions = count of currently attached sessions (those with a live agent); - * home = the host account home directory (Web display abbreviation on POSIX); - * canOpenPath = whether this deployment can hand a path to a user-visible native desktop. - */ - describe(request: RpcRequest<{}>): Promise> - -} diff --git a/packages/host/apiproxy/src/api/ids.schema.ts b/packages/host/apiproxy/src/api/ids.schema.ts deleted file mode 100644 index a79d7dc848..0000000000 --- a/packages/host/apiproxy/src/api/ids.schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Branded identity schemas shared by the remaining API Proxy domains. */ - -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { z } from 'zod' - -/** Non-empty Session identity after transport validation. */ -export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts deleted file mode 100644 index 7f6e5e8018..0000000000 --- a/packages/host/apiproxy/src/api/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * apiproxy contract-layer barrel. api/ has zero Node dependencies and is - * importable from the browser; the TypeScript interfaces are authoritative, - * while HTTP supplies the carrier. - */ - -import type { HostApi } from './host.ts' -import type { DownloadsApi } from './downloads.ts' - -/** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */ -export interface ApiProxy { - host: HostApi - /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ - downloads: DownloadsApi -} - -// ---- Domain interfaces and payload entities ---- -export type { - ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, -} from '@deepseek-ai/dsh-api-session-controller/types' -export type { HostApi } from './host.ts' -export type { DownloadsApi } from './downloads.ts' - -// ---- Message layer: narrow forms (domain-signature view) ---- -export type { RpcRequest, RpcResponse } from './rpc.ts' - -// ---- Message layer: unary wire forms ---- -export type { - ClientRequest, - RpcMessage, - ServerResponse, -} from './rpc.ts' - -// ---- Errors and ids ---- -export { RpcId, transportError } from './rpc.ts' -export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' -export { - clientRequestSchema, - serverResponseSchema, -} from './rpc.schema.ts' - -// ---- Method registry and derived generics ---- -export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts deleted file mode 100644 index e3e67a9c16..0000000000 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * RPC method registry and signature-derived generics. Map keys are the wire - * path segments of API Proxy unary calls. - */ - -import type { HostApi } from './host.ts' -import type { RpcResponse } from './rpc.ts' - -/** - * Method name → method signature. Signatures are the single source of truth; payload/value - * types are always derived from here. A method may declare a trailing AbortSignal after the - * request; the carrier passes its request signal, never a wire field. - */ -export interface RpcMethodMap { - 'host.describe': HostApi['describe'] -} - -/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ -export type RequestPayload = Parameters[0]['payload'] - -/** Business return value of method K (reaches through the RpcResponse narrow form to infer the ok value of result). */ -export type ResponseValue = - Awaited> extends RpcResponse ? T : never diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts deleted file mode 100644 index c8a322fced..0000000000 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Message-layer zod schemas for API Proxy unary calls and Host pushes. The - * payload slot is unknown in the full-form schemas — business payloads get a - * second parse dispatched by method (two-level parse discipline). Brand cast - * point: rpcIdSchema, and only there. - */ - -import { z } from 'zod' -import type { z as zCore } from 'zod' -type ZodIssue = zCore.core.$ZodIssue -import type { ClientRequest, RpcError, RpcId, ServerResponse } from './rpc.ts' - -/** - * Wire widening of a contract type: widens every property (deeply) to `original | undefined`. - * The repo enables exactOptionalPropertyTypes while zod `.optional()` outputs `T | undefined`, - * so `satisfies z.ZodType` is unusable across the board; anchoring is always - * written `satisfies z.ZodType>` — the widening only adds undefined, so - * missing fields / wrong types still fail to compile. On the JSON wire, "absent" and - * "value undefined" serialize identically, so the widening loses no validation semantics. - */ -export type Wire = T extends readonly (infer E)[] ? Wire[] - : T extends object ? { [K in keyof T]: Wire | undefined } - : T - -/** - * RpcId: one brand cast after schema validation (the only cast point in this - * file). No min-length: the id is an opaque echo token, and rejecting values - * here would only turn a correlatable error report into a client-side parse - * failure (the handler substitutes a sentinel when a request's id is unreadable). - */ -export const rpcIdSchema = z.string() as unknown as z.ZodType - -/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */ -export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', [ - z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom()) }) }), - z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }), - z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), - z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }), - z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), - z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), - z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), - z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), - z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), -]) as unknown as z.ZodType - -/** - * Business success/failure result schema (generic, reusable). - * @param value - Schema for the business value. - * @returns Schema for RpcResult. - */ -export function rpcResultSchema(value: z.ZodType): z.ZodUnion { - return z.union([ - z.object({ ok: z.literal(true), value }), - z.object({ ok: z.literal(false), error: rpcErrorSchema }), - ]) -} - -// ---- Wire envelope schemas (payload/result.value stay wide for the second business parse) ---- -// The wide value slot is optional: a void business result serializes with no -// `value` field at all. Each endpoint's own second parse still requires its -// declared value, so absence never passes for a method that returns data. - -/** ClientRequest full form (payload stays wide — the business layer runs the second parse). */ -export const clientRequestSchema = z.object({ - type: z.literal('client-request'), - rpcId: rpcIdSchema, - method: z.string(), - payload: z.unknown(), -}) as unknown as z.ZodType - -/** ServerResponse full form (result.value stays wide). */ -export const serverResponseSchema = z.object({ - type: z.literal('server-response'), - rpcId: rpcIdSchema, - result: rpcResultSchema(z.unknown().optional()), -}) as unknown as z.ZodType - -/** Wire full-form union (discriminated by type). */ -export const rpcMessageSchema = z.discriminatedUnion('type', [ - clientRequestSchema as unknown as z.ZodObject, - serverResponseSchema as unknown as z.ZodObject, -]) diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts deleted file mode 100644 index d3d8643301..0000000000 --- a/packages/host/apiproxy/src/api/rpc.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * API Proxy request and response message model. Logical messages remain - * independent of their physical carrier. - * api/ contract layer: zero Node dependencies, importable from the browser. - */ - -import type { z as zCore } from 'zod' -type ZodIssue = zCore.core.$ZodIssue -import type { Branded } from '@deepseek-ai/dsh-brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' - -/** - * Message correlation id: the initiator mints it on a request; a response - * echoes the matching request's rpcId and never mints a new one. - */ -export type RpcId = Branded<'rpc-id'> - -/** - * Brands a string as RpcId (same precedent as core `SessionId()`). The Client - * mints each request id and the Host echoes it in the response. - * @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures). - * @returns The same string, branded (compile-time cast, zero runtime cost). - */ -export function RpcId(id: string): RpcId { - return id as RpcId -} - -/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */ -export interface RpcErrorDetailsMap { - 'bad-request': { issues: ZodIssue[] } - 'cancelled': {} - 'session-not-found': { sessionId: SessionId } - 'invalid-time-zone': { value: string } - 'agent-preset-read-only': { agentPreset: string; reason: string } - 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } - 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } - 'agent-preset-invalid': { agentPreset: string; reason: string } - 'agent-busy': { reason: string } - 'internal': {} -} - -/** Closed error-code union (the keys of RpcErrorDetailsMap). */ -export type RpcErrorCode = keyof RpcErrorDetailsMap - -/** - * Distributive union expanded from the map: code is the discriminant, so - * `switch (error.code)` narrows details. details is required (internal uses an explicit {}). - */ -export type RpcError = { - [C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] } -}[RpcErrorCode] - -/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */ -export type RpcResult = { ok: true; value: T } | { ok: false; error: RpcError } - -/** - * Fold a transport exception into the RpcResult error branch (unified error - * API; 'internal' as the catch-all code). Lives with RpcResult so every - * carrier consumer folds the same way. - * @param error - the thrown value from the carrier. - * @returns the error branch of an RpcResult. - */ -export function transportError(error: unknown): RpcResult { - return { - ok: false, - error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }, - } -} - -/** - * Signature-layer narrow form, request side (domain-interface view, shared by - * both directions): rpcId is explicit in the signature, never mixed into the - * business payload; the type tag and method are filled in by the carrier layer. - */ -export interface RpcRequest

{ - rpcId: RpcId - payload: P -} - -/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */ -export interface RpcResponse { - rpcId: RpcId - result: RpcResult -} - -// ---- Wire full forms ---- - -/** Call initiated by the client (wire carrier: POST /api/ body). */ -export interface ClientRequest { - type: 'client-request' - rpcId: RpcId - method: string - payload: unknown -} - -/** Response to a ClientRequest (wire carrier: the HTTP response body of that POST); rpcId echoed. */ -export interface ServerResponse { - type: 'server-response' - rpcId: RpcId - result: RpcResult -} - -/** Authoritative wire full-form union; narrow via `switch (message.type)`. */ -export type RpcMessage = ClientRequest | ServerResponse diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts deleted file mode 100644 index 4c49240d24..0000000000 --- a/packages/host/apiproxy/src/fetch/client.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Client side of the fetch carrier. AbstractApiClient holds request correlation, - * envelope wrap/unwrap, zod parsing, and the payload-direct - * IApiClient domain methods (business code never mints). Platform differences ride two aspects: - * abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched. - */ - -import type { z } from 'zod' -import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' -import type { ClientRequest, RpcMessage, RpcResponse } from '../api/rpc.ts' -import { RpcId } from '../api/rpc.ts' -import type { Wire } from '../api/rpc.schema.ts' -import { serverResponseSchema } from '../api/rpc.schema.ts' -import { hostDescribeValueSchema } from '../api/host.schema.ts' - -/** - * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary - * methods take the business payload directly — the carrier mints the rpcId and wraps the - * envelope. Business code needing the call's rpcId reads it from the RpcResponse echo. - * Unary methods accept an optional external AbortSignal as the last parameter. - * Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls - * carry only that external signal. In both cases the signal rides beside the request, never - * on the wire, like the stream signatures. - * Relationship: ApiProxy is the narrow-form signature contract the impl side implements; - * IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two. - * Derived per method key from RpcMethodMap so a map row addition updates this mechanically. - */ -export interface IApiClient { - host: { - describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> - } -} - -/** - * S→C second-level parse table: value schema by method (the response-path - * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap). - */ -const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { - 'host.describe': hostDescribeValueSchema, -} - -/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */ -const DEFAULT_TIMEOUT_MS = 30_000 - -/** URL base for in-process handler injection (fake authority, opencode precedent). */ -const INTERNAL_BASE = 'http://dsh.internal' - -/** - * Abstract fetch-carrier client. Subclasses supply the transport (doFetch) and may refine the - * per-message tap (onEnvelope) — platform aspects stay in subclasses, protocol invariants stay - * here. Envelope observation is a first-class aspect of this data middle layer: the instance - * owns a microtask-batched buffer (frame storms must not cost one consumer update per frame), - * and observers subscribe via subscribeEnvelopes. The isomorphic point survives: an in-process - * subclass whose doFetch is toFetchHandler(api).fetch never touches the network. - */ -export abstract class AbstractApiClient implements IApiClient { - /** Instance-owned observation buffer (module-level state would leak across instances/tests). */ - private envelopeBatch: RpcMessage[] = [] - private flushScheduled = false - private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>() - - /** @param timeoutMs - timeout for unary calls. */ - constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} - - /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */ - protected abstract doFetch(input: URL, init?: RequestInit): Promise - - /** - * Subscribe to batched envelope observation (diagnostics/logging consumers). - * Batches follow microtask boundaries; a listener throw is isolated (observation - * must never break the carrier). - * @param listener - receives each flushed batch in arrival order. - * @returns unsubscribe function. - */ - subscribeEnvelopes(listener: (batch: readonly RpcMessage[]) => void): () => void { - this.envelopeListeners.add(listener) - return () => { - this.envelopeListeners.delete(listener) - } - } - - /** Per-message tap: feeds the instance buffer. Subclasses may override to observe unbatched (call super to keep batching). */ - protected onEnvelope(message: RpcMessage): void { - if (this.envelopeListeners.size === 0) return - this.envelopeBatch.push(message) - if (this.flushScheduled) return - this.flushScheduled = true - queueMicrotask(() => { - this.flushScheduled = false - // Never empty here: a flush is only ever scheduled by the push above, - // and this callback is the sole drain point. - const batch = this.envelopeBatch - this.envelopeBatch = [] - for (const notify of this.envelopeListeners) { - try { - notify(batch) - } catch (error) { - console.error('[apiproxy] envelope listener threw:', error) - } - } - }) - } - - /** Browser = same-origin (a fake authority would fail DNS on real requests); no-location env (Node) = fake authority. */ - protected resolveBase(): string { - const loc = (globalThis as { location?: { origin?: string } }).location - return loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : INTERNAL_BASE - } - - protected mintRpcId(): RpcId { - // Not crypto.randomUUID: browsers withhold it outside secure contexts, - // and this base also mints on pages served over plain HTTP. - return RpcId(randomUUID()) - } - - /** - * Shared POST leg of unary calls: JSON body, - * default timeout merged with the caller's external signal, non-2xx → transport throw. - */ - private async postJson( - path: string, - body: ClientRequest, - signal: AbortSignal | undefined, - ): Promise { - const requestSignal = signal === undefined - ? AbortSignal.timeout(this.timeoutMs) - : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) - const response = await this.doFetch(new URL(path, this.resolveBase()), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - signal: requestSignal, - }) - if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`) - return response - } - - /** - * Unary protocol path: mint → tap → POST full form → envelope parse → verify - * echo → value parse → tap → narrow. Virtual so a fake carrier (fixture) can - * override transport at this layer. - */ - protected async callUnary( - method: K, - payload: RequestPayload, - signal?: AbortSignal, - ): Promise>> { - const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload } - this.onEnvelope(message) - const response = await this.postJson(`/api/${method}`, message, signal) - const full = serverResponseSchema.parse(await response.json()) - this.onEnvelope(full) - if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`) - if (!full.result.ok) return { rpcId: full.rpcId, result: full.result } - // Second-level S→C parse: the ok value must match the method's Value schema (mirror of the - // handler's request-payload parse). The cast collapses the Wire<> widening, same as the handler side. - const value = UNARY_VALUE_SCHEMAS[method].parse(full.result.value) as ResponseValue - return { rpcId: full.rpcId, result: { ok: true, value } } - } - - // ---- IApiClient API (arrow properties so destructured/passed references stay bound) ---- - - readonly host: IApiClient['host'] = { - describe: (payload, signal) => this.callUnary('host.describe', payload, signal), - } - -} - -/** - * In-process client over an injected fetch-shaped handler (the isomorphic point: - * `new InProcessApiClient(toFetchHandler(api))` never touches the network). Lives here because - * in-process injection is this package's own capability (handler and client are both local). - */ -export class InProcessApiClient extends AbstractApiClient { - constructor(private readonly handler: { fetch: typeof fetch }, timeoutMs?: number) { - super(timeoutMs) - } - - /** - * Faithful to real fetch: reject on signal abort even when the in-process - * handler ignores the signal (a hung impl must not defeat timeout/cancel). - */ - protected doFetch(input: URL, init?: RequestInit): Promise { - const signal = init?.signal ?? undefined - if (signal === undefined) return this.handler.fetch(input, init) - if (signal.aborted) return Promise.reject(abortError(signal)) - return new Promise((resolve, reject) => { - const onAbort = (): void => { reject(abortError(signal)) } - signal.addEventListener('abort', onAbort, { once: true }) - this.handler.fetch(input, init) - .then(resolve, reject) - .finally(() => { signal.removeEventListener('abort', onAbort) }) - }) - } -} - -/** Mirror fetch's abort rejection: the signal's reason when present, else a DOMException-style AbortError. */ -function abortError(signal: AbortSignal): Error { - const reason: unknown = signal.reason - if (reason instanceof Error) return reason - if (typeof reason === 'string') return new Error(reason) - return new Error('This operation was aborted') -} diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts deleted file mode 100644 index 82142ef923..0000000000 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Server side of the fetch carrier: maps an ApiProxy onto a pure - * WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method + - * path==method) -> payload dispatched per method. HTTP status expresses only the carrier - * (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash); - * business errors are always 200 + ServerResponse. - */ - -import type { z } from 'zod' -import type { ApiProxy } from '../api/index.ts' -import { sessionLogQuerySchema } from '../api/downloads.schema.ts' -import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' -import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerResponse } from '../api/rpc.ts' -import { RpcId } from '../api/rpc.ts' -import type { Wire } from '../api/rpc.schema.ts' -import { clientRequestSchema } from '../api/rpc.schema.ts' -import { hostDescribeRequestSchema } from '../api/host.schema.ts' - -/** - * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a - * route row fails to compile, and each row's schema/invoke pair is checked against that row's - * payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise. - * Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation - * documented on Wire); the dispatch point carries the one Wire→exact cast. - * Every invoke receives the carrier Request's signal; routes whose contract - * declares a signal parameter forward it, and the rest ignore it. - */ -type UnaryRoutes = { - [K in keyof RpcMethodMap]: { - schema: z.ZodType>> - invoke(api: ApiProxy, request: RpcRequest>, signal: AbortSignal): Promise>> - } -} - -const UNARY_ROUTES: UnaryRoutes = { - 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, -} - -/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ -function methodFor(path: string): keyof RpcMethodMap | undefined { - return Object.hasOwn(UNARY_ROUTES, path) ? path as keyof RpcMethodMap : undefined -} - -/** - * Sentinel rpcId for error responses to envelopes whose own rpcId is unreadable: the response - * must still be a valid ServerResponse (a self-violating shape would turn the server's explicit - * bad-request report into a client-side parse failure). Fixed value, documented here as wire contract. - */ -const INVALID_REQUEST_RPC_ID = RpcId('invalid-request') - -/** Wrap a business error as a ServerResponse full form (rpcId backfilled; an unreadable rpcId uses the invalid-request sentinel). */ -function errorResponse(rpcId: RpcId, error: RpcError): Response { - const body: ServerResponse = { type: 'server-response', rpcId, result: { ok: false, error } } - return Response.json(body) -} - -/** Complete the impl's narrow form into a ServerResponse full form. */ -function fullResponse(narrow: RpcResponse): Response { - const body: ServerResponse = { type: 'server-response', rpcId: narrow.rpcId, result: narrow.result } - return Response.json(body) -} - -/** - * Parse the payload and invoke one unary route. Generic over the map key so - * the row's schema/invoke pairing typechecks; the only cast collapses the - * Wire<> widening back to the exact payload (undefined-valued properties and - * absent ones are indistinguishable after JSON transport). - */ -// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own -// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection. -// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -async function handleUnary( - api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal, -): Promise { - const route = UNARY_ROUTES[method] - const payload = route.schema.safeParse(message.payload) - if (!payload.success) { - return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } }) - } - try { - return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }, signal)) - } catch (error: unknown) { - // The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer. - return new Response(`handler failure: ${String(error)}`, { status: 500 }) - } -} - -/** - * Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient). - * @param api - the host-side ApiProxy implementation. - * @returns an object holding `fetch(Request)`; paths outside /api/ return 404. - */ -export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { - return { - // Signature matches global fetch: the isomorphic point hands this function to InProcessApiClient as its transport aspect, - // Clients call in (url, init) form — normalize to Request before handling. - async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const req = input instanceof Request ? input : new Request(input, init) - const url = new URL(req.url) - const path = url.pathname - - // No-envelope Host-only download channel: - // physical routes that answer directly, without a wire envelope. - if (path === '/api/session.export' && (req.method === 'GET' || req.method === 'HEAD')) { - // Query params are a different boundary from the POST envelope, but - // the request still casts its brands only through the domain schema. - const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams)) - if (!parsed.success) { - return new Response('missing or invalid sessionId query parameter', { status: 400 }) - } - const response = await api.downloads.sessionLog(parsed.data, req.signal) - if (req.method === 'GET') return response - await response.body?.cancel() - return new Response(null, { status: response.status, headers: response.headers }) - } - - if (req.method !== 'POST' || !path.startsWith('/api/')) { - return new Response('not found', { status: 404 }) - } - - // Cross-site write fence: browsers send "simple" POSTs (text/plain, - // form encodings) without a CORS preflight, so a malicious page could - // otherwise execute side-effectful RPCs blind — the response stays - // unreadable cross-origin, but the requested mutation would still run. Only the - // JSON media type is accepted; anything else is forced into a preflight - // this server never answers. 415 = carrier layer, like the 400 below. - const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() - if (mediaType !== 'application/json') { - return new Response('content type must be application/json', { status: 415 }) - } - - let body: unknown - try { - body = await req.json() - } catch { - // 400 = carrier layer (body is not even JSON); valid JSON with a bad shape goes 200 + bad-request. - return new Response('body is not JSON', { status: 400 }) - } - - const method = methodFor(path.slice('/api/'.length)) - if (method === undefined) return new Response('not found', { status: 404 }) - - const envelope = clientRequestSchema.safeParse(body) - if (!envelope.success) { - // Best effort at correlation: salvage a string rpcId from the raw body; - // otherwise the fixed sentinel keeps the response a valid ServerResponse. - const rawId = (body as { rpcId?: unknown } | null)?.rpcId - const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID - return errorResponse(rpcId, { code: 'bad-request', message: 'invalid client-request message', details: { issues: envelope.error.issues } }) - } - const message: ClientRequest = envelope.data - if (message.method !== method) { - return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } }) - } - return handleUnary(api, method, message, req.signal) - }, - } -} diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts deleted file mode 100644 index 474ad6beef..0000000000 --- a/packages/host/apiproxy/src/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares: - * the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch - * carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient + - * platform subclasses on the client side), and the host-side implementation - * (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing - * `ctx.apiProxy`). Transport-agnostic by design: this package registers no - * routes — physical carriers wrap `ctx.apiProxy` themselves. - * - * The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata - * returned by `host.describe`; Session Controller owns Session model selection. - */ - -import { Context, Service } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-agent-default-model' -import type { ApiProxy } from './api/index.ts' -import { createApiProxy } from './api-proxy.ts' -import { - DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, - type SessionLogCompressionLevel, -} from './session-export.ts' - -export type * from './api/index.ts' -export { RpcId } from './api/rpc.ts' -export { toFetchHandler } from './fetch/handler.ts' -export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts' -export type { IApiClient } from './fetch/client.ts' -export { createApiProxy } from './api-proxy.ts' -export type { ApiProxyDefaults } from './api-proxy.ts' - -declare module '@deepseek-ai/cordis' { - interface Context { - /** The host-side ApiProxy implementation (the transport-agnostic gateway face). */ - apiProxy: ApiProxy - } -} - -/** Gateway plugin configuration. */ -export interface Config { - /** - * Whether this deployment can hand paths to a native desktop opener — - * the `hasDocument` capability the agent-preset roster reports. Absent, - * the platform is asked (macOS/Windows/WSL yes; Linux only with a display - * server); set it explicitly where detection misleads, e.g. `false` in a - * container whose DISPLAY points nowhere a user can see. - */ - nativeOpen?: boolean - /** - * DEFLATE level for every session-log ZIP entry: `0` stores without - * compression, `1` favors CPU/latency, and `9` favors archive size. - * @default 6 - */ - sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 -} - -/** - * The API gateway service: implements the ApiProxy contract over the composed - * host context and provides it as `ctx.apiProxy`. Its cwd metadata must match - * the default project directory supplied to Session Controller. - */ -export class ApiProxyService extends Service implements ApiProxy { - static inject = [ - 'agentDefaultModel', 'agents', 'attachments', 'sessions', 'sessionQuery', - ] - - static Config: z = z.object({ - nativeOpen: z.boolean(), - sessionExportCompressionLevel: z.number().step(1).min(0).max(9) - .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z, - }) - - readonly host: ApiProxy['host'] - readonly downloads: ApiProxy['downloads'] - - constructor(ctx: Context, config: Config) { - super(ctx, 'apiProxy') - const api = createApiProxy(ctx, { - defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), - cwd: process.cwd(), - ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean }, - ...(config.sessionExportCompressionLevel === undefined - ? {} - : { sessionExportCompressionLevel: config.sessionExportCompressionLevel }), - }) - this.host = api.host - this.downloads = api.downloads - } -} - -export default ApiProxyService diff --git a/packages/host/apiproxy/src/invariant.ts b/packages/host/apiproxy/src/invariant.ts deleted file mode 100644 index 9e9489aaff..0000000000 --- a/packages/host/apiproxy/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-host-apiproxy`. - * @module @deepseek-ai/dsh-host-apiproxy/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-host-apiproxy' - -/** Cordis companion plugin name. */ -export const name = 'host-apiproxy-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package is the wire contract layer plus the - * host-side unary gateway over services owned elsewhere. rpcId round-trip and - * schema acceptance are enforced at the carrier boundary and exercised by the - * protocol-isomorphism suite. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts deleted file mode 100644 index 72b40a016d..0000000000 --- a/packages/host/apiproxy/src/session-export.ts +++ /dev/null @@ -1,457 +0,0 @@ -/** - * Host-side session-log download: streams one ZIP archive whose files are the - * sessions' stored artifact text verbatim plus every referenced media object. - * The root artifact sits under its original base name (`session.jsonl`); each - * subagent descendant under `subagents//`; each image referenced - * by any included log under `media/.` (content-addressed, - * so one archive never duplicates a shared image). No manifest is written — - * every file is byte-identical to the backend's durable artifact or attachment - * store and self-describing through its own header line or media type. Before - * each live session's artifact read, the SessionStore flush barrier makes the - * current in-memory log durable; cold sessions need no barrier. Request abort - * and response-consumer cancellation share one producer signal and terminate - * the active compressor. - * Compression runs on the host with fflate's streaming Zip API, so the archive - * bytes are produced incrementally and the host never holds the whole archive - * in one buffer; production waits for consumer pull whenever the response queue - * reaches its byte high-water mark, so a slow consumer bounds accumulation to - * the fixed 64 KiB response queue plus one synchronous fflate push. - * @module - */ - -import { Zip, ZipDeflate } from 'fflate' -import type { Context } from '@deepseek-ai/cordis' -import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' -import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session' -import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' - -/** Valid fflate DEFLATE levels accepted by session-log export. */ -export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 - -/** Balanced default used when a direct createApiProxy caller omits deployment config. */ -export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6 - -/** The services a session-log export needs (the live-session store is optional). */ -export interface SessionLogExportDeps { - readonly sessionQuery: SessionQueryEngine | undefined - readonly sessionPersistence: SessionPersistence | undefined - readonly attachments: AttachmentStore | undefined - readonly sessions: SessionStore | undefined -} - -/** The export services narrowed to the mounted ones streaming actually reads. */ -export interface SessionLogExportReady { - readonly sessionQuery: SessionQueryEngine - readonly sessionPersistence: SessionPersistence - readonly attachments: AttachmentStore - readonly sessions: SessionStore | undefined -} - -/** - * Resolve the persistence, session-query, and attachment services a log export needs. - * @param ctx - the composed host context. - * @returns the export services (absent when the deployment does not mount them). - */ -export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps { - return { - sessionQuery: ctx.get('sessionQuery'), - sessionPersistence: ctx.get('sessionPersistence'), - attachments: ctx.get('attachments'), - sessions: ctx.get('sessions'), - } -} - -/** - * Flush one currently live session through the store's authoritative durability - * barrier immediately before its raw artifact is read. A cold or absent id has - * no in-memory work to flush. - * @param deps - export services, including the optional live-session store. - * @param id - the session whose artifact is about to be read. - * @param signal - optional cancellation observed around the flush barrier. - */ -export async function flushLiveSessionLog( - deps: Pick, - id: SessionId, - signal?: AbortSignal, -): Promise { - signal?.throwIfAborted() - const sessions = deps.sessions - if (sessions === undefined) return - const session = sessions.get(id) - if (session === undefined) return - await sessions.flush(session) - signal?.throwIfAborted() -} - -/** One exported file: a stored artifact text or one referenced media object. */ -export type SessionLogZipEntry = - | { readonly path: string; readonly content: string } - | { readonly path: string; readonly data: Uint8Array } - -/** Zip extension for each accepted raster media type. */ -const MEDIA_TYPE_EXTENSIONS: Record = { - 'image/png': 'png', - 'image/jpeg': 'jpg', - 'image/webp': 'webp', - 'image/gif': 'gif', -} - -/** - * The zip path for one media object: content-addressed by the opaque - * attachment id so shared images land once and the id in the log maps back to - * the archive entry without a manifest. - * @param ref - the durable reference from a session log. - * @returns the archive path. - */ -function mediaEntryPath(ref: ImageAttachmentRef): string { - return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}` -} - -/** - * Collect every image reference inside one content array, descending into - * nested tool results the way the live attachment route does. - * @param content - an event content array (or nested tool-result content). - * @param refs - the dedupe map being filled (keyed by attachment id). - */ -function collectImageRefs(content: unknown, refs: Map): void { - if (!Array.isArray(content)) return - const pending: unknown[] = [] - for (const item of content) pending.push(item) - while (pending.length > 0) { - const value = pending.pop() - if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { type?: unknown; attachment?: unknown; content?: unknown } - if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { - const ref = block.attachment as ImageAttachmentRef - refs.set(String(ref.attachmentId), ref) - } - if (Array.isArray(block.content)) { - for (const item of block.content) pending.push(item) - } - } -} - -/** - * Collect every image reference one session event carries, across the same - * carriers the live attachment route scans (direct content, message content, - * inserted messages, and completed assistant chunk blocks). - * @param event - one parsed JSONL event object. - * @param refs - the dedupe map being filled (keyed by attachment id). - */ -function collectEventImageRefs(event: unknown, refs: Map): void { - const data = (event as { data?: unknown }).data - if (typeof data !== 'object' || data === null) return - const carrier = data as { - content?: unknown - message?: { content?: unknown } - inserted?: Array<{ content?: unknown }> - chunk?: { type?: unknown; block?: unknown } - } - collectImageRefs(carrier.content, refs) - if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs) - if (carrier.inserted !== undefined) { - for (const message of carrier.inserted) collectImageRefs(message.content, refs) - } - if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs) -} - -/** - * Collect the distinct media references one stored artifact text names. - * Lines that fail to parse cannot reference media and are skipped (the - * artifact text itself is exported verbatim regardless). - * @param content - the stored artifact text. - * @returns the dedupe map keyed by attachment id. - */ -function imageRefsInArtifact(content: string): Map { - const refs = new Map() - for (const line of content.split('\n')) { - if (line === '') continue - let event: unknown - try { - event = JSON.parse(line) - } catch { - continue - } - collectEventImageRefs(event, refs) - } - return refs -} - -/** - * One safe zip path segment from an untrusted session id. Session ids are - * host-controlled, but the brand allows any non-empty string, so `../`, dot - * segments, and separator characters are neutralized before they can shape - * archive entries. Distinct ids may collapse onto one segment (id collision - * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept). - * @param id - the raw session id. - * @returns a filesystem-safe single path segment. - */ -function safeSessionIdSegment(id: string): string { - return id.replace(/[^A-Za-z0-9_-]/g, '_') -} - -/** - * The export archive filename for one root session. - * @param sessionId - the root session id (sanitized to one safe path segment). - * @returns the attachment filename for the session's export archive. - */ -export function sessionLogZipFilename(sessionId: string): string { - return `dsh-session-${safeSessionIdSegment(sessionId)}.zip` -} - -/** - * Yield the export entries in zip order: the preloaded root artifact first, - * then every subagent descendant in lineage order (each flushed when live, - * read from the persistence backend right before it is yielded, and dropped - * after the consumer moves on), then every distinct media object referenced by any of - * the included logs (read and verified from the attachment store, one archive - * entry per attachment id). The host holds at most one descendant's artifact - * text and one media object at a time beyond the root. - * @param deps - the mounted export services (the caller answered 500 before this runs). - * @param root - the already-read root artifact (read by the caller so the - * missing-session path can answer cleanly before streaming starts). - * @param sessionId - the root session id. - * @param includeDescendants - whether to include every subagent descendant. - * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads. - * @returns the export entries in zip order. - */ -export async function* sessionLogZipEntries( - deps: SessionLogExportReady, - root: SessionRawArtifact, - sessionId: SessionId, - includeDescendants: boolean, - signal?: AbortSignal, -): AsyncGenerator { - const media = new Map() - const rememberMedia = (content: string): void => { - for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref) - } - rememberMedia(root.content) - yield { path: root.filename, content: root.content } - if (includeDescendants) { - const seen = new Set([sessionId]) - const collect = async function* ( - nodes: readonly SessionLineageNode[], - ): AsyncGenerator { - for (const node of nodes) { - signal?.throwIfAborted() - const id = node.session.header.id - if (seen.has(id)) continue - seen.add(id) - await flushLiveSessionLog(deps, id, signal) - const raw = await deps.sessionPersistence.readRaw(id, signal) - signal?.throwIfAborted() - if (raw === undefined) { - throw new Error(`subagent "${id}" has no stored log artifact`) - } - rememberMedia(raw.content) - yield { - path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`, - content: raw.content, - } - yield* collect(node.descendants) - } - } - const lineage = await deps.sessionQuery.traceSession(sessionId, signal) - signal?.throwIfAborted() - yield* collect(lineage.descendants) - } - for (const ref of media.values()) { - signal?.throwIfAborted() - const stored = await deps.attachments.readImage(ref, signal) - signal?.throwIfAborted() - yield { path: mediaEntryPath(ref), data: stored.data } - } -} - -/** How many code units of artifact text one zip push carries (bounded encode memory). */ -const PUSH_CHUNK_CODE_UNITS = 1 << 16 - -/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */ -const PUSH_CHUNK_BYTES = 1 << 16 - -/** Byte capacity retained by the response stream before ZIP production waits for pull. */ -const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16 - -/** One producer waiter released only when ReadableStream pull restores capacity. */ -class ResponseCapacityGate { - private releasePending: (() => void) | undefined - - /** - * Wait until the response queue has positive byte capacity or cancellation wins. - * @param controller - response controller whose desired size owns capacity. - * @param signal - combined request/consumer cancellation. - */ - async wait( - controller: ReadableStreamDefaultController, - signal: AbortSignal, - ): Promise { - signal.throwIfAborted() - if (controller.desiredSize === null || controller.desiredSize > 0) return - await new Promise((resolve) => { - const release = (): void => { - this.releasePending = undefined - signal.removeEventListener('abort', release) - resolve() - } - this.releasePending = release - signal.addEventListener('abort', release, { once: true }) - }) - signal.throwIfAborted() - } - - /** Release the current producer waiter after a consumer pull. */ - pulled(): void { - this.releasePending?.() - } -} - -/** - * Push one media object's bytes into a deflate stream in bounded chunks, - * waiting for consumer capacity between chunks like the artifact path does. - * @param deflate - the zip entry's deflate stream. - * @param data - the stored image bytes. - * @param controller - response queue controller. - * @param capacity - pull-driven response-capacity gate. - * @param signal - cancellation; throws when aborted. - */ -async function pushBinaryChunks( - deflate: ZipDeflate, - data: Uint8Array, - controller: ReadableStreamDefaultController, - capacity: ResponseCapacityGate, - signal: AbortSignal, -): Promise { - let offset = 0 - do { - signal.throwIfAborted() - const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength) - const finalChunk = end >= data.byteLength - deflate.push(data.subarray(offset, end), finalChunk) - offset = end - await capacity.wait(controller, signal) - } while (offset < data.byteLength) -} - -/** - * Push one artifact's text into a deflate stream in bounded chunks, never - * splitting a surrogate pair across a chunk boundary (a lone high surrogate - * re-encodes as U+FFFD and would silently corrupt the exported artifact). - * @param deflate - the zip entry's deflate stream. - * @param content - the artifact text verbatim. - * @param controller - response queue controller. - * @param capacity - pull-driven response-capacity gate. - * @param signal - cancellation; throws when aborted. - */ -async function pushArtifactChunks( - deflate: ZipDeflate, - content: string, - controller: ReadableStreamDefaultController, - capacity: ResponseCapacityGate, - signal: AbortSignal, -): Promise { - const encoder = new TextEncoder() - let offset = 0 - let finalChunk: boolean - do { - signal.throwIfAborted() - let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length) - if (end < content.length && end - offset > 1) { - // Back off one code unit when the boundary lands inside a surrogate - // pair: the pair then starts the next chunk whole. - const last = content.charCodeAt(end - 1) - if (last >= 0xd800 && last <= 0xdbff) end -= 1 - } - finalChunk = end >= content.length - deflate.push(encoder.encode(content.slice(offset, end)), finalChunk) - offset = end - await capacity.wait(controller, signal) - } while (!finalChunk) -} - -/** - * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is - * read and validated by the caller before this is called (missing root or - * missing services answer cleanly before any byte is produced); each entry is - * then encoded and deflated in bounded chunks as it is produced, so the - * archive bytes arrive incrementally. A descendant that fails to read errors - * the stream (fail-loud, never silent under-export). - * @param deps - the mounted export services (the caller answered 500 before this runs). - * @param root - the already-read root artifact (first zip entry). - * @param sessionId - the root session id. - * @param includeDescendants - whether to include every subagent descendant. - * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry. - * @param signal - request cancellation combined with response-consumer cancellation. - * @returns the zip byte stream. - */ -export function streamSessionLogZip( - deps: SessionLogExportReady, - root: SessionRawArtifact, - sessionId: SessionId, - includeDescendants: boolean, - compressionLevel: SessionLogCompressionLevel, - signal: AbortSignal, -): ReadableStream { - const consumerAbort = new AbortController() - const producerSignal = AbortSignal.any([signal, consumerAbort.signal]) - let zip: Zip | undefined - let zipTerminated = false - const capacity = new ResponseCapacityGate() - const terminateZip = (): void => { - if (zip === undefined || zipTerminated) return - zipTerminated = true - zip.terminate() - } - return new ReadableStream({ - start(controller) { - // fflate invokes the callback synchronously per compressed chunk, so a - // single push can enqueue ahead of a slow consumer; the capacity gate - // waits for pull between pushes once the byte queue is full, bounding - // accumulation to the queue high-water mark plus one synchronous push. - const archive = new Zip((error, data, final) => { - /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */ - if (error) { - controller.error(error) - return - } - /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */ - if (data.byteLength > 0) controller.enqueue(data) - if (final) controller.close() - }) - zip = archive - void (async () => { - try { - for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) { - const deflate = new ZipDeflate(entry.path, { level: compressionLevel }) - archive.add(deflate) - if ('content' in entry) { - await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal) - } else { - await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal) - } - } - archive.end() - } catch (error) { - // A mid-stream failure (missing descendant, cancellation, read - // error) must fail the download rather than ship a truncated archive. - /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */ - terminateZip() - controller.error(error instanceof Error ? error : new Error(String(error))) - } - })() - }, - pull() { - capacity.pulled() - }, - cancel(reason) { - consumerAbort.abort( - reason instanceof Error ? reason : new Error('session log export stream cancelled'), - ) - terminateZip() - }, - }, { - highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES, - size: chunk => chunk.byteLength, - }) -} diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts deleted file mode 100644 index f6ed7916b2..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Settings events consumed by Client model and permission surfaces. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { CredentialProvider } from '@deepseek-ai/dsh-credentials' -import type { - CredentialInfo, - CredentialKey, - CredentialRecord, - CredentialRecordEntry, - CredentialRecordInfo, - CredentialRef, - ResolvedCredential, -} from '@deepseek-ai/dsh-credentials' -import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' - -/** In-memory settings provider: the Service Definition base class owns all tested behavior. */ -class MemorySettings extends SettingsProvider { - doc: Record - - constructor(ctx: ConstructorParameters[0], options?: { - doc?: Record - readOnly?: boolean - documentPath?: string - preparedPath?: string - }) { - super(ctx) - this.doc = structuredClone(options?.doc ?? {}) - this.readOnly = options?.readOnly ?? false - this.path = options?.documentPath - this.preparedPath = options?.preparedPath - } - - private readonly readOnly: boolean - private readonly path: string | undefined - private readonly preparedPath: string | undefined - - get writable(): boolean { - return !this.readOnly - } - - override get documentPath(): string | undefined { - return this.path - } - - override prepareDocument(): Promise { - return Promise.resolve(this.preparedPath ?? this.documentPath) - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - this.doc[ns] = structuredClone(section) - return Promise.resolve() - } -} - -/** In-memory credential provider with an env-shadow double for the rejection path. */ -class MemoryCredentials extends CredentialProvider { - private readonly values = new Map() - - constructor(ctx: ConstructorParameters[0], options?: { shadowed?: string[] }) { - super(ctx) - this.shadowed = new Set(options?.shadowed ?? []) - } - - private readonly shadowed: Set - - resolve(ref: CredentialRef): Promise { - if (this.shadowed.has(ref)) return Promise.resolve({ value: 'from-env', source: 'env' }) - const value = this.values.get(ref) - return Promise.resolve(value === undefined ? undefined : { value, source: 'file' }) - } - - describe(ref: CredentialRef): Promise { - if (this.shadowed.has(ref)) return Promise.resolve({ configured: true, source: 'env', writable: false }) - const configured = this.values.has(ref) - return Promise.resolve({ configured, ...configured ? { source: 'file' } : {}, writable: true }) - } - - set(ref: CredentialRef, value: string): Promise { - if (this.shadowed.has(ref)) { - return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`)) - } - this.values.set(ref, value) - this.ctx.emit('credentials/reference-updated', ref) - return Promise.resolve() - } - - unset(ref: CredentialRef): Promise { - if (this.shadowed.has(ref)) { - return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`)) - } - this.values.delete(ref) - this.ctx.emit('credentials/reference-updated', ref) - return Promise.resolve() - } - - // The record half has no wire face on this proxy, so the double answers the - // empty store rather than modelling storage the tests never exercise. - readRecord(): Promise { - return Promise.resolve(undefined) - } - - describeRecord(): Promise { - return Promise.resolve({ configured: false, writable: true }) - } - - listRecords(): Promise { - return Promise.resolve([]) - } - - modifyRecord( - _key: CredentialKey, - mutate: (current: CredentialRecord | undefined) => Promise, - ): Promise { - return mutate(undefined) - } - - deleteRecord(): Promise { - return Promise.resolve() - } -} - -const NS = settingsNamespace('llm-deepseek') - -const AdapterConfig = z.object({ - apiKey: z.string().role('secret'), - apiKeyEnv: z.string().default('DEEPSEEK_API_KEY'), - baseURL: z.string(), -}) - -async function harness(options?: { - settings?: false | { - doc?: Record - readOnly?: boolean - documentPath?: string - preparedPath?: string - } - credentials?: false | { shadowed?: string[] } -}): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) - if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials) - return ctx -} - -/** Observe settings commits while one API operation runs. */ -async function captureSettingsUpdates( - ctx: Context, - run: () => Promise, -): Promise> { - const updates: Array = [] - const dispose = ctx.on('settings/document-updated', (namespace, revision) => { - updates.push([namespace, revision]) - }) - try { - await run() - return updates - } finally { - dispose() - } -} - -/** Expected settings event tuple with its owner-assigned revision. */ -function expectedSettingsUpdate(ns: string): readonly unknown[] { - return [ns, expect.any(Number)] -} - -describe('settings events', () => { - it('forwards a provider settings change for model-catalog consumers', async () => { - // Editing `models` changes no route, so llm/adapters-updated never fires - // and an open model picker would keep serving the stale catalog. Storing - // an override equal to the resolved value emits nothing on - // settings/updated, so another tab would never learn the field became - // overridden. - const ctx = await harness() - ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) - const updates = await captureSettingsUpdates(ctx, async () => { - await ctx.settings.update(settingsNamespace('llm-deepseek'), { baseURL: 'https://base' }) - }) - expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')]) - // The resolved value never moved: base already said https://base. - expect(ctx.settings.describe().find(view => String(view.ns) === 'llm-deepseek')?.value) - .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) - }) - - it('broadcasts a permission change without invalidating the model catalog', async () => { - const ctx = await harness() - const permission = ctx.settings.register(settingsNamespace('permission'), z.object({ - defaultPreset: z.union(['read-only', 'workspace-write']).required(), - }), { - base: { defaultPreset: 'read-only' }, - }) - const updates = await captureSettingsUpdates(ctx, async () => { - await permission.update({ defaultPreset: 'workspace-write' }) - }) - expect(updates).toEqual([expectedSettingsUpdate('permission')]) - }) - - it('forwards an Agent-default settings change for model-catalog consumers', async () => { - const ctx = await harness() - const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({ - provider: z.string().required(), - model: z.string().required(), - }), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) - // The shared section names the selection every blank session resolves to, - // so an externally edited default — another tab, a - // hand-edited settings.yaml — has to reach an open selector as well. - const updates = await captureSettingsUpdates(ctx, async () => { - await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) - }) - expect(updates).toEqual([expectedSettingsUpdate('agent-default-model')]) - }) - - - - - - -}) diff --git a/packages/host/apiproxy/tests/api-proxy-host.spec.ts b/packages/host/apiproxy/tests/api-proxy-host.spec.ts deleted file mode 100644 index 180be46507..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-host.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { homedir } from 'node:os' -import { afterEach, describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '../src/api-proxy.ts' - -let nextRpc = 1 -const contexts: Context[] = [] - -afterEach(async () => { - await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) -}) - -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`host-${String(nextRpc++)}`), payload } -} - -function expectOk(response: { readonly result: { readonly ok: true; readonly value: T } | { readonly ok: false } }): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -async function harness( - extras: { - canOpenPath?: () => boolean - } = {}, -) { - const ctx = new Context() - contexts.push(ctx) - await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { - defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), - cwd: '/tmp/dsh-apiproxy-host', - ...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath }, - }) - return { api } -} - -describe('host.describe', () => { - it('describes whether the deployment can reach a native desktop', async () => { - const visible = await harness({ canOpenPath: () => true }) - const headless = await harness({ canOpenPath: () => false }) - expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) - expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) - expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) - }) - -}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts deleted file mode 100644 index e587346061..0000000000 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Wire-protocol coverage over the isomorphic point: InProcessApiClient → - * toFetchHandler(scripted impl) runs the real envelope wrap/unwrap, zod - * two-level parse, and rpcId discipline with no network or browser. Each case - * scripts its own minimal ApiProxy. - */ - -import { describe, expect, it, vi } from 'vitest' -import type { ApiProxy, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' -import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' - -function ok(request: RpcRequest, value: T): Promise> { - return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } }) -} - -/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */ -function scriptedApi(overrides: { - host?: Partial -} = {}): ApiProxy { - return { - host: { - describe: r => ok(r, { - version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, - }), - ...overrides.host, - }, - downloads: { sessionLog: async () => new Response('stub', { status: 404 }) }, - } -} - -function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient { - return new InProcessApiClient(toFetchHandler(api), timeoutMs) -} - -describe('unary round trip', () => { - it('carries payload out and value back through the full wire form', async () => { - let seen: RpcRequest<{}> | undefined - const api = scriptedApi({ - host: { - describe: (request) => { - seen = request - return ok(request, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true }) - }, - }, - }) - const response = await client(api).host.describe({}) - expect(seen?.payload).toEqual({}) - expect(seen?.rpcId).toBeTruthy() - expect(response.rpcId).toBe(seen?.rpcId) - expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } }) - }) - - it('passes business errors through as 200 + err result, not a throw', async () => { - const api = scriptedApi({ - host: { - describe: request => Promise.resolve({ - rpcId: request.rpcId, - result: { ok: false, error: { code: 'internal', message: 'nope', details: {} } }, - }), - }, - }) - const response = await client(api).host.describe({}) - expect(response.result).toEqual({ ok: false, error: { code: 'internal', message: 'nope', details: {} } }) - }) - - it('throws on rpcId echo mismatch', async () => { - const api = scriptedApi({ - host: { - describe: () => Promise.resolve({ - rpcId: RpcId('forged'), - result: { ok: true, value: { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true } }, - }), - }, - }) - await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/) - }) - - it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => { - const handler = toFetchHandler(scriptedApi()) - // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse. - const noId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) }) - expect(noId.status).toBe(200) - const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } } - expect(noIdParsed.result.ok).toBe(false) - expect(noIdParsed.rpcId).toBe('invalid-request') - // A string rpcId in the otherwise-bad body is salvaged for correlation. - const withId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) - const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } } - expect(withIdParsed.result.ok).toBe(false) - expect(withIdParsed.rpcId).toBe('salvage-me') - }) - - it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => { - const handler = toFetchHandler(scriptedApi()) - // Unknown method → 404. - const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) - expect(notFound.status).toBe(404) - // Non-JSON body → 400. - const badBody = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }) - expect(badBody.status).toBe(400) - // Impl crash → 500, and through the client that is a throw, not an err result. - const crashing = scriptedApi({ host: { describe: () => { throw new Error('impl exploded') } } }) - await expect(client(crashing).host.describe({})).rejects.toThrow(/transport failure .*500/) - }) - - it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => { - const describe = vi.fn((request: RpcRequest<{}>) => ok(request, { - version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, - })) - const handler = toFetchHandler(scriptedApi({ host: { describe } })) - const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} }) - // A "simple" browser POST (text/plain — sent with no CORS preflight) is - // refused at the carrier before the impl runs. - const plain = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'text/plain' }, body }) - expect(plain.status).toBe(415) - // A string body with no explicit header defaults to text/plain — same fence. - const unlabelled = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', body }) - expect(unlabelled.status).toBe(415) - expect(describe).not.toHaveBeenCalled() - // Media-type parameters pass: the fence checks the type, not the exact string. - const charset = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body }) - expect(charset.status).toBe(200) - expect(describe).toHaveBeenCalledTimes(1) - }) - - it('rejects when the transport never resolves within timeoutMs', async () => { - // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast. - const never = new InProcessApiClient({ - fetch: (_i: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) }) - }), - }, 25) - await expect(never.host.describe({})).rejects.toThrow() - }) - - it('aborts a unary call through the caller-supplied external signal', async () => { - // Real-fetch semantics: on abort the rejection is the signal's reason, and the abort - // works even when the transport ignores the signal entirely (hung impl). - const gate = new AbortController() - const hung = new InProcessApiClient({ fetch: () => new Promise(() => {}) }, 60_000) - const call = hung.host.describe({}, gate.signal) - gate.abort(new Error('externally aborted')) - await expect(call).rejects.toThrow(/externally aborted/) - }) - - it('rejects an already-aborted signal before touching the transport, mapping a string reason to an Error', async () => { - let touched = false - const c = new InProcessApiClient({ - fetch: () => { - touched = true - return Promise.resolve(new Response('{}')) - }, - }, 60_000) - const gate = new AbortController() - gate.abort('gone before start') - await expect(c.host.describe({}, gate.signal)).rejects.toThrow('gone before start') - expect(touched).toBe(false) - }) - - it('maps a non-Error, non-string abort reason to the default AbortError message', async () => { - const gate = new AbortController() - const hung = new InProcessApiClient({ fetch: () => new Promise(() => {}) }, 60_000) - const call = hung.host.describe({}, gate.signal) - gate.abort(42) - await expect(call).rejects.toThrow('This operation was aborted') - }) - - it('passes a signal-less doFetch straight through to the handler', async () => { - class Probe extends InProcessApiClient { - direct(url: URL): Promise { - return this.doFetch(url) - } - } - const probe = new Probe({ fetch: () => Promise.resolve(new Response('raw')) }) - const response = await probe.direct(new URL('http://dsh.internal/probe')) - expect(await response.text()).toBe('raw') - }) - - it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => { - // Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject. - const api = scriptedApi({ - host: { describe: request => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { version: 1 } } }) as never }, - }) - await expect(client(api).host.describe({})).rejects.toThrow() - }) -}) - -describe('envelope tap', () => { - it('delivers one microtask batch of full forms per unary call', async () => { - const api = scriptedApi() - const tapped = client(api) - const batches: (readonly RpcMessage[])[] = [] - tapped.subscribeEnvelopes(batch => batches.push(batch)) - await tapped.host.describe({}) - await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) }) - const all = batches.flat() - expect(all.map(m => m.type)).toEqual(['client-request', 'server-response']) - expect(all[0]?.rpcId).toBe(all[1]?.rpcId) - }) - - it('isolates a throwing listener and keeps serving the call', async () => { - const api = scriptedApi() - const tapped = client(api) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - const good: string[] = [] - tapped.subscribeEnvelopes(() => { throw new Error('listener bug') }) - tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type))) - const response = await tapped.host.describe({}) - expect(response.result.ok).toBe(true) - await vi.waitFor(() => { expect(good).toContain('server-response') }) - } finally { - errorSpy.mockRestore() - } - }) - - it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => { - const api = scriptedApi() - const tapped = client(api) - await tapped.host.describe({}) // no subscribers: must not accumulate - const batches: (readonly RpcMessage[])[] = [] - const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch)) - unsubscribe() - await tapped.host.describe({}) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(batches).toEqual([]) - }) -}) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts deleted file mode 100644 index 844c4d5039..0000000000 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { ApiProxy } from '../src/api/index.ts' -import type { RpcMessage } from '../src/api/rpc.ts' -import { toFetchHandler } from '../src/fetch/handler.ts' -import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts' - -/** Minimal in-memory ApiProxy that echoes rpcIds. */ -function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { - return { - host: { - async describe(request) { - if (overrides.crashOn === 'host.describe') throw new Error('impl crashed') - return { - rpcId: request.rpcId, - result: { - ok: true, - value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true }, - }, - } - }, - }, - downloads: { - async sessionLog() { - return new Response('stub', { status: 404 }) - }, - }, - } -} - -function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClient { - return new InProcessApiClient(toFetchHandler(api), timeoutMs) -} - -describe('unary round trip (handler ⇄ client, no network)', () => { - it('carries a success result and echoes the minted rpcId', async () => { - const response = await client().host.describe({}) - expect(response.result).toMatchObject({ ok: true, value: { version: 'v', cwd: '/w' } }) - expect(response.rpcId).toMatch(/[0-9a-f-]{36}/) - }) - - it('carries a business error as 200 + error result', async () => { - const api = fakeApi() - api.host.describe = request => Promise.resolve({ - rpcId: request.rpcId, - result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } }, - }) - const response = await client(api).host.describe({}) - expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('internal') - }) - -}) - -describe('handler carrier-layer statuses', () => { - const handler = toFetchHandler(fakeApi()) - - it('404s unknown paths and non-POST non-stream methods', async () => { - expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404) - expect((await handler.fetch(new Request('http://x/api/host.describe', { method: 'GET' }))).status).toBe(404) - expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404) - }) - - it('400s a non-JSON body', async () => { - const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' })) - expect(response.status).toBe(400) - }) - - it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => { - const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) })) - expect(response.status).toBe(200) - const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } - expect(body.rpcId).toBe('invalid-request') - expect(body.result.error?.code).toBe('bad-request') - }) - - it('rejects an invalid payload with the zod issues attached', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'host.describe', payload: null }) - const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) - const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } } - expect(parsed.result.error?.code).toBe('bad-request') - expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0) - }) - - it('rejects a request whose envelope method does not match its path', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-mismatch', method: 'other.method', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) - const parsed = await response.json() as { result: { error?: { code: string; message: string } } } - expect(parsed.result.error).toMatchObject({ - code: 'bad-request', - message: 'method "other.method" does not match path "host.describe"', - }) - }) - - it('500s when the impl itself throws', async () => { - const crashing = toFetchHandler(fakeApi({ crashOn: 'host.describe' })) - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'host.describe', payload: {} }) - const response = await crashing.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) - expect(response.status).toBe(500) - expect(await response.text()).toContain('impl crashed') - }) - - it('accepts (url, init) form fetch invocation', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'host.describe', payload: {} }) - const response = await handler.fetch('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }) - expect(response.status).toBe(200) - }) -}) - -describe('client transport failures', () => { - it('throws on a non-OK unary transport', async () => { - const broken = new InProcessApiClient({ fetch: async () => new Response('down', { status: 503 }) }) - await expect(broken.host.describe({})).rejects.toThrow('transport failure for /api/host.describe: HTTP 503') - }) - - it('throws on an rpcId echo mismatch', async () => { - const lying = new InProcessApiClient({ - fetch: async () => Response.json({ - type: 'server-response', - rpcId: 'someone-else', - result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true } }, - }), - }) - await expect(lying.host.describe({})).rejects.toThrow('rpcId mismatch') - }) -}) - -describe('envelope observation', () => { - it('batches envelopes per microtask and isolates a throwing listener', async () => { - const c = client() - const batches: (readonly RpcMessage[])[] = [] - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const unsubscribeThrowing = c.subscribeEnvelopes(() => { throw new Error('observer bug') }) - const unsubscribe = c.subscribeEnvelopes((batch) => { batches.push(batch) }) - await c.host.describe({}) - await new Promise((resolve) => { setTimeout(resolve, 0) }) - // request and response tap in separate microtask windows (the await between - // them yields), so both arrive but batch count is timing-defined - expect(batches.flatMap(batch => batch.map(message => message.type))).toEqual(['client-request', 'server-response']) - expect(errorSpy).toHaveBeenCalled() - unsubscribe() - unsubscribeThrowing() - errorSpy.mockRestore() - }) - - it('skips buffering entirely with no listeners and after unsubscribe', async () => { - const c = client() - const seen: RpcMessage[] = [] - const unsubscribe = c.subscribeEnvelopes((batch) => { seen.push(...batch) }) - unsubscribe() - await c.host.describe({}) - await new Promise((resolve) => { setTimeout(resolve, 0) }) - expect(seen).toHaveLength(0) - }) - - it('coalesces multiple calls in one microtask window into one flush', async () => { - const c = client() - const batches: (readonly RpcMessage[])[] = [] - c.subscribeEnvelopes((batch) => { batches.push(batch) }) - await Promise.all([c.host.describe({}), c.host.describe({})]) - await new Promise((resolve) => { setTimeout(resolve, 0) }) - const total = batches.reduce((n, batch) => n + batch.length, 0) - expect(total).toBe(4) - }) -}) - -describe('resolveBase', () => { - it('prefers a real location.origin and falls back to the internal authority', async () => { - class Probe extends AbstractApiClient { - urls: string[] = [] - protected async doFetch(input: URL): Promise { - this.urls.push(input.href) - return Response.json({ - type: 'server-response', - rpcId: this.lastMinted, - result: { - ok: true, - value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true }, - }, - }) - } - - lastMinted = '' - protected override mintRpcId(): ReturnType { - const id = super.mintRpcId() - this.lastMinted = id - return id - } - } - const probe = new Probe() - await probe.host.describe({}) - expect(probe.urls[0]).toMatch(/^http:\/\/dsh\.internal\//) - - const globalWithLocation = globalThis as { location?: { origin?: string } } - globalWithLocation.location = { origin: 'http://host.example' } - try { - const probe2 = new Probe() - await probe2.host.describe({}) - expect(probe2.urls[0]).toMatch(/^http:\/\/host\.example\//) - globalWithLocation.location = { origin: 'null' } // sandboxed iframe shape - const probe3 = new Probe() - await probe3.host.describe({}) - expect(probe3.urls[0]).toMatch(/^http:\/\/dsh\.internal\//) - } finally { - delete globalWithLocation.location - } - }) -}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts deleted file mode 100644 index 33cf4b2eca..0000000000 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { RpcId, transportError } from '../src/api/rpc.ts' -import { - clientRequestSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, - rpcResultSchema, serverResponseSchema, -} from '../src/api/rpc.schema.ts' -import { z } from 'zod' -import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' - -describe('RpcId', () => { - it('brands a raw string at zero runtime cost', () => { - expect(RpcId('abc')).toBe('abc') - expect(rpcIdSchema.parse('abc')).toBe('abc') - // No min-length: the id is an opaque echo token (see rpcIdSchema's contract). - expect(rpcIdSchema.parse('')).toBe('') - expect(() => rpcIdSchema.parse(42)).toThrow() - }) -}) - -describe('transportError', () => { - it('folds Error and non-Error throws into the internal error branch', () => { - expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } }) - expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } }) - }) -}) - -describe('rpcErrorSchema', () => { - it('accepts every code branch with its required details', () => { - expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') - expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled') - expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found') - expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone') - expect(rpcErrorSchema.parse({ code: 'agent-preset-read-only', message: 'm', details: { agentPreset: 'p', reason: 'system' } }).code).toBe('agent-preset-read-only') - expect(rpcErrorSchema.parse({ code: 'agent-preset-locked', message: 'm', details: { sessionId: 's', agentPreset: 'p' } }).code).toBe('agent-preset-locked') - expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found') - expect(rpcErrorSchema.parse({ code: 'agent-preset-invalid', message: 'm', details: { agentPreset: 'p', reason: 'bad' } }).code).toBe('agent-preset-invalid') - expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') - expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') - }) - - it('rejects a known code with missing details', () => { - expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'internal', message: 'm' })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() - }) -}) - -describe('rpcResultSchema', () => { - it('accepts both result branches and rejects hybrids', () => { - const schema = rpcResultSchema(z.object({ n: z.number() })) - expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } }) - const err = schema.parse({ ok: false, error: { code: 'internal', message: 'x', details: {} } }) - expect(err).toMatchObject({ ok: false }) - expect(() => schema.parse({ ok: true, error: {} })).toThrow() - }) -}) - -describe('wire full-form schemas', () => { - it('parses both carrier forms and the union discriminates on type', () => { - const cq = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} } - const sr = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } } - expect(clientRequestSchema.parse(cq).method).toBe('host.describe') - expect(serverResponseSchema.parse(sr).rpcId).toBe('r1') - for (const message of [cq, sr]) expect(rpcMessageSchema.parse(message)).toBeTruthy() - expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() - }) - - it('rejects a quadrant missing its members but accepts a valueless success result', () => { - expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow() - expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow() - expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow() - // A void business result carries no value field; the endpoint's own second - // parse is what requires a value for methods that return data. - expect(serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } }).rpcId) - .toBe('r1') - }) -}) - -describe('host domain schemas', () => { - it('validates describe request/value', () => { - expect(hostDescribeRequestSchema.parse({})).toEqual({}) - const value = hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, home: '/h', canOpenPath: true, - }) - expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true }) - expect(hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', attachedSessions: 0, home: '/h', canOpenPath: false, - }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', attachedSessions: 0, - })).toThrow() - expect(() => hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true, - })).toThrow() - }) -}) diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts deleted file mode 100644 index aa4137e031..0000000000 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ /dev/null @@ -1,708 +0,0 @@ -/** - * session.export host path: the GET download endpoint streams a ZIP whose - * files are the stored artifacts verbatim (root + optional descendants), and - * the degenerate compositions fail loudly (missing services → 500, missing - * root → 404, missing descendant → errored stream). - */ - -import { randomBytes } from 'node:crypto' -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { unzipSync, strFromU8 } from 'fflate' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' -import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' -import ApiProxyService, { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' - -const sid = (id: string): SessionId => id as SessionId - -function header(id: string, parentSession?: SessionId): SessionHeader { - return { - version: 0, - id: sid(id), - createdAt: 1000, - cwd: '/proj', - ...parentSession === undefined ? {} : { parentSession }, - delegationDepth: parentSession === undefined ? 0 : 1, - } -} - -function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact { - return { - meta: header(id, parentSession), - filename: 'session.jsonl', - content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`, - } -} - -function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode { - return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants } -} - -/** One durable image object served by the fake attachment store. */ -function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') { - return { - ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef, - data: new Uint8Array([1, 2, 3, 4]), - } -} - -/** A user/message event line carrying one image reference. */ -function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string { - return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}` -} - -async function buildApi( - artifacts: Record, - descendants: SessionLineageNode[] = [], - services: { - query?: boolean - persistence?: boolean | 'throw' | 'unsupported' - attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise>) - sessions?: { - get(id: SessionId): { readonly id: SessionId } | undefined - flush(session: { readonly id: SessionId }): Promise - } - readRaw?: (id: SessionId, signal?: AbortSignal) => Promise - traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{ - target: { header: SessionHeader; live: boolean; persisted: boolean } - ancestors: readonly SessionLineageNode[] - complete: boolean - root: { header: SessionHeader; live: boolean; persisted: boolean } - descendants: readonly SessionLineageNode[] - }> - compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 - } = {}, -) { - const ctx = new Context() - const query = services.query ?? true - const persistence = services.persistence ?? true - if (query) { - ctx.provide('sessionQuery', { - traceSession: services.traceSession ?? (async () => ({ - target: { header: header('session-root'), live: false, persisted: true }, - ancestors: [], - complete: true, - root: { header: header('session-root'), live: false, persisted: true }, - descendants, - })), - } as never) - } - if (persistence) { - ctx.provide('sessionPersistence', { - supportsRawArtifacts: persistence !== 'unsupported', - readRaw: services.readRaw ?? (async (id: SessionId) => { - if (persistence === 'throw') throw new Error('/host/private/session.jsonl') - return artifacts[id] - }), - } as never) - } - if (services.attachments !== false) { - const readImage = typeof services.attachments === 'function' - ? services.attachments - : async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType) - ctx.provide('attachments', { - imageLimits: {} as never, - validateImage: async () => {}, - saveImage: async () => { throw new Error('export never saves images') }, - readImage, - } as never) - } - if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never) - return createApiProxy(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), - cwd: '/tmp', - ...services.compressionLevel === undefined - ? {} - : { sessionExportCompressionLevel: services.compressionLevel }, - }) -} - -async function responseBytes(response: Response): Promise { - return new Uint8Array(await response.arrayBuffer()) -} - -describe('session export compression config', () => { - it('defaults to level 6 and rejects values outside the integer 0-9 range', () => { - expect(ApiProxyService.Config({})).toEqual({ - sessionExportCompressionLevel: 6, - }) - expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 })) - .toEqual({ sessionExportCompressionLevel: 0 }) - expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 })) - .toEqual({ sessionExportCompressionLevel: 9 }) - for (const value of [-1, 10, 1.5]) { - expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow() - } - }) -}) - -describe('session.export download endpoint', () => { - it('streams a ZIP with the root artifact verbatim under its original filename', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toBe('application/zip') - expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files)).toEqual(['session.jsonl']) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) - }) - - it('preflights root preparation through HEAD without streaming a body', async () => { - const readRaw = vi.fn(async () => artifact('session-root')) - const api = await buildApi({}, [], { readRaw }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), - ) - - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toBe('application/zip') - expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') - expect(response.body).toBeNull() - expect(readRaw).toHaveBeenCalledOnce() - }) - - it('returns a bodyless preparation error from HEAD', async () => { - const api = await buildApi({}) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), - ) - - expect(response.status).toBe(404) - expect(response.body).toBeNull() - }) - - it('uses the resolved compression level for ZIP entries', async () => { - const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024)) - const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 }) - const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 }) - const stored = await storedApi.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: false }, - new AbortController().signal, - ) - const compressed = await compressedApi.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: false }, - new AbortController().signal, - ) - const storedBytes = await responseBytes(stored) - const compressedBytes = await responseBytes(compressed) - expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength) - expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content) - }) - - it('includes descendant artifacts under subagents// when requested', async () => { - const api = await buildApi({ - 'session-root': artifact('session-root'), - 'child-a': artifact('child-a', sid('session-root')), - 'grandchild-a': artifact('grandchild-a', sid('child-a')), - }, [ - node('child-a', node('grandchild-a')), - ]) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - expect(response.status).toBe(200) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual([ - 'session.jsonl', - 'subagents/child-a/session.jsonl', - 'subagents/grandchild-a/session.jsonl', - ]) - expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)) - .toBe(artifact('child-a').content) - }) - - it('flushes each live root and descendant immediately before reading its artifact', async () => { - const stored: Record = { - 'session-root': artifact('session-root', undefined, 'stale root'), - 'child-a': artifact('child-a', sid('session-root'), 'stale child'), - } - const durable: Record = { - 'session-root': artifact('session-root', undefined, 'durable root'), - 'child-a': artifact('child-a', sid('session-root'), 'durable child'), - } - const flushed: SessionId[] = [] - const api = await buildApi(stored, [node('child-a')], { - sessions: { - get: id => durable[id] === undefined ? undefined : { id }, - flush: async (session) => { - const artifactAfterFlush = durable[session.id] - if (artifactAfterFlush === undefined) throw new Error('unexpected session') - flushed.push(session.id) - stored[session.id] = artifactAfterFlush - return true - }, - }, - }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - const files = unzipSync(await responseBytes(response)) - expect(flushed).toEqual([sid('session-root'), sid('child-a')]) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root') - expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child') - }) - - it('reads a cold artifact without asking the live-session store to flush', async () => { - const flush = vi.fn(async () => true) - const root = artifact('session-root') - const api = await buildApi({ 'session-root': root }, [], { - sessions: { - get: () => undefined, - flush, - }, - }) - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: false }, - new AbortController().signal, - ) - const files = unzipSync(await responseBytes(response)) - expect(flush).not.toHaveBeenCalled() - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) - }) - - it('answers 404 for a missing root session', async () => { - const api = await buildApi({}) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(404) - }) - - it('answers 501 when the persistence backend has no per-session raw artifacts', async () => { - const api = await buildApi({}, [], { persistence: 'unsupported' }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(501) - expect(await response.text()).toContain('does not expose per-session raw artifacts') - }) - - it('answers 400 when the sessionId query parameter is absent', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?includeDescendants=true'), - ) - expect(response.status).toBe(400) - }) - - it('answers 400 for an includeDescendants value other than true or false', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'), - ) - expect(response.status).toBe(400) - }) - - it('answers 500 when the deployment mounts no persistence or session-query service', async () => { - const api = await buildApi({}, [], { query: false, persistence: false }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(500) - expect(await response.text()).toContain('session-query') - }) - - it('fails the whole export when a descendant has no stored artifact', async () => { - const api = await buildApi({ - 'session-root': artifact('session-root'), - }, [node('child-missing')]) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - expect(response.status).toBe(200) - // The stream errors before completing, so the body read rejects rather - // than returning a truncated-but-valid archive. - await expect(response.arrayBuffer()).rejects.toThrow() - }) - - it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => { - // The push loop slices by 2^16 code units and must back off one unit when - // the boundary lands inside a surrogate pair; otherwise the pair re-encodes - // as U+FFFD and the exported artifact is silently corrupted. - const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` } - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) - }) - - it('splits a long artifact on a plain code-unit boundary without backoff', async () => { - // A boundary that lands on a BMP character needs no surrogate backoff; the - // round trip must still be byte-identical across the multi-chunk push. - const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) } - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) - }) - - it('waits for response pull capacity before reading the next archive entry', async () => { - const root = artifact('session-root', undefined, [ - imageEventLine('after-root'), - randomBytes(512 * 1024).toString('base64'), - ].join('\n')) - let imageReads = 0 - const api = await buildApi({ 'session-root': root }, [], { - attachments: async (ref) => { - imageReads += 1 - return storedImage(String(ref.attachmentId), ref.mediaType) - }, - }) - vi.useFakeTimers() - let response: Response | undefined - try { - response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - // Exhausting timer turns must not advance a producer whose byte queue is - // full; only a consumer pull can release it. - await vi.runAllTimersAsync() - expect(imageReads).toBe(0) - } finally { - vi.useRealTimers() - } - if (response === undefined) throw new Error('missing export response') - const files = unzipSync(await responseBytes(response)) - expect(imageReads).toBe(1) - expect(files['media/after-root.png']).toEqual(storedImage('after-root').data) - }) - - it('exports an empty artifact as an empty zip entry', async () => { - const root = { ...artifact('session-root'), content: '' } - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files)).toEqual(['session.jsonl']) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') - }) - - it('exports a shared lineage node once (seen-set dedup)', async () => { - const api = await buildApi({ - 'session-root': artifact('session-root'), - 'child-a': artifact('child-a', sid('session-root')), - 'child-b': artifact('child-b', sid('session-root')), - shared: artifact('shared', sid('child-a')), - }, [ - node('child-a', node('shared')), - node('child-b', node('shared')), - ]) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual([ - 'session.jsonl', - 'subagents/child-a/session.jsonl', - 'subagents/child-b/session.jsonl', - 'subagents/shared/session.jsonl', - ]) - }) - - it('answers 500 without leaking the backend error when the root artifact read fails', async () => { - const api = await buildApi({}, [], { query: true, persistence: 'throw' }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(500) - const body = await response.text() - expect(body).toBe('session log export failed to prepare the stored artifact') - expect(body).not.toContain('/host/private/') - }) - - it('answers the private-error-safe 500 when the live root flush fails', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }, [], { - sessions: { - get: id => ({ id }), - flush: async () => { throw new Error('/host/private/flush-state') }, - }, - }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(500) - const body = await response.text() - expect(body).toBe('session log export failed to prepare the stored artifact') - expect(body).not.toContain('/host/private/') - }) - - it('forwards one request signal through root, lineage, and descendant reads', async () => { - const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = [] - const traces: AbortSignal[] = [] - const api = await buildApi({}, [node('child-a')], { - readRaw: async (id, signal) => { - reads.push({ id, signal }) - return id === sid('session-root') - ? artifact('session-root') - : artifact('child-a', sid('session-root')) - }, - traceSession: async (_id, signal) => { - if (signal !== undefined) traces.push(signal) - return { - target: { header: header('session-root'), live: false, persisted: true }, - ancestors: [], - complete: true, - root: { header: header('session-root'), live: false, persisted: true }, - descendants: [node('child-a')], - } - }, - }) - const controller = new AbortController() - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: true }, - controller.signal, - ) - await response.arrayBuffer() - const producerSignal = traces[0] - if (producerSignal === undefined) throw new Error('missing lineage signal') - expect(reads[0]).toEqual({ id: sid('session-root'), signal: controller.signal }) - expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal }) - const cancellation = new Error('request cancelled after response') - controller.abort(cancellation) - expect(producerSignal.aborted).toBe(true) - expect(producerSignal.reason).toBe(cancellation) - }) - - it('preserves request cancellation instead of translating it to HTTP 500', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) - const controller = new AbortController() - const cancellation = new Error('request cancelled') - controller.abort(cancellation) - await expect(api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: false }, - controller.signal, - )).rejects.toBe(cancellation) - }) - - it('aborts descendant work and terminates ZIP production when its reader cancels', async () => { - let reportDescendantStarted!: (signal: AbortSignal) => void - const descendantStarted = new Promise((resolve) => { - reportDescendantStarted = resolve - }) - const api = await buildApi({}, [node('child-a')], { - readRaw: async (id, signal) => { - if (id === sid('session-root')) return artifact('session-root') - if (signal === undefined) throw new Error('missing descendant signal') - reportDescendantStarted(signal) - return new Promise((_, reject) => { - signal.addEventListener('abort', () => { - reject(signal.reason as Error) - }, { once: true }) - }) - }, - }) - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: true }, - new AbortController().signal, - ) - const reader = response.body?.getReader() - if (reader === undefined) throw new Error('missing response body') - const descendantSignal = await descendantStarted - const cancellation = new Error('download consumer left') - await reader.cancel(cancellation) - expect(descendantSignal.aborted).toBe(true) - expect(descendantSignal.reason).toBe(cancellation) - }) - - it('aborts attachment reads when its reader cancels', async () => { - let reportAttachmentStarted!: (signal: AbortSignal) => void - const attachmentStarted = new Promise((resolve) => { - reportAttachmentStarted = resolve - }) - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - imageEventLine('slow-img'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }, [], { - attachments: async (_ref, signal) => { - if (signal === undefined) throw new Error('missing attachment signal') - reportAttachmentStarted(signal) - return new Promise((_, reject) => { - signal.addEventListener('abort', () => { - reject(signal.reason as Error) - }, { once: true }) - }) - }, - }) - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: false }, - new AbortController().signal, - ) - const reader = response.body?.getReader() - if (reader === undefined) throw new Error('missing response body') - const attachmentSignal = await attachmentStarted - const cancellation = new Error('download consumer left during attachment read') - await reader.cancel(cancellation) - expect(attachmentSignal.aborted).toBe(true) - expect(attachmentSignal.reason).toBe(cancellation) - }) - - it('uses a stable Error reason when its reader cancels without one', async () => { - let reportDescendantStarted!: (signal: AbortSignal) => void - const descendantStarted = new Promise((resolve) => { - reportDescendantStarted = resolve - }) - const api = await buildApi({}, [node('child-a')], { - readRaw: async (id, signal) => { - if (id === sid('session-root')) return artifact('session-root') - if (signal === undefined) throw new Error('missing descendant signal') - reportDescendantStarted(signal) - return new Promise((_, reject) => { - signal.addEventListener('abort', () => { - reject(signal.reason as Error) - }, { once: true }) - }) - }, - }) - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: true }, - new AbortController().signal, - ) - const reader = response.body?.getReader() - if (reader === undefined) throw new Error('missing response body') - const descendantSignal = await descendantStarted - await reader.cancel() - expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled')) - }) - - it('normalizes a non-Error descendant failure before erroring the stream', async () => { - const api = await buildApi({}, [node('child-a')], { - readRaw: async (id) => { - if (id === sid('session-root')) return artifact('session-root') - throw 'descendant read failed' - }, - }) - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: true }, - new AbortController().signal, - ) - await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed')) - }) - - it('includes media objects referenced by the root log under media/.', async () => { - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - imageEventLine('img-1'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(200) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl']) - expect(files['media/img-1.png']).toEqual(storedImage('img-1').data) - }) - - it('collects media referenced from nested tool results', async () => { - const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}' - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - nested, - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl']) - }) - - it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => { - const block = (id: string, mediaType: string) => - `{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}` - const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}` - const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}` - const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}` - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - wrapped, - inserted, - chunk, - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual([ - 'media/chunk-1.png', - 'media/inserted-1.gif', - 'media/wrapped-1.jpg', - 'session.jsonl', - ]) - }) - - it('deduplicates one media object referenced by several included logs', async () => { - const line = imageEventLine('shared-img') - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - line, - ].join('\n') + '\n') - const child = artifact('child-a', sid('session-root'), [ - '{"type":"session","version":0,"id":"child-a","createdAt":1000}', - line, - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')]) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - const files = unzipSync(await responseBytes(response)) - expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data) - expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png']) - }) - - it('includes descendant media only when descendants are requested', async () => { - const child = artifact('child-a', sid('session-root'), [ - '{"type":"session","version":0,"id":"child-a","createdAt":1000}', - imageEventLine('child-img'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')]) - const without = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl']) - const withDescendants = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([ - 'media/child-img.png', - 'session.jsonl', - 'subagents/child-a/session.jsonl', - ]) - }) - - it('fails the whole export when a referenced image cannot be read', async () => { - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - imageEventLine('gone-img'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }, [], { - attachments: async () => { throw new Error('attachment bytes missing') }, - }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(200) - await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing') - }) - - it('answers 500 when the deployment mounts no attachments service', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(500) - expect(await response.text()).toContain('attachments') - }) -}) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json deleted file mode 100644 index 048fffcc72..0000000000 --- a/packages/host/apiproxy/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../credentials/credentials" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../api/session-controller/tsconfig.host.json" - }, - { - "path": "../../util/brand" - }, - { - "path": "../../attachment/attachment" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/agent-default-model" - }, - { - "path": "../../core/session" - }, - { - "path": "../../session/session-persistence" - }, - { - "path": "../../session-query/session-query" - }, - { - "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../util/native-command" - }, - { - "path": "../../util/crypto" - } - ] -} diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts index ab62291aed..240aeab248 100644 --- a/packages/typert/generator/tests/cordis-catalog.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog.spec.ts @@ -90,9 +90,6 @@ describe('Typert-backed Cordis catalog', () => { // An interface-typed key is described by its Service Definition: that is where // the contract and, by repository convention, the member JSDoc live. expect(byKey.get('lsp')?.type).toBe('LspService') - // The Service Definition may sit anywhere in the package, including a nested - // contract directory (`src/api/`), while the Context merge stays in `src`. - expect(byKey.get('apiProxy')?.type).toBe('ApiProxy') // Two packages describe `ctx.typert` — a merge-extensible interface in // type-meta and the implementing class in registry. The class wins: it is the // object a caller meets and it carries the documentation. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a021582cd9..06d5d75a10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -376,9 +376,6 @@ importers: '@deepseek-ai/dsh-fs-sandbox': specifier: workspace:^ version: link:../../packages/fs/fs-sandbox - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../packages/host/apiproxy '@deepseek-ai/dsh-host-frontend-static': specifier: workspace:^ version: link:../../packages/host/frontend-static @@ -1575,9 +1572,6 @@ importers: '@deepseek-ai/dsh-file-reference-local': specifier: workspace:^ version: link:../../context/file-reference-local - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy '@deepseek-ai/dsh-host-directory-picker-auto': specifier: workspace:^ version: link:../../host/directory-picker-auto @@ -1654,6 +1648,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1661,15 +1658,15 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../../host/directory-picker @@ -2274,12 +2271,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -4974,12 +4977,12 @@ importers: '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ version: link:../../shell/bash-sandbox + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -5799,67 +5802,6 @@ importers: specifier: workspace:^ version: link:../../core/tools - packages/host/apiproxy: - dependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-default-model': - specifier: workspace:^ - version: link:../../core/agent-default-model - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller - '@deepseek-ai/dsh-attachment': - specifier: workspace:^ - version: link:../../attachment/attachment - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-native-command': - specifier: workspace:^ - version: link:../../util/native-command - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session/session-persistence - '@deepseek-ai/dsh-session-query': - specifier: workspace:^ - version: link:../../session-query/session-query - '@deepseek-ai/dsh-util-crypto': - specifier: workspace:^ - version: link:../../util/crypto - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - fflate: - specifier: ^0.8.2 - version: 0.8.3 - zod: - specifier: ^4.4.3 - version: 4.4.3 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-credentials': - specifier: workspace:^ - version: link:../../credentials/credentials - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:^ - version: link:../../typert/registry - packages/host/directory-picker: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index f464732b40..e76c248aa7 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -198,7 +198,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str ...exportDefault(manifest, './worker') === './lib/worker.js' ? ['lib/worker.js'] : [], // UI plugin packages ship their browser bundle beside the node lib // (single-artifact ruling: dist/ retired, ./client resolves lib/client.js). - // Keyed on the artifact path, not the subpath name: apiproxy's ./client is + // Keyed on the artifact path, not the subpath name: a package's ./client is // a browser-safe source channel, not a bundle. ...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [], // runtime's shell-held loader subpath ships as its own bundle beside the client half. diff --git a/scripts/doc-typecheck-paths.ts b/scripts/doc-typecheck-paths.ts index ec17f1b29c..1a0ae8ecd9 100644 --- a/scripts/doc-typecheck-paths.ts +++ b/scripts/doc-typecheck-paths.ts @@ -1,7 +1,7 @@ /** Map one workspace source alias target to its declaration-build target. */ export function builtDeclarationPath(candidate: string): string { // Two workspace path forms exist: whole-package entries end in /src, subpath - // wildcards (apiproxy's browser-safe /api and /client channels) in /src/*. + // wildcards (browser-safe /types and /client channels) in /src/*. if (candidate.endsWith('/src')) { return `${candidate.slice(0, -'/src'.length)}/lib/types` } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 5c2fcca659..dcca3d0722 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -58,7 +58,6 @@ export const SERVICE_PAGE: Record = { agentDefaultModel: 'core.md', agentPresets: 'core.md', agents: 'core.md', - apiProxy: 'typert.md', approval: 'approval.md', attachments: 'attachment.md', shell: 'shell.md', @@ -618,6 +617,7 @@ export const LINK_MAP: Readonly> = { DirectoryListing: 'workspace.md', TypertContribution: 'invariants.md', TypertRemoteEventSource: 'typert.md', + RemoteEventHostInfo: 'typert.md', TypertFace: 'invariants.md', TypertPackageFilter: 'invariants.md', TypertPackageRecord: 'invariants.md', @@ -661,7 +661,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts', CompactionAgentContext: 'compaction service input is owned by packages/compaction/compaction/src/index.ts', ManualCompactAgentContext: 'manual compaction service input is owned by packages/compaction/compaction/src/index.ts', - ClientResponse: 'wire response message is owned by packages/host/apiproxy/src/api/rpc.ts', + ClientResponse: 'wire response message is owned by packages/client/connection/src/rpc.ts', ApprovalRequestId: 'dynamic Plugin approval identity is owned by packages/extensions/cordis-host-runner/src/types.ts', CordisErrorDetails: 'Cordis runtime error payload is owned by packages/extensions/cordis-host-runner/src/types.ts', CordisInspectPlatform: 'Cordis inspect platform identity is owned by packages/extensions/cordis-host-runner/src/types.ts', @@ -714,7 +714,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission-presets/src/types.ts', PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', RequestRunId: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts', - RpcReceipt: 'carrier-layer receipt is owned by packages/host/apiproxy/src/api/rpc.ts', + RpcReceipt: 'carrier-layer receipt is owned by packages/client/connection/src/rpc.ts', Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md', SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts', SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1119f7be56..65876fd5d0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -104,7 +104,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable binary attachment storage', mode: 'seam', implementations: ['attachment-local'], - consumers: ['api-session-controller', 'host-apiproxy', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'], + consumers: ['api-session-controller', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'], note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.', }, { @@ -236,8 +236,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'User-settings seam', mode: 'seam', implementations: ['settings-file'], - consumers: ['llm-deepseek', 'llm-pi-ai', 'host-apiproxy'], - note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer.', + consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'], + note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the settings controller serves redacted layered descriptors and writes the user layer.', }, { key: 'subagentModelSelection', @@ -253,8 +253,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Credential seam', mode: 'seam', implementations: ['credentials-local'], - consumers: ['llm-deepseek', 'llm-pi-ai', 'host-apiproxy'], - note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage.', + consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'], + note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage.', }, { key: 'authorization', @@ -389,15 +389,15 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session-projection', title: 'Session projection units', mode: 'core', - consumers: ['tool-todo', 'session-title', 'host-apiproxy'], - note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values.', + consumers: ['api-session-controller', 'tool-todo', 'session-title'], + note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values.', }, { key: 'sessionProjectionCache', pkg: 'session-projection-cache', title: 'Persisted projection cache', mode: 'core', - consumers: ['host-apiproxy'], + consumers: ['api-session-controller', 'session-query', 'session-reference', 'subagent'], note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.', }, { @@ -422,7 +422,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent-default-model', title: 'Default Agent model selection', mode: 'core', - consumers: ['headless', 'host-apiproxy'], + consumers: ['api-session-controller', 'headless'], note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.', }, { @@ -648,14 +648,6 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-lsp'], note: 'Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result.', }, - { - key: 'apiProxy', - pkg: 'host-apiproxy', - title: 'Host API dispatch', - mode: 'core', - consumers: ['client-connection'], - note: 'The transport-agnostic host gateway face: it dispatches browser API calls, and each open host stream subscribes to the events it forwards rather than being pushed to through a broadcast verb.', - }, { key: 'dynamicCordisRunner', pkg: 'cordis-host-runner', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4ea84b36fe..46e80a86c1 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -114,7 +114,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, - 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register nothing model-facing.' }, 'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers nothing model-facing.' }, 'packages/host/directory-picker-auto': { kind: 'none', reason: 'The GUI-host picking chooser only mounts a backend row; it registers nothing model-facing.' }, 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 1c06baeac9..661a546bf8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -136,7 +136,12 @@ "@deepseek-ai/dsh-headless/startup": ["./packages/bundle/headless/src/startup.ts"], "@deepseek-ai/dsh-web-app/startup": ["./packages/bundle/web-app/src/startup.ts"], "@deepseek-ai/dsh-client-*/client": ["./packages/client/*/src/client"], - "@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"], + // One wildcard maps every @deepseek-ai/dsh- to its source. Package + // dir names are unique across groups, so first-on-disk-wins resolution is + // unambiguous; adding a package under an existing group needs no edit + // here. The aggregates' project references (tsconfig.host.json / + // tsconfig.client.json) stay explicit — TS project references have no + // wildcard form. "@deepseek-ai/dsh-host-directory-picker": ["./packages/host/directory-picker/src"], "@deepseek-ai/dsh-host-directory-picker/*": ["./packages/host/directory-picker/src/*"], "@deepseek-ai/dsh-host-directory-picker-browse": ["./packages/host/directory-picker-browse/src"], @@ -145,8 +150,6 @@ "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"], "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"], "@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"], - "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], - "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], "@deepseek-ai/dsh-host-frontend-static": ["./packages/host/frontend-static/src"], "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 8b70f8d2c7..115c328537 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -2,7 +2,7 @@ // Client-side typecheck aggregate: packages/client tests (.ts and .tsx). // Split from the host aggregate because both sides merge cordis Context // under the same keys (sessions, loader) with different services; shared - // leaves (session/llm/tools/apiproxy/...) build once and are referenced by + // leaves (session/llm/tools/...) build once and are referenced by // both programs through each client package's own references. "extends": "./tsconfig.base.client.json", "compilerOptions": { diff --git a/tsconfig.host.json b/tsconfig.host.json index a707fa8df7..14bfe0822f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -313,7 +313,6 @@ { "path": "./packages/hooks/hooks-claude-code" }, { "path": "./packages/hooks/hooks-codex" }, { "path": "./packages/mcp/mcp-client" }, - { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/directory-picker" }, { "path": "./packages/host/directory-picker-auto" }, { "path": "./packages/host/directory-picker-browse" }, diff --git a/vitest.config.ts b/vitest.config.ts index 2127545939..ecfd00ded4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -330,9 +330,6 @@ export default defineConfig({ // by decision: its correctness signal is its uninstrumented suite and // the packer's end-to-end image spec. 'packages/experimental/webworker-runtime/src/**/*.ts', - 'packages/host/apiproxy/src/index.ts', - 'packages/host/apiproxy/src/invariant.ts', - 'packages/host/apiproxy/src/api-proxy.ts', // Projection/command round: executor lifecycle branches and the // registry's drive tails need the same maturing lanes. TODO(gui): // cover and remove with the client test lane above. From e57e7c3f25c4d2386e74600ac6fe0faa14ea0d8a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:58:42 +0800 Subject: [PATCH 08/11] docs(api): describe Connection-owned transport --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 6 +++ ...026-07-19-gui-layering-and-rpc-protocol.md | 1 + ...-07-19-gui-layering-and-rpc-protocol.zh.md | 1 + ...08-04-websocket-downlink-carrier.i18n.yaml | 6 +++ .../2026-08-04-websocket-downlink-carrier.md | 1 + ...026-08-04-websocket-downlink-carrier.zh.md | 1 + .agents/notes/archived/manifest.json | 6 +++ ...19-gui-layering-and-rpc-protocol.i18n.yaml | 6 --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 10 ++-- ...26-07-19-gui-web-client-architecture.zh.md | 10 ++-- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 2 +- ...26-07-23-client-plugin-loading-model.zh.md | 2 +- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...config-tree-boot-and-transport-layering.md | 6 +-- ...fig-tree-boot-and-transport-layering.zh.md | 6 +-- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 +- .../2026-07-28-api-browser-trust-boundary.md | 2 +- ...026-07-28-api-browser-trust-boundary.zh.md | 2 +- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 8 +-- ...-28-directory-picker-capability-seam.zh.md | 8 +-- ...-token-usage-and-request-context.i18n.yaml | 4 +- ...ojected-token-usage-and-request-context.md | 4 +- ...cted-token-usage-and-request-context.zh.md | 4 +- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 6 +-- ...2026-08-03-per-session-agent-presets.zh.md | 6 +-- ...08-04-websocket-downlink-carrier.i18n.yaml | 6 --- ...headless-direct-core-entry-point.i18n.yaml | 4 +- ...-08-09-headless-direct-core-entry-point.md | 16 +++--- ...-09-headless-direct-core-entry-point.zh.md | 16 +++--- ...2026-08-10-remote-event-delivery.i18n.yaml | 4 +- .../2026-08-10-remote-event-delivery.md | 11 ++-- .../2026-08-10-remote-event-delivery.zh.md | 11 ++-- ...-unary-apiproxy-remote-migration.i18n.yaml | 4 +- ...6-08-10-unary-apiproxy-remote-migration.md | 18 ++++--- ...8-10-unary-apiproxy-remote-migration.zh.md | 18 ++++--- ...sion-history-and-event-transport.i18n.yaml | 4 +- ...-18-session-history-and-event-transport.md | 8 +-- ...-session-history-and-event-transport.zh.md | 8 +-- ...-24-browser-token-authentication.i18n.yaml | 4 +- ...2026-08-24-browser-token-authentication.md | 2 +- ...6-08-24-browser-token-authentication.zh.md | 2 +- ...-bounded-cold-blank-verification.i18n.yaml | 4 +- ...6-08-13-bounded-cold-blank-verification.md | 2 +- ...8-13-bounded-cold-blank-verification.zh.md | 2 +- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 2 +- ...-image-input-and-durable-attachments.zh.md | 2 +- .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 2 +- .../2026-07-27-web-session-search.zh.md | 2 +- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 4 +- ...026-07-27-web-subagent-conversations.zh.md | 4 +- ...28-todo-plan-clears-on-next-turn.i18n.yaml | 4 +- ...026-07-28-todo-plan-clears-on-next-turn.md | 2 +- ...-07-28-todo-plan-clears-on-next-turn.zh.md | 2 +- ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 2 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 2 +- ...mission-default-for-new-sessions.i18n.yaml | 4 +- ...-31-permission-default-for-new-sessions.md | 2 +- ...-permission-default-for-new-sessions.zh.md | 2 +- ...default-model-follows-the-picker.i18n.yaml | 4 +- ...-08-07-default-model-follows-the-picker.md | 4 +- ...-07-default-model-follows-the-picker.zh.md | 4 +- ...026-08-10-web-session-log-export.i18n.yaml | 4 +- .../2026-08-10-web-session-log-export.md | 4 +- .../2026-08-10-web-session-log-export.zh.md | 4 +- ...fig-solution-root-two-aggregates.i18n.yaml | 4 +- ...2-tsconfig-solution-root-two-aggregates.md | 2 +- ...sconfig-solution-root-two-aggregates.zh.md | 2 +- ...remotes-generated-contract-build.i18n.yaml | 4 +- ...08-api-remotes-generated-contract-build.md | 2 +- ...api-remotes-generated-contract-build.zh.md | 2 +- ...08-08-copy-only-preset-authoring.i18n.yaml | 4 +- .../2026-08-08-copy-only-preset-authoring.md | 2 +- ...026-08-08-copy-only-preset-authoring.zh.md | 2 +- ...ency-swaps-rejected-by-nih-audit.i18n.yaml | 4 +- ...-dependency-swaps-rejected-by-nih-audit.md | 2 +- ...pendency-swaps-rejected-by-nih-audit.zh.md | 2 +- AGENTS.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/web/tests/README.i18n.yaml | 4 +- apps/web/tests/README.md | 4 +- apps/web/tests/README.zh.md | 2 +- apps/web/tests/replay-round-trip.e2e.ts | 2 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 8 +-- docs/api-gateway.zh.md | 8 +-- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 33 ++++++------ docs/capability-seams.zh.md | 33 ++++++------ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 54 ++++++++----------- docs/config-catalog.zh.md | 52 ++++++++---------- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 27 +++++----- docs/module-graph.zh.md | 27 +++++----- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 6 +++ docs/subsystems/session.zh.md | 6 +++ docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 6 +++ docs/subsystems/settings.zh.md | 6 +++ docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 17 +++--- docs/subsystems/typert.zh.md | 17 +++--- docs/subsystems/web-client.i18n.yaml | 4 +- docs/subsystems/web-client.md | 4 +- docs/subsystems/web-client.zh.md | 4 +- docs/subsystems/web-server.i18n.yaml | 4 +- docs/subsystems/web-server.md | 2 +- docs/subsystems/web-server.zh.md | 2 +- packages/AGENTS.md | 2 +- packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 5 +- packages/api/README.zh.md | 5 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 6 +-- packages/api/gateway/README.zh.md | 6 +-- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 6 +-- packages/api/remotes/README.zh.md | 6 +-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 3 +- packages/api/session-controller/README.zh.md | 3 +- .../api/settings-controller/README.i18n.yaml | 4 +- packages/api/settings-controller/README.md | 2 +- packages/api/settings-controller/README.zh.md | 2 +- packages/client/AGENTS.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 10 ++-- packages/client/connection/README.zh.md | 10 ++-- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 4 +- packages/client/ui-deliverables/README.zh.md | 4 +- packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 11 ++-- packages/host/README.zh.md | 11 ++-- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- 154 files changed, 464 insertions(+), 444 deletions(-) create mode 100644 .agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml rename .agents/notes/{implemented => archived}/architecture/2026-07-19-gui-layering-and-rpc-protocol.md (99%) rename .agents/notes/{implemented => archived}/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md (99%) create mode 100644 .agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml rename .agents/notes/{implemented => archived}/architecture/2026-08-04-websocket-downlink-carrier.md (99%) rename .agents/notes/{implemented => archived}/architecture/2026-08-04-websocket-downlink-carrier.zh.md (99%) delete mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml diff --git a/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml new file mode 100644 index 0000000000..cce84b1ef3 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.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/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +2026-07-19-gui-layering-and-rpc-protocol.md: b27d8d024612d890819bfca9b43c0c81464dfdd3 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 3cf4ba6421c7332c1f8cebb61656a1546f3ad45f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md rename to .agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 372bf49260..b27d8d0246 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -1,6 +1,7 @@ # Agent Note: GUI layering and the RPC protocol — host/client layering by capability provider, the four-quadrant message model, and the fetch carrier Status: implemented +Archived: 2026-08-27 English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md rename to .agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index ecce57c01c..3cf4ba6421 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -1,6 +1,7 @@ # Agent Note: GUI 分层与 RPC 协议——host/client 按能力提供方分层、四象限消息模型与 fetch 载体 Status: implemented +Archived: 2026-08-27 [English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 diff --git a/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml new file mode 100644 index 0000000000..aad9bee1cc --- /dev/null +++ b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.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/archived/architecture/2026-08-04-websocket-downlink-carrier.md +2026-08-04-websocket-downlink-carrier.md: 5edcdd95cf2845d455a61930a9fc00e7e57e72eb +2026-08-04-websocket-downlink-carrier.zh.md: 213697effb8655583e7c420e58acfd171261a8d8 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md rename to .agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.md index 420a0d30f3..5edcdd95cf 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md +++ b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.md @@ -1,6 +1,7 @@ # Agent Note: WebSocket carrier for browser downlinks Status: implemented +Archived: 2026-08-27 English | [中文](2026-08-04-websocket-downlink-carrier.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md rename to .agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.zh.md index 5f277a4ed3..213697effb 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md +++ b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.zh.md @@ -1,6 +1,7 @@ # Agent Note: 浏览器下行 WebSocket 载体 Status: implemented +Archived: 2026-08-27 [English](2026-08-04-websocket-downlink-carrier.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 479a150d56..a770e0ac92 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -25,6 +25,9 @@ "architecture/2026-07-05-windows-fs-permissions.i18n.yaml": "sha256:7e61ee9bbd9de4bf3285a6f250d9625bd062e5fb90279dbffd64c820f1f7fe6b", "architecture/2026-07-05-windows-fs-permissions.md": "sha256:03734da511eae3b0736f7cad73d9da76ae2f69f9d5ed09089b0121ccb135a861", "architecture/2026-07-05-windows-fs-permissions.zh.md": "sha256:454848057ea905fe76c88d17264e71e71fb685f08f82088de6976878372865c3", + "architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml": "sha256:855477999c84236430dc9308e16797eb21658a67a72b8537315c6964ff0c0c0a", + "architecture/2026-07-19-gui-layering-and-rpc-protocol.md": "sha256:3517f37e98e74865dced37d5e1559d443e8fa827031c8335e99a1e910586e9ac", + "architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md": "sha256:8181386d957fa6d6b3eb9b05d29adb10804b5a926853425415d368cc7fceaefa", "architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml": "sha256:1b4822af5c8d642b73e3a0b04fb0a1dea9f50d0147046fbef53f5e49c030fb91", "architecture/2026-07-22-tui-interactive-extension-service.md": "sha256:ca6b2774f4821e66f7c8397f20fcd34926728ded853fa48cbe451db7a8d2f883", "architecture/2026-07-22-tui-interactive-extension-service.zh.md": "sha256:5b060c7626ee796c27108be7467a5e4be0677d7525d383336e7ec31ddce5c303", @@ -43,6 +46,9 @@ "architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml": "sha256:af071e07bce5d9bc8f3df65fed9dcd9b3779a98c5864badbd530363bda021b55", "architecture/2026-07-28-dsh-native-typescript-source-launch.md": "sha256:1b56e3454277ace713e2a01c4da538c756c45bf633fd24d7b16443d584afac5d", "architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md": "sha256:8c0f97472c2c89d2c19ae5cfa68c6e67f32b50960b08b60b46496f78ea6ffad1", + "architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml": "sha256:b9d742d068a0e36df2f3030f6a04a3638f3461f7e10b50ed0cd6bd5e85de5019", + "architecture/2026-08-04-websocket-downlink-carrier.md": "sha256:b9be27a4cda8abd410c6e8b728c571f96b4891eb003c5200e9a0ffbbc9145b42", + "architecture/2026-08-04-websocket-downlink-carrier.zh.md": "sha256:118b71b33710a7a3d28375c48b42c1ec19993ca7e13f286dd6ea64f934456f46", "architecture/2026-08-11-plugin-settings-tabs.i18n.yaml": "sha256:0365da2b317fc5f94dd190064198565f4c624afc91d2e62161ab9170f79d11bc", "architecture/2026-08-11-plugin-settings-tabs.md": "sha256:fdd92cfe55b6c4cd31b3f768dd46a2ecf129a04c9818249cbdd33857cf722bbf", "architecture/2026-08-11-plugin-settings-tabs.zh.md": "sha256:8993df1a0178aba1ea35c460ee67c522900344a4b386287bba9dfac2bfb87efa", diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml deleted file mode 100644 index 38e07804b7..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 372bf4926011835999ebae9b1e2d1f5beb8eb663 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: ecce57c01c155c2a0b19b7729da13c39d1a520a6 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index b80381f031..e48351a2ef 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 4448fd5c6871d67b30b71cfe4377682639704235 -2026-07-19-gui-web-client-architecture.zh.md: e8a8121a1a495db7f5392e288f3bcada92c70495 +2026-07-19-gui-web-client-architecture.md: 703bf2b873eee8afc7e13f89acba99b06fc98745 +2026-07-19-gui-web-client-architecture.zh.md: d61cc8119929b3efc912221df3343918e4851308 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 4448fd5c68..703bf2b873 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-19-gui-web-client-architecture.zh.md) -> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol note](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots. +> Division of labor: the historical channel-independent layering model and RPC protocol are recorded in the [archived layering and RPC protocol note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots. ## Problem @@ -17,7 +17,7 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon ``` ┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐ │ sessions/agents/SessionLog │ │ client cordis root ctx │ -│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ +│ Connection + Gateway: RPC/events│◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ │ webserver: │ │ ├ immediately entries: connection/runtime/ │ │ ├ GET /plugins//client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │ │ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │ @@ -42,7 +42,7 @@ Implementation homes: registry core and the props-share types live in `packages/ ## Services and scope addressing -A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). +A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (RPC transport + generation state), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)). @@ -53,7 +53,7 @@ There is no component registration model besides slots — the former view and t Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable): ``` -mux/host frames (ConnectionController pump, injected sinks) +$events frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. - **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering. -- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering note's territory. +- **ConnectionController** (in `packages/client/connection`): opens the `$events` Remote stream, pumps with for-await, and reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer calls generated namespaces through `ctx.remote`; Web carriage uses HTTP POST for unary Remote calls and API Gateway's WebSocket mux for logical streams, while Connection owns request transport and generations. ## The React face (`packages/client/ui-renderer`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index e8a8121a1a..d61cc81199 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-web-client-architecture.md) | 中文 -> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.zh.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 +> 分工线:历史上的通道无关分层模型与 RPC 协议见[已归档的分层与 RPC 协议笔记](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 ## Problem @@ -17,7 +17,7 @@ Status: implemented ``` ┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐ │ sessions/agents/SessionLog │ │ client cordis root ctx │ -│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ +│ Connection + Gateway: RPC/events│◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ │ webserver: │ │ ├ immediately entries: connection/runtime/ │ │ ├ GET /plugins//client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │ │ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │ @@ -42,7 +42,7 @@ slot 体系有自己的笔记——[slot 体系标准](2026-07-22-slot-type-chai ## 服务与 scope 寻址 -服务是插件对其他插件的唯一 API(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图 slot 注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 住 entry 声明的 store。 +服务是插件对其他插件的唯一 API(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图 slot 注册)。名册:`ctx.connection`(RPC 传输 + generation 状态)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 住 entry 声明的 store。 slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list slot entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.zh.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托 selected call 的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。与 target 无关的事件注册表和视图注册表是数据组装 seam,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.zh.md))。 @@ -53,7 +53,7 @@ slot 之外不存在第二种组件注册模型——原视图环与工具环都 帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言): ``` -mux/host frames (ConnectionController pump, injected sinks) +$events frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 - **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.zh.md)拥有组装边界,[Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.zh.md)拥有 Tool 递归渲染。 -- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.zh.md)载两个 server→client 象限,客户端类族归分层笔记属地。 +- **ConnectionController**(位于 `packages/client/connection`):打开 `$events` Remote 流、通过 for-await 泵入,并在 generation 围栏内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sink 单向注入,Controller 不认识 Session。重连即重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层通过 `ctx.remote` 调用生成的命名空间;Web 载体以 HTTP POST 承载 Remote 一元调用,以 API Gateway 的 WebSocket mux 承载逻辑流,Connection 则拥有请求传输与 generation。 ## React 面(`packages/client/ui-renderer`) diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 445a1264c8..82efeeb49e 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: 1d08aa71378e7e135b67428a1afca2499de7cc60 -2026-07-23-client-plugin-loading-model.zh.md: 8aaa5d38f63d115fa89216d2b37682ba65a1d8cc +2026-07-23-client-plugin-loading-model.md: 21bad78792c6b5aad48b51f454f6c08c0400ad72 +2026-07-23-client-plugin-loading-model.zh.md: 2758f28f3bd34131ece3bed74152fbfe0b36174e diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 1d08aa7137..21bad78792 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -98,7 +98,7 @@ One governance implementation runs on both sides of the wire; the browser-specif Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, generated one-resource responses, current startup combo responses, and one previous startup generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition. -Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding belongs to the connection node half over `api-gateway` (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch plus SSE channel belongs to the hmr node half. +Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding, browser authentication, RPC envelopes, and exact Fetch routes belong to the Connection node half, while Remote dispatch belongs to API Gateway and the dev bundle watch plus SSE channel belongs to the hmr node half. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 8aaa5d38f6..2758f28f3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -98,7 +98,7 @@ Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、生成的单资源响应、当前启动 combo 响应及上一代启动响应,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。 -名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定属于 connection node 半,并经 `api-gateway`(由 `dsh-host-apiproxy` 提供 `ctx.apiProxy`);开发期 bundle 监视与 SSE 通道属于 hmr node 半。 +名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 983d460bb0..e935dd601a 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 3d1ccc2a0f71411d496466288934d9038425e7cf -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c2409e128dfdbd8550bb7052a7e0f67a40fe1d1f +2026-07-24-web-config-tree-boot-and-transport-layering.md: c44f68bb65758ec4879da8bded60f27f2f11e0bc +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 80f385a0a76a427c8f604a0df76decf77d2e62b0 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 3d1ccc2a0f..c44f68bb65 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -16,9 +16,9 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. -**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. +**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Session Controller consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. Its socket-backed Node HTTP entry may apply configured gzip through maintained middleware without adding a response-writing service method or changing route owners; the Web Worker tunnel carries identity bytes. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**Transport responsibilities have explicit owners.** `dsh-client-connection` owns the `/api` route, request and response envelopes, browser authentication, Host/Origin checks, exact Fetch route registration, and the shared Typert interceptor seat. `dsh-api-gateway` owns typed Remote dispatch and the multiplexed WebSocket. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. Its socket-backed Node HTTP entry may apply configured gzip through maintained middleware without adding a response-writing service method or changing route owners; the Web Worker tunnel carries identity bytes. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. @@ -33,7 +33,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) | Rejected | One-line reason | |---|---| | Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct entry points | -| Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls | +| Runtime `assembly` shim plugin providing an `apiHandler` service | Connection already composes Remote interception and feature-owned exact Fetch routes into one handler at the transport edge | | Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too | | A bespoke `./impl` export on the modules package | Non-uniform exports; the standard `./client` carries the whole browser half | | dev overlay / `cordis.dev.yml` | One yml; `!!js` cannot conditionalize row existence, and `--dev` appending one row is the entire difference | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index c2409e128d..80f385a0a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -16,9 +16,9 @@ Status: implemented **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 -**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.zh.md)与 Web 网关消费同一份状态。 +**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.zh.md)与 Session Controller 消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。其基于 socket 的 Node HTTP 入口可以通过受维护的中间件应用已配置的 gzip,无需新增响应写出服务方法或改变 route 所有者;Web Worker 隧道传递 identity 字节。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输职责各有明确 owner。** `dsh-client-connection` 持有 `/api` 路由、请求与响应 envelope、浏览器认证、Host/Origin 检查、精确 Fetch 路由注册以及共享 Typert interceptor 席位。`dsh-api-gateway` 持有类型化 Remote 分发和多路复用 WebSocket。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。其基于 socket 的 Node HTTP 入口可以通过受维护的中间件应用已配置的 gzip,无需新增响应写出服务方法或改变 route owner;Web Worker 隧道传递 identity 字节。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)持有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换)node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由持有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 @@ -33,7 +33,7 @@ Status: implemented | 弃案 | 一行理由 | |---|---| | 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接入口 | -| 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住运行时;本体迁入 apiproxy 后网关可自承载,且 `toFetchHandler` 是绑定方自己调的纯函数 | +| 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | Connection 已在传输边缘把 Remote interception 与功能自有的精确 Fetch 路由组合成一个 handler | | 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 | | modules 包特设 `./impl` 出口 | 出口不统一;标准 `./client` 承载完整浏览器半 | | dev overlay / `cordis.dev.yml` | 一套 yml;`!!js` 无法条件化行存在性,`--dev` 追加一行就是全部差异 | diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index 5d8fa99508..d4ca06ac71 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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-07-28-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: 4401dc8e361281b1239eb84319fc5353948bc217 -2026-07-28-api-browser-trust-boundary.zh.md: c1db2632a6019585ab6a948bcb0f4f2ef853b8a7 +2026-07-28-api-browser-trust-boundary.md: 397c93e084f84579ce3f90654f514428e696ffcd +2026-07-28-api-browser-trust-boundary.zh.md: 6d3896fd2c1fd6c607b9d92376eee76ab03df3c7 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 4401dc8e36..397c93e084 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -12,7 +12,7 @@ The web GUI host serves `/api` over plain loopback HTTP (default `127.0.0.1:3080 Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves: -- **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. +- **Media-type fence (dsh-client-connection)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. - **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence. Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index c1db2632a6..6d3896fd2c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -12,7 +12,7 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI 在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——分为两部分: -- **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 +- **媒体类型栅栏(dsh-client-connection)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 - **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index a72957b18a..d09e7e13cd 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 423fec3ad517e645f1cdee3513bad312a56989a8 -2026-07-28-directory-picker-capability-seam.zh.md: f652157fc152dee44a50ab8b55cc6120d92a1a26 +2026-07-28-directory-picker-capability-seam.md: 10ab8e393d5d33da6a09af5caa4024f02c57165c +2026-07-28-directory-picker-capability-seam.zh.md: 8764dc208bef1ead3fc1ef608c4d3b2b37400602 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 423fec3ad5..10ab8e393d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -10,9 +10,9 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p ## Decision -A three-package capability seam in `packages/host/` — `directory-picker` (Service Definition), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. +A three-package capability seam in `packages/host/` — `directory-picker` (Service Definition), `directory-picker-native`, `directory-picker-browse` (backends) — has one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. `DirectoryPickerController` in `dsh-api-workspace-controller` injects `directoryPicker`, serves the matching generated Remote methods, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. -**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, retryable error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. +**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `directoryPicker/pick`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, retryable error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. Placement and policy rulings folded into this decision: @@ -31,7 +31,7 @@ Placement and policy rulings folded into this decision: - **Extend `ctx.fs` with browse methods.** Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam. - **One uniform Service Definition method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. -- **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. +- **Direct stdlib calls inside the API adapter (no seam).** Rejected: keeps the adapter the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. - **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. - **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. @@ -43,6 +43,6 @@ Placement and policy rulings folded into this decision: ## Consequences - `cordis.yml` chooses the interaction; `apps/cli` mounts the [`-auto` chooser](../feature/2026-07-29-directory-picker-adaptive-default.md), which resolves the host's situation at boot and mounts `-native` or `-browse` itself, one row still swapping backend and UI together; composing a backend row directly pins the interaction. -- The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests. +- The wire exposes generated `directoryPicker/list` and `directoryPicker/createDirectory` methods with four error codes; the Connection fixture serves a deterministic browse tree and `directoryPicker/pick` result for keyless assembled tests. - A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index f652157fc1..8764dc208b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -10,9 +10,9 @@ web GUI 的「打开本地文件夹」流程被焊死在一种交互上:`host. ## 决策 -在 `packages/host/` 落一个三包能力 seam——`directory-picker`(Service Definition)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一约定方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 +在 `packages/host/` 落一个三包能力 seam——`directory-picker`(Service Definition)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一约定方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。`dsh-api-workspace-controller` 中的 `DirectoryPickerController` 注入 `directoryPicker`,提供匹配的生成 Remote 方法,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 -**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 约定相同、占用者相同)。后端包是**双面包**:浏览器一侧把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、可重试的错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 +**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 约定相同、占用者相同)。后端包是**双面包**:浏览器一侧把匹配的交互注册进两个洞——`-native` 是驱动 `directoryPicker/pick` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、可重试的错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 并入本决策的位置与策略裁决: @@ -31,7 +31,7 @@ web GUI 的「打开本地文件夹」流程被焊死在一种交互上:`host. - **给 `ctx.fs` 增加浏览方法。** 否决:上述权限域耦合;且面向展示的列举约定(hidden 标志、面包屑、home 锚点)不属于存储 seam。 - **统一的 Service Definition 方法集(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而原生选择器实现不了原语。交互差异不可约,故用判别标签。 -- **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture(测试前置数据)/测试后端,与促成这项工作的插件教义相悖。 +- **API adapter 里直接调标准库(不建 seam)。** 否决:换装点仍是改 adapter 源码,失去 fixture(测试前置数据)/测试后端,与促成这项工作的插件教义相悖。 - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的「显示隐藏」开关(「隐藏隐藏文件」)。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 @@ -43,6 +43,6 @@ web GUI 的「打开本地文件夹」流程被焊死在一种交互上:`host. ## 后果 - `cordis.yml` 决定交互形态;`apps/cli` 挂 [`-auto` 选择器](../feature/2026-07-29-directory-picker-adaptive-default.zh.md),它在启动时判定宿主处境并自行挂载 `-native` 或 `-browse`,一行仍同时切换后端与 UI;直接组合某个后端行即固定交互。 -- 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。 +- 协议公开生成的 `directoryPicker/list` 与 `directoryPicker/createDirectory` 方法及四个错误码;Connection fixture 提供确定性浏览树与 `directoryPicker/pick` 结果供无密钥组装测试使用。 - 未来的新交互(或提供 `native` 交互的 Electron 提供方)只是一个双面后端包——无需网关手术,也不动 ui-workspace。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml index 35802e4e80..8baf8386d1 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.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-07-29-projected-token-usage-and-request-context.md -2026-07-29-projected-token-usage-and-request-context.md: 063f2300f378f6f7763bce87b11add5da3093230 -2026-07-29-projected-token-usage-and-request-context.zh.md: 37b8741d09e9ec56f6b9f273e05460b2deb4f6f9 +2026-07-29-projected-token-usage-and-request-context.md: 75a05e5a0e8f0183fef1e7d80701ce6d81041cd6 +2026-07-29-projected-token-usage-and-request-context.zh.md: 7cce5989d719156f1d66c48937780ff8aed02a42 diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md index 063f2300f3..75a05e5a0e 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md @@ -40,7 +40,7 @@ The non-atomicity is deliberate, not a defect. A consumer that genuinely needs a **An atomic request-boundary snapshot delivered as a transient mux frame (implemented, then rejected).** An earlier revision emitted `session/model-request`: one non-replayable frame carrying `contextTokens` and `contextWindow` measured at the same `agent/model-request` boundary. Being the only non-replayable class on the mux stream is what broke it. Host and mux are independent SSE streams with no cross-stream ordering, so a request emitted before a removal could arrive after `host/session-removed` and revive a dead session's telemetry, while a legitimate request for a new lifecycle reusing the same id could be fenced by a late removal. `session/subscribed` is not lifecycle proof — it says a queue began subscribing to an id, not that a new in-memory session replaced an older one — and `lastSeq` is a durable watermark two lifecycles can share. A correct fix required a monotonic lifecycle generation on the frame, on subscription, and on removal, plus a client watermark comparison. -That cost bought a worse display: occupancy went blank after every reconnect and never moved while a conversation grew. It also made ApiProxy a measurement site calling the O(surface) `measure()` on every request, and expressed reconnect state through a synthetic `cancelled` open error the UI had to special-case. +That cost bought a worse display: occupancy went blank after every reconnect and never moved while a conversation grew. It also made the transport adapter a measurement site calling the O(surface) `measure()` on every request, and expressed reconnect state through a synthetic `cancelled` open error the UI had to special-case. **Fold the loaded node window in React.** Cannot survive pagination or compaction, and makes a presentation package reconstruct log semantics. @@ -58,4 +58,4 @@ Token totals stay stable across pagination, compaction, replay, restart, and rec Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary. -Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. +Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md index 37b8741d09..7cce5989d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -40,7 +40,7 @@ Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节 **以临时 mux 帧交付请求边界上的原子快照(已实现,随后否决)。** 较早的一个修订版会发出 `session/model-request`:一个不可回放的帧,携带在同一个 `agent/model-request` 边界测得的 `contextTokens` 与 `contextWindow`。真正让它失效的,是它成了 mux 流上唯一的不可回放类别。Host 流与 mux 流是两条独立的 SSE(Server-Sent Events)流,彼此之间没有顺序保证:在移除之前发出的请求可能在 `host/session-removed` 之后才到达,让一个已死会话的遥测数据复活;而复用同一 id 的新生命周期的合法请求,又可能被一条迟到的移除拦下。`session/subscribed` 不能证明生命周期:它只说明某个队列开始订阅某个 id,而不说明新的内存会话替换了较早的会话;`lastSeq` 则是两个生命周期可以共用的持久水位线。正确的修法需要在帧上、订阅上和移除上都带一个单调递增的生命周期代次,再加上一次客户端水位线比较。 -这份代价换来的是更差的显示:占用率在每次重连后变为空白,而且会话增长期间从不移动。它还把 ApiProxy 变成一个测量点,每个请求都要调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处理的、连接打开时的合成 `cancelled` 错误来表达重连状态。 +这份代价换来的是更差的显示:占用率在每次重连后变为空白,而且会话增长期间从不移动。它还把传输适配器变成一个测量点,每个请求都要调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处理的、连接打开时的合成 `cancelled` 错误来表达重连状态。 **在 React 中归并已加载的节点窗口。** 无法跨分页或压缩保留数据,还会迫使展示包重建日志语义。 @@ -58,4 +58,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定, 占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。 -每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。 +每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 21995b0838..ad1a5c6eb1 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 97352a0e3376ce1fe26bac62b88c2c674202adc7 -2026-08-03-per-session-agent-presets.zh.md: b62baf3e3097ba99247c2e86cc3fb5b7e43e2fab +2026-08-03-per-session-agent-presets.md: 9d5fffbd4d69713fe733235cc0352bc93c9ce55c +2026-08-03-per-session-agent-presets.zh.md: 406d546828489ccd172205cde7d4b5e0ba96a39b diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 97352a0e33..9d5fffbd4d 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -55,9 +55,9 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Connection authenticates authoring, `list`, `select`, and the complete Host API with one browser session: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability, while choosing a preset grants nothing `session.create` with `agentPreset` did not already grant. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. -**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `SubagentRuntime` is a Host row that exposes the browser's cross-session Remote queries (`list`, `prompt`), so it waited forever for a service only sessions provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. -**A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. +**A real-composition test that disables a host row cannot audit that row.** The web composition test keeps API Gateway and the domain Remote services active while disabling the transport-only webserver, Connection, Session export, asset, and telemetry rows. With the browse directory picker substituted, its startup audit covers the host-plane service graph without binding a port. **A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it. @@ -67,7 +67,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright. -**A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `dsh-host-apiproxy` discards the `AgentHandle` it creates, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one. +**A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `ApiSessionAgentController` discards the `AgentHandle` returned by the registry, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one. - Remaining TODO: idle agent eviction — dispose after the session is persisted and re-mount on resume. It belongs to the host that owns the handle, not to this seam. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index b62baf3e30..406d546828 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -56,9 +56,9 @@ Status: implemented **创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。Connection 用一个浏览器会话认证创作操作、`list`、`select` 与完整 Host API:组装指明一个会话所运行的插件,因此读取它是侦察,写入它是任意能力;选择 preset 则没有授予 `session.create` 携带 `agentPreset` 时尚未拥有的能力。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 -**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.zh.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`SubagentRuntime` 是 Host 行,它公开浏览器的跨会话 Remote 查询(`list`、`prompt`),因而永远等待一个只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.zh.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 -**真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 +**真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试保持 API Gateway 与各业务 Remote 服务启用,同时禁用只承载传输的 webserver、Connection、Session export、资源与遥测行。替换为 browse 目录选择器后,其启动审计无需绑定端口即可覆盖 Host 平面的服务图。 **preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。 @@ -68,7 +68,7 @@ Status: implemented **这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。 -**preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`dsh-host-apiproxy` 创建后直接丢弃 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。 +**preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`ApiSessionAgentController` 会丢弃注册表返回的 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。 - 遗留 TODO:idle agent 驱逐——会话持久化后 dispose,恢复时重新挂载。它属于持有 handle 的那个宿主,不属于本 seam。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml deleted file mode 100644 index 8ded320f05..0000000000 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-08-04-websocket-downlink-carrier.md -2026-08-04-websocket-downlink-carrier.md: 420a0d30f31cca58448adc0afd46a5d6d5e9107f -2026-08-04-websocket-downlink-carrier.zh.md: 5f277a4ed33ffe97b51587fef960746b93eff1c1 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index 12f13004b8..d5eccf48bc 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.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-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: 9c17b8d418924c38174b4d958fd54b057b118019 -2026-08-09-headless-direct-core-entry-point.zh.md: d95978a832d52b26b1139cabb4b23ade93ce0da3 +2026-08-09-headless-direct-core-entry-point.md: 0234f8b7843be172eafe8bd4c38b3544f5cfb07b +2026-08-09-headless-direct-core-entry-point.zh.md: f864c94d69adc67fa0d3836af834e1ebde151063 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index 9c17b8d418..0234f8b784 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -6,21 +6,21 @@ English | [中文](2026-08-09-headless-direct-core-entry-point.zh.md) ## Problem -The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, no listening port, and the stderr reasoning projection owned by [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md). A composition containing Workspace Host services, ApiProxy, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. +The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, no listening port, and the stderr reasoning projection owned by [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md). A composition containing Workspace Host services, browser RPC, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. The direct entry point still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. ## Decision -The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the Code Mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. +The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the Code Mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no browser Connection, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. `headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. [Headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns the live stderr projection; a terminal `error` reason writes its durable code and message there, and unexpected driver failures also use stderr and exit 1. -`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy entry points consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. +`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct creation and Session Controller Remote calls consume this service; the Session Controller owns session-local precedence, model validation, and persistence of accepted Web selections. `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. -This note owns the headless transport and completion contracts; [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns successful stderr output. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. +This note owns the headless transport and completion contracts; [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns successful stderr output. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. ## Verification @@ -31,14 +31,14 @@ Package tests use the real Session store and Agent registry around a scripted Ag | Alternative | Contract mismatch | |---|---| | Keep `dsh-web-app` but suppress its observation line | The process still opens a port and carries the Host, Web, and browser trees. | -| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot entry point has no client boundary. | -| Use `InProcessApiClient` for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | +| Build a Host-only one-shot bundle around browser RPC | A local one-shot entry point has no client boundary. | +| Use the in-process Connection carrier for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | | Give headless a separate provider/model config | Direct and Web creation would have independent defaults and persistence. | | Omit Code Mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | | Normalize every tuple containing Web and headless bundles | Bundle lists are an extension surface; only the exact installation-owned tuple is safe to classify. | ## Consequences -`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Text-only successful runs leave stderr empty, reasoned runs stream the provider-reported content there, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. +`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Text-only successful runs leave stderr empty, reasoned runs stream the provider-reported content there, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no browser request id. -ApiProxy carrier coverage stays in the ApiProxy package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free. +Connection carrier coverage stays in the Connection package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free. diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index d95978a832..f864c94d69 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -6,21 +6,21 @@ Status: implemented ## 问题 -`headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,不打开监听端口,并由 [headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责 stderr 推理投影。包含 Workspace Host 服务、ApiProxy、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。 +`headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,不打开监听端口,并由 [headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责 stderr 推理投影。包含 Workspace Host 服务、浏览器 RPC、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。 直接入口仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 ## 决策 -随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 Code Mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含任何 `@deepseek-ai/dsh-host-*` 包、ApiProxy、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 +随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 Code Mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含浏览器 Connection、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 `headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。[Headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责实时 stderr 投影;结束原因为 `error` 时,其持久化错误码与消息写入 stderr,驱动器的意外失败也写入 stderr 并以 1 退出。 -`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接入口与 ApiProxy 入口均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 +`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接创建与 Session Controller Remote 调用均消费该服务;Session Controller 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 -本 Agent Note 负责 headless 的传输与完成约定;[headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责成功运行时的 stderr 输出。[应用持有自己的命令行](2026-08-06-app-owned-command-line.zh.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.zh.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.zh.md)负责共享 Agent 默认值的持久化。 +本 Agent Note 负责 headless 的传输与完成约定;[headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责成功运行时的 stderr 输出。[应用持有自己的命令行](2026-08-06-app-owned-command-line.zh.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.zh.md)负责共享 Agent 默认值的持久化。 ## 验证 @@ -31,14 +31,14 @@ Status: implemented | 替代方案 | 约定不匹配之处 | |---|---| | 保留 `dsh-web-app`,但隐藏观察行 | 进程仍会打开端口并携带 Host、Web 与浏览器插件树。 | -| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性入口没有客户端边界。 | -| 使用 `InProcessApiClient` 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | +| 围绕浏览器 RPC 构建纯 Host 一次性组合包 | 本地一次性入口没有客户端边界。 | +| 使用进程内 Connection carrier 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | | 为 headless 单独提供提供方/模型配置 | 直接创建与 Web 创建会拥有彼此独立的默认值和持久化。 | | 省略 Code Mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | | 规范化所有包含 Web 与 headless 组合包的元组 | 组合包列表是扩展面;只有精确的安装过程所属元组可以安全分类。 | ## 后果 -`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。没有推理内容的成功运行会保持 stderr 为空,有推理内容的运行则在那里流式输出提供方报告的内容;完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 +`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。没有推理内容的成功运行会保持 stderr 为空,有推理内容的运行则在那里流式输出提供方报告的内容;完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带浏览器 request id。 -ApiProxy 载体覆盖保留在 ApiProxy 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 +Connection carrier 覆盖保留在 Connection 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 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 33a31a1363..a1f574e789 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: 5e6e04bdf2c6b685bbf10f05ede9c96ea8104429 -2026-08-10-remote-event-delivery.zh.md: d744b92d47f5778397b519fa09d4b91f620dcd7e +2026-08-10-remote-event-delivery.md: 4b9c2f224e36fb97f798492c999726eb55bad214 +2026-08-10-remote-event-delivery.zh.md: 4ca1f8c244df4e985d1223b6c468b450be943abf 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 5e6e04bdf2..4b9c2f224e 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 @@ -70,9 +70,9 @@ $on(event: Event, listener: TypertClientEventLi **The contract exposes only the consumer verb.** `ClientRemoteService` registers the one internal `$events` pump as a Connection generation source when it activates, independently of whether any `$on` subscription exists. Browsers open `$events` through the shared Remote mux; in-process compositions open the same logical stream through `connection.rpc.open`. Decoding, exact item validation, and Cordis dispatch are private Gateway Client implementation. `TypertClientRemote` exposes no producer operation, so a business plugin cannot synthesize a Host event. -Each time the Host opens `$events`, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening `{ type: 'ready' }` before iterating the event source. `ConnectionController` waits for that ready item and `host.describe` in parallel and publishes `connected` only after both succeed, so baseline reads cannot race ahead of incremental listeners. +Each time the Host opens `$events`, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening `{ type: 'ready', clientId, host: { home } }` before iterating the event source. `ConnectionController` publishes `connected` only after that item arrives, so baseline reads cannot race ahead of incremental listeners. -A physical mux disconnect ends the logical stream with `RemoteStreamCarrierError`. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation's `hostDescription` and reopens `$events` and `host.describe` after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat `$on` as a reliable journal. +A physical mux disconnect ends the logical stream with `RemoteStreamCarrierError`. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation and reopens `$events` after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat `$on` as a reliable journal. The Client dispatches on a Cordis key private to each Remote instance. Ordinary `emit` uses `parallel()` and contains listener failures; Agent-scoped `waterfall` uses `waterfall()` on the resolved Agent Context and allows a result, rejection, or `next()` delegation. Both registration kinds belong to the calling fiber, and Host events do not trigger same-named Client-local events. @@ -132,13 +132,13 @@ cancel { type, eventId } The Client opens internal logical stream `$events` with payload `{ args: {} }`. Gateway rejects extra parameters, a missing Host source, and duplicate source registration. Withdrawing a source aborts every stream opened by that registration. Each Client stream owns an independent queue and allowlist listener set in `api/remotes`, so disconnecting one Client neither consumes nor withdraws another Client's events. -The Client requires an opening `ready` item with a non-empty `clientId`; every later item is checked for exact fields by discriminant. An ordinary `emit` with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use `eventId` to correlate `$events/result` and `agentId` to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields. +The Client requires an opening `ready` item with a non-empty `clientId` and `host.home`; every later item is checked for exact fields by discriminant. The ready item establishes the Connection generation and supplies the stable Host path-display fact. An ordinary `emit` with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use `eventId` to correlate `$events/result` and `agentId` to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields. `$events` is an internal Gateway endpoint. It does not enter a generated Typert Remote descriptor or become `ctx.remote.`. Application selection exists only in the API Remotes allowlist and Host source; Gateway owns registration, payload validation, and physical transport only. ### The `apps/web` browser e2e belongs to the Host face -The `apps/web/tests/**` e2e files typecheck in root `tsconfig.host.json`: they boot a real harness in process and directly access `ctx.apiProxy`, Host `SessionStore.get/create/flush`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate produces 21 errors because one program cannot hold both faces' merges for the same Context key. +The `apps/web/tests/**` e2e files typecheck in root `tsconfig.host.json`: they boot a real harness in process and directly access `ctx.connection`, Host `SessionStore.get/create/flush`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate produces errors because one program cannot hold both faces' merges for the same Context key. This implies one build rule needed by the design: importing a value or type from a Client package in those tests brings that package's whole project and all its project references into the Host build graph. Four consumers (`ui-settings-general`, `ui-settings-models`, `ui-permission`, and `ui-commands`) reference API Remotes' Client face, which cannot compile until Host tsdown generates `@deepseek-ai/dsh-goal/remote`. That forms a build-order cycle: Host tsc needs API Remotes Client, which needs generated `goal/remote`, which Host tsdown emits after Host tsc. @@ -153,11 +153,10 @@ The few required Client symbols are mirrored on the test side: `scaffold.ts` exp | `api/remotes` | `src/remote-events.ts` (mode-bearing allowlist value) and `src/types.ts` (key projection and selection) belong to both faces; Host registers each Client source and validates JSON before queueing; Client continues to compose generated Remote contributions | | Root `tsconfig.base.json` | Adds source-plane `paths` entries for `dsh-settings/types`, `dsh-credentials/types`, and `dsh-api-remotes/types` | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | Moves each `interface Events` member to the owner's Client-safe `./types`; settings and credentials add that export, move brands and pure types with it, retain constructors in index, and include `lib/types/**/*.js` in published files | -| `host/apiproxy` | Contains no `HostFrame`, `events.host()`, or other Host downlink carrier; API Proxy does not participate in Host events or Connection generation | | `dsh-session` | Exposes `isJsonValue` for validation of every event argument by the API Remotes Host source | | `client/runtime` | Removes the bridge from Host frames to the Remote subscription table; it only publishes `connection/reset` after a Connection generation is established | | Consumers | Client plugins subscribe directly through `ctx.remote.$on(...)`, import owner event declarations type-only, and inject `'remote'` | -| `client/connection` | Provides the one generation-source registration point; `ConnectionController` combines `$events` ready with `host.describe`, and the fixture emits events from the same source | +| `client/connection` | Provides the one generation-source registration point; `ConnectionController` publishes the Host facts from `$events` ready, and the fixture emits events from the same source | | `apps/web/tests` + `apps/cli` | Mirrors Client symbols on the test side as described above and removes 15 Client project references from `apps/cli/tsconfig.json` | ## Alternatives considered 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 d744b92d47..4ca1f8c244 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 @@ -70,9 +70,9 @@ $on(event: Event, listener: TypertClientEventLi **契约只公开消费动词。**`ClientRemoteService` 激活时就把内部唯一的 `$events` pump 注册为 Connection generation source,与当前有无 `$on` 订阅无关;浏览器通过共享 Remote mux 打开 `$events`,进程内组合通过 `connection.rpc.open` 打开同一 logical stream。解码、精确 item 校验和订阅表派发都是 Gateway Client 的私有实现,`TypertClientRemote` 不暴露生产方方法,因此业务插件不能伪造一条 Host 事件。 -每次 Host 打开 `$events` 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 `{ type: 'ready' }`,再开始迭代事件 source。`ConnectionController` 并行等待该 ready 与 `host.describe`,只有两者都成功才发布 `connected` 并允许 baseline 读取。这个顺序保证 baseline 不会跑在增量 listener 前面。 +每次 Host 打开 `$events` 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 `{ type: 'ready', clientId, host: { home } }`,再开始迭代事件 source。`ConnectionController` 只有在该项到达后才发布 `connected`,因此 baseline 读取不会跑在增量 listener 前面。 -物理 mux 断开会让 logical stream 以 `RemoteStreamCarrierError` 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation 的 `hostDescription`,在退避后重开 `$events` 和 `host.describe`;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 `$on` 当作可靠日志。 +物理 mux 断开会让 logical stream 以 `RemoteStreamCarrierError` 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation,在退避后重开 `$events`;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 `$on` 当作可靠日志。 Client 以 Remote 实例私有 Cordis key 分发。普通 `emit` 使用 `parallel()` 并隔离 listener 失败;Agent-scoped `waterfall` 在解析出的 Agent Context 上使用 `waterfall()`,允许结果、拒绝或 `next()` 委托。两类注册都归属调用方 fiber,且 Host 事件不会触发 Client 本地同名事件。 @@ -132,13 +132,13 @@ cancel { type, eventId } Client 以 endpoint `$events` 和 payload `{ args: {} }` 打开 internal logical stream。Gateway 拒绝额外参数、缺失 Host source 和重复 source 注册;source 被撤回时会中止所有由该注册打开的 stream。每个 Client stream 在 `api/remotes` 中拥有独立队列与一组 allowlist listener,因此一个 Client 断开不会消费或撤销另一个 Client 的事件。 -Client 要求首项是带非空 `clientId` 的 `ready`;后续 item 按 discriminant 精确校验字段。普通 `emit` 的未知但结构合法事件名在没有订阅者时静默丢弃。waterfall 通过 `eventId` 关联 `$events/result`,并由 `agentId` 选择 Client Agent Context;Client 只回传可无损表示为 JSON 的结果,不在 transport 层重复解释业务字段。 +Client 要求首项是带非空 `clientId` 与 `host.home` 的 `ready`;后续 item 按 discriminant 精确校验字段。ready 项建立 Connection generation,并提供稳定的 Host 路径显示信息。普通 `emit` 的未知但结构合法事件名在没有订阅者时静默丢弃。waterfall 通过 `eventId` 关联 `$events/result`,并由 `agentId` 选择 Client Agent Context;Client 只回传可无损表示为 JSON 的结果,不在 transport 层重复解释业务字段。 `$events` 是 Gateway 内部 endpoint,不进入生成的 Typert Remote descriptor,也不成为 `ctx.remote.`。应用选择仍只存在于 `api/remotes` 的 allowlist 和 Host source;Gateway 只拥有注册、payload 校验与物理传输。 ### apps/web 的 browser e2e 属于 Host 面 -`apps/web/tests/**` 那批 e2e 在**根 `tsconfig.host.json`** 做类型检查:它们在进程内起真 harness、直接摸 `ctx.apiProxy`、host `SessionStore.get/create/flush`、`ctx.sessionProjectionCache`。**运行时用浏览器 ≠ 类型上属于 client 程序**——把它们搬进 client 聚合会立刻报 21 条错,因为一个 program 装不下两个 face 对同一个 Context key 的合并。 +`apps/web/tests/**` 那批 e2e 在**根 `tsconfig.host.json`** 做类型检查:它们在进程内起真 harness、直接访问 `ctx.connection`、Host `SessionStore.get/create/flush` 与 `ctx.sessionProjectionCache`。**运行时用浏览器 ≠ 类型上属于 Client 程序**——把它们搬进 Client 聚合会报错,因为一个 program 装不下两个 face 对同一个 Context key 的合并。 由此得到一条对本设计要紧的连带纪律:**这些测试从客户端包 import 值或类型,会把该包的整个 project——以及它引用的每个 project——拖进 Host 构建图**。`ui-settings-general`/`ui-settings-models`/`ui-permission`/`ui-commands` 四个消费者 references `api/remotes` 的 client face,而该 face 必须等 host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 才能编译,于是形成构建期死锁:host tsc → api/remotes client face → `goal/remote` → host tsdown → 排在 host tsc 之后。 @@ -153,11 +153,10 @@ Client 要求首项是带非空 `clientId` 的 `ready`;后续 item 按 discrim | `api/remotes` | `src/remote-events.ts`(带 mode 的名单值)与 `src/types.ts`(键投影 + selection)双列进两个 face;Host 半注册每 Client source,并在入队前校验 JSON;Client 半继续组合生成的 Remote contribution | | 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) | -| `host/apiproxy` | 不包含 `HostFrame`、`events.host()` 或其他 Host 下行 carrier;API Proxy 不参与 Host 事件或 Connection generation | | `dsh-session` | `isJsonValue` 供 `api/remotes` Host source 校验每个事件参数 | | `client/runtime` | 删除 Host frame 到 Remote subscription table 的桥;只继续在 Connection generation 建立后发布 `connection/reset` | | 消费方 | Client 插件直接订阅 `ctx.remote.$on(...)`,type-only 引入 owner 事件声明并把 `'remote'` 加进 `inject` | -| `client/connection` | 提供唯一 generation source 注册位;`ConnectionController` 以 `$events` ready 与 `host.describe` 组成世代握手,fixture 也从同一 source 产生事件 | +| `client/connection` | 提供唯一 generation source 注册位;`ConnectionController` 发布 `$events` ready 携带的 Host 信息,fixture 也从同一 source 产生事件 | | `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 | ## 备选方案 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml index c316742ec1..5868c44f02 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.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-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: 11254099556113d921da502f0150522886a718f3 -2026-08-10-unary-apiproxy-remote-migration.zh.md: 50b303876863e992566f6ed6fb0bd0a89326344f +2026-08-10-unary-apiproxy-remote-migration.md: b98c7ee95b61ec00a5cab3106e812a6f17fc0a15 +2026-08-10-unary-apiproxy-remote-migration.zh.md: 74bca8fd72f3e41075f5a44eba116fe127343bb2 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md index 1125409955..b98c7ee95b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -12,9 +12,9 @@ Agent-bound calls require particular care. Shared lookup policy reuses live Agen ## Decision -Simple unary operations live on their natural business Remote owner. The business package owns the Remote signature and Host adaptation; `@deepseek-ai/dsh-api-remotes/client` selects its generated contribution; the Client package owns presentation joins. The API Proxy retains only `host.describe` and streamed `GET`/`HEAD /api/session.export`. +Simple unary operations live on their natural business Remote owner. The business package owns the Remote signature and Host adaptation; `@deepseek-ai/dsh-api-remotes/client` selects its generated contribution; the Client package owns presentation joins. Connection owns the transport envelope and exact Fetch route registry, and no API Proxy service remains. -| Legacy RPC | Remote destination | Owner and preserved behavior | +| Former API Proxy operation | Destination | Owner and preserved behavior | |---|---|---| | `session.rename` | `sessionTitle/rename` | `SessionTitleService` resolves the Session through the shared lookup policy and returns the title event sequence. | | `command.list`, `command.execute` | `commands/list`, `commands/execute` | `CommandRuntime` preserves Agent lookup, unmatched commands, and caller cancellation. | @@ -31,6 +31,8 @@ Simple unary operations live on their natural business Remote owner. The busines | `skill.list` | `skills/list` | `SessionSkillCatalog` observes the Session and its recorded preset, uses a live Agent only when one already exists, and never activates an Agent for listing. | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` supplies the Session Controller's established Agent lookup to the provider; cold lookup behavior remains unchanged. | | `host.openPath` | `session/openWorkspacePath` | The Session-aware Client resolves relative paths against the known workspace before `SessionController` hands them to the native opener. | +| `host.describe` | `$events` ready frame plus capability queries | API Remotes sends the Host home with generation readiness; Settings and Session controllers report their native-open capabilities when the corresponding page appears. Unused process metadata is not sent. | +| `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` registers an exact Connection Fetch route and streams the ZIP without a JSON Remote envelope. | The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. `TypertLookupFailure` preserves resolver-owned RPC errors instead of collapsing them into `internal`. @@ -38,7 +40,7 @@ The native path implementation lives in `@deepseek-ai/dsh-native-command`. Setti ## Browser authentication -Connection authenticates the complete `/api` request before choosing the Typert interceptor or API Proxy fallback. Remote-owned endpoints and retained API Proxy endpoints therefore require the same browser session and Host/Origin checks. +Connection authenticates the complete `/api` request before choosing a Typert endpoint or exact Fetch route. Remote calls and Session-log downloads therefore require the same browser session and Host/Origin checks. ## Verification @@ -48,12 +50,16 @@ Focused Host and Client tests cover Remote calls, lookup and no-activation polic **Keep simple calls in the API Proxy.** Rejected because it preserves duplicate interfaces, schemas, route rows, stubs, and result projections after a business owner exists. -**Move every unary operation.** Rejected because `host.describe` combines deployment facts and Connection readiness, while Session export is a streamed download rather than a unary business method. +**Keep `host.describe`.** Rejected because one bootstrap call coupled Connection readiness to unrelated process and business facts. The generation-ready frame carries the only lifecycle fact needed immediately, and capability-owning pages query their domains when shown. -**Put native opening in one controller.** Rejected because Session, Settings, and the retained Host description consume the same platform operation. A Host utility avoids controller-to-controller imports and duplicated platform logic. +**Publish every business capability in the generation-ready frame.** Rejected because those values have no common update lifecycle. Only the stable Host home belongs to Connection; each business owner answers its own current capability. + +**Represent Session export as a Remote.** Rejected because the browser download manager consumes a streamed HTTP response rather than a JSON result. An exact registered Fetch route keeps ownership in the feature package without adding a second gateway. + +**Put native opening in one controller.** Rejected because Session and Settings select different authorized targets. A Host utility avoids controller-to-controller imports without making the browser authoritative for filesystem targets. ## Consequences -Business owners and Client consumers each define one side of a unary operation, while Connection retains authentication, transport, and response envelopes. Removing the legacy client timeout is the accepted observable transport change; business results, cancellation, lifecycle policy, filtering, and native-path authority remain owned by their existing domains. +Business owners and Client consumers each define one side of a unary operation, while Connection owns authentication, transport, response envelopes, exact Fetch routes, and generation state. Removing the legacy client timeout is the accepted observable transport change; business results, cancellation, lifecycle policy, filtering, and native-path authority remain owned by their existing domains. Generated Remote artifacts and the explicit API Remotes assembly become required whenever a Remote signature or selected package changes. diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md index 50b3038768..74bca8fd72 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -12,9 +12,9 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由 ## 决策 -简单一元操作归属其自然的业务 Remote owner。业务包持有 Remote 签名与 Host 适配;`@deepseek-ai/dsh-api-remotes/client` 选择其生成贡献;Client 包持有呈现联接。API Proxy 只保留 `host.describe` 与流式 `GET`/`HEAD /api/session.export`。 +简单一元操作归属其自然的业务 Remote owner。业务包持有 Remote 签名与 Host 适配;`@deepseek-ai/dsh-api-remotes/client` 选择其生成贡献;Client 包持有呈现联接。Connection 持有传输 envelope 与精确 Fetch 路由注册表,不再存在 API Proxy 服务。 -| 旧 RPC | Remote 目标 | Owner 与保留行为 | +| 原 API Proxy 操作 | 目标 | Owner 与保留行为 | |---|---|---| | `session.rename` | `sessionTitle/rename` | `SessionTitleService` 通过共享 lookup 策略解析 Session,并返回标题事件序号。 | | `command.list`、`command.execute` | `commands/list`、`commands/execute` | `CommandRuntime` 保留 Agent lookup、未匹配命令与调用方取消。 | @@ -31,6 +31,8 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由 | `skill.list` | `skills/list` | `SessionSkillCatalog` 观察 Session 及其记录的 preset,仅在 live Agent 已存在时使用它,列表查询绝不激活 Agent。 | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` 向 provider 提供 Session Controller 的既有 Agent lookup;冷 lookup 行为保持不变。 | | `host.openPath` | `session/openWorkspacePath` | Session-aware Client 先基于已知 workspace 解析相对路径,再由 `SessionController` 交给原生打开器。 | +| `host.describe` | `$events` ready frame 与 capability 查询 | API Remotes 随 generation readiness 发送 Host home;Settings 与 Session controller 在对应页面显示时报告各自的原生打开能力。不发送无人使用的进程元数据。 | +| `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` 注册精确的 Connection Fetch 路由,并在没有 JSON Remote envelope 的情况下流式传输 ZIP。 | 共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。`TypertLookupFailure` 保留 resolver 持有的 RPC error,而不把它们归并为 `internal`。 @@ -38,7 +40,7 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由 ## 浏览器认证 -Connection 在选择 Typert interceptor 或 API Proxy fallback 前认证完整的 `/api` 请求。因此 Remote 持有的 endpoint 与保留的 API Proxy endpoint 要求相同的浏览器会话和 Host/Origin 校验。 +Connection 在选择 Typert endpoint 或精确 Fetch 路由前认证完整的 `/api` 请求。因此 Remote 调用与 Session 日志下载要求相同的浏览器会话和 Host/Origin 校验。 ## 验证 @@ -48,12 +50,16 @@ Connection 在选择 Typert interceptor 或 API Proxy fallback 前认证完整 **将简单调用留在 API Proxy。** 否决,因为业务 owner 已存在后,这仍会保留重复的 interface、schema、路由行、stub 与结果投影。 -**迁移每一个一元操作。** 否决,因为 `host.describe` 组合部署事实与 Connection readiness,而 Session export 是流式下载,不是一元业务方法。 +**保留 `host.describe`。** 否决,因为一次 bootstrap 调用会把 Connection readiness 与互不相关的进程和业务事实耦合起来。generation-ready frame 只携带立即需要的生命周期事实,各 capability owner 页面在显示时查询自己的当前能力。 -**把原生打开操作放入某个 controller。** 否决,因为 Session、Settings 与保留的 Host 描述都会消费同一平台操作。Host 工具可以避免 controller 间导入与重复的平台逻辑。 +**在 generation-ready frame 中发布所有业务 capability。** 否决,因为这些值没有共同的更新生命周期。只有稳定的 Host home 属于 Connection;各业务 owner 回答自己的当前 capability。 + +**把 Session export 表示为 Remote。** 否决,因为浏览器下载管理器消费流式 HTTP 响应,而不是 JSON 结果。精确注册的 Fetch 路由让功能包持有该行为,同时不引入第二个 gateway。 + +**把原生打开操作放入某个 controller。** 否决,因为 Session 与 Settings 选择不同的授权目标。Host 工具可以避免 controller 间导入,同时不让浏览器成为文件系统目标的权威。 ## 后果 -业务 owner 与 Client consumer 各自定义一元操作的一侧,而 Connection 继续持有认证、传输与响应 envelope。删除 legacy Client timeout 是已接受的可观察传输变化;业务结果、取消、生命周期策略、过滤与原生路径权限仍由既有领域持有。 +业务 owner 与 Client consumer 各自定义一元操作的一侧,而 Connection 持有认证、传输、响应 envelope、精确 Fetch 路由与 generation 状态。删除 legacy Client timeout 是已接受的可观察传输变化;业务结果、取消、生命周期策略、过滤与原生路径权限仍由既有领域持有。 每当 Remote 签名或所选包发生变化,都必须更新生成的 Remote 产物和显式 API Remotes assembly。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index bf8265d9a7..a73eb1a62f 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 3d7c1ae262cca410554bcb6b1a8af35686315ba7 -2026-08-18-session-history-and-event-transport.zh.md: cb1e02de580896a6278bb657e2097b823343ad0b +2026-08-18-session-history-and-event-transport.md: 8f26b2977dceeb2085bf270ae603cd21d48157f5 +2026-08-18-session-history-and-event-transport.zh.md: 10edeff16695cac265f2026b300eb206838a53e4 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 3d7c1ae262..8f26b2977d 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -70,11 +70,11 @@ In-process `connection.rpc.open` uses the same logical endpoint semantics while The Gateway-internal `$events` logical stream is the sole generation source for `ConnectionHandle`. It does not depend on whether any business `$on` subscription exists, so connection health does not vary with the number of UI listeners. -The Host event source installs incremental listeners synchronously before returning its first frame. Gateway then sends `{ type: 'ready' }` with a `clientId`; this frame proves that the current generation can receive increments. +The Host event source installs incremental listeners synchronously before returning its first frame. Gateway then sends `{ type: 'ready', clientId, host: { home } }`; this frame proves that the current generation can receive increments and carries the stable Host path-display fact. -`ConnectionController` waits for `$events` readiness and `host.describe` in parallel. It publishes `connected` only after both complete, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready. +`ConnectionController` publishes `connected` only after `$events` readiness, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready. -Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws `hostDescription`, then re-establishes `$events` and `host.describe` after backoff. +Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws the generation, then re-establishes `$events` after backoff. Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state. @@ -332,7 +332,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re Gateway mux tests pin connection without logical streams, idle residency, configurable Ping/Pong without application messages, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. -Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure. +Connection tests pin missing, duplicate, and withdrawn generation sources, readiness timeout, and generation withdrawal and rebuilding after failure. `RemoteStream` tests pin single consumption, retry reset after opening acceptance, generation-only `restart()`, no retry for terminal errors, and disposal quiescence. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index cb1e02de58..10edeff166 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -70,11 +70,11 @@ Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每 Gateway 内部 `$events` logical stream 是 `ConnectionHandle` 唯一的 generation source。它不依赖是否已有业务 `$on` 订阅,因此连接健康状态不会随 UI listener 数量变化。 -Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送带 `clientId` 的 `{ type: 'ready' }`,该帧证明当前 generation 已经能够接收增量。 +Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送 `{ type: 'ready', clientId, host: { home } }`;该 frame 证明当前 generation 已经能够接收增量,并携带稳定的 Host 路径显示信息。 -`ConnectionController` 并行等待 `$events` ready 与 `host.describe`。两者都完成后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 +`ConnectionController` 只有在 `$events` ready 后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 -`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回 `hostDescription`,退避后重新建立 `$events` 与 `host.describe`。 +`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回该 generation,退避后重新建立 `$events`。 Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 @@ -332,7 +332,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 -Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。 +Connection 测试固定 generation source 缺失、重复注册、撤回、ready 超时,以及 generation 失败后的撤回和重建。 `RemoteStream` 测试固定单 consumer、opening acceptance 后清零 retry、`restart()` 只替换 generation、terminal error 不重试和 dispose quiescence。 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index 6756a4d842..88e2561815 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.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-24-browser-token-authentication.md -2026-08-24-browser-token-authentication.md: c75f561d9529296ff668791c29453e522f309cc3 -2026-08-24-browser-token-authentication.zh.md: d4a5059619fefda9d9060e9879d10c0a2197f8f3 +2026-08-24-browser-token-authentication.md: cdfed1f250de6def39e386cf9fcd2e536c6c97e4 +2026-08-24-browser-token-authentication.zh.md: 29f3a6c0a2d83ed1c17b9c0bc5988c9472741f3f diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index c75f561d95..cdfed1f250 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -24,7 +24,7 @@ The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not im ## Verification -Unit coverage pins process-token retention across Connection reloads, one secret load per activation, synchronous verification without credential-provider reads, cookie attributes, HMAC and payload validation, authority and lifetime checks, record deletion taking effect on the next activation, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. +Unit coverage pins process-token retention across Connection reloads, one secret load per activation, synchronous verification without credential-provider reads, cookie attributes, HMAC and payload validation, authority and lifetime checks, record deletion taking effect on the next activation, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for generic RPC, Typert Remote HTTP, exact Fetch routes, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `settings/describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index d4a5059619..29f3a6c0a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -24,7 +24,7 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` ## 验证 -单元覆盖 Connection 重载时保留进程令牌、每次激活只加载一次密钥、无需读取凭据提供方的同步校验、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、记录删除在下一次激活时生效、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。 +单元覆盖 Connection 重载时保留进程令牌、每次激活只加载一次密钥、无需读取凭据提供方的同步校验、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、记录删除在下一次激活时生效、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定通用 RPC、Typert Remote HTTP、精确 Fetch 路由和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `settings/describe`,观测新的进程令牌,并在重启后复用旧 cookie。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml index 92d2177999..dd1cbe2e66 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.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/bug-fix/2026-08-13-bounded-cold-blank-verification.md -2026-08-13-bounded-cold-blank-verification.md: bf8d167d742001ce65b3e96713a9603adb19603e -2026-08-13-bounded-cold-blank-verification.zh.md: 1418301b8cb3c1452cbed2bdaf974949126079e6 +2026-08-13-bounded-cold-blank-verification.md: cd50d29f0b5d417077d5d848415607885c39474a +2026-08-13-bounded-cold-blank-verification.zh.md: 851b1fb35126a42623e251bf790dde2029189b36 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md index bf8d167d74..cd50d29f0b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md @@ -12,7 +12,7 @@ The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Sess ## Decision -`dsh-host-apiproxy` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`. +`dsh-api-session-controller` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`. A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank. When persistence exposes a physical artifact through `locate()` and its observed size is at most the `coldBlankProbeMaxBytes` eligibility threshold (default 1 KiB per Session), the gateway calls `readFrom(id, 0)` and folds exact list metadata from the stored prefix. Files above the threshold, backends without a location, vanished artifacts, and failed reads all produce `blank: false`, keeping the Session visible. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md index 1418301b8c..851b1fb351 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md @@ -12,7 +12,7 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N ## Decision -`dsh-host-apiproxy` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。 +`dsh-api-session-controller` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。 冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空。当 persistence 通过 `locate()` 暴露物理工件,且其观测大小不超过 `coldBlankProbeMaxBytes` 资格阈值(默认每个 Session 1 KiB)时,网关调用 `readFrom(id, 0)`,从已存前缀折叠精确列表元数据。超过阈值的文件、不提供位置的后端、已消失的工件和读取失败都产生 `blank: false`,让 Session 保持可见。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 951eb4a113..e195ff0f4b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 1dc1ffc3e3c0fdb3b7b084dff91a51209ac80458 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: e1163b43c5c2c8ff9577468a0069fa6956f9fab4 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: cc94357aae24bd0ca20ee73488f14de98ffd8ca7 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 90667c13e2917a77ffd0bcee6386ded690573bcf diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 1dc1ffc3e3..cc94357aae 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -10,7 +10,7 @@ Before this change, the Web composer accepted only text: `InputBar` received a s This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../archived/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. -The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. +The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [archived GUI layering and RPC protocol decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. Peer products converge on an attachment rail above the editor, but their storage choices differ. Codex-style paths such as `/var/folders/.../codex-clipboard-*.png` are reasonable intake staging locations, not durable message identities: the operating system may delete them, another host cannot read them, and a resumed session cannot rely on them. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index e1163b43c5..90667c13e2 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -10,7 +10,7 @@ Status: implemented 这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../archived/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 -[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 +[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[已归档的 GUI 分层与 RPC 协议决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 同类产品普遍在编辑器上方设置附件栏,但存储方案各不相同。诸如 `/var/folders/.../codex-clipboard-*.png` 的 Codex 式路径适合作为接收输入时的暂存位置,却不能作为持久消息身份:操作系统可能删除文件,另一台宿主无法读取文件,恢复后的会话也不能依赖文件仍然存在。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 1e3ba8b79e..2d0ef48750 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.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-07-27-web-session-search.md -2026-07-27-web-session-search.md: e467f4802e5a9b4b6bfef6b3f568b33c0b1fa7a6 -2026-07-27-web-session-search.zh.md: d440f0ce56668f34057959135791e9deca097b14 +2026-07-27-web-session-search.md: bb41028ec4732a602ab1570b22b5c51160692ab2 +2026-07-27-web-session-search.zh.md: 9f03c42cfba018aa379ac2de6f78c6cdbfdb2ee0 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index e467f4802e..bb41028ec4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -16,7 +16,7 @@ The host gateway exposes `session.search` through the existing typed RPC stack. [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. -The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives beside the response schema that enforces it in `dsh-host-apiproxy`, and `SessionRuntime.searchResultLimit` re-exposes that constant for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound that the schema's fixed `max` forbids, and would leave the same fact with two homes in the same module. +The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives with the request and result types in `@deepseek-ai/dsh-api-session-controller/types`; Session Controller enforces it, and `ClientSessions.searchResultLimit` re-exposes it for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The Connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound and leave the same fact with two owners. Content matching inherits the SQLite backend's normalized literal token/phrase semantics. The shared semantic projection excludes reasoning blocks, so UI search never returns a model's private reasoning as a hit or snippet; the derived-index schema version advances so existing persistent indexes rebuild without the former documents. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index d440f0ce56..9f03c42cfb 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -16,7 +16,7 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 -结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 位于 `dsh-host-apiproxy` 中强制执行它的响应 schema 旁边,`SessionRuntime.searchResultLimit` 则把该常量重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明了搜索动作。连接 handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,而 schema 固定的 `max` 恰恰禁止这一点,并且会让同一事实在同一模块内拥有两处归属。 +结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 与请求和结果类型一起位于 `@deepseek-ai/dsh-api-session-controller/types`;Session Controller 强制执行它,`ClientSessions.searchResultLimit` 则把它重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明搜索动作。Connection handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,并让同一事实拥有两处归属。 内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。共享语义投影会排除推理(reasoning)块,因此 UI 搜索绝不会将模型的私有推理作为命中或 snippet 返回;派生索引的 schema 版本会随之前进,使现有持久化索引重建并移除先前的这些文档。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 684ebea047..0838d23d51 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: c897d345d9749facfb2046104b7a95076cf2df95 -2026-07-27-web-subagent-conversations.zh.md: 0fdfd19b6dac56c275bb2d2306ed492a269e70d2 +2026-07-27-web-subagent-conversations.md: 5ae4c22627a5f39547f1ca7f22bb9794b74e4340 +2026-07-27-web-subagent-conversations.zh.md: 79065872837ff3dd9e22f4be660991e9c540c7c0 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index c897d345d9..5ae4c22627 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -51,7 +51,7 @@ Agent-bound auxiliary controls are unavailable in addressed child views. In part ## Host adapter and wire contract -`@deepseek-ai/dsh-host-apiproxy` owns a browser-safe `subagents` domain: +`@deepseek-ai/dsh-subagent` owns the browser-safe generated `subagent` Remote namespace: - `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries with each healthy row's boolean `hasChildren` snapshot, replaces each healthy row's corpus activity with whether its exact Agent driver is running, and includes whether the exact parent currently resolves from `ctx.agents`. - `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent. @@ -63,7 +63,7 @@ Viewing persisted history creates no mux subscription by itself. When a follow-u The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` and `session.updateQueue` apply the same check directly because they intentionally query only attached Agents. -The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [GUI RPC layering](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). +The adapter stays behind the generated Remote namespace; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [archived GUI RPC layering decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). ## Client object layer and presentation diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 0fdfd19b6d..7906587283 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -51,7 +51,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 ## 宿主适配器与协议约定 -`@deepseek-ai/dsh-host-apiproxy` 拥有浏览器安全的 `subagents` 域: +`@deepseek-ai/dsh-subagent` 拥有浏览器安全的生成 `subagent` Remote 命名空间: - `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `ctx.agents` 解析出确切 parent。 - `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。 @@ -63,7 +63,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 与 `session.updateQueue` 会直接执行同一检查,因为它们有意只查询已附加的 Agent。 -适配器仍位于 `dsh-host-apiproxy`;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)。 +适配器仍位于生成的 Remote 命名空间之后;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持[已归档的 GUI RPC 分层决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。 ## 客户端对象层与呈现 diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml index 89d0986ee9..56a7ab1fb8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.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-07-28-todo-plan-clears-on-next-turn.md -2026-07-28-todo-plan-clears-on-next-turn.md: 55a0d4f307bef5cd04add0baae220ea763772ee9 -2026-07-28-todo-plan-clears-on-next-turn.zh.md: c5fb809694c680951e1f2238d5596e771e8bcec8 +2026-07-28-todo-plan-clears-on-next-turn.md: b8a199840c683bc1ecb79aa8c9c4ea330da5e474 +2026-07-28-todo-plan-clears-on-next-turn.zh.md: bbe6fd35b5965c19864e3d181a845993e842132d diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md index 55a0d4f307..b8a199840c 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md @@ -14,7 +14,7 @@ The standing plan is the latest `todo/write` that is not followed by a later `tu ### Host projection (web) -`dsh-tool-todo`'s `todos` projection unit folds the rule: `apply` takes the whole list from each `todo/write` and returns `null` on each `turn/start` (`stateVersion` 2). Carriers (`dsh-host-apiproxy`) serve that value on the history tail `projections` block and push `session/projection` frames; the web dock reads it through `useProjection('todos')`. The keyless fixture mirrors the same fold for assembled snapshots. +`dsh-tool-todo`'s `todos` projection unit folds the rule: `apply` takes the whole list from each `todo/write` and returns `null` on each `turn/start` (`stateVersion` 2). Session Controller serves that value on the history tail `projections` block and pushes `session/projection` frames; the web dock reads it through `useProjection('todos')`. The keyless fixture mirrors the same fold for assembled snapshots. ### TUI live path diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md index c5fb809694..bbe6fd35b5 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 宿主投影(web) -`dsh-tool-todo` 的 `todos` 投影单元折叠该规则:`apply` 从每个 `todo/write` 取完整列表,并在每个 `turn/start` 返回 `null`(`stateVersion` 2)。载体(`dsh-host-apiproxy`)在历史记录尾部的 `projections` 块中提供该值,并以 `session/projection` 帧推送;web dock 经 `useProjection('todos')` 读取。无密钥 fixture(测试前置数据)镜像同一折叠,供组装后的快照使用。 +`dsh-tool-todo` 的 `todos` 投影单元折叠该规则:`apply` 从每个 `todo/write` 取完整列表,并在每个 `turn/start` 返回 `null`(`stateVersion` 2)。Session Controller 在历史记录尾部的 `projections` 块中提供该值,并以 `session/projection` 帧推送;Web dock 经 `useProjection('todos')` 读取。无密钥 fixture(测试前置数据)镜像同一折叠,供组装后的快照使用。 ### TUI 实时路径 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index fa703bf41d..85a24356aa 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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-07-28-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: a8d5bd116b3f4cf1434d44b643dad3d883763a82 -2026-07-28-tool-call-file-open-in-os.zh.md: b486ec0972356419c2df5abbc148dc57a4586920 +2026-07-28-tool-call-file-open-in-os.md: 4655af04243b28284cf46c058a472ee658d9a035 +2026-07-28-tool-call-file-open-in-os.zh.md: 769aeaeaa74af54aef60521505a8ed107500fa8a diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index a8d5bd116b..4655af0424 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -12,7 +12,7 @@ Chat tool rows treated the whole summary line as a click target that opened the File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as links underlined at rest with a pointer cursor. Clicking the path calls `session/openWorkspacePath` through the chat view's `openFile` injection; the chat view resolves relative paths against the addressed Session's cwd when it is known. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. -`session/openWorkspacePath` uses the authenticated Remote carrier, while the product UI offers the gesture only on a loopback page whose `host.describe.canOpenPath` is true. Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. +`session/openWorkspacePath` uses the authenticated Remote carrier, while the product UI offers the gesture only on a loopback page whose `session/canOpenWorkspacePath` result is true. Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index b486ec0972..769aeaeaa7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -12,7 +12,7 @@ Status: implemented 文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经聊天视图的 `openFile` injection 调用 `session/openWorkspacePath`;聊天视图会在目标 Session 的 cwd 已知时据此解析相对路径。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 -`session/openWorkspacePath` 使用经过认证的 Remote carrier,而产品 UI 只在 loopback 页面且 `host.describe.canOpenPath` 为 true 时提供该手势。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 +`session/openWorkspacePath` 使用经过认证的 Remote carrier,而产品 UI 只在 loopback 页面且 `session/canOpenWorkspacePath` 结果为 true 时提供该手势。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index aaa3cc3cc8..92a54e0263 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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-07-31-permission-default-for-new-sessions.md -2026-07-31-permission-default-for-new-sessions.md: ebf7fe39712d64c18e12b9b26d86201a61ad6cfd -2026-07-31-permission-default-for-new-sessions.zh.md: c0f450c8efca8647e3058fb305724cf0a554cc8d +2026-07-31-permission-default-for-new-sessions.md: b5bb72ee179760e4b58ae5ca1124f9948ed24d5d +2026-07-31-permission-default-for-new-sessions.zh.md: 9383a1b184f610d2995fb3d6982d3f4001729822 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index ebf7fe3971..b5bb72ee17 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -16,7 +16,7 @@ The service reads the current Settings value synchronously at `session/created`. The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows. -ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes reach the client through forwarded `settings/document-updated` ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)); they do not announce model topology. +The Settings Controller exposes the registered `permission` namespace through its redacted Remote view. This is a local presentation decision, not a general registration flag or a `local-client` access model. Permission changes reach the client through forwarded `settings/document-updated` ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)); they do not announce model topology. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index c0f450c8ef..9383a1b184 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -16,7 +16,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 ` 现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。 -ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更通过转发的 `settings/document-updated` 到达客户端([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.zh.md)),不会宣告模型拓扑。 +Settings Controller 通过脱敏的 Remote 视图暴露已注册的 `permission` namespace。这是局部的呈现决策,而不是通用注册标志或 `local-client` 访问模型。权限变更通过转发的 `settings/document-updated` 到达客户端([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.zh.md)),不会宣告模型拓扑。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml index 37ebb517b5..c8be4143b4 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.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-08-07-default-model-follows-the-picker.md -2026-08-07-default-model-follows-the-picker.md: 5ed9aff020f70c7f37452d903cd458697504bf97 -2026-08-07-default-model-follows-the-picker.zh.md: 1a5d025d41a853f8a662a58fc7dc99bbbfb68c49 +2026-08-07-default-model-follows-the-picker.md: b2d9c0fe58d9219fb33f749563d3d1fb10a20a8a +2026-08-07-default-model-follows-the-picker.zh.md: 7d3e5bc924f20e47b4d6a5e609c0e3c984ed6921 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md index 5ed9aff020..b2d9c0fe58 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -12,7 +12,7 @@ Reasoning effort makes the persistence shape significant: a model selection with ## Decision -`AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). +`AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and Session Controller Remote creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). `reasoningEffort` belongs to the Settings section but not to the plugin config. Settings layers merge by field, so a configured effort would survive a user selection that omits it. `saveSelection()` instead writes the complete user section; absence therefore clears a stored effort. A deployment-wide effort default belongs to the adapter profile, which resolves it per model. @@ -26,7 +26,7 @@ The stored selection does not require catalog membership. A provider route may s ## Consequences -`host.describe` reports the live Agent default. A successful model switch stores an `agent-default-model:` section in `settings.yaml`. The gateway does not expose that namespace through its Settings-page allowlist; the model picker is its editor. +`session/modelCatalog` reports the live Agent default. A successful model switch stores an `agent-default-model:` section in `settings.yaml`. The Settings page does not expose that namespace; the model picker is its editor. ## A session that cannot send diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md index 1a5d025d41..7d3e5bc924 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.zh.md))。 +`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 Session Controller Remote 创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.zh.md))。 `reasoningEffort` 属于 Settings 分节,但不属于插件配置。Settings 层按字段合并,因此已配置的强度会在用户选择省略它时继续存在。`saveSelection()` 写入完整的用户分节;因此,缺少该字段会清除已存强度。部署级强度默认值属于适配器 profile,并由它按模型解析。 @@ -26,7 +26,7 @@ Status: implemented ## 影响 -`host.describe` 报告当前 Agent 默认值。模型切换成功后,`settings.yaml` 中会存有一个 `agent-default-model:` 分节。网关不通过 Settings 页 allowlist 暴露该 namespace;模型选择器是它的编辑器。 +`session/modelCatalog` 报告当前 Agent 默认值。模型切换成功后,`settings.yaml` 中会存有一个 `agent-default-model:` 分节。Settings 页面不暴露该 namespace;模型选择器是它的编辑器。 ## 无法发送消息的会话 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index 3d120f1027..c093041c15 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: e64de4ecd564bf585ec857546913828a87ad1279 -2026-08-10-web-session-log-export.zh.md: 2ef377b4bb3c98f1935e67a1619bbcc5ed1789ef +2026-08-10-web-session-log-export.md: 69cc3d9fdfb267242de863c8359994ef25754bb1 +2026-08-10-web-session-log-export.zh.md: 9ab7318a4b89ddbd343739dc730569f4d8f584ba diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index e64de4ecd5..69cc3d9fdf 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -11,7 +11,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision - **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. -- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. +- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. Connection applies the `/api` trust fence before dispatching the exact `GET`/`HEAD /api/session.export` route registered by `session-log-export`. - **The UI just downloads**: browser consumers may issue a bodyless `HEAD` preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. - The current Header and `/export` consumers are defined by the [session-log export package contract](../../../../packages/session-query/session-log-export/README.md). @@ -25,6 +25,6 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Consequences - Export fidelity: immediately before reading each live root or descendant, the exporter crosses the authoritative `SessionStore.flush` durability barrier; every exported file is byte-identical to that resulting durable artifact. A live session may append again after its read, so the archive is a per-session read-boundary snapshot rather than one atomic tree snapshot. The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. -- `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. +- `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `session-log-export` registers one exact Host-only Fetch route with Connection; no Remote descriptor or JSON envelope represents the streamed response. - Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button. - Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 2ef377b4bb..9ab7318a4b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -11,7 +11,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 - **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 -- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。Connection 在分发 `session-log-export` 注册的精确 `GET`/`HEAD /api/session.export` 路由前应用 `/api` 信任围栏。 - **UI 只负责下载**:浏览器 Consumer 可以先发出不读取 body 的 `HEAD` 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 - 当前 Header 与 `/export` Consumer 由 [Session 日志导出包约定](../../../../packages/session-query/session-log-export/README.zh.md)定义。 @@ -25,6 +25,6 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 后果 - 导出保真度:读取每个实时根会话或后代前,导出器会通过权威的 `SessionStore.flush` 持久性屏障;每个导出文件都与由此得到的持久化工件逐字节一致。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 -- `supportsRawArtifacts` 明确区分后端能力与会话缺失:SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝;JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 +- `supportsRawArtifacts` 明确区分后端能力与会话缺失:SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝;JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`session-log-export` 向 Connection 注册一个精确的 Host-only Fetch 路由;流式响应不使用 Remote descriptor 或 JSON envelope 表示。 - fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。 - 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml index a43189052c..8620a0e642 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.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/process/2026-07-22-tsconfig-solution-root-two-aggregates.md -2026-07-22-tsconfig-solution-root-two-aggregates.md: f12f2e7c4b46eacb376ff443ad9080fcd367490f -2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: 9c1fc006e7d509fef8e90fc113849f4ef14c2184 +2026-07-22-tsconfig-solution-root-two-aggregates.md: 714f7c4b6ac9839ab51c8453f970346a6f18f17e +2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: 53fe3c4342b78cc90b537fdb5c2dbb29387fbe85 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md index f12f2e7c4b..714f7c4b6a 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md @@ -6,7 +6,7 @@ English | [中文](2026-07-22-tsconfig-solution-root-two-aggregates.zh.md) ## Problem -The GUI split introduced a second aggregate program (`tsconfig.client.json`, [layering RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)) while the root `tsconfig.json` kept doubling as the host aggregate, and `tsconfig.build.json` remained a third, hand-maintained full emit graph. That triple bookkeeping produced four concrete asymmetries: +The GUI split introduced a second aggregate program (`tsconfig.client.json`, [archived layering RFC](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)) while the root `tsconfig.json` kept doubling as the host aggregate, and `tsconfig.build.json` remained a third, hand-maintained full emit graph. That triple bookkeeping produced four concrete asymmetries: - The typecheck and build references lists drifted apart (`packages/goal/command-goal` was in the typecheck graph but missing from the build graph). - The lefthook pre-push hook ran `tsc -b tsconfig.json` only, so client-side type breakage passed the local checkpoint and surfaced in CI. diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md index 9c1fc006e7..53fe3c4342 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[分层 RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)),根 `tsconfig.json` 则继续兼任宿主侧聚合,`tsconfig.build.json` 还是第三份手工维护的全量 emit 图。三处账本并行,造成四个具体的不对称: +GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[已归档的分层 RFC](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)),根 `tsconfig.json` 则继续兼任宿主侧聚合,`tsconfig.build.json` 还是第三份手工维护的全量 emit 图。三处账本并行,造成四个具体的不对称: - 类型检查与构建的 references 列表逐渐脱节(`packages/goal/command-goal` 在类型检查图里,构建图里却没有)。 - lefthook 的 pre-push 钩子只运行 `tsc -b tsconfig.json`,客户端侧的类型破坏因此通过本地检查点,直到 CI 才暴露。 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml index 9ad937a5df..f054e63897 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.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/process/2026-08-08-api-remotes-generated-contract-build.md -2026-08-08-api-remotes-generated-contract-build.md: 2f5da20c947e60a3c76514631d1c64c38e07b4cd -2026-08-08-api-remotes-generated-contract-build.zh.md: 92d840765a4297d76dfdbab851869c90ac8e6609 +2026-08-08-api-remotes-generated-contract-build.md: 319b0205aacfe1c5d81ca5093199cdd1818a8132 +2026-08-08-api-remotes-generated-contract-build.zh.md: 21ed4593f01cb85f54dc1c75f3b0f269462c0fa0 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md index 2f5da20c94..319b0205aa 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md @@ -43,7 +43,7 @@ packages/api/remotes/ └─ index.ts ~~~ -The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and `host/apiproxy` reference `api/remotes/tsconfig.host.json`, while the root Client aggregate and `client/ui-goal` reference `api/remotes/tsconfig.client.json`. `ui-goal` itself remains an ordinary single Client project. The workspace constraints gate walks the reachable Project Reference graph and rejects any face-declared project that references a split package's solution root or opposite leaf; targets with only `tsconfig.json` remain valid from either face. +The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate references `api/remotes/tsconfig.host.json`, while the root Client aggregate and direct Client consumers reference `api/remotes/tsconfig.client.json`. `session-log-export` uses the same solution-and-leaves structure to keep its Node archive implementation out of its browser controller. The workspace constraints gate walks the reachable Project Reference graph and rejects any face-declared project that references a split package's solution root or opposite leaf; targets with only `tsconfig.json` remain valid from either face. The two projects use disjoint `files` and separate `.tsbuildinfo` files, so they can share `lib/types` without emitting any source file twice. If both sides later need a shared implementation, move that implementation into a neutral package instead of giving the same source to two emitting projects. diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md index 92d840765a..21ed4593f0 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md @@ -43,7 +43,7 @@ packages/api/remotes/ └─ index.ts ~~~ -包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 `host/apiproxy` 引用 `api/remotes/tsconfig.host.json`;根 Client aggregate 与 `client/ui-goal` 引用 `api/remotes/tsconfig.client.json`。`ui-goal` 本身仍是普通的单一 Client project。workspace constraints 门禁遍历可达的 Project Reference 图;凡已声明 face 的 project 引用了拆分包的 solution 根或另一侧 leaf,门禁都会拒绝,而只有 `tsconfig.json` 的目标仍可由任一 face 引用。 +包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 引用 `api/remotes/tsconfig.host.json`,根 Client aggregate 与直接 Client 消费方引用 `api/remotes/tsconfig.client.json`。`session-log-export` 使用相同的 solution 与 leaf 结构,让 Node archive 实现不进入浏览器 controller。workspace constraints 门禁遍历可达的 Project Reference 图;凡已声明 face 的 project 引用了拆分包的 solution 根或另一侧 leaf,门禁都会拒绝,而只有 `tsconfig.json` 的目标仍可由任一 face 引用。 两个 project 使用互不重叠的 `files` 和不同的 `.tsbuildinfo`,因此可以共享 `lib/types` 而不重复发射任何源码。若未来需要两侧共用一份实现,应把实现移入中立 package,不能把同一源码同时交给两个 emitting project。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml index 2b17c1d803..93a1ed623e 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.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/simplification/2026-08-08-copy-only-preset-authoring.md -2026-08-08-copy-only-preset-authoring.md: 54d317a3de236bf2e191424411c25291c52c7edd -2026-08-08-copy-only-preset-authoring.zh.md: ea449a7b73a0b5a7111ff924b6e47e6323bb58f1 +2026-08-08-copy-only-preset-authoring.md: e6ac66a7a3208b495333166d382e669d13633f51 +2026-08-08-copy-only-preset-authoring.zh.md: da25dcab0374126caea375f5312e063354456d3f diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md index 54d317a3de..e6ac66a7a3 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md @@ -10,7 +10,7 @@ The agent-preset settings page carried a web YAML editor: `agentPreset.write` ac ## Decision -Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `settings/openAgentPresetDirectory { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`; `host.describe.canOpenPath` gates the row, and Settings Controller's `nativeOpen` pins server behavior where platform detection would mislead). +Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `settings/openAgentPresetDirectory { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`; `settings/canOpenAgentPresetDirectory` gates the row, and Settings Controller's `nativeOpen` pins server behavior where platform detection would mislead). ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md index ea449a7b73..da25dcab03 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -10,7 +10,7 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` ## 决策 -创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`settings/openAgentPresetDirectory { agentPreset }` 在 Host 侧解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供该行以文本形式展示(`list` 上的 `hasDocument`;`host.describe.canOpenPath` 控制该行是否显示,Settings Controller 的 `nativeOpen` 则在平台探测可能误判时固定服务端行为)。 +创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称或 `order`。页面包含随附组装的只读查看器、作为唯一创建入口的复制对话框(不提供空白「新建预设」)、自定义行的删除,以及通向文件的位置操作。`settings/openAgentPresetDirectory { agentPreset }` 在 Host 侧解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供该行以文本形式展示;`settings/canOpenAgentPresetDirectory` 控制该行是否显示,Settings Controller 的 `nativeOpen` 则在平台探测可能误判时固定服务端行为。 ## 后果 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index d51608534c..089e243f05 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.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/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 14873d3296461357fe6582386f394bc1a5bd9483 -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 4bdac0712972eb3cb85c8e721b39146995401385 +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 93a40d2751dfb24778ed77815480effc7d57a1fc +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: c703a53bece7a53c7dc92a17fcccdbd29e9efb49 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index 14873d3296..93a40d2751 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -16,7 +16,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`vscode-jsonrpc` for LSP base-protocol framing/correlation** (`lsp-stdio`): the swappable core is ~255 of ~1,800 src lines; the package cannot express the configured `maxMessageBytes` incoming-size bound (restoring it means rebuilding the deleted framing), inverts the cancel-grace teardown semantics (`raceAbort` rejects immediately then tears down; vscode-jsonrpc keeps the promise pending), errors on pre-header stdout banners real servers emit, and is CJS in an ESM-everywhere repo. The [LSP seam note](../../implemented/architecture/2026-07-15-lsp-capability-seam.md) assigns JSON-RPC ownership to `dsh-lsp-stdio`; this audit is the explicit on-record weighing of the dependency it lacked. - **`vscode-languageserver-types` for lsp-stdio's wire-type subset**: ~80 type lines and ~45 guard lines, but upstream guards differ in both directions (accept `uri: undefined` the repo must reject; require `targetRange` the repo tolerates absent), and the initialize-result shapes live in `vscode-languageserver-protocol`, dragging `vscode-jsonrpc` in as a runtime dep — ~1 MB for 80 spec-exact lines. -- **`json-rpc-2.0` for `dsh-sdk-jsonrpc-server`**: deletable correlation/dispatch is real (~100–130 lines) but the NDJSON wire must stay bit-identical for the hand-rolled Python SDK client, the package is single-maintainer, and the [GUI RPC note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) already treats this package as a frozen narrow surface. `vscode-jsonrpc` is a worse fit still (Content-Length framing, cancellation vocabulary the protocol lacks). +- **`json-rpc-2.0` for `dsh-sdk-jsonrpc-server`**: deletable correlation/dispatch is real (~100–130 lines) but the NDJSON wire must stay bit-identical for the hand-rolled Python SDK client, the package is single-maintainer, and the [archived GUI RPC note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) already treats this package as a frozen narrow surface. `vscode-jsonrpc` is a worse fit still (Content-Length framing, cancellation vocabulary the protocol lacks). - **`jsonrpcclient` for the Python SDK client**: v4 builds/parses messages only — ~20 lines — while the 500 lines that matter (subprocess lifecycle, threaded reader, id correlation, bidirectional server-role responses) stay; the library is in low-maintenance mode. - **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [archived llm-deepseek dependency decision](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index 4bdac07129..c703a53bec 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -16,7 +16,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `vscode-jsonrpc` 承担 LSP 基础协议的分帧/关联**(`lsp-stdio`):可替换的核心只占 src 约 1,800 行中的约 255 行;该包无法表达已配置的 `maxMessageBytes` 入站大小上限(要恢复它就得重建被删掉的分帧代码),反转了取消宽限期的拆除语义(`raceAbort` 立即 reject 再拆除;vscode-jsonrpc 让 promise 保持挂起),会在真实服务器输出的 header 前 stdout 横幅上报错,而且在这个全面采用 ESM 的仓库里它是 CJS。[LSP seam 决策](../../implemented/architecture/2026-07-15-lsp-capability-seam.zh.md)把 JSON-RPC 的所有权划给 `dsh-lsp-stdio`;本次审计正是对该决策当时缺失的这项依赖权衡的明文记录。 - **以 `vscode-languageserver-types` 承担 lsp-stdio 的协议类型子集**:约 80 行类型加约 45 行守卫,但上游守卫在两个方向上都与本仓库不一致(接受本仓库必须拒绝的 `uri: undefined`;强制要求本仓库容忍缺失的 `targetRange`),而且 initialize 结果的形状住在 `vscode-languageserver-protocol` 里,会把 `vscode-jsonrpc` 拖成运行时依赖——为 80 行严格贴合规范的代码付出约 1 MB。 -- **以 `json-rpc-2.0` 替换 `dsh-sdk-jsonrpc-server`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且 [GUI RPC 决策](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)已把这个包当作冻结的窄接口面对待。`vscode-jsonrpc` 更不合适(Content-Length 分帧、该协议并不具备的取消词汇)。 +- **以 `json-rpc-2.0` 替换 `dsh-sdk-jsonrpc-server`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且[已归档的 GUI RPC 决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)已把这个包当作冻结的窄接口面对待。`vscode-jsonrpc` 更不合适(Content-Length 分帧、该协议并不具备的取消词汇)。 - **以 `jsonrpcclient` 承担 Python SDK 客户端**:v4 只做消息的构造/解析——约 20 行——而真正要紧的 500 行(子进程生命周期、线程化读取器、id 关联、双向的服务端角色应答)全都保留;该库处于低维护模式。 - **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比[已归档的 llm-deepseek 依赖决策](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) diff --git a/AGENTS.md b/AGENTS.md index eeb286ccaa..a92ef8a9cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **Trust TypeScript at typed same-process boundaries.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries. - **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). -- **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). +- **Keep compiler faces explicit.** A package with both Host and Client programs exposes face-specific leaf configs and a solution-only root; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Keep comments local.** Do not restate code, explain distant behavior unless locally required, or expand unrelated comments ([rationale](.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 4bcc0e2b0b..b4c3dc3f3c 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/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 apps/cli/reference/README.md -README.md: dcb5f7fd36ba2f5db3e4ce65abc23f266872c4d3 -README.zh.md: bc87059883f641d850e18516b86f4bb089d8158e +README.md: 03ec7c3a14b35f20b748a1534ae7f98861cc6a49 +README.zh.md: d8655832656d73d6b7193be918da422648c50e84 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index dcb5f7fd36..03ec7c3a14 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -30,7 +30,7 @@ The shipped apps own these command lines: | `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol | | `acp` | no options; stdio carries Agent Client Protocol | -A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It streams non-empty provider reasoning deltas to stderr under a `dsh: reasoning:` heading, prints only the final text on stdout, and exits 0 for `completed`, else 1; a successful response with no reasoning leaves stderr empty. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client, and opens no listening port. +A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It streams non-empty provider reasoning deltas to stderr under a `dsh: reasoning:` heading, prints only the final text on stdout, and exits 0 for `completed`, else 1; a successful response with no reasoning leaves stderr empty. An invocation with no task is a usage error from that app. The shipped headless profile mounts no browser Connection, HTTP server, Web runtime, or browser client, and opens no listening port. Inspect the composed tree without booting it: diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index bc87059883..d865583265 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -30,7 +30,7 @@ | `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 | | `acp` | 无选项;stdio 携带 Agent Client Protocol | -一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 `dsh: reasoning:` 标题下将非空的提供方推理分片流式写入 stderr,只在 stdout 打印最终文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出;没有推理内容的成功响应会保持 stderr 为空。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端,也不会打开监听端口。 +一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 `dsh: reasoning:` 标题下将非空的提供方推理分片流式写入 stderr,只在 stdout 打印最终文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出;没有推理内容的成功响应会保持 stderr 为空。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载浏览器 Connection、HTTP 服务器、Web 运行时或浏览器客户端,也不会打开监听端口。 可在不启动的情况下检查组合出的配置树: diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 3c260eab76..ef09bb3c70 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/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 apps/web/tests/README.md -README.md: 2104d9422cfbbcbc7ffc4b12e491c0a62daa3b9d -README.zh.md: 4dfa5b2f61c757e481d9b8e012b37a5e71d75093 +README.md: e5b97f0b7527da01ce7c926360145fee49e168f0 +README.zh.md: f360c2b487bbf5b1a1c81492e5b4c2d4eaa8d749 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 2104d9422c..e5b97f0b75 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -11,8 +11,8 @@ the deliberate composition divergences from `dsh web` — are documented in ## These are Host-face tests They type-check in the root `tsconfig.host.json`, not in the Client aggregate, -because they read Host services directly: `ctx.apiProxy`, the Host -`SessionStore`, `ctx.sessionProjectionCache`. Driving a browser at runtime does +because they read Host services directly: `ctx.connection`, the Host +`SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 4dfa5b2f61..f360c2b487 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -10,7 +10,7 @@ ## 这些是 Host 面的测试 它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 -Host 服务:`ctx.apiProxy`、Host 侧 `SessionStore`、`ctx.sessionProjectionCache`。运行时驱动 +Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动 浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。 diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 8f2b62ad29..a4899f5e57 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -1,5 +1,5 @@ // Web e2e scenario: fresh round trip. A real chromium types a prompt into the -// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo +// real composer; the wire, Remote gateway, agent loop, and the REAL bash tool (echo // in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless) // or the live adapter (record). Drive steps run in every mode and wait only // on generic completion (whenTurnSettled — never model-content selectors, so diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 07858f39c4..c5feee2c91 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: 86c43ccc5719107ded1e4004d6fabbc32b760520 -api-gateway.zh.md: 6fa170b84129eb96a70d0831158a9f9c43db256c +api-gateway.md: 43b00bcff3da0adb1d53ac25534b65366bc42611 +api-gateway.zh.md: bb2711220fd75ef0119e1188c22a0af5ad4169a8 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 86c43ccc57..43b00bcff3 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -118,13 +118,13 @@ Strict analysis requires a Remote to be a public, non-static instance method wit ## Runtime invocation -Remote and API Proxy share the Connection's `/api` route. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. +Remote calls use the Connection's `/api` route. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. -The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler in interceptor order. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; unclaimed requests fall back to the existing API Proxy. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. +The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; feature-owned exact Fetch routes handle non-JSON responses, and other requests return 404. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. -The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. The Web API Proxy supplies its Agent defaults and scope setup, then consumes the same resolver for legacy methods. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. +The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. @@ -159,6 +159,6 @@ The running Client watcher consumes these generated files when it rebundles. If 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. -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`. The API Proxy at `packages/host/apiproxy` handles endpoints without Remote 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. Lookup policy is configured per key, so all `agent` or `session` parameters share the cold-resume behavior. Accepting live objects only would require an explicit per-parameter or per-endpoint policy, which does not exist; the business method must not guess whether the object came from restoration. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 6fa170b841..bb2711220f 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -118,13 +118,13 @@ Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则 ## 运行时调用 -Remote 与 API Proxy 共用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 +Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 -Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内按 interceptor 顺序分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;未认领的请求回退到既有 API Proxy。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 +Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;功能自有的精确 Fetch 路由处理非 JSON 响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法并校验返回值。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 -lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。Web API Proxy 提供 Agent 默认值与 scope 设置,再让旧方法使用同一个 resolver。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 +lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的陈旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 @@ -159,6 +159,6 @@ pnpm run build:lib Remote 只处理有单个请求与单个结果的一元方法调用。会话事件流、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 -API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。位于 `packages/host/apiproxy` 的 API Proxy 处理没有 Remote 描述符的 endpoint。 +API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。需要流式或浏览器原生响应的功能注册精确的 Connection Fetch 路由,而不定义 Remote 方法。 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。只接受 live 对象需要显式的逐参数或逐 endpoint 策略,而这种策略并不存在;不能通过业务方法内部猜测对象是否来自恢复。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 4033b29587..97d450238e 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: 4e9109294c3b02476af9bf97ecf280b1ea84b128 -capability-seams.zh.md: a4d6dd1b7d4111736d532aa36a4b806aef7d8dc0 +capability-seams.md: 2ff2c3641f163c46ba19616bf35d60df82b3c748 +capability-seams.zh.md: 3cb636d0a997f4c36905f6434d7f2b2d0a22dbd8 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 4e9109294c..2ff2c3641f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -11,7 +11,6 @@ flowchart LR svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] pkg_api_session_controller["api-session-controller"] - pkg_host_apiproxy["host-apiproxy"] pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] @@ -114,6 +113,7 @@ flowchart LR svc_sessionProjections["ctx.sessionProjections
Session projection units"] pkg_session_projection_cache["session-projection-cache"] svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] + pkg_subagent["subagent"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_badge["skill-badge"] @@ -168,7 +168,6 @@ flowchart LR pkg_fs_observation_policy["fs-observation-policy"] pkg_compaction["compaction"] svc_compaction["ctx.compaction
Compaction seam"] - pkg_subagent["subagent"] svc_subagents["ctx.subagents
Subagent provider and continuation service"] pkg_subagent_spawn_in_process["subagent-spawn-in-process"] pkg_subagent_fork_in_process["subagent-fork-in-process"] @@ -215,7 +214,6 @@ flowchart LR pkg_lsp["lsp"] svc_lsp["ctx.lsp
Language-server navigation seam"] pkg_tool_lsp["tool-lsp"] - svc_apiProxy["ctx.apiProxy
Host API dispatch"] pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] @@ -257,7 +255,6 @@ flowchart LR pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs pkg_goal --> svc_goals - pkg_host_apiproxy --> svc_apiProxy pkg_host_directory_picker --> svc_directoryPicker pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker @@ -336,20 +333,18 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless - svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_subagent_in_process_driver - svc_apiProxy --> pkg_client_connection svc_approval --> pkg_acp svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_attachments --> pkg_api_session_controller - svc_attachments --> pkg_host_apiproxy svc_attachments --> pkg_llm_deepseek svc_attachments --> pkg_llm_pi_ai svc_attachments --> pkg_tool_fs @@ -358,7 +353,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_compaction --> pkg_compaction_basic svc_cordisInspect --> pkg_tool_cordis - svc_credentials --> pkg_host_apiproxy + svc_credentials --> pkg_api_settings_controller svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek @@ -391,8 +386,11 @@ flowchart LR svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash - svc_sessionProjectionCache --> pkg_host_apiproxy - svc_sessionProjections --> pkg_host_apiproxy + svc_sessionProjectionCache --> pkg_api_session_controller + svc_sessionProjectionCache --> pkg_session_query + svc_sessionProjectionCache --> pkg_session_reference + svc_sessionProjectionCache --> pkg_subagent + svc_sessionProjections --> pkg_api_session_controller svc_sessionProjections --> pkg_session_title svc_sessionProjections --> pkg_tool_todo svc_sessionQuery --> pkg_session_reference @@ -405,7 +403,7 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_in_process_driver - svc_settings --> pkg_host_apiproxy + svc_settings --> pkg_api_settings_controller svc_settings --> pkg_llm_deepseek svc_settings --> pkg_llm_pi_ai svc_shell --> pkg_hooks_claude_code @@ -465,7 +463,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | -| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`host-apiproxy`](../packages/host/apiproxy), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | +| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | @@ -482,9 +480,9 @@ flowchart LR | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the settings controller serves redacted layered descriptors and writes the user layer. | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | @@ -501,11 +499,11 @@ flowchart LR | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | -| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | -| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | +| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. | +| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | @@ -532,7 +530,6 @@ flowchart LR | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/webhook/webhook) | - | [`webhook-github`](../packages/webhook/webhook-github) | - | Provider adapters dispatch authenticated deliveries; trusted plugins register independent process-local rules, and the runtime turns non-null results into ordinary Workspace-backed Sessions without delivery or completion state. | | `ctx.lsp` | `seam` | [`lsp`](../packages/lsp/lsp) | [`lsp-stdio`](../packages/lsp/lsp-stdio) | [`tool-lsp`](../packages/lsp/tool-lsp) | - | Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result. | -| `ctx.apiProxy` | `core` | [`host-apiproxy`](../packages/host/apiproxy) | - | [`client-connection`](../packages/client/connection) | - | The transport-agnostic host gateway face: it dispatches browser API calls, and each open host stream subscribes to the events it forwards rather than being pushed to through a broadcast verb. | | `ctx.dynamicCordisRunner` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | Owns the in-memory definition registry, the vm sandbox for host halves, and the request-run round trip; browser pages reach the same service over the wire through its remote namespace. | | `ctx.cordisInspect` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | Registers host inspect providers, mirrors the client provider manifest, and routes client queries through the dynamic Cordis transport. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index a4d6dd1b7d..3cb636d0a9 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -13,7 +13,6 @@ flowchart LR svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] pkg_api_session_controller["api-session-controller"] - pkg_host_apiproxy["host-apiproxy"] pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] @@ -116,6 +115,7 @@ flowchart LR svc_sessionProjections["ctx.sessionProjections
Session projection units"] pkg_session_projection_cache["session-projection-cache"] svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] + pkg_subagent["subagent"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_badge["skill-badge"] @@ -170,7 +170,6 @@ flowchart LR pkg_fs_observation_policy["fs-observation-policy"] pkg_compaction["compaction"] svc_compaction["ctx.compaction
Compaction seam"] - pkg_subagent["subagent"] svc_subagents["ctx.subagents
Subagent provider and continuation service"] pkg_subagent_spawn_in_process["subagent-spawn-in-process"] pkg_subagent_fork_in_process["subagent-fork-in-process"] @@ -217,7 +216,6 @@ flowchart LR pkg_lsp["lsp"] svc_lsp["ctx.lsp
Language-server navigation seam"] pkg_tool_lsp["tool-lsp"] - svc_apiProxy["ctx.apiProxy
Host API dispatch"] pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] @@ -259,7 +257,6 @@ flowchart LR pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs pkg_goal --> svc_goals - pkg_host_apiproxy --> svc_apiProxy pkg_host_directory_picker --> svc_directoryPicker pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker @@ -338,20 +335,18 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless - svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_subagent_in_process_driver - svc_apiProxy --> pkg_client_connection svc_approval --> pkg_acp svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_attachments --> pkg_api_session_controller - svc_attachments --> pkg_host_apiproxy svc_attachments --> pkg_llm_deepseek svc_attachments --> pkg_llm_pi_ai svc_attachments --> pkg_tool_fs @@ -360,7 +355,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_compaction --> pkg_compaction_basic svc_cordisInspect --> pkg_tool_cordis - svc_credentials --> pkg_host_apiproxy + svc_credentials --> pkg_api_settings_controller svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek @@ -393,8 +388,11 @@ flowchart LR svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash - svc_sessionProjectionCache --> pkg_host_apiproxy - svc_sessionProjections --> pkg_host_apiproxy + svc_sessionProjectionCache --> pkg_api_session_controller + svc_sessionProjectionCache --> pkg_session_query + svc_sessionProjectionCache --> pkg_session_reference + svc_sessionProjectionCache --> pkg_subagent + svc_sessionProjections --> pkg_api_session_controller svc_sessionProjections --> pkg_session_title svc_sessionProjections --> pkg_tool_todo svc_sessionQuery --> pkg_session_reference @@ -407,7 +405,7 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_in_process_driver - svc_settings --> pkg_host_apiproxy + svc_settings --> pkg_api_settings_controller svc_settings --> pkg_llm_deepseek svc_settings --> pkg_llm_pi_ai svc_shell --> pkg_hooks_claude_code @@ -467,7 +465,7 @@ flowchart LR | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 | | --- | --- | --- | --- | --- | --- | --- | -| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`host-apiproxy`](../packages/host/apiproxy), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | +| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | @@ -484,9 +482,9 @@ flowchart LR | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | -| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;settings controller 提供经过脱敏的分层描述符,并写入用户层。 | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 | @@ -503,11 +501,11 @@ flowchart LR | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | -| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,api-proxy 提供基线并推送发生变化的值。 | -| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | +| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 | +| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | 拥有一个共享的 E2B SDK 句柄、远程工作目录和最终沙箱处置,使两个基础 E2B 提供方处于同一个 Linux 运行时中。 | @@ -534,7 +532,6 @@ flowchart LR | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/webhook/webhook) | - | [`webhook-github`](../packages/webhook/webhook-github) | - | 提供方适配器分派已认证交付;可信插件注册独立的进程本地规则,runtime 把非 null 结果转换为普通的 Workspace-backed Session,不保留交付或完成状态。 | | `ctx.lsp` | `seam` | [`lsp`](../packages/lsp/lsp) | [`lsp-stdio`](../packages/lsp/lsp-stdio) | [`tool-lsp`](../packages/lsp/tool-lsp) | - | 提供方注册与选择,加上恰好四种操作的标准化查询执行;该 seam 不提供协议逃生口,后端必须转换为标准化请求和结果。 | -| `ctx.apiProxy` | `core` | [`host-apiproxy`](../packages/host/apiproxy) | - | [`client-connection`](../packages/client/connection) | - | 与传输无关的 Host 网关接口:它分派浏览器 API 调用,每条打开的 Host 流自行订阅转发事件,而不是由广播方法向其推送。 | | `ctx.dynamicCordisRunner` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | 拥有内存定义注册表、Host 半的 vm 沙箱和 request-run 往返流程;浏览器页面通过其 Remote 命名空间在线访问同一服务。 | | `ctx.cordisInspect` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | 注册 Host inspect 提供方、镜像 Client 提供方 manifest,并通过动态 Cordis 传输路由 Client 查询。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 438a3ef9c8..b9d870136b 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: 05bd17a600869782409038188457db6d362f07a4 -config-catalog.zh.md: 1095b31530a28af2c4a23520fd5e68e067b46c11 +config-catalog.md: 3b05dbf6a9e4a591e876bd6993522223146288fb +config-catalog.zh.md: cbcc3d1acc9664ce3c92eb7dcb3fc0d12a8c466d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 05bd17a600..3b05dbf6a9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -288,7 +288,7 @@ export interface Config { } ``` -Source: [`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) @@ -301,6 +301,8 @@ Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean } ``` @@ -427,7 +429,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:70`](../packages/client/connection/src/index.ts) @@ -868,34 +870,6 @@ export interface Config { Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) - - -## `@deepseek-ai/dsh-host-apiproxy` - -Requires: `agentDefaultModel` · `agents` · `attachments` · `sessions` · `sessionQuery` - -```ts config-catalog -/** Gateway plugin configuration. */ -export interface Config { - /** - * Whether this deployment can hand paths to a native desktop opener — - * the `hasDocument` capability the agent-preset roster reports. Absent, - * the platform is asked (macOS/Windows/WSL yes; Linux only with a display - * server); set it explicitly where detection misleads, e.g. `false` in a - * container whose DISPLAY points nowhere a user can see. - */ - nativeOpen?: boolean - /** - * DEFLATE level for every session-log ZIP entry: `0` stores without - * compression, `1` favors CPU/latency, and `9` favors archive size. - * @default 6 - */ - sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 -} -``` - -Source: [`packages/host/apiproxy/src/index.ts:40`](../packages/host/apiproxy/src/index.ts) - ## `@deepseek-ai/dsh-host-directory-picker-browse` @@ -1850,6 +1824,25 @@ export interface Config { Source: [`packages/session/session-log-deepseek/src/index.ts:22`](../packages/session/session-log-deepseek/src/index.ts) + + +## `@deepseek-ai/dsh-session-log-export` + +Requires: `commands` · `connection` + +```ts config-catalog +/** Session-log archive policy. */ +export interface Config { + /** DEFLATE level for each ZIP entry. @default 6 */ + readonly compressionLevel?: SessionLogCompressionLevel +} + +/** Valid fflate DEFLATE levels accepted by session-log export. */ +export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 +``` + +Source: [`packages/session-query/session-log-export/src/index.ts:41`](../packages/session-query/session-log-export/src/index.ts) + ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -3471,7 +3464,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-schedule` — requires `agents` · `sessions` · `tools` · `sessionPersistence` ([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) -- `@deepseek-ai/dsh-session-log-export` — requires `commands` ([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) - `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 1095b31530..cbcc3d1acc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -290,7 +290,7 @@ export interface Config { } ``` -来源:[`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) @@ -303,6 +303,8 @@ export interface Config { export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean } ``` @@ -870,34 +872,6 @@ export interface Config { 来源:[`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) - - -## `@deepseek-ai/dsh-host-apiproxy` - -需要:`agentDefaultModel` · `agents` · `attachments` · `sessions` · `sessionQuery` - -```ts config-catalog -/** Gateway plugin configuration. */ -export interface Config { - /** - * Whether this deployment can hand paths to a native desktop opener — - * the `hasDocument` capability the agent-preset roster reports. Absent, - * the platform is asked (macOS/Windows/WSL yes; Linux only with a display - * server); set it explicitly where detection misleads, e.g. `false` in a - * container whose DISPLAY points nowhere a user can see. - */ - nativeOpen?: boolean - /** - * DEFLATE level for every session-log ZIP entry: `0` stores without - * compression, `1` favors CPU/latency, and `9` favors archive size. - * @default 6 - */ - sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 -} -``` - -来源:[`packages/host/apiproxy/src/index.ts:40`](../packages/host/apiproxy/src/index.ts) - ## `@deepseek-ai/dsh-host-directory-picker-browse` @@ -1852,6 +1826,25 @@ export interface Config { 来源:[`packages/session/session-log-deepseek/src/index.ts:22`](../packages/session/session-log-deepseek/src/index.ts) + + +## `@deepseek-ai/dsh-session-log-export` + +需要:`commands` · `connection` + +```ts config-catalog +/** Session-log archive policy. */ +export interface Config { + /** DEFLATE level for each ZIP entry. @default 6 */ + readonly compressionLevel?: SessionLogCompressionLevel +} + +/** Valid fflate DEFLATE levels accepted by session-log export. */ +export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 +``` + +来源:[`packages/session-query/session-log-export/src/index.ts:41`](../packages/session-query/session-log-export/src/index.ts) + ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -3473,7 +3466,6 @@ export interface Config { - `@deepseek-ai/dsh-schedule` — 需要 `agents` · `sessions` · `tools` · `sessionPersistence`([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) - `@deepseek-ai/dsh-session`([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) -- `@deepseek-ai/dsh-session-log-export` — 需要 `commands`([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)) - `@deepseek-ai/dsh-session-projection`([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) - `@deepseek-ai/dsh-session-stats` — 需要 `sessionProjections`([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — 需要 `skills`([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index fad92b97e3..14704caf43 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 12797904fab31d613db92af0da0656146eeea51a -development.zh.md: c759f4236efa356c5cc82dcec758654362fcc284 +development.md: 2647c0eddacb22d1f96e1e780e4c9f1d16b4ae0d +development.zh.md: b7ac5fb4d46de68f502dbcab8eef50944055718f diff --git a/docs/development.md b/docs/development.md index 12797904fa..2647c0edda 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,7 +59,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge - A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. - A new package is registered in exactly one aggregate; only the split packages above carry both leaf configs, and the shared leaves are registered in both aggregates because each side must type-check the same source. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -Five packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, and `client/connection`. `api/remotes`' Host entry must participate in the Host Typert graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order. +Six packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, `client/connection`, and `session-query/session-log-export`. `api/remotes`' Host entry participates in the Host Typert graph while its Client entry imports generated `/remote` declarations; `session-log-export` keeps Node archive production out of its browser controller. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) and [`session-log-export` README](../packages/session-query/session-log-export/README.md) explain their splits. The root build follows the generated dependency order: diff --git a/docs/development.zh.md b/docs/development.zh.md index c759f4236e..b7ac5fb4d4 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -63,7 +63,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 - 构造全仓 `ts.Program` 的脚本显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子——根 solution 永不作为种子,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 - 新包只登记进一个 aggregate;只有上述拆分包同时携带两个 leaf 配置,共享 leaf 因两侧需要对同一份源码做类型检查而登记进两个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client 插件的两份运行时产物都在 Client 构建阶段生成。 -拆分 Host/Client tsconfig 的包有五个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller` 与 `client/connection`。`api/remotes` 的 Host 入口必须进入 Host Typert 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此每个拆分包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 说明 Host/Client 拆分与构建顺序。 +拆分 Host/Client tsconfig 的包有六个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection` 与 `session-query/session-log-export`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。 根构建按生成依赖排序: diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 558d4a4200..445c9ac17a 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: 7abbcb938f2b2a203523e569422ef4cace658fe8 -module-graph.zh.md: 862f24dfc374e8b783bf94cebe29a58d52d7a02b +module-graph.md: b48c197299f1afbf53bf60962f544148f7f6defc +module-graph.zh.md: 1f85ab06c3ca98d6060dab400d2bf8dbfbda8cc5 diff --git a/docs/module-graph.md b/docs/module-graph.md index 7abbcb938f..b48c197299 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -229,7 +229,6 @@ flowchart TD pkg_tool_call_timeout_policy["tool-call-timeout-policy"] end subgraph group_host["packages/host"] - pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] @@ -384,7 +383,6 @@ flowchart TD pkg_experimental_agent_team_profile --> pkg_invariants pkg_experimental_agent_team_web_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants pkg_host_directory_picker_native --> pkg_invariants @@ -443,10 +441,6 @@ flowchart TD pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_inspector --> pkg_invariants - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -1023,9 +1017,9 @@ flowchart TD pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_brand pkg_client_connection --> pkg_commands pkg_client_connection --> pkg_credentials - pkg_client_connection --> pkg_host_apiproxy pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants @@ -1153,6 +1147,10 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver pkg_host_frontend_static --> pkg_invariants @@ -1583,6 +1581,8 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_log_export --> pkg_attachment + pkg_session_log_export --> pkg_client_connection pkg_session_log_export --> pkg_client_locale pkg_session_log_export --> pkg_client_ui_commands pkg_session_log_export --> pkg_client_ui_conversation @@ -1590,12 +1590,16 @@ flowchart TD pkg_session_log_export --> pkg_client_ui_session pkg_session_log_export --> pkg_commands pkg_session_log_export --> pkg_invariants + pkg_session_log_export --> pkg_session + pkg_session_log_export --> pkg_session_persistence + pkg_session_log_export --> pkg_session_query pkg_client_ui_attachment --> pkg_attachment pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_deliverables --> pkg_api_remotes pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale pkg_client_ui_deliverables --> pkg_client_ui_chat @@ -1733,7 +1737,6 @@ flowchart TD | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1762,7 +1765,6 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1876,7 +1878,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1889,6 +1891,7 @@ flowchart TD | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1941,9 +1944,9 @@ flowchart TD | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`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), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 862f24dfc3..1f85ab06c3 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -231,7 +231,6 @@ flowchart TD pkg_tool_call_timeout_policy["tool-call-timeout-policy"] end subgraph group_host["packages/host"] - pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] @@ -386,7 +385,6 @@ flowchart TD pkg_experimental_agent_team_profile --> pkg_invariants pkg_experimental_agent_team_web_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants pkg_host_directory_picker_native --> pkg_invariants @@ -445,10 +443,6 @@ flowchart TD pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_inspector --> pkg_invariants - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -1025,9 +1019,9 @@ flowchart TD pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_brand pkg_client_connection --> pkg_commands pkg_client_connection --> pkg_credentials - pkg_client_connection --> pkg_host_apiproxy pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants @@ -1155,6 +1149,10 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver pkg_host_frontend_static --> pkg_invariants @@ -1585,6 +1583,8 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_log_export --> pkg_attachment + pkg_session_log_export --> pkg_client_connection pkg_session_log_export --> pkg_client_locale pkg_session_log_export --> pkg_client_ui_commands pkg_session_log_export --> pkg_client_ui_conversation @@ -1592,12 +1592,16 @@ flowchart TD pkg_session_log_export --> pkg_client_ui_session pkg_session_log_export --> pkg_commands pkg_session_log_export --> pkg_invariants + pkg_session_log_export --> pkg_session + pkg_session_log_export --> pkg_session_persistence + pkg_session_log_export --> pkg_session_query pkg_client_ui_attachment --> pkg_attachment pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_deliverables --> pkg_api_remotes pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale pkg_client_ui_deliverables --> pkg_client_ui_chat @@ -1735,7 +1739,6 @@ flowchart TD | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1764,7 +1767,6 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1878,7 +1880,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1891,6 +1893,7 @@ flowchart TD | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1943,9 +1946,9 @@ flowchart TD | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`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), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f917871371..f459ae1fd9 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/session.md -session.md: 919a8eff583886610b56b34294ae1c13a06493b0 -session.zh.md: 8ffc7c8256311328c9e56e63625f3fbfcb5241a7 +session.md: f3c246f7a77386a088f0559d233031bdb8d997f8 +session.zh.md: bc706a11e9b504f6806cbf7c1beb67b972fb4f75 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 919a8eff58..f3c246f7a7 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -649,6 +649,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('modelCatalog') modelCatalog(): Promise +/** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ +@Remote canOpenWorkspacePath(): boolean + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 8ffc7c8256..bc706a11e9 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -653,6 +653,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('modelCatalog') modelCatalog(): Promise +/** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ +@Remote canOpenWorkspacePath(): boolean + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index 5b83c19297..2efb877591 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.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/settings.md -settings.md: 467d0fc5fb4c97eeb6a18f36d879733e063bd36b -settings.zh.md: 42ed78f321021c5b7b55c51db6ef55b506550057 +settings.md: 3215de191b4ef8fecb373d280c8bc8e4a89bbc7e +settings.zh.md: 0c28ef381ee6ea8e59da64717575fbf80c8a2e5f diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 467d0fc5fb..3215de191b 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -273,6 +273,12 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote */ @Remote describe(): SettingsDescribeValue +/** + * Report whether this deployment can open an authored Agent preset directory natively. + * @returns true when the matching open operation is available. + */ +@Remote canOpenAgentPresetDirectory(): boolean + /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 42ed78f321..0c28ef381e 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -273,6 +273,12 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote */ @Remote describe(): SettingsDescribeValue +/** + * Report whether this deployment can open an authored Agent preset directory natively. + * @returns true when the matching open operation is available. + */ +@Remote canOpenAgentPresetDirectory(): boolean + /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 598956872e..498532ed69 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: bf43280f7fa01e6300caeaadeadb2c6c8f78cdd2 -typert.zh.md: c50663ad2546d864efc5648059dde735ae4de5bd +typert.md: 20734175cc6b853ae7eeb18e6d5fc91a97cdbe0d +typert.zh.md: e900011f24a5a232ab8764501594b563f320e090 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index bf43280f7f..20734175cc 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -185,9 +185,13 @@ interface TypertGateway { /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this exact source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. @@ -238,14 +242,6 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - -### `ctx.apiProxy` — `ApiProxy` - -Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. - -Source: [`packages/host/apiproxy/src/api/index.ts`](../../packages/host/apiproxy/src/api/index.ts) - ### `ctx.typert` — `TypertRegistry` @@ -322,9 +318,10 @@ Resolve strict generated definitions or conservative SRC markers against current /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this source and cancelling its active streams. */ -registerRemoteEvents(source: TypertRemoteEventSource): () => Promise +registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise /** * Invoke one live Remote method through strict generated reflection or SRC markers. diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index c50663ad25..e900011f24 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -185,9 +185,13 @@ interface TypertGateway { /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this exact source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. @@ -238,14 +242,6 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - -### `ctx.apiProxy` — `ApiProxy` - -Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. - -Source: [`packages/host/apiproxy/src/api/index.ts`](../../packages/host/apiproxy/src/api/index.ts) - ### `ctx.typert` — `TypertRegistry` @@ -322,9 +318,10 @@ Resolve strict generated definitions or conservative SRC markers against current /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this source and cancelling its active streams. */ -registerRemoteEvents(source: TypertRemoteEventSource): () => Promise +registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise /** * Invoke one live Remote method through strict generated reflection or SRC markers. diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 98e549a149..5a83be6c89 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.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-client.md -web-client.md: e67da4980af881e426f2bc031c6ec49cc1df2857 -web-client.zh.md: c5e9904226de66381a54bcb8a4783a062d70750f +web-client.md: 166ad50df661e37318c5ed2f271569c292cce39a +web-client.zh.md: cdf91958e23c0ea5c99562e6ca347947fbeff292 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index e67da4980a..166ad50df6 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -27,9 +27,9 @@ The Web boot kernel creates the module system, prefetches `immediately` entries, Host business services annotate callable methods with Typert Remote decorators. Host generation emits strict descriptors, runtime codecs, declaration merges, and source maps. The Client-side `api-remotes` assembly selects those generated contributions and mounts concrete methods under `ctx.remote.` and Session-scoped `agentCtx.remote.`. Feature packages depend on the generated service face, not the Gateway implementation or a Host package's runtime entry. -The Connection owns request correlation, the `/api` carrier, trust checks, Host description, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. API Proxy handles only `/api` endpoints that no strict Remote descriptor claims; new controller operations belong on generated Remote methods or explicit Remote streams. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. +The Connection owns request correlation, the `/api` carrier, trust checks, exact Fetch routes, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. Controller operations belong on generated Remote methods or explicit Remote streams; feature-owned downloads register exact Fetch routes. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. -The internal `$events` logical stream is the Connection generation source. A generation becomes connected only after the event source emits `ready` and `host.describe` succeeds. Host listeners are therefore attached before any controller begins a baseline read. `ctx.remote.$on()` delivers allowlisted ordinary events to the root Client Context and scoped waterfall events to the resolved Session Context; a waterfall listener returns a result, calls `next()`, or rejects. +The internal `$events` logical stream is the Connection generation source. Its opening `ready` frame carries the Host home used for path display and establishes the generation after Host listeners are attached, before any controller begins a baseline read. `ctx.remote.$on()` delivers allowlisted ordinary events to the root Client Context and scoped waterfall events to the resolved Session Context; a waterfall listener returns a result, calls `next()`, or rejects. ## Client models diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index c5e9904226..cdf91958e2 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -27,9 +27,9 @@ Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendore Host 业务 service 使用 Typert Remote decorator 标记可调用 method。Host generation 产出严格 descriptor、runtime codec、declaration merge 与 source map。Client 侧 `api-remotes` assembly 选择这些生成贡献,并把具体 method 挂到 `ctx.remote.` 与 Session scope 的 `agentCtx.remote.`。功能包依赖生成的 service face,而不依赖 Gateway 实现或 Host 包的运行时 entry。 -Connection 拥有 request correlation、`/api` carrier、trust check、Host description 与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。API Proxy 只处理没有被严格 Remote descriptor 认领的 `/api` endpoint;新的 controller 操作应进入生成的 Remote method 或显式 Remote stream。[API Gateway 参考](../api-gateway.zh.md)定义生成与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 +Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fetch 路由与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。Controller 操作应进入生成的 Remote method 或显式 Remote stream;功能自有的下载则注册精确 Fetch 路由。[API Gateway 参考](../api-gateway.zh.md)定义 generation 与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 -内部 `$events` logical stream 是 Connection generation source。只有 event source 发出 `ready` 且 `host.describe` 成功后,一代 connection 才会进入 connected。Host listener 因而先于任何 controller baseline read 挂载。`ctx.remote.$on()` 把 allowlist 内的普通 event 交付给 root Client Context,并把 scoped waterfall event 交付给已解析的 Session Context;waterfall listener 可以返回结果、调用 `next()` 或拒绝。 +内部 `$events` logical stream 是 Connection generation source。它的 opening `ready` frame 携带用于路径显示的 Host home,并在 Host listener 已挂载、任何 controller 开始 baseline read 之前建立 generation。`ctx.remote.$on()` 把 allowlist 内的普通 event 交付给 root Client Context,并把 scoped waterfall event 交付给已解析的 Session Context;waterfall listener 可以返回结果、调用 `next()` 或拒绝。 ## Client models diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 87cca8b0b2..bec49cab10 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: b806f5b40a2f5ddada40752367e3da23e86ea13d -web-server.zh.md: e5f4a8794d46a2b55d4a69e2cdd13c7dc4c2a6dd +web-server.md: d9b1ec007bc73274bb0c8b5d58745da1c76e6cb6 +web-server.zh.md: 66375534a89e2c4674e748d4178cb7a31761e57e diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index b806f5b40a..d9b1ec007b 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -2,7 +2,7 @@ English | [中文](web-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index e5f4a8794d..66375534a8 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -2,7 +2,7 @@ [English](web-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index e753147a72..af47b16740 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md [Naming rules](../docs/cookbook/adding-a-package.md#name-the-role-that-exists): -- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `runtime-diagnostics/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)). +- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), sets `rootDir: src` and `outDir: lib/types`, references workspace dependencies plus `runtime-diagnostics/invariants`, and registers in one aggregate. Packages with distinct Host and Client compiler faces use `tsconfig.host.json` and `tsconfig.client.json` leaves plus a solution-only root; ordinary two-entry Client plugins do not split ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. - Update package README and JSDoc contracts in the same commit as behavior, and verify them against code with [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md). Group READMEs declare subsystem ownership through a canonical English page link or justified [exemption](../scripts/verify-subsystem-pages.ts). diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index b93fd190e7..c47c67f406 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: aaea6ef47aabdc8baa63ef7a875de065bcae94b9 -README.zh.md: 8e87f97c413620ede4ee160384d00f18382cfdf7 +README.md: b4d8ddd84baa411a2675c95dda1701937e06fe6d +README.zh.md: 5dff7a59219a539c75d7ab85d293cd5b389018ec diff --git a/packages/api/README.md b/packages/api/README.md index aaea6ef47a..b4d8ddd84b 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -32,19 +32,18 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | [`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` | -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. Endpoints without a Remote definition fall through to the application's API Proxy. +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. ----- ## Related documentation -Start with the API Gateway reference to see the Remote model end to end, then the Typert subsystem page for the shared definitions, and the carrier and fallback packages for how calls travel and how endpoints without Remote definitions are served. +Start with the API Gateway reference to see the Remote model end to end, then the Typert subsystem page for the shared definitions and Connection for the physical carrier. - [API Gateway reference](../../docs/api-gateway.md) — the current-state reference for the Typert API Gateway: programming model, generation pipeline, and runtime invocation. - [Typert subsystem reference](../../docs/subsystems/typert.md) — the public contracts shared by protocol, Gateway, and consumer assemblies. - [Connection](../client/connection/README.md) — the RPC carrier, `/api` trust fence, and response envelopes behind every Remote call. -- [API Proxy](../host/apiproxy/README.md) — the fallback for endpoints without Remote descriptors. ## Dev Note diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 8e87f97c41..5dff7a5921 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -32,19 +32,18 @@ kind: "package-group" | [`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` | -Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。没有 Remote 定义的 endpoint 会回退到应用的 API Proxy。 +Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 ----- ## 相关文档 -先读 API Gateway 参考以端到端了解 Remote 模型,再读 Typert 子系统页了解共享定义,以及载体与回退包了解调用如何传输、没有 Remote 定义的 endpoint 如何被服务。 +先读 API Gateway 参考以端到端了解 Remote 模型,再读 Typert 子系统页了解共享定义,并通过 Connection 了解物理载体。 - [API Gateway 参考](../../docs/api-gateway.zh.md)——Typert API Gateway 的现状参考:编程模型、生成流水线与运行时调用。 - [Typert 子系统参考](../../docs/subsystems/typert.zh.md)——protocol、Gateway 与消费方装配共享的公共约定。 - [Connection](../client/connection/README.zh.md)——每次 Remote 调用背后的 RPC 载体、`/api` 信任围栏与响应封装。 -- [API Proxy](../host/apiproxy/README.zh.md)——没有 Remote 描述符的 endpoint 的回退路径。 ## 开发备注 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 8353039833..f1d957d143 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: 504ff95494d8c374426c18d0a77fb238457561e3 -README.zh.md: e556b4981ce5789e6fe9f74bb7d4dc9c5217ae4c +README.md: d10eed46d5534a2459995bd687942d799f009c7b +README.zh.md: 310b44cfe633758089771cda4040679c09373ec2 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 504ff95494..d10eed46d5 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -28,13 +28,13 @@ 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 unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypertLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. +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` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypertLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. 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 validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. -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, and per-Client queues. Its source factory attaches incremental listeners synchronously; Gateway then yields `{ type: 'ready' }` before iterating the source, so the Client starts baseline reads only after incremental delivery is ready. +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. ## Client service: `ClientRemote` (ctx key: `remote`) @@ -45,7 +45,7 @@ Each unary call validates positional inputs, constructs the descriptor's exact n `ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. -`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the `ready` item and `host.describe` jointly establish a Connection generation. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. +`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. Generated declaration merges provide the TypeScript API through the shared `TypertClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index e556b4981c..310b44cfe6 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -28,13 +28,13 @@ kind: "package-reference" 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context adapter 解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 -Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验和每 Client 队列由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener;Gateway 随后先产出 `{ type: 'ready' }`,再迭代 source,让 Client 只在增量投递就绪后开始 baseline 读取。 +Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验、每 Client 队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 ## Client 服务:`ClientRemote`(ctx key:`remote`) @@ -45,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;`ready` 项与 `host.describe` 共同建立一个 Connection generation。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 生成的声明合并通过共享的 `TypertClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c1158e8aa7..43991614b9 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md -README.md: 50361d1a6ae2f0b3b53a5bd928429414ab12bbce -README.zh.md: 5545198cce2e43513af73adfc9d4277831adae92 +README.md: 7f855bd1cd37c0799f10cadfaf2167fe34f7ea40 +README.zh.md: edad8d735dd914e44bcf283336f78b243e3715d9 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 50361d1a6a..7f855bd1cd 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -40,18 +40,18 @@ This package owns no physical transport or Host service discovery. It projects t The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. -The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active. Withdrawing the registration aborts active streams; API Proxy does not participate in event forwarding or Connection generation. +The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. ## Build boundary -An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host Typert graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. +Most repository packages belong to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. This package splits because its Host entry must participate in the Host Typert graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory, with one deliberate exception: `src/remote-events.ts` and `src/types.ts` are listed in BOTH faces' `files`, because the forwarded-event allowlist is the single control point over what a consumer can receive, and the Host forwarding loop and the Client `ctx.remote.$on` key face must read one declaration rather than two that could drift. That exception is not just a `files` entry. The root `tsconfig.base.json` maps `@deepseek-ai/dsh-api-remotes/types` to `src/types.ts` — the source plane, like every other workspace subpath and unlike the generated `/remote` artifacts, which have no `paths` entry and resolve through `exports` to built output. Both faces therefore admit the same allowlist and type projection into their own programs and emit byte-identical `remote-events` and `types` outputs into `lib/types`; the `.tsbuildinfo` files stay independent. No gate enforces the faces' source-file disjointness — `scripts/project-reference-faces.ts` only checks that a reference into a split project names the matching face — so this paragraph records why the double listing is intentional. -The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`. +The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; split only when the two source sets require different compiler faces. ## Model Experience diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 5545198cce..edad8d735d 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -40,18 +40,18 @@ Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件 监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 Agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 -Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项能证明增量投递已就绪。撤回注册会中止活动 stream;API Proxy 不参与事件转发或 Connection generation。 +Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。 ## 构建边界 -仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host Typert 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 +仓库中的多数包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。本包需要拆分,因为 Host 入口要参与 Host Typert 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录——只有一处刻意的例外:`src/remote-events.ts` 与 `src/types.ts` **同时**列进两个 face 的 `files`,因为转发事件名单是「消费端能收到什么」的唯一控制点,Host 转发循环与 Client 的 `ctx.remote.$on` 键面必须读同一份声明,而不是两份可能彼此漂移的声明。 这条例外不止是一行 `files`。根 `tsconfig.base.json` 把 `@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把同一份名单与类型投影收进各自的 program,并向 `lib/types` 发射逐字相同的 `remote-events` 与 `types` 输出;`.tsbuildinfo` 仍各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此本段记录这次双列为何是有意的。 -包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。 +包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;只有两组源码需要不同 compiler face 时才拆分。 ## 模型体验 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 41a4c618d3..fc66cdfceb 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/session-controller/README.md -README.md: c25a7b53908f140ce866c984938401116f8e0c54 -README.zh.md: 4cc8d550e1685d8f15c71279135ede5826011f5f +README.md: 689635b83bce71e3aecfedf371685a24bede55ab +README.zh.md: 68da7db60b38aca1b72c991005438ced45b348ea diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index c25a7b5390..689635b83b 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. @@ -39,6 +39,7 @@ The Session object also carries local submission echoes: `session.beginSubmissio | Field | Default | Meaning | |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | Maximum physical size of a cold Session artifact eligible for blankness verification; `0` disables probes | +| `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 4cc8d550e1..68da7db60b 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 @@ -39,6 +39,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 | 字段 | 默认值 | 含义 | |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | 可进行空白状态验证的冷 Session 工件最大物理大小;`0` 禁用探测 | +| `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index 3b05b15761..cc5d27a374 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/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/settings-controller/README.md -README.md: b545b27ff1716e54be1a25a9bbd4131f86a12b68 -README.zh.md: 46977c6e0e3fbd43eb0a688b37e7db6eb7007f85 +README.md: 5032b8ff352d35abc05384719932691a93a49f84 +README.zh.md: 756a7fcdd1cd03b157ff5781aadd4db092c6d374 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index b545b27ff1..5032b8ff35 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -29,7 +29,7 @@ Mount this package as a Loader entry in a profile that serves browser configurat `settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. -`settings.openSettingsDocument()` prepares the provider-owned document and opens it with the native text-editor intent. `settings.openAgentPresetDirectory(id)` resolves only a user-authored preset and either opens its directory or returns the path when native opening is unavailable; neither method accepts a browser-supplied filesystem target. +`settings.openSettingsDocument()` prepares the provider-owned document and opens it with the native text-editor intent. `settings.canOpenAgentPresetDirectory()` reports native-opening availability when the preset page becomes visible. `settings.openAgentPresetDirectory(id)` resolves only a user-authored preset and either opens its directory or returns the path when native opening is unavailable; neither open method accepts a browser-supplied filesystem target. ----- diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index 46977c6e0e..756a7fcdd1 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" `settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings service 的三种写入操作,并返回该 namespace 的新脱敏视图;过期写入使用 `settings-conflict`,其他 provider 拒绝使用 `settings-rejected`。 -`settings.openSettingsDocument()` 准备 provider 持有的文档,并用原生文本编辑器意图将其打开。`settings.openAgentPresetDirectory(id)` 只解析用户创作的 preset,并在原生打开不可用时返回目录路径;两种方法都不接受浏览器提供的文件系统目标。 +`settings.openSettingsDocument()` 准备 provider 持有的文档,并用原生文本编辑器意图将其打开。`settings.canOpenAgentPresetDirectory()` 在 preset 页面显示时报告原生打开能力。`settings.openAgentPresetDirectory(id)` 只解析用户创作的 preset,并在原生打开不可用时返回目录路径;两个打开方法都不接受浏览器提供的文件系统目标。 ----- diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 27f1761cea..29a111effe 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -50,7 +50,7 @@ The stack has one-way knowledge, documented in the [Web Client architecture](../ Non-negotiables across the layers: - **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer. -- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest

`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). +- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes, and minting stays in Connection ([unary Remote migration](../../.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md)). - **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `../api/session-controller/src/client/sessions/notifier.ts`. - **The web layer is pure presentation.** Nothing that is only "how to draw" enters the session log. Tool cards derive in the Client from raw call/result events and persisted result metadata; process-local control state uses its own snapshots and frames. Unknown or malformed tool data falls back to the generic form. A new *model-visible* input still requires a session event (repo-wide rule). diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 75ab67d3de..6be2c4aa21 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: 72f8ee150c1879f920f189ad3d0221fce449c451 -README.zh.md: 3ef1e22554f568e5d4fe24556fafbc7ce0413209 +README.md: af757608aa7face6854aaf9d103654f974b51ed4 +README.zh.md: fef7248abe1e19595b90c43a6a1b9abf0aafa88a diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 72f8ee150c..af757608aa 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -1,5 +1,5 @@ --- -description: "Browser-host wire layer for the web GUI: the shared API client, event-stream delivery with reconnect, the /api HTTP bridge, and the browser-trust fence, for users and maintainers composing or debugging the connection." +description: "Browser-host wire layer for the web GUI: Remote RPC, event-stream delivery with reconnect, exact Fetch routes, the /api HTTP bridge, and the browser-trust fence." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Protocol and connection-generation layer. The Client plugin mounts `ctx.connection`, containing the shared API client, current-page loopback state, generation-scoped observable `hostDescription`, a generic RPC carrier, and the registration point for one generation source and the connection loop. A generation publishes `hostDescription` and calls `onConnected` only after its source is ready and `host.describe` succeeds; source completion, failure, withdrawal, or an explicit stop clears that value before `ConnectionController` reconnects with backoff. +The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, a generic RPC carrier, the active generation and its Host facts, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` reconnects with backoff. ## Table of Contents @@ -25,7 +25,7 @@ Protocol and connection-generation layer. The Client plugin mounts `ctx.connecti ## Use this package -The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks. Typert Gateway claims its Remote endpoints first, and unclaimed requests fall through to API Proxy. Loopback hostname classification remains package-internal to the browser-facing Client state. +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD` route registry. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. ----- @@ -41,9 +41,9 @@ Before authentication, every request still passes `src/api-request-trust.ts`. It ## Connection generation -API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready' }` item before events. `ConnectionController` waits for that item and `host.describe` in parallel; `onConnected` cannot start baseline reads until both succeed, so baseline acquisition cannot race ahead of incremental observation. +API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation. -An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. The controller immediately withdraws `hostDescription`, publishes `reconnecting`, and rebuilds the `$events` plus `host.describe` handshake after backoff. Gateway mux reconnects the physical WebSocket; Connection generation reopens the logical stream and establishes the next baseline starting point. +An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. The controller immediately withdraws the generation, publishes `reconnecting`, and reopens `$events` after backoff. Gateway mux reconnects the physical WebSocket; Connection generation reopens the logical stream and establishes the next baseline starting point. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 3ef1e22554..fef7248abe 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向用户与维护者的浏览器-宿主线层说明:共享 API 客户端、带重连的事件流投递、/api HTTP 桥与浏览器信任栅栏,用于组合或排查连接。" +description: "Web GUI 的浏览器-Host 线层:Remote RPC、带重连的事件流投递、精确 Fetch 路由、/api HTTP 桥与浏览器信任栅栏。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -协议与连接世代层:Client 插件挂载 `ctx.connection`,包含共享 API 客户端、当前页面的 loopback 状态、按 generation 生效的可观察 `hostDescription`、通用 RPC carrier,以及单一 generation source 与连接循环的注册面。每个 generation 只在 source 已就绪且 `host.describe` 成功后发布 `hostDescription` 并调用 `onConnected`;source 结束、失败、被撤回或显式 stop 都会清空该值,再由 `ConnectionController` 退避重连。 +本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC carrier、当前 generation 及其 Host 信息,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 退避重连。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 API Proxy 一元调用与通用 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;Typert Gateway 先认领自己的 Remote endpoint,未认领的请求再回退 API Proxy。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD` 路由注册表。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 ----- @@ -41,9 +41,9 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi ## Connection generation -API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready' }` 项,再发送事件。`ConnectionController` 并行等待该 ready 与 `host.describe`;只有两者都成功才允许 `onConnected` 启动 baseline 读取,因此 baseline 不会跑在增量 listener 前面。 +API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。 -`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 `hostDescription`、发布 `reconnecting`,并在退避后重建 `$events` 与 `host.describe` 握手。Gateway mux 自己负责重建底层 WebSocket;Connection 世代负责重建 logical stream 与 baseline 起点。 +`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 generation、发布 `reconnecting`,并在退避后重开 `$events`。Gateway mux 自己负责重建底层 WebSocket;Connection generation 负责重开 logical stream 并建立下一次 baseline 起点。 ## 模型体验 diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index f3461bcbf7..042caf6f1b 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 495d38631257bef3f9dbf74b7762c357b90779e4 -README.zh.md: fdf0a994b00c8a28e2bd0dc4dfa7238d25c4774b +README.md: 60a7f0ec9356c6107a32bb7c828746d54511d0f6 +README.zh.md: 64663fd76e0a8e3fd4cb799b05e81db7b62c0bde diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 495d386312..60a7f0ec93 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -43,7 +43,7 @@ When the roster carries the self-referential `cordis` preset, a dashed add-card

Implementation internals — click to expand -Options and the current default both come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change. +Options and the current default both come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index fdf0a994b0..64663fd76e 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -选项与当前默认值都来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的字段。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、delete 与 settings 所有的目录打开操作负责管理名单并驱动宿主桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。 +选项与当前默认值都来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是 Host 在创建时解析的字段。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被 Host 拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、delete 与 settings 所有的目录打开操作负责管理名单并驱动 Host 桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。
diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 83db7573d4..60f05a20e3 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: e5f9bf2a4ccbf6218a714762bc34a1a443c63022 -README.zh.md: 420a1e28a2893ca41cf24b720b2fbe416b1a38f6 +README.md: 04f9ff96c1573eb66a64de1d174e257c3a3608a2 +README.zh.md: f960d3da993fee3166486d8407c32e4f18278153 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index e5f9bf2a4c..04f9ff96c1 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -25,7 +25,7 @@ This package renders the deliverables row a finished turn ends with — the file ## Use this package -Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the Host opener, with relative paths resolved against the session cwd; a **Show in folder** action opens the session workspace when the page is loopback and the Host reports it can open paths. +Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the Host opener, with relative paths resolved against the session cwd; when the row first appears, it queries `session.canOpenWorkspacePath()`, and a **Show in folder** action opens the session workspace only when the page is loopback and that query succeeds with `true`. ### The row @@ -87,7 +87,7 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Files created indirectly by terminal commands remain outside the matching vocabulary** — naming such a file in inline code does not make it clickable unless a successful mutation location also records that path. -- **Native folder handoff targets the Host desktop** — a browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener; SSH forwarding that makes a remote Host look loopback-local must set the gateway's `nativeOpen: false`. +- **Native folder handoff targets the Host desktop** — a browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener; SSH forwarding that makes a remote Host look loopback-local must set the Session Controller's `nativeOpen: false`. ### Dev Note diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 420a1e28a2..f960d3da99 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经宿主打开器打开文件,相对路径按会话 cwd 解析;页面为 loopback 且宿主报告可打开路径时,**在文件夹中显示**动作会打开会话工作区。 +与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经 Host 打开器打开文件,相对路径按会话 cwd 解析;该行首次显示时会查询 `session.canOpenWorkspacePath()`,只有页面为 loopback 且查询成功返回 `true` 时,**在文件夹中显示**动作才会打开会话工作区。 ### 该行 @@ -87,7 +87,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端命令间接创建的文件仍不在匹配词表内**——除非某个成功修改位置也记录了该路径,否则在行内代码中点名这类文件不会使其可点击。 -- **原生文件夹交接以宿主桌面为目标**——经非 loopback 权威访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端宿主看似 loopback 本地,部署必须为网关设置 `nativeOpen: false`。 +- **原生文件夹交接以 Host 桌面为目标**——经非 loopback authority 访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端 Host 看似 loopback 本地,部署必须为 Session Controller 设置 `nativeOpen: false`。 ### 开发备注 diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 4f562d8962..9503648c05 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: e066f25f6413f72a66e9f0df487de3f32e1e42d4 -README.zh.md: 44fc3077234a8a26f26f349ba1494931127af919 +README.md: 19b4debcaa3de4ca2370f8900adc34205741db82 +README.zh.md: 2870048e3fcb9ddb4303a917d54160dd8b3f1e6e diff --git a/packages/host/README.md b/packages/host/README.md index e066f25f64..19b4debcaa 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -1,5 +1,5 @@ --- -description: "Package map for the web GUI host half: the shared API gateway, the HTTP server it rides on, the SPA dist server, the workspace-directory picking seam, and the plugin inventory projection." +description: "Package map for the web GUI host half: the HTTP and SPA servers, workspace-directory picking implementations, and the plugin inventory projection." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `host/` group is the web GUI host half: the API gateway every client shape shares, the plain HTTP server it rides on, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, and the read-only plugin inventory projection. All eight packages are product packages; the browser half that consumes the gateway lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The gateway contract is transport-independent, and the picker backends replace one another behind the shared seam. +The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, and the read-only plugin inventory projection. All seven packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam. ## Table of Contents @@ -22,11 +22,10 @@ The `host/` group is the web GUI host half: the API gateway every client shape s ## Packages -Eight packages play the host roles; each package README owns its contract and configuration. +Seven packages play the host roles; each package README owns its contract and configuration. | Package | Role | ctx key | |---|---|---| -| [`apiproxy/`](apiproxy/README.md) | Shared API gateway: the typed client↔host contract, fetch carriers, and the gateway service | `ctx.apiProxy` | | [`webserver/`](webserver/README.md) | Browser HTTP server: named routes, upgrades, index taps, and the fallback seat | `ctx.webServer` | | [`frontend-static/`](frontend-static/README.md) | SPA dist server on the webserver fallback seat | consumes `ctx.webServer` | | [`directory-picker/`](directory-picker/README.md) | Workspace-directory picking seam: capability contract and error vocabulary | `ctx.directoryPicker` | @@ -40,11 +39,11 @@ Eight packages play the host roles; each package README owns its contract and co ## Related documentation -Start with the subsystem references for the transport and the workspace records, then the layering decision behind the gateway. +Start with the subsystem references for the transport and the workspace records, then the layering decision behind the Web client. - [HTTP server subsystem](../../docs/subsystems/web-server.md) — the webserver's routes, matching order, and config. - [Workspace subsystem](../../docs/subsystems/workspace.md) — the workspace records the directory picker feeds. -- [GUI layering and RPC protocol RFC](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) — why the gateway contract is channel-independent. +- [Web config-tree boot and transport layering](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) — ownership of the Web transport layers. ## Dev Note diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 44fc307723..2870048e3f 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -1,5 +1,5 @@ --- -description: "web GUI 宿主侧的包映射:共享 API 网关、承载它的 HTTP 服务器、SPA dist 服务器、工作区目录选择 seam 与插件清单投影。" +description: "Web GUI Host 侧的包映射:HTTP 与 SPA 服务器、工作区目录选择实现和插件清单投影。" kind: "package-group" --- @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`host/` 组是 web GUI 宿主侧:所有客户端形态共享的 API 网关、承载它的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam,以及只读的插件清单投影。这八个包都是产品包;消费网关的浏览器半侧位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 web 应用。网关约定与传输无关,选择器后端可在共享 seam 后互相替换。 +`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam,以及只读的插件清单投影。这七个包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。 ## 目录 @@ -22,11 +22,10 @@ kind: "package-group" ## 包 -八个包分别承担宿主角色;各包的 README 拥有自己的约定与配置。 +七个包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。 | 包 | 职责 | ctx 键 | |---|---|---| -| [`apiproxy/`](apiproxy/README.zh.md) | 共享 API 网关:类型化的客户端↔宿主约定、fetch 载体与网关服务 | `ctx.apiProxy` | | [`webserver/`](webserver/README.zh.md) | 浏览器 HTTP 服务器:具名路由、upgrade、index 转换与回退席位 | `ctx.webServer` | | [`frontend-static/`](frontend-static/README.zh.md) | 占据 webserver 回退席位的 SPA dist 服务器 | 消费 `ctx.webServer` | | [`directory-picker/`](directory-picker/README.zh.md) | 工作区目录选择 seam:能力约定与错误词汇 | `ctx.directoryPicker` | @@ -40,11 +39,11 @@ kind: "package-group" ## 相关文档 -先从传输与工作区记录的子系统参考读起,再看网关背后的分层决策。 +先从传输与工作区记录的子系统参考读起,再看 Web Client 背后的分层决策。 - [HTTP 服务器子系统](../../docs/subsystems/web-server.zh.md)——webserver 的路由、匹配顺序与配置。 - [工作区子系统](../../docs/subsystems/workspace.zh.md)——目录选择器所喂给的工作区记录。 -- [GUI 分层与 RPC 协议 RFC](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)——网关约定为何与通道无关。 +- [Web 配置树启动与传输分层](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)——Web 传输各层的所有权。 ## 开发备注 diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 666f003a4a..e5f84d4a7a 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/webserver/README.md -README.md: 73b78511ca3c7d20dfab78499e052b00bc662a28 -README.zh.md: 00524e4ecc5f307fdadc60bb749d8628a4871856 +README.md: 4211c640a0e7386e3380a4289e5ba619818bd094 +README.zh.md: 6ad4f549cd234221cfef88ef0bfd9541fbe50043 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 73b78511ca..4211c640a0 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -88,7 +88,7 @@ Read these when the server contract is not enough: the subsystem reference, then - [HTTP server subsystem](../../../docs/subsystems/web-server.md) — routes, matching order, and the config the server accepts. - [SPA dist server](../frontend-static/README.md) — the shipped owner of the fallback seat. -- [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) — why feature plugins own every route. +- [Web config-tree boot and transport layering](../../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) — why feature plugins own every route. - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-webserver) — every accepted config field and its source declaration. ----- diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 00524e4ecc..6ad4f549cd 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -88,7 +88,7 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的 - [HTTP 服务器子系统](../../../docs/subsystems/web-server.zh.md)——路由、匹配顺序与服务器接受的配置。 - [SPA dist 服务器](../frontend-static/README.zh.md)——回退席位的随附持有者。 -- [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)——功能插件为何拥有每条路由。 +- [Web 配置树启动与传输分层](../../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)——功能插件为何拥有每条路由。 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-webserver)——每个受支持配置字段及其源声明。 ----- From 26f1eda42a6d1ed9e2f67c65eca67c2678e372c6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:21:48 +0800 Subject: [PATCH 09/11] test(connection): allow non-Error rejection fixture --- packages/client/connection/tests/connection.client.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 9ac090e947..94038abf8e 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -144,6 +144,7 @@ describe('connection lifecycle', () => { { label: 'ends normally', fail: () => Promise.resolve() }, { label: 'rejects with a non-Error reason', + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test fail: () => Promise.reject('fixture offline'), }, ])('retries when the generation source $label before reporting ready', async ({ fail }) => { From b0c44e54baff9207dd57d3e13ec0b1df483f3155 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:48:16 +0800 Subject: [PATCH 10/11] fix: build --- tsconfig.client.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.client.json b/tsconfig.client.json index 115c328537..ddeb2e84b7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -100,6 +100,7 @@ { "path": "./packages/client/ui-renderer" }, { "path": "./packages/client/ui-session" }, { "path": "./packages/client/web" }, + { "path": "./packages/context/file-reference" }, { "path": "./apps/web" } ] } From 9fa87800a2e67238891e6d2a3cb5a078cbf1d6cf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:58:43 +0800 Subject: [PATCH 11/11] fix(api): keep file-reference output in its project --- packages/api/session-controller/tsconfig.client.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 219893c980..030ed712d1 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -16,6 +16,7 @@ { "path": "../../attachment/attachment" }, { "path": "../../client/connection/tsconfig.client.json" }, { "path": "../../client/store" }, + { "path": "../../context/file-reference" }, { "path": "../../core/session" }, { "path": "../../jobs/jobs" }, { "path": "../../llm/llm" },