/** * Stream + mixed gateways for the web transport — ticket #13, lot F1. * * These gateways combine HTTP request/response (via {@link HttpInvoker}) with the * WebSocket live streams (via {@link WsLiveClient}), per the B0 contract: * - {@link HttpSystemGateway}: `health` over HTTP; `onDomainEvent` over the WS * `event.domain` stream; `pickFolder` is desktop-only ⇒ unsupported on web. * - {@link HttpAgentGateway}: all agent request/response over HTTP; `launchAgent` * / `reattach` over the WS PTY stream (**skeleton** — full round-trip in * F3/B5/B6). * - {@link HttpTicketGateway}: all `ticket_*`/`sprint_*` over HTTP; `sendTicketChat` * over the WS `chat.*` stream (**skeleton**). * - {@link HttpTerminalGateway}: PTY open/reattach/close over the WS terminal * stream (**skeleton**). * * "Skeleton" means the frames are wired to the B0 contract and the handles are * structurally correct against the ports, but a live end-to-end terminal needs * the server (B3/B4) and the F3/B5 wiring. Lives in `src/adapters/**`; no * `@tauri-apps/api`. */ import type { Agent, AppExitWorkGuardState, DomainEvent, HealthReport, ReplyChunk, ResumableAgent, Sprint, TerminalSession, Ticket, TicketCarnet, TicketChat, TicketLinkKind, TicketList, Unsubscribe, } from "@/domain"; import type { AgentGateway, ConversationDetails, CreateAgentInput, CreateTicketInput, LiveAgent, OpenTerminalOptions, ReattachResult, StoppedLiveAgent, SystemGateway, TerminalGateway, TerminalHandle, TicketGateway, TicketListQuery, UpdateTicketInput, } from "@/ports"; import type { HttpInvoker } from "./httpInvoker"; import { WsLiveClient } from "./wsLiveClient"; import { type ChatOutputPayload } from "./frames"; import { unsupportedOnWeb } from "./unsupported"; /** * Builds a {@link TerminalHandle} whose control operations are WS frames. The * output stream is delivered through the sink the gateway registered on the * {@link WsLiveClient} for this `sessionId`. */ export function makeWsTerminalHandle( sessionId: string, ws: WsLiveClient, assignedConversationId?: string, ): TerminalHandle { return { sessionId, ...(assignedConversationId ? { assignedConversationId } : {}), async write(data: Uint8Array): Promise { await ws.sendFireAndForget("terminal.input", { sessionId, bytesBase64: WsLiveClient.encodeInput(data), }); }, async resize(rows: number, cols: number): Promise { await ws.sendFireAndForget("terminal.resize", { sessionId, rows, cols }); }, detach(): void { // Stop delivering output locally and tell the server the view is gone. The // backend PTY keeps running (detach ≠ close), matching the Tauri handle. // Untracking also disables reconnection re-attach for this session. void ws.detachTerminal(sessionId); }, async close(): Promise { await ws.closeTerminalSession(sessionId); }, }; } export class HttpSystemGateway implements SystemGateway { constructor( private readonly http: HttpInvoker, private readonly ws: WsLiveClient, ) {} health(note?: string): Promise { return this.http.invoke("health", { request: note === undefined ? null : { note }, }); } async onDomainEvent(handler: (event: DomainEvent) => void): Promise { // The low-frequency domain-event stream rides the same WS connection // (`event.domain`), replacing Tauri `listen("domain://event")`. await this.ws.ensureConnected(); this.ws.setDomainEventHandler(handler); return () => this.ws.clearDomainEventHandler(); } pickFolder(): Promise { // Desktop-only: the native OS dialog has no web equivalent. A server-side // folder browser would be its own lot (flagged in the F1 report). return unsupportedOnWeb("Native folder picker"); } onAppExitWorkGuard( _handler: (state: AppExitWorkGuardState) => void, ): Promise { // Desktop-only (ticket #83): there is no interceptable native window to // guard on the web client, so this never fires — an inert unsubscribe, // not a rejection, so callers can subscribe unconditionally. return Promise.resolve(() => {}); } confirmAppExit(): Promise { return unsupportedOnWeb("Confirming an app exit"); } } export class HttpTerminalGateway implements TerminalGateway { constructor(private readonly ws: WsLiveClient) {} async openTerminal( options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ): Promise { // B5: `terminal.open` carries no projectId; `cwd` is a server-validated path. // A fresh open has empty scrollback (the view does not repaint on open). const res = await this.ws.openTerminal({ cwd: options.cwd, rows: options.rows, cols: options.cols, nodeId: options.nodeId ?? null, onData, }); return makeWsTerminalHandle(res.sessionId, this.ws); } async reattach( sessionId: string, onData: (bytes: Uint8Array) => void, ): Promise { // The view repaints the returned scrollback itself (TerminalView contract); // the client does not also push it through `onData` on the initial attach. const res = await this.ws.attachTerminal({ sessionId, onData }); return { handle: makeWsTerminalHandle(res.sessionId, this.ws), scrollback: res.scrollback, }; } async closeTerminal(sessionId: string): Promise { await this.ws.closeTerminalSession(sessionId); } } export class HttpAgentGateway implements AgentGateway { constructor( private readonly http: HttpInvoker, private readonly ws: WsLiveClient, ) {} listAgents(projectId: string): Promise { return this.http.invoke("list_agents", { projectId }); } listLiveAgents(projectId: string): Promise { return this.http.invoke("list_live_agents", { projectId }); } listResumableAgents(projectId: string): Promise { return this.http .invoke<{ resumable: ResumableAgent[] }>("list_resumable_agents", { projectId }) .then((res) => res.resumable); } attachLiveAgent(projectId: string, agentId: string, nodeId: string): Promise { return this.http.invoke("attach_live_agent", { request: { projectId, agentId, nodeId } }); } stopLiveAgent(projectId: string, agentId: string): Promise { return this.http.invoke("stop_live_agent", { request: { projectId, agentId } }); } createAgent(projectId: string, input: CreateAgentInput): Promise { return this.http.invoke("create_agent", { request: { projectId, name: input.name, profileId: input.profileId, initialContent: input.initialContent ?? null, }, }); } changeAgentProfile( projectId: string, agentId: string, profileId: string, rows: number, cols: number, ): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }> { return this.http.invoke<{ agent: Agent; relaunchedSession?: TerminalSession }>("change_agent_profile", { request: { projectId, agentId, profileId, rows, cols }, }); } readContext(projectId: string, agentId: string): Promise { return this.http.invoke("read_agent_context", { projectId, agentId }); } async updateContext(projectId: string, agentId: string, content: string): Promise { await this.http.invoke("update_agent_context", { request: { projectId, agentId, content } }); } async deleteAgent(projectId: string, agentId: string): Promise { await this.http.invoke("delete_agent", { projectId, agentId }); } inspectConversation( projectId: string, agentId: string, conversationId: string, ): Promise { return this.http.invoke("inspect_conversation", { request: { projectId, agentId, conversationId }, }); } async launchAgent( projectId: string, agentId: string, options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ): Promise { // B6: a raw-CLI agent streams over the same PTY channel as a terminal. The // WS client tracks the session (reconnection/replay/status parity with F3); // the unified `terminal.attached` ack yields the sessionId + the conversation // id minted by this launch. Structured agents are refused server-side with // `UNSUPPORTED` — the rejection propagates to the caller (a clear cell error, // no crash). A fresh launch has empty scrollback (no repaint on open). const res = await this.ws.launchAgent({ projectId, agentId, nodeId: options.nodeId ?? null, rows: options.rows, cols: options.cols, conversationId: options.conversationId ?? null, onData, }); return makeWsTerminalHandle(res.sessionId, this.ws, res.assignedConversationId); } async reattach( sessionId: string, onData: (bytes: Uint8Array) => void, ): Promise { // Agent sessions re-attach through the same `terminal.attach` mechanics as a // terminal — no relaunch, bounded scrollback replayed for the view to repaint. const res = await this.ws.attachTerminal({ sessionId, onData }); return { handle: makeWsTerminalHandle(res.sessionId, this.ws), scrollback: res.scrollback, }; } } export class HttpTicketGateway implements TicketGateway { constructor( private readonly http: HttpInvoker, private readonly ws: WsLiveClient, ) {} create(projectId: string, input: CreateTicketInput): Promise { return this.http.invoke("ticket_create", { request: { projectId, ...input } }); } read(projectId: string, ref: string, includeCarnet = false): Promise { return this.http.invoke("ticket_read", { request: { projectId, ref, includeCarnet } }); } list(projectId: string, query?: TicketListQuery): Promise { const { statuses, priorities, ...rest } = query ?? {}; return this.http.invoke("ticket_list", { request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest }, }); } update(projectId: string, ref: string, input: UpdateTicketInput): Promise { return this.http.invoke("ticket_update", { request: { projectId, ref, ...input } }); } async delete(projectId: string, ref: string): Promise { await this.http.invoke("ticket_delete", { request: { projectId, ref } }); } readCarnet(projectId: string, ref: string): Promise { return this.http.invoke("ticket_read_carnet", { request: { projectId, ref } }); } updateCarnet(projectId: string, ref: string, carnet: string, expectedVersion: number): Promise { return this.http.invoke("ticket_update_carnet", { request: { projectId, ref, carnet, expectedVersion }, }); } link( projectId: string, ref: string, targetRef: string, kind: TicketLinkKind, expectedVersion: number, ): Promise { return this.http.invoke("ticket_link", { request: { projectId, ref, targetRef, kind, expectedVersion }, }); } unlink( projectId: string, ref: string, targetRef: string, expectedVersion: number, kind?: TicketLinkKind, ): Promise { return this.http.invoke("ticket_unlink", { request: { projectId, ref, targetRef, expectedVersion, kind }, }); } assign( projectId: string, ref: string, agentId: string, assigned: boolean, expectedVersion: number, ): Promise { return this.http.invoke("ticket_assign", { request: { projectId, ref, agentId, assigned, expectedVersion }, }); } async listSprints(projectId: string): Promise { const list = await this.http.invoke<{ items: Sprint[] }>("sprint_list", { request: { projectId } }); return list.items; } setTicketSprint( projectId: string, ref: string, sprintId: string | null, expectedVersion: number, ): Promise { if (sprintId === null) { return this.http.invoke("ticket_unassign_sprint", { request: { projectId, ref, expectedVersion } }); } return this.http.invoke("ticket_assign_sprint", { request: { projectId, ref, sprintId, expectedVersion }, }); } createSprint(projectId: string, name: string): Promise { return this.http.invoke("sprint_create", { request: { projectId, name } }); } renameSprint(projectId: string, sprintId: string, name: string, expectedVersion: number): Promise { return this.http.invoke("sprint_rename", { request: { projectId, sprintId, name, expectedVersion } }); } async reorderSprints(projectId: string, orderedIds: string[]): Promise { const list = await this.http.invoke<{ items: Sprint[] }>("sprint_reorder", { request: { projectId, orderedIds }, }); return list.items; } async deleteSprint(projectId: string, sprintId: string): Promise { await this.http.invoke("sprint_delete", { request: { projectId, sprintId } }); } openTicketChat(projectId: string, issueRef: string, profileId: string): Promise { return this.http.invoke("open_ticket_chat", { request: { projectId, issueRef, profileId } }); } async closeTicketChat(projectId: string, issueRef: string): Promise { await this.http.invoke("close_ticket_chat", { request: { projectId, issueRef } }); } async sendTicketChat( sessionId: string, message: string, onChunk: (chunk: ReplyChunk) => void, ): Promise { // Structured assistant reply stream over WS (`chat.*`, B0). Skeleton: route // this session's `chat.output` chunks to `onChunk` until the `final` chunk. // TODO(F3/B5): the WsLiveClient currently routes PTY output + domain events; // a per-session chat sink is added alongside the terminal sink in F3. const ack = await this.ws.send("chat.send", { sessionId, prompt: message }); const payload = ack.payload as unknown as ChatOutputPayload | undefined; if (payload && payload.chunk) onChunk(payload.chunk as ReplyChunk); } }