From 647aeff8f462d6038b27b0dc529760cfee9a14e2 Mon Sep 17 00:00:00 2001 From: Ibrahim Kazimov <74775400+ibrahimkzmv@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:22:03 +0300 Subject: [PATCH] feat(cli): runTeam DAG dashboard (--dashboard) Adds `oma run --dashboard` to write a static post-execution DAG HTML to `oma-dashboards/runTeam-.html`. - Pure `renderTeamRunDashboard(result: TeamRunResult)` in `src/dashboard/`, no FS/network I/O in the library - `TeamRunResult` gains `goal` + `tasks: TaskExecutionRecord[]` (with `TaskExecutionMetrics`) - `layoutTasks()` extracted as pure function with cycle detection - XSS mitigations: `application/json` payload, `` escape, `textContent`-only node rendering - CLI awaits write before exit (no race with `process.exit`) Closes #4 Co-authored-by: Ibrahim Kazimov <74775400+ibrahimkzmv@users.noreply.github.com> --- .gitignore | 1 + docs/cli.md | 4 + src/cli/oma.ts | 27 +- src/dashboard/layout-tasks.ts | 98 +++++ src/dashboard/render-team-run-dashboard.ts | 460 +++++++++++++++++++++ src/index.ts | 6 + src/orchestrator/orchestrator.ts | 68 ++- src/types.ts | 24 ++ tests/dashboard-layout-tasks.test.ts | 46 +++ tests/dashboard-render.test.ts | 92 +++++ 10 files changed, 818 insertions(+), 8 deletions(-) create mode 100644 src/dashboard/layout-tasks.ts create mode 100644 src/dashboard/render-team-run-dashboard.ts create mode 100644 tests/dashboard-layout-tasks.test.ts create mode 100644 tests/dashboard-render.test.ts diff --git a/.gitignore b/.gitignore index 9dc13d6..00d46eb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ coverage/ *.tgz .DS_Store +oma-dashboards/ diff --git a/docs/cli.md b/docs/cli.md index 38a6522..687c94f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -30,6 +30,10 @@ Set the usual provider API keys in the environment (see [README](../README.md#qu Runs **`OpenMultiAgent.runTeam(team, goal)`**: coordinator decomposition, task queue, optional synthesis. +The **`oma` CLI** writes a static post-execution DAG dashboard HTML to `oma-dashboards/runTeam-.html` under the current working directory after each `runTeam` invocation (the library does not write files itself; if you want this outside the CLI, call `renderTeamRunDashboard()` in application code — see `src/dashboard/render-team-run-dashboard.ts`). + +The dashboard page loads **Tailwind CSS** (Play CDN), **Google Fonts** (Space Grotesk, Inter, Material Symbols), and **Material Symbols** from the network at view time. Opening the HTML file requires an **online** environment unless you host or inline those assets yourself (a future improvement). + | Argument | Required | Description | |----------|----------|-------------| | `--goal` | Yes | Natural-language goal passed to the team run. | diff --git a/src/cli/oma.ts b/src/cli/oma.ts index 859d9d5..d73760f 100644 --- a/src/cli/oma.ts +++ b/src/cli/oma.ts @@ -10,11 +10,13 @@ * 3 — unexpected runtime error (including LLM errors) */ +import { mkdir, writeFile } from 'node:fs/promises' import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { OpenMultiAgent } from '../orchestrator/orchestrator.js' +import { renderTeamRunDashboard } from '../dashboard/render-team-run-dashboard.js' import type { SupportedProvider } from '../llm/adapter.js' import type { AgentRunResult, CoordinatorConfig, OrchestratorConfig, TeamConfig, TeamRunResult } from '../types.js' @@ -224,6 +226,8 @@ export function serializeTeamRunResult(result: TeamRunResult, opts: CliJsonOptio } return { success: result.success, + goal: result.goal, + tasks: result.tasks, totalTokenUsage: result.totalTokenUsage, agentResults, } @@ -246,6 +250,7 @@ function help(): string { 'Flags:', ' --pretty Pretty-print JSON to stdout', ' --include-messages Include full LLM message arrays in run output (large)', + ' --dashboard Write team-run DAG HTML dashboard to oma-dashboards/', '', 'team.json may be a TeamConfig object, or { "team": TeamConfig, "orchestrator": { ... } }.', 'tasks.json: { "team": TeamConfig, "tasks": [ ... ], "orchestrator"?: { ... } }.', @@ -316,11 +321,21 @@ function mergeOrchestrator(base: OrchestratorConfig, ...partials: OrchestratorCo return o } +async function writeRunTeamDashboardFile(html: string): Promise { + const directory = join(process.cwd(), 'oma-dashboards') + await mkdir(directory, { recursive: true }) + const stamp = new Date().toISOString().replaceAll(':', '-').replace('.', '-') + const filePath = join(directory, `runTeam-${stamp}.html`) + await writeFile(filePath, html, 'utf8') + return filePath +} + async function main(): Promise { const argv = parseArgs(process.argv) const cmd = argv._[0] const pretty = argv.flags.has('pretty') const includeMessages = argv.flags.has('include-messages') + const dashboard = argv.flags.has('dashboard') if (cmd === undefined || cmd === 'help' || cmd === '-h' || cmd === '--help') { process.stdout.write(`${help()}\n`) @@ -366,6 +381,16 @@ async function main(): Promise { coordinator = asCoordinatorPartial(readJson(coordPath), 'coordinator file') } const result = await orchestrator.runTeam(team, goal, coordinator ? { coordinator } : undefined) + if (dashboard) { + const html = renderTeamRunDashboard(result) + try { + await writeRunTeamDashboardFile(html) + } catch (err) { + process.stderr.write( + `oma: failed to write runTeam dashboard: ${err instanceof Error ? err.message : String(err)}\n`, + ) + } + } await orchestrator.shutdown() const payload = { command: 'run' as const, ...serializeTeamRunResult(result, jsonOpts) } printJson(payload, pretty) diff --git a/src/dashboard/layout-tasks.ts b/src/dashboard/layout-tasks.ts new file mode 100644 index 0000000..f29286e --- /dev/null +++ b/src/dashboard/layout-tasks.ts @@ -0,0 +1,98 @@ +/** + * Pure DAG layout for the team-run dashboard (mirrors the browser algorithm). + */ + +export interface LayoutTaskInput { + readonly id: string + readonly dependsOn?: readonly string[] +} + +export interface LayoutTasksResult { + readonly positions: ReadonlyMap + readonly width: number + readonly height: number + readonly nodeW: number + readonly nodeH: number +} + +/** + * Assigns each task to a column by longest path from roots (topological level), + * then stacks rows within each column. Used by the dashboard canvas sizing. + */ +export function layoutTasks(taskList: readonly T[]): LayoutTasksResult { + const byId = new Map(taskList.map((task) => [task.id, task])) + const children = new Map(taskList.map((task) => [task.id, []])) + const indegree = new Map() + + for (const task of taskList) { + const deps = (task.dependsOn ?? []).filter((dep) => byId.has(dep)) + indegree.set(task.id, deps.length) + for (const depId of deps) { + children.get(depId)!.push(task.id) + } + } + + const levels = new Map() + const queue: string[] = [] + let processed = 0 + for (const task of taskList) { + if ((indegree.get(task.id) ?? 0) === 0) { + levels.set(task.id, 0) + queue.push(task.id) + } + } + + while (queue.length > 0) { + const currentId = queue.shift()! + processed += 1 + const baseLevel = levels.get(currentId) ?? 0 + for (const childId of children.get(currentId) ?? []) { + const nextLevel = Math.max(levels.get(childId) ?? 0, baseLevel + 1) + levels.set(childId, nextLevel) + indegree.set(childId, (indegree.get(childId) ?? 1) - 1) + if ((indegree.get(childId) ?? 0) === 0) { + queue.push(childId) + } + } + } + + if (processed !== taskList.length) { + throw new Error('Task dependency graph contains a cycle') + } + + for (const task of taskList) { + if (!levels.has(task.id)) levels.set(task.id, 0) + } + + const cols = new Map() + for (const task of taskList) { + const level = levels.get(task.id) ?? 0 + if (!cols.has(level)) cols.set(level, []) + cols.get(level)!.push(task) + } + + const sortedLevels = Array.from(cols.keys()).sort((a, b) => a - b) + const nodeW = 256 + const nodeH = 142 + const colGap = 96 + const rowGap = 72 + const padX = 120 + const padY = 100 + const positions = new Map() + let maxRows = 1 + for (const level of sortedLevels) maxRows = Math.max(maxRows, cols.get(level)!.length) + + for (const level of sortedLevels) { + const colTasks = cols.get(level)! + colTasks.forEach((task, idx) => { + positions.set(task.id, { + x: padX + level * (nodeW + colGap), + y: padY + idx * (nodeH + rowGap), + }) + }) + } + + const width = Math.max(1600, padX * 2 + sortedLevels.length * (nodeW + colGap)) + const height = Math.max(700, padY * 2 + maxRows * (nodeH + rowGap)) + return { positions, width, height, nodeW, nodeH } +} diff --git a/src/dashboard/render-team-run-dashboard.ts b/src/dashboard/render-team-run-dashboard.ts new file mode 100644 index 0000000..4200c3b --- /dev/null +++ b/src/dashboard/render-team-run-dashboard.ts @@ -0,0 +1,460 @@ +/** + * Pure HTML renderer for the post-run team task DAG dashboard (no filesystem or network I/O). + */ + +import type { TeamRunResult } from '../types.js' +import { layoutTasks } from './layout-tasks.js' + +/** + * Escape serialized JSON so it can be embedded in HTML without closing a {@code } even for {@code type="application/json"}. + */ +export function escapeJsonForHtmlScript(json: string): string { + return json.replace(/<\/script/gi, '<\\/script') +} + +export function renderTeamRunDashboard(result: TeamRunResult): string { + const generatedAt = new Date().toISOString() + const tasks = result.tasks ?? [] + const layout = layoutTasks(tasks) + const serializedPositions = Object.fromEntries(layout.positions) + const payload = { + generatedAt, + goal: result.goal ?? '', + tasks, + layout: { + positions: serializedPositions, + width: layout.width, + height: layout.height, + nodeW: layout.nodeW, + nodeH: layout.nodeH, + }, + } + const dataJson = escapeJsonForHtmlScript(JSON.stringify(payload)) + + return ` + + + + + Open Multi Agent + + + + + + + +
+
+
+ +
+
+
+ +
+
+ + + +` +} diff --git a/src/index.ts b/src/index.ts index 9a76e41..74b8da2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,8 @@ export { OpenMultiAgent, executeWithRetry, computeRetryDelay } from './orchestra export { Scheduler } from './orchestrator/scheduler.js' export type { SchedulingStrategy } from './orchestrator/scheduler.js' +export { renderTeamRunDashboard } from './dashboard/render-team-run-dashboard.js' + // --------------------------------------------------------------------------- // Agent layer // --------------------------------------------------------------------------- @@ -164,6 +166,10 @@ export type { TeamConfig, TeamRunResult, + // Dashboard (static HTML) + TaskExecutionMetrics, + TaskExecutionRecord, + // Task Task, TaskStatus, diff --git a/src/orchestrator/orchestrator.ts b/src/orchestrator/orchestrator.ts index 5050480..1ba6e91 100644 --- a/src/orchestrator/orchestrator.ts +++ b/src/orchestrator/orchestrator.ts @@ -48,6 +48,8 @@ import type { OrchestratorConfig, OrchestratorEvent, Task, + TaskExecutionMetrics, + TaskExecutionRecord, TaskStatus, TeamConfig, TeamInfo, @@ -414,6 +416,7 @@ interface RunContext { readonly maxTokenBudget?: number budgetExceededTriggered: boolean budgetExceededReason?: string + readonly taskMetrics: Map } /** @@ -616,7 +619,7 @@ async function executeQueue( team: buildTaskAgentTeamInfo(ctx, task.id, traceBase, 0, [assignee]), } - const taskStartMs = config.onTrace ? Date.now() : 0 + const taskStartMs = Date.now() let retryCount = 0 const result = await executeWithRetry( @@ -633,9 +636,10 @@ async function executeQueue( }, ) + const taskEndMs = Date.now() + // Emit task trace if (config.onTrace) { - const taskEndMs = Date.now() emitTrace(config.onTrace, { type: 'task', runId: ctx.runId ?? '', @@ -651,6 +655,14 @@ async function executeQueue( } ctx.agentResults.set(`${assignee}:${task.id}`, result) + + ctx.taskMetrics.set(task.id, { + startMs: taskStartMs, + endMs: taskEndMs, + durationMs: Math.max(0, taskEndMs - taskStartMs), + tokenUsage: result.tokenUsage, + toolCalls: result.toolCalls, + }) ctx.cumulativeUsage = addUsage(ctx.cumulativeUsage, result.tokenUsage) const totalTokens = ctx.cumulativeUsage.input_tokens + ctx.cumulativeUsage.output_tokens if ( @@ -1008,7 +1020,9 @@ export class OpenMultiAgent { ? { ...(traceFields ?? {}), ...(abortFields ?? {}) } : undefined + const scStartMs = Date.now() const result = await agent.run(goal, runOptions) + const scEndMs = Date.now() if (result.budgetExceeded) { this.config.onProgress?.({ @@ -1030,7 +1044,23 @@ export class OpenMultiAgent { const agentResults = new Map() agentResults.set(bestAgent.name, result) - return this.buildTeamRunResult(agentResults) + + + const tasks: readonly TaskExecutionRecord[] = [{ + id: 'short-circuit', + title: `Short-circuit: ${bestAgent.name}`, + assignee: bestAgent.name, + status: result.success ? 'completed' : 'failed', + dependsOn: [], + metrics: { + startMs: scStartMs, + endMs: scEndMs, + durationMs: Math.max(0, scEndMs - scStartMs), + tokenUsage: result.tokenUsage, + toolCalls: result.toolCalls, + }, + }] + return this.buildTeamRunResult(agentResults, goal, tasks) } // ------------------------------------------------------------------ @@ -1085,7 +1115,7 @@ export class OpenMultiAgent { maxTokenBudget, ), }) - return this.buildTeamRunResult(agentResults) + return this.buildTeamRunResult(agentResults, goal, []) } // ------------------------------------------------------------------ @@ -1095,6 +1125,7 @@ export class OpenMultiAgent { const queue = new TaskQueue() const scheduler = new Scheduler('dependency-first') + const taskMetrics = new Map() if (taskSpecs && taskSpecs.length > 0) { // Map title-based dependsOn references to real task IDs so we can @@ -1134,10 +1165,19 @@ export class OpenMultiAgent { maxTokenBudget, budgetExceededTriggered: false, budgetExceededReason: undefined, + taskMetrics, } await executeQueue(queue, ctx) cumulativeUsage = ctx.cumulativeUsage + const taskRecords: readonly TaskExecutionRecord[] = queue.list().map((task) => ({ + id: task.id, + title: task.title, + assignee: task.assignee, + status: task.status, + dependsOn: task.dependsOn ?? [], + metrics: taskMetrics.get(task.id), + })) // ------------------------------------------------------------------ // Step 5: Coordinator synthesises final result @@ -1146,7 +1186,7 @@ export class OpenMultiAgent { maxTokenBudget !== undefined && cumulativeUsage.input_tokens + cumulativeUsage.output_tokens > maxTokenBudget ) { - return this.buildTeamRunResult(agentResults) + return this.buildTeamRunResult(agentResults, goal, taskRecords) } const synthesisPrompt = await this.buildSynthesisPrompt(goal, queue.list(), team) const synthTraceOptions: Partial | undefined = this.config.onTrace @@ -1180,7 +1220,7 @@ export class OpenMultiAgent { // Only actual user tasks (non-coordinator keys) are counted in // buildTeamRunResult, so we do not increment completedTaskCount here. - return this.buildTeamRunResult(agentResults) + return this.buildTeamRunResult(agentResults, goal, taskRecords) } // ------------------------------------------------------------------------- @@ -1246,11 +1286,21 @@ export class OpenMultiAgent { maxTokenBudget: this.config.maxTokenBudget, budgetExceededTriggered: false, budgetExceededReason: undefined, + taskMetrics: new Map(), } await executeQueue(queue, ctx) - return this.buildTeamRunResult(agentResults) + const taskRecords: readonly TaskExecutionRecord[] = queue.list().map((task) => ({ + id: task.id, + title: task.title, + assignee: task.assignee, + status: task.status, + dependsOn: task.dependsOn ?? [], + metrics: ctx.taskMetrics.get(task.id), + })) + + return this.buildTeamRunResult(agentResults, undefined, taskRecords) } // ------------------------------------------------------------------------- @@ -1529,6 +1579,8 @@ export class OpenMultiAgent { */ private buildTeamRunResult( agentResults: Map, + goal?: string, + tasks?: readonly TaskExecutionRecord[], ): TeamRunResult { let totalUsage: TokenUsage = ZERO_USAGE let overallSuccess = true @@ -1566,6 +1618,8 @@ export class OpenMultiAgent { return { success: overallSuccess, + goal, + tasks, agentResults: collapsed, totalTokenUsage: totalUsage, } diff --git a/src/types.ts b/src/types.ts index d5db8e0..fa60ca0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -441,6 +441,8 @@ export interface TeamConfig { /** Aggregated result for a full team run. */ export interface TeamRunResult { readonly success: boolean + readonly goal?: string + readonly tasks?: readonly TaskExecutionRecord[] /** Keyed by agent name. */ readonly agentResults: Map readonly totalTokenUsage: TokenUsage @@ -453,6 +455,28 @@ export interface TeamRunResult { /** Valid states for a {@link Task}. */ export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked' | 'skipped' +/** + * Metrics shown in the team-run dashboard detail panel for a single task. + * Mirrors execution data collected during orchestration. + */ +export interface TaskExecutionMetrics { + readonly startMs: number + readonly endMs: number + readonly durationMs: number + readonly tokenUsage: TokenUsage + readonly toolCalls: AgentRunResult['toolCalls'] +} + +/** Serializable task snapshot embedded in the static HTML dashboard. */ +export interface TaskExecutionRecord { + readonly id: string + readonly title: string + readonly assignee?: string + readonly status: TaskStatus + readonly dependsOn: readonly string[] + readonly metrics?: TaskExecutionMetrics +} + /** A discrete unit of work tracked by the orchestrator. */ export interface Task { readonly id: string diff --git a/tests/dashboard-layout-tasks.test.ts b/tests/dashboard-layout-tasks.test.ts new file mode 100644 index 0000000..3c2205b --- /dev/null +++ b/tests/dashboard-layout-tasks.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { layoutTasks } from '../src/dashboard/layout-tasks.js' + +describe('layoutTasks', () => { + it('assigns increasing columns along a dependency chain (topological levels)', () => { + const tasks = [ + { id: 'a', dependsOn: [] as const }, + { id: 'b', dependsOn: ['a'] as const }, + { id: 'c', dependsOn: ['b'] as const }, + ] + const { positions } = layoutTasks(tasks) + expect(positions.get('a')!.x).toBeLessThan(positions.get('b')!.x) + expect(positions.get('b')!.x).toBeLessThan(positions.get('c')!.x) + }) + + it('places a merge node after all of its dependencies (diamond)', () => { + const tasks = [ + { id: 'root', dependsOn: [] as const }, + { id: 'left', dependsOn: ['root'] as const }, + { id: 'right', dependsOn: ['root'] as const }, + { id: 'merge', dependsOn: ['left', 'right'] as const }, + ] + const { positions } = layoutTasks(tasks) + const mx = positions.get('merge')!.x + expect(mx).toBeGreaterThan(positions.get('left')!.x) + expect(mx).toBeGreaterThan(positions.get('right')!.x) + }) + + it('orders independent roots in the same column with distinct rows', () => { + const tasks = [ + { id: 'a', dependsOn: [] as const }, + { id: 'b', dependsOn: [] as const }, + ] + const { positions } = layoutTasks(tasks) + expect(positions.get('a')!.x).toBe(positions.get('b')!.x) + expect(positions.get('a')!.y).not.toBe(positions.get('b')!.y) + }) + + it('throws when task dependencies contain a cycle', () => { + const tasks = [ + { id: 'a', dependsOn: ['b'] as const }, + { id: 'b', dependsOn: ['a'] as const }, + ] + expect(() => layoutTasks(tasks)).toThrow('Task dependency graph contains a cycle') + }) +}) diff --git a/tests/dashboard-render.test.ts b/tests/dashboard-render.test.ts new file mode 100644 index 0000000..764b893 --- /dev/null +++ b/tests/dashboard-render.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { renderTeamRunDashboard } from '../src/dashboard/render-team-run-dashboard.js' + +describe('renderTeamRunDashboard', () => { + it('does not embed unescaped script terminators in the JSON payload and keeps XSS payloads out of HTML markup', () => { + const malicious = '""' + const html = renderTeamRunDashboard({ + success: true, + goal: 'safe-goal', + tasks: [ + { + id: 't1', + title: malicious, + status: 'pending', + dependsOn: [], + }, + ], + agentResults: new Map(), + totalTokenUsage: { input_tokens: 0, output_tokens: 0 }, + }) + + const dataOpen = 'id="oma-data">' + const start = html.indexOf(dataOpen) + expect(start).toBeGreaterThan(-1) + const contentStart = start + dataOpen.length + const end = html.indexOf('', contentStart) + expect(end).toBeGreaterThan(contentStart) + const jsonSlice = html.slice(contentStart, end) + expect(jsonSlice.toLowerCase()).not.toContain(' { + const description = 'danger: ' + const html = renderTeamRunDashboard({ + success: true, + goal: 'safe-goal', + tasks: [ + { + id: 't1', + title: 'task', + description, + status: 'pending', + dependsOn: [], + } as { id: string; title: string; description: string; status: 'pending'; dependsOn: string[] }, + ], + agentResults: new Map(), + totalTokenUsage: { input_tokens: 0, output_tokens: 0 }, + }) + + const start = html.indexOf('id="oma-data">') + const contentStart = start + 'id="oma-data">'.length + const end = html.indexOf('', contentStart) + const parsed = JSON.parse(html.slice(contentStart, end)) as { + tasks: Array<{ description?: string }> + } + expect(parsed.tasks[0]!.description).toBe(description) + }) + + it('keeps task result text in JSON payload', () => { + const result = 'final output ' + const html = renderTeamRunDashboard({ + success: true, + goal: 'safe-goal', + tasks: [ + { + id: 't1', + title: 'task', + result, + status: 'completed', + dependsOn: [], + } as { id: string; title: string; result: string; status: 'completed'; dependsOn: string[] }, + ], + agentResults: new Map(), + totalTokenUsage: { input_tokens: 0, output_tokens: 0 }, + }) + + const start = html.indexOf('id="oma-data">') + const contentStart = start + 'id="oma-data">'.length + const end = html.indexOf('', contentStart) + const parsed = JSON.parse(html.slice(contentStart, end)) as { + tasks: Array<{ result?: string }> + } + expect(parsed.tasks[0]!.result).toBe(result) + }) +})