| 1 | #nullable enable |
| 2 | |
| 3 | using CascadeIDE.Cockpit.DataBus; |
| 4 | |
| 5 | namespace CascadeIDE.Features.Agent.Environment; |
| 6 | |
| 7 | public enum VerifyRungUiState |
| 8 | { |
| 9 | Pending, |
| 10 | Running, |
| 11 | Pass, |
| 12 | Fail, |
| 13 | Cancelled, |
| 14 | Died, |
| 15 | Skipped, |
| 16 | } |
| 17 | |
| 18 | public sealed record VerifyRungUiEntry( |
| 19 | string RungId, |
| 20 | VerifyRungUiState State, |
| 21 | double DurationSeconds, |
| 22 | string? Detail); |
| 23 | |
| 24 | /// <summary>Shared Verify Epoch formatting for chat + PFD (agent-verify-epoch-view-v1).</summary> |
| 25 | public static class AgentVerifyEpochFormatter |
| 26 | { |
| 27 | public static readonly string[] OrderedRungs = |
| 28 | [ |
| 29 | VerifyRung.DiagnoseFiles, |
| 30 | VerifyRung.CompileProject, |
| 31 | VerifyRung.BuildAffected, |
| 32 | VerifyRung.TestScoped, |
| 33 | VerifyRung.TestFull, |
| 34 | ]; |
| 35 | |
| 36 | public static string? MapTaskKindToRung(string kind) => kind switch |
| 37 | { |
| 38 | "roslyn.diagnose" => VerifyRung.DiagnoseFiles, |
| 39 | "msbuild.compile" => VerifyRung.BuildAffected, |
| 40 | "dotnet.test" => VerifyRung.TestScoped, |
| 41 | _ => null, |
| 42 | }; |
| 43 | |
| 44 | public static string FormatCompletedChatTrace(AgentRunCompleted evt) |
| 45 | { |
| 46 | var rungLines = FormatRungLinesFromCompleted(evt); |
| 47 | var status = evt.Green ? "green" : "failed"; |
| 48 | return $""" |
| 49 | [AEE] verify {evt.RunId[..8]}… |
| 50 | {rungLines} |
| 51 | Status: {status} · max rung: {evt.MaxRungReached} |
| 52 | """; |
| 53 | } |
| 54 | |
| 55 | public static string FormatRungLinesFromCompleted(AgentRunCompleted evt) |
| 56 | { |
| 57 | var entries = BuildEntriesFromTimeSlices(evt.TimeSlices, evt.Green, evt.MaxRungReached); |
| 58 | return FormatRungLineBlock(entries, includeSkipped: false); |
| 59 | } |
| 60 | |
| 61 | public static IReadOnlyList<VerifyRungUiEntry> BuildEntriesFromTimeSlices( |
| 62 | IReadOnlyList<AgentTimeSlice> timeSlices, |
| 63 | bool green, |
| 64 | string maxRungReached) |
| 65 | { |
| 66 | var byRung = new Dictionary<string, VerifyRungUiEntry>(StringComparer.Ordinal); |
| 67 | |
| 68 | foreach (var slice in timeSlices.Where(s => s.Phase == AgentRunPhaseKind.Environment)) |
| 69 | { |
| 70 | var rung = TryExtractRungId(slice.Detail); |
| 71 | if (rung is null) |
| 72 | continue; |
| 73 | |
| 74 | var state = ResolveStateFromDetail(slice.Detail, green && !IsFailureDetail(slice.Detail)); |
| 75 | byRung[rung] = new VerifyRungUiEntry(rung, state, slice.DurationSeconds, slice.Detail); |
| 76 | } |
| 77 | |
| 78 | var list = new List<VerifyRungUiEntry>(); |
| 79 | foreach (var rung in OrderedRungs) |
| 80 | { |
| 81 | if (byRung.TryGetValue(rung, out var entry)) |
| 82 | { |
| 83 | list.Add(entry); |
| 84 | continue; |
| 85 | } |
| 86 | |
| 87 | if (ShouldMarkSkipped(rung, maxRungReached, green)) |
| 88 | list.Add(new VerifyRungUiEntry(rung, VerifyRungUiState.Skipped, 0, null)); |
| 89 | } |
| 90 | |
| 91 | return list; |
| 92 | } |
| 93 | |
| 94 | public static string FormatCompactLine( |
| 95 | bool isActive, |
| 96 | bool isStale, |
| 97 | bool displayGreen, |
| 98 | string? policy, |
| 99 | string? runId, |
| 100 | string? activeRungId, |
| 101 | double activeSeconds, |
| 102 | string? maxRungReached, |
| 103 | bool hostDied) |
| 104 | { |
| 105 | var policyText = string.IsNullOrWhiteSpace(policy) ? "standard" : policy; |
| 106 | |
| 107 | if (isStale) |
| 108 | return $"⚠ verify устарел · {policyText}"; |
| 109 | |
| 110 | if (hostDied) |
| 111 | return $"☠ environment died · {policyText}"; |
| 112 | |
| 113 | if (isActive) |
| 114 | { |
| 115 | var rung = activeRungId ?? VerifyRung.DiagnoseFiles; |
| 116 | var elapsed = activeSeconds > 0 ? $"{activeSeconds:0.0}s" : "…"; |
| 117 | var shortId = ShortRunId(runId); |
| 118 | return $"⟳ {rung} {elapsed} · {policyText} · {shortId}"; |
| 119 | } |
| 120 | |
| 121 | if (displayGreen) |
| 122 | return $"✓ {maxRungReached ?? VerifyRung.BuildAffected} · green ({policyText})"; |
| 123 | |
| 124 | if (!string.IsNullOrWhiteSpace(maxRungReached)) |
| 125 | return $"✗ {maxRungReached} · failed ({policyText})"; |
| 126 | |
| 127 | return ""; |
| 128 | } |
| 129 | |
| 130 | public static string FormatExpandedBlock( |
| 131 | string? policy, |
| 132 | string? runId, |
| 133 | string? snapshotId, |
| 134 | bool isStale, |
| 135 | string? staleReason, |
| 136 | IReadOnlyList<VerifyRungUiEntry> rungs, |
| 137 | IReadOnlyList<AgentTimeSlice>? timeSlices, |
| 138 | bool displayGreen, |
| 139 | string? maxRungReached) |
| 140 | { |
| 141 | var lines = new List<string> |
| 142 | { |
| 143 | $"Verify · {policy ?? "standard"} · epoch {ShortRunId(runId)}", |
| 144 | $"snapshot {ShortSnapshot(snapshotId)}", |
| 145 | }; |
| 146 | |
| 147 | if (isStale) |
| 148 | lines.Add($"⚠ verify устарел — snapshot изменился ({staleReason ?? "stale"})"); |
| 149 | |
| 150 | lines.Add(FormatRungLineBlock(rungs, includeSkipped: true)); |
| 151 | |
| 152 | if (timeSlices is { Count: > 0 }) |
| 153 | lines.Add(FormatTimeAccountingFooter(timeSlices, displayGreen, policy, maxRungReached)); |
| 154 | else if (!isStale && !string.IsNullOrWhiteSpace(maxRungReached)) |
| 155 | { |
| 156 | var status = displayGreen ? "green" : "failed"; |
| 157 | lines.Add($"Status: {status} · max rung: {maxRungReached}"); |
| 158 | } |
| 159 | |
| 160 | return string.Join('\n', lines); |
| 161 | } |
| 162 | |
| 163 | public static string FormatRungLineBlock(IReadOnlyList<VerifyRungUiEntry> entries, bool includeSkipped) |
| 164 | { |
| 165 | if (entries.Count == 0) |
| 166 | return " — (no rungs yet)"; |
| 167 | |
| 168 | var lines = new List<string>(); |
| 169 | foreach (var entry in entries) |
| 170 | { |
| 171 | if (entry.State == VerifyRungUiState.Skipped && !includeSkipped) |
| 172 | continue; |
| 173 | |
| 174 | var glyph = entry.State switch |
| 175 | { |
| 176 | VerifyRungUiState.Pass => "✓", |
| 177 | VerifyRungUiState.Running => "⟳", |
| 178 | VerifyRungUiState.Fail => "✗", |
| 179 | VerifyRungUiState.Cancelled => "⊘", |
| 180 | VerifyRungUiState.Died => "☠", |
| 181 | VerifyRungUiState.Skipped => "—", |
| 182 | _ => "·", |
| 183 | }; |
| 184 | |
| 185 | var duration = entry.State == VerifyRungUiState.Running |
| 186 | ? "running…" |
| 187 | : entry.DurationSeconds > 0 |
| 188 | ? $"{entry.DurationSeconds:0.0}s" |
| 189 | : entry.State == VerifyRungUiState.Skipped |
| 190 | ? "(not required)" |
| 191 | : "—"; |
| 192 | |
| 193 | lines.Add($" {glyph} {entry.RungId,-18} {duration}"); |
| 194 | } |
| 195 | |
| 196 | return string.Join('\n', lines); |
| 197 | } |
| 198 | |
| 199 | public static string FormatTimeAccountingFooter( |
| 200 | IReadOnlyList<AgentTimeSlice> slices, |
| 201 | bool displayGreen, |
| 202 | string? policy, |
| 203 | string? maxRungReached) |
| 204 | { |
| 205 | double Sum(AgentRunPhaseKind phase) => |
| 206 | slices.Where(s => s.Phase == phase).Sum(s => s.DurationSeconds); |
| 207 | |
| 208 | var reasoning = Sum(AgentRunPhaseKind.Reasoning); |
| 209 | var environment = Sum(AgentRunPhaseKind.Environment); |
| 210 | var blocked = Sum(AgentRunPhaseKind.Blocked); |
| 211 | var status = displayGreen ? "green" : "failed"; |
| 212 | return $"Reasoning: {reasoning:0.0}s · Environment: {environment:0.0}s · Blocked: {blocked:0.0}s\nStatus: {status} ({policy ?? "standard"}) · max rung: {maxRungReached ?? "—"}"; |
| 213 | } |
| 214 | |
| 215 | public static bool ShouldDisplayGreen(bool completedGreen, bool isStale, string maxRungReached) => |
| 216 | completedGreen && !isStale && !string.IsNullOrWhiteSpace(maxRungReached); |
| 217 | |
| 218 | internal static string? TryExtractRungId(string? detail) |
| 219 | { |
| 220 | if (string.IsNullOrWhiteSpace(detail)) |
| 221 | return null; |
| 222 | |
| 223 | foreach (var rung in OrderedRungs) |
| 224 | { |
| 225 | if (detail.StartsWith(rung, StringComparison.Ordinal) |
| 226 | || detail.Contains(rung, StringComparison.Ordinal)) |
| 227 | return rung; |
| 228 | } |
| 229 | |
| 230 | return null; |
| 231 | } |
| 232 | |
| 233 | internal static bool IsFailureDetail(string? detail) => |
| 234 | detail?.Contains("error", StringComparison.OrdinalIgnoreCase) == true |
| 235 | || detail?.Contains("fail", StringComparison.OrdinalIgnoreCase) == true |
| 236 | || detail?.Contains("missing", StringComparison.OrdinalIgnoreCase) == true |
| 237 | || detail?.Contains("cancelled", StringComparison.OrdinalIgnoreCase) == true; |
| 238 | |
| 239 | private static VerifyRungUiState ResolveStateFromDetail(string? detail, bool passHint) |
| 240 | { |
| 241 | if (detail?.Contains("ci_parity marker", StringComparison.OrdinalIgnoreCase) == true) |
| 242 | return VerifyRungUiState.Pass; |
| 243 | |
| 244 | if (detail?.Contains("cancelled", StringComparison.OrdinalIgnoreCase) == true) |
| 245 | return VerifyRungUiState.Cancelled; |
| 246 | |
| 247 | if (detail?.Contains("died", StringComparison.OrdinalIgnoreCase) == true) |
| 248 | return VerifyRungUiState.Died; |
| 249 | |
| 250 | if (IsFailureDetail(detail)) |
| 251 | return VerifyRungUiState.Fail; |
| 252 | |
| 253 | return passHint ? VerifyRungUiState.Pass : VerifyRungUiState.Running; |
| 254 | } |
| 255 | |
| 256 | private static bool ShouldMarkSkipped(string rung, string maxRungReached, bool green) |
| 257 | { |
| 258 | var maxIndex = Array.IndexOf(OrderedRungs, maxRungReached); |
| 259 | var rungIndex = Array.IndexOf(OrderedRungs, rung); |
| 260 | if (maxIndex < 0 || rungIndex < 0) |
| 261 | return false; |
| 262 | |
| 263 | return green && rungIndex > maxIndex; |
| 264 | } |
| 265 | |
| 266 | private static string ShortRunId(string? runId) => |
| 267 | string.IsNullOrWhiteSpace(runId) ? "—" : runId.Length <= 6 ? runId : runId[..6]; |
| 268 | |
| 269 | private static string ShortSnapshot(string? snapshotId) |
| 270 | { |
| 271 | if (string.IsNullOrWhiteSpace(snapshotId)) |
| 272 | return "—"; |
| 273 | |
| 274 | return snapshotId.Length <= 12 ? snapshotId : snapshotId[..12] + "…"; |
| 275 | } |
| 276 | } |
| 277 | |