fix(tickets): normalise assignedAgentIds/links/attachments manquants sur les payloads legacy

Les lignes de ticket persistées avant l'introduction de ces champs peuvent
arriver sans assignedAgentIds (ni links/attachments), ce que TicketsPanel
déréférence sans garde côté rendu : la fenêtre de liste rend quelques lignes
puis devient noire dès qu'une ligne legacy est itérée. Les deux gateways
(Tauri et HTTP/stream) normalisent désormais chaque ticket et chaque item de
liste avant de les retourner à l'UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 13:51:13 +02:00
parent 25e1231a9e
commit 4280c70514
3 changed files with 171 additions and 52 deletions

View File

@ -286,20 +286,53 @@ export class HttpTicketGateway implements TicketGateway {
private readonly ws: WsLiveClient,
) {}
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 : [],
};
}
private normalizeTicketList(list: TicketList): TicketList {
return {
...list,
items: Array.isArray(list.items)
? list.items.map((item) => ({
...item,
assignedAgentIds: Array.isArray(item.assignedAgentIds)
? item.assignedAgentIds
: [],
}))
: [],
};
}
create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_create", { request: { projectId, ...input } });
return this.http
.invoke<Ticket>("ticket_create", { request: { projectId, ...input } })
.then((ticket) => this.normalizeTicket(ticket));
}
read(projectId: string, ref: string, includeCarnet = false): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_read", { request: { projectId, ref, includeCarnet } });
return this.http
.invoke<Ticket>("ticket_read", { request: { projectId, ref, includeCarnet } })
.then((ticket) => this.normalizeTicket(ticket));
}
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 },
});
return this.http
.invoke<TicketList>("ticket_list", {
request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest },
})
.then((list) => this.normalizeTicketList(list));
}
update(projectId: string, ref: string, input: UpdateTicketInput): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_update", { request: { projectId, ref, ...input } });
return this.http
.invoke<Ticket>("ticket_update", { request: { projectId, ref, ...input } })
.then((ticket) => this.normalizeTicket(ticket));
}
async delete(projectId: string, ref: string): Promise<void> {
await this.http.invoke<void>("ticket_delete", { request: { projectId, ref } });
@ -323,9 +356,11 @@ export class HttpTicketGateway implements TicketGateway {
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 },
});
return this.http
.invoke<Ticket>("ticket_update_carnet", {
request: { projectId, ref, carnet, expectedVersion },
})
.then((ticket) => this.normalizeTicket(ticket));
}
addAttachment(
projectId: string,
@ -334,9 +369,11 @@ export class HttpTicketGateway implements TicketGateway {
expectedVersion: number,
mime?: string | null,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_attachment_add", {
request: { projectId, ref, path, expectedVersion, mime },
});
return this.http
.invoke<Ticket>("ticket_attachment_add", {
request: { projectId, ref, path, expectedVersion, mime },
})
.then((ticket) => this.normalizeTicket(ticket));
}
readAttachment(
projectId: string,
@ -353,9 +390,11 @@ export class HttpTicketGateway implements TicketGateway {
attachmentId: string,
expectedVersion: number,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_attachment_mark_summarized", {
request: { projectId, ref, attachmentId, expectedVersion },
});
return this.http
.invoke<Ticket>("ticket_attachment_mark_summarized", {
request: { projectId, ref, attachmentId, expectedVersion },
})
.then((ticket) => this.normalizeTicket(ticket));
}
link(
projectId: string,
@ -364,9 +403,11 @@ export class HttpTicketGateway implements TicketGateway {
kind: TicketLinkKind,
expectedVersion: number,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_link", {
request: { projectId, ref, targetRef, kind, expectedVersion },
});
return this.http
.invoke<Ticket>("ticket_link", {
request: { projectId, ref, targetRef, kind, expectedVersion },
})
.then((ticket) => this.normalizeTicket(ticket));
}
unlink(
projectId: string,
@ -375,9 +416,11 @@ export class HttpTicketGateway implements TicketGateway {
expectedVersion: number,
kind?: TicketLinkKind,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_unlink", {
request: { projectId, ref, targetRef, expectedVersion, kind },
});
return this.http
.invoke<Ticket>("ticket_unlink", {
request: { projectId, ref, targetRef, expectedVersion, kind },
})
.then((ticket) => this.normalizeTicket(ticket));
}
assign(
projectId: string,
@ -386,9 +429,11 @@ export class HttpTicketGateway implements TicketGateway {
assigned: boolean,
expectedVersion: number,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_assign", {
request: { projectId, ref, agentId, assigned, expectedVersion },
});
return this.http
.invoke<Ticket>("ticket_assign", {
request: { projectId, ref, agentId, assigned, expectedVersion },
})
.then((ticket) => this.normalizeTicket(ticket));
}
async listSprints(projectId: string): Promise<Sprint[]> {
const list = await this.http.invoke<{ items: Sprint[] }>("sprint_list", { request: { projectId } });
@ -401,11 +446,17 @@ export class HttpTicketGateway implements TicketGateway {
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_unassign_sprint", {
request: { projectId, ref, expectedVersion },
})
.then((ticket) => this.normalizeTicket(ticket));
}
return this.http.invoke<Ticket>("ticket_assign_sprint", {
request: { projectId, ref, sprintId, expectedVersion },
});
return this.http
.invoke<Ticket>("ticket_assign_sprint", {
request: { projectId, ref, sprintId, expectedVersion },
})
.then((ticket) => this.normalizeTicket(ticket));
}
createSprint(projectId: string, name: string): Promise<Sprint> {
return this.http.invoke<Sprint>("sprint_create", { request: { projectId, name } });

View File

@ -55,6 +55,26 @@ describe("TauriTicketGateway invoke payloads", () => {
});
});
it("normalizes legacy ticket list rows that omit assignedAgentIds", async () => {
invoke.mockResolvedValueOnce({
items: [
{
ref: "#12",
path: ".ideai/tickets/12/issue.md",
title: "Legacy row",
status: "open",
priority: "medium",
createdBy: { kind: "user" },
updatedAt: 1,
},
],
});
const list = await new TauriTicketGateway().list("proj-1");
expect(list.items[0]?.assignedAgentIds).toEqual([]);
});
it("wraps ticket attachment commands in the request DTO", async () => {
const gateway = new TauriTicketGateway();
@ -97,4 +117,27 @@ describe("TauriTicketGateway invoke payloads", () => {
},
);
});
it("normalizes legacy ticket payloads that omit array fields", async () => {
invoke.mockResolvedValueOnce({
id: "issue-1",
ref: "#12",
number: 12,
title: "Legacy",
description: "",
status: "open",
priority: "medium",
createdBy: { kind: "user" },
updatedBy: { kind: "user" },
createdAt: 1,
updatedAt: 1,
version: 1,
});
const ticket = await new TauriTicketGateway().read("proj-1", "#12", true);
expect(ticket.links).toEqual([]);
expect(ticket.assignedAgentIds).toEqual([]);
expect(ticket.attachments).toEqual([]);
});
});

View File

@ -42,11 +42,36 @@ export function isTicketVersionConflict(error: unknown): boolean {
return typeof message === "string" && message.includes("version conflict");
}
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 : [],
};
}
function normalizeTicketList(list: TicketList): TicketList {
return {
...list,
items: Array.isArray(list.items)
? list.items.map((item) => ({
...item,
assignedAgentIds: Array.isArray(item.assignedAgentIds)
? item.assignedAgentIds
: [],
}))
: [],
};
}
export class TauriTicketGateway implements TicketGateway {
async create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
return invoke<Ticket>("ticket_create", {
return normalizeTicket(await invoke<Ticket>("ticket_create", {
request: { projectId, ...input },
});
}));
}
async read(
@ -54,23 +79,23 @@ export class TauriTicketGateway implements TicketGateway {
ref: string,
includeCarnet = false,
): Promise<Ticket> {
return invoke<Ticket>("ticket_read", {
return normalizeTicket(await invoke<Ticket>("ticket_read", {
request: { projectId, ref, includeCarnet },
});
}));
}
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
const { statuses, priorities, ...rest } = query ?? {};
// Multi-select facets (ticket #12): the backend expects `statuses`/
// `priorities` arrays (empty ⇒ no constraint on that facet).
return invoke<TicketList>("ticket_list", {
return normalizeTicketList(await invoke<TicketList>("ticket_list", {
request: {
projectId,
statuses: statuses ?? [],
priorities: priorities ?? [],
...rest,
},
});
}));
}
async update(
@ -78,9 +103,9 @@ export class TauriTicketGateway implements TicketGateway {
ref: string,
input: UpdateTicketInput,
): Promise<Ticket> {
return invoke<Ticket>("ticket_update", {
return normalizeTicket(await invoke<Ticket>("ticket_update", {
request: { projectId, ref, ...input },
});
}));
}
async delete(projectId: string, ref: string): Promise<void> {
@ -130,9 +155,9 @@ export class TauriTicketGateway implements TicketGateway {
carnet: string,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_update_carnet", {
return normalizeTicket(await invoke<Ticket>("ticket_update_carnet", {
request: { projectId, ref, carnet, expectedVersion },
});
}));
}
async addAttachment(
@ -142,9 +167,9 @@ export class TauriTicketGateway implements TicketGateway {
expectedVersion: number,
mime?: string | null,
): Promise<Ticket> {
return invoke<Ticket>("ticket_attachment_add", {
return normalizeTicket(await invoke<Ticket>("ticket_attachment_add", {
request: { projectId, ref, path, expectedVersion, mime },
});
}));
}
async readAttachment(
@ -163,9 +188,9 @@ export class TauriTicketGateway implements TicketGateway {
attachmentId: string,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_attachment_mark_summarized", {
return normalizeTicket(await invoke<Ticket>("ticket_attachment_mark_summarized", {
request: { projectId, ref, attachmentId, expectedVersion },
});
}));
}
async link(
@ -175,9 +200,9 @@ export class TauriTicketGateway implements TicketGateway {
kind: TicketLinkKind,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_link", {
return normalizeTicket(await invoke<Ticket>("ticket_link", {
request: { projectId, ref, targetRef, kind, expectedVersion },
});
}));
}
async unlink(
@ -187,9 +212,9 @@ export class TauriTicketGateway implements TicketGateway {
expectedVersion: number,
kind?: TicketLinkKind,
): Promise<Ticket> {
return invoke<Ticket>("ticket_unlink", {
return normalizeTicket(await invoke<Ticket>("ticket_unlink", {
request: { projectId, ref, targetRef, expectedVersion, kind },
});
}));
}
async assign(
@ -199,9 +224,9 @@ export class TauriTicketGateway implements TicketGateway {
assigned: boolean,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_assign", {
return normalizeTicket(await invoke<Ticket>("ticket_assign", {
request: { projectId, ref, agentId, assigned, expectedVersion },
});
}));
}
async listSprints(projectId: string): Promise<Sprint[]> {
@ -220,13 +245,13 @@ export class TauriTicketGateway implements TicketGateway {
): Promise<Ticket> {
// Two backend commands: assign to a sprint, or unassign (clear membership).
if (sprintId === null) {
return invoke<Ticket>("ticket_unassign_sprint", {
return normalizeTicket(await invoke<Ticket>("ticket_unassign_sprint", {
request: { projectId, ref, expectedVersion },
});
}));
}
return invoke<Ticket>("ticket_assign_sprint", {
return normalizeTicket(await invoke<Ticket>("ticket_assign_sprint", {
request: { projectId, ref, sprintId, expectedVersion },
});
}));
}
async createSprint(projectId: string, name: string): Promise<Sprint> {