Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion apps/server/src/runs/event-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
COORDINATOR_AGENT,
COORDINATOR_TASK_ID,
deserializeTeamRunResult,
type ForgePlanTask,
type ForgeWorkflowEvent,
Expand Down Expand Up @@ -54,7 +56,16 @@ export function applyForgeWorkflowEvent(
return false
}
case 'plan': {
session.setPlanRecords(planTasksToRecords(event.data.tasks))
const records = planTasksToRecords(event.data.tasks)
session.setPlanRecords(records)
publishTraceLine(session, {
runId: session.id,
at: Date.now(),
level: 'info',
agent: COORDINATOR_AGENT,
taskId: COORDINATOR_TASK_ID,
message: `Plan published: ${records.length} tasks`,
})
publishSnapshot(session)
return false
}
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/runs/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class RunSession {
}

setPlan(tasks: readonly Task[]): void {
this.tasks = tasks.map(taskToRecord)
this.setPlanRecords(tasks.map(taskToRecord))
}

setPlanRecords(tasks: TaskExecutionRecord[]): void {
Expand Down
56 changes: 56 additions & 0 deletions apps/server/tests/coordinator-trace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { filterTraceLinesForCoordinator, filterTraceLinesForTask } from '@oma-forge/shared'

describe('filterTraceLinesForCoordinator', () => {
it('keeps coordinator agent and plan messages', () => {
const lines = [
{
runId: 'r1',
at: 1,
level: 'stream' as const,
agent: 'researcher',
message: 'Finding sources…',
},
{
runId: 'r1',
at: 2,
level: 'info' as const,
agent: 'coordinator',
message: 'Plan ready: 2 tasks (approved)',
},
]
expect(filterTraceLinesForCoordinator(lines).map((l) => l.message)).toEqual([
'Plan ready: 2 tasks (approved)',
])
})
})

describe('filterTraceLinesForTask', () => {
it('excludes coordinator traces from worker nodes', () => {
const worker = {
id: 't1',
title: 'Research',
assignee: 'researcher',
status: 'in_progress' as const,
dependsOn: [],
}
const lines = [
{
runId: 'r1',
at: 1,
level: 'info' as const,
agent: 'coordinator',
message: 'Agent started: coordinator',
},
{
runId: 'r1',
at: 2,
level: 'stream' as const,
agent: 'researcher',
taskId: 't1',
message: 'Hello',
},
]
expect(filterTraceLinesForTask(lines, worker).map((l) => l.message)).toEqual(['Hello'])
})
})
25 changes: 23 additions & 2 deletions apps/server/tests/runs-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ describe('RunSession', () => {
])

run.applyProgress({ type: 'task_start', task: 't1' })
expect(run.toSnapshot().tasks[0]?.status).toBe('in_progress')
expect(run.toSnapshot().tasks.find((t) => t.id === 't1')?.status).toBe('in_progress')

run.applyProgress({ type: 'task_complete', task: 't1' })
expect(run.toSnapshot().tasks[0]?.status).toBe('completed')
expect(run.toSnapshot().tasks.find((t) => t.id === 't1')?.status).toBe('completed')
})

it('creates tasks on task_start when no plan was received', () => {
Expand Down Expand Up @@ -81,6 +81,27 @@ describe('RunSession', () => {
])
})

it('does not add a coordinator task to the DAG snapshot', () => {
const run = new RunSession('run-1', 'Ship the feature')
run.applyProgress({ type: 'agent_start', agent: 'coordinator' })
expect(run.toSnapshot().tasks).toEqual([])

run.setPlan([
{
id: 't1',
title: 'Research',
status: 'pending',
description: 'Research',
dependsOn: [],
createdAt: new Date(),
updatedAt: new Date(),
},
])
expect(run.toSnapshot().tasks).toEqual([
{ id: 't1', title: 'Research', status: 'pending', dependsOn: [] },
])
})

it('tracks short-circuit runs before the final result arrives', () => {
const run = new RunSession('run-1', 'Quick ask')
run.applyProgress({
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "npm run build -w @oma-forge/shared && vite",
"build": "npm run build -w @oma-forge/shared && tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "npm run build -w @oma-forge/shared && tsc --noEmit"
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/components/dashboard/CoordinatorPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
filterTraceLinesForCoordinator,
type ForgeTraceLine,
type RunStatus,
} from '@oma-forge/shared'
import { LiveOutput } from './LiveOutput.tsx'

type CoordinatorPanelProps = {
readonly traceLines: readonly ForgeTraceLine[]
readonly runStatus?: RunStatus
readonly hasPlanTasks: boolean
}

export function CoordinatorPanel({
traceLines,
runStatus,
hasPlanTasks,
}: CoordinatorPanelProps) {
const [expanded, setExpanded] = useState(false)
const autoExpanded = useRef(false)
const coordinatorLines = useMemo(
() => filterTraceLinesForCoordinator(traceLines),
[traceLines],
)
const hasActivity = coordinatorLines.length > 0
const isRunning = runStatus === 'running'
const planningDone = hasPlanTasks || !isRunning

useEffect(() => {
if (!hasActivity || autoExpanded.current) return
autoExpanded.current = true
setExpanded(true)
}, [hasActivity])

if (!isRunning && !hasActivity) {
return null
}

return (
<section
data-collateral
className={`flex flex-col shrink-0 border-b border-outline-variant/10 ${expanded ? 'min-h-0 max-h-[min(42vh,360px)]' : ''}`}
>
<button
type="button"
className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-surface-container transition-colors"
onClick={() => setExpanded((open) => !open)}
aria-expanded={expanded}
>
<span className="material-symbols-outlined text-primary text-lg">hub</span>
<span className="font-headline font-black text-[10px] tracking-widest text-on-surface-variant flex-1">
COORDINATOR
</span>
{hasActivity ? (
<span className="text-[9px] font-mono text-secondary tabular-nums">
{coordinatorLines.length}
</span>
) : null}
{isRunning && !planningDone ? (
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" aria-hidden />
) : null}
<span className="material-symbols-outlined text-on-surface-variant">
{expanded ? 'expand_less' : 'expand_more'}
</span>
</button>
{expanded ? (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pb-4">
<LiveOutput
traceLines={coordinatorLines}
tasks={[]}
variant="coordinator"
planningDone={planningDone}
/>
</div>
) : null}
</section>
)
}
5 changes: 3 additions & 2 deletions apps/web/src/components/dashboard/DetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export function DetailsPanel({

return (
<aside
className={`${open ? 'flex flex-1' : 'hidden'} w-full lg:w-[400px] lg:flex-none shrink-0 min-h-0 max-h-full overflow-y-auto overscroll-contain bg-surface-container-high p-6 flex-col gap-8 border-l border-outline-variant/10 relative`}
data-node-details
className={`${open ? 'flex flex-1' : 'hidden'} min-h-0 overflow-y-auto overscroll-contain p-6 flex-col gap-8 relative`}
>
<div>
<h2 className="font-headline font-black text-lg tracking-widest mb-6 text-primary flex items-center gap-2">
Expand Down Expand Up @@ -122,7 +123,7 @@ export function DetailsPanel({
<h2 className="font-headline font-black text-[10px] tracking-widest mb-4 text-on-surface-variant">
LIVE_AGENT_OUTPUT
</h2>
<LiveOutput tasks={tasks} traceLines={traceLines} />
<LiveOutput tasks={tasks} traceLines={traceLines} scopeTask={selected} />
</div>
</aside>
)
Expand Down
63 changes: 49 additions & 14 deletions apps/web/src/components/dashboard/LiveOutput.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import type { ForgeTraceLine, TaskExecutionRecord, TraceLineLevel } from '@oma-forge/shared'
import { filterTraceLinesForTask } from '@oma-forge/shared'

type LiveOutputProps = {
readonly tasks: readonly TaskExecutionRecord[]
readonly traceLines: readonly ForgeTraceLine[]
/** When set, only trace and status lines for this DAG node are shown. */
readonly scopeTask?: TaskExecutionRecord | null
readonly variant?: 'default' | 'coordinator'
readonly planningDone?: boolean
}

const terminalStatuses = new Set(['completed', 'failed', 'skipped', 'blocked'])
Expand All @@ -21,17 +26,46 @@ function formatTracePrefix(line: ForgeTraceLine): string {
return parts.length > 0 ? `[${parts.join(':')}] ` : ''
}

export function LiveOutput({ tasks, traceLines }: LiveOutputProps) {
const finished = tasks.every((task) => terminalStatuses.has(task.status))
const visibleTrace = traceLines.slice(-80)
function systemMessage(
variant: 'default' | 'coordinator',
finished: boolean,
planningDone?: boolean,
): string {
if (variant === 'coordinator') {
if (finished || planningDone) {
return '[SYSTEM] Coordinator planning finished.'
}
return '[SYSTEM] Coordinator planning in progress.'
}
return finished
? '[SYSTEM] Task graph execution finished.'
: '[SYSTEM] Task graph execution in progress.'
}

export function LiveOutput({
tasks,
traceLines,
scopeTask,
variant = 'default',
planningDone,
}: LiveOutputProps) {
const scopedTasks = scopeTask ? [scopeTask] : tasks
const finished =
variant === 'coordinator'
? Boolean(planningDone)
: scopedTasks.every((task) => terminalStatuses.has(task.status))
const filteredTrace = scopeTask
? filterTraceLinesForTask(traceLines, scopeTask)
: traceLines
const visibleTrace = filteredTrace.slice(-80)

return (
<div className="bg-surface-container-lowest p-3 font-mono text-[10px] leading-relaxed space-y-1 max-h-64 overflow-y-auto">
<p className="text-tertiary">
{finished
? '[SYSTEM] Task graph execution finished.'
: '[SYSTEM] Task graph execution in progress.'}
</p>
<div
className={`bg-surface-container-lowest p-3 font-mono text-[10px] leading-relaxed space-y-1 overflow-y-auto ${
variant === 'coordinator' ? 'flex-1 min-h-0 max-h-full' : 'max-h-64'
}`}
>
<p className="text-tertiary">{systemMessage(variant, finished, planningDone)}</p>
{visibleTrace.map((line, index) => (
<p
key={`${line.at}-${index}`}
Expand All @@ -41,14 +75,15 @@ export function LiveOutput({ tasks, traceLines }: LiveOutputProps) {
{line.message}
</p>
))}
{visibleTrace.length === 0
? tasks.map((task) => (
{visibleTrace.length === 0 && variant === 'coordinator' ? (
<p className="text-on-surface-variant">Waiting for coordinator output…</p>
) : null}
{visibleTrace.length === 0 && variant !== 'coordinator'
? scopedTasks.map((task) => (
<p
key={task.id}
className={
task.status === 'failed'
? 'text-error'
: 'text-on-surface-variant'
task.status === 'failed' ? 'text-error' : 'text-on-surface-variant'
}
>
[{task.assignee?.toUpperCase() ?? 'UNASSIGNED'}] {task.title} {'->'}{' '}
Expand Down
Loading
Loading