/** * Tauri adapter for {@link TicketGateway}. * * The single frontend owner of the `ticket_*` command names and their request * envelopes (every command takes one camelCase `request` object). Features * consume the gateway port through DI and never call `invoke()` directly. * * Optimistic concurrency: the backend rejects a stale write with an `INVALID` * {@link GatewayError} whose message contains `"version conflict"`; use * {@link isTicketVersionConflict} to branch on it in the UI. */ import { Channel, invoke } from "@tauri-apps/api/core"; import type { GatewayError, ReplyChunk, Sprint, Ticket, TicketCarnet, TicketChat, TicketLinkKind, TicketList, } from "@/domain"; import type { CreateTicketInput, TicketGateway, TicketListQuery, UpdateTicketInput, } from "@/ports"; /** * Whether a gateway error is an optimistic-concurrency conflict (the ticket was * mutated since it was read). The backend returns a generic `INVALID` code, so * we key off the stable message fragment emitted by the domain store error. */ export function isTicketVersionConflict(error: unknown): boolean { if (!error || typeof error !== "object") return false; const message = (error as GatewayError).message; return typeof message === "string" && message.includes("version conflict"); } export class TauriTicketGateway implements TicketGateway { async create(projectId: string, input: CreateTicketInput): Promise { return invoke("ticket_create", { request: { projectId, ...input }, }); } async read( projectId: string, ref: string, includeCarnet = false, ): Promise { return invoke("ticket_read", { request: { projectId, ref, includeCarnet }, }); } async list(projectId: string, query?: TicketListQuery): Promise { return invoke("ticket_list", { request: { projectId, ...(query ?? {}) }, }); } async update( projectId: string, ref: string, input: UpdateTicketInput, ): Promise { return invoke("ticket_update", { request: { projectId, ref, ...input }, }); } async readCarnet(projectId: string, ref: string): Promise { return invoke("ticket_read_carnet", { request: { projectId, ref }, }); } async updateCarnet( projectId: string, ref: string, carnet: string, expectedVersion: number, ): Promise { return invoke("ticket_update_carnet", { request: { projectId, ref, carnet, expectedVersion }, }); } async link( projectId: string, ref: string, targetRef: string, kind: TicketLinkKind, expectedVersion: number, ): Promise { return invoke("ticket_link", { request: { projectId, ref, targetRef, kind, expectedVersion }, }); } async unlink( projectId: string, ref: string, targetRef: string, expectedVersion: number, kind?: TicketLinkKind, ): Promise { return invoke("ticket_unlink", { request: { projectId, ref, targetRef, expectedVersion, kind }, }); } async assign( projectId: string, ref: string, agentId: string, assigned: boolean, expectedVersion: number, ): Promise { return invoke("ticket_assign", { request: { projectId, ref, agentId, assigned, expectedVersion }, }); } async listSprints(projectId: string): Promise { // `sprint_list` returns a `SprintListDto { items }`; unwrap to the array. const list = await invoke<{ items: Sprint[] }>("sprint_list", { request: { projectId }, }); return list.items; } async setTicketSprint( projectId: string, ref: string, sprintId: string | null, expectedVersion: number, ): Promise { // Two backend commands: assign to a sprint, or unassign (clear membership). if (sprintId === null) { return invoke("ticket_unassign_sprint", { request: { projectId, ref, expectedVersion }, }); } return invoke("ticket_assign_sprint", { request: { projectId, ref, sprintId, expectedVersion }, }); } async createSprint(projectId: string, name: string): Promise { return invoke("sprint_create", { request: { projectId, name }, }); } async renameSprint( projectId: string, sprintId: string, name: string, expectedVersion: number, ): Promise { return invoke("sprint_rename", { request: { projectId, sprintId, name, expectedVersion }, }); } async reorderSprints( projectId: string, orderedIds: string[], ): Promise { // `sprint_reorder` returns a `SprintListDto { items }`; unwrap to the array. const list = await invoke<{ items: Sprint[] }>("sprint_reorder", { request: { projectId, orderedIds }, }); return list.items; } async deleteSprint(projectId: string, sprintId: string): Promise { await invoke("sprint_delete", { request: { projectId, sprintId }, }); } async openTicketChat( projectId: string, issueRef: string, profileId: string, ): Promise { return invoke("open_ticket_chat", { request: { projectId, issueRef, profileId }, }); } async closeTicketChat(projectId: string, issueRef: string): Promise { await invoke("close_ticket_chat", { request: { projectId, issueRef }, }); } async sendTicketChat( sessionId: string, message: string, onChunk: (chunk: ReplyChunk) => void, ): Promise { // Per-turn reply channel, tagged on `kind` (`textDelta`|`toolActivity`| // `final`). Reuses the shared `agent_send` chat transport (the same command // and ChatBridge the structured chat cell uses). Wire the handler before the // invoke so no early chunk is missed. const channel = new Channel(); channel.onmessage = (chunk) => onChunk(chunk); await invoke("agent_send", { sessionId, prompt: message, onReply: channel, }); } }