diff --git a/apps/api/src/orchestrator/runs.ts b/apps/api/src/orchestrator/runs.ts index cea962f..6c6ecf8 100644 --- a/apps/api/src/orchestrator/runs.ts +++ b/apps/api/src/orchestrator/runs.ts @@ -15,7 +15,7 @@ import { newToken, REPO_ROOT } from './config.js'; import { buildPrompt } from './prompt.js'; import { Runner, type PersistedEvent } from './runner.js'; import { BevyProcess } from './bevy.js'; -import { createWorktree, dirtySummary, headSha, isWorktreePresent, removeWorktree, type Worktree } from './worktrees.js'; +import { createWorktree, dirtySummary, headSha, isWorktreePresent, isMergedIntoHead, mergeBranches, removeWorktree, type BatchMergeResult, type Worktree } from './worktrees.js'; export interface StartRunInput { cardId: string; @@ -267,6 +267,40 @@ class RunManager { return rows.map((r) => this.reconcile(hydrateRun(r)) as AgentRun); } + /** + * Merge all reviewable agent branches into the main branch sequentially. + * + * Candidates are settled runs that own their worktree and have commits not + * yet in HEAD, oldest first (so merges land in roughly the order the runs + * happened). De-dups by branch and skips anything already merged. The actual + * sequential merge + conflict handling is in `worktrees.mergeBranches`. + */ + mergeAll(): BatchMergeResult { + const rows = db + .prepare( + `SELECT * FROM agent_runs + WHERE status IN ('completed','failed','stopped') + AND owns_worktree = 1 + AND branch IS NOT NULL + ORDER BY created_at ASC`, + ) + .all() as AgentRunRow[]; + + const seenBranch = new Set(); + const candidates: { branch: string; runId: string }[] = []; + for (const row of rows) { + const branch = row.branch!; + if (seenBranch.has(branch)) continue; // dedup (e.g. multiple runs same branch) + seenBranch.add(branch); + // Skip branches already fully in HEAD — nothing to merge, and including + // them would let an unrelated failure mask that they're done. + if (isMergedIntoHead(branch)) continue; + candidates.push({ branch, runId: row.id }); + } + + return mergeBranches(candidates); + } + /** Replayable event history for a run, optionally after a sequence number. */ events(id: string, sinceSeq = 0): AgentRunEvent[] { const rows = db diff --git a/apps/api/src/orchestrator/worktrees.ts b/apps/api/src/orchestrator/worktrees.ts index 35ed11a..95c079f 100644 --- a/apps/api/src/orchestrator/worktrees.ts +++ b/apps/api/src/orchestrator/worktrees.ts @@ -177,6 +177,71 @@ export interface MergeResult { output: string; } +/** Outcome of a single branch in a batch merge. */ +export interface BatchMergeItem extends MergeResult { + /** The run this branch belonged to (for UI correlation). */ + runId: string; +} + +/** Result of merging several branches sequentially into the main branch. */ +export interface BatchMergeResult { + target: string; + /** One entry per candidate branch, in the order processed. */ + items: BatchMergeItem[]; + /** The branch that conflicted and stopped the batch, if any. Branches after + * it were not attempted. */ + haltedOn: string | null; + /** True if the main worktree was dirty and nothing was attempted. */ + aborted: boolean; + /** Human-readable reason when `aborted` or `haltedOn`. */ + reason: string | null; +} + +/** + * Merge multiple agent branches into the main worktree's checked-out branch, + * sequentially. Each successful merge advances HEAD, so later branches merge + * against the accumulated result. + * + * Safety: refuses outright if the main tree is dirty. On the first conflict, + * aborts that merge (leaving the main tree clean), records it as `haltedOn`, + * and stops — branches after it are NOT attempted, and prior successful merges + * stay in place. The caller should surface `haltedOn` + remaining branches so + * the operator can resolve or re-run after fixing the conflict. + * + * @param candidates branches to merge, in order, each tagged with its run id. + * Already-merged branches are reported as no-ops and skipped (never halt). + */ +export function mergeBranches(candidates: { branch: string; runId: string }[]): BatchMergeResult { + const target = currentBranch(); + const dirty = mainDirtySummary(); + if (dirty) { + return { + target, + items: [], + haltedOn: null, + aborted: true, + reason: `Cannot merge: ${dirty}`, + }; + } + + const items: BatchMergeItem[] = []; + let haltedOn: string | null = null; + let reason: string | null = null; + + for (const { branch, runId } of candidates) { + const result = mergeBranch(branch); + items.push({ ...result, runId }); + if (!result.ok) { + // A conflict aborts the batch: stop here, leaving earlier merges in place. + haltedOn = branch; + reason = `Conflict merging ${branch} — aborted, ${target} left clean.`; + break; + } + } + + return { target, items, haltedOn, aborted: false, reason }; +} + /** * Merge an agent branch into the main worktree's checked-out branch. * diff --git a/apps/api/src/routes/orchestrator.ts b/apps/api/src/routes/orchestrator.ts index 4ffc08d..0ead666 100644 --- a/apps/api/src/routes/orchestrator.ts +++ b/apps/api/src/routes/orchestrator.ts @@ -144,6 +144,9 @@ orchestrator.post('/runs/:id/merge', (c) => { return c.json(mergeBranch(run.branch)); }); +/** Merge all reviewable agent branches into the main branch sequentially. */ +orchestrator.post('/runs/merge-all', (c) => c.json(runManager.mergeAll())); + /** Whether a Bevy playtest is running for a run. */ orchestrator.get('/runs/:id/bevy', (c) => { const run = runManager.get(c.req.param('id')); diff --git a/apps/docs/src/components/kanban/MergeAllModal.tsx b/apps/docs/src/components/kanban/MergeAllModal.tsx new file mode 100644 index 0000000..2f4bead --- /dev/null +++ b/apps/docs/src/components/kanban/MergeAllModal.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from 'react'; +import type { BatchMergeResult } from '../../lib/orchestratorApi'; + +/** + * Results overlay for a "merge all" batch: shows each branch's outcome, which + * one (if any) conflicted and halted the batch, and which were left unattempted. + * + * A success means the merge landed; `alreadyMerged` is a no-op; `ok:false` is a + * conflict that stopped the run. Branches after the conflict are simply absent + * from `items` (the server didn't attempt them) — surfaced via the `haltedOn` + * banner so the operator knows to resolve and re-run. + */ +interface MergeAllModalProps { + result: BatchMergeResult | null; + busy: boolean; + onClose: () => void; +} + +export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) { + // Close on Escape. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + > +
+

Merge all agent branches

+ +
+ + {busy && !result && ( +

Merging branches sequentially…

+ )} + + {result && ( + <> +

+ Target branch: {result.target} + {' · '} + {result.items.filter((i) => i.ok && !i.alreadyMerged).length} merged + {result.items.some((i) => i.alreadyMerged) && + ` · ${result.items.filter((i) => i.alreadyMerged).length} already in`} + {result.haltedOn && ' · halted on conflict'} +

+ + {result.aborted && result.reason && ( +
⚠ {result.reason}
+ )} + + {result.haltedOn && ( +
+ ⚠ Conflict merging {result.haltedOn} — batch stopped,{' '} + {result.target} left clean. Resolve or drop that branch, then re-run. +
+ )} + + {!result.aborted && !result.haltedOn && result.items.length > 0 && ( +
+ ✓ All reviewable branches merged into {result.target}. +
+ )} + + {result.items.length === 0 && !result.aborted && ( +

+ No branches needed merging — everything is already in {result.target}. +

+ )} + + {result.items.length > 0 && ( +
+ {result.items.map((item) => { + const tone = + item.alreadyMerged ? 'var(--muted)' : item.ok ? 'var(--green)' : 'var(--red)'; + return ( +
+
+ + {item.alreadyMerged ? '⇢' : item.ok ? '✓' : '⚠'} + + {item.branch} + + {item.alreadyMerged ? 'already in target' : item.ok ? 'merged' : 'conflict'} + +
+ {!item.ok && item.output && ( +
+                          {item.output}
+                        
+ )} +
+ ); + })} +
+ )} + + )} +
+
+ ); +} + +// --- styles ---------------------------------------------------------------- + +const overlayStyle: React.CSSProperties = { + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.6)', + backdropFilter: 'blur(2px)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 60, + padding: 'var(--sp-4)', +}; + +function bannerStyle(color: string): React.CSSProperties { + return { + padding: '8px 12px', + marginBottom: 'var(--sp-3)', + border: `1px solid ${color}33`, + background: `${color}0f`, + borderRadius: 'var(--radius-sm)', + borderLeft: `2px solid ${color}`, + color: 'var(--fg-dim)', + fontSize: '0.8rem', + lineHeight: 1.5, + }; +} + +const monoStyle: React.CSSProperties = { + fontFamily: 'var(--font-mono)', + fontSize: '0.74rem', + color: 'var(--fg)', +}; + +const preStyle: React.CSSProperties = { + background: 'var(--bg)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-sm)', + padding: '6px 8px', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + maxHeight: '140px', + overflowY: 'auto', +}; diff --git a/apps/docs/src/components/kanban/RunningAgentsBar.tsx b/apps/docs/src/components/kanban/RunningAgentsBar.tsx new file mode 100644 index 0000000..e33b73e --- /dev/null +++ b/apps/docs/src/components/kanban/RunningAgentsBar.tsx @@ -0,0 +1,279 @@ +import { memo, useEffect, useState } from 'react'; +import type { AgentRun } from '../../lib/orchestratorApi'; +import type { Card } from '../../lib/kanbanApi'; + +/** + * Floating "active agents" dock for the implementation board. + * + * Pins to the bottom-right and lists every run currently in flight, each a + * one-click jump to that card's detail modal. Lives below the card modal + * (z-index 40 < 50): when a modal is open you're focused on one card; the dock + * is for the common case of scanning the board while agents work in the + * background. Reads only from the registry (`runs`) + the board's card map, so + * it needs no backend changes. Re-renders are isolated: a 1 s tick for live + * elapsed times lives inside this component, never the board page. + */ + +interface RunningAgentsBarProps { + /** Full run list from the registry; only `running` entries are shown. */ + runs: AgentRun[]; + /** Card lookup for titles/categories (empty until the board loads — ids still show). */ + cardById: Map; + /** Open a card's detail modal. */ + onOpenCard: (cardId: string) => void; +} + +/** Tick once a second while anything is active, for live elapsed times. */ +function useNow(active: boolean): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [active]); + return now; +} + +function fmtElapsed(startedAtIso: string | null, now: number): string { + if (!startedAtIso) return ''; + const start = Date.parse(startedAtIso); + if (!Number.isFinite(start)) return ''; + const s = Math.max(0, Math.floor((now - start) / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`; + const h = Math.floor(m / 60); + return `${h}h ${String(m % 60).padStart(2, '0')}m`; +} + +/** Shorten `agent/u1-bec600e2` → `u1-bec600e2`. */ +function shortBranch(branch: string): string { + const idx = branch.lastIndexOf('/'); + return idx >= 0 ? branch.slice(idx + 1) : branch; +} + +export const RunningAgentsBar = memo(function RunningAgentsBar({ + runs, + cardById, + onOpenCard, +}: RunningAgentsBarProps) { + const active = runs.filter((r) => r.status === 'running'); + const [collapsed, setCollapsed] = useState(false); + const [hovered, setHovered] = useState(null); + const now = useNow(active.length > 0); + + if (active.length === 0) return null; + + const count = active.length; + + return ( + // Wrapper is non-interactive so the empty space around the dock never + // swallows board clicks; children opt back into pointer events. +
+ {!collapsed && ( +
+
+ + + {count} agent{count === 1 ? '' : 's'} running + +
+
+ {active.map((r) => { + const card = cardById.get(r.cardId); + const isHovered = hovered === r.id; + return ( + + ); + })} +
+
+ )} + + +
+ ); +}); + +// --- styles ---------------------------------------------------------------- + +const wrapStyle: React.CSSProperties = { + position: 'fixed', + right: 'var(--sp-4)', + bottom: 'var(--sp-4)', + zIndex: 40, // below the card modal (50) so an open modal stays focused + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + gap: 'var(--sp-2)', + maxWidth: '320px', +}; + +const panelStyle: React.CSSProperties = { + pointerEvents: 'auto', + width: '300px', + background: 'var(--surface)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', + boxShadow: '0 8px 28px rgba(0,0,0,0.35)', + overflow: 'hidden', +}; + +const panelHeaderStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: '6px', + padding: '8px 10px', + borderBottom: '1px solid var(--border)', + background: 'var(--surface-raised)', +}; + +const headerTextStyle: React.CSSProperties = { + fontSize: '0.72rem', + fontWeight: 600, + color: 'var(--fg)', + letterSpacing: '0.02em', +}; + +const listStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + maxHeight: '264px', + overflowY: 'auto', +}; + +const rowStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: '8px', + width: '100%', + padding: '8px 10px', + textAlign: 'left', + cursor: 'pointer', + borderTop: 'none', + borderLeft: 'none', + borderRight: 'none', + borderBottom: '1px solid var(--border)', + borderRadius: 0, + transition: 'background 0.12s ease, border-color 0.12s ease', +}; + +const rowDotStyle: React.CSSProperties = { + width: '7px', + height: '7px', + borderRadius: '50%', + background: 'var(--accent)', + boxShadow: '0 0 6px var(--accent)', + animation: 'vn-pulse 1.1s ease-in-out infinite', + flexShrink: 0, +}; + +const titleStyle: React.CSSProperties = { + display: 'block', + fontSize: '0.8rem', + fontWeight: 500, + color: 'var(--fg)', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}; + +const subStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: '6px', + marginTop: '2px', +}; + +const idStyle: React.CSSProperties = { + fontFamily: 'var(--font-mono)', + fontSize: '0.62rem', + color: 'var(--muted)', + background: 'var(--bg)', + padding: '1px 5px', + borderRadius: 'var(--radius-sm)', +}; + +const branchStyle: React.CSSProperties = { + fontFamily: 'var(--font-mono)', + fontSize: '0.6rem', + color: 'var(--fg-dim)', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}; + +const elapsedStyle: React.CSSProperties = { + fontFamily: 'var(--font-mono)', + fontSize: '0.66rem', + color: 'var(--accent)', + flexShrink: 0, + fontVariantNumeric: 'tabular-nums', +}; + +const openGlyphStyle: React.CSSProperties = { + fontSize: '0.7rem', + color: 'var(--fg-dim)', + flexShrink: 0, +}; + +const toggleStyle: React.CSSProperties = { + pointerEvents: 'auto', + display: 'inline-flex', + alignItems: 'center', + gap: '6px', + padding: '6px 12px', + fontSize: '0.72rem', + fontFamily: 'var(--font-mono)', + color: 'var(--accent)', + background: 'var(--surface)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-pill)', + cursor: 'pointer', + boxShadow: '0 4px 14px rgba(0,0,0,0.3)', + whiteSpace: 'nowrap', +}; + +const liveDotStyle: React.CSSProperties = { + width: '8px', + height: '8px', + borderRadius: '50%', + background: 'var(--accent)', + boxShadow: '0 0 6px var(--accent)', + animation: 'vn-pulse 1.1s ease-in-out infinite', + flexShrink: 0, +}; diff --git a/apps/docs/src/components/kanban/useOrchestrator.ts b/apps/docs/src/components/kanban/useOrchestrator.ts index cf79bca..ff500cc 100644 --- a/apps/docs/src/components/kanban/useOrchestrator.ts +++ b/apps/docs/src/components/kanban/useOrchestrator.ts @@ -4,6 +4,7 @@ import { type AgentRun, type DiffResult, type MergeResult, + type BatchMergeResult, type RunStatus, } from '../../lib/orchestratorApi'; @@ -49,6 +50,8 @@ export interface UseOrchestrator { getDiff: (runId: string) => Promise; /** Merge a run's branch into the main worktree. */ mergeRun: (runId: string) => Promise; + /** Merge all reviewable agent branches into the main branch sequentially. */ + mergeAll: () => Promise; /** Start a Bevy playtest in a run's worktree. */ startBevy: (runId: string) => Promise; /** Stop a run's Bevy playtest. */ @@ -196,6 +199,7 @@ export function useOrchestrator(): UseOrchestrator { const getDiff = useCallback((runId: string) => orchestratorApi.getDiff(runId), []); const mergeRun = useCallback((runId: string) => orchestratorApi.mergeRun(runId), []); + const mergeAll = useCallback(() => orchestratorApi.mergeAll(), []); const startBevy = useCallback(async (runId: string) => { await orchestratorApi.startBevy(runId); @@ -260,6 +264,7 @@ export function useOrchestrator(): UseOrchestrator { reflectBevy, getDiff, mergeRun, + mergeAll, startBevy, stopBevy, bevyIsRunning, @@ -281,6 +286,7 @@ export function useOrchestrator(): UseOrchestrator { reflectBevy, getDiff, mergeRun, + mergeAll, startBevy, stopBevy, bevyIsRunning, diff --git a/apps/docs/src/lib/orchestratorApi.ts b/apps/docs/src/lib/orchestratorApi.ts index 196295d..8aaa089 100644 --- a/apps/docs/src/lib/orchestratorApi.ts +++ b/apps/docs/src/lib/orchestratorApi.ts @@ -113,6 +113,25 @@ export interface MergeResult { output: string; } +/** One branch's outcome in a batch (merge-all) merge. */ +export interface BatchMergeItem extends MergeResult { + /** The run this branch belonged to. */ + runId: string; +} + +/** Result of merging several branches sequentially into the main branch. */ +export interface BatchMergeResult { + target: string; + /** One entry per candidate branch, in the order processed. */ + items: BatchMergeItem[]; + /** The branch that conflicted and stopped the batch, if any. */ + haltedOn: string | null; + /** True if the main worktree was dirty and nothing was attempted. */ + aborted: boolean; + /** Human-readable reason when aborted or halted. */ + reason: string | null; +} + export const orchestratorApi = { listRuns: (cardId?: string) => req<{ runs: AgentRun[] }>(`/runs${cardId ? `?cardId=${encodeURIComponent(cardId)}` : ''}`), @@ -141,6 +160,9 @@ export const orchestratorApi = { mergeRun: (id: string) => req(`/runs/${id}/merge`, { method: 'POST' }), + /** Merge all reviewable agent branches into the main branch sequentially. */ + mergeAll: () => req('/runs/merge-all', { method: 'POST' }), + bevyStatus: (id: string) => req<{ running: boolean }>(`/runs/${id}/bevy`), startBevy: (id: string) => req<{ ok: boolean }>(`/runs/${id}/bevy`, { method: 'POST' }), diff --git a/apps/docs/src/pages/docs/KanbanBoardPage.tsx b/apps/docs/src/pages/docs/KanbanBoardPage.tsx index 6ff7ee8..19d1b9d 100644 --- a/apps/docs/src/pages/docs/KanbanBoardPage.tsx +++ b/apps/docs/src/pages/docs/KanbanBoardPage.tsx @@ -3,9 +3,12 @@ import { Link } from 'react-router-dom'; import { useKanbanBoard } from '../../components/kanban/useKanbanBoard'; import { KanbanCard } from '../../components/kanban/KanbanCard'; import { CardModal } from '../../components/kanban/CardModal'; +import { RunningAgentsBar } from '../../components/kanban/RunningAgentsBar'; +import { MergeAllModal } from '../../components/kanban/MergeAllModal'; import { useOrchestrator } from '../../components/kanban/useOrchestrator'; import { useCustomPages } from '../../lib/customPagesStore'; import type { Card, Column } from '../../lib/kanbanApi'; +import type { BatchMergeResult } from '../../lib/orchestratorApi'; const COLUMNS: { key: Column; title: string; color: string; bgColor: string }[] = [ { key: 'done', title: 'DONE', color: 'var(--green)', bgColor: 'rgba(34,197,94,0.05)' }, @@ -46,11 +49,35 @@ export function KanbanBoardPage() { const [openCardId, setOpenCardId] = useState(null); const [saving, setSaving] = useState(false); const [justSaved, setJustSaved] = useState(false); + const [mergeAllResult, setMergeAllResult] = useState(null); + const [mergeAllBusy, setMergeAllBusy] = useState(false); // Stable identity across renders — memoized cards receive a referentially // stable `onOpen` so they don't re-render just because the parent did. const openById = useCallback((id: string) => setOpenCardId(id), []); + /** Sequentially merge every reviewable agent branch into the main branch. */ + const mergeAll = async () => { + setMergeAllBusy(true); + try { + const result = await orch.mergeAll(); + setMergeAllResult(result); + // Branches that landed change the board's underlying git state; reload so + // diffs/merge state reflect reality. + await reload(); + } catch (e) { + setMergeAllResult({ + target: '?', + items: [], + haltedOn: null, + aborted: true, + reason: e instanceof Error ? e.message : 'merge-all failed', + }); + } finally { + setMergeAllBusy(false); + } + }; + // The card whose detail modal is open (null when closed). const openCard = board?.cards.find((c) => c.id === openCardId) ?? null; @@ -78,6 +105,14 @@ export function KanbanBoardPage() { return groups; }, [board]); + // Card-id index for the floating active-agents dock (titles/categories). Built + // from the board so it's stable between unrelated renders. + const cardById = useMemo(() => { + const m = new Map(); + if (board) for (const c of board.cards) m.set(c.id, c); + return m; + }, [board]); + const counts = useMemo(() => { const c = { done: 0, 'in-progress': 0, todo: 0, backlog: 0, total: 0 }; if (board) { @@ -113,6 +148,15 @@ export function KanbanBoardPage() { +

@@ -253,6 +297,18 @@ export function KanbanBoardPage() { onRemoveReference={(href) => void removeReference(openCard.id, href)} /> )} + + {/* Floating dock: jump to any agent currently running. */} + + + {/* Batch-merge results overlay. */} + {mergeAllResult && ( + setMergeAllResult(null)} + /> + )} ); }