Add scout constellation mode for APK venue persona packs.
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
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 L0–L4 with tooltip permissions', () => {
|
||||
renderPanel(mockAgent({ clearance_level: 2 }));
|
||||
const badge = screen.getByLabelText(/Clearance L2/i);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
server/web/src/help/scoutBiomeWeather.test.ts
Normal file
29
server/web/src/help/scoutBiomeWeather.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
server/web/src/help/scoutBiomeWeather.ts
Normal file
56
server/web/src/help/scoutBiomeWeather.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user