/** * 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, TicketAttachmentContent, TicketBulkResult, 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"); } const LEGACY_TICKET_ACTOR: Ticket["createdBy"] = { kind: "user" }; function normalizeTicketActor(actor: unknown): Ticket["createdBy"] { if (!actor || typeof actor !== "object") return LEGACY_TICKET_ACTOR; 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 LEGACY_TICKET_ACTOR; } function 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: normalizeTicketActor(ticket.createdBy), updatedBy: normalizeTicketActor(ticket.updatedBy), }; } function normalizeTicketList(list: TicketList): TicketList { return { ...list, items: Array.isArray(list.items) ? list.items.map((item) => ({ ...item, assignedAgentIds: Array.isArray(item.assignedAgentIds) ? item.assignedAgentIds : [], createdBy: normalizeTicketActor(item.createdBy), })) : [], }; } export class TauriTicketGateway implements TicketGateway { async create(projectId: string, input: CreateTicketInput): Promise { return normalizeTicket(await invoke("ticket_create", { request: { projectId, ...input }, })); } async read( projectId: string, ref: string, includeCarnet = false, ): Promise { return normalizeTicket(await invoke("ticket_read", { request: { projectId, ref, includeCarnet }, })); } async list(projectId: string, query?: TicketListQuery): Promise { const { statuses, priorities, ...rest } = query ?? {}; // Multi-select facets (ticket #12): the backend expects `statuses`/ // `priorities` arrays (empty ⇒ no constraint on that facet). return normalizeTicketList(await invoke("ticket_list", { request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest, }, })); } async update( projectId: string, ref: string, input: UpdateTicketInput, ): Promise { return normalizeTicket(await invoke("ticket_update", { request: { projectId, ref, ...input }, })); } async delete(projectId: string, ref: string): Promise { // `ticket_delete` takes the shared `{ request }` envelope like every other // ticket command; a missing ref surfaces as a `NOT_FOUND` GatewayError from // the backend, propagated as-is. await invoke("ticket_delete", { request: { projectId, ref }, }); } async bulkUpdateStatus( projectId: string, refs: string[], status: Ticket["status"], ): Promise { return invoke("ticket_bulk_update_status", { request: { projectId, refs, status }, }); } async bulkUpdatePriority( projectId: string, refs: string[], priority: Ticket["priority"], ): Promise { return invoke("ticket_bulk_update_priority", { request: { projectId, refs, priority }, }); } async bulkDelete(projectId: string, refs: string[]): Promise { return invoke("ticket_bulk_delete", { request: { projectId, refs }, }); } 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 normalizeTicket(await invoke("ticket_update_carnet", { request: { projectId, ref, carnet, expectedVersion }, })); } async addAttachment( projectId: string, ref: string, path: string, expectedVersion: number, mime?: string | null, ): Promise { return normalizeTicket(await invoke("ticket_attachment_add", { request: { projectId, ref, path, expectedVersion, mime }, })); } async readAttachment( projectId: string, ref: string, attachmentId: string, ): Promise { return invoke("ticket_attachment_read", { request: { projectId, ref, attachmentId }, }); } async markAttachmentSummarized( projectId: string, ref: string, attachmentId: string, expectedVersion: number, ): Promise { return normalizeTicket(await invoke("ticket_attachment_mark_summarized", { request: { projectId, ref, attachmentId, expectedVersion }, })); } async link( projectId: string, ref: string, targetRef: string, kind: TicketLinkKind, expectedVersion: number, ): Promise { return normalizeTicket(await invoke("ticket_link", { request: { projectId, ref, targetRef, kind, expectedVersion }, })); } async unlink( projectId: string, ref: string, targetRef: string, expectedVersion: number, kind?: TicketLinkKind, ): Promise { return normalizeTicket(await invoke("ticket_unlink", { request: { projectId, ref, targetRef, expectedVersion, kind }, })); } async assign( projectId: string, ref: string, agentId: string, assigned: boolean, expectedVersion: number, ): Promise { return normalizeTicket(await 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 normalizeTicket(await invoke("ticket_unassign_sprint", { request: { projectId, ref, expectedVersion }, })); } return normalizeTicket(await 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, }); } }