Add scout constellation mode for APK venue persona packs.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Cluster 3+ scout_report hits on the same SSID within 10 minutes; server infers airport/campus/retail venue class and pushes persona spread_policy. Emberwake weather-map merges active scout biomes. Includes agent, server API, and Vitest coverage.
This commit is contained in:
AetherForge
2026-06-07 09:18:58 -07:00
parent 8b14582975
commit bbab38f8e1
60 changed files with 2610 additions and 43 deletions

View File

@@ -65,4 +65,50 @@ test.describe('Path Tracer E2E', () => {
await expect(page.getByText(/dns_txt/)).toBeVisible();
await expect(page.getByText(/RS lanes/)).toBeVisible();
});
test('onion timeline fork button and mermaid panel', async ({ page }) => {
await page.route(`**/pathtrace/${SESSION_ID}/status`, async (route) => {
await route.fulfill({
json: {
session_id: SESSION_ID,
ready: true,
hops: [{ agent_id: E2E_STUB_AGENT_ID, status: 'ready', agent_name: E2E_STUB_AGENT_HOSTNAME }],
},
});
});
await page.route(`**/pathtrace/${SESSION_ID}/qr`, async (route) => {
await route.fulfill({
json: { config: '[Interface]', qr_png_b64: 'iVBORw0KGgo=' },
});
});
await page.route('**/pathtrace/fork', async (route) => {
await route.fulfill({
json: {
ok: true,
session_id: SESSION_ID,
branches: [
{
id: 'ghost-e2e-1',
fork_hop_index: 0,
persona: 'aggressive',
status: 'running',
is_ghost: true,
active_tier: 'smb',
},
],
mermaid: 'graph TD\n fork --> ghost_e2e',
},
});
});
const card = page.locator('.pt-agent-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME });
await card.click();
await page.locator('.pt-actions').getByRole('button', { name: /TRACE/i }).click();
await expect(page.getByRole('button', { name: /Fork/i })).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: /Fork/i }).click();
await expect(page.getByTestId('pt-timeline-tree')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/aggressive/i)).toBeVisible();
await page.getByText('Mermaid branch graph').click();
await expect(page.getByTestId('pt-mermaid-src')).toContainText('graph TD');
});
});

View File

@@ -24,6 +24,7 @@ const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
const LotlTimelinePage = lazy(() => import('./pages/LotlTimelinePage'));
const ROIPage = lazy(() => import('./pages/ROIPage'));
const ActivityFeedPage = lazy(() => import('./pages/ActivityFeedPage'));
const SeerPage = lazy(() => import('./pages/SeerPage'));
export function PageFallback() {
return (
@@ -66,6 +67,7 @@ function App() {
<Route path="/onion" element={<Navigate to="/lotl-timeline" replace />} />
<Route path="/roi" element={<ROIPage />} />
<Route path="/activity" element={<ActivityFeedPage />} />
<Route path="/seer" element={<SeerPage />} />
</Routes>
</Suspense>
</Layout>

View File

@@ -1,4 +1,4 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, SpreadRouteRecommendation, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, SpreadRouteRecommendation, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes, SubnetAutopsyPacket } from '../types';
import { authHeaders, clearStoredAuth } from './auth';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
@@ -230,6 +230,10 @@ export const api = {
params.set('limit', String(limit));
return fetchJSON<AIDecisionRecord[]>(`/ai/decisions?${params}`);
},
getSeerStream: (limit = 100) =>
fetchJSON<{ events: import('../types').SeerEventRecord[]; notes: import('../types').SeerNoteRecord[] }>(
`/seer/stream?limit=${limit}`,
),
getClearanceEvents: (agentId?: string, limit = 50) => {
const params = new URLSearchParams();
if (agentId?.trim()) params.set('agent_id', agentId.trim());
@@ -361,6 +365,21 @@ export const api = {
'/fleet/modules/push',
{ method: 'POST', body: JSON.stringify(body) },
),
listStrainCards: (agentId?: string) =>
fetchJSON<import('../types').StrainCard[]>(
agentId ? `/fleet/strain-cards?agent_id=${encodeURIComponent(agentId)}` : '/fleet/strain-cards',
),
playStrainCard: (body: { agent_id: string; card_id: string }) =>
fetchJSON<{
success: boolean;
agent_id?: string;
card_id?: string;
play_id?: string;
persona?: string;
strain?: string;
queued?: boolean;
error?: string;
}>('/fleet/play-strain-card', { method: 'POST', body: JSON.stringify(body) }),
// Public builds (unauthenticated — used on login page)
listPublicBuilds: async (): Promise<PublicBuildsResponse> => {
@@ -481,6 +500,13 @@ export const api = {
discover_error?: string;
discovered_at?: string;
spread_routes?: SpreadRouteRecommendation[];
timeline_root_id?: string;
timeline_branches?: import('../help/pathTracerTimeline').PathTraceTimelineBranch[];
merged_persona?: string;
merged_spread_lane?: string;
merged_branch_id?: string;
merged_hashrate?: number;
mermaid?: string;
}>(`/pathtrace/${id}/status`),
spreadRouteTrace: (sessionId: string, targetSubnets: string[], joinLane?: string) =>
fetchJSON<{
@@ -508,6 +534,36 @@ export const api = {
deleteTrace: (id: string) =>
fetchJSON<{ ok: boolean }>(`/pathtrace/${id}`, { method: 'DELETE' }),
getSubnetAutopsy: (subnet: string) =>
fetchJSON<SubnetAutopsyPacket>(`/atlas/subnet-autopsy?subnet=${encodeURIComponent(subnet)}`),
forkTraceTimeline: (sessionId: string, forkHopIndex: number, personas?: string[]) =>
fetchJSON<{
ok: boolean;
session_id: string;
branches: import('../help/pathTracerTimeline').PathTraceTimelineBranch[];
mermaid: string;
}>('/pathtrace/fork', {
method: 'POST',
body: JSON.stringify({
session_id: sessionId,
fork_hop_index: forkHopIndex,
personas: personas ?? [],
}),
}),
mergeTraceTimeline: (sessionId: string, branchId: string) =>
fetchJSON<{
ok: boolean;
session_id: string;
merged_branch_id: string;
merged_persona: string;
merged_spread_lane?: string;
branches: import('../help/pathTracerTimeline').PathTraceTimelineBranch[];
mermaid: string;
}>('/pathtrace/merge', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, branch_id: branchId }),
}),
// Cancel an in-progress forge build by its cancel token.
cancelBuild: (cancelToken: string) =>
fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, {

View File

@@ -291,3 +291,56 @@
border: 1px solid rgba(255, 255, 255, 0.25);
flex-shrink: 0;
}
.access-depth-strain-cards {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.35rem;
}
.access-depth-strain-card {
border: 1px solid rgba(255, 255, 255, 0.12);
border-left: 3px solid var(--strain-accent, #6a8fad);
border-radius: 4px;
padding: 0.35rem 0.45rem;
background: rgba(0, 0, 0, 0.2);
}
.access-depth-strain-card[data-strain] {
--strain-accent: #6a8fad;
}
.access-depth-strain-card-head {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
}
.access-depth-strain-card-title {
flex: 1;
color: #e0e8f0;
text-transform: lowercase;
}
.access-depth-strain-play {
font-size: 0.65rem;
padding: 0.1rem 0.4rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
color: #c8e6ff;
cursor: pointer;
}
.access-depth-strain-play:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.access-depth-strain-card-meta {
font-size: 0.65rem;
color: #98a8b8;
margin-top: 0.15rem;
}

View File

@@ -11,6 +11,7 @@ import {
parseAccessDepthDiagnostics,
parseAccessDepthServerPolicy,
} from '../../help/accessDepth';
import { api } from '../../api/client';
vi.mock('../../hooks/useWebSocket', () => ({
useWebSocket: () => ({ latestMessage: null }),
@@ -27,6 +28,8 @@ vi.mock('../../api/client', () => ({
},
},
}),
listStrainCards: vi.fn().mockResolvedValue([]),
playStrainCard: vi.fn().mockResolvedValue({ success: true }),
},
}));
@@ -228,6 +231,32 @@ describe('AccessDepthPanel', () => {
expect(screen.getByText(/parent 11112222/i)).toBeInTheDocument();
});
it('renders lineage strain card with play control', async () => {
vi.mocked(api.listStrainCards).mockResolvedValueOnce([
{
id: 'card-1',
root_agent_id: 'root',
source_agent_id: 'a1',
source_agent_name: 'Winner',
spread_strain: '#a1b2c3',
spread_lane: 'dns_txt',
persona: 'persuasive',
parents: [],
wins: ['container', 'wsl'],
losses: ['docker'],
subnets: ['10.0.0.x'],
erasure_recovery_rate: 1,
peak_hashrate: 800,
tier_order: ['container', 'wsl'],
tree_size: 2,
},
]);
renderPanel(mockAgent({ id: 'a1', status: 'online' }));
expect(await screen.findByText(/strain · persuasive/i)).toBeInTheDocument();
expect(screen.getByText(/2W · 1L · 1 subnets · erasure 100%/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /play/i })).toBeInTheDocument();
});
it('renders clearance badge L0L4 with tooltip permissions', () => {
renderPanel(mockAgent({ clearance_level: 2 }));
const badge = screen.getByLabelText(/Clearance L2/i);

View File

@@ -13,7 +13,7 @@ import {
formatClearanceElevation,
} from '../../help/clearance';
import { useWebSocket } from '../../hooks/useWebSocket';
import type { Agent } from '../../types';
import type { Agent, StrainCard } from '../../types';
import { HelpTip } from '../HelpTip';
import JoinLaneBadge from './JoinLaneBadge';
import LotlTierBadge from './LotlTierBadge';
@@ -77,6 +77,8 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
const [policyLoaded, setPolicyLoaded] = useState(false);
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
const [elevationFlash, setElevationFlash] = useState<string | null>(null);
const [strainCards, setStrainCards] = useState<StrainCard[]>([]);
const [strainPlayBusy, setStrainPlayBusy] = useState<string | null>(null);
const flashTimerRef = useRef<number | null>(null);
const clearanceLevel = agent.clearance_level ?? 1;
@@ -124,6 +126,44 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
};
}, []);
useEffect(() => {
let cancelled = false;
api
.listStrainCards(agent.id)
.then((cards) => {
if (!cancelled) setStrainCards(cards ?? []);
})
.catch(() => {
if (!cancelled) setStrainCards([]);
});
return () => {
cancelled = true;
};
}, [agent.id]);
useEffect(() => {
if (!latestMessage) return;
if (latestMessage.type === 'strain_card' || latestMessage.type === 'strain_card_played') {
const p = latestMessage.payload as { card?: StrainCard; agent_id?: string };
if (p.card && (p.card.source_agent_id === agent.id || p.card.root_agent_id === agent.id || p.agent_id === agent.id)) {
setStrainCards((prev) => {
const next = prev.filter((c) => c.id !== p.card!.id);
return [p.card!, ...next];
});
}
}
}, [latestMessage, agent.id]);
const playStrainCard = async (card: StrainCard) => {
if (strainPlayBusy) return;
setStrainPlayBusy(card.id);
try {
await api.playStrainCard({ agent_id: agent.id, card_id: card.id });
} finally {
setStrainPlayBusy(null);
}
};
const model = useMemo(
() => buildAccessDepthModel(agent, diagnostics, serverPolicy),
[agent, diagnostics, serverPolicy],
@@ -205,6 +245,45 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
) : null}
</div>
)}
{strainCards.length > 0 && (
<div className="access-depth-strain-cards">
{strainCards.slice(0, 2).map((card) => (
<div
key={card.id}
className="access-depth-strain-card"
data-strain={card.spread_strain?.replace(/^#/, '') ?? ''}
>
<div className="access-depth-strain-card-head">
{card.spread_strain ? (
<span
className="access-depth-strain-swatch"
style={{ backgroundColor: card.spread_strain }}
aria-hidden
/>
) : null}
<span className="access-depth-strain-card-title">
strain · {card.persona}
</span>
<button
type="button"
className="access-depth-strain-play"
disabled={agent.status !== 'online' || strainPlayBusy === card.id}
onClick={() => playStrainCard(card)}
title={`Play ${card.source_agent_name} lineage preset`}
>
{strainPlayBusy === card.id ? '…' : 'play'}
</button>
</div>
<div className="access-depth-strain-card-meta">
{card.wins.length}W · {card.losses.length}L · {card.subnets.length} subnets
{card.erasure_recovery_rate > 0
? ` · erasure ${Math.round(card.erasure_recovery_rate * 100)}%`
: ''}
</div>
</div>
))}
</div>
)}
{model.phenotypeSource && (
<div className="access-depth-phenotype">
phenotype cloned from <strong>{model.phenotypeSource}</strong>
@@ -216,6 +295,13 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
) : null}
</div>
)}
{serverPolicy.graft_enabled && (agent.graft_tier || agent.graft_source_strain) && (
<div className="access-depth-graft-note access-depth-muted">
genealogy graft pending · tier {agent.graft_tier}
{agent.graft_source_strain ? ` · strain ${agent.graft_source_strain}` : ''}
{' '}(applies on next spread)
</div>
)}
</div>
<div className="access-depth-section">

View File

@@ -12,6 +12,7 @@ import { SacredMotif } from '../Visual/sacredGeometry/motifs';
import SetupBanner from '../SetupBanner';
import { getSetupStatus } from '../../help/setupStatus';
import { resolvePageWeather } from '../../help/pageWeather';
import { mergeScoutBiomeWeather, type ScoutConstellationSnapshot } from '../../help/scoutBiomeWeather';
import { isDashboardRoute } from '../../help/routeEffects';
import { api } from '../../api/client';
import { usePresence } from '../../context/PresenceContext';
@@ -40,10 +41,11 @@ function operatorDeckId(pathname: string): string {
if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline';
if (path.startsWith('/roi')) return 'roi';
if (path.startsWith('/activity')) return 'activity';
if (path.startsWith('/seer')) return 'seer';
return 'dashboard';
}
const NAV = [
const NAV_BASE = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/activity', label: 'Activity Feed', icon: 'activity' },
@@ -57,6 +59,20 @@ const NAV = [
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
] as const;
const SEER_NAV = { to: '/seer', label: 'Seer', icon: 'seer' } as const;
function buildNav(aiControlEnabled: boolean) {
if (!aiControlEnabled) {
return [...NAV_BASE];
}
const items = [...NAV_BASE];
const calibrateIdx = items.findIndex((i) => i.to === '/settings');
items.splice(calibrateIdx, 0, SEER_NAV);
return items;
}
const NAV = NAV_BASE;
const DOCS_HREF = '/docs/';
/** Primary tabs on mobile bottom bar — Deck, Crucible, Activity, ROI, Onion */
@@ -150,6 +166,14 @@ function NavIcon({ type }: { type: string }) {
<path d="M12 8v4" />
</svg>
);
case 'seer':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<ellipse cx="12" cy="12" rx="9" ry="5" />
<circle cx="12" cy="12" r="2.5" fill="currentColor" strokeWidth="0" />
<path d="M4 12c2-3 5-4.5 8-4.5s6 1.5 8 4.5" strokeOpacity="0.45" />
</svg>
);
case 'docs':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -281,7 +305,19 @@ export default function Layout({ children }: LayoutProps) {
}, [moreOpen]);
const setupStatus = getSetupStatus(serverConfig, serverInfo);
const pageWeather = resolvePageWeather(location.pathname);
const { latestMessage } = useWebSocket();
const scoutBiome = useMemo(() => {
if (latestMessage?.type !== 'scout_constellations') return null;
return latestMessage.payload as ScoutConstellationSnapshot;
}, [latestMessage]);
const pageWeather = useMemo(() => {
const base = resolvePageWeather(location.pathname);
const path = location.pathname.split('?')[0].replace(/\/$/, '') || '/';
if (path === '/emberwake' || path === '/spread' || path === '/dashboard' || path === '/agents') {
return mergeScoutBiomeWeather(base, scoutBiome);
}
return base;
}, [location.pathname, scoutBiome]);
const showDeckEffects = isDashboardRoute(location.pathname);
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
const mobileShortLabel: Record<string, string> = {

View File

@@ -54,6 +54,7 @@ export interface AccessDepthServerPolicy {
lotl_onion_tiers?: string[];
mining_tier_order?: string[];
mining_skip_tiers?: string[];
graft_enabled?: boolean;
triple_onion?: {
recon_tiers?: string[];
deploy_lanes?: string[];
@@ -521,6 +522,8 @@ export function buildAccessDepthModel(
export function parseAccessDepthServerPolicy(config: {
server?: {
lotl_onion_tiers?: string[];
ai_control_enabled?: boolean;
fleet_roles_enabled?: boolean;
triple_onion_policy?: {
recon_tiers?: string[];
deploy_lanes?: string[];
@@ -528,9 +531,12 @@ export function parseAccessDepthServerPolicy(config: {
};
}): AccessDepthServerPolicy {
const server = config.server;
const graftEnabled =
server?.ai_control_enabled === true && server?.fleet_roles_enabled === true;
return {
lotl_onion_tiers: server?.lotl_onion_tiers,
mining_tier_order: [...DEFAULT_MINING_TIER_ORDER],
graft_enabled: graftEnabled,
triple_onion: server?.triple_onion_policy
? {
recon_tiers: server.triple_onion_policy.recon_tiers,

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { PAGE_WEATHER } from './pageWeather';
import { dominantScoutVenue, mergeScoutBiomeWeather } from './scoutBiomeWeather';
describe('scoutBiomeWeather', () => {
it('picks dominant venue by agent weight', () => {
const venue = dominantScoutVenue({
constellations: [
{ ssid: 'a', venue_class: 'campus', agent_ids: ['1', '2', '3'] },
{ ssid: 'b', venue_class: 'airport', agent_ids: ['4', '5', '6', '7'] },
],
});
expect(venue).toBe('airport');
});
it('boosts emberwake pulse for retail scout biome', () => {
const base = PAGE_WEATHER['/emberwake'];
const merged = mergeScoutBiomeWeather(base, {
constellations: [{ ssid: 'Target-Guest', venue_class: 'retail', agent_ids: ['a', 'b', 'c'] }],
});
expect(merged.pulse).toBeGreaterThan(base.pulse);
expect(merged.energyPulse).toBe(true);
});
it('returns base weather when no constellations', () => {
const base = PAGE_WEATHER['/dashboard'];
expect(mergeScoutBiomeWeather(base, null)).toEqual(base);
});
});

View File

@@ -0,0 +1,56 @@
/** Scout constellation venue → ambient weather biome overlay. */
import type { PageWeatherConfig } from './pageWeather';
export interface ScoutConstellationBiome {
ssid: string;
venue_class: string;
persona_pack?: string;
agent_ids?: string[];
hits?: number;
}
export interface ScoutConstellationSnapshot {
constellations?: ScoutConstellationBiome[];
}
const VENUE_BIOME: Record<string, Partial<PageWeatherConfig>> = {
airport: { pulse: 1.35, linkStrength: 0.92, density: 0.95, palette: 'campaign' },
campus: { pulse: 1.1, linkStrength: 0.82, density: 0.88, palette: 'default' },
retail: { pulse: 1.75, speed: 0.62, linkStrength: 0.88, palette: 'campaign', energyPulse: true },
unknown: { pulse: 0.95, linkStrength: 0.7, density: 0.8 },
};
/** Pick the dominant venue class from active scout constellations. */
export function dominantScoutVenue(snapshot: ScoutConstellationSnapshot | null | undefined): string | null {
const list = snapshot?.constellations ?? [];
if (!list.length) return null;
const rank: Record<string, number> = { airport: 4, retail: 3, campus: 2, unknown: 1 };
let best = list[0].venue_class || 'unknown';
let bestScore = (list[0].agent_ids?.length ?? 1) * (rank[best] ?? 1);
for (let i = 1; i < list.length; i++) {
const venue = list[i].venue_class || 'unknown';
const score = (list[i].agent_ids?.length ?? 1) * (rank[venue] ?? 1);
if (score > bestScore) {
best = venue;
bestScore = score;
}
}
return best;
}
/** Merge fleet scout biome hints into route weather (Emberwake / dashboard). */
export function mergeScoutBiomeWeather(
base: PageWeatherConfig,
snapshot: ScoutConstellationSnapshot | null | undefined,
): PageWeatherConfig {
const venue = dominantScoutVenue(snapshot);
if (!venue) return base;
const overlay = VENUE_BIOME[venue] ?? VENUE_BIOME.unknown;
return {
...base,
...overlay,
intensity: Math.min(1, (base.intensity + (overlay.intensity ?? base.intensity)) / 2 + 0.08),
energyPulse: overlay.energyPulse ?? base.energyPulse,
};
}

View File

@@ -156,6 +156,10 @@ export const UI_HELP: Record<string, string> = {
md_preflight:
'Wallet, control URL, and worker name must pass validation before Equip & Strike unlocks. Spread Kit export is skipped automatically when your loadout ships a single-platform or fusion deliverable instead.',
subnet_immune_autopsy:
'Auto-built when a /24 hits five spread failures: last LOTL attempts, WSUS mimic, persona, erasure fallback, atlas gossip whispers, cause-of-death, and BGP vaccination lane from the spread router.',
pt_subnet_autopsy:
'Immune autopsy for spread-target /24 prefixes paused by subnet_spread_pause — shows vaccination route hints beside Path Tracer spread routes.',
ew_overview:
'Spread desk after you forge: tag install links with ?c=, export lure kits, and read campaign funnels. Forge agents on Mission Deck (fast) or Forge (full control).',
ew_campaign_setup:

View File

@@ -90,6 +90,7 @@ export const WS_LATEST_MESSAGE_TYPES = new Set([
'notes_typing',
'emberwake_notes_updated',
'emberwake_war_room',
'scout_constellations',
'agent_online',
'agent_offline',
'new_share',

View File

@@ -22,6 +22,8 @@ import { usePresence } from '../context/PresenceContext';
import AlsoHere from '../components/Presence/AlsoHere';
import ComradeAvatar from '../components/Presence/ComradeAvatar';
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
import SubnetAutopsyCard from '../components/Atlas/SubnetAutopsyCard';
import { parseSeerSubnetAutopsy } from '../help/subnetAutopsy';
import { HelpTip } from '../components/HelpTip';
import './EmberwakePage.css';
import '../components/Presence/Presence.css';
@@ -63,6 +65,7 @@ export default function EmberwakePage() {
const [exportBusy, setExportBusy] = useState(false);
const [siteName, setSiteName] = useState('my-blog');
const [notesBusy, setNotesBusy] = useState(false);
const [autopsySubnet, setAutopsySubnet] = useState('');
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const typingActiveRef = useRef(false);
@@ -115,6 +118,12 @@ export default function EmberwakePage() {
void load().catch(() => {});
}, [load]);
useEffect(() => {
if (!latestMessage || latestMessage.type !== 'seer_events') return;
const ev = parseSeerSubnetAutopsy(latestMessage.payload);
if (ev?.prefix) setAutopsySubnet(ev.prefix);
}, [latestMessage]);
const liveTelemetry = useMemo(() => aggregateCampaignTelemetry(wsAgents), [wsAgents]);
const maxLiveHashrate = useMemo(() => maxTelemetryHashrate(liveTelemetry), [liveTelemetry]);
@@ -242,6 +251,8 @@ export default function EmberwakePage() {
<AlsoHere page="/emberwake" />
{autopsySubnet && <SubnetAutopsyCard subnet={autopsySubnet} />}
<section
className="spread-section spread-section--ember operator-deck-card operator-interactive emberwake-primary-block"
aria-labelledby="ew-setup-heading"

View File

@@ -429,6 +429,174 @@
}
@keyframes pt-spin { to { transform: rotate(360deg); } }
/* ── Onion timeline fork/merge ───────────────────────────────── */
.pt-timeline-panel {
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(180, 120, 255, 0.22);
border-radius: 10px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pt-timeline-notice {
font-size: 0.68rem;
color: rgba(180, 140, 255, 0.85);
font-family: var(--font-tech, monospace);
}
.pt-timeline-tree {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.pt-timeline-fork-group {
border-left: 2px solid rgba(180, 120, 255, 0.35);
padding-left: 0.65rem;
}
.pt-timeline-fork-label {
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(180, 120, 255, 0.65);
font-family: var(--font-tech, monospace);
margin-bottom: 0.35rem;
}
.pt-timeline-branches {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.pt-timeline-branch {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 0.5rem 0.65rem;
font-size: 0.68rem;
font-family: var(--font-tech, monospace);
}
.pt-timeline-branch.running {
border-color: rgba(255, 200, 0, 0.45);
box-shadow: 0 0 8px rgba(255, 200, 0, 0.12);
}
.pt-timeline-branch.won,
.pt-timeline-branch.merged {
border-color: rgba(0, 255, 170, 0.45);
box-shadow: 0 0 10px rgba(0, 255, 170, 0.15);
}
.pt-timeline-branch.failed {
border-color: rgba(255, 80, 80, 0.35);
opacity: 0.75;
}
.pt-timeline-branch-head {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.pt-timeline-ghost-icon {
font-size: 0.85rem;
}
.pt-timeline-persona {
color: #e0d4ff;
font-weight: 600;
text-transform: capitalize;
}
.pt-branch-status {
font-size: 0.58rem;
padding: 1px 5px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-left: auto;
}
.pt-branch-status.running { background: rgba(255,200,0,0.15); color: #ffc800; }
.pt-branch-status.won,
.pt-branch-status.merged { background: rgba(0,255,170,0.15); color: #00ffaa; }
.pt-branch-status.failed { background: rgba(255,80,80,0.15); color: #ff5050; }
.pt-branch-status.canonical { background: rgba(0,232,245,0.12); color: #00e8f5; }
.pt-timeline-tier {
font-size: 0.6rem;
color: rgba(0, 232, 245, 0.5);
margin-top: 0.2rem;
}
.pt-timeline-error {
font-size: 0.6rem;
color: #ff7070;
margin-top: 0.2rem;
}
.pt-timeline-canonical {
font-size: 0.62rem;
color: rgba(0, 232, 245, 0.55);
display: flex;
align-items: center;
gap: 0.35rem;
}
.pt-timeline-canonical-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #00e8f5;
box-shadow: 0 0 6px rgba(0, 232, 245, 0.5);
}
.pt-mermaid-details {
margin-top: 0.25rem;
}
.pt-mermaid-details summary {
cursor: pointer;
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(180, 140, 255, 0.7);
font-family: var(--font-tech, monospace);
}
.pt-mermaid-src {
margin-top: 0.5rem;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(180, 120, 255, 0.2);
border-radius: 6px;
padding: 0.65rem;
font-size: 0.58rem;
font-family: monospace;
color: #c8b8ff;
white-space: pre-wrap;
max-height: 220px;
overflow: auto;
}
.pt-btn-sm {
padding: 0.25rem 0.55rem;
font-size: 0.58rem;
margin-top: 0.35rem;
}
.pt-fork-btn {
margin-left: 0.35rem;
padding: 0.15rem 0.4rem;
font-size: 0.55rem;
flex-shrink: 0;
}
@media (max-width: 768px) {
.pt-page {
padding: 0;

View File

@@ -103,6 +103,40 @@ describe('PathTracerPage', () => {
vi.spyOn(api, 'getTraceStatus').mockResolvedValue({ session_id: 'sess-1', ready: false, hops: [] });
vi.spyOn(api, 'getTraceQR').mockResolvedValue({ config: 'wg-conf', qr_png_b64: 'abc123' });
vi.spyOn(api, 'deleteTrace').mockResolvedValue({ ok: true });
vi.spyOn(api, 'forkTraceTimeline').mockResolvedValue({
ok: true,
session_id: 'sess-1',
branches: [
{
id: 'ghost-1',
fork_hop_index: 0,
persona: 'aggressive',
status: 'running',
is_ghost: true,
active_tier: 'smb',
},
],
mermaid: 'graph TD\n fork --> ghost',
});
vi.spyOn(api, 'mergeTraceTimeline').mockResolvedValue({
ok: true,
session_id: 'sess-1',
merged_branch_id: 'ghost-1',
merged_persona: 'aggressive',
merged_spread_lane: 'smb',
branches: [
{
id: 'ghost-1',
fork_hop_index: 0,
persona: 'aggressive',
status: 'merged',
is_ghost: true,
mining_linked: true,
hashrate: 500,
},
],
mermaid: 'graph TD\n fork --> ghost',
});
});
afterEach(() => {
@@ -341,4 +375,66 @@ describe('PathTracerPage', () => {
expect(screen.getByAltText('WireGuard QR')).toBeInTheDocument();
expect(screen.getByText('wg-conf')).toBeInTheDocument();
});
it('shows onion timeline panel after fork at hop', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: true,
hops: [{ agent_id: 'win-1', status: 'ready', agent_name: 'Rig Alpha' }],
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => expect(api.getTraceQR).toHaveBeenCalled());
const forkBtn = screen.getByRole('button', { name: /Fork/i });
await user.click(forkBtn);
await waitFor(() => expect(api.forkTraceTimeline).toHaveBeenCalledWith('sess-1', 0));
expect(await screen.findByTestId('pt-timeline-tree')).toBeInTheDocument();
expect(screen.getByText(/aggressive/i)).toBeInTheDocument();
expect(screen.getByTestId('pt-mermaid-src')).toBeInTheDocument();
});
it('merge best branch calls merge API', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: true,
hops: [{ agent_id: 'win-1', status: 'ready' }],
timeline_branches: [
{ id: 'canonical', fork_hop_index: -1, status: 'canonical', is_ghost: false },
{
id: 'ghost-won',
fork_hop_index: 0,
persona: 'silent',
status: 'won',
is_ghost: true,
mining_linked: true,
hashrate: 900,
},
],
mermaid: 'graph TD\n a --> b',
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => screen.getByTestId('pt-timeline-tree'));
await user.click(screen.getByRole('button', { name: /Merge best branch/i }));
await waitFor(() => expect(api.mergeTraceTimeline).toHaveBeenCalledWith('sess-1', 'ghost-won'));
});
});

View File

@@ -1,10 +1,21 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, PathTraceHop, SpreadRouteRecommendation } from '../types';
import SubnetAutopsyCard from '../components/Atlas/SubnetAutopsyCard';
import { parseSeerSubnetAutopsy, subnetAutopsyPrefix } from '../help/subnetAutopsy';
import { HelpTip } from '../components/HelpTip';
import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader';
import {
branchStatusClass,
branchStatusLabel,
ghostBranchesByHop,
mergeMermaidStyles,
pickMergeCandidate,
type PathTraceTimelineBranch,
type PathTraceTimelineWS,
} from '../help/pathTracerTimeline';
import './PathTracerPage.css';
// ── types ─────────────────────────────────────────────────────────────────────
@@ -15,6 +26,12 @@ interface TraceStatus {
error?: string;
hops: PathTraceHop[];
spread_routes?: SpreadRouteRecommendation[];
timeline_branches?: PathTraceTimelineBranch[];
merged_persona?: string;
merged_spread_lane?: string;
merged_branch_id?: string;
merged_hashrate?: number;
mermaid?: string;
}
interface QRData {
@@ -28,6 +45,71 @@ function HopStatusBadge({ status }: { status: PathTraceHop['status'] }) {
return <span className={`pt-hop-status ${status}`}>{status}</span>;
}
function BranchStatusBadge({ branch }: { branch: PathTraceTimelineBranch }) {
return (
<span className={`pt-branch-status ${branchStatusClass(branch.status)}`}>
{branchStatusLabel(branch.status)}
{branch.mining_linked && branch.hashrate ? ` · ${Math.round(branch.hashrate)} H/s` : ''}
</span>
);
}
function TimelineBranchTree({
branches,
hopCount,
onMerge,
mergeBusy,
}: {
branches: PathTraceTimelineBranch[];
hopCount: number;
onMerge: (branchId: string) => void;
mergeBusy: boolean;
}) {
const byHop = ghostBranchesByHop(branches);
if (byHop.size === 0) return null;
return (
<div className="pt-timeline-tree" data-testid="pt-timeline-tree">
{Array.from(byHop.entries()).map(([hopIdx, ghosts]) => (
<div key={hopIdx} className="pt-timeline-fork-group">
<div className="pt-timeline-fork-label">Fork @ hop {hopIdx + 1}</div>
<div className="pt-timeline-branches">
{ghosts.map((b) => (
<div key={b.id} className={`pt-timeline-branch ${branchStatusClass(b.status)}`}>
<div className="pt-timeline-branch-head">
<span className="pt-timeline-ghost-icon">👻</span>
<span className="pt-timeline-persona">{b.persona}</span>
<BranchStatusBadge branch={b} />
</div>
{b.active_tier && (
<div className="pt-timeline-tier">lane: {b.active_tier}</div>
)}
{b.error && <div className="pt-timeline-error">{b.error}</div>}
{(b.status === 'won' || b.mining_linked) && (
<button
type="button"
className="pt-btn pt-btn-primary pt-btn-sm"
disabled={mergeBusy || b.status === 'merged'}
onClick={() => onMerge(b.id)}
>
{b.status === 'merged' ? 'Merged' : 'Merge winner'}
</button>
)}
</div>
))}
</div>
</div>
))}
{hopCount > 0 && (
<div className="pt-timeline-canonical">
<span className="pt-timeline-canonical-dot" />
Canonical chain · {hopCount} hop{hopCount === 1 ? '' : 's'}
</div>
)}
</div>
);
}
// ── QR Modal ──────────────────────────────────────────────────────────────────
function QRModal({
@@ -102,7 +184,7 @@ function QRModal({
// ── main component ────────────────────────────────────────────────────────────
export default function PathTracerPage() {
const { agents: wsAgents } = useWebSocket();
const { agents: wsAgents, latestMessage } = useWebSocket();
const [restAgents, setRestAgents] = useState<Agent[]>([]);
const [selected, setSelected] = useState<string[]>([]); // ordered chain
const [loading, setLoading] = useState(false);
@@ -114,6 +196,14 @@ export default function PathTracerPage() {
const [qr, setQR] = useState<QRData | null>(null);
const [showQR, setShowQR] = useState(false);
const [spreadRoutes, setSpreadRoutes] = useState<SpreadRouteRecommendation[]>([]);
const [autopsySubnet, setAutopsySubnet] = useState('');
const [timelineBranches, setTimelineBranches] = useState<PathTraceTimelineBranch[]>([]);
const [mermaidSrc, setMermaidSrc] = useState('');
const [mergedPersona, setMergedPersona] = useState('');
const [forkBusyHop, setForkBusyHop] = useState<number | null>(null);
const [mergeBusy, setMergeBusy] = useState(false);
const [timelineNotice, setTimelineNotice] = useState('');
const [graftNoteVisible, setGraftNoteVisible] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [autoEndCountdown, setAutoEndCountdown] = useState<number | null>(null);
@@ -126,6 +216,61 @@ export default function PathTracerPage() {
api.listAgents().then(setRestAgents).catch(() => {});
}, []);
useEffect(() => {
api
.getConfig()
.then((cfg) => {
setGraftNoteVisible(
cfg.server?.ai_control_enabled === true && cfg.server?.fleet_roles_enabled === true,
);
})
.catch(() => setGraftNoteVisible(false));
}, []);
useEffect(() => {
if (!latestMessage || latestMessage.type !== 'seer_events') return;
const ev = parseSeerSubnetAutopsy(latestMessage.payload);
if (ev?.prefix) setAutopsySubnet(ev.prefix);
}, [latestMessage]);
useEffect(() => {
if (autopsySubnet || spreadRoutes.length === 0) return;
const target = spreadRoutes[0]?.target_subnet;
if (target) setAutopsySubnet(subnetAutopsyPrefix(target));
}, [spreadRoutes, autopsySubnet]);
const applyTimelineStatus = useCallback((status: TraceStatus) => {
if (status.timeline_branches?.length) {
setTimelineBranches(status.timeline_branches);
}
if (status.mermaid) {
setMermaidSrc(mergeMermaidStyles(status.mermaid));
}
if (status.merged_persona) {
setMergedPersona(status.merged_persona);
}
}, []);
useEffect(() => {
if (!latestMessage || latestMessage.type !== 'pathtrace_timeline') return;
const p = latestMessage.payload as PathTraceTimelineWS;
if (p.session_id && sessionID && p.session_id !== sessionID) return;
if (p.branches?.length) {
setTimelineBranches(p.branches);
}
if (p.mermaid) {
setMermaidSrc(mergeMermaidStyles(p.mermaid));
}
if (p.event === 'fork') {
setTimelineNotice(`Ghost branches spawned (${p.branches?.filter((b) => b.is_ghost).length ?? 0})`);
} else if (p.event === 'branch_won') {
setTimelineNotice(`Branch won: ${p.branch?.persona ?? 'unknown'} — mining linked`);
} else if (p.event === 'merge') {
setTimelineNotice(`Merged ${p.branch?.persona ?? 'winner'} into canonical timeline`);
if (p.branch?.persona) setMergedPersona(p.branch.persona);
}
}, [latestMessage, sessionID]);
// Stop polling and auto-end timer on unmount.
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
@@ -172,6 +317,7 @@ export default function PathTracerPage() {
if (status.spread_routes?.length) {
setSpreadRoutes(status.spread_routes);
}
applyTimelineStatus(status);
if (status.error) {
setError(status.error);
clearInterval(pollRef.current!);
@@ -213,9 +359,59 @@ export default function PathTracerPage() {
setQR(null);
setSelected([]);
setSpreadRoutes([]);
setTimelineBranches([]);
setMermaidSrc('');
setMergedPersona('');
setTimelineNotice('');
setError('');
}, [sessionID]);
const handleForkAtHop = useCallback(async (hopIndex: number) => {
if (!sessionID || forkBusyHop !== null) return;
setForkBusyHop(hopIndex);
setTimelineNotice('');
try {
const res = await api.forkTraceTimeline(sessionID, hopIndex);
if (res.branches?.length) {
setTimelineBranches((prev) => {
const ids = new Set(prev.map((b) => b.id));
const merged = [...prev];
for (const b of res.branches) {
if (!ids.has(b.id)) merged.push(b);
}
return merged;
});
}
if (res.mermaid) setMermaidSrc(mergeMermaidStyles(res.mermaid));
setTimelineNotice(`Forked at hop ${hopIndex + 1}${res.branches?.length ?? 0} ghost branches exploring`);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fork failed');
} finally {
setForkBusyHop(null);
}
}, [sessionID, forkBusyHop]);
const handleMergeBranch = useCallback(async (branchId: string) => {
if (!sessionID || mergeBusy) return;
setMergeBusy(true);
try {
const res = await api.mergeTraceTimeline(sessionID, branchId);
if (res.branches?.length) setTimelineBranches(res.branches);
if (res.mermaid) setMermaidSrc(mergeMermaidStyles(res.mermaid));
if (res.merged_persona) setMergedPersona(res.merged_persona);
setTimelineNotice(`Merged ${res.merged_persona} (${res.merged_spread_lane ?? 'default lane'})`);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Merge failed');
} finally {
setMergeBusy(false);
}
}, [sessionID, mergeBusy]);
const mergeCandidate = useMemo(
() => pickMergeCandidate(timelineBranches),
[timelineBranches],
);
// Auto-delete the session 10 seconds after an error, with a visible countdown.
useEffect(() => {
if (!error || !sessionID) return;
@@ -258,6 +454,13 @@ export default function PathTracerPage() {
subtitle="Build an on-demand multi-hop WireGuard VPN — select up to 3 agents, click TRACE."
/>
{graftNoteVisible && (
<p className="pt-hint pt-graft-note">
Genealogy grafting is active (Fleet AI + fleet roles) court <code>spread_graft</code> splices
winning strains without re-spreading the target; tier order applies on the agent&apos;s next spread.
</p>
)}
{error && (
<div className="pt-error-banner" role="alert">
<strong> Session Error</strong>
@@ -357,6 +560,20 @@ export default function PathTracerPage() {
{agent?.name ?? id.slice(0, 8)}
</span>
{hop && <HopStatusBadge status={hop.status} />}
{allHopsReady && sessionID && (
<button
type="button"
className="pt-btn pt-btn-ghost pt-btn-sm pt-fork-btn"
disabled={forkBusyHop !== null}
onClick={(e) => {
e.stopPropagation();
handleForkAtHop(i);
}}
title={`Fork onion timeline at hop ${i + 1}`}
>
{forkBusyHop === i ? '…' : '⑂ Fork'}
</button>
)}
</div>
{hop?.external_ip && (
<div className="pt-hop-meta">
@@ -421,6 +638,44 @@ export default function PathTracerPage() {
{allHopsReady && (
<div className="pt-info-banner">
All hops ready tunnel is active.
{mergedPersona && (
<span style={{ marginLeft: '0.5rem', opacity: 0.85 }}>
· merged persona: <strong>{mergedPersona}</strong>
</span>
)}
</div>
)}
{(timelineBranches.some((b) => b.is_ghost) || mermaidSrc) && (
<div className="pt-timeline-panel">
<div className="pt-chain-title">
Onion Timeline <HelpTip field="pt_onion_timeline" />
</div>
{timelineNotice && (
<div className="pt-timeline-notice">{timelineNotice}</div>
)}
<TimelineBranchTree
branches={timelineBranches}
hopCount={hops.length}
onMerge={handleMergeBranch}
mergeBusy={mergeBusy}
/>
{mergeCandidate && mergeCandidate.status === 'won' && (
<button
type="button"
className="pt-btn pt-btn-primary"
disabled={mergeBusy}
onClick={() => handleMergeBranch(mergeCandidate.id)}
>
Merge best branch ({mergeCandidate.persona})
</button>
)}
{mermaidSrc && (
<details className="pt-mermaid-details">
<summary>Mermaid branch graph</summary>
<pre className="pt-mermaid-src" data-testid="pt-mermaid-src">{mermaidSrc}</pre>
</details>
)}
</div>
)}
@@ -439,6 +694,10 @@ export default function PathTracerPage() {
</ul>
</div>
)}
{autopsySubnet && (
<SubnetAutopsyCard subnet={autopsySubnet} compact />
)}
</div>
</div>

View File

@@ -118,11 +118,43 @@ export interface Agent {
parent_agent_id?: string;
spread_generation?: number;
spread_strain?: string;
graft_source_strain?: string;
graft_tier?: string;
graft_approved_at?: string;
/** Cloned fleet phenotype from a sibling with the same host fingerprint. */
inherited_phenotype?: InheritedPhenotype;
}
/** Light gamification card for a winning spread tree lineage. */
export interface StrainCard {
id: string;
root_agent_id: string;
source_agent_id: string;
source_agent_name: string;
spread_strain: string;
spread_lane: string;
persona: string;
parents: StrainCardParent[];
wins: string[];
losses: string[];
subnets: string[];
erasure_recovery_rate: number;
peak_hashrate: number;
tier_order: string[];
tree_size: number;
created_at?: string;
updated_at?: string;
}
export interface StrainCardParent {
agent_id: string;
agent_name?: string;
join_lane?: string;
spread_lane?: string;
generation?: number;
}
export interface InheritedPhenotype {
source_agent_name: string;
fingerprint?: string;
@@ -482,6 +514,24 @@ export interface AIDecisionRecord {
judge_verdict?: string;
}
/** Seer LLM stream event — GET /api/v1/seer/stream */
export interface SeerEventRecord {
id: number;
event_type: string;
agent_id?: string;
payload: unknown;
ts?: string;
}
/** Seer persisted AI memory note */
export interface SeerNoteRecord {
id: number;
agent_id?: string;
note: string;
source?: string;
ts?: string;
}
export interface EarningsEstimate {
hashrate: number;
xmr_per_day: number;
@@ -697,6 +747,38 @@ export interface SpreadRouteRecommendation {
erasure_lanes_enabled?: boolean;
}
export interface SubnetAutopsyAttempt {
agent_id?: string;
agent_name?: string;
tier: string;
ok: boolean;
error?: string;
duration_ms?: number;
phase?: string;
}
export interface SubnetAutopsyPacket {
prefix: string;
triggered_at: string;
fail_count: number;
paused_until?: string;
lotl_attempts: SubnetAutopsyAttempt[];
wsus_mimic: {
format_mimic_enabled: boolean;
cache_peer_lane: string;
recent_join_lane?: string;
};
persona: string;
erasure_fallback: {
erasure_lanes_enabled: boolean;
available_as_fallback: boolean;
};
gossip_whispers: { tier: string; condition: string; reason?: string }[];
failure_atlas?: string;
cause_of_death: string;
vaccination_lane?: SpreadRouteRecommendation;
}
export interface ServiceGraphEntry {
service_name: string;
port?: number;