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>
242 lines
8.0 KiB
TypeScript
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),
|
|
};
|
|
}
|