// BranchingPipeline.jsx — Workflow DAG with fan-out/fan-in + approval gate. // Sequence: Research → Plan → [Approve plan?] → Execute Plan 1 → (Plan 2 ‖ Plan 3) → Review → Open PR // Click any node to expand a detail card with artifacts + a View Details button. // The Approval node is a human-input gate — its detail card shows Approve / Request changes / Reject. const BP_NODES = [ { id: 'research', label: 'Research', col: 0, row: 0, kind: 'main', duration: '4m 12s' }, { id: 'plan', label: 'Plan', col: 1, row: 0, kind: 'main', duration: '8m 47s' }, { id: 'approval', label: 'Approve Plan?', col: 2, row: 0, kind: 'approval', duration: '~2m wait' }, { id: 'exec1', label: 'Execute Plan 1', col: 3, row: 0, kind: 'main', duration: '19m 04s' }, { id: 'exec2', label: 'Execute Plan 2', col: 4, row: -1, kind: 'parallel', duration: '21m 33s' }, { id: 'exec3', label: 'Execute Plan 3', col: 4, row: 1, kind: 'parallel', duration: '17m 58s' }, { id: 'review', label: 'Review', col: 5, row: 0, kind: 'main', duration: '3m 21s' }, { id: 'pr', label: 'Open PR', col: 6, row: 0, kind: 'main', duration: '0m 41s' }, ]; const BP_EDGES = [ ['research', 'plan'], ['plan', 'approval'], ['approval', 'exec1'], ['exec1', 'exec2'], ['exec1', 'exec3'], ['exec2', 'review'], ['exec3', 'review'], ['review', 'pr'], ]; // Phases — note approval *blocks* progress (longer pause + amber state) const BP_PHASES = [ { live: ['research'], blocked: false }, { live: ['plan'], blocked: false }, { live: ['approval'], blocked: true }, // waiting on human review { live: ['approval'], blocked: true }, // ↳ hold { live: ['exec1'], blocked: false }, { live: ['exec2', 'exec3'], blocked: false }, { live: ['exec2', 'exec3'], blocked: false }, { live: ['review'], blocked: false }, { live: ['pr'], blocked: false }, ]; const BP_ARTIFACTS = { research: { summary: 'Indexed 47 files across 3 packages. 12 dependencies traced. 4 prior commits referenced.', files: [ { name: 'context-graph.json', size: '184 KB', kind: 'graph' }, { name: 'reference-snippets.md', size: '24 KB', kind: 'doc' }, { name: 'dependency-trace.log', size: '8.2 KB', kind: 'log' }, ], }, plan: { summary: 'Decomposed into 3 plans by failure-domain isolation. Each plan is independently mergeable.', files: [ { name: 'plan-001-oauth-config.md', size: '6.1 KB', kind: 'doc' }, { name: 'plan-002-token-refresh.md', size: '4.8 KB', kind: 'doc' }, { name: 'plan-003-error-paths.md', size: '3.4 KB', kind: 'doc' }, { name: 'acceptance-criteria.yaml', size: '1.9 KB', kind: 'yaml' }, ], }, approval: { kind: 'gate', summary: 'Plan ready for review. Requesting approval before execution begins.', requestedBy: 'sourceweaver-bot', requestedAt: '2 min ago', files: [ { name: 'plan-001-oauth-config.md', size: '6.1 KB', kind: 'doc' }, { name: 'plan-002-token-refresh.md', size: '4.8 KB', kind: 'doc' }, { name: 'plan-003-error-paths.md', size: '3.4 KB', kind: 'doc' }, ], }, exec1: { summary: 'Wrote OAuth config primitives + provider registry. 6 files touched, 184 lines added.', files: [ { name: 'src/auth/oauth/config.ts', size: '+92 lines', kind: 'code' }, { name: 'src/auth/oauth/registry.ts', size: '+58 lines', kind: 'code' }, { name: 'tests/oauth.config.spec.ts', size: '+34 lines', kind: 'test' }, ], }, exec2: { summary: 'Built token refresh + retry path. Diff isolated from Plan 1 by interface boundary.', files: [ { name: 'src/auth/oauth/refresh.ts', size: '+71 lines', kind: 'code' }, { name: 'tests/oauth.refresh.spec.ts', size: '+42 lines', kind: 'test' }, ], }, exec3: { summary: 'Error-path handling and structured logs. Fixtures match Plan 1 contract.', files: [ { name: 'src/auth/oauth/errors.ts', size: '+38 lines', kind: 'code' }, { name: 'src/auth/oauth/logging.ts', size: '+22 lines', kind: 'code' }, { name: 'tests/oauth.errors.spec.ts', size: '+19 lines', kind: 'test' }, ], }, review: { summary: '8 of 8 acceptance checks. Type-check clean. Lint 0 warnings. Coverage +2.4%.', files: [ { name: 'review-report.md', size: '11 KB', kind: 'doc' }, { name: 'coverage-delta.json', size: '4.2 KB', kind: 'graph' }, ], }, pr: { summary: 'Pull request opened against main. Awaiting human review.', files: [ { name: 'PR #1142 description', size: '—', kind: 'doc' }, { name: 'CHANGELOG.md', size: '+1 line', kind: 'doc' }, ], }, }; function BranchingPipeline() { const [phase, setPhase] = React.useState(0); const [tick, setTick] = React.useState(0); // Default-open the approval gate so users see the most distinctive node first const [openId, setOpenId] = React.useState('approval'); // Per-node animated elapsed seconds, keyed by node id. const [elapsed, setElapsed] = React.useState({}); React.useEffect(() => { const id = setInterval(() => { setPhase((p) => (p + 1) % BP_PHASES.length); setTick((t) => t + 1); }, 850); return () => clearInterval(id); }, []); // Parse "8m 47s", "0m 41s", "~2m wait" → seconds. const parseDur = (s) => { if (!s) return 0; const m = s.match(/(\d+)\s*m/); const sec = s.match(/(\d+)\s*s/); return (m ? +m[1] * 60 : 0) + (sec ? +sec[1] : 0); }; const fmtDur = (totalSec) => { const m = Math.floor(totalSec / 60); const s = Math.floor(totalSec % 60); return `${m}m ${String(s).padStart(2, '0')}s`; }; // Animate elapsed counter for any live, non-blocked, non-gate node. React.useEffect(() => { const currentPhase = BP_PHASES[phase]; if (currentPhase.blocked) return; const liveNodes = currentPhase.live .map((id) => BP_NODES.find((n) => n.id === id)) .filter((n) => n && n.kind !== 'approval'); if (liveNodes.length === 0) return; // Each phase is 850ms; animate elapsed from 0 → target across ~700ms (16fps). const FRAME_MS = 40; const FRAMES = 18; let frame = 0; setElapsed((e) => { const next = { ...e }; liveNodes.forEach((n) => { next[n.id] = 0; }); return next; }); const id = setInterval(() => { frame++; const t = Math.min(1, frame / FRAMES); // ease-out-cubic for a snappy fast-then-settle feel const eased = 1 - Math.pow(1 - t, 3); setElapsed((e) => { const next = { ...e }; liveNodes.forEach((n) => { next[n.id] = Math.round(parseDur(n.duration) * eased); }); return next; }); if (frame >= FRAMES) clearInterval(id); }, FRAME_MS); return () => clearInterval(id); }, [phase]); const COLS = 7; const W = 720; const H = 180; const colX = (c) => 56 + c * ((W - 112) / (COLS - 1)); const rowY = (r) => H / 2 + r * 50; const nodeById = Object.fromEntries(BP_NODES.map((n) => [n.id, n])); const currentPhase = BP_PHASES[phase]; const liveSet = new Set(currentPhase.live); const completedSet = new Set(); for (let i = 0; i < phase; i++) BP_PHASES[i].live.forEach((id) => completedSet.add(id)); const isBlocked = currentPhase.blocked; const isEdgeLive = (a, b) => liveSet.has(b) && !isBlocked; const isEdgeDone = (a, b) => completedSet.has(b); const stageStatus = (id) => { if (liveSet.has(id)) return isBlocked ? 'blocked' : 'live'; if (completedSet.has(id)) return 'done'; return 'pending'; }; // Display duration: animate up from 0 while live, settle to target when done. const nodeDuration = (n) => { const st = stageStatus(n.id); if (n.kind === 'approval') { if (st === 'blocked') return 'awaiting…'; if (st === 'done') return n.duration; return n.duration; } if (st === 'live') { const e = elapsed[n.id] ?? 0; return fmtDur(e); } if (st === 'done') return n.duration; return n.duration; }; const activeLabels = currentPhase.live.map((id) => nodeById[id].label).join(' · '); const openNode = openId ? nodeById[openId] : null; const openArtifacts = openId ? BP_ARTIFACTS[openId] : null; const openStatus = openId ? stageStatus(openId) : null; return (