Files
deepseek-harness/packages/host/apiproxy/src/api/rpc.ts
T
imccyu 19336686a6 feat(apiproxy): session.rename RPC delegating to the session-title service
New unary method in RpcMethodMap with the title-invalid error code; the impl
resolves the agent (cold sessions resume first) and delegates to
ctx.sessionTitle.rename, returning the normalized title plus its event seq so
clients settle the title projection cell ahead of the push frame. session.fork
stays on the reserved-seam list.
2026-07-29 18:59:23 +08:00

145 lines
5.7 KiB
TypeScript

/**
* Four-quadrant RPC message model. Channels and messages are
* decoupled: HTTP is the client→server physical channel, SSE the server→client one; logical
* messages are channel-independent, and the wire full form is a four-member discriminated union.
* api/ contract layer: zero Node dependencies, importable from the browser.
*/
import type { z as zCore } from 'zod'
type ZodIssue = zCore.core.$ZodIssue
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/**
* Message correlation id: the initiator mints it on a request; a response
* echoes the matching request's rpcId and never mints a new one.
*/
export type RpcId = Branded<'rpc-id'>
/**
* Brands a string as RpcId (same precedent as core `SessionId()`). Minted by the initiator:
* client-request → client mints; server-request → host mints (answerable frames get a stable
* logical id, pure pushes mint a fresh one each time).
* @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures).
* @returns The same string, branded (compile-time cast, zero runtime cost).
*/
export function RpcId(id: string): RpcId {
return id as RpcId
}
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'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 }
'directory-unreadable': { path: string }
'directory-exists': { path: string }
'directory-create-failed': { path: string }
'directory-picker-unavailable': { capability: string }
'agent-busy': { reason: string }
/** A known slash command reported a usage/state error; the message is the command's own text. */
'command-error': {}
/** A leading-/ prompt named no registered command; the message names the token. */
'unknown-command': {}
'title-invalid': { sessionId: SessionId }
'internal': {}
}
/** Closed error-code union (the keys of RpcErrorDetailsMap). */
export type RpcErrorCode = keyof RpcErrorDetailsMap
/**
* Distributive union expanded from the map: code is the discriminant, so
* `switch (error.code)` narrows details. details is required (internal uses an explicit {}).
*/
export type RpcError = {
[C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] }
}[RpcErrorCode]
/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
/**
* Fold a transport exception into the RpcResult error branch (unified error
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
* carrier consumer folds the same way.
* @param error - the thrown value from the carrier.
* @returns the error branch of an RpcResult.
*/
export function transportError<T>(error: unknown): RpcResult<T> {
return {
ok: false,
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
}
}
/**
* Signature-layer narrow form, request side (domain-interface view, shared by
* both directions): rpcId is explicit in the signature, never mixed into the
* business payload; the type tag and method are filled in by the carrier layer.
*/
export interface RpcRequest<P> {
rpcId: RpcId
payload: P
}
/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */
export interface RpcResponse<T> {
rpcId: RpcId
result: RpcResult<T>
}
// ---- Wire full forms: four named members of a discriminated union (discriminant = the four `type` literals) ----
/** Call initiated by the client (wire carrier: POST /api/<method> body). */
export interface ClientRequest {
type: 'client-request'
rpcId: RpcId
method: string
payload: unknown
}
/** Response to a ClientRequest (wire carrier: the HTTP response body of that POST); rpcId echoed. */
export interface ServerResponse {
type: 'server-response'
rpcId: RpcId
result: RpcResult<unknown>
}
/**
* Message initiated by the server (wire carrier: SSE frame). Answerable interactions
* (approval/question requested — stable rpcId, reused on replay) and pure pushes
* (session/event etc. — rpcId identifies that one push) share this shape; whether a
* response is expected is determined statically by method (a strict dichotomy, no third kind).
*/
export interface ServerRequest {
type: 'server-request'
rpcId: RpcId
method: string
payload: unknown
}
/** Response to a ServerRequest (wire carrier: POST /api/respond body); rpcId echoed, never minted anew. */
export interface ClientResponse {
type: 'client-response'
rpcId: RpcId
result: RpcResult<unknown>
}
/** Authoritative wire full-form union; narrow via `switch (message.type)`. */
export type RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse
/**
* Carrier receipt (not an RpcMessage — it belongs to the carrier layer, same
* discipline as "HTTP status describes only the carrier"): the HTTP response
* body of the POST carrying a client-response. Late/duplicate responses yield not-pending.
*/
export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }