diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e9cf4893..6e941c3a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -82,15 +82,185 @@ export interface MergeQueueEntry { export type Actor = "human" | "orchestrator" | "scheduler" | "agent" | "system"; -export interface Event { +// --------------------------------------------------------------------------- +// Typed event payloads +// --------------------------------------------------------------------------- + +/** orchestrator:decision */ +export interface OrchestratorDecisionData { + entry_id?: string; + approved: boolean; + reasoning?: string; + task_id?: string; +} + +/** orchestrator:feedback */ +export interface OrchestratorFeedbackData { + feedback?: string; + context?: string; + task_id?: string; + // Conflict-resolution variant + action?: string; + entry_id?: string; + pr_url?: string; + success?: boolean; +} + +/** orchestrator:escalation */ +export interface OrchestratorEscalationData { + action?: string; + reasoning?: string; + reason?: string; + message?: string; + entry_id?: string; + pr_url?: string; + from?: string; + to?: string; + details?: Record; +} + +/** orchestrator:message / orchestrator:response / orchestrator:thought */ +export interface OrchestratorMessageData { + message?: string; + error?: boolean; +} + +/** human:message */ +export interface HumanMessageData { + message?: string; + source?: string; +} + +/** agent:message */ +export interface AgentMessageData { + text?: string; + stream?: string; + completion_hint?: boolean; + source?: string; +} + +/** agent:error */ +export interface AgentErrorData { + text?: string; + source?: string; +} + +/** agent:question */ +export interface AgentQuestionData { + question?: string; + message?: string; + text?: string; + source?: string; +} + +/** automation:run:output */ +export interface AutomationRunOutputData { + automation_id?: string; + chunk?: string; +} + +/** Content block inside a parsed agent message (JSON in agent:message text) */ +export interface AgentContentBlockText { + type: "text"; + text: string; +} + +export interface AgentToolInput { + file_path?: string; + filePath?: string; + path?: string; + pattern?: string; + command?: string; + description?: string; + [key: string]: unknown; +} + +export interface AgentContentBlockToolUse { + type: "tool_use"; + name?: string; + input?: AgentToolInput; +} + +export interface AgentContentBlockToolResult { + type: "tool_result"; + content?: string; +} + +export interface AgentContentBlockThinking { + type: "thinking"; +} + +export type AgentContentBlock = + | AgentContentBlockText + | AgentContentBlockToolUse + | AgentContentBlockToolResult + | AgentContentBlockThinking; + +/** Parsed JSON message from agent stdout (the text field parsed as JSON) */ +export interface AgentParsedMessage { + type?: string; + result?: { text?: string }; + message?: { content?: AgentContentBlock[] }; + content?: AgentContentBlock[]; +} + +// --------------------------------------------------------------------------- +// Event type map — maps event.type string to its data shape +// --------------------------------------------------------------------------- + +export interface EventDataMap { + "orchestrator:decision": OrchestratorDecisionData; + "orchestrator:feedback": OrchestratorFeedbackData; + "orchestrator:escalation": OrchestratorEscalationData; + "orchestrator:message": OrchestratorMessageData; + "orchestrator:response": OrchestratorMessageData; + "orchestrator:thought": OrchestratorMessageData; + "human:message": HumanMessageData; + "agent:message": AgentMessageData; + "agent:error": AgentErrorData; + "agent:question": AgentQuestionData; + "automation:run:output": AutomationRunOutputData; +} + +export type KnownEventType = keyof EventDataMap; + +// --------------------------------------------------------------------------- +// Event — discriminated by `type` +// --------------------------------------------------------------------------- + +interface EventBase { id: string; - type: string; task: string; actor: Actor; ts: string; +} + +/** A typed event whose `type` is one of the known event types. */ +export type TypedEvent = EventBase & { + type: T; + data: EventDataMap[T]; +}; + +/** An event with an unrecognized type (e.g. task:state:*, future additions). */ +export interface UntypedEvent extends EventBase { + type: string; data: Record; } +/** Union of all events. Consumer code can narrow via `isEventType()`. */ +export type Event = UntypedEvent; + +/** + * Type guard to narrow an Event to a specific known type with typed data. + * Returns the event with its `data` field typed according to the EventDataMap. + */ +export function isEventType( + event: Event, + type: T, +): event is Event & { type: T; data: EventDataMap[T] } { + return event.type === type; +} + export interface SlotUtilization { active: number; max: number; diff --git a/web/src/pages/automation-detail.tsx b/web/src/pages/automation-detail.tsx index 705cc3ad..b1a2149e 100644 --- a/web/src/pages/automation-detail.tsx +++ b/web/src/pages/automation-detail.tsx @@ -39,7 +39,8 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import type { Automation, AutomationRun, AutomationState, Event } from "@/lib/types"; +import type { Automation, AutomationRun, AutomationState, Event, AgentContentBlock, AgentParsedMessage, AgentToolInput } from "@/lib/types"; +import { isEventType } from "@/lib/types"; // --------------------------------------------------------------------------- // State badge configuration @@ -189,9 +190,9 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { for (const event of events) { // Handle automation lifecycle events if (event.type.startsWith("automation:run:")) { - if (event.type === "automation:run:output") { + if (isEventType(event, "automation:run:output")) { // Streaming output chunk - const chunk = event.data?.chunk as string | undefined; + const chunk = event.data.chunk; if (chunk) { blocks.push({ kind: "output_chunk", content: chunk, timestamp: event.ts }); } @@ -205,22 +206,23 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { } // Handle agent errors - if (event.type === "agent:error") { + if (isEventType(event, "agent:error")) { + const d = event.data; blocks.push({ kind: "error", - content: typeof event.data?.text === "string" ? event.data.text : JSON.stringify(event.data), + content: d.text ?? JSON.stringify(event.data), timestamp: event.ts, }); continue; } // Handle agent messages (same parsing as task-detail) - const raw = event.data?.text; + const raw = (event.data as Record)?.text; if (typeof raw !== "string") continue; - let msg: Record; + let msg: AgentParsedMessage; try { - msg = JSON.parse(raw); + msg = JSON.parse(raw) as AgentParsedMessage; } catch { if (raw.trim()) blocks.push({ kind: "text", content: raw }); continue; @@ -229,24 +231,19 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { if (msg.type === "system") continue; if (msg.type === "result") { - const result = msg.result as Record | undefined; - if (typeof result?.text === "string" && result.text) { - blocks.push({ kind: "text", content: result.text }); + if (msg.result?.text) { + blocks.push({ kind: "text", content: msg.result.text }); } continue; } - const message = msg.message as Record | undefined; - const contentBlocks = (message?.content ?? msg.content) as unknown[] | undefined; + const contentBlocks: AgentContentBlock[] | undefined = msg.message?.content ?? msg.content; if (!Array.isArray(contentBlocks)) continue; - for (const block of contentBlocks) { - if (typeof block !== "object" || block === null) continue; - const b = block as Record; - + for (const b of contentBlocks) { if (b.type === "thinking") continue; - if (b.type === "text" && typeof b.text === "string") { + if (b.type === "text") { if (b.text.trim()) { blocks.push({ kind: "text", content: b.text, timestamp: event.ts }); } @@ -254,8 +251,8 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { } if (b.type === "tool_use") { - const name = typeof b.name === "string" ? b.name : "tool"; - const input = (b.input ?? {}) as Record; + const name = b.name ?? "tool"; + const input: AgentToolInput = b.input ?? {}; const filePath = input.file_path ?? input.filePath ?? input.path ?? input.pattern; const command = input.command; const description = input.description; @@ -271,7 +268,7 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { } if (b.type === "tool_result") { - const content = typeof b.content === "string" ? b.content : ""; + const content = b.content ?? ""; if (!content) continue; const lines = content.split("\n"); const preview = diff --git a/web/src/pages/orchestrator/page.tsx b/web/src/pages/orchestrator/page.tsx index 17fbd71c..0aef94af 100644 --- a/web/src/pages/orchestrator/page.tsx +++ b/web/src/pages/orchestrator/page.tsx @@ -17,6 +17,7 @@ import { cn, formatRelativeTime, projectLabel } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import type { Event, Task, Project } from "@/lib/types"; +import { isEventType } from "@/lib/types"; // --------------------------------------------------------------------------- // Task helpers: resolve task ID → title, number, repo info @@ -90,80 +91,75 @@ function parseOrchestratorEvents( ): OrchestratorBlock[] { const blocks: OrchestratorBlock[] = []; for (const event of events) { - if (event.type === "orchestrator:decision") { - const taskId = typeof event.data?.task_id === "string" ? event.data.task_id : event.task; + if (isEventType(event, "orchestrator:decision")) { + const d = event.data; + const taskId = d.task_id ?? event.task; blocks.push({ kind: "decision", id: event.id, timestamp: event.ts, - approved: event.data?.approved === true, - reasoning: typeof event.data?.reasoning === "string" ? event.data.reasoning : undefined, + approved: d.approved === true, + reasoning: d.reasoning, taskId, taskInfo: getTaskInfo(taskId, tasks, projects) ?? undefined, - entryId: typeof event.data?.entry_id === "string" ? event.data.entry_id : undefined, + entryId: d.entry_id, }); - } else if (event.type === "orchestrator:feedback") { - const taskId = typeof event.data?.task_id === "string" ? event.data.task_id : event.task; - const context = typeof event.data?.context === "string" ? event.data.context : undefined; + } else if (isEventType(event, "orchestrator:feedback")) { + const d = event.data; + const taskId = d.task_id ?? event.task; // Distinguish question answers from regular feedback blocks.push({ - kind: context === "question_answer" ? "question_answer" : "feedback", + kind: d.context === "question_answer" ? "question_answer" : "feedback", id: event.id, timestamp: event.ts, - feedback: typeof event.data?.feedback === "string" ? event.data.feedback : undefined, + feedback: d.feedback, taskId, taskInfo: getTaskInfo(taskId, tasks, projects) ?? undefined, - content: context, + content: d.context, }); - } else if (event.type === "orchestrator:escalation") { - const action = typeof event.data?.action === "string" ? event.data.action : undefined; + } else if (isEventType(event, "orchestrator:escalation")) { + const d = event.data; // Extract reasoning - backend sends "reasoning" for conflicts, "reason" for mode changes - const reasoning = - typeof event.data?.reasoning === "string" ? event.data.reasoning : - typeof event.data?.reason === "string" ? event.data.reason : - undefined; + const reasoning = d.reasoning ?? d.reason; const taskId = event.task; - // For agent_question escalations, the message field contains the question - const message = typeof event.data?.message === "string" ? event.data.message : undefined; - blocks.push({ kind: "escalation", id: event.id, timestamp: event.ts, - content: action === "agent_question" ? message : reasoning, + content: d.action === "agent_question" ? d.message : reasoning, taskId, taskInfo: getTaskInfo(taskId, tasks, projects) ?? undefined, - entryId: typeof event.data?.entry_id === "string" ? event.data.entry_id : undefined, - escalationAction: action, - prUrl: typeof event.data?.pr_url === "string" ? event.data.pr_url : undefined, - fromMode: typeof event.data?.from === "string" ? event.data.from : undefined, - toMode: typeof event.data?.to === "string" ? event.data.to : undefined, + entryId: d.entry_id, + escalationAction: d.action, + prUrl: d.pr_url, + fromMode: d.from, + toMode: d.to, }); - } else if (event.type === "orchestrator:message") { + } else if (isEventType(event, "orchestrator:message")) { // Human message to orchestrator blocks.push({ kind: "message", id: event.id, timestamp: event.ts, - content: typeof event.data?.message === "string" ? event.data.message : undefined, + content: event.data.message, actor: "human", }); - } else if (event.type === "orchestrator:response") { + } else if (isEventType(event, "orchestrator:response")) { // Orchestrator response to human blocks.push({ kind: "message", id: event.id, timestamp: event.ts, - content: typeof event.data?.message === "string" ? event.data.message : undefined, + content: event.data.message, actor: "orchestrator", }); - } else if (event.type === "orchestrator:thought") { + } else if (isEventType(event, "orchestrator:thought")) { // Stream-of-consciousness narration from the orchestrator blocks.push({ kind: "thought", id: event.id, timestamp: event.ts, - content: typeof event.data?.message === "string" ? event.data.message : undefined, + content: event.data.message, }); } } diff --git a/web/src/pages/task-detail.tsx b/web/src/pages/task-detail.tsx index ce7bc4a6..e0ea86fb 100644 --- a/web/src/pages/task-detail.tsx +++ b/web/src/pages/task-detail.tsx @@ -43,7 +43,8 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { Event, FailureInfo, Task, TaskState } from "@/lib/types"; +import type { Event, FailureInfo, Task, TaskState, AgentContentBlock, AgentParsedMessage, AgentToolInput } from "@/lib/types"; +import { isEventType } from "@/lib/types"; import { taskStateMeta } from "./tasks/columns"; // --------------------------------------------------------------------------- @@ -149,43 +150,44 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { continue; } - if (event.type === "agent:question") { - const question = (event.data?.question ?? event.data?.message ?? event.data?.text) as string | undefined; + if (isEventType(event, "agent:question")) { + const d = event.data; + const question = d.question ?? d.message ?? d.text; if (question) { blocks.push({ kind: "agent_question", content: question, timestamp: event.ts }); } continue; } - if (event.type === "human:message") { - const message = event.data?.message as string | undefined; - const source = event.data?.source as string | undefined; - if (message) { + if (isEventType(event, "human:message")) { + const d = event.data; + if (d.message) { // Orchestrator answers come as human:message with source "orchestrator_answer" blocks.push({ - kind: source === "orchestrator_answer" ? "orchestrator_answer" : "human_message", - content: message, + kind: d.source === "orchestrator_answer" ? "orchestrator_answer" : "human_message", + content: d.message, timestamp: event.ts, }); } continue; } - if (event.type === "agent:error") { + if (isEventType(event, "agent:error")) { + const d = event.data; blocks.push({ kind: "error", - content: typeof event.data?.text === "string" ? event.data.text : JSON.stringify(event.data), + content: d.text ?? JSON.stringify(event.data), timestamp: event.ts, }); continue; } - const raw = event.data?.text; + const raw = (event.data as Record)?.text; if (typeof raw !== "string") continue; - let msg: Record; + let msg: AgentParsedMessage; try { - msg = JSON.parse(raw); + msg = JSON.parse(raw) as AgentParsedMessage; } catch { if (raw.trim()) blocks.push({ kind: "text", content: raw }); continue; @@ -194,24 +196,19 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { if (msg.type === "system") continue; if (msg.type === "result") { - const result = msg.result as Record | undefined; - if (typeof result?.text === "string" && result.text) { - blocks.push({ kind: "text", content: result.text }); + if (msg.result?.text) { + blocks.push({ kind: "text", content: msg.result.text }); } continue; } - const message = msg.message as Record | undefined; - const contentBlocks = (message?.content ?? msg.content) as unknown[] | undefined; + const contentBlocks: AgentContentBlock[] | undefined = msg.message?.content ?? msg.content; if (!Array.isArray(contentBlocks)) continue; - for (const block of contentBlocks) { - if (typeof block !== "object" || block === null) continue; - const b = block as Record; - + for (const b of contentBlocks) { if (b.type === "thinking") continue; - if (b.type === "text" && typeof b.text === "string") { + if (b.type === "text") { if (b.text.trim()) { blocks.push({ kind: "text", content: b.text, timestamp: event.ts }); } @@ -219,8 +216,8 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { } if (b.type === "tool_use") { - const name = typeof b.name === "string" ? b.name : "tool"; - const input = (b.input ?? {}) as Record; + const name = b.name ?? "tool"; + const input: AgentToolInput = b.input ?? {}; const filePath = input.file_path ?? input.filePath ?? input.path ?? input.pattern; const command = input.command; const description = input.description; @@ -236,7 +233,7 @@ function parseAgentEvents(events: Event[]): ParsedBlock[] { } if (b.type === "tool_result") { - const content = typeof b.content === "string" ? b.content : ""; + const content = b.content ?? ""; if (!content) continue; const lines = content.split("\n"); const preview =