Écoute l'event Tauri `app-exit-work-guard` (émis par le backend quand la fermeture de la fenêtre main est interceptée) et affiche une popup modale "Du travail est encore en cours" avec le résumé pluralisé exact du carnet #83, le détail agents/tâches capé à 5 lignes, et les deux actions Annuler (focus par défaut, no-op local) / Quitter quand même (danger, appelle confirm_app_exit). Pas d'option "ne plus demander". - domain/ports/adapters (Tauri listen+invoke, HTTP desktop-only stub, mock avec helpers de test) : onAppExitWorkGuard/confirmAppExit sur SystemGateway, suivant le patron déjà utilisé pour focused-project et les domain events. - AppExitConfirmDialog : mounted une fois près de la racine (App.tsx), à côté d'AnnouncementsProvider — role="alertdialog", focus trap, Échap = Annuler, pas de fermeture au clic extérieur, ne se referme jamais automatiquement (un event pendant l'ouverture rafraîchit juste le résumé). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
/**
|
|
* 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<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.
|
|
// Untracking also disables reconnection re-attach for this session.
|
|
void ws.detachTerminal(sessionId);
|
|
},
|
|
async close(): Promise<void> {
|
|
await ws.closeTerminalSession(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");
|
|
}
|
|
|
|
onAppExitWorkGuard(
|
|
_handler: (state: AppExitWorkGuardState) => void,
|
|
): Promise<Unsubscribe> {
|
|
// 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<void> {
|
|
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<TerminalHandle> {
|
|
// 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<ReattachResult> {
|
|
// 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<void> {
|
|
await this.ws.closeTerminalSession(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> {
|
|
// 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<ReattachResult> {
|
|
// 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<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);
|
|
}
|
|
}
|