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

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:
AetherForge
2026-06-07 09:20:51 -07:00
parent 23d107b144
commit e1cecd8aef
6 changed files with 1056 additions and 0 deletions

View 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);
});
});

View 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}`;
}

View 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);
});
});

View 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);
}