feat(cli): runTeam DAG dashboard (--dashboard)

Adds `oma run --dashboard` to write a static post-execution DAG HTML to `oma-dashboards/runTeam-<timestamp>.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, `</script>` 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>
This commit is contained in:
Ibrahim Kazimov 2026-04-20 21:22:03 +03:00 committed by GitHub
parent a33622bdf1
commit 647aeff8f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 818 additions and 8 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ dist/
coverage/
*.tgz
.DS_Store
oma-dashboards/

View File

@ -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-<timestamp>.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. |

View File

@ -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<string> {
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<number> {
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<number> {
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)

View File

@ -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<string, { readonly x: number; readonly y: number }>
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<T extends LayoutTaskInput>(taskList: readonly T[]): LayoutTasksResult {
const byId = new Map(taskList.map((task) => [task.id, task]))
const children = new Map<string, string[]>(taskList.map((task) => [task.id, []]))
const indegree = new Map<string, number>()
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<string, number>()
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<number, T[]>()
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<string, { x: number; y: number }>()
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 }
}

View File

@ -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 <script>} tag.
* The HTML tokenizer ends a script on {@code </script>} 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 `<!DOCTYPE html>
<html class="dark" lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>Open Multi Agent</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&amp;family=Inter:wght@400;500;600&amp;display=swap"
rel="stylesheet" />
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap"
rel="stylesheet" />
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"inverse-surface": "#faf8ff",
"secondary-dim": "#ecb200",
"on-primary": "#005762",
"on-tertiary-fixed-variant": "#006827",
"primary-fixed-dim": "#00d4ec",
"tertiary-container": "#5cfd80",
"secondary": "#fdc003",
"primary-dim": "#00d4ec",
"surface-container": "#0f1930",
"on-secondary": "#553e00",
"surface": "#060e20",
"on-surface": "#dee5ff",
"surface-container-highest": "#192540",
"on-secondary-fixed-variant": "#674c00",
"on-tertiary-container": "#005d22",
"secondary-fixed-dim": "#f7ba00",
"surface-variant": "#192540",
"surface-container-low": "#091328",
"secondary-container": "#785900",
"tertiary-fixed-dim": "#4bee74",
"on-primary-fixed-variant": "#005762",
"primary-container": "#00e3fd",
"surface-dim": "#060e20",
"error-container": "#9f0519",
"on-error-container": "#ffa8a3",
"primary-fixed": "#00e3fd",
"tertiary-dim": "#4bee74",
"surface-container-high": "#141f38",
"background": "#060e20",
"surface-bright": "#1f2b49",
"error-dim": "#d7383b",
"on-primary-container": "#004d57",
"outline": "#6d758c",
"error": "#ff716c",
"on-secondary-container": "#fff6ec",
"on-primary-fixed": "#003840",
"inverse-on-surface": "#4d556b",
"secondary-fixed": "#ffca4d",
"tertiary-fixed": "#5cfd80",
"on-tertiary-fixed": "#004819",
"surface-tint": "#81ecff",
"tertiary": "#b8ffbb",
"outline-variant": "#40485d",
"on-error": "#490006",
"on-surface-variant": "#a3aac4",
"surface-container-lowest": "#000000",
"on-tertiary": "#006727",
"primary": "#81ecff",
"on-secondary-fixed": "#443100",
"inverse-primary": "#006976",
"on-background": "#dee5ff"
},
"borderRadius": {
"DEFAULT": "0px",
"lg": "0px",
"xl": "0px",
"full": "9999px"
},
"fontFamily": {
"headline": ["Space Grotesk"],
"body": ["Inter"],
"label": ["Space Grotesk"]
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.grid-pattern {
background-image: radial-gradient(circle, #40485d 1px, transparent 1px);
background-size: 24px 24px;
}
.node-active-glow {
box-shadow: 0 0 15px rgba(129, 236, 255, 0.15);
}
</style>
</head>
<body class="bg-surface text-on-surface font-body selection:bg-primary selection:text-on-primary">
<main class="p-8 min-h-[calc(100vh-64px)] grid-pattern relative overflow-hidden flex flex-col lg:flex-row gap-6">
<div id="viewport" class="flex-1 relative min-h-[600px] overflow-hidden cursor-grab">
<div id="canvas" class="absolute inset-0 origin-top-left">
<svg id="edgesLayer" class="absolute inset-0 w-full h-full pointer-events-none" xmlns="http://www.w3.org/2000/svg"></svg>
<div id="nodesLayer"></div>
</div>
</div>
<aside id="detailsPanel" class="hidden w-full lg:w-[400px] bg-surface-container-high p-6 flex flex-col gap-8 border-l border-outline-variant/10">
<div>
<h2 class="font-headline font-black text-lg tracking-widest mb-6 text-primary flex items-center gap-2">
<span class="material-symbols-outlined" data-icon="info">info</span>
NODE_DETAILS
</h2>
<button id="closePanel" class="absolute top-4 right-4 text-on-surface-variant hover:text-primary">
<span class="material-symbols-outlined">close</span>
</button>
<div class="space-y-6">
<div class="flex flex-col gap-2">
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Goal</label>
<p id="goalText" class="text-xs bg-surface-container p-3 border-b border-outline-variant/20"></p>
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Assigned Agent</label>
<div class="flex items-center gap-4 bg-surface-container p-3">
<div>
<p id="selectedAssignee" class="text-sm font-bold text-on-surface">-</p>
<p id="selectedState" class="text-[10px] font-mono text-secondary">ACTIVE STATE: -</p>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-1">
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Execution Start</label>
<p id="selectedStart" class="text-xs font-mono bg-surface-container p-2 border-b border-outline-variant/20">-</p>
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Execution End</label>
<p id="selectedEnd" class="text-xs font-mono bg-surface-container p-2 border-b border-outline-variant/20 text-on-surface-variant">-</p>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Token Breakdown</label>
<div class="space-y-2 bg-surface-container p-4">
<div class="flex justify-between text-xs font-mono">
<span class="text-on-surface-variant">PROMPT:</span>
<span id="selectedPromptTokens" class="text-on-surface">0</span>
</div>
<div class="flex justify-between text-xs font-mono">
<span class="text-on-surface-variant">COMPLETION:</span>
<span id="selectedCompletionTokens" class="text-on-surface text-secondary">0</span>
</div>
<div class="w-full h-1 bg-surface-variant mt-2">
<div id="selectedTokenRatio" class="bg-primary h-full w-0"></div>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Tool Calls</label>
<p id="selectedToolCalls" class="text-xs font-mono bg-surface-container p-2 border-b border-outline-variant/20">0</p>
</div>
</div>
</div>
<div class="flex-1 flex flex-col min-h-[200px]">
<h2 class="font-headline font-black text-[10px] tracking-widest mb-4 text-on-surface-variant">LIVE_AGENT_OUTPUT</h2>
<div id="liveOutput" class="bg-surface-container-lowest flex-1 p-3 font-mono text-[10px] leading-relaxed overflow-y-auto space-y-1">
</div>
</div>
</aside>
</main>
<div class="fixed left-0 top-0 w-1 h-screen bg-gradient-to-b from-primary via-secondary to-tertiary z-[60] opacity-30"></div>
<script type="application/json" id="oma-data">${dataJson}</script>
<script>
const dataEl = document.getElementById("oma-data");
const payload = JSON.parse(dataEl.textContent);
const panel = document.getElementById("detailsPanel");
const closeBtn = document.getElementById("closePanel");
const canvas = document.getElementById("canvas");
const viewport = document.getElementById("viewport");
const edgesLayer = document.getElementById("edgesLayer");
const nodesLayer = document.getElementById("nodesLayer");
const goalText = document.getElementById("goalText");
const liveOutput = document.getElementById("liveOutput");
const selectedAssignee = document.getElementById("selectedAssignee");
const selectedState = document.getElementById("selectedState");
const selectedStart = document.getElementById("selectedStart");
const selectedToolCalls = document.getElementById("selectedToolCalls");
const selectedEnd = document.getElementById("selectedEnd");
const selectedPromptTokens = document.getElementById("selectedPromptTokens");
const selectedCompletionTokens = document.getElementById("selectedCompletionTokens");
const selectedTokenRatio = document.getElementById("selectedTokenRatio");
const svgNs = "http://www.w3.org/2000/svg";
let scale = 1;
let translate = { x: 0, y: 0 };
let isDragging = false;
let last = { x: 0, y: 0 };
function updateTransform() {
canvas.style.transform = \`
translate(\${translate.x}px, \${translate.y}px)
scale(\${scale})
\`;
}
viewport.addEventListener("wheel", (e) => {
e.preventDefault();
const zoomIntensity = 0.0015;
const delta = -e.deltaY * zoomIntensity;
const newScale = Math.min(Math.max(0.4, scale + delta), 2.5);
const rect = viewport.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const dx = mouseX - translate.x;
const dy = mouseY - translate.y;
translate.x -= dx * (newScale / scale - 1);
translate.y -= dy * (newScale / scale - 1);
scale = newScale;
updateTransform();
});
viewport.addEventListener("mousedown", (e) => {
isDragging = true;
last = { x: e.clientX, y: e.clientY };
viewport.classList.add("cursor-grabbing");
});
window.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const dx = e.clientX - last.x;
const dy = e.clientY - last.y;
translate.x += dx;
translate.y += dy;
last = { x: e.clientX, y: e.clientY };
updateTransform();
});
window.addEventListener("mouseup", () => {
isDragging = false;
viewport.classList.remove("cursor-grabbing");
});
updateTransform();
closeBtn.addEventListener("click", () => {
panel.classList.add("hidden");
});
document.addEventListener("click", (e) => {
const isClickInsidePanel = panel.contains(e.target);
const isNode = e.target.closest(".node");
if (!isClickInsidePanel && !isNode) {
panel.classList.add("hidden");
}
});
const tasks = Array.isArray(payload.tasks) ? payload.tasks : [];
goalText.textContent = payload.goal ?? "";
const statusStyles = {
completed: { border: "border-tertiary", icon: "check_circle", iconColor: "text-tertiary", container: "bg-surface-container-lowest node-active-glow", statusColor: "text-on-surface-variant", chip: "STABLE" },
failed: { border: "border-error", icon: "error", iconColor: "text-error", container: "bg-surface-container-lowest", statusColor: "text-error", chip: "FAILED" },
blocked: { border: "border-outline", icon: "lock", iconColor: "text-outline", container: "bg-surface-container-low opacity-60 grayscale", statusColor: "text-on-surface-variant", chip: "BLOCKED" },
skipped: { border: "border-outline", icon: "skip_next", iconColor: "text-outline", container: "bg-surface-container-low opacity-60", statusColor: "text-on-surface-variant", chip: "SKIPPED" },
in_progress: { border: "border-secondary", icon: "sync", iconColor: "text-secondary", container: "bg-surface-container-low node-active-glow border border-outline-variant/20 shadow-[0_0_20px_rgba(253,192,3,0.1)]", statusColor: "text-secondary", chip: "ACTIVE_STREAM", spin: true },
pending: { border: "border-outline", icon: "hourglass_empty", iconColor: "text-outline", container: "bg-surface-container-low opacity-60 grayscale", statusColor: "text-on-surface-variant", chip: "WAITING" },
};
function durationText(task) {
const ms = task?.metrics?.durationMs ?? 0;
const seconds = Math.max(0, ms / 1000).toFixed(1);
return task.status === "completed" ? "DONE (" + seconds + "s)" : task.status.toUpperCase();
}
function renderLiveOutput(taskList) {
liveOutput.innerHTML = "";
const finished = taskList.every((task) => ["completed", "failed", "skipped", "blocked"].includes(task.status));
const header = document.createElement("p");
header.className = "text-tertiary";
header.textContent = finished ? "[SYSTEM] Task graph execution finished." : "[SYSTEM] Task graph execution in progress.";
liveOutput.appendChild(header);
taskList.forEach((task) => {
const p = document.createElement("p");
p.className = task.status === "completed" ? "text-on-surface-variant" : task.status === "failed" ? "text-error" : "text-on-surface-variant";
p.textContent = "[" + (task.assignee || "UNASSIGNED").toUpperCase() + "] " + task.title + " -> " + task.status.toUpperCase();
liveOutput.appendChild(p);
});
}
function renderDetails(task) {
const metrics = task?.metrics ?? {};
const statusLabel = (statusStyles[task.status] || statusStyles.pending).chip;
const usage = metrics.tokenUsage ?? { input_tokens: 0, output_tokens: 0 };
const inTokens = usage.input_tokens ?? 0;
const outTokens = usage.output_tokens ?? 0;
const total = inTokens + outTokens;
const ratio = total > 0 ? Math.round((inTokens / total) * 100) : 0;
selectedAssignee.textContent = task?.assignee || "UNASSIGNED";
selectedState.textContent = "STATE: " + statusLabel;
selectedStart.textContent = metrics.startMs ? new Date(metrics.startMs).toISOString() : "-";
selectedEnd.textContent = metrics.endMs ? new Date(metrics.endMs).toISOString() : "-";
selectedToolCalls.textContent = (metrics.toolCalls ?? []).length.toString();
selectedPromptTokens.textContent = inTokens.toLocaleString();
selectedCompletionTokens.textContent = outTokens.toLocaleString();
selectedTokenRatio.style.width = ratio + "%";
}
function makeEdgePath(x1, y1, x2, y2) {
return "M " + x1 + " " + y1 + " C " + (x1 + 42) + " " + y1 + ", " + (x2 - 42) + " " + y2 + ", " + x2 + " " + y2;
}
function renderDag(taskList) {
const rawLayout = payload.layout ?? {};
const positions = new Map(Object.entries(rawLayout.positions ?? {}));
const width = Number(rawLayout.width ?? 1600);
const height = Number(rawLayout.height ?? 700);
const nodeW = Number(rawLayout.nodeW ?? 256);
const nodeH = Number(rawLayout.nodeH ?? 142);
canvas.style.width = width + "px";
canvas.style.height = height + "px";
edgesLayer.setAttribute("viewBox", "0 0 " + width + " " + height);
edgesLayer.innerHTML = "";
const defs = document.createElementNS(svgNs, "defs");
const marker = document.createElementNS(svgNs, "marker");
marker.setAttribute("id", "arrow");
marker.setAttribute("markerWidth", "8");
marker.setAttribute("markerHeight", "8");
marker.setAttribute("refX", "7");
marker.setAttribute("refY", "4");
marker.setAttribute("orient", "auto");
const markerPath = document.createElementNS(svgNs, "path");
markerPath.setAttribute("d", "M0,0 L8,4 L0,8 z");
markerPath.setAttribute("fill", "#40485d");
marker.appendChild(markerPath);
defs.appendChild(marker);
edgesLayer.appendChild(defs);
taskList.forEach((task) => {
const to = positions.get(task.id);
(task.dependsOn || []).forEach((depId) => {
const from = positions.get(depId);
if (!from || !to) return;
const edge = document.createElementNS(svgNs, "path");
edge.setAttribute("d", makeEdgePath(from.x + nodeW, from.y + nodeH / 2, to.x, to.y + nodeH / 2));
edge.setAttribute("fill", "none");
edge.setAttribute("stroke", "#40485d");
edge.setAttribute("stroke-width", "2");
edge.setAttribute("marker-end", "url(#arrow)");
edgesLayer.appendChild(edge);
});
});
nodesLayer.innerHTML = "";
taskList.forEach((task, idx) => {
const pos = positions.get(task.id);
const status = statusStyles[task.status] || statusStyles.pending;
const nodeId = "#NODE_" + String(idx + 1).padStart(3, "0");
const chips = [task.assignee ? task.assignee.toUpperCase() : "UNASSIGNED", status.chip];
const node = document.createElement("div");
node.className = "node absolute w-64 border-l-2 p-4 cursor-pointer " + status.border + " " + status.container;
node.style.left = pos.x + "px";
node.style.top = pos.y + "px";
const rowTop = document.createElement("div");
rowTop.className = "flex justify-between items-start mb-4";
const nodeIdSpan = document.createElement("span");
nodeIdSpan.className = "text-[10px] font-mono " + status.iconColor;
nodeIdSpan.textContent = nodeId;
const iconSpan = document.createElement("span");
iconSpan.className = "material-symbols-outlined " + status.iconColor + " text-lg " + (status.spin ? "animate-spin" : "");
iconSpan.textContent = status.icon;
iconSpan.setAttribute("data-icon", status.icon);
rowTop.appendChild(nodeIdSpan);
rowTop.appendChild(iconSpan);
const titleEl = document.createElement("h3");
titleEl.className = "font-headline font-bold text-sm tracking-tight mb-1";
titleEl.textContent = task.title;
const statusLine = document.createElement("p");
statusLine.className = "text-xs " + status.statusColor + " mb-4";
statusLine.textContent = "STATUS: " + durationText(task);
const chipRow = document.createElement("div");
chipRow.className = "flex gap-2";
chips.forEach((chip) => {
const chipEl = document.createElement("span");
chipEl.className = "px-2 py-0.5 bg-surface-variant text-[9px] font-mono text-on-surface-variant";
chipEl.textContent = chip;
chipRow.appendChild(chipEl);
});
node.appendChild(rowTop);
node.appendChild(titleEl);
node.appendChild(statusLine);
node.appendChild(chipRow);
node.addEventListener("click", () => {
renderDetails(task);
panel.classList.remove("hidden");
});
nodesLayer.appendChild(node);
});
renderLiveOutput(taskList);
}
renderDag(tasks);
</script>
</body>
</html>`
}

View File

@ -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,

View File

@ -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<string, TaskExecutionMetrics>
}
/**
@ -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<string, AgentRunResult>()
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<string, TaskExecutionMetrics>()
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<RunOptions> | 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<string, TaskExecutionMetrics>(),
}
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<string, AgentRunResult>,
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,
}

View File

@ -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<string, AgentRunResult>
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

View File

@ -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')
})
})

View File

@ -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 = '"</script><img src=x onerror=alert(1)>"'
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('</script>', contentStart)
expect(end).toBeGreaterThan(contentStart)
const jsonSlice = html.slice(contentStart, end)
expect(jsonSlice.toLowerCase()).not.toContain('</script')
const parsed = JSON.parse(jsonSlice) as { tasks: { title: string }[] }
expect(parsed.tasks[0]!.title).toBe(malicious)
const beforeData = html.slice(0, start)
expect(beforeData).not.toContain(malicious)
expect(beforeData.toLowerCase()).not.toMatch(/\sonerror\s*=/)
})
it('keeps task description text in JSON payload', () => {
const description = 'danger: </script><svg onload=alert(1)>'
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('</script>', 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 </script><img src=x onerror=alert(1)>'
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('</script>', contentStart)
const parsed = JSON.parse(html.slice(contentStart, end)) as {
tasks: Array<{ result?: string }>
}
expect(parsed.tasks[0]!.result).toBe(result)
})
})