mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(api): remove ApiProxy package
This commit is contained in:
@@ -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:^",
|
||||
|
||||
@@ -32,9 +32,6 @@
|
||||
{
|
||||
"path": "../../packages/bundle/web-app"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/webserver"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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<SessionOpenWorkspacePathValue>',
|
||||
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<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>',
|
||||
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<void>',
|
||||
signature: 'registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise<void>',
|
||||
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<K extends string = string, V = unknown> {\n readonly valueSchema: ZodType<V>;\n readonly __key?: K;\n}',
|
||||
},
|
||||
{
|
||||
name: 'DownloadsApi',
|
||||
declaration: 'export interface DownloadsApi {\n sessionLog(request: {\n sessionId: SessionId;\n includeDescendants?: boolean;\n }, signal: AbortSignal): Promise<Response>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'DshEnvironment',
|
||||
declaration: 'export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>;',
|
||||
@@ -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<T> = {\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<unknown>;\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<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 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/<id>/`, and each referenced image under `media/<attachmentId>.<ext>`. `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.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
### 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/<method>` 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.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 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.
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 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
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
|
||||
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.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
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.
|
||||
|
||||
</details>
|
||||
@@ -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)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
当 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/<id>/` 下,每张被引用的图片位于 `media/<attachmentId>.<ext>` 下。`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 的穷尽式真源。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
### 设计理念
|
||||
|
||||
本包建立在一个分离之上:API 约定与通道无关,物理传输只是围绕它的载体。协议消息构成一个二元可辨识联合——`ClientRequest`(POST `/api/<method>` 的请求体)与 `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 分发与流;业务包持有各自的方法和结果类型。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 进一步探索
|
||||
|
||||
当包级约定不够用时阅读以下内容。它们从分层决策进入浏览器侧消费架构与相邻子系统。
|
||||
|
||||
- [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)——每个受支持配置字段及其源声明。
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
无。该协议约定与 fetch 载体只搬运已组装好的消息,不注册任何面向模型的内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
|
||||
这些限制说明网关在何处不合适;它们是当前包约束,不是任务积压。
|
||||
|
||||
- **没有协议版本字段**——客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者的工作上下文——点击展开</summary>
|
||||
|
||||
本开发备注是维护者的工作上下文:开放方向。它明确不具权威性——已交付行为与限制见上文各节。协议版本字段等待独立发布的客户端;多用户载体必须把提供方搜索诊断替换为可安全公开的文本;按连接的自适应目录选择(本地浏览器用 native、远程浏览器用 browse)仍是宿主表面的一个未定方向。
|
||||
|
||||
</details>
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* Host-side ApiProxy implementation. Signature discipline: unary takes the
|
||||
* narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
|
||||
*/
|
||||
|
||||
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<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
|
||||
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)}"`,
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<Parameters<DownloadsApi['sessionLog']>[0]>
|
||||
@@ -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<Response>
|
||||
}
|
||||
@@ -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<Wire<RequestPayload<'host.describe'>>>
|
||||
|
||||
/** 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<Wire<ResponseValue<'host.describe'>>>
|
||||
@@ -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<RpcResponse<{
|
||||
version: string
|
||||
cwd: string
|
||||
provider?: string
|
||||
model?: string
|
||||
attachedSessions: number
|
||||
home: string
|
||||
canOpenPath: boolean
|
||||
}>>
|
||||
|
||||
}
|
||||
@@ -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<SessionId>
|
||||
@@ -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'
|
||||
@@ -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<K extends keyof RpcMethodMap> = Parameters<RpcMethodMap[K]>[0]['payload']
|
||||
|
||||
/** Business return value of method K (reaches through the RpcResponse narrow form to infer the ok value of result). */
|
||||
export type ResponseValue<K extends keyof RpcMethodMap> =
|
||||
Awaited<ReturnType<RpcMethodMap[K]>> extends RpcResponse<infer T> ? T : never
|
||||
@@ -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<ContractType>` is unusable across the board; anchoring is always
|
||||
* written `satisfies z.ZodType<Wire<ContractType>>` — 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> = T extends readonly (infer E)[] ? Wire<E>[]
|
||||
: T extends object ? { [K in keyof T]: Wire<T[K]> | 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<RpcId>
|
||||
|
||||
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
|
||||
export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
|
||||
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
|
||||
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<RpcError>
|
||||
|
||||
/**
|
||||
* Business success/failure result schema (generic, reusable).
|
||||
* @param value - Schema for the business value.
|
||||
* @returns Schema for RpcResult<T>.
|
||||
*/
|
||||
export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodUnion<readonly [z.ZodType, z.ZodType]> {
|
||||
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<ClientRequest>
|
||||
|
||||
/** 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<ServerResponse>
|
||||
|
||||
/** Wire full-form union (discriminated by type). */
|
||||
export const rpcMessageSchema = z.discriminatedUnion('type', [
|
||||
clientRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
|
||||
serverResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
|
||||
])
|
||||
@@ -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<T> = { 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<T>(error: unknown): RpcResult<T> {
|
||||
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<P> {
|
||||
rpcId: RpcId
|
||||
payload: P
|
||||
}
|
||||
|
||||
/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */
|
||||
export interface RpcResponse<T> {
|
||||
rpcId: RpcId
|
||||
result: RpcResult<T>
|
||||
}
|
||||
|
||||
// ---- Wire full forms ----
|
||||
|
||||
/** Call initiated by the client (wire carrier: POST /api/<method> 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<unknown>
|
||||
}
|
||||
|
||||
/** Authoritative wire full-form union; narrow via `switch (message.type)`. */
|
||||
export type RpcMessage = ClientRequest | ServerResponse
|
||||
@@ -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<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Wire<ResponseValue<K>>> } = {
|
||||
'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<Response>
|
||||
|
||||
/**
|
||||
* 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<Response> {
|
||||
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<K extends keyof RpcMethodMap>(
|
||||
method: K,
|
||||
payload: RequestPayload<K>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<ResponseValue<K>>> {
|
||||
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<K>
|
||||
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<Response> {
|
||||
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')
|
||||
}
|
||||
@@ -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<Wire<RequestPayload<K>>>
|
||||
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>, signal: AbortSignal): Promise<RpcResponse<ResponseValue<K>>>
|
||||
}
|
||||
}
|
||||
|
||||
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<unknown>): 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<K extends keyof RpcMethodMap>(
|
||||
api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
|
||||
): Promise<Response> {
|
||||
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<Response> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<Config> = z.object({
|
||||
nativeOpen: z.boolean(),
|
||||
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
|
||||
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
|
||||
})
|
||||
|
||||
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
|
||||
@@ -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 */
|
||||
@@ -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/<id>/<filename>`; each image referenced
|
||||
* by any included log under `media/<attachmentId>.<ext>` (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<SessionLogExportDeps, 'sessions'>,
|
||||
id: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
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<ImageAttachmentRef['mediaType'], string> = {
|
||||
'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<string, ImageAttachmentRef>): 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<string, ImageAttachmentRef>): 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<string, ImageAttachmentRef> {
|
||||
const refs = new Map<string, ImageAttachmentRef>()
|
||||
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<SessionLogZipEntry> {
|
||||
const media = new Map<string, ImageAttachmentRef>()
|
||||
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>([sessionId])
|
||||
const collect = async function* (
|
||||
nodes: readonly SessionLineageNode[],
|
||||
): AsyncGenerator<SessionLogZipEntry> {
|
||||
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<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
if (controller.desiredSize === null || controller.desiredSize > 0) return
|
||||
await new Promise<void>((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<Uint8Array>,
|
||||
capacity: ResponseCapacityGate,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
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<Uint8Array>,
|
||||
capacity: ResponseCapacityGate,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
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<Uint8Array> {
|
||||
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<Uint8Array>({
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof SettingsProvider>[0], options?: {
|
||||
doc?: Record<string, unknown>
|
||||
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<string | undefined> {
|
||||
return Promise.resolve(this.preparedPath ?? this.documentPath)
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
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<string, string>()
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof CredentialProvider>[0], options?: { shadowed?: string[] }) {
|
||||
super(ctx)
|
||||
this.shadowed = new Set(options?.shadowed ?? [])
|
||||
}
|
||||
|
||||
private readonly shadowed: Set<string>
|
||||
|
||||
resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
|
||||
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<CredentialInfo> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<CredentialRecord | undefined> {
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
describeRecord(): Promise<CredentialRecordInfo> {
|
||||
return Promise.resolve({ configured: false, writable: true })
|
||||
}
|
||||
|
||||
listRecords(): Promise<readonly CredentialRecordEntry[]> {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
modifyRecord(
|
||||
_key: CredentialKey,
|
||||
mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
|
||||
): Promise<CredentialRecord | undefined> {
|
||||
return mutate(undefined)
|
||||
}
|
||||
|
||||
deleteRecord(): Promise<void> {
|
||||
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<string, unknown>
|
||||
readOnly?: boolean
|
||||
documentPath?: string
|
||||
preparedPath?: string
|
||||
}
|
||||
credentials?: false | { shadowed?: string[] }
|
||||
}): Promise<Context> {
|
||||
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<void>,
|
||||
): Promise<Array<readonly [SettingsNamespace, number]>> {
|
||||
const updates: Array<readonly [SettingsNamespace, number]> = []
|
||||
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')])
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
})
|
||||
@@ -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<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`host-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
function expectOk<T>(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())
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>> {
|
||||
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['host']>
|
||||
} = {}): 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<Response>((_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<Response>(() => {}) }, 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<Response>(() => {}) }, 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<Response> {
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<Response> {
|
||||
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<AbstractApiClient['mintRpcId']> {
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, SessionRawArtifact>,
|
||||
descendants: SessionLineageNode[] = [],
|
||||
services: {
|
||||
query?: boolean
|
||||
persistence?: boolean | 'throw' | 'unsupported'
|
||||
attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise<ReturnType<typeof storedImage>>)
|
||||
sessions?: {
|
||||
get(id: SessionId): { readonly id: SessionId } | undefined
|
||||
flush(session: { readonly id: SessionId }): Promise<boolean>
|
||||
}
|
||||
readRaw?: (id: SessionId, signal?: AbortSignal) => Promise<SessionRawArtifact | undefined>
|
||||
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<Uint8Array> {
|
||||
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/<id>/ 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<string, SessionRawArtifact> = {
|
||||
'session-root': artifact('session-root', undefined, 'stale root'),
|
||||
'child-a': artifact('child-a', sid('session-root'), 'stale child'),
|
||||
}
|
||||
const durable: Record<string, SessionRawArtifact> = {
|
||||
'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<AbortSignal>((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<AbortSignal>((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<AbortSignal>((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/<id>.<ext>', 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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Generated
+15
-73
@@ -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':
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
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<Record<string, string>> = {
|
||||
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<Record<string, string>> = {
|
||||
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<Record<string, string>> = {
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -114,7 +114,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'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.' },
|
||||
|
||||
+6
-3
@@ -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-<name> 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"],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user