Files
IdeA/frontend/src/adapters/workStateNormalization.ts
Blomios 3d603cebcc feat(workstate): dette de contrat DTO work-state (frontend)
Aligne le contrat DTO du panneau work-state (#5).

- ajout summary: string|null à BackgroundCompletion, mappé depuis task.summary
- affichage discret du résumé sur une tâche terminale
- suppression du chemin de merge top-level mort des background tasks

Tests verts : vitest workstate.test.tsx (21), suite complète (507),
npm run build (exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:05:03 +02:00

242 lines
8.0 KiB
TypeScript

import type {
AgentTicketState,
AgentWorkState,
BackgroundCompletion,
ConversationPreviewStatus,
ConversationTurnWorkPreview,
ConversationWorkSummary,
InboxItem,
ProjectWorkState,
TicketWorkSource,
TicketWorkStatus,
WorkBusyState,
} from "@/domain";
type RecordLike = Record<string, unknown>;
function isRecord(value: unknown): value is RecordLike {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function stringValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function numberValue(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function nullableNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function nullableString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function normalizeSource(value: unknown): TicketWorkSource {
if (!isRecord(value)) return { kind: "human" };
if (value.kind === "agent") {
return { kind: "agent", agentId: stringValue(value.agentId, "unknown") };
}
return { kind: "human" };
}
function normalizeTicketStatus(value: unknown): TicketWorkStatus {
return value === "inProgress" ? "inProgress" : "queued";
}
function normalizeBusy(value: unknown): WorkBusyState {
if (isRecord(value) && value.state === "busy") {
return {
state: "busy",
ticket: stringValue(value.ticket, "unknown"),
sinceMs: numberValue(value.sinceMs, 0),
};
}
return { state: "idle" };
}
function normalizeTicket(value: unknown, index: number): AgentTicketState {
const ticket = isRecord(value) ? value : {};
const taskPreview = stringValue(ticket.taskPreview);
return {
ticketId: stringValue(ticket.ticketId, `legacy-ticket-${index}`),
conversationId: stringValue(ticket.conversationId),
position: numberValue(ticket.position, index),
status: normalizeTicketStatus(ticket.status),
source: normalizeSource(ticket.source),
requesterLabel: stringValue(ticket.requesterLabel),
taskPreview,
taskLen: numberValue(ticket.taskLen, taskPreview.length),
};
}
function normalizeBackgroundStatus(
value: unknown,
): BackgroundCompletion["status"] {
const raw = typeof value === "string" ? value.toLowerCase() : "";
if (raw === "completed" || raw === "success" || raw === "succeeded") return "completed";
if (raw === "failed" || raw === "failure") return "failed";
if (raw === "cancelled" || raw === "canceled" || raw === "expired") return "cancelled";
if (raw === "running" || raw === "started") return "running";
if (raw === "delivered") return "delivered";
// Backend `queued`/`waiting` collapse to the cancellable "pending" bucket.
if (raw === "queued" || raw === "waiting") return "pending";
return "pending";
}
function normalizeBackgroundTask(
value: unknown,
index: number,
fallbackAgentId: string,
): BackgroundCompletion {
const task = isRecord(value) ? value : {};
return {
taskId: stringValue(task.taskId, `legacy-task-${index}`),
ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId),
projectId: stringValue(task.projectId),
kind: stringValue(task.kind, "background"),
status: normalizeBackgroundStatus(task.status ?? task.state),
exitCode: nullableNumber(task.exitCode),
summary: nullableString(task.summary),
stdoutTail: nullableString(task.stdoutTail),
stderrTail: nullableString(task.stderrTail),
updatedAtMs: numberValue(task.updatedAtMs),
};
}
function normalizeInboxItem(
value: unknown,
index: number,
fallbackAgentId: string,
): InboxItem {
const item = isRecord(value) ? value : {};
return {
id: stringValue(item.id, `legacy-inbox-${index}`),
agentId: stringValue(item.agentId ?? item.agent_id, fallbackAgentId),
source: stringValue(item.source, "unknown"),
kind: stringValue(item.kind, "message"),
body: stringValue(item.body),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms, 0),
...(optionalString(item.correlationId ?? item.correlation_id)
? { correlationId: optionalString(item.correlationId ?? item.correlation_id) }
: {}),
priority: numberValue(item.priority, 0),
};
}
function normalizeAgent(value: unknown, index: number): AgentWorkState {
const agent = isRecord(value) ? value : {};
const liveKind: "pty" | "structured" =
isRecord(agent.live) && agent.live.kind === "structured" ? "structured" : "pty";
const live = isRecord(agent.live)
? {
nodeId: stringValue(agent.live.nodeId),
sessionId: stringValue(agent.live.sessionId),
kind: liveKind,
}
: undefined;
const agentId = stringValue(agent.agentId, `legacy-agent-${index}`);
const inbox = arrayValue(agent.inbox).map((item, itemIndex) =>
normalizeInboxItem(item, itemIndex, agentId),
);
const backgroundTasks = arrayValue(
agent.backgroundTasks ?? agent.backgroundCompletions,
).map((task, taskIndex) =>
normalizeBackgroundTask(task, taskIndex, agentId),
);
return {
agentId,
name: stringValue(agent.name, "Unnamed agent"),
profileId: stringValue(agent.profileId, "unknown"),
...(live ? { live } : {}),
busy: normalizeBusy(agent.busy),
tickets: arrayValue(agent.tickets).map(normalizeTicket),
inboxDepth: numberValue(agent.inboxDepth, inbox.length),
inbox,
backgroundTasks,
};
}
function normalizeSummaryStatus(value: unknown): ConversationPreviewStatus {
if (
value === "ready" ||
value === "partial" ||
value === "unavailable" ||
value === "missing"
) {
return value;
}
return "missing";
}
function normalizeTurn(value: unknown, index: number): ConversationTurnWorkPreview {
const turn = isRecord(value) ? value : {};
const textPreview = stringValue(turn.textPreview);
const role =
turn.role === "response" || turn.role === "toolActivity"
? turn.role
: "prompt";
return {
role,
source: normalizeSource(turn.source),
atMs: numberValue(turn.atMs, index),
textPreview,
textLen: numberValue(turn.textLen, textPreview.length),
};
}
function normalizeConversation(
value: unknown,
index: number,
): ConversationWorkSummary {
const summary = isRecord(value) ? value : {};
return {
conversationId: stringValue(summary.conversationId, `legacy-conversation-${index}`),
status: normalizeSummaryStatus(summary.status),
objectivePreview: nullableString(summary.objectivePreview),
summaryPreview: nullableString(summary.summaryPreview),
summaryLen: numberValue(summary.summaryLen, 0),
upTo: nullableString(summary.upTo),
recentTurns: arrayValue(summary.recentTurns).map(normalizeTurn),
};
}
export function normalizeProjectWorkState(value: unknown): ProjectWorkState {
const state = isRecord(value) ? value : {};
const agents = arrayValue(state.agents).map(normalizeAgent);
// Background tasks are always emitted nested under their owning agent
// (`agent.backgroundTasks`) and the backend DTO carries no `ownerAgentId`, so a
// top-level merge keyed on `task.ownerAgentId` is unreachable for this contract
// and has been removed (ticket #5). The per-agent `ownerAgentId = agentId`
// fallback in `normalizeBackgroundTask` is what nested tasks rely on.
const topLevelInbox = arrayValue(state.inboxItems).map((item, index) =>
normalizeInboxItem(item, index, ""),
);
for (const item of topLevelInbox) {
const owner = agents.find((agent) => agent.agentId === item.agentId);
if (!owner) continue;
owner.inbox ??= [];
owner.inboxDepth ??= 0;
if (!owner.inbox.some((entry) => entry.id === item.id)) {
owner.inbox.push(item);
owner.inboxDepth = Math.max(owner.inboxDepth, owner.inbox.length);
}
}
return {
agents,
conversations: arrayValue(state.conversations).map(normalizeConversation),
};
}