Merge master (slash/input/session architecture) into web-session-model-selector
This commit is contained in:
6
packages/host/apiproxy/README.i18n.yaml
Normal file
6
packages/host/apiproxy/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f
|
||||
README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10
|
||||
@@ -1,6 +1,8 @@
|
||||
# @deepseek-ai/dsh-host-apiproxy
|
||||
|
||||
The ApiProxy front layer every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser) and the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side). Host assembly lives in `dsh-host-runtime`.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml).
|
||||
|
||||
## Contract layer (`/api`)
|
||||
|
||||
@@ -10,7 +12,9 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
|
||||
|
||||
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
|
||||
|
||||
Session model routing is a session-domain contract. `session.history` returns the selected `modelTarget`, `session.models` returns that target with provider-grouped advisory model metadata and provider-local lookup failures, and `session.selectModel` replaces the target selected for the next prompt-assembly boundary. Catalog membership is not validation: a registered provider may accept an unlisted model, while an unregistered provider returns `model-unavailable`.
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
@@ -26,6 +30,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `dsh-host-runtime` and is still a stub there.
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
|
||||
35
packages/host/apiproxy/README.zh.md
Normal file
35
packages/host/apiproxy/README.zh.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# @deepseek-ai/dsh-host-apiproxy
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml)。
|
||||
|
||||
## 契约层(`/api`)
|
||||
|
||||
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。
|
||||
|
||||
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
|
||||
|
||||
mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包定义客户端与宿主间的协议契约和载体,其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-apiproxy",
|
||||
"description": "ApiProxy front layer: the TS contract (api/) and the fetch carrier pair (fetch/); host assembly lives in dsh-host-runtime",
|
||||
"description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -40,12 +40,19 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"schemastery": "^3.18.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -53,6 +60,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -4,21 +4,31 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
|
||||
WorkspaceMoveInvalidError, WorkspaceNameConflictError,
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelTarget,
|
||||
MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
import { RpcId } from './api/rpc.ts'
|
||||
import type {
|
||||
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -140,6 +150,7 @@ function summarize(session: Session, running: boolean): SessionSummary {
|
||||
sessionId: session.id,
|
||||
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
|
||||
running,
|
||||
blank: session.events.length === 0,
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
}
|
||||
@@ -164,6 +175,9 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
|
||||
sessionId: meta.id,
|
||||
updatedAt,
|
||||
running: false,
|
||||
// Lazy persistence keeps never-appended sessions out of list(): a cold
|
||||
// session necessarily has events, so blank is constantly false here.
|
||||
blank: false,
|
||||
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
|
||||
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
|
||||
filters those out (legacy logs are not served); the conditional mirrors
|
||||
@@ -172,12 +186,14 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-level default agent routing (same shape as bootHost's HostDefaults; avoids an impl→index reverse import). */
|
||||
/** Resolved Host routing and project-directory defaults consumed by the API implementation. */
|
||||
export interface ApiProxyDefaults {
|
||||
provider: string
|
||||
model: string
|
||||
/** Default project directory for new sessions whose create request carries no cwd. */
|
||||
cwd: string
|
||||
/** Parent directory for name-created workspaces. */
|
||||
workspaceRoot: string
|
||||
}
|
||||
|
||||
/** The tool/call payload fields the presenter path reads. */
|
||||
@@ -273,51 +289,115 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
|
||||
*/
|
||||
class SessionNotFound extends Error {}
|
||||
|
||||
/** Requested identity already belongs to a session with another project cwd. */
|
||||
class SessionCwdConflict extends Error {
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
readonly requestedCwd: string,
|
||||
readonly existingCwd: string | undefined,
|
||||
) {
|
||||
super(
|
||||
`session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; `
|
||||
+ `requested ${JSON.stringify(requestedCwd)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Host failed before the registry could adopt a name-created directory. */
|
||||
class WorkspaceDirectoryCreationError extends Error {}
|
||||
|
||||
/** Shared workspace-not-found error response of the workspace.* mutation rows. */
|
||||
function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `workspace "${workspaceId}" not found`,
|
||||
details: { workspaceId },
|
||||
})
|
||||
}
|
||||
|
||||
/** Wire projection of one workspace entity (the workspace.* value row). */
|
||||
function workspaceView(workspace: Workspace): WorkspaceView {
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
path: workspace.path,
|
||||
title: workspace.title,
|
||||
sessionIds: [...workspace.sessionIds],
|
||||
createdAt: workspace.createdAt,
|
||||
updatedAt: workspace.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire projection of the durable record carried by `domain/changed`. */
|
||||
function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
|
||||
const record: WorkspaceRecord = workspaceRecord.parse(value)
|
||||
return {
|
||||
workspaceId: workspaceId as WorkspaceId,
|
||||
path: record.path,
|
||||
title: record.title,
|
||||
sessionIds: [...record.sessionIds],
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implement ApiProxy over the ctx composed by bootHost.
|
||||
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
|
||||
* @param defaults - host-level default provider/model: injected as
|
||||
* agentOptions on create/resume, reported by describe from the same source.
|
||||
* Implement ApiProxy over a composed host context.
|
||||
* @param ctx - a context with the Host spine and Workspace registry mounted.
|
||||
* @param defaults - host routing and project-directory defaults.
|
||||
* @returns the ApiProxy implementation.
|
||||
*/
|
||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
||||
const agentOptions = { provider: defaults.provider, model: defaults.model }
|
||||
type WebLlmTargetRef = AgentLlmTargetRef & { current: ModelTarget }
|
||||
const targets = new WeakMap<Agent, WebLlmTargetRef>()
|
||||
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
||||
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
|
||||
const sessionCreations = new Map<SessionId, Promise<Agent>>()
|
||||
/** Serializes path ownership checks with record creation across spellings. */
|
||||
let workspaceCreationChain = Promise.resolve()
|
||||
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
||||
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
||||
|
||||
/** Install or return the session-local target that prompt assembly snapshots. */
|
||||
function targetFor(agent: Agent): WebLlmTargetRef {
|
||||
const installed = targets.get(agent)
|
||||
if (installed !== undefined) return installed
|
||||
const logged = agent.session.requestHeader()?.config
|
||||
const target: WebLlmTargetRef = {
|
||||
current: logged === undefined
|
||||
? { provider: defaults.provider, model: defaults.model }
|
||||
: { provider: logged.provider, model: logged.model },
|
||||
assembled: undefined,
|
||||
}
|
||||
installAgentLlmTarget(agent.ctx, target)
|
||||
targets.set(agent, target)
|
||||
return target
|
||||
}
|
||||
|
||||
/** Pre-publication setup used by both fresh and resumed Web agents. */
|
||||
function installTarget(agentCtx: Context): void {
|
||||
const agent = agentCtx.agent
|
||||
if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
|
||||
targetFor(agent)
|
||||
}
|
||||
|
||||
/** Send one transient frame to every connected mux consumer. */
|
||||
function broadcast(payload: MuxFrame): void {
|
||||
const envelope = frame(payload)
|
||||
for (const queue of muxQueues) queue.push(envelope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session inbox mirror serving the mux-open queue snapshot (the same
|
||||
* refresh-recovery baseline as pending questions). Keyed by the stable
|
||||
* AgentMessageId: every enqueued id receives exactly one terminal
|
||||
* `agent/inbox/dequeue` OR `agent/inbox/discard` (the inbox contract), so
|
||||
* the mirror needs no consumption heuristics or sweeps beyond disposal.
|
||||
*/
|
||||
const queuedMirror = new Map<SessionId, Map<AgentMessageId, AgentMessage>>()
|
||||
ctx.effect(() => {
|
||||
const retire = (agent: Agent, id: AgentMessageId): void => {
|
||||
const entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) return
|
||||
entries.delete(id)
|
||||
if (entries.size === 0) queuedMirror.delete(agent.id)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('agent/inbox/enqueue', (agent: Agent, message: AgentMessage) => {
|
||||
let entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) queuedMirror.set(agent.id, entries = new Map<AgentMessageId, AgentMessage>())
|
||||
entries.set(message.id, message)
|
||||
broadcast({ type: 'session/queued', sessionId: agent.id, content: message.content, source: message.source, steering: message.steering })
|
||||
}),
|
||||
ctx.on('agent/inbox/dequeue', (agent: Agent, message: AgentMessage) => {
|
||||
retire(agent, message.id)
|
||||
}),
|
||||
ctx.on('agent/inbox/discard', (agent: Agent, messages: AgentMessage[]) => {
|
||||
for (const message of messages) retire(agent, message.id)
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
queuedMirror.delete(session.id)
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'api-proxy: queued mirror')
|
||||
|
||||
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
|
||||
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
|
||||
pendingQuestions.delete(pending.rpcId)
|
||||
@@ -390,11 +470,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
resume = (async () => {
|
||||
try {
|
||||
await assertServable(sessionId)
|
||||
const handle = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions,
|
||||
setup: installTarget,
|
||||
})
|
||||
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
|
||||
return handle.agent
|
||||
} finally {
|
||||
resumes.delete(sessionId)
|
||||
@@ -413,6 +489,78 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve one requested identity to a live agent, creating or resuming it once. */
|
||||
async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
|
||||
let creation = sessionCreations.get(sessionId)
|
||||
if (creation === undefined) {
|
||||
creation = (async () => {
|
||||
const live = ctx.agents.get(sessionId)
|
||||
if (live !== undefined) return live
|
||||
|
||||
const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
|
||||
const stored = persistence === undefined
|
||||
? undefined
|
||||
: (await persistence.list()).find(header => header.id === sessionId)
|
||||
if (stored !== undefined) {
|
||||
if (stored.cwd !== cwd) {
|
||||
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
|
||||
}
|
||||
return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })).agent
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
|
||||
}
|
||||
return (await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })).agent
|
||||
})().catch((error: unknown) => {
|
||||
// Another Host entry path may have published the same identity while
|
||||
// this operation crossed an asynchronous persistence/filesystem step.
|
||||
const live = ctx.agents.get(sessionId)
|
||||
if (live !== undefined) return live
|
||||
throw error
|
||||
}).finally(() => {
|
||||
sessionCreations.delete(sessionId)
|
||||
})
|
||||
sessionCreations.set(sessionId, creation)
|
||||
}
|
||||
const agent = await creation
|
||||
if (agent.session.header.cwd !== cwd) {
|
||||
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Resolve or create one path while holding the Host's workspace-create chain. */
|
||||
function ensureWorkspace(
|
||||
path: string,
|
||||
title: string | undefined,
|
||||
rejectExistingName = false,
|
||||
createDirectory = false,
|
||||
): Promise<{ workspace: Workspace; created: boolean }> {
|
||||
const operation = workspaceCreationChain.then(async () => {
|
||||
if (rejectExistingName && title !== undefined
|
||||
&& ctx.workspace.list().some(workspace => workspace.title === title)) {
|
||||
throw new WorkspaceNameConflictError(title)
|
||||
}
|
||||
if (createDirectory) {
|
||||
try {
|
||||
await mkdir(path, { recursive: true })
|
||||
} catch (error: unknown) {
|
||||
throw new WorkspaceDirectoryCreationError(
|
||||
`failed to create workspace directory "${path}": ${String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const existing = await ctx.workspace.resolveByPath(path)
|
||||
if (existing !== undefined) return { workspace: existing, created: false }
|
||||
return { workspace: await ctx.workspace.create(path, title), created: true }
|
||||
})
|
||||
workspaceCreationChain = operation.then(() => undefined, () => undefined)
|
||||
return operation
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
// Attached sessions summarize from memory; persisted-but-unattached (cold)
|
||||
@@ -435,18 +583,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
|
||||
async create(request) {
|
||||
const sessionId = `session-${randomUUID()}` as SessionId
|
||||
// A session's cwd is its project path. When the creator does not choose
|
||||
// one, the default project is the host-level default (the host process
|
||||
// working directory unless boot overrides it).
|
||||
const cwd = request.payload.cwd ?? defaults.cwd
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId,
|
||||
agentOptions,
|
||||
meta: { cwd },
|
||||
setup: installTarget,
|
||||
})
|
||||
return ok(request, { sessionId: handle.agent.id })
|
||||
const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
|
||||
let workspace: Workspace | undefined
|
||||
if (request.payload.workspaceId !== undefined) {
|
||||
workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
|
||||
if (workspace === undefined) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `workspace "${request.payload.workspaceId}" not found`,
|
||||
details: { workspaceId: request.payload.workspaceId },
|
||||
})
|
||||
}
|
||||
}
|
||||
const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
|
||||
try {
|
||||
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SessionCwdConflict) {
|
||||
return err(request, {
|
||||
code: 'session-conflict',
|
||||
message: error.message,
|
||||
details: {
|
||||
sessionId: error.sessionId,
|
||||
requestedCwd: error.requestedCwd,
|
||||
...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd },
|
||||
},
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `failed to create session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (workspace !== undefined) {
|
||||
try {
|
||||
await workspace.attachSession(sessionId)
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'workspace-attach-failed',
|
||||
message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
|
||||
details: { sessionId, workspaceId: workspace.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
return ok(request, { sessionId })
|
||||
},
|
||||
|
||||
async history(request) {
|
||||
@@ -461,71 +642,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
||||
return { event, ...view === undefined ? {} : { view } }
|
||||
})
|
||||
const current = targetFor(found.agent).current
|
||||
return ok(request, { events: entries, hasMore: page.hasMore, modelTarget: { ...current } })
|
||||
},
|
||||
|
||||
async models(request) {
|
||||
const { sessionId } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const current = targetFor(found.agent).current
|
||||
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
|
||||
try {
|
||||
const models = await ctx.llm.listModels(provider.id)
|
||||
const group: ModelProviderGroup = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
models: models.map(model => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
})),
|
||||
}
|
||||
return { kind: 'group' as const, group }
|
||||
} catch (error: unknown) {
|
||||
const failure: ModelCatalogFailure = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
return { kind: 'failure' as const, failure }
|
||||
}
|
||||
}))
|
||||
const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
|
||||
const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : [])
|
||||
const currentGroup = groups.find(group => group.id === current.provider)
|
||||
if (
|
||||
currentGroup !== undefined
|
||||
&& !currentGroup.models.some(model => model.id === current.model)
|
||||
) {
|
||||
currentGroup.models.push({
|
||||
id: current.model,
|
||||
name: current.model,
|
||||
unlisted: true,
|
||||
})
|
||||
}
|
||||
return ok(request, {
|
||||
current: { ...current },
|
||||
groups: groups.filter(group => group.models.length > 0),
|
||||
failures,
|
||||
})
|
||||
},
|
||||
|
||||
async selectModel(request) {
|
||||
const { sessionId, provider, model } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
if (!ctx.llm.listProviders().some(entry => entry.id === provider)) {
|
||||
return err(request, {
|
||||
code: 'model-unavailable',
|
||||
message: `provider "${provider}" is not registered`,
|
||||
details: { provider, model },
|
||||
})
|
||||
}
|
||||
const selected: ModelTarget = { provider, model }
|
||||
targetFor(found.agent).current = selected
|
||||
return ok(request, { selected: { ...selected } })
|
||||
return ok(request, { events: entries, hasMore: page.hasMore })
|
||||
},
|
||||
|
||||
async prompt(request) {
|
||||
@@ -537,9 +654,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
|
||||
try {
|
||||
if (mode === 'steer') agent.steer(content, { source })
|
||||
else agent.send(content, { source })
|
||||
else agent.followup(content, { source })
|
||||
} catch (error: unknown) {
|
||||
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
// A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
|
||||
}
|
||||
return ok(request, { accepted: true as const })
|
||||
@@ -560,12 +677,125 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
workspace: {
|
||||
list(request) {
|
||||
return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) }))
|
||||
},
|
||||
|
||||
// Exactly one of path/name arrives (schema refine). Existing-folder
|
||||
// adoption reuses its canonical path; create-by-name rejects a name
|
||||
// already present in the registry.
|
||||
async create(request) {
|
||||
const { payload } = request
|
||||
let path: string
|
||||
if (payload.name !== undefined) {
|
||||
const name = payload.name.trim()
|
||||
if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
|
||||
return err(request, {
|
||||
code: 'workspace-invalid-path',
|
||||
message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
|
||||
details: { path: payload.name },
|
||||
})
|
||||
}
|
||||
path = join(defaults.workspaceRoot, name)
|
||||
} else {
|
||||
path = payload.path as string
|
||||
}
|
||||
try {
|
||||
const name = payload.name?.trim()
|
||||
const { workspace, created } = await ensureWorkspace(
|
||||
path,
|
||||
name,
|
||||
name !== undefined,
|
||||
name !== undefined,
|
||||
)
|
||||
return ok(request, { workspace: workspaceView(workspace), created })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkspaceNameConflictError) {
|
||||
return err(request, {
|
||||
code: 'workspace-name-conflict',
|
||||
message: error.message,
|
||||
details: { name: error.workspaceName },
|
||||
})
|
||||
}
|
||||
if (error instanceof WorkspaceDirectoryCreationError) {
|
||||
return err(request, { code: 'internal', message: error.message, details: {} })
|
||||
}
|
||||
// The registry rejects a path that does not resolve to an existing
|
||||
// directory (realpath ENOENT / not-a-directory) — the business
|
||||
// error of the typed-path flow, surfaced as a validation failure.
|
||||
return err(request, {
|
||||
code: 'workspace-invalid-path',
|
||||
message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
details: { path },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async rename(request) {
|
||||
const { payload } = request
|
||||
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
|
||||
if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
|
||||
const title = payload.title.trim()
|
||||
// Uniqueness AND the same-title no-op both ride the create chain so
|
||||
// they observe the state left by earlier queued renames — checked
|
||||
// up front, a queued A→A could report success while an earlier A→B
|
||||
// still lands afterwards.
|
||||
const operation = workspaceCreationChain.then(async () => {
|
||||
if (title === workspace.title) return
|
||||
if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
|
||||
throw new WorkspaceNameConflictError(title)
|
||||
}
|
||||
await workspace.setTitle(title)
|
||||
})
|
||||
workspaceCreationChain = operation.then(() => undefined, () => undefined)
|
||||
try {
|
||||
await operation
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkspaceNameConflictError) {
|
||||
return err(request, {
|
||||
code: 'workspace-name-conflict',
|
||||
message: error.message,
|
||||
details: { name: error.workspaceName },
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return ok(request, { workspace: workspaceView(workspace) })
|
||||
},
|
||||
|
||||
async insertSessionBefore(request) {
|
||||
const { payload } = request
|
||||
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
|
||||
if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
|
||||
try {
|
||||
await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
|
||||
} catch (error: unknown) {
|
||||
// Only the entity's unaccounted-id rejection is the business code;
|
||||
// storage/durability failures propagate as internal errors.
|
||||
if (!(error instanceof WorkspaceMoveInvalidError)) throw error
|
||||
return err(request, {
|
||||
code: 'workspace-move-invalid',
|
||||
message: error.message,
|
||||
details: {
|
||||
workspaceId: payload.workspaceId,
|
||||
sessionId: payload.sessionId,
|
||||
...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
|
||||
},
|
||||
})
|
||||
}
|
||||
return ok(request, { workspace: workspaceView(workspace) })
|
||||
},
|
||||
},
|
||||
|
||||
host: {
|
||||
describe(request) {
|
||||
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
|
||||
return Promise.resolve(ok(request, {
|
||||
version: '0.0.1',
|
||||
cwd: process.cwd(),
|
||||
// Same source as session.create's fallback: the UI's default project
|
||||
// must match where an unspecified-cwd session actually lands.
|
||||
cwd: defaults.cwd,
|
||||
provider: defaults.provider,
|
||||
model: defaults.model,
|
||||
attachedSessions: ctx.agents.list().length,
|
||||
@@ -573,6 +803,88 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
commands: {
|
||||
// Both methods address one session's agent (agentFor keeps its
|
||||
// resume-on-miss: clients only send a sessionId for a published
|
||||
// session, and resume restores an existing entity).
|
||||
async list(request) {
|
||||
// Missing service = the deployment omitted dsh-commands from its
|
||||
// composition, not an empty catalog: fail loud instead of serving [].
|
||||
const commands = ctx.get('commands')
|
||||
if (commands === undefined) {
|
||||
return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
const found = await agentFor(request.payload.sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
return ok(request, { commands: commands.list(found.agent) })
|
||||
},
|
||||
|
||||
async execute(request, signal) {
|
||||
const commands = ctx.get('commands')
|
||||
if (commands === undefined) {
|
||||
return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
const { sessionId, line } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const result = await commands.execute(found.agent, line, signal)
|
||||
if (result === undefined) return ok(request, { matched: false })
|
||||
return ok(request, {
|
||||
matched: true,
|
||||
result: { kind: result.kind, ...result.text === undefined ? {} : { text: result.text } },
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
|
||||
return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
// Skill lookup never touches the Agent registry: the session address
|
||||
// resolves to a canonical cwd from the host-resident session header, so
|
||||
// listing skills cannot create or resume an agent as a side effect.
|
||||
async list(request) {
|
||||
const { sessionId } = request.payload
|
||||
const session = ctx.sessions.get(sessionId)
|
||||
if (session === undefined) {
|
||||
return err(request, {
|
||||
code: 'session-not-found',
|
||||
message: `session "${sessionId}" not found (not attached)`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
if (session.header.cwd === undefined) {
|
||||
// Every served session records its project at create time; a
|
||||
// cwd-less header is a pre-project legacy log (not served).
|
||||
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
|
||||
}
|
||||
const cwd = session.header.cwd
|
||||
// Same stance as the commands domain: a missing service means the
|
||||
// deployment omitted dsh-skill from its composition, not an empty
|
||||
// catalog. ctx.get also keeps this handler independent of the gateway
|
||||
// plugin's inject list (an undeclared `ctx.skills` property read
|
||||
// fails the reflect proxy).
|
||||
const skillRegistry = ctx.get('skills')
|
||||
if (skillRegistry === undefined) {
|
||||
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
try {
|
||||
const skills = await skillRegistry.list({ cwd })
|
||||
return ok(request, {
|
||||
skills: skills.map(skill => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
|
||||
})),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
mux(_request, signal) {
|
||||
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
|
||||
@@ -589,6 +901,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
})
|
||||
}
|
||||
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
|
||||
// in arrival order per session; a reconnecting client rebuilds its
|
||||
// queue view from these alone.
|
||||
for (const [sessionId, entries] of queuedMirror) {
|
||||
for (const entry of entries.values()) {
|
||||
queue.push(frame({ type: 'session/queued', sessionId, content: entry.content, source: entry.source, steering: entry.steering }))
|
||||
}
|
||||
}
|
||||
// Per-session open-call table for result-view pairing. Bounded by the
|
||||
// per-turn call count: entries clear on turn/end; a table miss (stream
|
||||
// opened mid-turn) backscans the session's in-memory events instead.
|
||||
@@ -630,12 +950,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|
||||
host(_request, signal) {
|
||||
const queue = new FrameQueue<RpcRequest<HostFrame>>()
|
||||
const committedWorkspaceIds = new Set(
|
||||
ctx.workspace.list().map(workspace => String(workspace.id)),
|
||||
)
|
||||
const disposers = [
|
||||
ctx.on('session/created', (session: Session) => {
|
||||
queue.push(frame({
|
||||
type: 'host/session-added',
|
||||
sessionId: session.id,
|
||||
// Derived at frame time like summarize(); a just-created session
|
||||
// has no events yet, so this is constantly true in practice.
|
||||
blank: session.events.length === 0,
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
// cwd rides the frame so the client list needs no refresh to group the new session.
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
}))
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
@@ -648,6 +976,32 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {
|
||||
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
|
||||
}),
|
||||
ctx.on('domain/changed', (change) => {
|
||||
if (change.domain !== 'workspace' || change.operation !== 'put') return
|
||||
if (change.table === '') {
|
||||
const state = workspaceDomainState.parse(change.value)
|
||||
for (const workspaceId of state.workspaceIds) {
|
||||
if (committedWorkspaceIds.has(workspaceId)) continue
|
||||
const workspace = ctx.workspace.get(workspaceId)
|
||||
if (workspace === undefined) {
|
||||
throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
|
||||
}
|
||||
committedWorkspaceIds.add(workspaceId)
|
||||
queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return
|
||||
// Existing-entity table writes are complete attach/touch commits.
|
||||
// A new entity's first put waits for the global registry write above.
|
||||
queue.push(frame({
|
||||
type: 'host/workspace-changed',
|
||||
workspace: changedWorkspaceView(change.key, change.value),
|
||||
}))
|
||||
}),
|
||||
ctx.on('commands/change', () => {
|
||||
queue.push(frame({ type: 'host/commands-changed' }))
|
||||
}),
|
||||
]
|
||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
||||
},
|
||||
45
packages/host/apiproxy/src/api/commands.schema.ts
Normal file
45
packages/host/apiproxy/src/api/commands.schema.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* commands domain zod schemas (names derived from map keys: commandListRequestSchema /
|
||||
* commandListValueSchema / commandExecuteRequestSchema / commandExecuteValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { CommandDescriptor, CommandExecuteResult } from './commands.ts'
|
||||
|
||||
/** CommandDescriptor row of command.list. */
|
||||
export const commandDescriptorSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
input: z.object({ hint: z.string() }).optional(),
|
||||
}) satisfies z.ZodType<Wire<CommandDescriptor>>
|
||||
|
||||
/** command.list request payload. */
|
||||
export const commandListRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'command.list'>>>
|
||||
|
||||
/** command.list response value. */
|
||||
export const commandListValueSchema = z.object({
|
||||
commands: z.array(commandDescriptorSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'command.list'>>>
|
||||
|
||||
/** command.execute request payload. */
|
||||
export const commandExecuteRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
line: z.string(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
|
||||
|
||||
/** Detached command outcome (result slot of command.execute's value). */
|
||||
export const commandExecuteResultSchema = z.object({
|
||||
kind: z.union([z.literal('success'), z.literal('error')]),
|
||||
text: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<CommandExecuteResult>>
|
||||
|
||||
/** command.execute response value (matched=false carries no result). */
|
||||
export const commandExecuteValueSchema = z.object({
|
||||
matched: z.boolean(),
|
||||
result: commandExecuteResultSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>>
|
||||
48
packages/host/apiproxy/src/api/commands.ts
Normal file
48
packages/host/apiproxy/src/api/commands.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* commands domain contract: the web catalog/dispatch face of the host command
|
||||
* registry (`ctx.commands`). Both methods address one session's agent via
|
||||
* `sessionId` — every served session has an Agent (Session+Agent are born
|
||||
* together), so there is no agent-less surface on this wire.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
* Handler-free command view served to clients. Wire mirror of the host
|
||||
* registry descriptor (which stays host-side with its cordis dependencies);
|
||||
* no source field — the host descriptor has none.
|
||||
*/
|
||||
export interface CommandDescriptor {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
readonly name: string
|
||||
/** Human-readable summary used in discovery UI. */
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: { readonly hint: string }
|
||||
}
|
||||
|
||||
/** Detached command outcome rendered directly by the requesting client. */
|
||||
export interface CommandExecuteResult {
|
||||
readonly kind: 'success' | 'error'
|
||||
readonly text?: string
|
||||
}
|
||||
|
||||
/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */
|
||||
export interface CommandsApi {
|
||||
/**
|
||||
* Lists the addressed agent's effective command catalog (name-sorted,
|
||||
* globals plus its scoped shadows).
|
||||
*/
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ commands: readonly CommandDescriptor[] }>>
|
||||
|
||||
/**
|
||||
* Parses and executes one slash-command line against the addressed agent
|
||||
* without sending it to the model. matched=false when syntax or name does
|
||||
* not resolve (the client falls back to its default sink). The signal rides
|
||||
* beside the request, never on the wire: the fetch carrier's request signal
|
||||
* cancels the running handler.
|
||||
*/
|
||||
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
|
||||
Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import type { HostFrame, MuxFrame } from './events.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import { sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
|
||||
import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
|
||||
import { workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question shape validated strictly against core dsh-user-interaction. */
|
||||
export const askUserQuestionItemSchema = z.object({
|
||||
@@ -34,14 +35,18 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
// and must fail loud here, not reach the composer.
|
||||
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
|
||||
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
|
||||
// content/source reuse the wide passthroughs (both are merge-extensible in core).
|
||||
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, content: z.array(contentBlockSchema), source: z.looseObject({ kind: z.string() }), steering: z.boolean() }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<MuxFrame>
|
||||
|
||||
/** HostFrame union (payload slot of a host-stream ServerRequest). */
|
||||
export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional() }),
|
||||
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
|
||||
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
|
||||
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
|
||||
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
|
||||
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
|
||||
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
|
||||
import type { WorkspaceView } from './workspace.ts'
|
||||
|
||||
// Client-side consumers take the render-intent vocabulary from the contract;
|
||||
// dsh-tools remains its owner.
|
||||
@@ -60,12 +62,41 @@ export type MuxFrame =
|
||||
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
|
||||
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
|
||||
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
|
||||
/**
|
||||
* A message entered the addressed agent's inbox (`agent/queued` passthrough:
|
||||
* a queued message is not model-visible, so there is no session event to
|
||||
* ride — this transient frame is the only wire signal). On stream open the
|
||||
* host replays the current queue snapshot for every attached session (same
|
||||
* refresh-recovery baseline as pending questions); queue clearing on cancel
|
||||
* has no dedicated frame — clients fold it from the status flip.
|
||||
* source carries the prompt's rpcId when the message came over this wire
|
||||
* (the client's provisional-echo reconciliation key).
|
||||
*/
|
||||
| { type: 'session/queued'; sessionId: SessionId; content: ContentBlock[]; source: MessageSource; steering: boolean }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
/** Host stream frames. session-added carries the lineage anchor; agent-error is the only outlet for live failures with no turn position. */
|
||||
/**
|
||||
* Host stream frames. session-added carries the lineage anchor, the project
|
||||
* cwd, and the blank bit (the list-summary fields a client cannot wait for a
|
||||
* refresh to learn); the frame fires at session/created, so blank is
|
||||
* constantly true — clients flip it on the session's first
|
||||
* `host/session-status(running:true)` (a blank session never runs), and a
|
||||
* reconnecting client takes `session.list`'s summary.blank as authoritative.
|
||||
* agent-error is the only outlet for live failures with no turn position;
|
||||
* workspace-changed pushes the full new snapshot after every durable
|
||||
* workspace mutation (create/attach/order change — the client upserts, while
|
||||
* `workspace.list` provides the reconnect baseline).
|
||||
*/
|
||||
export type HostFrame =
|
||||
| { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId }
|
||||
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
|
||||
| { type: 'host/session-removed'; sessionId: SessionId }
|
||||
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
|
||||
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
|
||||
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
|
||||
/**
|
||||
* The command registry changed (`commands/change` passthrough). Pure
|
||||
* invalidation signal, no payload: clients refetch `command.list` in the
|
||||
* background rather than diffing.
|
||||
*/
|
||||
| { type: 'host/commands-changed' }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
|
||||
@@ -13,6 +16,9 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
export interface ApiProxy {
|
||||
sessions: SessionsApi
|
||||
host: HostApi
|
||||
workspace: WorkspaceApi
|
||||
commands: CommandsApi
|
||||
skills: SkillsApi
|
||||
events: EventsApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||
@@ -24,6 +30,9 @@ export type {
|
||||
SessionModels, SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { HostApi } from './host.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
@@ -6,9 +6,16 @@
|
||||
|
||||
import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.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. */
|
||||
/**
|
||||
* 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 (command.execute): the carrier passes its request signal, never a wire field.
|
||||
*/
|
||||
export interface RpcMethodMap {
|
||||
'session.list': SessionsApi['list']
|
||||
'session.create': SessionsApi['create']
|
||||
@@ -18,6 +25,13 @@ export interface RpcMethodMap {
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
'host.describe': HostApi['describe']
|
||||
'workspace.list': WorkspaceApi['list']
|
||||
'workspace.create': WorkspaceApi['create']
|
||||
'workspace.rename': WorkspaceApi['rename']
|
||||
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
'skill.list': SkillsApi['list']
|
||||
}
|
||||
|
||||
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
|
||||
|
||||
@@ -35,7 +35,12 @@ 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('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }),
|
||||
z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
|
||||
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>
|
||||
|
||||
@@ -32,7 +32,12 @@ export interface RpcErrorDetailsMap {
|
||||
'bad-request': { issues: ZodIssue[] }
|
||||
'cancelled': {}
|
||||
'session-not-found': { sessionId: SessionId }
|
||||
'model-unavailable': { provider: string; model: string }
|
||||
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
|
||||
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
|
||||
'workspace-not-found': { workspaceId: string }
|
||||
'workspace-invalid-path': { path: string }
|
||||
'workspace-name-conflict': { name: string }
|
||||
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
|
||||
'agent-busy': { reason: string }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,19 @@ import type {
|
||||
SessionSummary,
|
||||
} from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
|
||||
|
||||
/**
|
||||
* WorkspaceId: the workspace domain's one brand cast. Hosted here rather
|
||||
* than in workspace.schema because session.create references it while
|
||||
* workspace.schema references sessionIdSchema — schema modules must stay a
|
||||
* DAG (both casts used at module top level; a cycle is a load-time TDZ).
|
||||
*/
|
||||
export const workspaceIdSchema = z.string().min(1) as unknown as z.ZodType<WorkspaceId>
|
||||
|
||||
/** SessionEvent passthrough: strict envelope, wide data (the client fold handles unknown types via its documented default). */
|
||||
export const sessionEventSchema = z.object({
|
||||
type: z.string(),
|
||||
@@ -33,6 +42,7 @@ export const sessionSummarySchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
updatedAt: z.number(),
|
||||
running: z.boolean(),
|
||||
blank: z.boolean(),
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<SessionSummary>>
|
||||
@@ -47,10 +57,15 @@ export const sessionListValueSchema = z.object({
|
||||
items: z.array(sessionSummarySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
|
||||
|
||||
/** session.create request payload. */
|
||||
/** session.create request payload (at most one of workspaceId / cwd). */
|
||||
export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
|
||||
sessionId: sessionIdSchema.optional(),
|
||||
}).refine(
|
||||
payload => payload.workspaceId === undefined || payload.cwd === undefined,
|
||||
{ message: 'session.create accepts workspaceId or cwd, not both' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
|
||||
|
||||
/** session.create response value. */
|
||||
export const sessionCreateValueSchema = z.object({
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
@@ -88,6 +89,14 @@ export interface SessionSummary {
|
||||
updatedAt: number
|
||||
/** Status of the attached agent; always false for cold (unattached) sessions. */
|
||||
running: boolean
|
||||
/**
|
||||
* Derived emptiness bit: true while the session log holds zero events (no
|
||||
* user message yet). Clients hide blank sessions from lists and reuse them
|
||||
* for New Session on the same workspace. Always false for cold sessions —
|
||||
* lazy persistence keeps a never-appended session out of the store, so a
|
||||
* listed cold session necessarily has events.
|
||||
*/
|
||||
blank: boolean
|
||||
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
|
||||
parentSessionId?: SessionId
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
@@ -99,8 +108,16 @@ export interface SessionsApi {
|
||||
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
|
||||
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
|
||||
|
||||
/** Creates a new session (and its agent, idle and standing by). */
|
||||
create(request: RpcRequest<{ cwd?: string }>): Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
/**
|
||||
* Creates a real session and its idle agent. At most one of `workspaceId` /
|
||||
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
|
||||
* preallocate `sessionId`: retries with the same id and cwd return the same
|
||||
* session, while a different cwd fails with `session-conflict`. Workspace
|
||||
* creation attaches the session after publication; an attach failure
|
||||
* returns `workspace-attach-failed` with the published session id.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/**
|
||||
* Reads a window of history events; page boundaries align to message boundaries: one page =
|
||||
|
||||
27
packages/host/apiproxy/src/api/skills.schema.ts
Normal file
27
packages/host/apiproxy/src/api/skills.schema.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* skills domain zod schemas (names derived from map keys: skillListRequestSchema /
|
||||
* skillListValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { SkillEntry } from './skills.ts'
|
||||
|
||||
/** SkillEntry row of skill.list. */
|
||||
export const skillEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
whenToUse: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<SkillEntry>>
|
||||
|
||||
/** skill.list request payload. */
|
||||
export const skillListRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'skill.list'>>>
|
||||
|
||||
/** skill.list response value. */
|
||||
export const skillListValueSchema = z.object({
|
||||
skills: z.array(skillEntrySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>
|
||||
25
packages/host/apiproxy/src/api/skills.ts
Normal file
25
packages/host/apiproxy/src/api/skills.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* skills domain contract: read-only skill catalog lookup addressed by session.
|
||||
* The session's header cwd resolves to the canonical project root host-side —
|
||||
* the client never submits a raw path, and skill lookup never creates or
|
||||
* resumes an Agent.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */
|
||||
export interface SkillEntry {
|
||||
/** Kebab-case identifier referenced as `<skill>name</skill>` in prompts. */
|
||||
readonly name: string
|
||||
/** Short routing description. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance. */
|
||||
readonly whenToUse?: string
|
||||
}
|
||||
|
||||
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
|
||||
export interface SkillsApi {
|
||||
/** Lists model-invocable skills for the addressed session's project root. */
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
|
||||
}
|
||||
72
packages/host/apiproxy/src/api/workspace.schema.ts
Normal file
72
packages/host/apiproxy/src/api/workspace.schema.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* workspace domain zod schemas (names derived from map keys). The
|
||||
* WorkspaceId brand cast lives in sessions.schema (see the note there) and
|
||||
* is re-exported here as the domain-local name.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { WorkspaceView } from './workspace.ts'
|
||||
import { sessionIdSchema, workspaceIdSchema } from './sessions.schema.ts'
|
||||
|
||||
export { workspaceIdSchema } from './sessions.schema.ts'
|
||||
|
||||
/** WorkspaceView row of every workspace.* response. */
|
||||
export const workspaceViewSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
path: z.string(),
|
||||
title: z.string(),
|
||||
sessionIds: z.array(sessionIdSchema),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}) satisfies z.ZodType<Wire<WorkspaceView>>
|
||||
|
||||
/** workspace.list request payload (empty object literal). */
|
||||
export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'workspace.list'>>>
|
||||
|
||||
/** workspace.list response value. */
|
||||
export const workspaceListValueSchema = z.object({
|
||||
items: z.array(workspaceViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
|
||||
|
||||
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */
|
||||
export const workspaceCreateRequestSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
}).refine(
|
||||
payload => (payload.path === undefined) !== (payload.name === undefined),
|
||||
{ message: 'workspace.create requires exactly one of path / name' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
|
||||
|
||||
/** workspace.create response value. */
|
||||
export const workspaceCreateValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
created: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>
|
||||
|
||||
/** workspace.rename request payload: the new title must be non-blank. */
|
||||
export const workspaceRenameRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
title: z.string(),
|
||||
}).refine(
|
||||
payload => payload.title.trim() !== '',
|
||||
{ message: 'workspace.rename requires a non-blank title' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>>
|
||||
|
||||
/** workspace.rename response value. */
|
||||
export const workspaceRenameValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
|
||||
|
||||
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
|
||||
export const workspaceInsertSessionBeforeRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
sessionId: sessionIdSchema,
|
||||
beforeSessionId: sessionIdSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>>
|
||||
|
||||
/** workspace.insertSessionBefore response value. */
|
||||
export const workspaceInsertSessionBeforeValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
81
packages/host/apiproxy/src/api/workspace.ts
Normal file
81
packages/host/apiproxy/src/api/workspace.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* workspace domain contract. Wire projection of the host-side workspace
|
||||
* entity (@deepseek-ai/dsh-workspace): a stable id over a directory path,
|
||||
* a display title, and the ordered session account. Method signatures are the
|
||||
* source of truth, same as the sessions domain.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
* Wire-side workspace id brand. Deliberately re-declared here rather than
|
||||
* imported from dsh-workspace: api/ must stay browser-importable with zero
|
||||
* host-package dependencies, and the brand string matches, so both sides
|
||||
* agree structurally.
|
||||
*/
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
|
||||
/** One workspace row: the record projection every workspace.* value carries. */
|
||||
export interface WorkspaceView {
|
||||
workspaceId: WorkspaceId
|
||||
/** Canonical directory path (host-side realpath canon). */
|
||||
path: string
|
||||
/** Unique display title (defaults to the path basename at create). */
|
||||
title: string
|
||||
/**
|
||||
* Sessions accounted under this workspace, in manually owned order
|
||||
* (attach prepends, insertSessionBefore reorders; activity never does).
|
||||
*/
|
||||
sessionIds: SessionId[]
|
||||
/** ISO-8601 creation instant. */
|
||||
createdAt: string
|
||||
/** ISO-8601 last-mutation instant. */
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */
|
||||
export interface WorkspaceApi {
|
||||
/** Lists all workspaces in the registry's durable display order. */
|
||||
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[] }>>
|
||||
|
||||
/**
|
||||
* Creates (or idempotently resolves) a workspace. Exactly one of `path` /
|
||||
* `name` (schema-enforced): `path` registers an EXISTING directory (no
|
||||
* mkdir — a missing or non-directory path fails with `workspace-invalid-path`);
|
||||
* `name` is a single path segment the host mkdirs under its default project
|
||||
* root before registering. Either spelling resolving to a directory already
|
||||
* owned by a workspace returns that workspace (`created: false`) for the
|
||||
* existing-folder spelling. Create-by-name rejects an existing title with
|
||||
* `workspace-name-conflict`; a new path whose basename duplicates another
|
||||
* Workspace title is rejected by the registry with the same code.
|
||||
* A new name-created workspace uses `name` as both directory name and title;
|
||||
* a path-created workspace uses the registry's basename title default.
|
||||
*/
|
||||
create(request: RpcRequest<{ path?: string; name?: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
|
||||
|
||||
/**
|
||||
* Renames a workspace. `title` is trimmed and must be non-empty
|
||||
* (schema-enforced). An unknown id fails with `workspace-not-found`; a
|
||||
* title equal to another workspace's fails with `workspace-name-conflict`.
|
||||
* Renaming to the current title is a no-op success (no durable write).
|
||||
*/
|
||||
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView }>>
|
||||
|
||||
/**
|
||||
* Moves an accounted session within its workspace's manual order,
|
||||
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
|
||||
* before that anchor; omitted appends to the end. An unknown workspace
|
||||
* fails with `workspace-not-found`; a session or anchor not accounted by
|
||||
* the workspace fails with `workspace-move-invalid`. A move to the current
|
||||
* position is a no-op success.
|
||||
*/
|
||||
insertSessionBefore(request: RpcRequest<{
|
||||
workspaceId: WorkspaceId
|
||||
sessionId: SessionId
|
||||
beforeSessionId?: SessionId
|
||||
}>): Promise<RpcResponse<{ workspace: WorkspaceView }>>
|
||||
}
|
||||
@@ -23,6 +23,14 @@ import {
|
||||
sessionPromptValueSchema,
|
||||
sessionSelectModelValueSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import {
|
||||
workspaceCreateValueSchema,
|
||||
workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListValueSchema,
|
||||
workspaceRenameValueSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -52,6 +60,19 @@ export interface IApiClient {
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
}
|
||||
workspace: {
|
||||
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
|
||||
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
|
||||
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
|
||||
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
}
|
||||
commands: {
|
||||
list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.list'>>>
|
||||
execute(payload: RequestPayload<'command.execute'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.execute'>>>
|
||||
}
|
||||
skills: {
|
||||
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
|
||||
@@ -73,6 +94,13 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'workspace.list': workspaceListValueSchema,
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
'workspace.rename': workspaceRenameValueSchema,
|
||||
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
}
|
||||
|
||||
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
@@ -261,6 +289,22 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
|
||||
}
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
|
||||
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
|
||||
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
|
||||
}
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload, signal) => this.callUnary('command.list', payload, signal),
|
||||
execute: (payload, signal) => this.callUnary('command.execute', payload, signal),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
}
|
||||
|
||||
readonly events: IApiClient['events'] = {
|
||||
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
|
||||
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),
|
||||
|
||||
@@ -24,6 +24,14 @@ import {
|
||||
sessionSelectModelRequestSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema,
|
||||
workspaceListRequestSchema,
|
||||
workspaceRenameRequestSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -31,11 +39,13 @@ import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
* 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; methods whose contract
|
||||
* declares a signal parameter (command.execute) forward it, the rest ignore it.
|
||||
*/
|
||||
type UnaryRoutes = {
|
||||
[K in keyof RpcMethodMap]: {
|
||||
schema: z.ZodType<Wire<RequestPayload<K>>>
|
||||
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>): Promise<RpcResponse<ResponseValue<K>>>
|
||||
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>, signal: AbortSignal): Promise<RpcResponse<ResponseValue<K>>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +58,13 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
||||
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
||||
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
|
||||
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
|
||||
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
|
||||
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
}
|
||||
|
||||
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
||||
@@ -83,14 +100,16 @@ function fullResponse(narrow: RpcResponse<unknown>): Response {
|
||||
// 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.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
||||
async function handleUnary<K extends keyof RpcMethodMap>(api: ApiProxy, method: K, message: ClientRequest): Promise<Response> {
|
||||
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 }))
|
||||
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 })
|
||||
@@ -195,7 +214,7 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
|
||||
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)
|
||||
return handleUnary(api, method, message, req.signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,85 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-apiproxy — the front layer every client shape shares:
|
||||
* the ApiProxy contract (api/: types + zod schemas, browser-safe) and the
|
||||
* fetch carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
|
||||
* platform subclasses on the client side). Host assembly (bootHost/createApiProxy/startHost)
|
||||
* lives in @deepseek-ai/dsh-host-runtime.
|
||||
* @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 — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
import { createApiProxy } from './api-proxy.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 'cordis' {
|
||||
interface Context {
|
||||
/** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
|
||||
apiProxy: ApiProxy
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
|
||||
export interface Config {
|
||||
/** Default provider route for created/resumed agents. */
|
||||
provider: string
|
||||
/** Default model id. */
|
||||
model: string
|
||||
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The API gateway service: implements the ApiProxy contract over the composed
|
||||
* host context and provides it as `ctx.apiProxy`. The Host cwd is the default
|
||||
* project directory and the fallback parent for name-created Workspaces.
|
||||
*/
|
||||
export class ApiProxyService extends Service implements ApiProxy {
|
||||
static inject = ['agents', 'sessions', 'tools', 'userInteraction', 'workspace']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
workspaceRoot: z.string(),
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
readonly workspace: ApiProxy['workspace']
|
||||
readonly host: ApiProxy['host']
|
||||
readonly commands: ApiProxy['commands']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly respond: ApiProxy['respond']
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'apiProxy')
|
||||
const cwd = process.cwd()
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
cwd,
|
||||
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.workspace = api.workspace
|
||||
this.host = api.host
|
||||
this.commands = api.commands
|
||||
this.skills = api.skills
|
||||
this.events = api.events
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
// the unbound-method lint without changing behavior.
|
||||
this.respond = api.respond.bind(api)
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiProxyService
|
||||
|
||||
@@ -15,11 +15,12 @@ export const name = 'host-apiproxy-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package is the wire contract layer (types,
|
||||
* schemas, fetch carrier glue) — it emits no cordis events and owns no
|
||||
* mutable cross-plugin relation. rpcId round-trip and schema acceptance are
|
||||
* enforced at the carrier boundary and exercised by the protocol-isomorphism
|
||||
* suite; the live implementation relations belong to dsh-host-runtime.
|
||||
* No runtime invariant: this package is the wire contract layer plus the
|
||||
* host-side gateway over services owned elsewhere — it emits no cordis events
|
||||
* of its own; the session/agent event streams it projects are asserted by
|
||||
* their owning packages' companions. rpcId round-trip and schema acceptance
|
||||
* are enforced at the carrier boundary and exercised by the
|
||||
* protocol-isomorphism suite.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
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'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('sessions.list cold merge', () => {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const response = await api.sessions.list(request({}))
|
||||
expect(response.result.ok).toBe(true)
|
||||
@@ -65,6 +65,9 @@ describe('sessions.list cold merge', () => {
|
||||
const [a, b, c] = items
|
||||
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
|
||||
expect(a?.running).toBe(false)
|
||||
// Cold summaries are never blank: lazy persistence keeps never-appended
|
||||
// sessions out of list(), so a listed session necessarily has events.
|
||||
expect(items.every(item => !item.blank)).toBe(true)
|
||||
expect(a?.cwd).toBe('/proj')
|
||||
expect(a?.parentSessionId).toBeUndefined()
|
||||
expect(b?.updatedAt).toBe(2000)
|
||||
@@ -79,7 +82,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const listed = await api.sessions.list(request({}))
|
||||
expect(listed.result.ok).toBe(true)
|
||||
314
packages/host/apiproxy/tests/api-proxy-commands.spec.ts
Normal file
314
packages/host/apiproxy/tests/api-proxy-commands.spec.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Command/skill RPC handlers and the two new frames over createApiProxy:
|
||||
* command.list serves the addressed agent's effective catalog (missing
|
||||
* registry = loud internal error), command.execute dispatches through the
|
||||
* registry with the carrier signal, skill.list resolves cwd from the session
|
||||
* header (never via the Agent registry), the host stream broadcasts
|
||||
* commands-changed, and the mux stream carries live queued frames plus the
|
||||
* open-time queue snapshot.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentMessage } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import type { HostFrame, MuxFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
let nextRpc = 1
|
||||
|
||||
function expectOk<T>(response: RpcResponse<T>): T {
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
function expectErr<T>(response: RpcResponse<T>): { code: string; message: string } {
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
return response.result.error
|
||||
}
|
||||
|
||||
/** Composition floor for the command/skill paths (no LLM, no persistence). */
|
||||
async function harness(options: { commands?: boolean; skills?: boolean } = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (options.skills !== false) await ctx.plugin(SkillService, {})
|
||||
if (options.commands !== false) await ctx.plugin(CommandService)
|
||||
// Host-stream opener reads the committed-workspace baseline; the stub
|
||||
// suffices here — the real workspace composition is api-proxy-workspace.spec's.
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
|
||||
function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
|
||||
const session = ctx.sessions.create(sessionId)
|
||||
const agent = { id: session.id, session, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Drain `count` frames from a stream, then abort it. */
|
||||
async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number, abort: AbortController): Promise<F[]> {
|
||||
const frames: F[] = []
|
||||
for await (const frame of iterable) {
|
||||
frames.push(frame.payload)
|
||||
if (frames.length >= count) abort.abort()
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
describe('command.list', () => {
|
||||
it('serves the addressed agent\'s name-sorted catalog', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) })
|
||||
ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '<x>' }, handler: () => ({ kind: 'success' }) })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const value = expectOk(await api.commands.list(request({ sessionId: agent.id })))
|
||||
expect(value.commands).toEqual([
|
||||
{ name: 'alpha', description: 'a', input: { hint: '<x>' } },
|
||||
{ name: 'zeta', description: 'z' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fails loud with internal when the command registry is not mounted', async () => {
|
||||
const ctx = await harness({ commands: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId })))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('command registry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('command.execute', () => {
|
||||
it('executes a known command against the addressed agent and detaches the result', async () => {
|
||||
const ctx = await harness()
|
||||
let received: string | undefined
|
||||
ctx.commands.register({
|
||||
name: 'goal',
|
||||
description: 'set goal',
|
||||
handler: (invocation) => {
|
||||
received = invocation.rawInput
|
||||
return { kind: 'success', text: `goal:${invocation.agent.id}` }
|
||||
},
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
|
||||
expect(value).toEqual({ matched: true, result: { kind: 'success', text: `goal:${agent.id}` } })
|
||||
expect(received).toBe(' ship it')
|
||||
})
|
||||
|
||||
it('returns matched:false when syntax or name does not resolve', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const signal = new AbortController().signal
|
||||
expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false })
|
||||
expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false })
|
||||
})
|
||||
|
||||
it('maps a session miss to session-not-found and a registry gap to internal', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const missing = expectErr(await api.commands.execute(
|
||||
request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal))
|
||||
expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate
|
||||
|
||||
const bare = await harness({ commands: false })
|
||||
const bareApi = createApiProxy(bare, DEFAULTS)
|
||||
expect(expectErr(await bareApi.commands.execute(
|
||||
request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal')
|
||||
})
|
||||
|
||||
it('reports an aborted handler as cancelled and a throwing handler as internal', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.commands.register({
|
||||
name: 'hang',
|
||||
description: 'never settles on its own',
|
||||
handler: () => new Promise(() => { /* settled only by abort */ }),
|
||||
})
|
||||
ctx.commands.register({
|
||||
name: 'boom',
|
||||
description: 'throws',
|
||||
handler: () => { throw new Error('kaboom') },
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal)
|
||||
controller.abort()
|
||||
expect(expectErr(await pending).code).toBe('cancelled')
|
||||
|
||||
const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal))
|
||||
expect(thrown.code).toBe('internal')
|
||||
expect(thrown.message).toContain('kaboom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('skill.list', () => {
|
||||
it('lists skills for the session cwd taken from the header', async () => {
|
||||
const ctx = await harness()
|
||||
const seenCwds: (string | undefined)[] = []
|
||||
ctx.skills.registerProvider({
|
||||
name: 'probe',
|
||||
list: (options) => {
|
||||
seenCwds.push(options.cwd)
|
||||
return Promise.resolve([{
|
||||
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
}])
|
||||
},
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
// No agent is registered for this session: header resolution must not
|
||||
// touch (or resume through) the Agent registry.
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
|
||||
expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }])
|
||||
expect(seenCwds).toEqual(['/proj'])
|
||||
expect(ctx.agents.get(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails loud on an unattached session id (business error, no resume attempt)', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId })))
|
||||
expect(error.code).toBe('session-not-found')
|
||||
})
|
||||
|
||||
it('fails loud with internal when the skill registry is not mounted', async () => {
|
||||
const ctx = await harness({ skills: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const error = expectErr(await api.skills.list(request({ sessionId: session.id })))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('skill registry is absent')
|
||||
})
|
||||
|
||||
it('folds a provider failure into internal', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.skills.registerProvider({
|
||||
name: 'broken',
|
||||
list: () => Promise.reject(new Error('directory exploded')),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const response = await api.skills.list(request({ sessionId: session.id }))
|
||||
// dsh-skill contains one provider's failure (logs and serves the rest), so
|
||||
// this surfaces as an empty ok catalog rather than an error.
|
||||
const value = expectOk(response)
|
||||
expect(value.skills).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('host/commands-changed frame', () => {
|
||||
it('broadcasts on registry change', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal)
|
||||
const collected = collect<HostFrame>(stream, 1, abort)
|
||||
ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) })
|
||||
expect(await collected).toEqual([{ type: 'host/commands-changed' }])
|
||||
})
|
||||
})
|
||||
|
||||
/** Build one frozen inbox message for the live `agent/inbox/*` events. */
|
||||
function inboxMessage(id: string, text: string, steering: boolean, rpcId?: string): AgentMessage {
|
||||
return Object.freeze({
|
||||
id: AgentMessageId(id),
|
||||
content: [{ type: 'text' as const, text }],
|
||||
source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) },
|
||||
contexts: [],
|
||||
steering,
|
||||
wakeup: true,
|
||||
})
|
||||
}
|
||||
|
||||
describe('session/queued frames', () => {
|
||||
it('forwards live enqueue events and replays the snapshot on a later mux open', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const live = new AbortController()
|
||||
const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
|
||||
// subscribed baseline + 2 queued frames
|
||||
const liveCollected = collect<MuxFrame>(liveStream, 3, live)
|
||||
|
||||
const queued = inboxMessage('m-1', 'queued prompt', false)
|
||||
const steering = inboxMessage('m-2', 'queued prompt', true)
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued)
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
|
||||
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued')
|
||||
expect(liveFrames).toEqual([
|
||||
{ type: 'session/queued', sessionId: agent.id, content: queued.content, source: { kind: 'user' }, steering: false },
|
||||
{ type: 'session/queued', sessionId: agent.id, content: steering.content, source: { kind: 'user' }, steering: true },
|
||||
])
|
||||
|
||||
// A fresh mux connection replays the still-pending entries as its baseline.
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 3, replay)
|
||||
expect(replayFrames.filter(f => f.type === 'session/queued')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('retires mirror entries on their terminal dequeue', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const queued = inboxMessage('m-3', 'x', false)
|
||||
const steering = inboxMessage('m-4', 'x', true, 'r-1')
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued)
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
ctx.emit('agent/inbox/dequeue', agent, queued)
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering)
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort)
|
||||
expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retires mirror entries on a batch discard (cancel path)', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const doomed = inboxMessage('m-5', 'doomed', false)
|
||||
const survivor = inboxMessage('m-6', 'survivor', false)
|
||||
ctx.emit('agent/inbox/enqueue', agent, doomed)
|
||||
ctx.emit('agent/inbox/enqueue', agent, survivor)
|
||||
ctx.emit('agent/inbox/discard', agent, [doomed])
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 2, abort)
|
||||
const remaining = frames.filter(f => f.type === 'session/queued')
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0]).toMatchObject({ content: survivor.content })
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
|
||||
|
||||
@@ -76,7 +76,7 @@ async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: num
|
||||
describe('mux live view computation', () => {
|
||||
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 9, abort)
|
||||
@@ -122,7 +122,7 @@ describe('mux live view computation', () => {
|
||||
|
||||
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
// history resolves the agent first; a live structural stub is enough (only
|
||||
// .session is read on this path).
|
||||
@@ -156,7 +156,7 @@ describe('mux live view computation', () => {
|
||||
|
||||
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('mux live view computation', () => {
|
||||
|
||||
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 4, abort)
|
||||
247
packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
Normal file
247
packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
|
||||
import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
|
||||
let nextRpc = 1
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
function expectOk<T>(response: RpcResponse<T>): T {
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
async function nextHostFrame(
|
||||
stream: AsyncIterator<RpcRequest<HostFrame>>,
|
||||
): Promise<RpcRequest<HostFrame>> {
|
||||
const next = await stream.next()
|
||||
if (next.done === true) throw new Error('Host stream ended before the expected increment')
|
||||
return next.value
|
||||
}
|
||||
|
||||
function stubAgent(session: Session): Agent {
|
||||
return {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
status: 'idle',
|
||||
ctx: new Context(),
|
||||
followup: () => AgentMessageId('stub'),
|
||||
queue: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
inject: () => AgentMessageId('stub'),
|
||||
send: () => AgentMessageId('stub'),
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
|
||||
async function harness(
|
||||
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend())
|
||||
const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', storageDomain)
|
||||
ctx.provide('storageDomain', storageDomain)
|
||||
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
|
||||
await ctx.plugin(WorkspaceRegistry)
|
||||
|
||||
const factory: AgentFactory = {
|
||||
async createAgent(_ownerCtx, options) {
|
||||
const session = ctx.sessions.create(
|
||||
options.sessionId,
|
||||
options.meta === undefined ? {} : { meta: options.meta },
|
||||
)
|
||||
const agent = stubAgent(session)
|
||||
const unregister = ctx.agents.register(agent)
|
||||
return {
|
||||
agent,
|
||||
dispose: () => {
|
||||
unregister()
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
},
|
||||
async resume() {
|
||||
throw new Error('test harness has no persisted sessions')
|
||||
},
|
||||
}
|
||||
ctx.agents.setFactory(factory)
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
})
|
||||
return { api, ctx, storageDomain, workspaceRoot }
|
||||
}
|
||||
|
||||
describe('workspace.create', () => {
|
||||
it('serializes concurrent names and rejects the duplicate', async () => {
|
||||
const { api, workspaceRoot } = await harness()
|
||||
const responses = await Promise.all([
|
||||
api.workspace.create(request({ name: 'alpha' })),
|
||||
api.workspace.create(request({ name: 'alpha' })),
|
||||
])
|
||||
const created = responses.find(response => response.result.ok)
|
||||
const duplicate = responses.find(response => !response.result.ok)
|
||||
|
||||
expect(created).toBeDefined()
|
||||
expect(expectOk(created!)).toMatchObject({
|
||||
created: true,
|
||||
workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
|
||||
})
|
||||
expect(duplicate?.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
|
||||
})
|
||||
expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
|
||||
})
|
||||
|
||||
it('adopts only existing directories and rejects unsafe names', async () => {
|
||||
const { api, workspaceRoot } = await harness()
|
||||
const existing = join(workspaceRoot, 'existing')
|
||||
mkdirSync(existing)
|
||||
const first = expectOk(await api.workspace.create(request({ path: existing })))
|
||||
const repeated = expectOk(await api.workspace.create(request({ path: existing })))
|
||||
expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
|
||||
expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } })
|
||||
|
||||
const missing = join(workspaceRoot, 'missing')
|
||||
const missingResult = await api.workspace.create(request({ path: missing }))
|
||||
expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
|
||||
expect(existsSync(missing)).toBe(false)
|
||||
|
||||
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
|
||||
const invalid = await api.workspace.create(request({ name }))
|
||||
expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('session creation and Workspace membership', () => {
|
||||
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
||||
const sessionId = SessionId('session-workspace-preallocated')
|
||||
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
|
||||
expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1)
|
||||
|
||||
const ungrouped = SessionId('session-cwd-only')
|
||||
expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped })))
|
||||
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped)
|
||||
|
||||
const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId }))
|
||||
expect(conflict.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } },
|
||||
})
|
||||
const missing = await api.sessions.create(request({
|
||||
workspaceId: 'missing-workspace' as WorkspaceId,
|
||||
sessionId: SessionId('session-missing-workspace'),
|
||||
}))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
|
||||
})
|
||||
|
||||
it('retains a published session when attachment fails and repairs it on retry', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
||||
const workspace = ctx.workspace.list()[0]
|
||||
if (workspace === undefined) throw new Error('workspace missing from registry')
|
||||
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
|
||||
const sessionId = SessionId('session-attach-retry')
|
||||
|
||||
const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))
|
||||
expect(failed.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } },
|
||||
})
|
||||
expect(ctx.agents.get(sessionId)).toBeDefined()
|
||||
|
||||
expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })))
|
||||
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Host Workspace increments', () => {
|
||||
it('streams committed Workspace and Session increments after empty baselines', async () => {
|
||||
const { api } = await harness()
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
|
||||
|
||||
const abort = new AbortController()
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const workspaceIncrement = nextHostFrame(stream)
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
||||
expect(await workspaceIncrement).toMatchObject({
|
||||
payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
|
||||
})
|
||||
|
||||
const sessionId = SessionId('session-streamed-workspace')
|
||||
const pending = nextHostFrame(stream)
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
const increments: HostFrame[] = []
|
||||
increments.push((await pending).payload)
|
||||
while (increments.length < 2) {
|
||||
const next = await stream.next()
|
||||
if (next.done === true) throw new Error('Host stream ended before both increments')
|
||||
increments.push(next.value.payload)
|
||||
}
|
||||
expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({
|
||||
// A just-created session has no events: the frame constantly carries blank:true.
|
||||
type: 'host/session-added', sessionId, blank: true, cwd: workspace.path,
|
||||
})
|
||||
const workspaceChanged = increments.find(
|
||||
(increment): increment is Extract<HostFrame, { type: 'host/workspace-changed' }> =>
|
||||
increment.type === 'host/workspace-changed',
|
||||
)
|
||||
expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId])
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('does not publish a Workspace whose registry-order commit fails', async () => {
|
||||
const { api, storageDomain } = await harness()
|
||||
const domain = storageDomain.get('workspace')
|
||||
if (domain === undefined) throw new Error('workspace domain is not open')
|
||||
vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
|
||||
const abort = new AbortController()
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const next = stream.next()
|
||||
|
||||
const failed = await api.workspace.create(request({ name: 'ghost' }))
|
||||
expect(failed.result.ok).toBe(false)
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
||||
abort.abort()
|
||||
expect(await next).toMatchObject({ done: true })
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,8 @@ function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>>
|
||||
function scriptedApi(overrides: {
|
||||
sessions?: Partial<ApiProxy['sessions']>
|
||||
host?: Partial<ApiProxy['host']>
|
||||
commands?: Partial<ApiProxy['commands']>
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
events?: Partial<ApiProxy['events']>
|
||||
respond?: ApiProxy['respond']
|
||||
} = {}): ApiProxy {
|
||||
@@ -46,6 +48,18 @@ function scriptedApi(overrides: {
|
||||
...overrides.sessions,
|
||||
},
|
||||
host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host },
|
||||
workspace: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
|
||||
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
},
|
||||
commands: {
|
||||
list: r => ok(r, { commands: [] }),
|
||||
execute: r => ok(r, { matched: false }),
|
||||
...overrides.commands,
|
||||
},
|
||||
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
|
||||
}
|
||||
@@ -62,7 +76,7 @@ describe('unary round trip', () => {
|
||||
sessions: {
|
||||
list: (r) => {
|
||||
seen = r
|
||||
return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false }] })
|
||||
return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false, blank: false }] })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -71,7 +85,20 @@ describe('unary round trip', () => {
|
||||
expect(seen?.payload).toEqual({ cursor: 'c1' })
|
||||
expect(seen?.rpcId).toBeTruthy()
|
||||
expect(response.rpcId).toBe(seen?.rpcId)
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } })
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
|
||||
})
|
||||
|
||||
it('routes workspace rename and insertSessionBefore through the wire', async () => {
|
||||
const api = scriptedApi()
|
||||
const c = client(api)
|
||||
const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
|
||||
expect(renamed.result.ok).toBe(true)
|
||||
const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' })
|
||||
expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
|
||||
const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
|
||||
expect(anchored.result.ok).toBe(true)
|
||||
const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
|
||||
expect(appended.result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('passes business errors through as 200 + err result, not a throw', async () => {
|
||||
@@ -202,6 +229,23 @@ describe('unary round trip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace domain round trip', () => {
|
||||
it('routes both workspace methods through their handler rows and value schemas', async () => {
|
||||
const c = client(scriptedApi())
|
||||
const list = await c.workspace.list({})
|
||||
expect(list.result).toEqual({ ok: true, value: { items: [] } })
|
||||
const created = await c.workspace.create({ path: '/t' })
|
||||
expect(created.result.ok).toBe(true)
|
||||
if (created.result.ok) expect(created.result.value.created).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a create payload violating the exactly-one refine at the handler', async () => {
|
||||
const response = await client(scriptedApi()).workspace.create({})
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSE stream path', () => {
|
||||
it('yields frames in order and skips the comment preamble', async () => {
|
||||
const frames: MuxFrame[] = [
|
||||
@@ -253,7 +297,7 @@ describe('SSE stream path', () => {
|
||||
const api = scriptedApi({
|
||||
events: {
|
||||
async *host(request): AsyncGenerator<RpcRequest<HostFrame>> {
|
||||
yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1') } }
|
||||
yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1'), blank: true } }
|
||||
throw new Error('impl died mid-stream')
|
||||
},
|
||||
},
|
||||
|
||||
@@ -64,6 +64,53 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
|
||||
},
|
||||
async create(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } },
|
||||
}
|
||||
},
|
||||
async rename(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
|
||||
}
|
||||
},
|
||||
async insertSessionBefore(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
|
||||
}
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } } }
|
||||
},
|
||||
async execute(request, signal) {
|
||||
if (request.payload.line === '/hang') {
|
||||
// Cooperative hang: settles only through the carrier signal (sticky
|
||||
// abort checked first — listeners never fire retroactively).
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
|
||||
}
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } }
|
||||
}
|
||||
if (request.payload.line.startsWith('/plan')) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, result: { kind: 'success' as const, text: 'plan set' } } } }
|
||||
}
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: (_request, signal) => stream(muxFrames, signal),
|
||||
host: (_request, signal) => stream(hostFrames, signal),
|
||||
@@ -110,6 +157,32 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
expect((await c.host.describe({})).result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips command.list / command.execute / skill.list through the wire form', async () => {
|
||||
const c = client()
|
||||
const list = await c.commands.list({ sessionId: 's' as never })
|
||||
expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } })
|
||||
const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' })
|
||||
expect(hit.result).toEqual({ ok: true, value: { matched: true, result: { kind: 'success', text: 'plan set' } } })
|
||||
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
|
||||
expect(miss.result).toEqual({ ok: true, value: { matched: false } })
|
||||
const skills = await c.skills.list({ sessionId: 's' as never })
|
||||
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into command.execute', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } })
|
||||
// The fake's /hang settles only when the invoke-level signal aborts: a
|
||||
// completed response with the cancelled error proves req.signal reached it.
|
||||
const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', body, signal: controller.signal }))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
|
||||
expect(parsed.rpcId).toBe('r-sig')
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handler carrier-layer statuses', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { RpcId, transportError } from '../src/api/rpc.ts'
|
||||
import {
|
||||
clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema,
|
||||
rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema,
|
||||
@@ -8,11 +8,21 @@ import { z } from 'zod'
|
||||
import {
|
||||
contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema,
|
||||
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
|
||||
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
|
||||
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
|
||||
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
|
||||
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema,
|
||||
sessionPromptValueSchema, sessionSummarySchema,
|
||||
} from '../src/api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListRequestSchema, workspaceListValueSchema,
|
||||
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
||||
} from '../src/api/workspace.schema.ts'
|
||||
import {
|
||||
commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema,
|
||||
commandListRequestSchema, commandListValueSchema,
|
||||
} from '../src/api/commands.schema.ts'
|
||||
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
|
||||
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
|
||||
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
||||
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
|
||||
@@ -27,16 +37,24 @@ describe('RpcId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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: 'model-unavailable',
|
||||
message: 'm',
|
||||
details: { provider: 'p', model: 'm' },
|
||||
}).code).toBe('model-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b' } }).code).toBe('session-conflict')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-attach-failed', message: 'm', details: { sessionId: 's', workspaceId: 'w' } }).code).toBe('workspace-attach-failed')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-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')
|
||||
})
|
||||
@@ -90,8 +108,10 @@ describe('sessions domain schemas', () => {
|
||||
it('validates ids, summaries, and the event passthrough envelope', () => {
|
||||
expect(sessionIdSchema.parse('s1')).toBe('s1')
|
||||
expect(() => sessionIdSchema.parse('')).toThrow()
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toMatchObject({ sessionId: 's1' })
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true })
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
|
||||
// blank is mandatory: a summary without it fails the parse.
|
||||
expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow()
|
||||
const event = sessionEventSchema.parse({ type: 'user/message', seq: 0, time: 1, data: { any: true } })
|
||||
expect(event).toMatchObject({ type: 'user/message' })
|
||||
expect(() => sessionEventSchema.parse({ type: 'user/message', seq: -1, time: 1, data: {} })).toThrow()
|
||||
@@ -102,42 +122,13 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
|
||||
expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
|
||||
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
|
||||
// The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
|
||||
expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(() => sessionCreateRequestSchema.parse({ workspaceId: 'w1', cwd: '/w' })).toThrow(/not both/)
|
||||
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
|
||||
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
|
||||
expect(sessionHistoryValueSchema.parse({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}).hasMore).toBe(false)
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek V4 Flash',
|
||||
description: 'fast',
|
||||
unlisted: true,
|
||||
}],
|
||||
}],
|
||||
failures: [{ id: 'broken', name: 'Broken', message: 'offline' }],
|
||||
}).groups[0]?.models[0]?.id).toBe('deepseek-v4-flash')
|
||||
expect(sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
}).model).toBe('deepseek-v4-pro')
|
||||
expect(sessionSelectModelValueSchema.parse({
|
||||
selected: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
}).selected.model).toBe('deepseek-v4-pro')
|
||||
expect(() => sessionSelectModelRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
provider: '',
|
||||
model: 'm',
|
||||
})).toThrow()
|
||||
expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
|
||||
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
|
||||
expect(prompt.mode).toBe('queue')
|
||||
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
|
||||
@@ -157,6 +148,88 @@ describe('host domain schemas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace domain schemas', () => {
|
||||
const view = {
|
||||
workspaceId: 'w1', path: '/p', title: 'p', sessionIds: ['s1'],
|
||||
createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z',
|
||||
}
|
||||
|
||||
it('validates ids, the view row, and list request/value', () => {
|
||||
expect(workspaceIdSchema.parse('w1')).toBe('w1')
|
||||
expect(() => workspaceIdSchema.parse('')).toThrow()
|
||||
expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1'])
|
||||
expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow()
|
||||
expect(workspaceListRequestSchema.parse({})).toEqual({})
|
||||
expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('create requires exactly one of path/name (both refine arms)', () => {
|
||||
expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
|
||||
expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n')
|
||||
expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/)
|
||||
expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/)
|
||||
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
|
||||
})
|
||||
|
||||
it('rename requires a non-blank title (both refine arms)', () => {
|
||||
expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new')
|
||||
expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/)
|
||||
expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
|
||||
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
||||
expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow()
|
||||
expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('commands domain schemas', () => {
|
||||
it('validates the catalog request/value pair', () => {
|
||||
expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
// The wire is session-addressed only: a sessionId-less payload fails.
|
||||
expect(() => commandListRequestSchema.parse({})).toThrow()
|
||||
expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([])
|
||||
const value = commandListValueSchema.parse({ commands: [
|
||||
{ name: 'plan', description: 'Toggle plan mode' },
|
||||
{ name: 'goal', description: 'Set the goal', input: { hint: '<goal>' } },
|
||||
] })
|
||||
expect(value.commands[1]?.input?.hint).toBe('<goal>')
|
||||
expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined()
|
||||
expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow()
|
||||
expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow()
|
||||
})
|
||||
|
||||
it('validates the execute request/value pair with both matched branches', () => {
|
||||
expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off')
|
||||
// Both members are mandatory: dropping either fails the parse.
|
||||
expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
|
||||
expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
|
||||
expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
|
||||
const matched = commandExecuteValueSchema.parse({ matched: true, result: { kind: 'success', text: 'done' } })
|
||||
expect(matched.result?.kind).toBe('success')
|
||||
expect(commandExecuteValueSchema.parse({ matched: true, result: { kind: 'error', text: 'bad' } }).result?.kind).toBe('error')
|
||||
expect(() => commandExecuteValueSchema.parse({ matched: true, result: { kind: 'other' } })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills domain schemas', () => {
|
||||
it('validates the list request/value pair', () => {
|
||||
expect(skillListRequestSchema.parse({ sessionId: 's1' })).toEqual({ sessionId: 's1' })
|
||||
// The wire is session-addressed only: a sessionId-less payload fails.
|
||||
expect(() => skillListRequestSchema.parse({})).toThrow()
|
||||
expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([])
|
||||
const value = skillListValueSchema.parse({ skills: [
|
||||
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' },
|
||||
{ name: 'bare', description: 'No guidance' },
|
||||
] })
|
||||
expect(value.skills[0]?.whenToUse).toBe('when committing')
|
||||
expect(value.skills[1]?.whenToUse).toBeUndefined()
|
||||
expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('events frame schemas', () => {
|
||||
it('accepts every mux frame branch', () => {
|
||||
const frames = [
|
||||
@@ -167,6 +240,8 @@ describe('events frame schemas', () => {
|
||||
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
|
||||
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
|
||||
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
|
||||
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' }, steering: false },
|
||||
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' }, steering: true },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
@@ -185,13 +260,20 @@ describe('events frame schemas', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a queued frame missing its members', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [{ type: 'text' }], source: { kind: 'user' } })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: 'x', source: { kind: 'user' }, steering: false })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: {}, steering: false })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts every host frame branch', () => {
|
||||
const frames = [
|
||||
{ type: 'host/session-added', sessionId: 's', parentSessionId: 'p' },
|
||||
{ type: 'host/session-added', sessionId: 's' },
|
||||
{ type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' },
|
||||
{ type: 'host/session-added', sessionId: 's', blank: true },
|
||||
{ type: 'host/session-removed', sessionId: 's' },
|
||||
{ type: 'host/session-status', sessionId: 's', running: true },
|
||||
{ type: 'host/agent-error', sessionId: 's', message: 'boom' },
|
||||
{ type: 'host/commands-changed' },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
|
||||
@@ -8,24 +8,48 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../workspace/workspace"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# @deepseek-ai/dsh-host-runtime
|
||||
|
||||
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
|
||||
|
||||
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Contract |
|
||||
|---|---:|---|
|
||||
| `persistenceRoot` | (required) | Root directory for JSONL session persistence. |
|
||||
| `workspaceContext` | (required) | [`AGENTS.md`/`CLAUDE.md` loader](../../context/workspace-context/README.md) config with an explicit `maxBytes`, or `false` to disable it. |
|
||||
| `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. |
|
||||
| `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. |
|
||||
| `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. |
|
||||
| `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback and accepted-title limits. |
|
||||
| `sessionTitleLlm` | disabled | `true` enables the 5-word / 10-CJK-character, 4,096-input-byte, 64-output-token, 60-second first-message policy; an explicit config overrides it. An omitted route inherits the logged main-request provider and model. |
|
||||
|
||||
## ApiProxy implementation notes
|
||||
|
||||
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
|
||||
|
||||
The Web front door installs the shared `installAgentLlmTarget` coupling for every created or resumed agent. A session starts from the latest logged `request/header` route when one exists, otherwise from the Host default. `session.models` discovers every registered provider concurrently, keeps successful groups when another provider fails, and inserts the current target as an unlisted row when its provider omits it. A selection changes the mutable session target immediately; prompt assembly snapshots it atomically with request routing, so a change during a running step first applies to the next assembled step.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No main-request invalidation; when enabled, the auxiliary title request has its own cache behavior and leaves the conversation prefix unchanged.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence.
|
||||
- **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version.
|
||||
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-runtime",
|
||||
"description": "Host runtime assembly for dsh: bootHost composes the core spine, createApiProxy implements the contract, startHost is the one-step shell seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"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"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@cordisjs/plugin-timer": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* Core spine composition for the dsh host: mounts the harness core plugins
|
||||
* one by one (each awaited so a load failure surfaces deterministically at
|
||||
* boot, unlike bundle plugins whose children mount unawaited).
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
|
||||
import * as SessionTitleFirstMessageLlm from '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
|
||||
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as toolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import FsLocal from '@deepseek-ai/dsh-fs-local'
|
||||
import * as fsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as toolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import CompactBasic from '@deepseek-ai/dsh-compact-basic'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
|
||||
import * as toolSubagent from '@deepseek-ai/dsh-tool-subagent'
|
||||
import WorkflowWorkerthread from '@deepseek-ai/dsh-workflow-workerthread'
|
||||
import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
||||
import SpillLocal from '@deepseek-ai/dsh-spill-local'
|
||||
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
/** Default deterministic title policy for sessions created through the host. */
|
||||
const DEFAULT_SESSION_TITLE_CONFIG: SessionTitleConfig = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 80,
|
||||
}
|
||||
|
||||
/** Default first-message model-title policy for sessions created through the host. */
|
||||
const DEFAULT_SESSION_TITLE_LLM_CONFIG: SessionTitleLlmConfig = {
|
||||
targetWords: 5,
|
||||
targetCjkCharacters: 10,
|
||||
maxInputBytes: 4_096,
|
||||
maxOutputTokens: 64,
|
||||
timeoutMs: 60_000,
|
||||
}
|
||||
|
||||
/** Options for bootHost — the assembly-layer composition knobs. */
|
||||
export interface BootHostOptions {
|
||||
/** Root directory for JSONL session persistence. */
|
||||
persistenceRoot: string
|
||||
/** Workspace-instruction byte budget/config, or false to disable AGENTS.md/CLAUDE.md loading. */
|
||||
workspaceContext: workspaceContext.Config | false
|
||||
/** Default provider route for created/resumed agents (defaults to 'deepseek', the only adapter bootHost registers). */
|
||||
provider?: string
|
||||
/** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */
|
||||
model?: string
|
||||
/** Deterministic fallback-title limits. */
|
||||
sessionTitle?: SessionTitleConfig
|
||||
/** Opt-in first-message model-title policy; `true` selects host defaults and an explicit config overrides them. */
|
||||
sessionTitleLlm?: true | SessionTitleLlmConfig
|
||||
/**
|
||||
* Default project directory for sessions created without an explicit cwd
|
||||
* (defaults to the host process working directory). A session's cwd is its
|
||||
* project path — a per-session choice, not a host property; this option only
|
||||
* supplies the value used when the creator does not choose one.
|
||||
*/
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/** Host-level default agent routing: the single source injected on create and reported by host.describe. */
|
||||
export interface HostDefaults {
|
||||
provider: string
|
||||
model: string
|
||||
/** Default project directory for new sessions whose create request carries no cwd. */
|
||||
cwd: string
|
||||
}
|
||||
|
||||
/** Booted host handle: composed root context + resolved defaults + disposer. */
|
||||
export interface HostHandle {
|
||||
/** Root context with the full plugin assembly mounted. */
|
||||
ctx: Context
|
||||
/** Resolved default agent routing (options ?? built-in fallbacks). */
|
||||
defaults: HostDefaults
|
||||
/** Tear down the whole plugin tree. */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the harness host plugin assembly (the one place deciding which plugins mount and
|
||||
* with what defaults — shells must not alter the assembly).
|
||||
* @param options - persistence, workspace instructions, and optional default routing.
|
||||
* @returns the booted handle (ctx + defaults + dispose).
|
||||
*/
|
||||
export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
|
||||
const defaults: HostDefaults = {
|
||||
provider: options.provider ?? 'deepseek',
|
||||
model: options.model ?? 'deepseek-v4-flash',
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, options.sessionTitle ?? DEFAULT_SESSION_TITLE_CONFIG)
|
||||
if (options.sessionTitleLlm !== undefined) {
|
||||
await ctx.plugin(
|
||||
SessionTitleFirstMessageLlm,
|
||||
options.sessionTitleLlm === true ? DEFAULT_SESSION_TITLE_LLM_CONFIG : options.sessionTitleLlm,
|
||||
)
|
||||
}
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
// Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml +
|
||||
// the agent-spine bundle) so web sessions get the same coding-agent tool
|
||||
// face; deviations are noted inline.
|
||||
await ctx.plugin(toolBash, {})
|
||||
await ctx.plugin(toolTodo)
|
||||
await ctx.plugin(toolTasks, {})
|
||||
// fs paths resolve against the host default project rather than the raw
|
||||
// process cwd — the same source create() injects into session.cwd.
|
||||
await ctx.plugin(FsLocal, { cwd: defaults.cwd })
|
||||
await ctx.plugin(fsPolicy)
|
||||
await ctx.plugin(toolFs, {})
|
||||
await ctx.plugin(toolFsSearch, {})
|
||||
if (options.workspaceContext !== false) {
|
||||
await ctx.plugin(workspaceContext, options.workspaceContext)
|
||||
}
|
||||
// Skill stack with the demo default dshHome (~/.dsh via resolveDshHome).
|
||||
await ctx.plugin(SkillService, {})
|
||||
await ctx.plugin(SkillLocal, {})
|
||||
await ctx.plugin(toolSkill, {})
|
||||
// Request pressure + compaction (service-wide defaults, as in repl-agent).
|
||||
await ctx.plugin(TokenMeter)
|
||||
await ctx.plugin(CompactBasic)
|
||||
// Subagent spawn/fork backends and their two model-facing tool instances.
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(SubagentFork, { providerName: 'fork' })
|
||||
await ctx.plugin(toolSubagent, { provider: 'spawn', toolName: 'subagent' })
|
||||
await ctx.plugin(toolSubagent, { provider: 'fork', toolName: 'subagent_fork' })
|
||||
await ctx.plugin(WorkflowWorkerthread, { provider: 'spawn' })
|
||||
await ctx.plugin(toolWorkflow, {})
|
||||
// Declared per-tool timeouts become enforced deadlines.
|
||||
await ctx.plugin(timeoutPolicy)
|
||||
// Oversized tool output spills to session-scoped files (repl-agent budget).
|
||||
await ctx.plugin(SpillLocal, {})
|
||||
await ctx.plugin(spillPolicy, { maxInlineBytes: 50000 })
|
||||
return { ctx, defaults, dispose: () => ctx.fiber.dispose() }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-runtime — host runtime assembly layer: the core spine
|
||||
* composition (bootHost), the ApiProxy implementation (createApiProxy), and
|
||||
* the one-step shell seam (startHost). Host-level configuration (defaults,
|
||||
* persistenceRoot, future user profile) lives here.
|
||||
*/
|
||||
|
||||
export { bootHost } from './boot.ts'
|
||||
export type { BootHostOptions, HostDefaults, HostHandle } from './boot.ts'
|
||||
export { createApiProxy } from './api-proxy.ts'
|
||||
export type { ApiProxyDefaults } from './api-proxy.ts'
|
||||
export { startHost } from './start.ts'
|
||||
export type { StartHostOptions, RunningHost } from './start.ts'
|
||||
export { mountWebPlugins } from './web-plugins.ts'
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-host-runtime`.
|
||||
* @module @deepseek-ai/dsh-host-runtime/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-host-runtime'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'host-runtime-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this assembly layer only composes plugins owned
|
||||
* elsewhere; the event/data relations it touches (session events, agent
|
||||
* lifecycle, wire frames) are asserted by their owning packages' companions.
|
||||
*/
|
||||
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,57 +0,0 @@
|
||||
/**
|
||||
* One-step host startup seam: boot core → assemble ApiProxy → assemble the
|
||||
* fetch handler. The returned RunningHost is shell-agnostic — node:http
|
||||
* (dsh web), in-process injection (dsh -p, tests), an IPC bridge (future
|
||||
* Electron sidecar), and front-door plugin mounting (future dsh acp) all
|
||||
* consume the same shape.
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { bootHost } from './boot.ts'
|
||||
import type { BootHostOptions, HostDefaults } from './boot.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
|
||||
/** Options for startHost. */
|
||||
export interface StartHostOptions {
|
||||
/**
|
||||
* Passed through to bootHost verbatim. Future host-level knobs (profile,
|
||||
* log sink — any output added to the assembly MUST be switchable off here)
|
||||
* land as additive fields.
|
||||
*/
|
||||
boot: BootHostOptions
|
||||
}
|
||||
|
||||
/** Running host handle: the contract impl plus its fetch carrier and root ctx. */
|
||||
export interface RunningHost {
|
||||
/** Contract implementation (direct calls for in-process consumers; the input of an IPC adapter). */
|
||||
api: ApiProxy
|
||||
/** WHATWG-fetch-shaped carrier (web shell bridges it to node:http; host-side endpoint of an IPC bridge). */
|
||||
handler: { fetch: typeof fetch }
|
||||
/** Host-level default routing (describe and every shell share this single source). */
|
||||
defaults: HostDefaults
|
||||
/**
|
||||
* Root context — a formal seam, not an escape hatch: (1) the mount point for
|
||||
* protocol front-door plugins (`dsh acp` = startHost() → ctx.plugin(uiAcp, config));
|
||||
* (2) headless session-event subscription. Discipline: consuming clients must
|
||||
* not bypass `api` through ctx; shells must not ctx.plugin to alter the
|
||||
* assembly (mounting a front door is the shell's own shape, not an assembly change).
|
||||
*/
|
||||
ctx: Context
|
||||
/** Single shutdown exit (ctx.fiber.dispose()). Idempotent: a second call returns the same promise. */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the host and assemble its consumption surfaces in one step.
|
||||
* @param options - boot passthrough (see StartHostOptions).
|
||||
* @returns the running host handle shared by every shell shape.
|
||||
*/
|
||||
export async function startHost(options: StartHostOptions): Promise<RunningHost> {
|
||||
const host = await bootHost(options.boot)
|
||||
const api = createApiProxy(host.ctx, host.defaults)
|
||||
const handler = toFetchHandler(api)
|
||||
let disposing: Promise<void> | undefined
|
||||
return { api, handler, defaults: host.defaults, ctx: host.ctx, dispose: () => (disposing ??= host.dispose()) }
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Web client plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
|
||||
* entry tree over the caller-supplied client plugin roster. The roster is a
|
||||
* composition decision and lives in the composing app (apps/cli); this module
|
||||
* only owns the mount/settle/fail-loud mechanics. The web plugin registry
|
||||
* discovers fetch-arrival entries among the mounted packages by their
|
||||
* package.json dshClient declarations; node halves are empty applies, so
|
||||
* mounting them here costs nothing beyond Loader governance.
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
import type { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
|
||||
/** What the shell hands the web plugin registry (loader view + module resolution seam). */
|
||||
export interface MountedWebPlugins {
|
||||
/** Entry enumeration surface of the mounted Loader (registry scan source). */
|
||||
loader: { entries(): Iterable<{ options: { name: string }; fiber?: unknown; disabled: boolean }> }
|
||||
/** Resolve a plugin package's package.json absolute path. */
|
||||
resolvePkgJson: (name: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the Loader (when absent) and create one in-memory entry per client
|
||||
* plugin package, then wait for the tree to settle. A plugin whose import
|
||||
* fails leaves its entry fiber-less — surfaced here as a loud throw listing
|
||||
* the failures (misconfiguration must not silently drop a client plugin).
|
||||
* @param ctx - host root context (bootHost product).
|
||||
* @param plugins - client plugin package names to mount (the composition layer's roster).
|
||||
* @param anchor - module URL anchoring bare-specifier resolution (the composing
|
||||
* app's import.meta.url; the roster packages must be dependencies of that app).
|
||||
* @returns the loader view and package.json resolver the registry consumes.
|
||||
*/
|
||||
export async function mountWebPlugins(
|
||||
ctx: Context, plugins: readonly string[], anchor: string,
|
||||
): Promise<MountedWebPlugins> {
|
||||
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
|
||||
// import silently fails and every entry stays fiber-less. The composing app
|
||||
// declares the roster packages as dependencies, so its URL is the right anchor.
|
||||
ctx.baseUrl ??= anchor
|
||||
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
|
||||
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
|
||||
for (const name of plugins) {
|
||||
if (!existing.has(name)) await ctx.loader.create({ name })
|
||||
}
|
||||
await ctx.loader.await()
|
||||
const dead = [...ctx.loader.entries()]
|
||||
.filter(entry => plugins.includes(entry.options.name))
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
if (dead.length > 0) {
|
||||
throw new Error(`web-plugins: client plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`)
|
||||
}
|
||||
const require = createRequire(anchor)
|
||||
return {
|
||||
loader: ctx.loader,
|
||||
resolvePkgJson: name => require.resolve(`${name}/package.json`),
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Web session model-directory and selection behavior: dynamic provider grouping,
|
||||
* provider-local catalog failures, logged-target restoration, advisory unlisted
|
||||
* models, and the prompt-assembly boundary for a running selection change.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmProviderInfo, StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
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
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
class CatalogAdapter extends LlmAdapter {
|
||||
constructor(
|
||||
private readonly name: string,
|
||||
private readonly models: readonly LlmModelInfo[] | Error,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: this.name }
|
||||
}
|
||||
|
||||
override listModels(): Promise<readonly LlmModelInfo[]> {
|
||||
return this.models instanceof Error
|
||||
? Promise.reject(this.models)
|
||||
: Promise.resolve(this.models)
|
||||
}
|
||||
|
||||
override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
// Catalog tests never enter provider streaming.
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(logged?: { provider: string; model: string }): Promise<{
|
||||
ctx: Context
|
||||
agent: Agent
|
||||
sessionId: SessionId
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
|
||||
{ provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||||
{ provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
|
||||
]))
|
||||
ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
|
||||
ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
|
||||
ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
|
||||
{ provider: 'duplicate', id: 'same', name: 'Same' },
|
||||
{ provider: 'duplicate', id: 'same', name: 'Same Again' },
|
||||
]))
|
||||
const session = ctx.sessions.create()
|
||||
if (logged !== undefined) {
|
||||
session.append('request/header', { header: { config: logged }, reason: 'initial' })
|
||||
}
|
||||
const agent = {
|
||||
id: session.id,
|
||||
session,
|
||||
status: 'running',
|
||||
ctx,
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, agent, sessionId: session.id }
|
||||
}
|
||||
|
||||
function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
|
||||
if (!response.result.ok) throw new Error('expected successful response')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
describe('Web session model selection', () => {
|
||||
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
|
||||
const { ctx, sessionId } = await harness({ provider: 'deepseek', model: 'private-preview' })
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp' })
|
||||
|
||||
const history = expectValue(await api.sessions.history(request({ sessionId })))
|
||||
expect(history.modelTarget).toEqual({ provider: 'deepseek', model: 'private-preview' })
|
||||
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.current).toEqual({ provider: 'deepseek', model: 'private-preview' })
|
||||
expect(catalog.groups).toEqual([{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||||
{ id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
|
||||
{ id: 'private-preview', name: 'private-preview', unlisted: true },
|
||||
],
|
||||
}])
|
||||
expect(catalog.failures).toEqual([
|
||||
{ id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
|
||||
{
|
||||
id: 'duplicate',
|
||||
name: 'Duplicate Provider',
|
||||
message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
|
||||
},
|
||||
])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp' })
|
||||
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
||||
const signal = new AbortController().signal
|
||||
|
||||
expect(expectValue(await api.sessions.history(request({ sessionId }))).modelTarget)
|
||||
.toEqual({ provider: 'deepseek', model: 'deepseek-chat' })
|
||||
expect((await ctx.systemPrompt.assemble()).variables)
|
||||
.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
|
||||
|
||||
const selected = expectValue(await api.sessions.selectModel(request({
|
||||
sessionId,
|
||||
provider: 'deepseek',
|
||||
model: 'private-preview',
|
||||
})))
|
||||
expect(selected.selected).toEqual({ provider: 'deepseek', model: 'private-preview' })
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables)
|
||||
.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
|
||||
|
||||
const rejected = await api.sessions.selectModel(request({
|
||||
sessionId,
|
||||
provider: 'missing',
|
||||
model: 'model',
|
||||
}))
|
||||
expect(rejected.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'model-unavailable',
|
||||
message: 'provider "missing" is not registered',
|
||||
details: { provider: 'missing', model: 'model' },
|
||||
},
|
||||
})
|
||||
expect(expectValue(await api.sessions.history(request({ sessionId }))).modelTarget)
|
||||
.toEqual({ provider: 'deepseek', model: 'private-preview' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,769 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
|
||||
import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/index.ts'
|
||||
|
||||
/** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */
|
||||
class ScriptedAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private script: (StreamChunk[] | 'hang')[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if ((options.tools?.length ?? 0) === 0) {
|
||||
yield * textResponse('Durable append-only session titles')
|
||||
return
|
||||
}
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('ScriptedAdapter: script exhausted')
|
||||
if (entry === 'hang') {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
yield * entry
|
||||
}
|
||||
}
|
||||
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
let nextRpc = 1
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject: Agent, status: string) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function expectOk<T>(response: RpcResponse<T>): T {
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
async function nextMux(iterator: AsyncIterator<RpcRequest<MuxFrame>>): Promise<RpcRequest<MuxFrame>> {
|
||||
const next = await iterator.next()
|
||||
if (next.done === true) throw new Error('mux ended before the expected frame')
|
||||
return next.value
|
||||
}
|
||||
|
||||
/** Durably append a title event without mounting title-generation policy. */
|
||||
function appendTitle(ctx: Context, agent: Agent, title: string) {
|
||||
return ctx.sessions.appendOutOfBand(agent.session, 'session/title', {
|
||||
title,
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
}, { kind: 'session-title' })
|
||||
}
|
||||
|
||||
let host: RunningHost | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'spec-placeholder-key')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await host?.dispose()
|
||||
host = undefined
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
async function boot(
|
||||
script: (StreamChunk[] | 'hang')[] = [],
|
||||
sessionTitle?: SessionTitleConfig,
|
||||
sessionTitleLlm?: true | SessionTitleLlmConfig,
|
||||
): Promise<RunningHost> {
|
||||
host = await startHost({
|
||||
boot: {
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')),
|
||||
workspaceContext: false,
|
||||
provider: 'scripted',
|
||||
model: 'test-model',
|
||||
...(sessionTitle === undefined ? {} : { sessionTitle }),
|
||||
...(sessionTitleLlm === undefined ? {} : { sessionTitleLlm }),
|
||||
},
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
|
||||
return host
|
||||
}
|
||||
|
||||
describe('bootHost / startHost', () => {
|
||||
it('falls back to the deepseek defaults and disposes idempotently', async () => {
|
||||
const handle: HostHandle = await bootHost({
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')),
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
expect(typeof handle.defaults.cwd).toBe('string')
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('uses the JSONL backend compressed default', async () => {
|
||||
const handle: HostHandle = await bootHost({
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-zstd-')),
|
||||
workspaceContext: false,
|
||||
})
|
||||
const session = handle.ctx.sessions.create()
|
||||
expect(handle.ctx.sessionPersistence.locate(session.header)?.path).toMatch(/\.jsonl\.zstd$/)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('startHost assembles api + handler over the same defaults and dedupes dispose', async () => {
|
||||
const running = await boot()
|
||||
expect(running.defaults).toMatchObject({ provider: 'scripted', model: 'test-model' })
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-h', method: 'host.describe', payload: {} })
|
||||
const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body }))
|
||||
const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } }
|
||||
expect(parsed.result.value.provider).toBe('scripted')
|
||||
const first = running.dispose()
|
||||
expect(running.dispose()).toBe(first)
|
||||
await first
|
||||
host = undefined
|
||||
})
|
||||
|
||||
it('routes workspace instructions through the assembled agent request prefix', async () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-host-workspace-'))
|
||||
mkdirSync(join(workspace, '.git'))
|
||||
writeFileSync(join(workspace, 'AGENTS.md'), 'host-workspace-context-probe\n')
|
||||
const adapter = new ScriptedAdapter([textResponse('done')])
|
||||
host = await startHost({
|
||||
boot: {
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-workspace-sessions-')),
|
||||
workspaceContext: { dshHome: join(workspace, '.dsh'), maxBytes: 65_536 },
|
||||
provider: 'scripted',
|
||||
model: 'test-model',
|
||||
cwd: workspace,
|
||||
},
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], adapter)
|
||||
const { sessionId } = expectOk(await host.api.sessions.create(request({})))
|
||||
const agent = host.ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(host.ctx, agent)
|
||||
|
||||
expectOk(await host.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'go' }],
|
||||
})))
|
||||
await idle
|
||||
|
||||
const requestText = adapter.requests[0]?.messages
|
||||
.flatMap(message => message.content)
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('\n') ?? ''
|
||||
expect(requestText).toContain('Instructions from: AGENTS.md')
|
||||
expect(requestText).toContain('host-workspace-context-probe')
|
||||
})
|
||||
|
||||
it('keeps model title generation disabled when sessionTitleLlm is omitted', async () => {
|
||||
const running = await boot([textResponse('pong')])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
expectOk(await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'Explain durable session titles.' }],
|
||||
})))
|
||||
await idle
|
||||
|
||||
expect((await ctx.sessionTitle.refresh(agent.session))?.source).toEqual({ kind: 'fallback' })
|
||||
expect(agent.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host.describe', () => {
|
||||
it('reports version, cwd, defaults, and the attached count', async () => {
|
||||
const { api } = await boot()
|
||||
const value = expectOk(await api.host.describe(request({})))
|
||||
expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.create / list', () => {
|
||||
it('creates a session (echoing the request rpcId) and lists it newest-first', async () => {
|
||||
const { api } = await boot()
|
||||
const created = await api.sessions.create(request({ cwd: '/tmp' }))
|
||||
const { sessionId } = expectOk(created)
|
||||
expect(created.rpcId).toMatch(/^req-/)
|
||||
const second = expectOk(await api.sessions.create(request({}))).sessionId
|
||||
|
||||
const { items } = expectOk(await api.sessions.list(request({})))
|
||||
expect(items.map(item => item.sessionId)).toContain(sessionId)
|
||||
expect(items.map(item => item.sessionId)).toContain(second)
|
||||
const first = items.find(item => item.sessionId === sessionId)
|
||||
expect(first?.cwd).toBe('/tmp')
|
||||
expect(first?.running).toBe(false)
|
||||
expect(first?.parentSessionId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.prompt / cancel', () => {
|
||||
it.each([
|
||||
{ name: 'host default', config: true, target: '5 words', maxTokens: 64 },
|
||||
{
|
||||
name: 'configured policy',
|
||||
config: {
|
||||
targetWords: 3,
|
||||
targetCjkCharacters: 8,
|
||||
maxInputBytes: 2_048,
|
||||
maxOutputTokens: 24,
|
||||
timeoutMs: 2_000,
|
||||
},
|
||||
target: '3 words',
|
||||
maxTokens: 24,
|
||||
},
|
||||
] satisfies {
|
||||
name: string
|
||||
config: true | SessionTitleLlmConfig
|
||||
target: string
|
||||
maxTokens: number
|
||||
}[])('replaces the fallback with a model-backed first-message title using the $name', async ({ config, target, maxTokens }) => {
|
||||
const modelTitle = 'Durable append-only session titles'
|
||||
const running = await boot([textResponse('pong')], undefined, config)
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
expectOk(await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'Explain why append-only logs make session titles durable.' }],
|
||||
})))
|
||||
await idle
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.session.events.filter(event => event.type === 'session/title').map(event => event.data))
|
||||
.toEqual([
|
||||
{
|
||||
title: 'Explain why append-only logs make',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
{
|
||||
title: modelTitle,
|
||||
messageSeqs: [1],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: 'session-title-first-message-llm',
|
||||
model: { provider: 'scripted', model: 'test-model' },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
const titleRequest = agent.session.events.find(event => event.type === 'session/title-llm-request')
|
||||
expect(titleRequest?.data.system).toContain(target)
|
||||
expect(titleRequest?.data.maxTokens).toBe(maxTokens)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'host default', config: undefined, expected: 'Show the Web UI durable' },
|
||||
{
|
||||
name: 'configured limit',
|
||||
config: { fallbackMaxWords: 2, fallbackMaxBytes: 40, maxTitleBytes: 80 },
|
||||
expected: 'Show the',
|
||||
},
|
||||
] satisfies { name: string; config: SessionTitleConfig | undefined; expected: string }[])(
|
||||
'logs a durable fallback title with the $name',
|
||||
async ({ config, expected }) => {
|
||||
const running = await boot([textResponse('pong')], config)
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
expectOk(await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'Show the Web UI durable session title' }],
|
||||
})))
|
||||
await idle
|
||||
|
||||
const title = agent.session.events.find(event => event.type === 'session/title')
|
||||
expect(title?.data).toEqual({
|
||||
title: expected,
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
|
||||
const running = await boot([textResponse('pong')])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId)
|
||||
expect(agent).toBeDefined()
|
||||
const idle = waitForIdle(ctx, agent as Agent)
|
||||
const promptRequest = request({ sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'ping' }] })
|
||||
expectOk(await api.sessions.prompt(promptRequest))
|
||||
await idle
|
||||
|
||||
const value = expectOk(await api.sessions.history(request({ sessionId })))
|
||||
const events = value.events.map(entry => entry.event)
|
||||
const userEvent = events.find(event => event.type === 'user/message') as
|
||||
| { data: { source?: { rpcId?: string } } } | undefined
|
||||
expect(userEvent?.data.source?.rpcId).toBe(promptRequest.rpcId)
|
||||
const reply = events.find(event => event.type === 'assistant/message')
|
||||
expect(reply).toBeDefined()
|
||||
})
|
||||
|
||||
it('steer on an idle agent falls through to send', async () => {
|
||||
const running = await boot([textResponse('steered')])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const idle = waitForIdle(ctx, ctx.agents.get(sessionId) as Agent)
|
||||
expectOk(await api.sessions.prompt(request({ sessionId, mode: 'steer' as const, content: [{ type: 'text' as const, text: 'now' }] })))
|
||||
await idle
|
||||
})
|
||||
|
||||
it('errors session-not-found on a ghost session', async () => {
|
||||
const { api } = await boot()
|
||||
const response = await api.sessions.prompt(request({ sessionId: 'session-void' as SessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
|
||||
})
|
||||
|
||||
it('maps a synchronous send throw to agent-busy', async () => {
|
||||
const { api } = await boot()
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const poisoned = [{ type: 'text', text: 'x', bad: () => 1 }] as never
|
||||
const response = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: poisoned }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('agent-busy')
|
||||
})
|
||||
|
||||
it('cancels an attached agent and rejects an unattached one', async () => {
|
||||
const running = await boot(['hang'])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
agent.send([{ type: 'text', text: 'run forever' }])
|
||||
expectOk(await api.sessions.cancel(request({ sessionId })))
|
||||
|
||||
const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId }))
|
||||
expect(missing.result.ok).toBe(false)
|
||||
if (!missing.result.ok) expect(missing.result.error.code).toBe('session-not-found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.history', () => {
|
||||
it('implicitly resumes a cold session, deduplicating concurrent calls to one attach', async () => {
|
||||
const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-host-resume-'))
|
||||
const first = await startHost({
|
||||
boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' },
|
||||
})
|
||||
first.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('persisted')]))
|
||||
const { sessionId } = expectOk(await first.api.sessions.create(request({})))
|
||||
const agent = first.ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(first.ctx, agent)
|
||||
agent.send([{ type: 'text', text: 'save me' }])
|
||||
await idle
|
||||
const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title')
|
||||
await first.dispose()
|
||||
|
||||
host = await startHost({
|
||||
boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' },
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
|
||||
expect(host.ctx.agents.get(sessionId)).toBeUndefined()
|
||||
const abort = new AbortController()
|
||||
const mux = host.api.events.mux(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const [a, b] = await Promise.all([
|
||||
host.api.sessions.history(request({ sessionId })),
|
||||
host.api.sessions.history(request({ sessionId })),
|
||||
])
|
||||
for (const response of [a, b]) {
|
||||
const value = expectOk(response)
|
||||
expect(value.events.some(entry => entry.event.type === 'assistant/message')).toBe(true)
|
||||
}
|
||||
expect(host.ctx.agents.get(sessionId)).toBeDefined()
|
||||
expect(host.ctx.agents.list()).toHaveLength(1)
|
||||
expect((await nextMux(mux)).payload).toMatchObject({ type: 'session/subscribed', sessionId })
|
||||
expect((await nextMux(mux)).payload).toEqual(expect.objectContaining({
|
||||
type: 'session/title', sessionId, title: 'Persisted title', eventSeq: titleEvent.seq,
|
||||
}))
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => {
|
||||
const { api } = await boot()
|
||||
const ghost = 'session-ghost' as SessionId
|
||||
const [first, second] = await Promise.all([
|
||||
api.sessions.history(request({ sessionId: ghost })),
|
||||
api.sessions.history(request({ sessionId: ghost })),
|
||||
])
|
||||
for (const response of [first, second]) {
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
|
||||
}
|
||||
})
|
||||
|
||||
it('paginates backwards on message boundaries with hasMore', async () => {
|
||||
const running = await boot([textResponse('a1'), textResponse('a2'), textResponse('a3')])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
for (const text of ['q1', 'q2', 'q3']) {
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text }])
|
||||
await idle
|
||||
}
|
||||
|
||||
const all = expectOk(await api.sessions.history(request({ sessionId })))
|
||||
expect(all.hasMore).toBe(false)
|
||||
const messageCount = all.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length
|
||||
expect(messageCount).toBe(6)
|
||||
|
||||
const lastPage = expectOk(await api.sessions.history(request({ sessionId, maxMessages: 1 })))
|
||||
expect(lastPage.hasMore).toBe(true)
|
||||
expect(lastPage.events.filter(entry => entry.event.type === 'assistant/message')).toHaveLength(1)
|
||||
expect(lastPage.events.filter(entry => entry.event.type === 'user/message')).toHaveLength(0)
|
||||
|
||||
const firstSeq = lastPage.events[0]?.event.seq as number
|
||||
const olderPage = expectOk(await api.sessions.history(request({ sessionId, beforeSeq: firstSeq, maxMessages: 2 })))
|
||||
expect(olderPage.events.at(-1)?.event.seq).toBeLessThan(firstSeq)
|
||||
expect(olderPage.hasMore).toBe(true)
|
||||
expect(olderPage.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('events streams', () => {
|
||||
it('mux: a pending pull wakes when a frame arrives (waiter path)', async () => {
|
||||
const running = await boot()
|
||||
const { api } = running
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
// no sessions yet: next() must pend on the queue's waiter, not the buffer
|
||||
const pending = stream.next()
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const frame = (await pending).value as RpcRequest<MuxFrame>
|
||||
expect(frame.payload).toMatchObject({ type: 'session/subscribed', sessionId })
|
||||
ac.abort()
|
||||
expect((await stream.next()).done).toBe(true)
|
||||
})
|
||||
|
||||
it('lists fork lineage and announces it on the host stream', async () => {
|
||||
const running = await boot()
|
||||
const { api, ctx } = running
|
||||
const { sessionId: parent } = expectOk(await api.sessions.create(request({})))
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
const child = `session-child-${String(Date.now())}` as SessionId
|
||||
const handle = await ctx.agents.create({ sessionId: child, meta: { parentSession: parent }, agentOptions: { provider: 'scripted', model: 'test-model' } })
|
||||
expect(handle.agent.id).toBe(child)
|
||||
const added = (await stream.next()).value as RpcRequest<HostFrame>
|
||||
expect(added.payload).toMatchObject({ type: 'host/session-added', sessionId: child, parentSessionId: parent })
|
||||
const { items } = expectOk(await api.sessions.list(request({})))
|
||||
expect(items.find(item => item.sessionId === child)?.parentSessionId).toBe(parent)
|
||||
|
||||
await handle.dispose()
|
||||
let frame: RpcRequest<HostFrame>
|
||||
do frame = (await stream.next()).value as RpcRequest<HostFrame>
|
||||
while (frame.payload.type !== 'host/session-removed')
|
||||
expect(frame.payload).toMatchObject({ type: 'host/session-removed', sessionId: child })
|
||||
ac.abort()
|
||||
})
|
||||
|
||||
it('mux: emits subscribed baselines, live session events, and new-session subscriptions until abort', async () => {
|
||||
const running = await boot([textResponse('live')])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
const baseline = await stream.next()
|
||||
expect((baseline.value as RpcRequest<MuxFrame>).payload).toMatchObject({ type: 'session/subscribed', sessionId })
|
||||
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await idle
|
||||
const live = await stream.next()
|
||||
expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
|
||||
|
||||
const other = expectOk(await api.sessions.create(request({}))).sessionId
|
||||
let frame: RpcRequest<MuxFrame>
|
||||
do frame = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||
while (!(frame.payload.type === 'session/subscribed' && frame.payload.sessionId === other))
|
||||
|
||||
ac.abort()
|
||||
expect((await stream.next()).done).toBe(true)
|
||||
})
|
||||
|
||||
it('mux: projects durable titles after open baselines and immediately after live raw events', async () => {
|
||||
const running = await boot()
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const initial = await appendTitle(ctx, agent, 'Initial title')
|
||||
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId })
|
||||
expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({
|
||||
type: 'session/title', sessionId, title: 'Initial title', eventSeq: initial.seq, updatedAt: initial.time,
|
||||
}))
|
||||
|
||||
const revised = await appendTitle(ctx, agent, 'Revised title')
|
||||
let raw: RpcRequest<MuxFrame>
|
||||
do raw = await nextMux(stream)
|
||||
while (!(raw.payload.type === 'session/event' && raw.payload.event.type === 'session/title'))
|
||||
expect(raw.payload).toMatchObject({ type: 'session/event', sessionId, event: { seq: revised.seq } })
|
||||
expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({
|
||||
type: 'session/title', sessionId, title: 'Revised title', eventSeq: revised.seq, updatedAt: revised.time,
|
||||
}))
|
||||
ac.abort()
|
||||
})
|
||||
|
||||
it('mux: emits no title control for untitled subscriptions', async () => {
|
||||
const { api } = await boot()
|
||||
const first = expectOk(await api.sessions.create(request({}))).sessionId
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: first })
|
||||
|
||||
const second = expectOk(await api.sessions.create(request({}))).sessionId
|
||||
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: second })
|
||||
ac.abort()
|
||||
})
|
||||
|
||||
it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => {
|
||||
const running = await boot([textResponse('x')])
|
||||
const { api, ctx } = running
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const added = await stream.next()
|
||||
expect((added.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-added', sessionId })
|
||||
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text: 'run' }])
|
||||
await idle
|
||||
const runningFrame = await stream.next()
|
||||
expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })
|
||||
const idleFrame = await stream.next()
|
||||
expect((idleFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: false })
|
||||
|
||||
// Raw ctx.emit lacks the scope carrier the mounted invariants plugin now
|
||||
// enforces; dispatch the way the loop does.
|
||||
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('boom'))
|
||||
const errorFrame = await stream.next()
|
||||
expect((errorFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/agent-error', message: 'Error: boom' })
|
||||
|
||||
ac.abort()
|
||||
// Push-after-done: an event landing between abort and generator wind-down
|
||||
// must be dropped silently, not crash the queue.
|
||||
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('late'))
|
||||
expect((await stream.next()).done).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('question request / response', () => {
|
||||
const questions = [{
|
||||
id: 'mode', question: 'Choose a mode',
|
||||
options: [
|
||||
{ label: 'Fast (Recommended)', description: 'Move quickly.' },
|
||||
{ label: 'Careful', description: 'Review first.' },
|
||||
],
|
||||
}]
|
||||
|
||||
it('waits, replays the same rpcId on reconnect, validates, and resolves first-wins', async () => {
|
||||
const running = await boot()
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
await stream.next() // subscribed baseline starts the generator and installs the queue
|
||||
|
||||
const answerPromise = ctx.userInteraction.ask({ questions, agent })
|
||||
const requested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||
expect(requested.payload).toMatchObject({ type: 'question/requested', sessionId, questions })
|
||||
|
||||
const wrongSession = await api.respond({
|
||||
type: 'client-response', rpcId: requested.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { sessionId: 'session-other', answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
|
||||
},
|
||||
})
|
||||
expect(wrongSession).toEqual({ accepted: false, reason: 'bad-response' })
|
||||
const badChoice = await api.respond({
|
||||
type: 'client-response', rpcId: requested.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Unknown'] }] } },
|
||||
},
|
||||
})
|
||||
expect(badChoice).toEqual({ accepted: false, reason: 'bad-response' })
|
||||
const invalidResults = [
|
||||
{ ok: true as const, value: null },
|
||||
{ ok: true as const, value: { sessionId, answer: { answers: [] } } },
|
||||
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'wrong', selected: ['Fast (Recommended)'] }] } } },
|
||||
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Fast (Recommended)'] }] } } },
|
||||
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Careful'] }] } } },
|
||||
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: [], custom: ' ' }] } } },
|
||||
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Careful'], custom: 'Other' }] } } },
|
||||
{ ok: false as const, error: { code: 'internal' as const, message: 'wrong error', details: {} } },
|
||||
]
|
||||
for (const result of invalidResults) {
|
||||
expect(await api.respond({
|
||||
type: 'client-response', rpcId: requested.rpcId, result,
|
||||
})).toEqual({ accepted: false, reason: 'bad-response' })
|
||||
}
|
||||
|
||||
const reconnectAbort = new AbortController()
|
||||
const replay = api.events.mux(request({}), reconnectAbort.signal)[Symbol.asyncIterator]()
|
||||
await replay.next()
|
||||
const replayed = (await replay.next()).value as RpcRequest<MuxFrame>
|
||||
expect(replayed.rpcId).toBe(requested.rpcId)
|
||||
expect(replayed.payload).toEqual(requested.payload)
|
||||
|
||||
const response = {
|
||||
type: 'client-response' as const,
|
||||
rpcId: requested.rpcId,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
|
||||
},
|
||||
}
|
||||
const [first, duplicate] = await Promise.all([api.respond(response), api.respond(response)])
|
||||
expect([first, duplicate]).toContainEqual({ accepted: true })
|
||||
expect([first, duplicate]).toContainEqual({ accepted: false, reason: 'not-pending' })
|
||||
await expect(answerPromise).resolves.toEqual({
|
||||
answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }],
|
||||
})
|
||||
|
||||
const resolved = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||
expect(resolved.payload).toMatchObject({
|
||||
type: 'question/resolved', sessionId, questionRpcId: requested.rpcId, outcome: 'answered',
|
||||
})
|
||||
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
|
||||
|
||||
const customQuestions = [{ id: 'detail', question: 'What else?' }]
|
||||
const customAnswer = ctx.userInteraction.ask({ questions: customQuestions, agent })
|
||||
const customRequested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||
expect(await api.respond({
|
||||
type: 'client-response', rpcId: customRequested.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { sessionId, answer: { answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }] } },
|
||||
},
|
||||
})).toEqual({ accepted: true })
|
||||
await expect(customAnswer).resolves.toEqual({
|
||||
answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }],
|
||||
})
|
||||
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||
type: 'question/resolved', questionRpcId: customRequested.rpcId, outcome: 'answered',
|
||||
})
|
||||
|
||||
const blankAnswer = ctx.userInteraction.ask({ questions, agent })
|
||||
const blankRequested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||
expect(await api.respond({
|
||||
type: 'client-response', rpcId: blankRequested.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { sessionId, answer: { answers: [{ id: 'mode', selected: [] }] } },
|
||||
},
|
||||
})).toEqual({ accepted: true })
|
||||
await expect(blankAnswer).resolves.toEqual({
|
||||
answers: [{ id: 'mode', selected: [] }],
|
||||
})
|
||||
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||
type: 'question/resolved', questionRpcId: blankRequested.rpcId, outcome: 'answered',
|
||||
})
|
||||
ac.abort()
|
||||
reconnectAbort.abort()
|
||||
})
|
||||
|
||||
it('distinguishes user cancellation from owner abort and rejects late responses', async () => {
|
||||
const running = await boot()
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const streamAbort = new AbortController()
|
||||
const stream = api.events.mux(request({}), streamAbort.signal)[Symbol.asyncIterator]()
|
||||
await stream.next()
|
||||
|
||||
const cancelled = ctx.userInteraction.ask({ questions, agent }).catch((error: unknown) => error)
|
||||
const requested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||
expect(await api.respond({
|
||||
type: 'client-response', rpcId: requested.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
|
||||
})).toEqual({ accepted: true })
|
||||
await expect(cancelled).resolves.toMatchObject({ code: 'ASK_CANCELLED' })
|
||||
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||
type: 'question/resolved', outcome: 'cancelled',
|
||||
})
|
||||
|
||||
const ownerAbort = new AbortController()
|
||||
const aborted = ctx.userInteraction.ask({ questions, agent, signal: ownerAbort.signal })
|
||||
.catch((error: unknown) => error)
|
||||
const abortRequest = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||
ownerAbort.abort()
|
||||
await expect(aborted).resolves.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||
type: 'question/resolved', questionRpcId: abortRequest.rpcId, outcome: 'cancelled',
|
||||
})
|
||||
expect(await api.respond({
|
||||
type: 'client-response', rpcId: abortRequest.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled', message: 'late', details: {} } },
|
||||
})).toEqual({ accepted: false, reason: 'not-pending' })
|
||||
streamAbort.abort()
|
||||
})
|
||||
|
||||
it('rejects missing routing and pre-abort, then aborts outstanding waits on disposal', async () => {
|
||||
const running = await boot()
|
||||
const { ctx } = running
|
||||
await expect(ctx.userInteraction.ask({ questions })).rejects.toMatchObject({ code: 'ASK_MISSING_AGENT' })
|
||||
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const alreadyAborted = new AbortController()
|
||||
alreadyAborted.abort()
|
||||
await expect(ctx.userInteraction.ask({ questions, agent, signal: alreadyAborted.signal }))
|
||||
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
const outstanding = ctx.userInteraction.ask({ questions, agent })
|
||||
const disposed = running.dispose()
|
||||
host = undefined
|
||||
await expect(outstanding).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await disposed
|
||||
})
|
||||
})
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* mountWebPlugins unit coverage (keyless). The Loader-facing behavior —
|
||||
* baseUrl anchoring, entry creation with idempotent reuse, the fiber-less
|
||||
* fail-loud sweep, and the resolver seam — is exercised against a stubbed
|
||||
* loader service so it runs without built lib/ artifacts. The roster is
|
||||
* caller-supplied now (composition moved to apps/cli), so these tests pass
|
||||
* their own lists.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mountWebPlugins } from '../src/web-plugins.ts'
|
||||
|
||||
const ROSTER = [
|
||||
'@deepseek-ai/dsh-plugin-a',
|
||||
'@deepseek-ai/dsh-plugin-b',
|
||||
'@deepseek-ai/dsh-plugin-c',
|
||||
] as const
|
||||
|
||||
interface FakeEntry {
|
||||
options: { name: string }
|
||||
fiber?: unknown
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/** Loader stub provided under the real service name (mountWebPlugins skips ctx.plugin(Loader) when present). */
|
||||
class FakeLoader {
|
||||
readonly created: string[] = []
|
||||
awaited = 0
|
||||
constructor(private readonly entriesList: FakeEntry[], private readonly onCreate?: (name: string) => void) {}
|
||||
entries(): Iterable<FakeEntry> {
|
||||
return this.entriesList
|
||||
}
|
||||
async create(options: { name: string }): Promise<void> {
|
||||
this.created.push(options.name)
|
||||
this.onCreate?.(options.name)
|
||||
}
|
||||
async await(): Promise<void> {
|
||||
this.awaited += 1
|
||||
}
|
||||
}
|
||||
|
||||
let root: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await root?.fiber.dispose()
|
||||
root = undefined
|
||||
})
|
||||
|
||||
function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void): { ctx: Context; loader: FakeLoader } {
|
||||
root = new Context()
|
||||
const loader = new FakeLoader(entriesList, onCreate)
|
||||
root.reflect.provide('loader', loader)
|
||||
return { ctx: root, loader }
|
||||
}
|
||||
|
||||
describe('mountWebPlugins (stubbed loader)', () => {
|
||||
it('creates one entry per roster package, awaits the tree, and returns the loader view + resolver', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx, loader } = withLoader(entriesList, (name) => {
|
||||
entriesList.push({ options: { name }, fiber: {}, disabled: false })
|
||||
})
|
||||
const mounted = await mountWebPlugins(ctx, ROSTER, import.meta.url)
|
||||
expect(loader.created).toEqual([...ROSTER])
|
||||
expect(loader.awaited).toBe(1)
|
||||
expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...ROSTER])
|
||||
// The resolver resolves a real package manifest through real module resolution, anchored at this test file.
|
||||
expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/)
|
||||
expect(ctx.baseUrl).toBeDefined()
|
||||
})
|
||||
|
||||
it('reuses existing entries (idempotent mount creates no duplicates)', async () => {
|
||||
const preexisting: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: {}, disabled: false }))
|
||||
const { ctx, loader } = withLoader(preexisting)
|
||||
await mountWebPlugins(ctx, ROSTER, import.meta.url)
|
||||
expect(loader.created).toEqual([])
|
||||
})
|
||||
|
||||
it('throws listing every fiber-less entry (silent import failure must not drop a client plugin)', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx } = withLoader(entriesList, (name) => {
|
||||
// First one loads; the rest stay fiber-less (import failed silently).
|
||||
entriesList.push({ options: { name }, fiber: entriesList.length < 1 ? {} : undefined, disabled: false })
|
||||
})
|
||||
await expect(mountWebPlugins(ctx, ROSTER, import.meta.url))
|
||||
.rejects.toThrow(/client plugin\(s\) failed to load: .*dsh-plugin-c/)
|
||||
})
|
||||
|
||||
it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => {
|
||||
const entriesList: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
|
||||
const { ctx } = withLoader(entriesList)
|
||||
await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
|
||||
root = new Context()
|
||||
// An empty roster keeps this keyless and artifact-free: the branch under
|
||||
// test is only the Loader auto-mount.
|
||||
await mountWebPlugins(root, [], import.meta.url)
|
||||
expect(root.get('loader') !== undefined).toBe(true)
|
||||
}, 30_000) // cold-cache import of the real vendored Loader crosses the network-disk 5s default
|
||||
|
||||
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx } = withLoader(entriesList, (name) => {
|
||||
entriesList.push({ options: { name }, fiber: {}, disabled: false })
|
||||
})
|
||||
ctx.baseUrl = 'file:///caller/anchor/'
|
||||
await mountWebPlugins(ctx, ROSTER, import.meta.url)
|
||||
expect(ctx.baseUrl).toBe('file:///caller/anchor/')
|
||||
})
|
||||
})
|
||||
@@ -1,132 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm-deepseek"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title-first-message-llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-loop"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-local"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/tool-bash"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact-basic"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs-local"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/tool-fs"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/tool-fs-search"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill-local"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/tool-skill"
|
||||
},
|
||||
{
|
||||
"path": "../../spill/spill-local"
|
||||
},
|
||||
{
|
||||
"path": "../../spill/spill-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent-fork"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent-spawn"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/tool-subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tool-tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../timeout/timeout-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
{
|
||||
"path": "../../workflow/tool-workflow"
|
||||
},
|
||||
{
|
||||
"path": "../../workflow/workflow-workerthread"
|
||||
},
|
||||
{
|
||||
"path": "../apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../context/workspace-context"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/host/webserver/README.i18n.yaml
Normal file
6
packages/host/webserver/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: c589c32c4e641e188f19ac6c5ad2e88e3eb79be3
|
||||
README.zh.md: 767195086b90a76160d87865caebf514ca75b0e3
|
||||
@@ -1,16 +1,18 @@
|
||||
# @deepseek-ai/dsh-host-webserver
|
||||
|
||||
Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
|
||||
|
||||
Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own.
|
||||
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and reported to `onError`; it never becomes a process-killing unhandled rejection.
|
||||
A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
|
||||
|
||||
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package is a pure HTTP carrier between the browser and the injected API handler; nothing here reaches a model request.
|
||||
None, as the package is a pure HTTP carrier between the browser and the routes other plugins register; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -18,6 +20,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No TLS, auth, or origin policy** — callers that bind a non-loopback address expose the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
|
||||
- **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
|
||||
- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
|
||||
- **Socket options are fixed** — callers select the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.
|
||||
- **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.
|
||||
|
||||
25
packages/host/webserver/README.zh.md
Normal file
25
packages/host/webserver/README.zh.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @deepseek-ai/dsh-host-webserver
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
朴素的 HTTP 路由注册插件(默认导出 `WebServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
|
||||
|
||||
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
|
||||
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。
|
||||
|
||||
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包只是浏览器与其他插件所注册路由之间的纯 HTTP 载体,其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **不提供 TLS、认证或来源策略**:绑定非回环地址会向对应网络公开服务器;面向部署的加固措施(或在前方放置真正的反向代理)有意不纳入面向开发环境的 v1。
|
||||
- **初始 MIME 表很精简**:Vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。
|
||||
- **Socket 选项固定不变**:配置只选择绑定宿主与端口;在具体部署产生需求前,backlog 和其他 socket 设置仍保持内部实现。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-webserver",
|
||||
"description": "Web-shape HTTP carrier: static file serving plus the /api/* bridge to an injected fetch-shaped handler (SSE streamed through)",
|
||||
"description": "Plain HTTP route-registration plugin: named-route registry (webServer service) + index transform taps + static dist fallback; knows no harness concepts",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -30,6 +30,9 @@
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
|
||||
@@ -1,232 +1,184 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-webserver — the web-shape HTTP carrier: node:http server
|
||||
* routing /api/* to an injected fetch-shaped handler (node:http ↔ WHATWG
|
||||
* bridge with SSE streamed out chunk by chunk) and everything else to static
|
||||
* file serving. Web (browser) shape only — Electron loads dist over file://
|
||||
* and carries fetch over an IPC bridge, not this server. This package never
|
||||
* prints: the URL line belongs to the shell.
|
||||
* @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a
|
||||
* node:http server plus the `httpServer` service (named-route registry + index
|
||||
* transform taps + static dist fallback). Knows no harness concepts — every
|
||||
* feature surface (API bridge, plugin bundles, SSE) is a route some other
|
||||
* plugin registers. Web (browser) shape only — Electron loads dist over
|
||||
* file:// and carries fetch over an IPC bridge, not this server. This package
|
||||
* never prints: the URL line belongs to the shell.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { dirname } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { serveStatic } from './static.ts'
|
||||
import { createPluginEventChannel } from './plugin-events.ts'
|
||||
import type { HostWebPluginRegistry, WebBootGraph } from './web-plugins.ts'
|
||||
|
||||
export { createHostWebPluginRegistry } from './web-plugins.ts'
|
||||
export type {
|
||||
HostWebPluginRegistry, LoaderEntryView, LoaderView, WebBootEntry, WebBootGraph, WebPluginRegistryDeps,
|
||||
} from './web-plugins.ts'
|
||||
export type { PluginEventChannel, PluginEventFrame } from './plugin-events.ts'
|
||||
|
||||
/** Options for startWebServer. */
|
||||
export interface WebServerOptions {
|
||||
/** Address or hostname to listen on. */
|
||||
host: string
|
||||
/** Port to listen on; zero requests an OS-assigned port. */
|
||||
port: number
|
||||
/**
|
||||
* Absolute path of index.html inside the static root — the caller resolves
|
||||
* it (dist location is workspace knowledge of the shell, not this package's).
|
||||
*/
|
||||
distIndex: string
|
||||
/** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */
|
||||
apiHandler: { fetch: typeof fetch }
|
||||
/**
|
||||
* Web plugin table. When present, every index.html response carries the
|
||||
* `window.__DSH_BOOT__` entry graph script, `/plugins/<id>/client.js` serves
|
||||
* each fetch entry's client bundle, and `GET /plugins/events` streams graph/
|
||||
* rebuilt frames (SSE) — rebuilt frames ride the registry's own bundle-watch
|
||||
* notifications (`onRebuilt`). Absent = all three surfaces off (carrier-only
|
||||
* use).
|
||||
*/
|
||||
webPlugins?: Pick<HostWebPluginRegistry, 'graph' | 'clientPath' | 'onRebuilt'>
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
httpServer: HttpServerService
|
||||
}
|
||||
}
|
||||
|
||||
/** Listening web server handle. */
|
||||
export interface RunningWebServer {
|
||||
/** The listening port, including the OS-assigned value when options.port is zero. */
|
||||
/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
|
||||
export type WebRouteKind = 'exact' | 'prefix'
|
||||
|
||||
/** One named route registration. */
|
||||
export interface WebRoute {
|
||||
kind: WebRouteKind
|
||||
/** Absolute pathname, no trailing slash. */
|
||||
path: string
|
||||
/** Owns the full response lifecycle (may hold the response open, e.g. SSE). */
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */
|
||||
export interface Config {
|
||||
/** Listen host; the two supported values are loopback and all-interfaces. */
|
||||
host: '127.0.0.1' | '0.0.0.0'
|
||||
/** Listen port; zero requests an OS-assigned port. */
|
||||
port: number
|
||||
/**
|
||||
* Shutdown: close + closeAllConnections (SSE connections never end on their
|
||||
* own; without the force-close, close() would hang). Idempotent.
|
||||
*/
|
||||
close(): Promise<void>
|
||||
/** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */
|
||||
distIndex: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the web-shape HTTP server on the caller-selected host and port.
|
||||
* Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else →
|
||||
* static with the step1-locked semantics (403 traversal, SPA fallback 200).
|
||||
* A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a
|
||||
* server error after listen goes to onError. A request whose handling throws
|
||||
* (malformed %-escapes, a client dropping mid-body) is answered 400 — or the
|
||||
* socket destroyed when headers are already out — and reported to onError;
|
||||
* it never becomes an unhandled rejection.
|
||||
* @param options - port, static root anchor, and the API carrier.
|
||||
* @param onError - sink for post-listen server errors and per-request handling failures.
|
||||
* @returns the running server handle once listening.
|
||||
* The web-shape HTTP carrier service. Activation listens immediately (route
|
||||
* registration order carries no request-facing semantics: named routes are
|
||||
* composed to be disjoint, and the static dist fallback answers anything not
|
||||
* yet claimed during the boot window). A listen failure throws out of init —
|
||||
* a FAILED fiber the boot's fail-loud sweep reports.
|
||||
*/
|
||||
export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise<RunningWebServer> {
|
||||
const { host, port, distIndex, apiHandler, webPlugins } = options
|
||||
const distRoot = dirname(distIndex)
|
||||
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
|
||||
const html = await readFile(distIndex, 'utf8')
|
||||
return injectBootManifest(html, webPlugins.graph())
|
||||
}
|
||||
const pluginEvents = webPlugins === undefined ? undefined : createPluginEventChannel()
|
||||
// Rebuilt frames come from the registry's own bundle watch (dev mode); a
|
||||
// prod registry without watching simply never notifies.
|
||||
const unsubscribeRebuilt = webPlugins !== undefined && pluginEvents !== undefined
|
||||
? webPlugins.onRebuilt((id, rev) => { pluginEvents.broadcast({ type: 'rebuilt', id, rev }) })
|
||||
: undefined
|
||||
export class HttpServerService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
|
||||
port: z.natural().max(65535).required(),
|
||||
distIndex: z.string().required(),
|
||||
})
|
||||
|
||||
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
|
||||
requests; the field is only optional on the client-side IncomingMessage type */
|
||||
const rawPath = new URL(req.url ?? '/', 'http://x').pathname
|
||||
if (rawPath.startsWith('/api/')) {
|
||||
await bridge(req, res, apiHandler)
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (webPlugins !== undefined && pluginEvents !== undefined && rawPath === '/plugins/events') {
|
||||
pluginEvents.connect(res, webPlugins.graph())
|
||||
return
|
||||
}
|
||||
if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) {
|
||||
await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins)
|
||||
return
|
||||
}
|
||||
await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex)
|
||||
private readonly exact = new Map<string, WebRoute>()
|
||||
private readonly prefixes = new Map<string, WebRoute>()
|
||||
private readonly indexTaps: ((html: string) => string)[] = []
|
||||
private readonly distRoot: string
|
||||
private readonly distIndex: string
|
||||
private server!: Server
|
||||
private listenedPort!: number
|
||||
|
||||
constructor(ctx: Context, private config: Config) {
|
||||
super(ctx, 'httpServer')
|
||||
this.distIndex = config.distIndex
|
||||
this.distRoot = dirname(config.distIndex)
|
||||
}
|
||||
// Last-resort guard: handle() rejecting would otherwise be an unhandled
|
||||
// rejection, and one malformed request (a bad %-escape hitting
|
||||
// decodeURIComponent, a client dropping mid-body) would kill the whole
|
||||
// process. Nothing after this catch can throw again on the same response.
|
||||
const server = createServer((req, res) => {
|
||||
handle(req, res).catch((err: unknown) => {
|
||||
onError(err instanceof Error ? err : new Error(String(err)))
|
||||
if (res.headersSent) {
|
||||
res.destroy()
|
||||
|
||||
/** The listening port (the OS-assigned value when config.port is 0). */
|
||||
get port(): number {
|
||||
return this.listenedPort
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a named route. Duplicate (kind, path) throws — route patterns are
|
||||
* a composition-level contract, so a collision is a misconfiguration.
|
||||
* @param route - kind, path, and the owning handler.
|
||||
* @returns the disposer removing the route.
|
||||
*/
|
||||
register(route: WebRoute): () => void {
|
||||
const table = route.kind === 'exact' ? this.exact : this.prefixes
|
||||
if (table.has(route.path)) {
|
||||
throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
|
||||
}
|
||||
table.set(route.path, route)
|
||||
return () => { table.delete(route.path) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an index.html transform, applied to every index response in
|
||||
* registration order.
|
||||
* @param transform - pure html-to-html function.
|
||||
* @returns the disposer removing the transform.
|
||||
*/
|
||||
tapIndex(transform: (html: string) => string): () => void {
|
||||
this.indexTaps.push(transform)
|
||||
return () => {
|
||||
const at = this.indexTaps.indexOf(transform)
|
||||
if (at !== -1) this.indexTaps.splice(at, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Listen; resolves once the socket is bound (rejection = FAILED fiber). */
|
||||
async [Service.init](): Promise<void> {
|
||||
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
|
||||
requests; the field is only optional on the client-side IncomingMessage type */
|
||||
const rawPath = new URL(req.url ?? '/', 'http://x').pathname
|
||||
const route = this.match(rawPath)
|
||||
if (route !== undefined) {
|
||||
await route.handler(req, res)
|
||||
return
|
||||
}
|
||||
res.writeHead(400)
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
const close = (): Promise<void> => (closing ??= new Promise((resolveClose) => {
|
||||
unsubscribeRebuilt?.()
|
||||
server.close(() => { resolveClose() })
|
||||
server.closeAllConnections()
|
||||
}))
|
||||
|
||||
return new Promise((resolveListen, rejectListen) => {
|
||||
server.once('error', rejectListen)
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', rejectListen)
|
||||
server.on('error', onError)
|
||||
resolveListen({ port: (server.address() as AddressInfo).port, close })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
|
||||
* first script in <head> (before the shell bundle reads it). `<` is escaped in
|
||||
* the JSON so plugin-controlled strings cannot break out of the script element.
|
||||
* @param html - the index.html source.
|
||||
* @param graph - the composed entry graph from the registry.
|
||||
* @returns the html with the graph script injected.
|
||||
*/
|
||||
export function injectBootManifest(html: string, graph: WebBootGraph): string {
|
||||
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
|
||||
const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
|
||||
const head = html.indexOf('<head>')
|
||||
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
|
||||
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
|
||||
return `${script}${html}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve one plugin client bundle from the registry table (unknown id = 404;
|
||||
* the id may contain a scope slash). The `?rev=` query is a cache-busting
|
||||
* parameter only — serving ignores it; `no-cache` makes the browser revalidate
|
||||
* so a stale rev never sticks.
|
||||
*/
|
||||
async function servePluginBundle(
|
||||
pathname: string, res: ServerResponse, webPlugins: Pick<HostWebPluginRegistry, 'clientPath'>,
|
||||
): Promise<void> {
|
||||
const id = pathname.slice('/plugins/'.length, -'/client.js'.length)
|
||||
const path = webPlugins.clientPath(id)
|
||||
if (path === undefined) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path)
|
||||
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */
|
||||
async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
|
||||
const abort = new AbortController()
|
||||
// Client-disconnect detection MUST hang off the response, not the request:
|
||||
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
||||
// fully consumed (immediately for a bodyless GET), which would abort every SSE
|
||||
// stream right after open. ServerResponse 'close' fires on connection teardown;
|
||||
// writableEnded distinguishes a normal end() from the client going away.
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) abort.abort()
|
||||
})
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
|
||||
requests; the fields are only optional on the client-side IncomingMessage type */
|
||||
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
|
||||
method: req.method ?? 'GET',
|
||||
headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
|
||||
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
|
||||
signal: abort.signal,
|
||||
})
|
||||
const response = await apiHandler.fetch(request)
|
||||
res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
|
||||
if (response.body === null) {
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
for await (const chunk of response.body) {
|
||||
// Backpressure: a false return means the socket buffer is full — wait for drain
|
||||
// instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
|
||||
// resolves so a mid-wait disconnect can't park this loop forever; the close
|
||||
// handler above aborts the handler stream, which then ends the iteration.
|
||||
if (!res.write(chunk)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const done = (): void => {
|
||||
res.off('drain', done)
|
||||
res.off('close', done)
|
||||
resolve()
|
||||
}
|
||||
res.once('drain', done)
|
||||
res.once('close', done)
|
||||
})
|
||||
// Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405,
|
||||
// traversal 403, miss falls back to index.html 200 (SPA routing).
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex())
|
||||
}
|
||||
// Last-resort guard: handle() rejecting would otherwise be an unhandled
|
||||
// rejection killing the process on one malformed request (bad %-escape,
|
||||
// client dropping mid-body). Per-request failures log and answer 400 —
|
||||
// never a process exit.
|
||||
this.server = createServer((req, res) => {
|
||||
handle(req, res).catch((err: unknown) => {
|
||||
this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))
|
||||
if (res.headersSent) {
|
||||
res.destroy()
|
||||
return
|
||||
}
|
||||
res.writeHead(400)
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.once('error', reject)
|
||||
this.server.listen(this.config.port, this.config.host, () => {
|
||||
this.server.off('error', reject)
|
||||
this.server.on('error', (err) => { this.ctx.logger.error(err) })
|
||||
this.listenedPort = (this.server.address() as AddressInfo).port
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
// close + closeAllConnections: held-open responses (SSE) never end on
|
||||
// their own; without the force-close, close() would hang teardown.
|
||||
this.ctx.effect(() => () => new Promise<void>((resolve) => {
|
||||
this.server.close(() => { resolve() })
|
||||
this.server.closeAllConnections()
|
||||
}), 'httpServer.listen')
|
||||
}
|
||||
|
||||
/** Longest-prefix-wins over the prefix table after an exact-table miss. */
|
||||
private match(pathname: string): WebRoute | undefined {
|
||||
const exact = this.exact.get(pathname)
|
||||
if (exact !== undefined) return exact
|
||||
let best: WebRoute | undefined
|
||||
for (const [prefix, route] of this.prefixes) {
|
||||
if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue
|
||||
if (best === undefined || prefix.length > best.path.length) best = route
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Index body: dist index.html through the registered taps in order. */
|
||||
private async renderIndex(): Promise<string> {
|
||||
let html = await readFile(this.distIndex, 'utf8')
|
||||
for (const transform of this.indexTaps) html = transform(html)
|
||||
return html
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
|
||||
export default HttpServerService
|
||||
|
||||
@@ -15,28 +15,30 @@ export const name = 'host-webserver-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Owned relation: the web plugin registry's boot entry graph must stay
|
||||
* self-consistent — every row must resolve a clientPath under the same id
|
||||
* (the /plugins/<id>/client.js URL it advertises would otherwise 404 on a
|
||||
* browser that just received the graph). Checked synchronously on every
|
||||
* rescan trigger (cordis 'internal/plugin'): graph() and clientPath() read
|
||||
* the same table object, so the relation is self-consistent at any instant —
|
||||
* no need to wait out the registry's own debounced rescan. The registry
|
||||
* arrives through the context key the assembly publishes it under.
|
||||
* Owned relation: route registrations and their disposers must stay
|
||||
* symmetric — after the owning fiber of a registered route unloads, the
|
||||
* route table must no longer answer for its path (a stale route would keep
|
||||
* serving a disposed plugin's handler). Checked on every fiber teardown
|
||||
* (cordis 'internal/plugin'): the service's own registry state is compared
|
||||
* against the set of live fibers' registrations indirectly, by probing that
|
||||
* dispose really removed the entry — the register() disposer contract.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/plugin', () => {
|
||||
const registry = ctx.get('webPlugins') as
|
||||
| {
|
||||
graph(): { entries: { id: string; url: string }[] }
|
||||
clientPath(id: string): string | undefined
|
||||
}
|
||||
const server = ctx.get('httpServer') as
|
||||
| { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void }
|
||||
| undefined
|
||||
if (registry === undefined) return // carrier-only deployments never publish the registry
|
||||
for (const row of registry.graph().entries) {
|
||||
if (registry.clientPath(row.id) === undefined) {
|
||||
fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`)
|
||||
}
|
||||
if (server === undefined) return // no webserver row in this composition
|
||||
// Register/dispose probe on a reserved path: if dispose leaves the route
|
||||
// behind, a second register throws the duplicate error — the asymmetry.
|
||||
// Each register(probe)() is one register+dispose cycle, so the probe never
|
||||
// leaves residue; a leftover from the first cycle makes the second throw.
|
||||
const probe = { kind: 'exact' as const, path: '/__dsh_invariant_probe__', handler: () => {} }
|
||||
try {
|
||||
server.register(probe)()
|
||||
server.register(probe)()
|
||||
} catch {
|
||||
fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged')
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* `/plugins/events` SSE channel: the system-side push surface for the client
|
||||
* entry graph (connect → current graph frame; dev rebuild → rebuilt frame).
|
||||
* Presentation-only wire — frames never enter the session log (distinct from
|
||||
* the /api/* session SSE, which is api-contract territory). Connections are
|
||||
* plain node:http responses held in a set; the server's closeAllConnections
|
||||
* tears them down on shutdown.
|
||||
*/
|
||||
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { WebBootGraph } from './web-plugins.ts'
|
||||
|
||||
/** One `/plugins/events` frame: the full graph on connect, or one rebuilt bundle notice. */
|
||||
export type PluginEventFrame =
|
||||
| { type: 'graph'; graph: WebBootGraph }
|
||||
| { type: 'rebuilt'; id: string; rev: string }
|
||||
|
||||
/** Broadcast surface owned by the webserver routing layer. */
|
||||
export interface PluginEventChannel {
|
||||
/** Adopt one incoming SSE request: writes the SSE preamble and the current-graph frame, then keeps the response open. */
|
||||
connect(res: ServerResponse, graph: WebBootGraph): void
|
||||
/** Push one frame to every open connection. */
|
||||
broadcast(frame: PluginEventFrame): void
|
||||
}
|
||||
|
||||
/** Serialize one frame as an SSE data line. */
|
||||
function sseData(frame: PluginEventFrame): string {
|
||||
return `data: ${JSON.stringify(frame)}\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the channel (one per running server).
|
||||
* @returns the connect/broadcast surface.
|
||||
*/
|
||||
export function createPluginEventChannel(): PluginEventChannel {
|
||||
const connections = new Set<ServerResponse>()
|
||||
return {
|
||||
connect(res, graph) {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
'connection': 'keep-alive',
|
||||
})
|
||||
// Comment line on open so clients/proxies see a live channel even when
|
||||
// no rebuild ever happens; EventSource frame parsing skips it naturally.
|
||||
res.write(': connected\n\n')
|
||||
res.write(sseData({ type: 'graph', graph }))
|
||||
connections.add(res)
|
||||
res.on('close', () => { connections.delete(res) })
|
||||
},
|
||||
broadcast(frame) {
|
||||
const line = sseData(frame)
|
||||
for (const res of connections) res.write(line)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
/**
|
||||
* HostWebPluginRegistry: composes the client entry graph served as
|
||||
* `window.__DSH_BOOT__` ({rev, entries}). Every row is discovered among the
|
||||
* host Loader's loaded entries by its package.json `dshClient` declaration
|
||||
* (all client plugin packages arrive by fetch — one uniform bundle shape),
|
||||
* resolving each one's client bundle path from `exports["./client"]` and
|
||||
* hashing the bundle content into a `rev` (cache busting + HMR diff anchor).
|
||||
* `inject` edges and the `immediately` prefetch mark come from the manifest
|
||||
* (dshClient — the package owns its dependency edges and its boot tier); the
|
||||
* composition layer contributes only the roster. The webserver consumes the
|
||||
* table to emit the boot graph and to serve `GET /plugins/<id>/client.js`;
|
||||
* in dev mode the registry additionally stat-polls each scanned bundle file
|
||||
* and re-hashes + notifies `onRebuilt` subscribers on change (the rebuild
|
||||
* signal is the registry's own observation — no builder protocol exists).
|
||||
*
|
||||
* The vendored loader emits no "entry loaded" event (only `loader/entry-init`,
|
||||
* which fires at Entry construction before import/apply), so the registry
|
||||
* scans `loader.entries()` and rescans on cordis `internal/plugin` (fiber
|
||||
* create/dispose), microtask-debounced. Plugin-set changes take effect on
|
||||
* restart per the config-source ruling; the subscription only keeps the table
|
||||
* fresh within a process lifetime.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync, unwatchFile, watchFile } from 'node:fs'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
/** One composed client entry (`window.__DSH_BOOT__.entries` row). */
|
||||
export interface WebBootEntry {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Bundle URL served by this webserver (`/plugins/<id>/client.js?rev=<rev>`). */
|
||||
url: string
|
||||
/** Bundle content hash (sha1, shortened). */
|
||||
rev: string
|
||||
/** Package-name dependency edges from the manifest (dshClient.inject), informational (preflight/HMR display). */
|
||||
inject?: string[]
|
||||
/** Boot phase-one prefetch tier: the shell fetches these bundles in parallel before creating entries. */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** The composed entry graph: injected into index.html and pushed on /plugins/events connect. */
|
||||
export interface WebBootGraph {
|
||||
/** Consistency anchor over all rows: changes whenever any entry row changes. */
|
||||
rev: string
|
||||
/** All composed entries (order carries no semantics; governance ordering is the client Loader's job). */
|
||||
entries: WebBootEntry[]
|
||||
}
|
||||
|
||||
/** The web plugin table consumed by the boot injection, the bundle endpoint, and the rebuild channel. */
|
||||
export interface HostWebPluginRegistry {
|
||||
/** Current composed entry graph (stable object between changes). */
|
||||
graph(): WebBootGraph
|
||||
/**
|
||||
* Absolute path of an entry's client bundle.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the path, or undefined for an unknown id.
|
||||
*/
|
||||
clientPath(id: string): string | undefined
|
||||
/**
|
||||
* Re-hash one entry's bundle: updates the row's rev/url and the graph rev.
|
||||
* The dev bundle watch calls this on every observed file change.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the new bundle rev, or undefined for an unknown id.
|
||||
*/
|
||||
rebuilt(id: string): string | undefined
|
||||
/**
|
||||
* Subscribe to bundle rebuilds observed by the dev watch (only fires when
|
||||
* the re-hash produced a different rev — an unchanged bundle is silent).
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
onRebuilt(listener: (id: string, rev: string) => void): () => void
|
||||
/** Remove the loader subscription, all bundle watches, and all rebuild listeners. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** Structural view of a loader entry (webserver keeps zero workspace dependencies; cordis stays a type-only peer). */
|
||||
export interface LoaderEntryView {
|
||||
options: { name: string }
|
||||
/** Present once the entry's plugin fiber exists (import succeeded and apply ran/started). */
|
||||
fiber?: unknown
|
||||
/** True when the entry or an owning group is disabled. */
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/** Structural view of the host Loader (entry enumeration is all the registry needs). */
|
||||
export interface LoaderView {
|
||||
entries(): Iterable<LoaderEntryView>
|
||||
}
|
||||
|
||||
/** Dependencies injected by the assembly layer. */
|
||||
export interface WebPluginRegistryDeps {
|
||||
/** Host root context; used only to subscribe `internal/plugin` for rescans. */
|
||||
ctx: Context
|
||||
/** The host Loader owning the plugin entries. */
|
||||
loader: LoaderView
|
||||
/**
|
||||
* Resolve a package specifier to its package.json absolute path (assembly
|
||||
* passes `createRequire(...).resolve(`${name}/package.json`)`); injected so
|
||||
* the registry makes no module-resolution assumptions of its own.
|
||||
*/
|
||||
resolvePkgJson: (name: string) => string
|
||||
/** Sink for rescan failures (the initial scan throws instead — misconfiguration fails loud at load). */
|
||||
onError: (err: Error) => void
|
||||
/**
|
||||
* Dev-mode bundle watching: stat-poll every scanned row's client bundle
|
||||
* (fs.watchFile — polling by design: network mounts deliver no inotify
|
||||
* events) and re-hash + notify onRebuilt subscribers on change. Absent =
|
||||
* no watching (prod composition).
|
||||
*/
|
||||
watch?: {
|
||||
/** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */
|
||||
intervalMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
|
||||
interface DshClientDeclaration {
|
||||
inject?: string[]
|
||||
platform: string
|
||||
/** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
interface WebPluginRecord {
|
||||
entry: WebBootEntry
|
||||
clientPath: string
|
||||
}
|
||||
|
||||
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
|
||||
function parseDshClient(name: string, value: unknown): DshClientDeclaration | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error(`web-plugins: ${name} has a non-object dshClient declaration`)
|
||||
}
|
||||
const decl = value as Record<string, unknown>
|
||||
if (typeof decl.platform !== 'string') {
|
||||
throw new Error(`web-plugins: ${name} dshClient.platform must be a string`)
|
||||
}
|
||||
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
|
||||
throw new Error(`web-plugins: ${name} dshClient.inject must be a string array`)
|
||||
}
|
||||
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
|
||||
throw new Error(`web-plugins: ${name} dshClient.immediately must be a boolean`)
|
||||
}
|
||||
return {
|
||||
platform: decl.platform,
|
||||
...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
|
||||
...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
|
||||
function clientExportOf(name: string, exportsField: unknown): string | undefined {
|
||||
if (typeof exportsField !== 'object' || exportsField === null) return undefined
|
||||
const client = (exportsField as Record<string, unknown>)['./client']
|
||||
if (client === undefined) return undefined
|
||||
if (typeof client === 'string') return client
|
||||
if (typeof client === 'object' && client !== null) {
|
||||
const fallback = (client as Record<string, unknown>).default
|
||||
if (typeof fallback === 'string') return fallback
|
||||
}
|
||||
throw new Error(`web-plugins: ${name} exports["./client"] has an unsupported shape`)
|
||||
}
|
||||
|
||||
/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
|
||||
function shortHash(input: string | Buffer): string {
|
||||
return createHash('sha1').update(input).digest('hex').slice(0, 12)
|
||||
}
|
||||
|
||||
/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
|
||||
function graphRow(id: string, rev: string, inject: string[] | undefined, immediately: boolean): WebBootEntry {
|
||||
return {
|
||||
id,
|
||||
url: `/plugins/${id}/client.js?rev=${rev}`,
|
||||
rev,
|
||||
...(inject !== undefined ? { inject } : {}),
|
||||
...(immediately ? { immediately: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Compose the graph value from the current table. */
|
||||
function composeGraph(table: Map<string, WebPluginRecord>): WebBootGraph {
|
||||
const entries = [...table.values()].map(record => record.entry)
|
||||
return { rev: shortHash(JSON.stringify(entries)), entries }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the web plugin registry: scan once synchronously (a malformed
|
||||
* declaration, an unbuilt bundle, or an invalid watch interval throws here —
|
||||
* load-time fail loud), then rescan on `internal/plugin`, microtask-debounced
|
||||
* (failures go to `deps.onError`). With `deps.watch`, every scanned bundle
|
||||
* file is stat-polled and a content change re-hashes the row and notifies
|
||||
* `onRebuilt` subscribers.
|
||||
* @param deps - loader view, resolution hook, error sink, and optional dev watch (see {@link WebPluginRegistryDeps}).
|
||||
* @returns the registry handle.
|
||||
*/
|
||||
export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWebPluginRegistry {
|
||||
const watchInterval = deps.watch === undefined ? undefined : deps.watch.intervalMs ?? 500
|
||||
if (watchInterval !== undefined && (!Number.isInteger(watchInterval) || watchInterval <= 0)) {
|
||||
throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`)
|
||||
}
|
||||
|
||||
let table = scan(deps)
|
||||
let graph = composeGraph(table)
|
||||
const rebuildListeners = new Set<(id: string, rev: string) => void>()
|
||||
|
||||
const rebuilt = (id: string): string | undefined => {
|
||||
const record = table.get(id)
|
||||
if (record === undefined) return undefined
|
||||
const rev = shortHash(readFileSync(record.clientPath))
|
||||
record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
|
||||
graph = composeGraph(table)
|
||||
return rev
|
||||
}
|
||||
|
||||
// Dev bundle watch: one fs.watchFile stat poll per table row. A torn read
|
||||
// of a half-written bundle self-heals — the ongoing write keeps changing
|
||||
// the stats, so the next poll tick re-hashes the completed file.
|
||||
const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>()
|
||||
const syncWatches = (): void => {
|
||||
if (watchInterval === undefined) return
|
||||
for (const [id, watch] of watched) {
|
||||
if (table.get(id)?.clientPath === watch.path) continue
|
||||
unwatchFile(watch.path, watch.listener)
|
||||
watched.delete(id)
|
||||
}
|
||||
for (const [id, record] of table) {
|
||||
if (watched.has(id)) continue
|
||||
const listener = (curr: Stats, prev: Stats): void => {
|
||||
// fs.watchFile fires on any stat delta (atime included); only content
|
||||
// signals count. An all-zero curr means the file vanished mid-rebuild
|
||||
// — the completing write fires the next tick, so skipping is safe.
|
||||
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return
|
||||
if (curr.mtimeMs === 0) return
|
||||
const before = table.get(id)?.entry.rev
|
||||
let rev: string | undefined
|
||||
try {
|
||||
rev = rebuilt(id)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick
|
||||
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
if (rev === undefined || rev === before) return
|
||||
for (const notify of rebuildListeners) {
|
||||
// A throwing subscriber must not escape the fs.watchFile callback
|
||||
// (that would skip later subscribers and can kill the process).
|
||||
try {
|
||||
notify(id, rev)
|
||||
} catch (error) {
|
||||
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
watchFile(record.clientPath, { interval: watchInterval, persistent: false }, listener)
|
||||
watched.set(id, { path: record.clientPath, listener })
|
||||
}
|
||||
}
|
||||
syncWatches()
|
||||
|
||||
let pending = false
|
||||
const unsubscribe = deps.ctx.on('internal/plugin', () => {
|
||||
if (pending) return
|
||||
pending = true
|
||||
queueMicrotask(() => {
|
||||
pending = false
|
||||
try {
|
||||
table = scan(deps)
|
||||
graph = composeGraph(table)
|
||||
syncWatches()
|
||||
} catch (error) {
|
||||
// Keep serving the previous graph: a mid-flight rescan failure must not
|
||||
// take down the boot manifest for plugins that were fine.
|
||||
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
graph: () => graph,
|
||||
clientPath: id => table.get(id)?.clientPath,
|
||||
rebuilt,
|
||||
onRebuilt: (listener) => {
|
||||
rebuildListeners.add(listener)
|
||||
return () => { rebuildListeners.delete(listener) }
|
||||
},
|
||||
dispose: () => {
|
||||
unsubscribe()
|
||||
for (const { path, listener } of watched.values()) unwatchFile(path, listener)
|
||||
watched.clear()
|
||||
rebuildListeners.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** One full table build from the loader's current entries (bundle content is hashed here — an unreadable bundle throws). */
|
||||
function scan(deps: WebPluginRegistryDeps): Map<string, WebPluginRecord> {
|
||||
const table = new Map<string, WebPluginRecord>()
|
||||
for (const entry of deps.loader.entries()) {
|
||||
if (entry.fiber === undefined || entry.disabled) continue
|
||||
const name = entry.options.name
|
||||
if (table.has(name)) continue
|
||||
const pkgPath = deps.resolvePkgJson(name)
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
|
||||
const decl = parseDshClient(name, pkg.dshClient)
|
||||
if (decl === undefined || decl.platform !== 'web') continue
|
||||
const clientRel = clientExportOf(name, pkg.exports)
|
||||
if (clientRel === undefined) {
|
||||
throw new Error(`web-plugins: ${name} declares dshClient but exports no "./client" bundle`)
|
||||
}
|
||||
const clientPath = join(dirname(pkgPath), clientRel)
|
||||
const rev = shortHash(readFileSync(clientPath))
|
||||
table.set(name, { entry: graphRow(name, rev, decl.inject, decl.immediately === true), clientPath })
|
||||
}
|
||||
return table
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Webserver invariant companion: the boot-graph consistency audit — every
|
||||
* fetch-arrival graph row must resolve a clientPath, checked on fiber
|
||||
* lifecycle events against the assembly-published 'webPlugins' context key.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as WebserverInvariant from '../src/invariant.ts'
|
||||
|
||||
interface RegistryStub {
|
||||
graph(): { entries: { id: string; url: string }[] }
|
||||
clientPath(id: string): string | undefined
|
||||
}
|
||||
|
||||
async function setup(registry?: RegistryStub): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(WebserverInvariant).await()
|
||||
if (registry !== undefined) ctx.reflect.provide('webPlugins', registry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Fire the audit trigger directly (same technique as the scope invariant
|
||||
* spec): a synchronous emit propagates the fail() throw to the caller. */
|
||||
function trigger(ctx: Context): void {
|
||||
;(ctx.emit as (event: string, ...args: unknown[]) => void)('internal/plugin', ctx.fiber)
|
||||
}
|
||||
|
||||
describe('webserver manifest invariant', () => {
|
||||
it('stays silent without a registry (carrier-only deployment) and with a consistent table', async () => {
|
||||
const bare = await setup()
|
||||
expect(() => { trigger(bare) }).not.toThrow() // no 'webPlugins' key published
|
||||
|
||||
const consistent = await setup({
|
||||
graph: () => ({ entries: [{ id: 'p1', url: '/plugins/p1/client.js?rev=abc' }] }),
|
||||
clientPath: id => id === 'p1' ? '/tmp/p1/lib/client.js' : undefined,
|
||||
})
|
||||
expect(() => { trigger(consistent) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws on a graph row whose bundle path no longer resolves', async () => {
|
||||
const ctx = await setup({
|
||||
graph: () => ({ entries: [{ id: 'ghost', url: '/plugins/ghost/client.js?rev=abc' }] }),
|
||||
clientPath: () => undefined,
|
||||
})
|
||||
expect(() => { trigger(ctx) })
|
||||
.toThrow(/graph row "ghost".*resolves no client bundle path/)
|
||||
})
|
||||
})
|
||||
@@ -1,265 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts'
|
||||
import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts'
|
||||
|
||||
/** Write a fake installed package (package.json + optional client bundle) and return its package.json path. */
|
||||
function makePkg(root: string, name: string, pkg: Record<string, unknown>, withBundle = true): string {
|
||||
const dir = join(root, name.replaceAll('/', '__'))
|
||||
mkdirSync(join(dir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...pkg }))
|
||||
if (withBundle) writeFileSync(join(dir, 'lib', 'client.js'), `// bundle of ${name}`)
|
||||
return join(dir, 'package.json')
|
||||
}
|
||||
|
||||
const webDecl = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
dshClient: { inject: [], platform: 'web', ...extra },
|
||||
exports: { '.': './lib/index.js', './client': './lib/client.js' },
|
||||
})
|
||||
|
||||
interface Fixture {
|
||||
deps: WebPluginRegistryDeps
|
||||
entries: LoaderEntryView[]
|
||||
errors: Error[]
|
||||
ctx: Context
|
||||
root: string
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
specs: { name: string; pkg: Record<string, unknown>; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[],
|
||||
): Fixture {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-'))
|
||||
const paths = new Map<string, string>()
|
||||
const entries: LoaderEntryView[] = specs.map((spec) => {
|
||||
paths.set(spec.name, makePkg(root, spec.name, spec.pkg, spec.withBundle ?? true))
|
||||
return { options: { name: spec.name }, fiber: spec.loaded === false ? undefined : {}, disabled: spec.disabled ?? false }
|
||||
})
|
||||
const ctx = new Context()
|
||||
const errors: Error[] = []
|
||||
const deps: WebPluginRegistryDeps = {
|
||||
ctx,
|
||||
loader: { entries: () => entries },
|
||||
resolvePkgJson: (name) => {
|
||||
const path = paths.get(name)
|
||||
if (path === undefined) throw new Error(`unresolvable ${name}`)
|
||||
return path
|
||||
},
|
||||
onError: err => void errors.push(err),
|
||||
}
|
||||
return { deps, entries, errors, ctx, root }
|
||||
}
|
||||
|
||||
describe('createHostWebPluginRegistry', () => {
|
||||
it('discovers dshClient rows with rev-stamped urls, manifest inject edges, and the declared immediately mark', () => {
|
||||
const { deps } = makeDeps([
|
||||
{ name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) },
|
||||
{ name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) },
|
||||
{ name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped
|
||||
])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const graph = registry.graph()
|
||||
expect(graph.rev).toMatch(/^[0-9a-f]{12}$/)
|
||||
const connection = graph.entries[0]
|
||||
expect(connection?.id).toBe('@deepseek-ai/dsh-client-connection')
|
||||
expect(connection?.rev).toMatch(/^[0-9a-f]{12}$/)
|
||||
expect(connection?.url).toBe(`/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=${connection?.rev ?? ''}`)
|
||||
expect(connection?.immediately).toBe(true)
|
||||
const layout = graph.entries[1]
|
||||
expect(layout?.id).toBe('@deepseek-ai/dsh-client-ui-layout')
|
||||
expect(layout?.inject).toEqual(['@deepseek-ai/dsh-client-runtime'])
|
||||
expect(layout?.immediately).toBeUndefined()
|
||||
expect(graph.entries).toHaveLength(2)
|
||||
expect(registry.clientPath('@deepseek-ai/dsh-client-ui-layout')).toMatch(/lib[/\\]client\.js$/)
|
||||
expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined()
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('skips entries that are unloaded, disabled, or declare another platform', () => {
|
||||
const { deps } = makeDeps([
|
||||
{ name: 'not-loaded', pkg: webDecl(), loaded: false },
|
||||
{ name: 'disabled', pkg: webDecl(), disabled: true },
|
||||
{ name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } },
|
||||
])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.graph().entries).toEqual([])
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('fails loud at build time on a dshClient declaration without a "./client" export', () => {
|
||||
const { deps } = makeDeps([
|
||||
{ name: 'broken', pkg: { dshClient: { platform: 'web' }, exports: { '.': './lib/index.js' } } },
|
||||
])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
|
||||
})
|
||||
|
||||
it('fails loud at build time on a registered bundle that is not built (rev hashing reads the file)', () => {
|
||||
const { deps } = makeDeps([{ name: 'unbuilt', pkg: webDecl(), withBundle: false }])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('fails loud on malformed declaration fields', () => {
|
||||
for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) {
|
||||
const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/dshClient/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rebuilt(id) re-hashes the bundle, updates the row and graph rev, and keeps the immediately mark', () => {
|
||||
const { deps, root } = makeDeps([{ name: 'hot', pkg: webDecl({ immediately: true }) }])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const before = registry.graph()
|
||||
const beforeRow = before.entries.find(e => e.id === 'hot')
|
||||
writeFileSync(join(root, 'hot', 'lib', 'client.js'), '// rebuilt bundle contents')
|
||||
const rev = registry.rebuilt('hot')
|
||||
expect(rev).toMatch(/^[0-9a-f]{12}$/)
|
||||
expect(rev).not.toBe(beforeRow?.rev)
|
||||
const after = registry.graph()
|
||||
const afterRow = after.entries.find(e => e.id === 'hot')
|
||||
expect(afterRow?.rev).toBe(rev)
|
||||
expect(afterRow?.url).toBe(`/plugins/hot/client.js?rev=${rev ?? ''}`)
|
||||
expect(afterRow?.immediately).toBe(true)
|
||||
expect(after.rev).not.toBe(before.rev)
|
||||
// Unknown ids are not rebuildable.
|
||||
expect(registry.rebuilt('nope')).toBeUndefined()
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('watch mode: a bundle content change re-hashes the row and notifies onRebuilt; dispose stops the watch', async () => {
|
||||
const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
|
||||
deps.watch = { intervalMs: 20 }
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const before = registry.graph().entries[0]?.rev
|
||||
const rebuilds: { id: string; rev: string }[] = []
|
||||
registry.onRebuilt((id, rev) => rebuilds.push({ id, rev }))
|
||||
|
||||
writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// new bundle contents')
|
||||
await vi.waitFor(() => { expect(rebuilds).toHaveLength(1) }, { timeout: 5000 })
|
||||
expect(rebuilds[0]?.id).toBe('watched')
|
||||
expect(rebuilds[0]?.rev).not.toBe(before)
|
||||
expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev)
|
||||
|
||||
registry.dispose()
|
||||
writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// post-dispose contents')
|
||||
await new Promise((resolve) => { setTimeout(resolve, 100) })
|
||||
expect(rebuilds).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects a non-positive or non-integer watch interval at build time', () => {
|
||||
for (const intervalMs of [0, -5, 1.5]) {
|
||||
const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }])
|
||||
deps.watch = { intervalMs }
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/watch\.intervalMs/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rescans on internal/plugin (debounced) and keeps the old graph when a rescan fails', async () => {
|
||||
const { deps, entries, errors, ctx } = makeDeps([
|
||||
{ name: 'late-loader', pkg: webDecl(), loaded: false },
|
||||
])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.graph().entries).toEqual([])
|
||||
|
||||
// Entry finishes loading; a fiber lifecycle event triggers the debounced rescan.
|
||||
;(entries[0] as { fiber?: unknown }).fiber = {}
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan
|
||||
await Promise.resolve()
|
||||
expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
|
||||
|
||||
// A failing rescan reports the error and keeps serving the previous graph.
|
||||
entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false })
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
await Promise.resolve()
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
|
||||
|
||||
// After dispose, further fiber events no longer rescan.
|
||||
registry.dispose()
|
||||
entries.pop()
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
await Promise.resolve()
|
||||
expect(errors).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('injectBootManifest', () => {
|
||||
it('injects the graph as the first script inside <head> and escapes </script> breakouts', () => {
|
||||
const html = '<html><head><script src="app.js"></script></head><body></body></html>'
|
||||
const out = injectBootManifest(html, {
|
||||
rev: 'r1',
|
||||
entries: [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js?rev=r2', rev: 'r2' }],
|
||||
})
|
||||
expect(out.indexOf('window.__DSH_BOOT__')).toBeLessThan(out.indexOf('app.js'))
|
||||
expect(out).not.toContain('</script><script>alert(1)')
|
||||
expect(out).toContain('\\u003c/script')
|
||||
})
|
||||
|
||||
it('prepends when the page has no <head>', () => {
|
||||
const out = injectBootManifest('<body>x</body>', { rev: 'r0', entries: [] })
|
||||
expect(out.startsWith('<script>window.__DSH_BOOT__')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clientExportOf shapes (through the registry build)', () => {
|
||||
it('accepts the conditional {types, default} export form', () => {
|
||||
const { deps } = makeDeps([{
|
||||
name: 'conditional',
|
||||
pkg: {
|
||||
dshClient: { platform: 'web' },
|
||||
exports: { './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' } },
|
||||
},
|
||||
}])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.clientPath('conditional')).toMatch(/lib[/\\]client\.js$/)
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('rejects a conditional form without a string default, an array form, and a non-object exports field', () => {
|
||||
for (const exportsField of [
|
||||
{ './client': { types: './x.d.ts' } },
|
||||
{ './client': ['./a.js'] },
|
||||
]) {
|
||||
const { deps } = makeDeps([{ name: 'bad-shape', pkg: { dshClient: { platform: 'web' }, exports: exportsField } }])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/unsupported shape/)
|
||||
}
|
||||
// Non-object exports: treated as "no ./client export" → the declares-but-no-bundle throw.
|
||||
const { deps } = makeDeps([{ name: 'no-exports', pkg: { dshClient: { platform: 'web' }, exports: './single.js' } }])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
|
||||
})
|
||||
|
||||
it('skips duplicate loader entries for the same package name (first wins)', () => {
|
||||
const { deps, entries } = makeDeps([{ name: 'dup-entry', pkg: webDecl() }])
|
||||
const first = entries[0] as LoaderEntryView
|
||||
entries.push({ options: { name: 'dup-entry' }, fiber: {}, disabled: false })
|
||||
void first
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.graph().entries.filter(r => r.id === 'dup-entry')).toHaveLength(1)
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('rejects a null conditional form and wraps a non-Error rescan throw', async () => {
|
||||
// client: null → the object-form branch's null guard.
|
||||
const nulled = makeDeps([{ name: 'null-client', pkg: { dshClient: { platform: 'web' }, exports: { './client': null } } }])
|
||||
expect(() => createHostWebPluginRegistry(nulled.deps)).toThrow(/unsupported shape/)
|
||||
|
||||
// Non-Error rescan throw: resolvePkgJson throws a string; onError must get a wrapped Error.
|
||||
const { deps, entries, errors, ctx } = makeDeps([{ name: 'ok-one', pkg: webDecl() }])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
entries.push({ options: { name: 'ghost-two' }, fiber: {}, disabled: false })
|
||||
const original = deps.resolvePkgJson
|
||||
deps.resolvePkgJson = (name) => {
|
||||
|
||||
if (name === 'ghost-two') throw 'string failure'
|
||||
return original(name)
|
||||
}
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
await Promise.resolve()
|
||||
expect(errors[0]).toBeInstanceOf(Error)
|
||||
expect(String(errors[0])).toContain('string failure')
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,400 +1,168 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { Server as NetServer } from 'node:net'
|
||||
/**
|
||||
* REAL-composition coverage: a test-only cordis.yml booted through the
|
||||
* vendored Loader mounts the webserver row, and every assertion observes the
|
||||
* user-visible HTTP surface of the running server (routing precedence, index
|
||||
* taps, static-fallback semantics, per-request error containment, teardown).
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startWebServer, type RunningWebServer } from '../src/index.ts'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context, FiberState } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import HttpServer from '../src/index.ts'
|
||||
|
||||
/** dist fixture: index.html + one asset of each MIME class + a subdir. */
|
||||
function makeDist(): { distIndex: string; distRoot: string } {
|
||||
const distRoot = mkdtempSync(join(tmpdir(), 'dsh-webserver-'))
|
||||
writeFileSync(join(distRoot, 'index.html'), '<html>INDEX</html>')
|
||||
writeFileSync(join(distRoot, 'app.js'), 'console.log(1)')
|
||||
writeFileSync(join(distRoot, 'app.css'), 'body{}')
|
||||
writeFileSync(join(distRoot, 'logo.svg'), '<svg/>')
|
||||
writeFileSync(join(distRoot, 'data.json'), '{}')
|
||||
writeFileSync(join(distRoot, 'app.js.map'), '{}')
|
||||
writeFileSync(join(distRoot, 'blob.bin'), 'BIN')
|
||||
mkdirSync(join(distRoot, 'sub'))
|
||||
writeFileSync(join(distRoot, 'sub', 'page.html'), '<html>SUB</html>')
|
||||
return { distIndex: join(distRoot, 'index.html'), distRoot }
|
||||
}
|
||||
|
||||
const echoingApi = {
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const req = input instanceof Request ? input : new Request(input, init)
|
||||
if (req.url.endsWith('/api/echo')) {
|
||||
return Response.json({ method: req.method, body: await req.text(), header: req.headers.get('x-probe') })
|
||||
}
|
||||
if (req.url.endsWith('/api/empty')) return new Response(null, { status: 204 })
|
||||
if (req.url.endsWith('/api/big')) {
|
||||
// Chunks far above any socket highWaterMark force res.write to return false.
|
||||
const big = new Uint8Array(4 * 1024 * 1024).fill(65)
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(big)
|
||||
controller.enqueue(big)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'application/octet-stream' } })
|
||||
}
|
||||
if (req.url.endsWith('/api/sse')) {
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: one\n\n'))
|
||||
controller.enqueue(encoder.encode('data: two\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
|
||||
}
|
||||
if (req.url.endsWith('/api/throw-string')) {
|
||||
// Non-Error rejection: the guard must wrap it for onError.
|
||||
throw 'string failure'
|
||||
}
|
||||
if (req.url.endsWith('/api/explode-mid-stream')) {
|
||||
// Headers go out with the first chunk, then the source errors: the
|
||||
// guard's headersSent leg must destroy the socket, not writeHead again.
|
||||
// The error is deferred a tick so the 200 + first chunk actually flush
|
||||
// to the client before the teardown.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('data: first\n\n'))
|
||||
setTimeout(() => { controller.error(new Error('stream exploded')) }, 20)
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
|
||||
}
|
||||
if (req.url.endsWith('/api/abort-probe')) {
|
||||
// Endless SSE that only ends when the request signal aborts.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
req.signal.addEventListener('abort', () => {
|
||||
try {
|
||||
controller.close()
|
||||
} catch { /* already closed by teardown: nothing else can reach this */ }
|
||||
}, { once: true })
|
||||
controller.enqueue(new TextEncoder().encode('data: open\n\n'))
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
|
||||
}
|
||||
return new Response('nope', { status: 404 })
|
||||
},
|
||||
}
|
||||
|
||||
let server: RunningWebServer | undefined
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close()
|
||||
server = undefined
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
|
||||
const { distIndex } = makeDist()
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, onError)
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
/** Write a dist fixture and a cordis.yml with one webserver row, then boot it through the real Loader. */
|
||||
async function loadComposition(port = 0): Promise<Context> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-'))
|
||||
const dist = join(root, 'dist')
|
||||
await mkdir(dist)
|
||||
const distIndex = join(dist, 'index.html')
|
||||
await writeFile(distIndex, '<head></head><body>shell</body>')
|
||||
await writeFile(join(dist, 'app.js'), 'export {}')
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-host-webserver'",
|
||||
' config:',
|
||||
" host: '127.0.0.1'",
|
||||
` port: ${String(port)}`,
|
||||
` distIndex: '${distIndex}'`,
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-host-webserver', HttpServer],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
return context
|
||||
}
|
||||
|
||||
describe('startWebServer', () => {
|
||||
it('reports the listening port and closes idempotently', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
expect(server.port).toBeGreaterThan(0)
|
||||
const first = server.close()
|
||||
const second = server.close()
|
||||
expect(second).toBe(first)
|
||||
await first
|
||||
server = undefined
|
||||
/** GET (by default) one path against the running server; returns status plus a body prefix. */
|
||||
async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; body: string }> {
|
||||
const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init)
|
||||
return { status: response.status, body: (await response.text()).slice(0, 80) }
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
// Real-Loader composition resolves workspace packages through tsx at test
|
||||
// time; first resolution after the host/client program split is slow enough
|
||||
// to trip the default 5s budget on cold caches.
|
||||
it('serves registered routes, index taps, and the static fallback semantics', { timeout: 60_000 }, async () => {
|
||||
const loaded = await loadComposition()
|
||||
const unloaded = [...loaded.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
|
||||
const server = loaded.httpServer
|
||||
expect(server).toBeInstanceOf(HttpServer)
|
||||
const port = server.port
|
||||
expect(port).toBeGreaterThan(0)
|
||||
|
||||
// Routing precedence: exact beats prefix, longest prefix wins, a prefix
|
||||
// route answers its own path, and routes own their method handling
|
||||
// (POST reaches a registered prefix; 405 is fallback-only semantics).
|
||||
server.register({ kind: 'exact', path: '/probe', handler: (_req, res) => { res.writeHead(200); res.end('EXACT') } })
|
||||
server.register({ kind: 'prefix', path: '/api', handler: (_req, res) => { res.writeHead(200); res.end('API') } })
|
||||
server.register({ kind: 'prefix', path: '/api/deep', handler: (_req, res) => { res.writeHead(200); res.end('DEEP') } })
|
||||
expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' })
|
||||
expect(await request(port, '/api/anything')).toMatchObject({ status: 200, body: 'API' })
|
||||
expect(await request(port, '/api/deep/leaf')).toMatchObject({ status: 200, body: 'DEEP' })
|
||||
expect(await request(port, '/api')).toMatchObject({ status: 200, body: 'API' })
|
||||
expect(await request(port, '/api/anything', { method: 'POST' })).toMatchObject({ status: 200, body: 'API' })
|
||||
|
||||
// Index taps apply in registration order on `/` and on the SPA fallback;
|
||||
// the disposer removes the transform.
|
||||
const untap = server.tapIndex(html => html.replace('<head>', '<head><script>window.__T__=1</script>'))
|
||||
expect((await request(port, '/')).body).toContain('__T__')
|
||||
expect((await request(port, '/no/such/route')).body).toContain('__T__')
|
||||
untap()
|
||||
expect((await request(port, '/')).body).not.toContain('__T__')
|
||||
|
||||
// Static fallback semantics: real asset served, traversal 403, non-GET/
|
||||
// HEAD without a matching route 405.
|
||||
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' })
|
||||
expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
|
||||
expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
|
||||
|
||||
// Per-request error containment: a malformed %-escape answers 400 and the
|
||||
// server keeps serving afterwards (no process-level failure path).
|
||||
expect((await request(port, '/%zz')).status).toBe(400)
|
||||
expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' })
|
||||
|
||||
// Duplicate (kind, path) is a misconfiguration and throws; the disposer
|
||||
// restores registrability (register/disposer symmetry).
|
||||
expect(() => server.register({ kind: 'exact', path: '/probe', handler: () => {} }))
|
||||
.toThrow(/duplicate exact route/)
|
||||
const disposeOnce = server.register({ kind: 'exact', path: '/once', handler: (_req, res) => { res.writeHead(200); res.end('ONCE') } })
|
||||
expect(await request(port, '/once')).toMatchObject({ status: 200, body: 'ONCE' })
|
||||
disposeOnce()
|
||||
expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback
|
||||
expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow()
|
||||
|
||||
// Teardown: fiber dispose closes the socket and severs held connections.
|
||||
await loaded.fiber.dispose()
|
||||
await expect(request(port, '/probe')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it.each(['127.0.0.1', '0.0.0.0'])('forwards bind address %s without opening a socket', async (host) => {
|
||||
const { distIndex } = makeDist()
|
||||
const port = 3080
|
||||
const listen = vi.spyOn(NetServer.prototype, 'listen').mockImplementation(function (
|
||||
this: NetServer, ...args: unknown[]
|
||||
): NetServer {
|
||||
const callback = args.at(-1)
|
||||
if (typeof callback !== 'function') throw new TypeError('listen callback missing')
|
||||
queueMicrotask(callback as () => void)
|
||||
return this
|
||||
})
|
||||
const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
|
||||
it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {
|
||||
const first = await loadComposition()
|
||||
const takenPort = first.httpServer.port
|
||||
const firstRoot = root
|
||||
root = undefined // keep the first composition's files until the end
|
||||
|
||||
// loader.await() never rejects (allSettled); the bind failure surfaces as
|
||||
// a FAILED fiber whose error escapes as a late rejection — the shape the
|
||||
// boot's installFailLoud is contracted to catch. Capture it here the same
|
||||
// way, and assert it really is the bind error.
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (err: unknown): void => { rejections.push(err) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
let second: Context | undefined
|
||||
try {
|
||||
const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
|
||||
await inertServer.close()
|
||||
} finally {
|
||||
address.mockRestore()
|
||||
listen.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects when the port is already taken', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
const { port } = server
|
||||
await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
|
||||
.rejects.toMatchObject({ code: 'EADDRINUSE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('static serving', () => {
|
||||
it('serves index at /, subpaths by MIME, octet-stream for unknown, SPA fallback on miss', async () => {
|
||||
const base = await boot()
|
||||
const index = await fetch(`${base}/`)
|
||||
expect(index.status).toBe(200)
|
||||
expect(index.headers.get('content-type')).toBe('text/html; charset=utf-8')
|
||||
expect(await index.text()).toBe('<html>INDEX</html>')
|
||||
|
||||
expect((await fetch(`${base}/app.js`)).headers.get('content-type')).toBe('text/javascript; charset=utf-8')
|
||||
expect((await fetch(`${base}/app.css`)).headers.get('content-type')).toBe('text/css; charset=utf-8')
|
||||
expect((await fetch(`${base}/logo.svg`)).headers.get('content-type')).toBe('image/svg+xml')
|
||||
expect((await fetch(`${base}/data.json`)).headers.get('content-type')).toBe('application/json')
|
||||
expect((await fetch(`${base}/app.js.map`)).headers.get('content-type')).toBe('application/json')
|
||||
expect((await fetch(`${base}/blob.bin`)).headers.get('content-type')).toBe('application/octet-stream')
|
||||
expect(await (await fetch(`${base}/sub/page.html`)).text()).toBe('<html>SUB</html>')
|
||||
|
||||
const miss = await fetch(`${base}/routes/deep/link`)
|
||||
expect(miss.status).toBe(200)
|
||||
expect(await miss.text()).toBe('<html>INDEX</html>')
|
||||
})
|
||||
|
||||
it('403s traversal outside the dist root and 405s non-GET/HEAD', async () => {
|
||||
const base = await boot()
|
||||
// %2e%2e would be dot-collapsed by WHATWG URL parsing on both ends; an
|
||||
// encoded slash keeps the segment intact until the server's decodeURIComponent.
|
||||
const traversal = await fetch(`${base}/..%2f..%2fetc%2fpasswd`)
|
||||
expect(traversal.status).toBe(403)
|
||||
const put = await fetch(`${base}/index.html`, { method: 'PUT', body: 'x' })
|
||||
expect(put.status).toBe(405)
|
||||
})
|
||||
|
||||
it('answers HEAD like GET (no 405)', async () => {
|
||||
const base = await boot()
|
||||
const head = await fetch(`${base}/`, { method: 'HEAD' })
|
||||
expect(head.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injection + bundle endpoint + events channel)', () => {
|
||||
const FETCH_ID = '@deepseek-ai/dsh-client-ui-layout'
|
||||
const graphValue = {
|
||||
rev: 'graphrev00001',
|
||||
entries: [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', url: '/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=eeee2222ffff', rev: 'eeee2222ffff', immediately: true },
|
||||
{ id: FETCH_ID, url: `/plugins/${FETCH_ID}/client.js?rev=aaaa0000bbbb`, rev: 'aaaa0000bbbb', inject: [] },
|
||||
],
|
||||
}
|
||||
|
||||
/** Captures the server's onRebuilt subscription so tests can fire registry notifications by hand. */
|
||||
interface RebuiltHarness {
|
||||
notify: (id: string, rev: string) => void
|
||||
unsubscribed: boolean
|
||||
}
|
||||
|
||||
async function bootWithPlugins(harness?: RebuiltHarness): Promise<string> {
|
||||
const { distIndex, distRoot } = makeDist()
|
||||
writeFileSync(join(distRoot, 'bundle.js'), 'window.DSHClientProxy.loadPlugin({})')
|
||||
const webPlugins = {
|
||||
graph: () => graphValue,
|
||||
clientPath: (id: string) => id === FETCH_ID ? join(distRoot, 'bundle.js') : undefined,
|
||||
onRebuilt: (listener: (id: string, rev: string) => void) => {
|
||||
if (harness !== undefined) harness.notify = listener
|
||||
return () => {
|
||||
if (harness !== undefined) harness.unsubscribed = true
|
||||
}
|
||||
},
|
||||
}
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
)
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
}
|
||||
|
||||
it('injects the window.__DSH_BOOT__ graph into / and SPA fallbacks; asset requests stay verbatim', async () => {
|
||||
const base = await bootWithPlugins()
|
||||
const index = await (await fetch(`${base}/`)).text()
|
||||
expect(index).toContain('window.__DSH_BOOT__')
|
||||
const manifest = /window\.__DSH_BOOT__ = (.*?)<\/script>/.exec(index)?.[1]
|
||||
expect(JSON.parse(manifest ?? '')).toEqual(graphValue)
|
||||
|
||||
const fallback = await (await fetch(`${base}/routes/deep/link`)).text()
|
||||
expect(fallback).toContain('window.__DSH_BOOT__')
|
||||
const direct = await (await fetch(`${base}/index.html`)).text()
|
||||
expect(direct).toContain('window.__DSH_BOOT__')
|
||||
|
||||
expect(await (await fetch(`${base}/app.js`)).text()).toBe('console.log(1)')
|
||||
})
|
||||
|
||||
it('serves registered client bundles with no-cache (rev query ignored) and 404s unknown ids (no SPA fallback)', async () => {
|
||||
const base = await bootWithPlugins()
|
||||
const bundle = await fetch(`${base}/plugins/${FETCH_ID}/client.js?rev=whatever`)
|
||||
expect(bundle.status).toBe(200)
|
||||
expect(bundle.headers.get('content-type')).toBe('text/javascript; charset=utf-8')
|
||||
expect(bundle.headers.get('cache-control')).toBe('no-cache')
|
||||
expect(await bundle.text()).toContain('DSHClientProxy')
|
||||
|
||||
expect((await fetch(`${base}/plugins/unknown/client.js`)).status).toBe(404)
|
||||
})
|
||||
|
||||
it('404s a registered id whose bundle file is unreadable (unbuilt dist must fail loud, not fall back to HTML)', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
const webPlugins = {
|
||||
graph: () => graphValue,
|
||||
clientPath: () => '/nonexistent/lib/client.js',
|
||||
onRebuilt: () => () => undefined,
|
||||
}
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
)
|
||||
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/${FETCH_ID}/client.js`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('keeps all plugin surfaces off without the webPlugins option', async () => {
|
||||
const base = await boot()
|
||||
expect(await (await fetch(`${base}/`)).text()).toBe('<html>INDEX</html>')
|
||||
// No plugin routes: fall through to static SPA fallback semantics.
|
||||
const res = await fetch(`${base}/plugins/x/client.js`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('<html>INDEX</html>')
|
||||
const events = await fetch(`${base}/plugins/events`)
|
||||
expect(await events.text()).toBe('<html>INDEX</html>')
|
||||
})
|
||||
|
||||
it('GET /plugins/events opens SSE with the current graph frame; a registry rebuild notification broadcasts', async () => {
|
||||
const harness: RebuiltHarness = { notify: () => { throw new Error('onRebuilt never subscribed') }, unsubscribed: false }
|
||||
const base = await bootWithPlugins(harness)
|
||||
const events = await fetch(`${base}/plugins/events`)
|
||||
expect(events.status).toBe(200)
|
||||
expect(events.headers.get('content-type')).toBe('text/event-stream')
|
||||
const reader = events.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
async function readUntil(marker: string): Promise<void> {
|
||||
while (!buffer.includes(marker)) {
|
||||
const chunk = await reader?.read()
|
||||
if (chunk?.done !== false) throw new Error('SSE stream ended early')
|
||||
buffer += decoder.decode(chunk.value, { stream: true })
|
||||
second = await loadComposition(takenPort)
|
||||
const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
|
||||
expect(entry?.fiber?.state).toBe(FiberState.FAILED)
|
||||
// The rejection escapes a tick after loader.await() settles; bounded poll.
|
||||
for (let i = 0; i < 100 && rejections.length === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(rejections.map(String).join('\n')).toContain('EADDRINUSE')
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
await second?.fiber.dispose()
|
||||
context = first
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = firstRoot
|
||||
}
|
||||
await readUntil('"type":"graph"')
|
||||
expect(buffer).toContain(': connected')
|
||||
const graphLine = /data: (.*)\n\n/.exec(buffer)?.[1]
|
||||
expect(JSON.parse(graphLine ?? '')).toEqual({ type: 'graph', graph: graphValue })
|
||||
|
||||
// The registry's bundle watch observed a rebuild: the server relays it as an SSE frame.
|
||||
harness.notify(FETCH_ID, 'cccc1111dddd')
|
||||
await readUntil('"type":"rebuilt"')
|
||||
expect(buffer).toContain(JSON.stringify({ type: 'rebuilt', id: FETCH_ID, rev: 'cccc1111dddd' }))
|
||||
await reader?.cancel()
|
||||
|
||||
// Shutdown unsubscribes the relay (no broadcast into a closed channel).
|
||||
await server?.close()
|
||||
server = undefined
|
||||
expect(harness.unsubscribed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('request-handling guard (one bad request must not kill the process)', () => {
|
||||
it('400s malformed %-escapes, reports to onError, and stays alive', async () => {
|
||||
const errors: Error[] = []
|
||||
const base = await boot(err => errors.push(err))
|
||||
for (const path of ['/%', '/%c0', '/%zz%']) {
|
||||
expect((await fetch(`${base}${path}`)).status).toBe(400)
|
||||
}
|
||||
expect(errors.length).toBe(3)
|
||||
expect(errors[0]?.name).toBe('URIError')
|
||||
// The barrage left the server serving.
|
||||
expect((await fetch(`${base}/`)).status).toBe(200)
|
||||
})
|
||||
|
||||
it('wraps a non-Error throw for onError and still answers 400', async () => {
|
||||
const errors: Error[] = []
|
||||
const base = await boot(err => errors.push(err))
|
||||
expect((await fetch(`${base}/api/throw-string`, { method: 'POST' })).status).toBe(400)
|
||||
expect(errors[0]).toBeInstanceOf(Error)
|
||||
expect(errors[0]?.message).toBe('string failure')
|
||||
})
|
||||
|
||||
it('destroys the socket when the failure lands after headers went out', async () => {
|
||||
const errors: Error[] = []
|
||||
const base = await boot(err => errors.push(err))
|
||||
const response = await fetch(`${base}/api/explode-mid-stream`)
|
||||
expect(response.status).toBe(200) // headers made it out before the explosion
|
||||
await expect(response.text()).rejects.toThrow() // then the socket is torn down
|
||||
expect(errors.length).toBe(1)
|
||||
expect((await fetch(`${base}/`)).status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('/api bridge', () => {
|
||||
it('forwards method, headers, and body; relays status and body back', async () => {
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/echo`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-probe': 'p1' },
|
||||
body: JSON.stringify({ n: 1 }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' })
|
||||
})
|
||||
|
||||
it('relays a bodyless response', async () => {
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/empty`, { method: 'POST' })
|
||||
expect(response.status).toBe(204)
|
||||
expect(await response.text()).toBe('')
|
||||
})
|
||||
|
||||
it('streams SSE frames through chunk by chunk', async () => {
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/sse`)
|
||||
expect(response.headers.get('content-type')).toBe('text/event-stream')
|
||||
expect(await response.text()).toBe('data: one\n\ndata: two\n\n')
|
||||
})
|
||||
|
||||
it('waits for drain when a streamed chunk overfills the socket buffer', async () => {
|
||||
// 4 MiB chunks dwarf the socket highWaterMark, so res.write returns false
|
||||
// and the bridge parks on 'drain'; reading the body to completion proves
|
||||
// the loop resumed instead of dropping the remainder.
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/big`)
|
||||
const body = new Uint8Array(await response.arrayBuffer())
|
||||
expect(body.length).toBe(8 * 1024 * 1024)
|
||||
expect(body[0]).toBe(65)
|
||||
expect(body[body.length - 1]).toBe(65)
|
||||
})
|
||||
|
||||
it('releases a drain wait when the client disconnects mid-chunk', async () => {
|
||||
// The 'close' leg of the drain race: abort while the socket buffer is
|
||||
// still full so the parked write wakes via 'close', not 'drain'.
|
||||
const base = await boot()
|
||||
const ac = new AbortController()
|
||||
const response = await fetch(`${base}/api/big`, { signal: ac.signal })
|
||||
const reader = response.body?.getReader()
|
||||
const first = await reader?.read()
|
||||
expect(first?.value?.length).toBeGreaterThan(0)
|
||||
ac.abort()
|
||||
// afterEach close() completing is the leak assertion, same as abort-probe.
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50) })
|
||||
})
|
||||
|
||||
it('aborts the bridged request when the client disconnects mid-SSE', async () => {
|
||||
const base = await boot()
|
||||
const ac = new AbortController()
|
||||
const response = await fetch(`${base}/api/abort-probe`, { signal: ac.signal })
|
||||
const reader = response.body?.getReader()
|
||||
expect(reader).toBeDefined()
|
||||
const first = await reader?.read()
|
||||
expect(new TextDecoder().decode(first?.value)).toContain('open')
|
||||
ac.abort()
|
||||
// server-side abort propagation has no client-observable handshake beyond
|
||||
// the closed connection; close() would hang on a leaked live SSE socket,
|
||||
// so afterEach completing IS the assertion that the bridge released it.
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50) })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user