import type { AgentTicketState, AgentWorkState, BackgroundCompletion, ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, InboxItem, ProjectWorkState, TicketWorkSource, TicketWorkStatus, WorkBusyState, } from "@/domain"; type RecordLike = Record; 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), }; }