Add fleet phenotype cloning for sibling machines.

Publish winning tier paths per host fingerprint on hashrate success, inherit on auth before adaptive strategy, and surface clone badges in LOTL Timeline and Access Depth.
This commit is contained in:
AetherForge
2026-06-07 02:28:00 -07:00
parent bb48515b2b
commit 4204dd6b6d
25 changed files with 1421 additions and 38 deletions

View File

@@ -69,6 +69,12 @@
font-style: italic;
}
.access-depth-phenotype {
margin-top: 0.45rem;
font-size: 0.72rem;
color: var(--text-muted);
}
.access-depth-join {
display: flex;
flex-wrap: wrap;
@@ -192,6 +198,21 @@
border: 1px solid rgba(255, 180, 60, 0.3);
}
.access-depth-atlas-list {
margin: 0;
padding-left: 1.1rem;
font-size: 0.78rem;
color: var(--text-muted);
}
.access-depth-atlas-row {
margin: 0.2rem 0;
}
.access-depth-onion-item--skipped_by_atlas .access-depth-onion-label {
opacity: 0.7;
}
.access-depth-source {
margin-left: 0.35rem;
color: var(--text-muted);
@@ -220,3 +241,35 @@
.access-depth-calibrate-link:hover {
text-decoration: underline;
}
.access-depth-clearance-badge {
font-family: var(--font-tech);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--neon-cyan);
border: 1px solid rgba(0, 245, 255, 0.45);
border-radius: 3px;
padding: 1px 6px;
cursor: help;
}
.access-depth-clearance-flash {
font-size: 0.68rem;
color: var(--neon-amber, #ffb347);
background: rgba(255, 180, 60, 0.12);
border: 1px solid rgba(255, 180, 60, 0.45);
border-radius: 3px;
padding: 2px 8px;
animation: access-depth-clearance-flash 1.2s ease-in-out 2;
}
@keyframes access-depth-clearance-flash {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}

View File

@@ -12,6 +12,10 @@ import {
parseAccessDepthServerPolicy,
} from '../../help/accessDepth';
vi.mock('../../hooks/useWebSocket', () => ({
useWebSocket: () => ({ latestMessage: null }),
}));
vi.mock('../../api/client', () => ({
api: {
getConfig: vi.fn().mockResolvedValue({
@@ -155,6 +159,22 @@ describe('AccessDepthPanel', () => {
expect(await screen.findByText('WinRM')).toBeInTheDocument();
});
it('shows phenotype source when inherited phenotype present', async () => {
renderPanel(
mockAgent({
platform: 'windows',
inherited_phenotype: {
source_agent_name: 'worker-07',
spread_lane: 'winrm',
tier_order: ['container', 'wsl', 'cpu_inprocess'],
},
}),
);
expect(await screen.findByText(/phenotype cloned from/i)).toBeInTheDocument();
expect(screen.getByText('worker-07')).toBeInTheDocument();
expect(await screen.findByText('WinRM')).toBeInTheDocument();
});
it('renders adaptive strategy reasoning fixtures', () => {
renderPanel(
mockAgent({ platform: 'windows', status: 'online' }),
@@ -180,6 +200,13 @@ describe('AccessDepthPanel', () => {
expect(screen.getByText(/confidence 72%/i)).toBeInTheDocument();
});
it('renders clearance badge L0L4 with tooltip permissions', () => {
renderPanel(mockAgent({ clearance_level: 2 }));
const badge = screen.getByLabelText(/Clearance L2/i);
expect(badge).toHaveTextContent('L2');
expect(badge.getAttribute('title')).toMatch(/spread/i);
});
it('shows pending chain when tiers not yet attempted', () => {
renderPanel(
mockAgent({

View File

@@ -1,11 +1,18 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client';
import {
atlasSkipDisplayLabel,
buildAccessDepthModel,
parseAccessDepthServerPolicy,
type AccessDepthDiagnostics,
} from '../../help/accessDepth';
import {
clearanceLabel,
clearancePermissions,
formatClearanceElevation,
} from '../../help/clearance';
import { useWebSocket } from '../../hooks/useWebSocket';
import type { Agent } from '../../types';
import { HelpTip } from '../HelpTip';
import JoinLaneBadge from './JoinLaneBadge';
@@ -31,6 +38,9 @@ function OnionList({ rows, empty }: { rows: ReturnType<typeof buildAccessDepthMo
<span className="access-depth-onion-label">{row.label}</span>
{row.status === 'active' && <span className="access-depth-tag access-depth-tag--active">active</span>}
{row.status === 'skipped' && <span className="access-depth-tag access-depth-tag--skip">skipped</span>}
{row.status === 'skipped_by_atlas' && (
<span className="access-depth-tag access-depth-tag--skip">atlas skip</span>
)}
{row.status === 'done' && <span className="access-depth-tag access-depth-tag--ok">ok</span>}
{row.status === 'failed' && <span className="access-depth-tag access-depth-tag--fail">fail</span>}
{row.status === 'pending' && <span className="access-depth-tag access-depth-tag--pending">pending</span>}
@@ -63,8 +73,38 @@ function AttemptMiniList({
}
export default function AccessDepthPanel({ agent, diagnostics }: Props) {
const { latestMessage } = useWebSocket();
const [policyLoaded, setPolicyLoaded] = useState(false);
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
const [elevationFlash, setElevationFlash] = useState<string | null>(null);
const flashTimerRef = useRef<number | null>(null);
const clearanceLevel = agent.clearance_level ?? 1;
useEffect(() => {
if (!latestMessage || latestMessage.type !== 'clearance_elevated') return;
const p = latestMessage.payload as {
agent_id?: string;
to_level?: number;
source?: string;
reason?: string;
};
if (p.agent_id !== agent.id || typeof p.to_level !== 'number') return;
const text = formatClearanceElevation({
to_level: p.to_level,
source: p.source,
reason: p.reason,
});
setElevationFlash(text);
if (flashTimerRef.current != null) window.clearTimeout(flashTimerRef.current);
flashTimerRef.current = window.setTimeout(() => setElevationFlash(null), 6000);
}, [latestMessage, agent.id]);
useEffect(() => {
return () => {
if (flashTimerRef.current != null) window.clearTimeout(flashTimerRef.current);
};
}, []);
useEffect(() => {
let cancelled = false;
@@ -95,6 +135,18 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
<span className="lotl-attempts-title">
ACCESS DEPTH <HelpTip field="crucible_access_depth" />
</span>
<span
className="access-depth-clearance-badge"
title={clearancePermissions(clearanceLevel)}
aria-label={`Clearance ${clearanceLabel(clearanceLevel)}: ${clearancePermissions(clearanceLevel)}`}
>
{clearanceLabel(clearanceLevel)}
</span>
{elevationFlash && (
<span className="access-depth-clearance-flash" role="status">
{elevationFlash}
</span>
)}
{!policyLoaded && <span className="access-depth-muted">loading policy</span>}
</div>
@@ -137,6 +189,17 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
) : (
<div className="access-depth-muted">No join lane yet</div>
)}
{model.phenotypeSource && (
<div className="access-depth-phenotype">
phenotype cloned from <strong>{model.phenotypeSource}</strong>
{model.phenotypeSpreadLane ? (
<>
{' '}
· spread <JoinLaneBadge lane={model.phenotypeSpreadLane} />
</>
) : null}
</div>
)}
</div>
<div className="access-depth-section">
@@ -161,6 +224,19 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
</div>
</div>
{model.atlasSkips.length > 0 && (
<div className="access-depth-section access-depth-atlas-block">
<div className="access-depth-section-title">Atlas skips</div>
<ul className="access-depth-atlas-list">
{model.atlasSkips.map((skip) => (
<li key={`${skip.tier}-${skip.condition}`} className="access-depth-atlas-row">
{atlasSkipDisplayLabel(skip)}
</li>
))}
</ul>
</div>
)}
{model.strategyReasoning.length > 0 && (
<div className="access-depth-section access-depth-strategy-block">
<div className="access-depth-section-title">

View File

@@ -40,6 +40,20 @@ describe('LotlTierTimeline', () => {
expect(screen.getByText('GPO')).toBeInTheDocument();
});
it('shows cloned-from badge when inherited phenotype present', () => {
const order = resolveLotlTierOrder();
const agent = mockAgent({
inherited_phenotype: {
source_agent_name: 'worker-07',
tier_order: ['container', 'wsl'],
spread_lane: 'winrm',
},
});
const model = buildLotlTimelineModel(agent, order, []);
render(<LotlTierTimeline model={model} agentName="Node B" clonedFrom="worker-07" />);
expect(screen.getByText('Cloned from worker-07')).toBeInTheDocument();
});
it('expands attempt detail on tier click', () => {
const order = resolveLotlTierOrder();
const agent = mockAgent({

View File

@@ -7,6 +7,7 @@ import '../Fleet/LotlVisuals.css';
interface Props {
model: LotlTimelineModel;
agentName: string;
clonedFrom?: string;
className?: string;
}
@@ -17,6 +18,7 @@ function stateGlyph(state: LotlTimelineTierRow['state']): string {
case 'failed':
return '✗';
case 'skipped':
case 'skipped_by_atlas':
return '—';
case 'trying':
return '◉';
@@ -49,14 +51,18 @@ function TierDetail({ row }: { row: LotlTimelineTierRow }) {
</>
) : (
<div className="lotl-tier-detail-row">
{row.state === 'skipped' ? 'Tier skipped by policy' : 'No attempt recorded yet'}
{row.state === 'skipped_by_atlas'
? 'Tier skipped by failure atlas'
: row.state === 'skipped'
? 'Tier skipped by policy'
: 'No attempt recorded yet'}
</div>
)}
</div>
);
}
export default function LotlTierTimeline({ model, agentName, className = '' }: Props) {
export default function LotlTierTimeline({ model, agentName, clonedFrom, className = '' }: Props) {
const [expandedTier, setExpandedTier] = useState<string | null>(null);
const expandedRow = useMemo(
@@ -73,7 +79,14 @@ export default function LotlTierTimeline({ model, agentName, className = '' }: P
<div className="lotl-tier-timeline-header">
<div>
<div className="lotl-tier-timeline-title">ONION TIER CHAIN</div>
<div className="lotl-tier-timeline-agent">{agentName}</div>
<div className="lotl-tier-timeline-agent">
{agentName}
{clonedFrom && (
<span className="lotl-phenotype-badge" title={`Cloned tier order from ${clonedFrom}`}>
Cloned from {clonedFrom}
</span>
)}
</div>
</div>
<div className="lotl-tier-timeline-progress">
{model.succeeded}/{model.total} tiers succeeded

View File

@@ -116,6 +116,19 @@
color: var(--text-muted);
}
.lotl-phenotype-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
border-radius: 3px;
font-size: 0.62rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9dffb8;
background: rgba(40, 120, 80, 0.25);
border: 1px solid rgba(80, 200, 130, 0.35);
}
.lotl-tier-timeline-progress {
font-family: var(--font-tech);
font-size: 0.75rem;
@@ -185,12 +198,17 @@
border-color: rgba(255, 255, 255, 0.1);
}
.lotl-tier-step--skipped .lotl-tier-node {
.lotl-tier-step--skipped .lotl-tier-node,
.lotl-tier-step--skipped_by_atlas .lotl-tier-node {
color: rgba(180, 180, 190, 0.5);
border-color: rgba(255, 255, 255, 0.06);
opacity: 0.55;
}
.lotl-tier-step--skipped_by_atlas .lotl-tier-node {
border-color: rgba(255, 140, 80, 0.25);
}
.lotl-tier-step--success .lotl-tier-node {
color: #7dffaa;
border-color: rgba(100, 220, 140, 0.45);
@@ -302,6 +320,43 @@
margin-bottom: 0.25rem;
}
.lotl-ai-decision--court {
border-color: rgba(255, 170, 100, 0.35);
background: rgba(40, 24, 8, 0.35);
}
.lotl-court-role {
margin-top: 0.35rem;
}
.lotl-court-role-label {
display: block;
font-family: var(--font-tech);
font-size: 0.58rem;
letter-spacing: 0.08em;
color: #ffaa66;
margin-bottom: 0.15rem;
}
.lotl-court-role-text {
color: var(--text-muted);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.35;
}
.lotl-ai-decision-commands {
margin-top: 0.25rem;
font-size: 0.68rem;
color: #7dffaa;
}
.lotl-ai-decision-ts {
margin-top: 0.25rem;
font-size: 0.65rem;
color: var(--text-muted);
}
.lotl-timeline-links {
margin-top: 0.85rem;
font-size: 0.72rem;
@@ -324,6 +379,43 @@
font-size: 0.85rem;
}
.lotl-clearance-timeline {
margin-top: 0.85rem;
padding: 0.65rem 0.75rem;
border-radius: var(--deck-card-radius, 6px);
border: 1px solid rgba(255, 180, 60, 0.25);
background: rgba(8, 6, 4, 0.45);
}
.lotl-clearance-event-list {
list-style: none;
margin: 0.35rem 0 0;
padding: 0;
}
.lotl-clearance-event-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.35rem 0.75rem;
font-size: 0.68rem;
padding: 0.2rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.lotl-clearance-event-row:last-child {
border-bottom: none;
}
.lotl-clearance-event-summary {
color: var(--neon-amber, #ffb347);
}
.lotl-clearance-event-ts {
color: var(--text-muted);
font-size: 0.62rem;
}
@media (max-width: 720px) {
.lotl-tier-track {
flex-direction: column;