Merge branch 'feature/issue-ticket-system' into feature/background-tasks-first-class

Intègre le système de tickets/issues V1 (validé QA vert de bout en bout,
mémoire tickets-v1-e2e-validated-qa) dans la branche des tâches de fond.
Conflits résolus : app-tauri/state.rs, domain/events.rs, domain/lib.rs, domain/ports.rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 15:15:43 +02:00
38 changed files with 6539 additions and 45 deletions

View File

@ -38,6 +38,11 @@ import type {
SkillScope,
Template,
TerminalSession,
Ticket,
TicketCarnet,
TicketLink,
TicketLinkKind,
TicketList,
TurnPage,
TurnView,
Unsubscribe,
@ -70,6 +75,10 @@ import type {
TemplateGateway,
TerminalGateway,
TerminalHandle,
TicketGateway,
TicketListQuery,
CreateTicketInput,
UpdateTicketInput,
WorkStateGateway,
} from "@/ports";
import { normalizeProjectWorkState } from "../workStateNormalization";
@ -1791,6 +1800,274 @@ export class MockConversationGateway implements ConversationGateway {
}
}
/**
* In-memory {@link TicketGateway} with real optimistic-concurrency semantics:
* every mutation checks `expectedVersion` against the stored version and rejects
* a stale write with an `INVALID` {@link GatewayError} whose message contains
* `"version conflict"` (mirroring the backend `IssueStoreError::VersionConflict`
* → `AppError::Invalid` mapping). Mutations emit the matching `Issue*` domain
* event through the injected {@link MockSystemGateway} so event-driven refresh
* can be exercised offline.
*/
export class MockTicketGateway implements TicketGateway {
private tickets = new Map<string, Map<string, Ticket>>();
private carnets = new Map<string, Map<string, string>>();
private counters = new Map<string, number>();
constructor(private readonly system?: MockSystemGateway) {}
/** Seeds a ticket for deterministic tests; returns the stored clone. */
_seedTicket(projectId: string, ticket: Ticket): Ticket {
const stored = structuredClone(ticket);
this.projectTickets(projectId).set(stored.ref, stored);
this.counters.set(
projectId,
Math.max(this.counters.get(projectId) ?? 0, stored.number),
);
return structuredClone(stored);
}
private projectTickets(projectId: string): Map<string, Ticket> {
let map = this.tickets.get(projectId);
if (!map) {
map = new Map();
this.tickets.set(projectId, map);
}
return map;
}
private projectCarnets(projectId: string): Map<string, string> {
let map = this.carnets.get(projectId);
if (!map) {
map = new Map();
this.carnets.set(projectId, map);
}
return map;
}
private require(projectId: string, ref: string): Ticket {
const ticket = this.projectTickets(projectId).get(ref);
if (!ticket) {
throw { code: "NOT_FOUND", message: `ticket ${ref} not found` } as GatewayError;
}
return ticket;
}
private guard(ticket: Ticket, expectedVersion: number): void {
if (ticket.version !== expectedVersion) {
throw {
code: "INVALID",
message: `issue version conflict: expected ${expectedVersion}, actual ${ticket.version}`,
} as GatewayError;
}
}
async create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
const number = (this.counters.get(projectId) ?? 0) + 1;
this.counters.set(projectId, number);
const now = Date.now();
const ticket: Ticket = {
id: `mock-ticket-${projectId}-${number}`,
ref: `#${number}`,
number,
title: input.title,
description: input.description ?? "",
status: input.status ?? "open",
priority: input.priority ?? "medium",
links: [],
assignedAgentIds: [...(input.assignedAgentIds ?? [])],
createdBy: { kind: "user" },
updatedBy: { kind: "user" },
createdAt: now,
updatedAt: now,
version: 1,
};
this.projectTickets(projectId).set(ticket.ref, ticket);
this.system?.emit({
type: "issueCreated",
issueId: ticket.id,
issueRef: ticket.ref,
});
return structuredClone(ticket);
}
async read(
projectId: string,
ref: string,
includeCarnet = false,
): Promise<Ticket> {
const ticket = structuredClone(this.require(projectId, ref));
if (includeCarnet) {
ticket.carnet = this.projectCarnets(projectId).get(ref) ?? "";
}
return ticket;
}
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
const q = query ?? {};
const text = q.text?.trim().toLowerCase();
const rows = [...this.projectTickets(projectId).values()]
.filter((t) => !q.status || t.status === q.status)
.filter((t) => !q.priority || t.priority === q.priority)
.filter(
(t) =>
!q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),
)
.filter(
(t) =>
!text ||
t.title.toLowerCase().includes(text) ||
t.description.toLowerCase().includes(text),
)
.sort((a, b) => b.number - a.number);
const start = Number.parseInt(q.cursor ?? "0", 10) || 0;
const limit = Math.min(Math.max(q.limit ?? 100, 1), 500);
const end = Math.min(rows.length, start + limit);
return {
items: rows.slice(start, end).map((t) => ({
ref: t.ref,
path: `.ideai/tickets/${t.number}.md`,
title: t.title,
status: t.status,
priority: t.priority,
assignedAgentIds: [...t.assignedAgentIds],
updatedAt: t.updatedAt,
})),
...(end < rows.length ? { nextCursor: String(end) } : {}),
};
}
private bump(ticket: Ticket): void {
ticket.version += 1;
ticket.updatedAt = Date.now();
ticket.updatedBy = { kind: "user" };
}
async update(
projectId: string,
ref: string,
input: UpdateTicketInput,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, input.expectedVersion);
if (input.title !== undefined) ticket.title = input.title;
if (input.description !== undefined) ticket.description = input.description;
if (input.status !== undefined) ticket.status = input.status;
if (input.priority !== undefined) ticket.priority = input.priority;
if (input.assignedAgentIds !== undefined) {
ticket.assignedAgentIds = [...input.assignedAgentIds];
}
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return structuredClone(ticket);
}
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
const ticket = this.require(projectId, ref);
return {
ref,
carnet: this.projectCarnets(projectId).get(ref) ?? "",
version: ticket.version,
};
}
async updateCarnet(
projectId: string,
ref: string,
carnet: string,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
this.projectCarnets(projectId).set(ref, carnet);
this.bump(ticket);
this.system?.emit({
type: "issueCarnetUpdated",
issueRef: ref,
version: ticket.version,
});
return structuredClone(ticket);
}
async link(
projectId: string,
ref: string,
targetRef: string,
kind: TicketLinkKind,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
const exists = ticket.links.some(
(l: TicketLink) => l.targetRef === targetRef && l.kind === kind,
);
if (!exists) ticket.links.push({ targetRef, kind });
this.bump(ticket);
this.system?.emit({
type: "issueLinked",
issueRef: ref,
target: targetRef,
kind,
version: ticket.version,
});
return structuredClone(ticket);
}
async unlink(
projectId: string,
ref: string,
targetRef: string,
expectedVersion: number,
kind?: TicketLinkKind,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
ticket.links = ticket.links.filter(
(l: TicketLink) =>
l.targetRef !== targetRef || (kind !== undefined && l.kind !== kind),
);
this.bump(ticket);
this.system?.emit({
type: "issueUnlinked",
issueRef: ref,
target: targetRef,
kind: kind ?? "relatesTo",
version: ticket.version,
});
return structuredClone(ticket);
}
async assign(
projectId: string,
ref: string,
agentId: string,
assigned: boolean,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
const has = ticket.assignedAgentIds.includes(agentId);
if (assigned && !has) ticket.assignedAgentIds.push(agentId);
if (!assigned && has) {
ticket.assignedAgentIds = ticket.assignedAgentIds.filter(
(id) => id !== agentId,
);
}
this.bump(ticket);
this.system?.emit({
type: assigned ? "issueAgentAssigned" : "issueAgentUnassigned",
issueRef: ref,
agentId,
version: ticket.version,
});
return structuredClone(ticket);
}
}
function mostRestrictive(
project?: PermissionSet["fallback"],
agent?: PermissionSet["fallback"],
@ -1804,8 +2081,9 @@ function mostRestrictive(
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
const systemGateway = new MockSystemGateway();
return {
system: new MockSystemGateway(),
system: systemGateway,
agent: agentGateway,
input: new MockInputGateway(),
terminal: new MockTerminalGateway(),
@ -1821,6 +2099,7 @@ export function createMockGateways(): Gateways {
permission: new MockPermissionGateway(),
workState: new MockWorkStateGateway(),
conversation: new MockConversationGateway(),
ticket: new MockTicketGateway(systemGateway),
};
}