@skyhook-io/radar-app 1.13.1 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +19 -1
  3. package/src/RadarApp.tsx +2 -2
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +72 -9
  6. package/src/components/diagnose/AISettings.tsx +1 -1
  7. package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
  8. package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
  9. package/src/components/diagnose/DiagnoseContext.tsx +12 -17
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
  11. package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +289 -119
  14. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
  15. package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
  16. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
  17. package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
  18. package/src/components/diagnose/InvestigationView.test.ts +17 -0
  19. package/src/components/diagnose/InvestigationView.tsx +1900 -393
  20. package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
  21. package/src/components/diagnose/agentCatalog.ts +1 -1
  22. package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
  23. package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
  24. package/src/components/diagnose/investigationEvidence.ts +3492 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
  27. package/src/components/diagnose/investigationExplanation.test.ts +63 -0
  28. package/src/components/diagnose/investigationExplanation.ts +22 -0
  29. package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
  30. package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
  31. package/src/components/diagnose/investigationSourceFocus.ts +98 -0
  32. package/src/components/diagnose/investigationState.test.ts +695 -0
  33. package/src/components/diagnose/investigationState.ts +451 -0
  34. package/src/components/diagnose/parts.test.tsx +864 -3
  35. package/src/components/diagnose/parts.tsx +1337 -541
  36. package/src/components/diagnose/target.test.ts +39 -0
  37. package/src/components/diagnose/target.ts +36 -0
  38. package/src/components/diagnose/useDisclosureReveal.ts +117 -0
  39. package/src/components/home/MCPSetupDialog.tsx +2 -2
  40. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  41. package/src/components/home/mcpToolCatalog.ts +3 -2
  42. package/src/components/issues/IssuesPane.tsx +5 -1
  43. package/src/components/settings/SettingsDialog.tsx +11 -13
  44. package/src/components/workload/WorkloadView.tsx +1 -1
  45. package/src/context/DiagnoseCustomization.tsx +11 -8
  46. package/src/index.css +63 -79
  47. package/src/index.ts +1 -1
@@ -0,0 +1,451 @@
1
+ // Pure presentation decisions over the durable transcript and evidence projection.
2
+ // Keep React/DOM orchestration in InvestigationView; these rules have no UI runtime.
3
+ import {
4
+ DiagnoseError,
5
+ type DiagnoseStreamEvent,
6
+ type RunSummary,
7
+ } from "../../api/diagnose";
8
+ import type { Turn } from "./parts";
9
+ import type {
10
+ InvestigationEvidenceProjection,
11
+ InvestigationEvidenceTurn,
12
+ } from "./investigationEvidence";
13
+
14
+ export function initialInvestigationPane(
15
+ status: RunSummary["status"],
16
+ ): "activity" | "evidence" {
17
+ return status === "done" || status === "stale" ? "evidence" : "activity";
18
+ }
19
+
20
+ export function investigationEvidenceShouldMarkUnread({
21
+ hasNewLiveSource,
22
+ selectedPane,
23
+ evidencePaneVisible,
24
+ }: {
25
+ hasNewLiveSource: boolean;
26
+ selectedPane: "activity" | "evidence";
27
+ evidencePaneVisible: boolean;
28
+ }): boolean {
29
+ return (
30
+ hasNewLiveSource && selectedPane === "activity" && !evidencePaneVisible
31
+ );
32
+ }
33
+
34
+ export function investigationEvidenceAnnouncement({
35
+ unreadEvidence,
36
+ evidenceUpdateAvailable,
37
+ }: {
38
+ unreadEvidence: boolean;
39
+ evidenceUpdateAvailable: boolean;
40
+ }): string {
41
+ if (unreadEvidence) return "New evidence available";
42
+ if (evidenceUpdateAvailable) {
43
+ return "New evidence available in Findings.";
44
+ }
45
+ return "";
46
+ }
47
+
48
+ export function investigationIsReadOnly(
49
+ status: RunSummary["status"],
50
+ gone: boolean,
51
+ ): boolean {
52
+ return status === "stale" || gone;
53
+ }
54
+
55
+ export function investigationInteractionsBlocked(input: {
56
+ streamReady: boolean;
57
+ busy: boolean;
58
+ requestPending: boolean;
59
+ readOnly: boolean;
60
+ verificationPending: boolean;
61
+ }): boolean {
62
+ return (
63
+ !input.streamReady ||
64
+ input.busy ||
65
+ input.requestPending ||
66
+ input.readOnly ||
67
+ input.verificationPending
68
+ );
69
+ }
70
+
71
+ export function canOfferInvestigationApply(input: {
72
+ currentAssessmentIdx: number;
73
+ lastRemediationIdx: number;
74
+ lastApplyAttemptIdx: number;
75
+ localApplyAttemptAssessmentIdx: number;
76
+ interactionsBlocked: boolean;
77
+ hosted: boolean;
78
+ hasNewerEvidence: boolean;
79
+ }): boolean {
80
+ return (
81
+ input.currentAssessmentIdx === input.lastRemediationIdx &&
82
+ input.currentAssessmentIdx >
83
+ Math.max(
84
+ input.lastApplyAttemptIdx,
85
+ input.localApplyAttemptAssessmentIdx,
86
+ ) &&
87
+ !input.interactionsBlocked &&
88
+ !input.hosted &&
89
+ !input.hasNewerEvidence
90
+ );
91
+ }
92
+
93
+ export function investigationApplyAttemptVerified(input: {
94
+ localApplyAttemptAssessmentIdx: number;
95
+ currentAssessmentIdx: number;
96
+ currentAssessmentIsVerification: boolean;
97
+ }): boolean {
98
+ return (
99
+ input.localApplyAttemptAssessmentIdx >= 0 &&
100
+ input.currentAssessmentIdx > input.localApplyAttemptAssessmentIdx &&
101
+ input.currentAssessmentIsVerification
102
+ );
103
+ }
104
+
105
+ export function investigationAssessmentNeedsCurrentStateVerification(input: {
106
+ currentAssessmentIdx: number;
107
+ lastApplyAttemptIdx: number;
108
+ lastApplyOutcome: Turn["applyOutcome"];
109
+ localApplyAttemptAssessmentIdx: number;
110
+ }): boolean {
111
+ if (input.currentAssessmentIdx < 0) return false;
112
+
113
+ // Once the durable apply turn exists it is authoritative for the local
114
+ // pessimistic marker. A producer-confirmed failure means no write occurred;
115
+ // every other outcome (including a running/missing outcome) leaves the
116
+ // pre-write assessment unsafe to present as current.
117
+ if (input.lastApplyAttemptIdx > input.currentAssessmentIdx) {
118
+ return input.lastApplyOutcome !== "failed";
119
+ }
120
+
121
+ return input.localApplyAttemptAssessmentIdx >= input.currentAssessmentIdx;
122
+ }
123
+
124
+ export function investigationApplyRejectionIsDefinitive(
125
+ error: unknown,
126
+ ): error is DiagnoseError {
127
+ return error instanceof DiagnoseError && error.status < 500;
128
+ }
129
+
130
+ export function investigationApplyCompletionEffects(input: {
131
+ live: boolean;
132
+ applyStartedLive: boolean;
133
+ stale: boolean;
134
+ }): {
135
+ refreshClusterState: boolean;
136
+ verificationPending: boolean;
137
+ } {
138
+ const belongsToThisLiveView = input.live || input.applyStartedLive;
139
+ return {
140
+ refreshClusterState: belongsToThisLiveView,
141
+ verificationPending: belongsToThisLiveView && !input.stale,
142
+ };
143
+ }
144
+
145
+ export function investigationTurnWithTerminalEvent(
146
+ turn: Turn,
147
+ event: Pick<
148
+ DiagnoseStreamEvent,
149
+ "type" | "diagnosis" | "error" | "applyOutcome"
150
+ >,
151
+ animateResult: boolean,
152
+ ): Turn {
153
+ if (event.type === "done") {
154
+ return {
155
+ ...turn,
156
+ diagnosis: event.diagnosis ?? null,
157
+ error: null,
158
+ status: "done",
159
+ applyOutcome: event.applyOutcome,
160
+ animateResult,
161
+ };
162
+ }
163
+ if (event.type === "error" && turn.status === "running") {
164
+ return {
165
+ ...turn,
166
+ error: event.error || "The investigation failed.",
167
+ status: "error",
168
+ applyOutcome: event.applyOutcome,
169
+ animateResult,
170
+ };
171
+ }
172
+ return turn;
173
+ }
174
+
175
+ export function investigationApplyTerminalNeedsClusterRefresh(input: {
176
+ localApplyRequestPending: boolean;
177
+ streamedApplyPending: boolean;
178
+ streamedApplyStartedLive: boolean;
179
+ terminalEventIsLive: boolean;
180
+ }): boolean {
181
+ return (
182
+ input.localApplyRequestPending ||
183
+ (input.streamedApplyPending &&
184
+ (input.streamedApplyStartedLive || input.terminalEventIsLive))
185
+ );
186
+ }
187
+
188
+ export function investigationClosedEventIsLive(input: {
189
+ reason: "run_closed" | "unavailable";
190
+ subscribedRunStatus: RunSummary["status"];
191
+ replayComplete: boolean;
192
+ }): boolean {
193
+ // A retained stale run replays `replay_complete` immediately before its
194
+ // durable `closed` sentinel. The replay flag is therefore already true when
195
+ // that historical close arrives; the status captured when this subscription
196
+ // opened is what distinguishes it from a run that closed while being watched.
197
+ if (input.reason === "run_closed") {
198
+ return input.subscribedRunStatus !== "stale";
199
+ }
200
+ return input.replayComplete;
201
+ }
202
+
203
+ export function investigationClosedRunIsUnavailable(input: {
204
+ reason: "run_closed" | "unavailable";
205
+ subscribedRunStatus: RunSummary["status"];
206
+ }): boolean {
207
+ if (input.reason === "unavailable") return true;
208
+ // A running stream is finalized only when its cluster context changes; its
209
+ // refreshed summary will be stale, not gone. Retained stale streams likewise
210
+ // end with their durable closed sentinel. Other terminal runs can close only
211
+ // after retention eviction, so those are genuinely unavailable.
212
+ return !["running", "stale"].includes(input.subscribedRunStatus);
213
+ }
214
+
215
+ function isCompletedEvidenceTool(
216
+ item: InvestigationEvidenceTurn["timeline"][number],
217
+ ) {
218
+ return item.kind === "tool" && item.status === "done";
219
+ }
220
+
221
+ /**
222
+ * Reasoning reveal replaces a Turn every 150 ms, but completed tool records are
223
+ * immutable. Compare only the fields the evidence projector consumes so those
224
+ * cosmetic transcript updates do not repeatedly parse every retained payload.
225
+ */
226
+ export function investigationEvidenceInputsEqual(
227
+ previous: readonly InvestigationEvidenceTurn[],
228
+ next: readonly InvestigationEvidenceTurn[],
229
+ ): boolean {
230
+ if (previous.length !== next.length) return false;
231
+ for (let turnIndex = 0; turnIndex < previous.length; turnIndex += 1) {
232
+ const a = previous[turnIndex];
233
+ const b = next[turnIndex];
234
+ if (
235
+ a.question !== b.question ||
236
+ a.apply !== b.apply ||
237
+ a.verify !== b.verify ||
238
+ a.status !== b.status
239
+ ) {
240
+ return false;
241
+ }
242
+
243
+ let aIndex = 0;
244
+ let bIndex = 0;
245
+ while (true) {
246
+ while (
247
+ aIndex < a.timeline.length &&
248
+ !isCompletedEvidenceTool(a.timeline[aIndex])
249
+ ) {
250
+ aIndex += 1;
251
+ }
252
+ while (
253
+ bIndex < b.timeline.length &&
254
+ !isCompletedEvidenceTool(b.timeline[bIndex])
255
+ ) {
256
+ bIndex += 1;
257
+ }
258
+ const aDone = aIndex >= a.timeline.length;
259
+ const bDone = bIndex >= b.timeline.length;
260
+ if (aDone || bDone) {
261
+ if (aDone !== bDone) return false;
262
+ break;
263
+ }
264
+ if (aIndex !== bIndex || a.timeline[aIndex] !== b.timeline[bIndex]) {
265
+ return false;
266
+ }
267
+ aIndex += 1;
268
+ bIndex += 1;
269
+ }
270
+ }
271
+ return true;
272
+ }
273
+
274
+ export function investigationEvidenceCoverageLimited(
275
+ projection: Pick<
276
+ InvestigationEvidenceProjection,
277
+ "limitations" | "coverage"
278
+ > & {
279
+ sources: readonly {
280
+ id: string;
281
+ tool: string;
282
+ confirmedSuccess: boolean;
283
+ }[];
284
+ groups: readonly {
285
+ latest: {
286
+ relevance: "target" | "producer-related" | "broader";
287
+ source: { id: string };
288
+ };
289
+ }[];
290
+ },
291
+ ): boolean {
292
+ const completeDiagnosisSourceIds = new Set(
293
+ projection.sources
294
+ .filter((source) => source.tool === "diagnose" && source.confirmedSuccess)
295
+ .map((source) => source.id),
296
+ );
297
+ const hasTargetDiagnosis = projection.groups.some(
298
+ (group) =>
299
+ group.latest.relevance !== "broader" &&
300
+ completeDiagnosisSourceIds.has(group.latest.source.id),
301
+ );
302
+ return (
303
+ projection.limitations.length > 0 ||
304
+ projection.coverage.projected === 0 ||
305
+ !hasTargetDiagnosis
306
+ );
307
+ }
308
+
309
+ const HEALTH_CONFLICT_EVIDENCE_KINDS = new Set([
310
+ "issue",
311
+ "startup",
312
+ "crash",
313
+ "resource",
314
+ "logs",
315
+ "events",
316
+ "dns",
317
+ "network",
318
+ ]);
319
+
320
+ /**
321
+ * A model-authored all-clear must not overrule active adverse Radar evidence.
322
+ * Context-only warnings (for example a Helm ownership advisory) and ordinary
323
+ * recent changes are deliberately excluded: they are useful context, not proof
324
+ * that the investigated resource is unhealthy.
325
+ */
326
+ export function investigationEvidenceConflictsWithHealthy(projection: {
327
+ groups: readonly {
328
+ historical: boolean;
329
+ kind: string;
330
+ latest: {
331
+ relevance: "target" | "producer-related" | "broader";
332
+ tier: "key" | "supporting" | "context" | "checked";
333
+ tone: string;
334
+ };
335
+ }[];
336
+ }): boolean {
337
+ return projection.groups.some(
338
+ (group) =>
339
+ !group.historical &&
340
+ group.latest.relevance !== "broader" &&
341
+ (group.latest.tier === "key" || group.latest.tier === "supporting") &&
342
+ (group.latest.tone === "warning" || group.latest.tone === "error") &&
343
+ HEALTH_CONFLICT_EVIDENCE_KINDS.has(group.kind),
344
+ );
345
+ }
346
+
347
+ export function investigationEndedBeforeConclusion(
348
+ status: RunSummary["status"],
349
+ lastTurn: Pick<Turn, "status" | "apply" | "explainAssessment"> | undefined,
350
+ ): boolean {
351
+ return (
352
+ (status === "error" || status === "stopped") &&
353
+ (lastTurn === undefined ||
354
+ (lastTurn.status === "error" &&
355
+ lastTurn.apply !== true &&
356
+ !lastTurn.explainAssessment))
357
+ );
358
+ }
359
+
360
+ export interface InvestigationHistoryUnavailableState {
361
+ error: string;
362
+ retryable: boolean;
363
+ }
364
+
365
+ export function investigationHistoryUnavailablePresentation(
366
+ state: InvestigationHistoryUnavailableState,
367
+ ): { title: string; detail: string; loading: boolean } {
368
+ if (state.retryable) {
369
+ const error = state.error.trim();
370
+ return {
371
+ title: "Saved history is temporarily unavailable",
372
+ detail: error
373
+ ? `${error}${/[.!?]$/.test(error) ? " " : ". "}Radar is retrying without discarding this run.`
374
+ : "Radar is retrying without discarding this run.",
375
+ loading: true,
376
+ };
377
+ }
378
+ return {
379
+ title: "Saved history is unavailable",
380
+ detail:
381
+ state.error || "Radar could not restore this run from its saved history.",
382
+ loading: false,
383
+ };
384
+ }
385
+
386
+ export function investigationPaneCenteredScrollTop({
387
+ scrollTop,
388
+ viewportHeight,
389
+ contentHeight,
390
+ targetTop,
391
+ targetHeight,
392
+ }: {
393
+ scrollTop: number;
394
+ viewportHeight: number;
395
+ contentHeight: number;
396
+ /** Target top relative to the scroll viewport, before this adjustment. */
397
+ targetTop: number;
398
+ targetHeight: number;
399
+ }): number {
400
+ const centered =
401
+ scrollTop + targetTop - Math.max(0, (viewportHeight - targetHeight) / 2);
402
+ return Math.max(0, Math.min(centered, contentHeight - viewportHeight));
403
+ }
404
+
405
+ export function canStopInvestigation(
406
+ run: RunSummary,
407
+ busy: boolean,
408
+ gone: boolean,
409
+ latestTurnStatus?: Turn["status"],
410
+ ): boolean {
411
+ // The transcript is fresher than the polled run summary. Once it has a
412
+ // terminal frame, a lagging/failed summary refresh must not resurrect Stop.
413
+ const transcriptTerminal =
414
+ latestTurnStatus === "done" || latestTurnStatus === "error";
415
+ return (
416
+ run.trigger !== "background" &&
417
+ run.status !== "stale" &&
418
+ run.status !== "stopping" &&
419
+ !gone &&
420
+ !transcriptTerminal &&
421
+ (busy || run.status === "running")
422
+ );
423
+ }
424
+
425
+ export function canContinueInvestigation(
426
+ run: RunSummary,
427
+ latestTurnStatus?: Turn["status"],
428
+ gone = false,
429
+ ): boolean {
430
+ const transcriptTerminal =
431
+ latestTurnStatus === "done" || latestTurnStatus === "error";
432
+ const summaryIsLaggingTerminalTranscript =
433
+ run.status === "running" &&
434
+ run.trigger !== "background" &&
435
+ transcriptTerminal;
436
+ return (
437
+ !gone &&
438
+ run.status !== "stale" &&
439
+ run.status !== "stopping" &&
440
+ (run.canContinue !== false || summaryIsLaggingTerminalTranscript)
441
+ );
442
+ }
443
+
444
+ export function canInvestigateFurther(run: RunSummary, gone = false): boolean {
445
+ return (
446
+ !gone &&
447
+ run.trigger === "background" &&
448
+ run.status === "done" &&
449
+ !!run.issueId
450
+ );
451
+ }