Add Path Tracer onion timeline fork/merge with ghost branches and Seer feed.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Operators fork at hop N to run persona ensembles in parallel on the target agent until mining links; winners merge back into the canonical SQLite-persisted session with WS and Seer events plus mermaid branch graphs in the UI.
This commit is contained in:
54
server/web/src/help/pathTracerTimeline.test.ts
Normal file
54
server/web/src/help/pathTracerTimeline.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
branchStatusClass,
|
||||
ghostBranchesByHop,
|
||||
mergeMermaidStyles,
|
||||
pickMergeCandidate,
|
||||
} from './pathTracerTimeline';
|
||||
import type { PathTraceTimelineBranch } from './pathTracerTimeline';
|
||||
|
||||
function branch(partial: Partial<PathTraceTimelineBranch>): PathTraceTimelineBranch {
|
||||
return {
|
||||
id: 'b1',
|
||||
fork_hop_index: 0,
|
||||
status: 'running',
|
||||
is_ghost: true,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('pathTracerTimeline', () => {
|
||||
it('groups ghost branches by fork hop', () => {
|
||||
const map = ghostBranchesByHop([
|
||||
branch({ id: 'a', fork_hop_index: 1 }),
|
||||
branch({ id: 'b', fork_hop_index: 1 }),
|
||||
branch({ id: 'c', fork_hop_index: 0 }),
|
||||
]);
|
||||
expect(map.get(1)?.map((b) => b.id)).toEqual(['a', 'b']);
|
||||
expect(map.get(0)?.map((b) => b.id)).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('prefers won branch for merge candidate', () => {
|
||||
const pick = pickMergeCandidate([
|
||||
branch({ id: 'run', status: 'running' }),
|
||||
branch({ id: 'win', status: 'won', mining_linked: true, hashrate: 120 }),
|
||||
]);
|
||||
expect(pick?.id).toBe('win');
|
||||
});
|
||||
|
||||
it('maps status to CSS class', () => {
|
||||
expect(branchStatusClass('won')).toBe('won');
|
||||
expect(branchStatusClass('running')).toBe('running');
|
||||
expect(branchStatusClass('failed')).toBe('failed');
|
||||
});
|
||||
|
||||
it('appends mermaid classDef styles once', () => {
|
||||
const raw = 'graph TD\n a --> b';
|
||||
const styled = mergeMermaidStyles(raw);
|
||||
expect(styled).toContain('classDef won');
|
||||
expect(mergeMermaidStyles(styled)).toBe(styled);
|
||||
});
|
||||
});
|
||||
107
server/web/src/help/pathTracerTimeline.ts
Normal file
107
server/web/src/help/pathTracerTimeline.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Onion timeline fork/merge helpers for Path Tracer UI + Seer/AI context.
|
||||
*/
|
||||
|
||||
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<number, PathTraceTimelineBranch[]> {
|
||||
const map = new Map<number, PathTraceTimelineBranch[]>();
|
||||
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;
|
||||
}
|
||||
|
||||
export function pickMergeCandidate(branches: PathTraceTimelineBranch[]): PathTraceTimelineBranch | null {
|
||||
const ghosts = branches.filter((b) => b.is_ghost);
|
||||
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}`;
|
||||
}
|
||||
47
server/web/src/help/seerEvents.test.ts
Normal file
47
server/web/src/help/seerEvents.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isPathTraceTimelineEvent,
|
||||
isSurgicalReplayEvent,
|
||||
mergeSeerEvents,
|
||||
pathTraceTimelineSummary,
|
||||
surgicalReplaySummary,
|
||||
type SeerEventRecord,
|
||||
} from './seerEvents';
|
||||
|
||||
describe('seerEvents', () => {
|
||||
const replay: SeerEventRecord = {
|
||||
id: 1,
|
||||
event_type: 'surgical_replay',
|
||||
agent_id: 'agent-1',
|
||||
payload: { failed_tier: 'docker', fix_type: 'skip_tier', outcome: 'reorder_tiers' },
|
||||
ts: '2026-06-07T12:00:00Z',
|
||||
};
|
||||
|
||||
it('summarizes path trace timeline fork events', () => {
|
||||
const ev: SeerEventRecord = {
|
||||
event_type: 'pathtrace_timeline',
|
||||
payload: { event: 'branch_won', branch: { persona: 'silent', hashrate: 800 } },
|
||||
};
|
||||
expect(pathTraceTimelineSummary(ev)).toContain('silent');
|
||||
expect(pathTraceTimelineSummary(ev)).toContain('800');
|
||||
});
|
||||
|
||||
it('detects surgical replay events', () => {
|
||||
expect(isSurgicalReplayEvent(replay)).toBe(true);
|
||||
expect(isSurgicalReplayEvent({ event_type: 'court_debate' })).toBe(false);
|
||||
});
|
||||
|
||||
it('summarizes surgical replay payload', () => {
|
||||
expect(surgicalReplaySummary(replay)).toContain('docker');
|
||||
expect(surgicalReplaySummary(replay)).toContain('skip_tier');
|
||||
expect(surgicalReplaySummary({ event_type: 'noop' })).toBe('');
|
||||
});
|
||||
|
||||
it('merges seer events without duplicates', () => {
|
||||
const base = [replay];
|
||||
const next = mergeSeerEvents(base, { ...replay, id: 2, ts: '2026-06-07T12:01:00Z' });
|
||||
expect(next).toHaveLength(2);
|
||||
const dup = mergeSeerEvents(next, replay);
|
||||
expect(dup).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
56
server/web/src/help/seerEvents.ts
Normal file
56
server/web/src/help/seerEvents.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/** Seer feed event from WS `seer_events` or GET /api/v1/seer/stream. */
|
||||
export interface SeerEventRecord {
|
||||
id?: number;
|
||||
event_type: string;
|
||||
agent_id?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
ts?: string;
|
||||
}
|
||||
|
||||
export function isPathTraceTimelineEvent(event: SeerEventRecord): boolean {
|
||||
return event.event_type === 'pathtrace_timeline';
|
||||
}
|
||||
|
||||
export function pathTraceTimelineSummary(event: SeerEventRecord): string {
|
||||
if (!isPathTraceTimelineEvent(event)) return '';
|
||||
const p = event.payload ?? {};
|
||||
const ev = typeof p.event === 'string' ? p.event : 'update';
|
||||
const branch = p.branch as { persona?: string; status?: string; hashrate?: number } | undefined;
|
||||
if (ev === 'fork') return 'Path Tracer fork — ghost branches spawned';
|
||||
if (ev === 'branch_won') {
|
||||
return `Path Tracer branch won (${branch?.persona ?? 'unknown'})${branch?.hashrate ? ` · ${Math.round(branch.hashrate)} H/s` : ''}`;
|
||||
}
|
||||
if (ev === 'merge') return `Path Tracer merged ${branch?.persona ?? 'winner'} into canonical timeline`;
|
||||
return `Path Tracer timeline ${ev}`;
|
||||
}
|
||||
|
||||
export function isSurgicalReplayEvent(event: SeerEventRecord): boolean {
|
||||
return event.event_type === 'surgical_replay';
|
||||
}
|
||||
|
||||
export function surgicalReplaySummary(event: SeerEventRecord): string {
|
||||
if (!isSurgicalReplayEvent(event)) {
|
||||
return '';
|
||||
}
|
||||
const p = event.payload ?? {};
|
||||
const tier = typeof p.failed_tier === 'string' ? p.failed_tier : 'tier';
|
||||
const fix = typeof p.fix_type === 'string' ? p.fix_type : 'fix';
|
||||
const outcome = typeof p.outcome === 'string' ? p.outcome : '';
|
||||
return `Surgical replay ${tier} → ${fix}${outcome ? ` (${outcome})` : ''}`;
|
||||
}
|
||||
|
||||
export function mergeSeerEvents(
|
||||
existing: SeerEventRecord[],
|
||||
incoming: SeerEventRecord | SeerEventRecord[],
|
||||
): SeerEventRecord[] {
|
||||
const batch = Array.isArray(incoming) ? incoming : [incoming];
|
||||
const seen = new Set(existing.map((e) => `${e.id ?? ''}:${e.event_type}:${e.ts ?? ''}`));
|
||||
const merged = [...existing];
|
||||
for (const ev of batch) {
|
||||
const key = `${ev.id ?? ''}:${ev.event_type}:${ev.ts ?? ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.unshift(ev);
|
||||
}
|
||||
return merged.slice(0, 200);
|
||||
}
|
||||
Reference in New Issue
Block a user