feat(frontend): adapter web HTTP+WebSocket derrière les ports (#13)
Lot F1 du chantier server/client mode : nouvel adaptateur web branché derrière les ports d'invocation et de flux live, permettant au frontend de dialoguer avec le backend via HTTP + WebSocket en mode client/serveur. Le mode desktop (Tauri IPC) reste inchangé. - frontend/src/adapters/http : invoker HTTP, client live WebSocket, gateways request/response et stream, frames, garde unsupported (7 fichiers + 2 tests). - frontend/src/app : câblage DI (di.tsx) et son test, typage vite-env.d.ts. Validé : build vert, garde no-direct-invoke verte, 724 tests verts, desktop inchangé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
392
frontend/src/adapters/http/streamGateways.ts
Normal file
392
frontend/src/adapters/http/streamGateways.ts
Normal file
@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 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,
|
||||
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 { base64ToBytes, type AttachedPayload, type ChatOutputPayload } from "./frames";
|
||||
import { unsupportedOnWeb } from "./unsupported";
|
||||
|
||||
/** Concatenates a scrollback frame list into a single byte buffer. */
|
||||
function attachedToScrollback(payload: AttachedPayload): Uint8Array {
|
||||
const chunks = payload.scrollback.map((c) => base64ToBytes(c.bytesBase64));
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
await ws.sendFireAndForget("terminal.input", {
|
||||
sessionId,
|
||||
bytesBase64: WsLiveClient.encodeInput(data),
|
||||
});
|
||||
},
|
||||
async resize(rows: number, cols: number): Promise<void> {
|
||||
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.
|
||||
ws.removeOutputSink(sessionId);
|
||||
void ws.sendFireAndForget("terminal.detach", { sessionId });
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
ws.removeOutputSink(sessionId);
|
||||
await ws.send("terminal.close", { sessionId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class HttpSystemGateway implements SystemGateway {
|
||||
constructor(
|
||||
private readonly http: HttpInvoker,
|
||||
private readonly ws: WsLiveClient,
|
||||
) {}
|
||||
|
||||
health(note?: string): Promise<HealthReport> {
|
||||
return this.http.invoke<HealthReport>("health", {
|
||||
request: note === undefined ? null : { note },
|
||||
});
|
||||
}
|
||||
|
||||
async onDomainEvent(handler: (event: DomainEvent) => void): Promise<Unsubscribe> {
|
||||
// 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<string | null> {
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpTerminalGateway implements TerminalGateway {
|
||||
constructor(private readonly ws: WsLiveClient) {}
|
||||
|
||||
async openTerminal(
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<TerminalHandle> {
|
||||
const ack = await this.ws.send("terminal.open", {
|
||||
projectId: undefined, // TODO(F3/B5): the port lacks projectId; confirm contract.
|
||||
nodeId: options.nodeId ?? null,
|
||||
cwd: options.cwd,
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
});
|
||||
const payload = ack.payload as unknown as AttachedPayload;
|
||||
const sessionId = payload.session.sessionId;
|
||||
this.ws.setOutputSink(sessionId, onData);
|
||||
const scrollback = attachedToScrollback(payload);
|
||||
if (scrollback.length > 0) onData(scrollback);
|
||||
return makeWsTerminalHandle(sessionId, this.ws);
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<ReattachResult> {
|
||||
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
|
||||
const payload = ack.payload as unknown as AttachedPayload;
|
||||
this.ws.setOutputSink(sessionId, onData);
|
||||
return {
|
||||
handle: makeWsTerminalHandle(sessionId, this.ws),
|
||||
scrollback: attachedToScrollback(payload),
|
||||
};
|
||||
}
|
||||
|
||||
async closeTerminal(sessionId: string): Promise<void> {
|
||||
this.ws.removeOutputSink(sessionId);
|
||||
await this.ws.send("terminal.close", { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpAgentGateway implements AgentGateway {
|
||||
constructor(
|
||||
private readonly http: HttpInvoker,
|
||||
private readonly ws: WsLiveClient,
|
||||
) {}
|
||||
|
||||
listAgents(projectId: string): Promise<Agent[]> {
|
||||
return this.http.invoke<Agent[]>("list_agents", { projectId });
|
||||
}
|
||||
listLiveAgents(projectId: string): Promise<LiveAgent[]> {
|
||||
return this.http.invoke<LiveAgent[]>("list_live_agents", { projectId });
|
||||
}
|
||||
listResumableAgents(projectId: string): Promise<ResumableAgent[]> {
|
||||
return this.http
|
||||
.invoke<{ resumable: ResumableAgent[] }>("list_resumable_agents", { projectId })
|
||||
.then((res) => res.resumable);
|
||||
}
|
||||
attachLiveAgent(projectId: string, agentId: string, nodeId: string): Promise<LiveAgent> {
|
||||
return this.http.invoke<LiveAgent>("attach_live_agent", { request: { projectId, agentId, nodeId } });
|
||||
}
|
||||
stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
|
||||
return this.http.invoke<StoppedLiveAgent>("stop_live_agent", { request: { projectId, agentId } });
|
||||
}
|
||||
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||
return this.http.invoke<Agent>("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<string> {
|
||||
return this.http.invoke<string>("read_agent_context", { projectId, agentId });
|
||||
}
|
||||
async updateContext(projectId: string, agentId: string, content: string): Promise<void> {
|
||||
await this.http.invoke("update_agent_context", { request: { projectId, agentId, content } });
|
||||
}
|
||||
async deleteAgent(projectId: string, agentId: string): Promise<void> {
|
||||
await this.http.invoke("delete_agent", { projectId, agentId });
|
||||
}
|
||||
inspectConversation(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
): Promise<ConversationDetails> {
|
||||
return this.http.invoke<ConversationDetails>("inspect_conversation", {
|
||||
request: { projectId, agentId, conversationId },
|
||||
});
|
||||
}
|
||||
|
||||
async launchAgent(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<TerminalHandle> {
|
||||
// Raw-CLI agents stream over the same WS PTY channel (B0). Structured
|
||||
// sessions do not use this channel — that branch is B6.
|
||||
const ack = await this.ws.send("agent.launch", {
|
||||
projectId,
|
||||
agentId,
|
||||
nodeId: options.nodeId ?? null,
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
conversationId: options.conversationId ?? null,
|
||||
});
|
||||
const payload = ack.payload as unknown as AttachedPayload;
|
||||
const sessionId = payload.session.sessionId;
|
||||
this.ws.setOutputSink(sessionId, onData);
|
||||
const scrollback = attachedToScrollback(payload);
|
||||
if (scrollback.length > 0) onData(scrollback);
|
||||
return makeWsTerminalHandle(sessionId, this.ws, payload.assignedConversationId);
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<ReattachResult> {
|
||||
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
|
||||
const payload = ack.payload as unknown as AttachedPayload;
|
||||
this.ws.setOutputSink(sessionId, onData);
|
||||
return {
|
||||
handle: makeWsTerminalHandle(sessionId, this.ws),
|
||||
scrollback: attachedToScrollback(payload),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpTicketGateway implements TicketGateway {
|
||||
constructor(
|
||||
private readonly http: HttpInvoker,
|
||||
private readonly ws: WsLiveClient,
|
||||
) {}
|
||||
|
||||
create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_create", { request: { projectId, ...input } });
|
||||
}
|
||||
read(projectId: string, ref: string, includeCarnet = false): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_read", { request: { projectId, ref, includeCarnet } });
|
||||
}
|
||||
list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
||||
const { statuses, priorities, ...rest } = query ?? {};
|
||||
return this.http.invoke<TicketList>("ticket_list", {
|
||||
request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest },
|
||||
});
|
||||
}
|
||||
update(projectId: string, ref: string, input: UpdateTicketInput): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_update", { request: { projectId, ref, ...input } });
|
||||
}
|
||||
async delete(projectId: string, ref: string): Promise<void> {
|
||||
await this.http.invoke<void>("ticket_delete", { request: { projectId, ref } });
|
||||
}
|
||||
readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
||||
return this.http.invoke<TicketCarnet>("ticket_read_carnet", { request: { projectId, ref } });
|
||||
}
|
||||
updateCarnet(projectId: string, ref: string, carnet: string, expectedVersion: number): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_update_carnet", {
|
||||
request: { projectId, ref, carnet, expectedVersion },
|
||||
});
|
||||
}
|
||||
link(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
targetRef: string,
|
||||
kind: TicketLinkKind,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_link", {
|
||||
request: { projectId, ref, targetRef, kind, expectedVersion },
|
||||
});
|
||||
}
|
||||
unlink(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
targetRef: string,
|
||||
expectedVersion: number,
|
||||
kind?: TicketLinkKind,
|
||||
): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_unlink", {
|
||||
request: { projectId, ref, targetRef, expectedVersion, kind },
|
||||
});
|
||||
}
|
||||
assign(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
agentId: string,
|
||||
assigned: boolean,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_assign", {
|
||||
request: { projectId, ref, agentId, assigned, expectedVersion },
|
||||
});
|
||||
}
|
||||
async listSprints(projectId: string): Promise<Sprint[]> {
|
||||
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<Ticket> {
|
||||
if (sprintId === null) {
|
||||
return this.http.invoke<Ticket>("ticket_unassign_sprint", { request: { projectId, ref, expectedVersion } });
|
||||
}
|
||||
return this.http.invoke<Ticket>("ticket_assign_sprint", {
|
||||
request: { projectId, ref, sprintId, expectedVersion },
|
||||
});
|
||||
}
|
||||
createSprint(projectId: string, name: string): Promise<Sprint> {
|
||||
return this.http.invoke<Sprint>("sprint_create", { request: { projectId, name } });
|
||||
}
|
||||
renameSprint(projectId: string, sprintId: string, name: string, expectedVersion: number): Promise<Sprint> {
|
||||
return this.http.invoke<Sprint>("sprint_rename", { request: { projectId, sprintId, name, expectedVersion } });
|
||||
}
|
||||
async reorderSprints(projectId: string, orderedIds: string[]): Promise<Sprint[]> {
|
||||
const list = await this.http.invoke<{ items: Sprint[] }>("sprint_reorder", {
|
||||
request: { projectId, orderedIds },
|
||||
});
|
||||
return list.items;
|
||||
}
|
||||
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
|
||||
await this.http.invoke<void>("sprint_delete", { request: { projectId, sprintId } });
|
||||
}
|
||||
openTicketChat(projectId: string, issueRef: string, profileId: string): Promise<TicketChat> {
|
||||
return this.http.invoke<TicketChat>("open_ticket_chat", { request: { projectId, issueRef, profileId } });
|
||||
}
|
||||
async closeTicketChat(projectId: string, issueRef: string): Promise<void> {
|
||||
await this.http.invoke<void>("close_ticket_chat", { request: { projectId, issueRef } });
|
||||
}
|
||||
|
||||
async sendTicketChat(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
): Promise<void> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user