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:
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ describe("createMockGateways", () => {
|
||||
"system",
|
||||
"template",
|
||||
"terminal",
|
||||
"ticket",
|
||||
"workState",
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user