/** * Onion timeline fork/merge helpers for Path Tracer UI + Seer/AI context. */ import { spreadStrainFromJoinLane } from './fleetTopologyEpidemiology'; export type TimelineBranchStatus = | 'canonical' | 'running' | 'won' | 'lost' | 'merged' | 'failed' | 'aborted'; export interface PathTraceTimelineBranch { id: string; parent_id?: string; fork_hop_index: number; target_agent_id?: string; target_agent_name?: string; persona?: string; spread_lanes?: string[]; status: TimelineBranchStatus; mining_linked?: boolean; hashrate?: number; active_tier?: string; error?: string; is_ghost: boolean; created_at?: string; merged_at?: string; } export interface PathTraceTimelineWS { session_id: string; event: string; branch?: PathTraceTimelineBranch; branches?: PathTraceTimelineBranch[]; mermaid?: string; target_agent_id?: string; } export function branchStatusLabel(status: TimelineBranchStatus): string { switch (status) { case 'canonical': return 'canonical'; case 'running': return 'exploring'; case 'won': return 'mining linked'; case 'merged': return 'merged'; case 'lost': return 'lost'; case 'failed': return 'failed'; case 'aborted': return 'aborted'; default: return status; } } export function branchStatusClass(status: TimelineBranchStatus): string { switch (status) { case 'won': case 'merged': return 'won'; case 'running': return 'running'; case 'failed': case 'lost': case 'aborted': return 'failed'; case 'canonical': return 'canonical'; default: return 'ghost'; } } /** Ghost branches grouped under fork hop index for tree rendering. */ export function ghostBranchesByHop(branches: PathTraceTimelineBranch[]): Map { const map = new Map(); for (const b of branches) { if (!b.is_ghost) continue; const list = map.get(b.fork_hop_index) ?? []; list.push(b); map.set(b.fork_hop_index, list); } return map; } function branchPrimaryLane(branch: PathTraceTimelineBranch): string { return branch.spread_lanes?.[0]?.trim() ?? ''; } /** Spread strain from join lane — mirrors Go SpreadStrainFromJoinLane for hospice guards. */ export function spreadStrainFromPersonaLane(lane: string): string { if (!lane) return ''; return spreadStrainFromJoinLane(lane).toLowerCase(); } export function pickMergeCandidate( branches: PathTraceTimelineBranch[], hospiceStrains?: Set, ): PathTraceTimelineBranch | null { const hospice = hospiceStrains ?? new Set(); const eligible = (b: PathTraceTimelineBranch) => { const lane = branchPrimaryLane(b); if (!lane || hospice.size === 0) return true; const strain = spreadStrainFromPersonaLane(lane); return strain === '' || !hospice.has(strain); }; const ghosts = branches.filter((b) => b.is_ghost && eligible(b)); const won = ghosts.find((b) => b.status === 'won' || b.mining_linked); if (won) return won; const running = ghosts.find((b) => b.status === 'running'); return running ?? null; } export function mergeMermaidStyles(mermaid: string): string { const styles = `classDef won fill:#0a3d2e,stroke:#00ffaa,color:#00ffaa classDef running fill:#3d320a,stroke:#ffc800,color:#ffc800 classDef failed fill:#3d0a0a,stroke:#ff5050,color:#ff5050 classDef ghost fill:#1a1a2e,stroke:#888,color:#ccc`; if (mermaid.includes('classDef won')) return mermaid; return `${mermaid.trim()}\n${styles}`; }