/** * 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, AgentContextDocument, AppExitWorkGuardState, DomainEvent, EffortSelection, ExecuteSlashCommandResult, HealthReport, ReplyChunk, ResumableAgent, SlashCommand, Sprint, TerminalSession, Ticket, TicketAttachmentContent, TicketBulkResult, TicketCarnet, TicketChat, TicketLinkKind, TicketList, Unsubscribe, } from "@/domain"; import type { AgentChatHandle, AgentGateway, ChatDraftScope, ChatDraftSnapshot, ConversationDetails, CreateAgentInput, CreateTicketInput, LiveAgent, OpenTerminalOptions, ReattachAgentChatResult, ReattachResult, RestartCellAgentRuntimeInput, RestartCellAgentRuntimeScheduled, SendAgentChatOptions, StoppedLiveAgent, SystemGateway, TerminalGateway, TerminalHandle, TicketGateway, TicketListQuery, UpdateTicketInput, } from "@/ports"; import type { HttpInvoker } from "./httpInvoker"; import { WsLiveClient } from "./wsLiveClient"; import { type ChatAttachedPayload, 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); }, }; } function chatSessionId(payload: ChatAttachedPayload): string { const sessionId = payload.sessionId ?? payload.session?.sessionId; if (!sessionId) { throw { code: "INVALID_CHAT_ACK", message: "chat acknowledgement omitted sessionId" }; } return 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"); } pickArchiveFile(): Promise { // Desktop-only, same rationale as `pickFolder`. return unsupportedOnWeb("Native file picker"); } pickFile(): Promise { // Desktop-only, same rationale as `pickFolder`. return unsupportedOnWeb("Native file 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, reason?: string): Promise { return this.http.invoke("stop_live_agent", { request: { projectId, agentId, ...(reason ? { reason } : {}) }, }); } restartCellAgentRuntime( input: RestartCellAgentRuntimeInput, ): Promise { return this.http.invoke( "restart_cell_agent_runtime", { request: input }, ); } 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 }, }); } updateAgentEffort( projectId: string, agentId: string, effort: EffortSelection | null, ): Promise { return this.http.invoke("update_agent_effort", { request: { projectId, agentId, effort }, }); } updateAgentContactable( projectId: string, agentId: string, contactable: boolean, ): Promise { return this.http.invoke("update_agent_contactable", { request: { projectId, agentId, contactable }, }); } updateProjectOrchestrator( projectId: string, agentId: string, ): Promise<{ orchestratorAgentId: string }> { return this.http.invoke<{ orchestratorAgentId: string }>( "update_project_orchestrator", { request: { projectId, agentId } }, ); } readContext(projectId: string, agentId: string): Promise { return this.http .invoke("read_agent_context", { projectId, agentId }) .then((res) => res.content); } 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 }, }); } readChatDraft(scope: ChatDraftScope): Promise { return this.http.invoke("read_chat_draft", { request: scope }); } async saveChatDraft(scope: ChatDraftScope, text: string): Promise { await this.http.invoke("save_chat_draft", { request: { ...scope, text } }); } async clearChatDraft(scope: ChatDraftScope): Promise { await this.http.invoke("clear_chat_draft", { request: scope }); } 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, freshStart: options.freshStart ?? false, 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, }; } async launchAgentChat( projectId: string, agentId: string, options: OpenTerminalOptions, ): Promise { const ack = await this.ws.send("agent.launch", { projectId, agentId, nodeId: options.nodeId ?? null, rows: options.rows, cols: options.cols, cellKind: "chat", conversationId: options.conversationId ?? null, freshStart: options.freshStart ?? false, }); if (ack.kind !== "chat.attached") { throw { code: "STRUCTURED_ROUTED_TO_NON_CHAT", message: `custom CLI launch returned ${ack.kind}; expected chat.attached`, }; } const payload = ack.payload as unknown as ChatAttachedPayload; if (payload.cellKind && payload.cellKind !== "chat") { throw { code: "STRUCTURED_ROUTED_TO_NON_CHAT", message: `custom CLI launch returned cellKind=${payload.cellKind}; expected chat`, }; } const sessionId = chatSessionId(payload); return { sessionId, cellKind: "chat", ...(payload.assignedConversationId ? { assignedConversationId: payload.assignedConversationId } : {}), ...(payload.transcriptUnavailableReason ? { transcriptUnavailableReason: payload.transcriptUnavailableReason } : {}), }; } async reattachAgentChat( sessionId: string, onChunk: (chunk: ReplyChunk) => void, options: { projectId?: string; agentId?: string } = {}, ): Promise { const ack = await this.ws.send("chat.attach", { sessionId, ...options }); if (ack.kind !== "chat.attached") { throw { code: "CHAT_ATTACH_FAILED", message: `chat attach returned ${ack.kind}; expected chat.attached`, }; } const payload = ack.payload as unknown as ChatAttachedPayload; const attachedSessionId = chatSessionId(payload); this.ws.setChatSink(attachedSessionId, onChunk); return { sessionId: attachedSessionId, ...(payload.conversationId ?? payload.assignedConversationId ?? payload.engineSessionId ? { conversationId: payload.conversationId ?? payload.assignedConversationId ?? payload.engineSessionId, } : {}), scrollback: payload.scrollback ?? [], ...(payload.transcriptUnavailableReason ? { transcriptUnavailableReason: payload.transcriptUnavailableReason } : {}), }; } async detachAgentChat(sessionId: string): Promise { this.ws.removeChatSink(sessionId); await this.ws.sendFireAndForget("chat.detach", { sessionId }); } async sendAgentChat( sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void, options: SendAgentChatOptions = {}, ): Promise { this.ws.setChatSink(sessionId, onChunk); const ack = await this.ws.send("chat.send", { sessionId, prompt, attachments: options.attachments ?? [], }); const payload = ack.payload as unknown as ChatOutputPayload | undefined; if (payload?.chunk) onChunk(payload.chunk); } listSlashCommands(prefix?: string): Promise { const request = prefix === undefined ? {} : { prefix }; return this.http .invoke<{ commands: SlashCommand[] }>("list_slash_commands", { request }) .then((res) => res.commands); } executeSlashCommand( name: string, options: { sessionId?: string | null; arguments?: unknown[] } = {}, ): Promise { return this.http.invoke("execute_slash_command", { request: { name, sessionId: options.sessionId ?? null, arguments: options.arguments ?? [], }, }); } async closeAgentChat(sessionId: string): Promise { this.ws.removeChatSink(sessionId); await this.http.invoke("close_agent_session", { sessionId }); } async cancelAgentChat(sessionId: string): Promise { await this.http.invoke("cancel_agent_chat", { sessionId }); } } export class HttpTicketGateway implements TicketGateway { constructor( private readonly http: HttpInvoker, private readonly ws: WsLiveClient, ) {} private normalizeTicketActor(actor: unknown): Ticket["createdBy"] { if (!actor || typeof actor !== "object") return { kind: "user" }; const rec = actor as Record; if (rec.kind === "user" || rec.kind === "system") return { kind: rec.kind }; if (rec.kind === "agent" && typeof rec.agentId === "string" && rec.agentId) { return { kind: "agent", agentId: rec.agentId }; } return { kind: "user" }; } private normalizeTicket(ticket: Ticket): Ticket { return { ...ticket, links: Array.isArray(ticket.links) ? ticket.links : [], assignedAgentIds: Array.isArray(ticket.assignedAgentIds) ? ticket.assignedAgentIds : [], attachments: Array.isArray(ticket.attachments) ? ticket.attachments : [], createdBy: this.normalizeTicketActor(ticket.createdBy), updatedBy: this.normalizeTicketActor(ticket.updatedBy), }; } private normalizeTicketList(list: TicketList): TicketList { return { ...list, items: Array.isArray(list.items) ? list.items.map((item) => ({ ...item, assignedAgentIds: Array.isArray(item.assignedAgentIds) ? item.assignedAgentIds : [], createdBy: this.normalizeTicketActor(item.createdBy), })) : [], }; } create(projectId: string, input: CreateTicketInput): Promise { return this.http .invoke("ticket_create", { request: { projectId, ...input } }) .then((ticket) => this.normalizeTicket(ticket)); } read(projectId: string, ref: string, includeCarnet = false): Promise { return this.http .invoke("ticket_read", { request: { projectId, ref, includeCarnet } }) .then((ticket) => this.normalizeTicket(ticket)); } list(projectId: string, query?: TicketListQuery): Promise { const { statuses, priorities, ...rest } = query ?? {}; return this.http .invoke("ticket_list", { request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest }, }) .then((list) => this.normalizeTicketList(list)); } update(projectId: string, ref: string, input: UpdateTicketInput): Promise { return this.http .invoke("ticket_update", { request: { projectId, ref, ...input } }) .then((ticket) => this.normalizeTicket(ticket)); } async delete(projectId: string, ref: string): Promise { await this.http.invoke("ticket_delete", { request: { projectId, ref } }); } bulkUpdateStatus(projectId: string, refs: string[], status: Ticket["status"]): Promise { return this.http.invoke("ticket_bulk_update_status", { request: { projectId, refs, status }, }); } bulkUpdatePriority(projectId: string, refs: string[], priority: Ticket["priority"]): Promise { return this.http.invoke("ticket_bulk_update_priority", { request: { projectId, refs, priority }, }); } bulkDelete(projectId: string, refs: string[]): Promise { return this.http.invoke("ticket_bulk_delete", { request: { projectId, refs }, }); } 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 }, }) .then((ticket) => this.normalizeTicket(ticket)); } addAttachment( projectId: string, ref: string, path: string, expectedVersion: number, mime?: string | null, ): Promise { return this.http .invoke("ticket_attachment_add", { request: { projectId, ref, path, expectedVersion, mime }, }) .then((ticket) => this.normalizeTicket(ticket)); } readAttachment( projectId: string, ref: string, attachmentId: string, ): Promise { return this.http.invoke("ticket_attachment_read", { request: { projectId, ref, attachmentId }, }); } markAttachmentSummarized( projectId: string, ref: string, attachmentId: string, expectedVersion: number, ): Promise { return this.http .invoke("ticket_attachment_mark_summarized", { request: { projectId, ref, attachmentId, expectedVersion }, }) .then((ticket) => this.normalizeTicket(ticket)); } link( projectId: string, ref: string, targetRef: string, kind: TicketLinkKind, expectedVersion: number, ): Promise { return this.http .invoke("ticket_link", { request: { projectId, ref, targetRef, kind, expectedVersion }, }) .then((ticket) => this.normalizeTicket(ticket)); } unlink( projectId: string, ref: string, targetRef: string, expectedVersion: number, kind?: TicketLinkKind, ): Promise { return this.http .invoke("ticket_unlink", { request: { projectId, ref, targetRef, expectedVersion, kind }, }) .then((ticket) => this.normalizeTicket(ticket)); } assign( projectId: string, ref: string, agentId: string, assigned: boolean, expectedVersion: number, ): Promise { return this.http .invoke("ticket_assign", { request: { projectId, ref, agentId, assigned, expectedVersion }, }) .then((ticket) => this.normalizeTicket(ticket)); } 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 }, }) .then((ticket) => this.normalizeTicket(ticket)); } return this.http .invoke("ticket_assign_sprint", { request: { projectId, ref, sprintId, expectedVersion }, }) .then((ticket) => this.normalizeTicket(ticket)); } 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); } }