From 2f73b389f97b8953ca1b3476fa069ffef3e9a648 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 18:05:55 +0000 Subject: [PATCH] Add workflow swimlane graph prototype Prototype for visualizing dynamic agent workflows (subagent fan-out / parallel lanes / fan-in) that the linear timeline flattens away. - public/workflow-graph.js: isomorphic buildWorkflowGraph() + renderWorkflowSVG(). Infers spawn edges from Agent/Task tool_use calls matched to inferred subagent sessions within a 60s window (mirrors server/store.js inferParentSession), plus sequence + fan-in edges. - prototype/gen-mockup.js: drives it with a realistic synthetic session and asserts the inference recovers the intended graph; emits SVG + HTML. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013SyRHEvmrA2SSnTvbq5Qj8 --- prototype/gen-mockup.js | 92 ++++++++++ prototype/workflow-swimlane.html | 4 + prototype/workflow-swimlane.svg | 1 + public/workflow-graph.js | 286 +++++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+) create mode 100644 prototype/gen-mockup.js create mode 100644 prototype/workflow-swimlane.html create mode 100644 prototype/workflow-swimlane.svg create mode 100644 public/workflow-graph.js diff --git a/prototype/gen-mockup.js b/prototype/gen-mockup.js new file mode 100644 index 0000000..2d5b8ae --- /dev/null +++ b/prototype/gen-mockup.js @@ -0,0 +1,92 @@ +'use strict'; +// Drives public/workflow-graph.js with a realistic synthetic Claude Code session +// (orchestrator spawns two parallel subagents, then fans in) and writes a static +// SVG + an HTML wrapper. No browser / deps needed. + +const fs = require('fs'); +const path = require('path'); +const { buildWorkflowGraph, renderWorkflowSVG } = require('../public/workflow-graph.js'); + +const T0 = Date.parse('2026-06-17T10:00:00Z'); +const s = ms => T0 + ms; + +// Helper: an assistant message carrying tool_use blocks (so extractSpawnCalls works). +const asst = (...blocks) => ({ role: 'assistant', content: blocks }); +const agentCall = (id, description) => ({ type: 'tool_use', id, name: 'Agent', input: { description } }); + +const entries = [ + // ── orchestrator (explicit session) ── + { + id: 'main-1', sessionId: 'sess-main', receivedAt: s(0), elapsed: 2600, + agent: 'claude', displayNum: 1, title: 'Refactor auth + add tests', + toolCalls: { Read: 1, Grep: 2 }, + req: { messages: [ asst({ type: 'tool_use', id: 't1', name: 'Read', input: {} }) ] }, + }, + { + id: 'main-2', sessionId: 'sess-main', receivedAt: s(3200), elapsed: 800, + agent: 'claude', displayNum: 2, + toolCalls: { Agent: 2 }, + req: { messages: [ asst( + agentCall('a1', 'explore auth module structure'), + agentCall('a2', 'survey existing test coverage'), + ) ] }, + }, + { + id: 'main-3', sessionId: 'sess-main', receivedAt: s(8400), elapsed: 4200, + agent: 'claude', displayNum: 3, + toolCalls: { Edit: 2, Write: 1 }, + req: { messages: [ asst({ type: 'tool_use', id: 't9', name: 'Edit', input: {} }) ] }, + }, + { + id: 'main-4', sessionId: 'sess-main', receivedAt: s(13200), elapsed: 5100, + agent: 'claude', displayNum: 4, + toolCalls: { Bash: 1 }, + req: { messages: [ asst({ type: 'tool_use', id: 't12', name: 'Bash', input: {} }) ] }, + }, + + // ── subagent A: explore auth (inferred session) ── + { + id: 'subA-1', sessionId: 'sess-subA', receivedAt: s(4200), elapsed: 1500, + agent: 'claude', displayNum: 1, isSubagent: true, sessionInferred: true, + title: 'explore auth', toolCalls: { Glob: 1, Read: 2 }, + req: { messages: [] }, + }, + { + id: 'subA-2', sessionId: 'sess-subA', receivedAt: s(6000), elapsed: 1400, + agent: 'claude', displayNum: 2, isSubagent: true, sessionInferred: true, + toolCalls: { Grep: 3 }, req: { messages: [] }, + }, + + // ── subagent B: survey tests (inferred session, runs in parallel with A) ── + { + id: 'subB-1', sessionId: 'sess-subB', receivedAt: s(4500), elapsed: 2900, + agent: 'claude', displayNum: 1, isSubagent: true, sessionInferred: true, + title: 'survey tests', toolCalls: { Bash: 1, Read: 1 }, toolFail: true, + req: { messages: [] }, + }, +]; + +const graph = buildWorkflowGraph(entries); + +// ── sanity: did inference recover the intended spawn structure? ── +const spawns = graph.edges.filter(e => e.type === 'spawn'); +const fanins = graph.edges.filter(e => e.type === 'fanin'); +console.log('lanes :', graph.lanes.map(l => `${l.sessionId}(${l.kind})`).join(', ')); +console.log('spawn edges:', spawns.map(e => `${e.from}→${e.to} [${e.label}]`).join(' ')); +console.log('fanin edges:', fanins.map(e => `${e.from}→${e.to}`).join(' ')); +const ok = spawns.length === 2 + && spawns.every(e => e.from === 'main-2') + && spawns.some(e => e.to === 'subA-1') && spawns.some(e => e.to === 'subB-1') + && fanins.length === 2 && fanins.every(e => e.to === 'main-3'); +console.log(ok ? '✓ spawn/fan-in inference reproduced the intended graph' : '✗ inference MISMATCH'); + +const svg = renderWorkflowSVG(graph, { pxPerSec: 40 }); +const outDir = __dirname; +fs.writeFileSync(path.join(outDir, 'workflow-swimlane.svg'), svg); +fs.writeFileSync(path.join(outDir, 'workflow-swimlane.html'), +`ccxray · Workflow Swimlane prototype + +${svg} +`); +console.log('wrote', path.join(outDir, 'workflow-swimlane.svg')); +if (!ok) process.exit(1); diff --git a/prototype/workflow-swimlane.html b/prototype/workflow-swimlane.html new file mode 100644 index 0000000..5a6bf33 --- /dev/null +++ b/prototype/workflow-swimlane.html @@ -0,0 +1,4 @@ +ccxray · Workflow Swimlane prototype + +Workflow · Swimlane Floworchestrator + 2 subagent lane(s) · X = time · → spawn · ⇠ fan-inorchestratormain · 4 turn(s)explore authsubagent · 2 turn(s)survey testssubagent · 1 turn(s)0s5s10s15sspawn: explore auth module structurspawn: survey existing test coverag#1 claudeRead Grep×22.6s#2 claude·0.8s⑂2#3 claudeEdit×2 Write4.2s#4 claudeBash5.1s#1 claudeGlob Read×21.5s#2 claudeGrep×31.4s#1 claudeBash Read2.9s + \ No newline at end of file diff --git a/prototype/workflow-swimlane.svg b/prototype/workflow-swimlane.svg new file mode 100644 index 0000000..c9aa2ac --- /dev/null +++ b/prototype/workflow-swimlane.svg @@ -0,0 +1 @@ +Workflow · Swimlane Floworchestrator + 2 subagent lane(s) · X = time · → spawn · ⇠ fan-inorchestratormain · 4 turn(s)explore authsubagent · 2 turn(s)survey testssubagent · 1 turn(s)0s5s10s15sspawn: explore auth module structurspawn: survey existing test coverag#1 claudeRead Grep×22.6s#2 claude·0.8s⑂2#3 claudeEdit×2 Write4.2s#4 claudeBash5.1s#1 claudeGlob Read×21.5s#2 claudeGrep×31.4s#1 claudeBash Read2.9s \ No newline at end of file diff --git a/public/workflow-graph.js b/public/workflow-graph.js new file mode 100644 index 0000000..1814381 --- /dev/null +++ b/public/workflow-graph.js @@ -0,0 +1,286 @@ +// ── Workflow Graph (prototype) ─────────────────────────────────────────────── +// Turns a flat list of turn-entries into a swimlane workflow graph that exposes +// the structure a linear timeline flattens away: subagent spawn (fan-out), +// parallel lanes, and fan-in back to the orchestrator. +// +// Isomorphic: runs in the browser (attaches to window) and in Node (module.exports) +// so it can be unit-driven / rendered to a static SVG without a headless browser. +// +// INPUT — entries: loaded turn objects shaped like the dashboard's allEntries[]: +// { id, sessionId, receivedAt, elapsed, agent, title, displayNum, +// isSubagent, sessionInferred, toolCalls:{name:count}, req:{messages:[...]} } +// OUTPUT — { lanes, nodes, edges, t0, t1 } +// +// Edge inference here mirrors what server/store.js already knows (inferParentSession: +// inflight + 30s window). The real integration would read an explicit +// entry.spawnedBy / entry.parentEntryId the server can stamp; the time-window +// inference below is the zero-backend-change fallback. + +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) Object.assign(root, api); +})(typeof window !== 'undefined' ? window : null, function () { + 'use strict'; + + const SPAWN_WINDOW_MS = 60000; // a subagent's first turn must start within 60s of the spawning Agent call + + function agentToolCount(entry) { + const tc = entry.toolCalls || {}; + return (tc.Agent || 0) + (tc.Task || 0) + (tc.TaskCreate || 0); + } + + // Pull the Agent/Task tool_use blocks (with their descriptions) out of a turn's + // own output. We look at assistant messages and keep spawn calls in order. + function extractSpawnCalls(entry) { + const out = []; + const msgs = (entry.req && entry.req.messages) || []; + for (const m of msgs) { + if (m.role !== 'assistant' || !Array.isArray(m.content)) continue; + for (const b of m.content) { + if (b.type === 'tool_use' && (b.name === 'Agent' || b.name === 'Task' || b.name === 'TaskCreate')) { + const inp = b.input || {}; + out.push({ id: b.id, name: b.name, label: (inp.description || inp.subject || inp.prompt || '').slice(0, 48) }); + } + } + } + // Fallback: we know the count but not the text (summary-only entries). + if (!out.length && agentToolCount(entry)) { + for (let i = 0; i < agentToolCount(entry); i++) out.push({ id: null, name: 'Agent', label: '' }); + } + return out; + } + + // Per-turn tool chips for display (name + count), skipping spawn tools which + // are drawn as edges instead. + function turnToolChips(entry) { + const tc = entry.toolCalls || {}; + const chips = []; + for (const k of Object.keys(tc)) { + if (k === 'Agent' || k === 'Task' || k === 'TaskCreate') continue; + chips.push(tc[k] > 1 ? k + '×' + tc[k] : k); + } + return chips; + } + + function buildWorkflowGraph(entries, opts) { + opts = opts || {}; + const list = (entries || []).filter(Boolean).slice().sort((a, b) => (a.receivedAt || 0) - (b.receivedAt || 0)); + if (!list.length) return { lanes: [], nodes: [], edges: [], t0: 0, t1: 0 }; + + // 1. Partition into lanes by session. Main (explicit) session leads; each + // inferred/subagent session is its own lane, ordered by first activity. + const bySession = new Map(); + for (const e of list) { + const sid = e.sessionId || 'unknown'; + if (!bySession.has(sid)) bySession.set(sid, []); + bySession.get(sid).push(e); + } + + const laneMeta = []; + for (const [sid, turns] of bySession) { + const isSub = turns.some(t => t.isSubagent || t.sessionInferred); + laneMeta.push({ + sessionId: sid, + kind: isSub ? 'subagent' : 'main', + turns, + t0: turns[0].receivedAt || 0, + t1: turns[turns.length - 1].receivedAt || 0, + label: turns.find(t => t.title)?.title || (isSub ? 'subagent' : 'main') , + }); + } + // main lanes first, then subagents by first-activity time + laneMeta.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === 'main' ? -1 : 1; + return a.t0 - b.t0; + }); + laneMeta.forEach((l, i) => { l.idx = i; }); + const laneBySession = new Map(laneMeta.map(l => [l.sessionId, l])); + + // 2. Nodes — one per turn, on its lane. + const nodes = []; + for (const lane of laneMeta) { + lane.turns.forEach((e, ti) => { + nodes.push({ + id: e.id, + laneIdx: lane.idx, + sessionId: lane.sessionId, + t: e.receivedAt || 0, + dur: e.elapsed || 0, + turnIndexInLane: ti, + displayNum: e.displayNum || (ti + 1), + agent: e.agent || 'claude', + title: e.title || '', + chips: turnToolChips(e), + spawns: extractSpawnCalls(e), + fail: !!e.toolFail, + kind: lane.kind, + }); + }); + } + const nodeById = new Map(nodes.map(n => [n.id, n])); + + // 3. Sequence edges — consecutive turns within a lane. + const edges = []; + for (const lane of laneMeta) { + for (let i = 1; i < lane.turns.length; i++) { + edges.push({ type: 'seq', from: lane.turns[i - 1].id, to: lane.turns[i].id }); + } + } + + // 4. Spawn edges (fan-out) — match each subagent lane to the spawning main turn. + // A subagent lane is spawned by the latest main turn that (a) issued an + // Agent/Task call and (b) started no more than SPAWN_WINDOW_MS before the + // subagent's first turn. Each spawn-call slot is consumed once. + const mainSpawnSlots = []; + for (const lane of laneMeta) { + if (lane.kind !== 'main') continue; + for (const e of lane.turns) { + for (const s of extractSpawnCalls(e)) { + mainSpawnSlots.push({ turnId: e.id, t: e.receivedAt || 0, label: s.label, used: false }); + } + } + } + mainSpawnSlots.sort((a, b) => a.t - b.t); + + const subLanes = laneMeta.filter(l => l.kind === 'subagent').sort((a, b) => a.t0 - b.t0); + for (const sub of subLanes) { + const firstTurnId = sub.turns[0].id; + let best = null; + for (const slot of mainSpawnSlots) { + if (slot.used) continue; + if (slot.t > sub.t0) continue; + if (sub.t0 - slot.t > SPAWN_WINDOW_MS) continue; + if (!best || slot.t > best.t) best = slot; + } + if (best) { + best.used = true; + sub.spawnedBy = best.turnId; + edges.push({ type: 'spawn', from: best.turnId, to: firstTurnId, label: best.label || sub.label }); + } + } + + // 5. Fan-in edges — a subagent lane's last turn returns into the next main + // turn that starts after it (the turn that consumes the tool_result). + for (const sub of subLanes) { + const lastTurn = sub.turns[sub.turns.length - 1]; + const lastEnd = (lastTurn.receivedAt || 0) + (lastTurn.elapsed || 0); + let target = null; + for (const lane of laneMeta) { + if (lane.kind !== 'main') continue; + for (const e of lane.turns) { + if ((e.receivedAt || 0) >= lastEnd - 1000) { // small slack + if (!target || (e.receivedAt || 0) < target.t) target = { id: e.id, t: e.receivedAt || 0 }; + break; + } + } + } + if (target) edges.push({ type: 'fanin', from: lastTurn.id, to: target.id }); + } + + const t0 = Math.min(...nodes.map(n => n.t)); + const t1 = Math.max(...nodes.map(n => n.t + n.dur)); + return { lanes: laneMeta, nodes, edges, nodeById, laneBySession, t0, t1 }; + } + + // ── SVG renderer ─────────────────────────────────────────────────────────── + const C = { + bg: '#0d1117', surface: '#161b22', border: '#30363d', text: '#e6edf3', dim: '#8b949e', + accent: '#58a6ff', green: '#3fb950', red: '#f85149', yellow: '#d29922', + spawn: '#ff8a65', fanin: '#4dd0e1', + }; + + function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } + + function renderWorkflowSVG(graph, opts) { + opts = opts || {}; + const padL = 150; // lane-label gutter + const padR = 40, padT = 56, padB = 30; + const laneH = 92; + const minBoxW = 96, maxBoxW = 240; + const pxPerSec = opts.pxPerSec || 36; + + const span = Math.max(1, (graph.t1 - graph.t0) / 1000); + const plotW = Math.max(620, span * pxPerSec); + const W = padL + plotW + padR; + const H = padT + graph.lanes.length * laneH + padB; + const xOf = t => padL + ((t - graph.t0) / 1000) * pxPerSec; + const laneY = i => padT + i * laneH + laneH / 2; + const boxH = 46; + + let s = ''; + s += ``; + s += ``; + s += `Workflow · Swimlane Flow`; + s += `orchestrator + ${graph.lanes.filter(l=>l.kind==='subagent').length} subagent lane(s) · X = time · → spawn · ⇠ fan-in`; + + // lane bands + labels + graph.lanes.forEach((lane, i) => { + const y = padT + i * laneH; + if (i % 2 === 0) s += ``; + s += ``; + const dotColor = lane.kind === 'main' ? C.accent : C.spawn; + s += ``; + const lbl = lane.kind === 'main' ? 'orchestrator' : esc(lane.label || 'subagent'); + s += `${esc(lbl).slice(0,18)}`; + s += `${lane.kind} · ${lane.turns.length} turn(s)`; + }); + + // time axis ticks (every 5s) + for (let sec = 0; sec <= span + 0.001; sec += 5) { + const x = padL + sec * pxPerSec; + s += ``; + s += `${sec}s`; + } + + const boxW = n => Math.max(minBoxW, Math.min(maxBoxW, n.chips.join(' ').length * 6.5 + 64)); + + // edges first (under boxes) + for (const e of graph.edges) { + const a = graph.nodeById.get(e.from), b = graph.nodeById.get(e.to); + if (!a || !b) continue; + const ax = xOf(a.t), ay = laneY(a.laneIdx); + const bx = xOf(b.t), by = laneY(b.laneIdx); + if (e.type === 'seq') { + const ax2 = ax + boxW(a); + s += ``; + } else if (e.type === 'spawn') { + const sx = ax + boxW(a) * 0.5, sy = ay + boxH / 2; + const ty = by - boxH / 2; + const midY = (sy + ty) / 2; + s += ``; + if (e.label) s += `spawn: ${esc(e.label).slice(0,28)}`; + } else if (e.type === 'fanin') { + const sx = ax + boxW(a), sy = ay - boxH / 2; + const ty = by + boxH / 2; + const midY = (sy + ty) / 2; + s += ``; + } + } + + // arrow markers + s += ``; + s += ``; + s += ``; + s += ``; + + // boxes + for (const n of graph.nodes) { + const x = xOf(n.t), y = laneY(n.laneIdx); + const w = boxW(n); + const fill = n.kind === 'main' ? '#16243a' : '#2a1f1a'; + const stroke = n.fail ? C.red : (n.kind === 'main' ? C.accent : C.spawn); + s += ``; + s += `#${n.displayNum} ${esc(n.agent)}`; + const chipText = n.chips.length ? n.chips.join(' ') : (n.title ? esc(n.title).slice(0, 26) : '·'); + s += `${esc(chipText).slice(0, 34)}`; + if (n.dur) s += `${(n.dur/1000).toFixed(1)}s`; + if (n.spawns.length) s += `⑂${n.spawns.length}`; + } + + s += ``; + return s; + } + + return { buildWorkflowGraph, renderWorkflowSVG, _SPAWN_WINDOW_MS: SPAWN_WINDOW_MS }; +});