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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 L0–L4 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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,10 +27,17 @@ export interface AdaptiveStrategyView {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface AtlasSkipView {
|
||||
tier: string;
|
||||
condition: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AccessDepthDiagnostics {
|
||||
environment_probes?: EnvironmentProbes;
|
||||
tier_chain_order?: string[];
|
||||
tier_chain_skipped?: string[];
|
||||
atlas_skips?: AtlasSkipView[];
|
||||
lotl_tier?: string;
|
||||
lotl_attempts?: TierAttempt[];
|
||||
active_method?: string;
|
||||
@@ -67,7 +74,8 @@ export interface OnionTierRow {
|
||||
index: number;
|
||||
tier: string;
|
||||
label: string;
|
||||
status: 'active' | 'skipped' | 'pending' | 'done' | 'failed' | 'neutral';
|
||||
status: 'active' | 'skipped' | 'skipped_by_atlas' | 'pending' | 'done' | 'failed' | 'neutral';
|
||||
atlasCondition?: string;
|
||||
}
|
||||
|
||||
export interface AccessDepthModel {
|
||||
@@ -92,6 +100,9 @@ export interface AccessDepthModel {
|
||||
adaptiveActive: boolean;
|
||||
strategyReasoning: StrategyReason[];
|
||||
adaptiveConfidence?: number;
|
||||
atlasSkips: AtlasSkipView[];
|
||||
phenotypeSource?: string;
|
||||
phenotypeSpreadLane?: string;
|
||||
}
|
||||
|
||||
/** Default mining tier onion pushed at agent auth when Calibrate sends no override. */
|
||||
@@ -143,11 +154,13 @@ export function parseAccessDepthDiagnostics(raw: Record<string, unknown>): Acces
|
||||
|
||||
const adaptive = parseAdaptiveStrategy(raw.adaptive_strategy);
|
||||
const reasoning = parseStrategyReasoning(raw.strategy_reasoning) ?? adaptive?.reasoning;
|
||||
const atlas_skips = parseAtlasSkips(raw.atlas_skips);
|
||||
|
||||
return {
|
||||
environment_probes: parseEnvironmentProbes(raw.environment_probes),
|
||||
tier_chain_order: tier_chain_order?.length ? tier_chain_order : undefined,
|
||||
tier_chain_skipped: tier_chain_skipped?.length ? tier_chain_skipped : undefined,
|
||||
atlas_skips,
|
||||
lotl_tier,
|
||||
lotl_attempts: attempts.length ? attempts : undefined,
|
||||
active_method: typeof raw.active_method === 'string' ? raw.active_method : undefined,
|
||||
@@ -216,6 +229,43 @@ function probeChips(probes: EnvironmentProbes | undefined, agent: Agent): ProbeC
|
||||
return chips.filter((c) => c.ok || probes != null);
|
||||
}
|
||||
|
||||
function parseAtlasSkips(raw: unknown): AtlasSkipView[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const out: AtlasSkipView[] = [];
|
||||
for (const row of raw) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
const r = row as Record<string, unknown>;
|
||||
if (typeof r.tier !== 'string' || typeof r.condition !== 'string') continue;
|
||||
out.push({
|
||||
tier: r.tier,
|
||||
condition: r.condition,
|
||||
reason: typeof r.reason === 'string' ? r.reason : '',
|
||||
});
|
||||
}
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
export function atlasConditionLabel(condition: string): string {
|
||||
switch (condition) {
|
||||
case 'defender_on':
|
||||
return 'Defender on';
|
||||
case 'no_docker':
|
||||
return 'no Docker';
|
||||
case 'av_blocks_exe':
|
||||
return 'AV blocks exe';
|
||||
case 'goos=windows':
|
||||
return 'Windows';
|
||||
case 'goos=linux':
|
||||
return 'Linux';
|
||||
default:
|
||||
return condition.replace(/_/g, ' ');
|
||||
}
|
||||
}
|
||||
|
||||
export function atlasSkipDisplayLabel(skip: AtlasSkipView): string {
|
||||
return `${formatLotlTierLabel(skip.tier)} (${atlasConditionLabel(skip.condition)})`;
|
||||
}
|
||||
|
||||
function spreadCapabilities(agent: Agent): string[] {
|
||||
const caps = agent.capabilities;
|
||||
const out: string[] = [];
|
||||
@@ -303,8 +353,13 @@ function buildOnionRows(
|
||||
skipped: string[],
|
||||
attempts: TierAttempt[],
|
||||
activeTier?: string,
|
||||
atlasSkips: AtlasSkipView[] = [],
|
||||
): OnionTierRow[] {
|
||||
const skippedSet = new Set(skipped.map((s) => s.toLowerCase()));
|
||||
const atlasByTier = new Map<string, AtlasSkipView>();
|
||||
for (const skip of atlasSkips) {
|
||||
atlasByTier.set(skip.tier.toLowerCase(), skip);
|
||||
}
|
||||
const okSet = new Set(attempts.filter((a) => a.ok).map((a) => a.tier.toLowerCase()));
|
||||
const failSet = new Set(attempts.filter((a) => !a.ok).map((a) => a.tier.toLowerCase()));
|
||||
const active = activeTier?.toLowerCase();
|
||||
@@ -313,12 +368,15 @@ function buildOnionRows(
|
||||
const fullOrder = [
|
||||
...order,
|
||||
...skipped.filter((s) => !orderLower.has(s.toLowerCase())),
|
||||
...atlasSkips.map((s) => s.tier).filter((s) => !orderLower.has(s.toLowerCase()) && !skippedSet.has(s.toLowerCase())),
|
||||
];
|
||||
|
||||
return fullOrder.map((tier, i) => {
|
||||
const key = tier.toLowerCase();
|
||||
const atlas = atlasByTier.get(key);
|
||||
let status: OnionTierRow['status'] = 'neutral';
|
||||
if (active && key === active) status = 'active';
|
||||
else if (atlas) status = 'skipped_by_atlas';
|
||||
else if (skippedSet.has(key)) status = 'skipped';
|
||||
else if (okSet.has(key)) status = 'done';
|
||||
else if (failSet.has(key)) status = 'failed';
|
||||
@@ -329,6 +387,7 @@ function buildOnionRows(
|
||||
tier,
|
||||
label: formatLotlTierLabel(tier),
|
||||
status,
|
||||
atlasCondition: atlas?.condition,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -367,6 +426,7 @@ export function buildAccessDepthModel(
|
||||
): AccessDepthModel {
|
||||
const attempts = diagnostics?.lotl_attempts ?? agent.lotl_attempts ?? [];
|
||||
const activeTier = diagnostics?.lotl_tier ?? agent.lotl_tier;
|
||||
const atlasSkips = diagnostics?.atlas_skips ?? [];
|
||||
const { order, skipped, source } = resolveMiningOrder(diagnostics, policy, agent);
|
||||
const pending = computePendingTiers(order, skipped, attempts);
|
||||
const inProgressTier = detectInProgress(agent, attempts, pending, activeTier);
|
||||
@@ -402,7 +462,7 @@ export function buildAccessDepthModel(
|
||||
inProgressLabel: inProgressTier ? formatLotlTierLabel(inProgressTier) : undefined,
|
||||
pendingTiers: pending,
|
||||
pendingLabels: pending.map(formatLotlTierLabel),
|
||||
miningOnion: buildOnionRows(order, skipped, attempts, activeTier),
|
||||
miningOnion: buildOnionRows(order, skipped, attempts, activeTier, atlasSkips),
|
||||
spreadOnion: spreadOrder.map((tier, i) => ({
|
||||
index: i + 1,
|
||||
tier,
|
||||
@@ -414,6 +474,9 @@ export function buildAccessDepthModel(
|
||||
adaptiveActive: source === 'adaptive',
|
||||
strategyReasoning: diagnostics?.strategy_reasoning ?? diagnostics?.adaptive_strategy?.reasoning ?? [],
|
||||
adaptiveConfidence: diagnostics?.adaptive_strategy?.confidence,
|
||||
atlasSkips,
|
||||
phenotypeSource: agent.inherited_phenotype?.source_agent_name,
|
||||
phenotypeSpreadLane: agent.inherited_phenotype?.spread_lane,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildLotlTimelineModel,
|
||||
resolveLotlTierOrder,
|
||||
} from '../help/lotlTimeline';
|
||||
import { clearanceTimelineSummary, type ClearanceEventRecord } from '../help/clearance';
|
||||
import { parseAccessDepthServerPolicy } from '../help/accessDepth';
|
||||
import type { AIDecisionRecord } from '../types';
|
||||
import LotlFleetOverview from '../components/Lotl/LotlFleetOverview';
|
||||
@@ -19,7 +20,16 @@ export default function LotlTimelinePage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [tierOrder, setTierOrder] = useState<string[]>(() => resolveLotlTierOrder());
|
||||
const [aiControlEnabled, setAiControlEnabled] = useState(false);
|
||||
const [lastDecision, setLastDecision] = useState<{ response?: string; commands?: string; ts?: string } | null>(null);
|
||||
const [lastDecision, setLastDecision] = useState<{
|
||||
response?: string;
|
||||
commands?: string;
|
||||
ts?: string;
|
||||
court_session?: boolean;
|
||||
prosecutor_snippet?: string;
|
||||
defender_snippet?: string;
|
||||
judge_verdict?: string;
|
||||
} | null>(null);
|
||||
const [clearanceEvents, setClearanceEvents] = useState<ClearanceEventRecord[]>([]);
|
||||
|
||||
const paramAgentId = searchParams.get('agent') ?? '';
|
||||
|
||||
@@ -60,6 +70,10 @@ export default function LotlTimelinePage() {
|
||||
response: row.response,
|
||||
commands: row.commands_executed,
|
||||
ts: row.ts,
|
||||
court_session: row.court_session,
|
||||
prosecutor_snippet: row.prosecutor_snippet,
|
||||
defender_snippet: row.defender_snippet,
|
||||
judge_verdict: row.judge_verdict,
|
||||
});
|
||||
} else {
|
||||
setLastDecision(null);
|
||||
@@ -73,6 +87,25 @@ export default function LotlTimelinePage() {
|
||||
};
|
||||
}, [selectedAgent, aiControlEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAgent) {
|
||||
setClearanceEvents([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api
|
||||
.getClearanceEvents(selectedAgent.id, 20)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setClearanceEvents(rows ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setClearanceEvents([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedAgent]);
|
||||
|
||||
const selectAgent = useCallback(
|
||||
(agentId: string) => {
|
||||
setSearchParams({ agent: agentId }, { replace: true });
|
||||
@@ -88,6 +121,8 @@ export default function LotlTimelinePage() {
|
||||
selectedAgent,
|
||||
tierOrder,
|
||||
selectedAgent.lotl_attempts ?? [],
|
||||
[],
|
||||
selectedAgent.atlas_skips ?? [],
|
||||
);
|
||||
}, [selectedAgent, tierOrder]);
|
||||
|
||||
@@ -124,27 +159,81 @@ export default function LotlTimelinePage() {
|
||||
</div>
|
||||
) : timelineModel ? (
|
||||
<>
|
||||
<LotlTierTimeline model={timelineModel} agentName={selectedAgent.name} />
|
||||
<LotlTierTimeline
|
||||
model={timelineModel}
|
||||
agentName={selectedAgent.name}
|
||||
clonedFrom={selectedAgent.inherited_phenotype?.source_agent_name}
|
||||
/>
|
||||
|
||||
{clearanceEvents.length > 0 && (
|
||||
<div className="lotl-clearance-events">
|
||||
<div className="lotl-ai-decision-label">CLEARANCE HISTORY</div>
|
||||
<ul className="lotl-clearance-list">
|
||||
{clearanceEvents.map((ev) => (
|
||||
<li key={ev.id}>{clearanceTimelineSummary(ev)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiEnabled && lastDecision && (
|
||||
<div className="lotl-ai-decision">
|
||||
<div className="lotl-ai-decision-label">LAST AI DECISION</div>
|
||||
{lastDecision.response && (
|
||||
<div style={{ color: 'var(--text-muted)' }}>{lastDecision.response}</div>
|
||||
<div className={`lotl-ai-decision${lastDecision.court_session ? ' lotl-ai-decision--court' : ''}`}>
|
||||
<div className="lotl-ai-decision-label">
|
||||
{lastDecision.court_session ? 'SINGULAR MACHINE COURT' : 'LAST AI DECISION'}
|
||||
</div>
|
||||
{lastDecision.court_session ? (
|
||||
<>
|
||||
{lastDecision.prosecutor_snippet && (
|
||||
<div className="lotl-court-role">
|
||||
<span className="lotl-court-role-label">Prosecutor</span>
|
||||
<div className="lotl-court-role-text">{lastDecision.prosecutor_snippet}</div>
|
||||
</div>
|
||||
)}
|
||||
{lastDecision.defender_snippet && (
|
||||
<div className="lotl-court-role">
|
||||
<span className="lotl-court-role-label">Defender</span>
|
||||
<div className="lotl-court-role-text">{lastDecision.defender_snippet}</div>
|
||||
</div>
|
||||
)}
|
||||
{(lastDecision.judge_verdict || lastDecision.response) && (
|
||||
<div className="lotl-court-role">
|
||||
<span className="lotl-court-role-label">Judge</span>
|
||||
<div className="lotl-court-role-text">
|
||||
{lastDecision.judge_verdict || lastDecision.response}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
lastDecision.response && (
|
||||
<div style={{ color: 'var(--text-muted)' }}>{lastDecision.response}</div>
|
||||
)
|
||||
)}
|
||||
{lastDecision.commands && (
|
||||
<div style={{ marginTop: '0.25rem', fontSize: '0.68rem', color: '#7dffaa' }}>
|
||||
{lastDecision.commands}
|
||||
</div>
|
||||
<div className="lotl-ai-decision-commands">{lastDecision.commands}</div>
|
||||
)}
|
||||
{lastDecision.ts && (
|
||||
<div style={{ marginTop: '0.25rem', fontSize: '0.65rem', color: 'var(--text-muted)' }}>
|
||||
<div className="lotl-ai-decision-ts">
|
||||
{new Date(lastDecision.ts).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clearanceEvents.length > 0 && (
|
||||
<div className="lotl-clearance-timeline">
|
||||
<div className="lotl-ai-decision-label">CLEARANCE EVENTS</div>
|
||||
<ul className="lotl-clearance-event-list">
|
||||
{clearanceEvents.map((ev) => (
|
||||
<li key={ev.id} className="lotl-clearance-event-row">
|
||||
<span className="lotl-clearance-event-summary">{clearanceTimelineSummary(ev)}</span>
|
||||
<span className="lotl-clearance-event-ts">{new Date(ev.ts).toLocaleString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="lotl-timeline-links">
|
||||
Data sources: Crucible →{' '}
|
||||
<Link to={`/crucible?agent=${encodeURIComponent(selectedAgent.id)}`}>Access Depth panel</Link>
|
||||
|
||||
@@ -98,12 +98,29 @@ export interface Agent {
|
||||
lotl_tier?: string;
|
||||
/** Per-tier attempt history from agent TierReport. */
|
||||
lotl_attempts?: import('./lotl').TierAttempt[];
|
||||
/** Fleet failure atlas hard subtree skips. */
|
||||
atlas_skips?: { tier: string; condition: string; reason: string }[];
|
||||
|
||||
/** Read-only vuln probe findings (LOTL recon tier). */
|
||||
vuln_findings?: import('./recon').VulnFinding[];
|
||||
vuln_risk_score?: number;
|
||||
/** Last successful discover_and_join deploy lane (winrm, smb, gpo, docker, …). */
|
||||
join_lane?: string;
|
||||
|
||||
/** Session security clearance L0–L4 (live from server). */
|
||||
clearance_level?: number;
|
||||
|
||||
/** Cloned fleet phenotype from a sibling with the same host fingerprint. */
|
||||
inherited_phenotype?: InheritedPhenotype;
|
||||
}
|
||||
|
||||
export interface InheritedPhenotype {
|
||||
source_agent_name: string;
|
||||
fingerprint?: string;
|
||||
spread_lane?: string;
|
||||
tier_order?: string[];
|
||||
active_tier?: string;
|
||||
peak_hashrate?: number;
|
||||
}
|
||||
|
||||
export type { LOTLTier, TierAttempt, TierReport } from './lotl';
|
||||
@@ -444,6 +461,10 @@ export interface AIDecisionRecord {
|
||||
response?: string;
|
||||
commands_executed?: string;
|
||||
ts?: string;
|
||||
court_session?: boolean;
|
||||
prosecutor_snippet?: string;
|
||||
defender_snippet?: string;
|
||||
judge_verdict?: string;
|
||||
}
|
||||
|
||||
export interface EarningsEstimate {
|
||||
|
||||
Reference in New Issue
Block a user