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.
145 lines
5.7 KiB
TypeScript
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' }
|