@skyhook-io/radar-app 1.13.4 → 1.13.5
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.
- package/package.json +1 -1
- package/src/RadarApp.tsx +5 -0
- package/src/api/client.ts +4 -0
- package/src/api/diagnose.ts +61 -15
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
- package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
- package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
- package/src/components/diagnose/InvestigationView.tsx +222 -5
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
- package/src/components/diagnose/investigationEvidence.ts +831 -43
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +2 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +482 -0
- package/src/components/diagnose/parts.tsx +418 -41
- package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +1 -0
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
|
@@ -16,11 +16,13 @@ import {
|
|
|
16
16
|
investigationEvidenceInputsEqual,
|
|
17
17
|
investigationEvidenceCoverageLimited,
|
|
18
18
|
investigationEvidenceConflictsWithHealthy,
|
|
19
|
+
investigationHealthConflictExplainedBy,
|
|
19
20
|
investigationEndedBeforeConclusion,
|
|
20
21
|
investigationHistoryUnavailablePresentation,
|
|
21
22
|
investigationInteractionsBlocked,
|
|
22
23
|
investigationIsReadOnly,
|
|
23
24
|
investigationPaneCenteredScrollTop,
|
|
25
|
+
investigationLiveCaseTurnIndex,
|
|
24
26
|
} from "./investigationState";
|
|
25
27
|
|
|
26
28
|
describe("investigation terminal presentation", () => {
|
|
@@ -289,6 +291,127 @@ describe("investigation evidence projection stability", () => {
|
|
|
289
291
|
}),
|
|
290
292
|
).toBe(false);
|
|
291
293
|
});
|
|
294
|
+
it("names the explained cards only when the agent addressed every conflict", () => {
|
|
295
|
+
const group = (id: string, title: string, tone = "warning") => ({
|
|
296
|
+
id,
|
|
297
|
+
identity: `issue:${id}`,
|
|
298
|
+
kind: "issue",
|
|
299
|
+
historical: false,
|
|
300
|
+
latest: {
|
|
301
|
+
tier: "supporting" as const,
|
|
302
|
+
relevance: "target" as const,
|
|
303
|
+
tone,
|
|
304
|
+
title,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
const projection = {
|
|
308
|
+
groups: [group("g1", "CrashLoopBackOff"), group("g2", "OOMKilled")],
|
|
309
|
+
};
|
|
310
|
+
const note = (
|
|
311
|
+
groupId: string,
|
|
312
|
+
role: string,
|
|
313
|
+
placement: "card" | "revision" | "source" = "card",
|
|
314
|
+
claim = "the agent's reading of this card",
|
|
315
|
+
) => ({ role, placement, claim, groupId });
|
|
316
|
+
|
|
317
|
+
expect(investigationHealthConflictExplainedBy(projection, undefined)).toBe(
|
|
318
|
+
null,
|
|
319
|
+
);
|
|
320
|
+
expect(
|
|
321
|
+
investigationHealthConflictExplainedBy(projection, [
|
|
322
|
+
note("g1", "benign"),
|
|
323
|
+
]),
|
|
324
|
+
).toBe(null);
|
|
325
|
+
expect(
|
|
326
|
+
investigationHealthConflictExplainedBy(projection, [
|
|
327
|
+
note("g1", "benign"),
|
|
328
|
+
note("g2", "cause"),
|
|
329
|
+
]),
|
|
330
|
+
).toBe(null);
|
|
331
|
+
expect(
|
|
332
|
+
investigationHealthConflictExplainedBy(projection, [
|
|
333
|
+
note("g1", "benign"),
|
|
334
|
+
note("g2", "benign", "source"),
|
|
335
|
+
]),
|
|
336
|
+
).toBe(null);
|
|
337
|
+
// A note pinned to a superseded read addressed the card as it was then,
|
|
338
|
+
// not the card the banner is qualifying now.
|
|
339
|
+
expect(
|
|
340
|
+
investigationHealthConflictExplainedBy(projection, [
|
|
341
|
+
note("g1", "benign"),
|
|
342
|
+
note("g2", "benign", "revision"),
|
|
343
|
+
]),
|
|
344
|
+
).toBe(null);
|
|
345
|
+
// An empty claim renders nothing, so the banner would be pointing at a
|
|
346
|
+
// note the reader cannot find.
|
|
347
|
+
expect(
|
|
348
|
+
investigationHealthConflictExplainedBy(projection, [
|
|
349
|
+
note("g1", "benign"),
|
|
350
|
+
note("g2", "benign", "card", " "),
|
|
351
|
+
]),
|
|
352
|
+
).toBe(null);
|
|
353
|
+
// "Excludes some hypothesis" and "is peripheral here" are both true of a
|
|
354
|
+
// problem that is still live, so neither reconciles a healthy verdict.
|
|
355
|
+
expect(
|
|
356
|
+
investigationHealthConflictExplainedBy(projection, [
|
|
357
|
+
note("g1", "benign"),
|
|
358
|
+
note("g2", "rules_out"),
|
|
359
|
+
]),
|
|
360
|
+
).toBe(null);
|
|
361
|
+
// A note on a superseded revision of the card is not an explanation of
|
|
362
|
+
// the card as it now stands.
|
|
363
|
+
expect(
|
|
364
|
+
investigationHealthConflictExplainedBy(projection, [
|
|
365
|
+
note("g1", "benign"),
|
|
366
|
+
note("g2", "demoted"),
|
|
367
|
+
]),
|
|
368
|
+
).toBe(null);
|
|
369
|
+
expect(
|
|
370
|
+
investigationHealthConflictExplainedBy(projection, [
|
|
371
|
+
note("g1", "benign"),
|
|
372
|
+
note("g2", "benign"),
|
|
373
|
+
]),
|
|
374
|
+
).toEqual(["CrashLoopBackOff", "OOMKilled"]);
|
|
375
|
+
// The agent contradicting itself on the same card is not an explanation.
|
|
376
|
+
expect(
|
|
377
|
+
investigationHealthConflictExplainedBy(projection, [
|
|
378
|
+
note("g1", "benign"),
|
|
379
|
+
note("g2", "benign"),
|
|
380
|
+
note("g2", "cause"),
|
|
381
|
+
]),
|
|
382
|
+
).toBe(null);
|
|
383
|
+
expect(
|
|
384
|
+
investigationHealthConflictExplainedBy({ groups: [] }, [
|
|
385
|
+
note("g1", "benign"),
|
|
386
|
+
]),
|
|
387
|
+
).toBe(null);
|
|
388
|
+
|
|
389
|
+
// One log stream read through two calls lands in two groups sharing an
|
|
390
|
+
// identity, so the partition must not decide whether the agent addressed
|
|
391
|
+
// it: a note on either twin counts for the conflict recorded on the other.
|
|
392
|
+
const stream = (id: string) => ({
|
|
393
|
+
id,
|
|
394
|
+
identity: "logs:previous:api-abc:api",
|
|
395
|
+
kind: "logs",
|
|
396
|
+
historical: false,
|
|
397
|
+
latest: {
|
|
398
|
+
tier: "supporting" as const,
|
|
399
|
+
relevance: "target" as const,
|
|
400
|
+
tone: "warning",
|
|
401
|
+
title: "Previous logs · api-abc / api",
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
expect(
|
|
405
|
+
investigationHealthConflictExplainedBy(
|
|
406
|
+
{ groups: [stream("scope-a"), stream("scope-b")] },
|
|
407
|
+
[note("scope-b", "benign")],
|
|
408
|
+
),
|
|
409
|
+
).toEqual([
|
|
410
|
+
"Previous logs · api-abc / api",
|
|
411
|
+
"Previous logs · api-abc / api",
|
|
412
|
+
]);
|
|
413
|
+
});
|
|
414
|
+
|
|
292
415
|
it("ignores reasoning-only transcript updates while retaining completed tool identity", () => {
|
|
293
416
|
const completedTool = {
|
|
294
417
|
kind: "tool" as const,
|
|
@@ -693,3 +816,202 @@ describe("investigation action gating", () => {
|
|
|
693
816
|
).toBe(true);
|
|
694
817
|
});
|
|
695
818
|
});
|
|
819
|
+
|
|
820
|
+
describe("investigationLiveCaseTurnIndex", () => {
|
|
821
|
+
const linkedItem = {
|
|
822
|
+
status: "linked" as const,
|
|
823
|
+
ref: "ev_x",
|
|
824
|
+
role: "context" as const,
|
|
825
|
+
claim: "c",
|
|
826
|
+
};
|
|
827
|
+
const assessment = {
|
|
828
|
+
status: "done" as const,
|
|
829
|
+
diagnosis: { healthy: true, rootCause: "", report: "", remediation: [] },
|
|
830
|
+
};
|
|
831
|
+
it("keeps the assessment when no later turn cites evidence", () => {
|
|
832
|
+
const answer = {
|
|
833
|
+
status: "done" as const,
|
|
834
|
+
question: "why?",
|
|
835
|
+
diagnosis: { rootCause: "", report: "because", remediation: [] },
|
|
836
|
+
};
|
|
837
|
+
expect(investigationLiveCaseTurnIndex([assessment, answer], 0)).toBe(0);
|
|
838
|
+
});
|
|
839
|
+
it("moves to an answer turn that carries a bound case or linked root-cause refs", () => {
|
|
840
|
+
const cited = {
|
|
841
|
+
status: "done" as const,
|
|
842
|
+
question: "chart it and cite it",
|
|
843
|
+
diagnosis: {
|
|
844
|
+
rootCause: "",
|
|
845
|
+
report: "",
|
|
846
|
+
remediation: [],
|
|
847
|
+
evidence: [linkedItem],
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
expect(investigationLiveCaseTurnIndex([assessment, cited], 0)).toBe(1);
|
|
851
|
+
const legacy = {
|
|
852
|
+
status: "done" as const,
|
|
853
|
+
question: "what broke?",
|
|
854
|
+
diagnosis: {
|
|
855
|
+
rootCause: "x",
|
|
856
|
+
report: "",
|
|
857
|
+
remediation: [],
|
|
858
|
+
rootCauseEvidence: { status: "linked" as const, refs: ["ev_x"] },
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
expect(investigationLiveCaseTurnIndex([assessment, legacy], 0)).toBe(1);
|
|
862
|
+
const unlinkedOnly = {
|
|
863
|
+
...cited,
|
|
864
|
+
diagnosis: {
|
|
865
|
+
...cited.diagnosis,
|
|
866
|
+
evidence: [{ status: "unlinked" as const }],
|
|
867
|
+
},
|
|
868
|
+
};
|
|
869
|
+
expect(investigationLiveCaseTurnIndex([assessment, unlinkedOnly], 0)).toBe(
|
|
870
|
+
0,
|
|
871
|
+
);
|
|
872
|
+
});
|
|
873
|
+
it("ignores apply, explanation, running, and superseded turns", () => {
|
|
874
|
+
const cited = {
|
|
875
|
+
status: "done" as const,
|
|
876
|
+
question: "cite",
|
|
877
|
+
diagnosis: {
|
|
878
|
+
rootCause: "",
|
|
879
|
+
report: "",
|
|
880
|
+
remediation: [],
|
|
881
|
+
evidence: [linkedItem],
|
|
882
|
+
},
|
|
883
|
+
};
|
|
884
|
+
expect(
|
|
885
|
+
investigationLiveCaseTurnIndex(
|
|
886
|
+
[
|
|
887
|
+
assessment,
|
|
888
|
+
{ ...cited, apply: true },
|
|
889
|
+
{ ...cited, explainAssessment: 2 },
|
|
890
|
+
{ ...cited, status: "running" as const },
|
|
891
|
+
],
|
|
892
|
+
0,
|
|
893
|
+
),
|
|
894
|
+
).toBe(0);
|
|
895
|
+
// A newer assessment after the cited answer is the current one.
|
|
896
|
+
expect(
|
|
897
|
+
investigationLiveCaseTurnIndex([assessment, cited, assessment], 2),
|
|
898
|
+
).toBe(2);
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
describe("adverse evidence and the healthy-conflict banner", () => {
|
|
903
|
+
const group = (kind: string, tone: string) => ({
|
|
904
|
+
id: `${kind}-1`,
|
|
905
|
+
kind,
|
|
906
|
+
historical: false,
|
|
907
|
+
latest: { tier: "supporting" as const, relevance: "target" as const, tone },
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it("counts every kind Radar can capture a live problem in", () => {
|
|
911
|
+
// The round that added alerts and Helm cards forgot this rule, so a
|
|
912
|
+
// firing alert naming the workload sat under a green banner.
|
|
913
|
+
for (const kind of [
|
|
914
|
+
"issue",
|
|
915
|
+
"startup",
|
|
916
|
+
"crash",
|
|
917
|
+
"resource",
|
|
918
|
+
"logs",
|
|
919
|
+
"events",
|
|
920
|
+
"dns",
|
|
921
|
+
"network",
|
|
922
|
+
"alerts",
|
|
923
|
+
"helm",
|
|
924
|
+
]) {
|
|
925
|
+
expect(
|
|
926
|
+
investigationEvidenceConflictsWithHealthy({
|
|
927
|
+
groups: [group(kind, "error")],
|
|
928
|
+
}),
|
|
929
|
+
kind,
|
|
930
|
+
).toBe(true);
|
|
931
|
+
}
|
|
932
|
+
// A change, a receipt, a chart or a permission grant describe the world,
|
|
933
|
+
// not a problem in it.
|
|
934
|
+
for (const kind of [
|
|
935
|
+
"changes",
|
|
936
|
+
"receipt",
|
|
937
|
+
"metrics",
|
|
938
|
+
"permissions",
|
|
939
|
+
"relationships",
|
|
940
|
+
"topology",
|
|
941
|
+
"inventory",
|
|
942
|
+
]) {
|
|
943
|
+
expect(
|
|
944
|
+
investigationEvidenceConflictsWithHealthy({
|
|
945
|
+
groups: [group(kind, "error")],
|
|
946
|
+
}),
|
|
947
|
+
kind,
|
|
948
|
+
).toBe(false);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// `observe` splits one log stream read by two calls into two groups, so the
|
|
953
|
+
// explained check deliberately matches a note across groups sharing a kind
|
|
954
|
+
// and identity. That crossing must not reach forward in time: a later turn
|
|
955
|
+
// reading the same stream can report a different failure, and the
|
|
956
|
+
// assessment never saw it.
|
|
957
|
+
it("refuses a note as explaining a reading captured after the assessment", () => {
|
|
958
|
+
const logGroup = (id: string, turnIndex: number) => ({
|
|
959
|
+
id,
|
|
960
|
+
// Same stream, different calls: what the twin lookup exists for.
|
|
961
|
+
identity: "logs:current:api-7f6-a:api",
|
|
962
|
+
kind: "logs",
|
|
963
|
+
historical: false,
|
|
964
|
+
latest: {
|
|
965
|
+
tier: "supporting" as const,
|
|
966
|
+
relevance: "target" as const,
|
|
967
|
+
tone: "warning",
|
|
968
|
+
title: "Error logs",
|
|
969
|
+
source: { turnIndex },
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
const note = (groupId: string, turnIndex: number) => ({
|
|
973
|
+
role: "benign",
|
|
974
|
+
placement: "card" as const,
|
|
975
|
+
claim: "the warmup error clears once the cache fills",
|
|
976
|
+
groupId,
|
|
977
|
+
source: { turnIndex },
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
// Two calls in the assessment's own turn read the same stream. The note
|
|
981
|
+
// sits on one group and explains its twin as well.
|
|
982
|
+
expect(
|
|
983
|
+
investigationHealthConflictExplainedBy(
|
|
984
|
+
{ groups: [logGroup("g-a", 0), logGroup("g-b", 0)] },
|
|
985
|
+
[note("g-a", 0)],
|
|
986
|
+
),
|
|
987
|
+
).toEqual(["Error logs", "Error logs"]);
|
|
988
|
+
|
|
989
|
+
// A later turn reads the same stream and reports a different failure.
|
|
990
|
+
// The turn-0 note was about something else and must not soften it.
|
|
991
|
+
expect(
|
|
992
|
+
investigationHealthConflictExplainedBy(
|
|
993
|
+
{ groups: [logGroup("g-a", 0), logGroup("g-c", 1)] },
|
|
994
|
+
[note("g-a", 0)],
|
|
995
|
+
),
|
|
996
|
+
).toBe(null);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("counts the intermediate alert tone, which carries every high severity", () => {
|
|
1000
|
+
for (const tone of ["warning", "alert", "error"]) {
|
|
1001
|
+
expect(
|
|
1002
|
+
investigationEvidenceConflictsWithHealthy({
|
|
1003
|
+
groups: [group("issue", tone)],
|
|
1004
|
+
}),
|
|
1005
|
+
tone,
|
|
1006
|
+
).toBe(true);
|
|
1007
|
+
}
|
|
1008
|
+
for (const tone of ["neutral", "info", "success"]) {
|
|
1009
|
+
expect(
|
|
1010
|
+
investigationEvidenceConflictsWithHealthy({
|
|
1011
|
+
groups: [group("issue", tone)],
|
|
1012
|
+
}),
|
|
1013
|
+
tone,
|
|
1014
|
+
).toBe(false);
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Pure presentation decisions over the durable transcript and evidence projection.
|
|
2
2
|
// Keep React/DOM orchestration in InvestigationView; these rules have no UI runtime.
|
|
3
|
+
import { evidenceKindIsAdverse } from "./investigationEvidenceKinds";
|
|
3
4
|
import {
|
|
4
5
|
DiagnoseError,
|
|
5
6
|
type DiagnoseStreamEvent,
|
|
@@ -271,6 +272,35 @@ export function investigationEvidenceInputsEqual(
|
|
|
271
272
|
return true;
|
|
272
273
|
}
|
|
273
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The turn whose agent case annotates the Evidence pane. A follow-up answer
|
|
277
|
+
* that cites evidence ("chart this and cite it") must reach Findings, so the
|
|
278
|
+
* newest completed non-apply, non-explanation turn carrying a bound case or
|
|
279
|
+
* linked root-cause refs wins; earlier turns keep their case read-only.
|
|
280
|
+
*/
|
|
281
|
+
export function investigationLiveCaseTurnIndex(
|
|
282
|
+
turns: readonly Pick<
|
|
283
|
+
Turn,
|
|
284
|
+
"status" | "apply" | "explainAssessment" | "diagnosis"
|
|
285
|
+
>[],
|
|
286
|
+
currentAssessmentIdx: number,
|
|
287
|
+
): number {
|
|
288
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
289
|
+
const turn = turns[index];
|
|
290
|
+
if (turn.status !== "done" || turn.apply || turn.explainAssessment)
|
|
291
|
+
continue;
|
|
292
|
+
const diagnosis = turn.diagnosis;
|
|
293
|
+
if (!diagnosis) continue;
|
|
294
|
+
if (
|
|
295
|
+
diagnosis.evidence?.some((item) => item.status === "linked") ||
|
|
296
|
+
(diagnosis.rootCause && diagnosis.rootCauseEvidence?.status === "linked")
|
|
297
|
+
) {
|
|
298
|
+
return Math.max(index, currentAssessmentIdx);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return currentAssessmentIdx;
|
|
302
|
+
}
|
|
303
|
+
|
|
274
304
|
export function investigationEvidenceCoverageLimited(
|
|
275
305
|
projection: Pick<
|
|
276
306
|
InvestigationEvidenceProjection,
|
|
@@ -306,44 +336,136 @@ export function investigationEvidenceCoverageLimited(
|
|
|
306
336
|
);
|
|
307
337
|
}
|
|
308
338
|
|
|
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
339
|
/**
|
|
321
340
|
* A model-authored all-clear must not overrule active adverse Radar evidence.
|
|
322
341
|
* Context-only warnings (for example a Helm ownership advisory) and ordinary
|
|
323
342
|
* recent changes are deliberately excluded: they are useful context, not proof
|
|
324
343
|
* that the investigated resource is unhealthy.
|
|
325
344
|
*/
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
345
|
+
interface HealthConflictGroup {
|
|
346
|
+
id?: string;
|
|
347
|
+
/** Display identity; logs partition into several groups sharing one. */
|
|
348
|
+
identity?: string;
|
|
349
|
+
historical: boolean;
|
|
350
|
+
kind: string;
|
|
351
|
+
latest: {
|
|
352
|
+
relevance: "target" | "producer-related" | "broader";
|
|
353
|
+
tier: "key" | "supporting" | "context" | "checked";
|
|
354
|
+
tone: string;
|
|
355
|
+
title?: string;
|
|
356
|
+
/** Which turn captured this reading; a note cannot explain a later one. */
|
|
357
|
+
source?: { turnIndex: number };
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function investigationHealthConflictGroups<
|
|
362
|
+
G extends HealthConflictGroup,
|
|
363
|
+
>(projection: { groups: readonly G[] }): G[] {
|
|
364
|
+
return projection.groups.filter(
|
|
338
365
|
(group) =>
|
|
339
366
|
!group.historical &&
|
|
340
367
|
group.latest.relevance !== "broader" &&
|
|
341
368
|
(group.latest.tier === "key" || group.latest.tier === "supporting") &&
|
|
342
|
-
|
|
343
|
-
|
|
369
|
+
// Radar's adverse tones run warning → alert → error; "high" severity
|
|
370
|
+
// from the Go side lands on alert, so leaving it out silently excused
|
|
371
|
+
// every high-severity finding.
|
|
372
|
+
(group.latest.tone === "warning" ||
|
|
373
|
+
group.latest.tone === "alert" ||
|
|
374
|
+
group.latest.tone === "error") &&
|
|
375
|
+
evidenceKindIsAdverse(group.kind),
|
|
344
376
|
);
|
|
345
377
|
}
|
|
346
378
|
|
|
379
|
+
export function investigationEvidenceConflictsWithHealthy(projection: {
|
|
380
|
+
groups: readonly HealthConflictGroup[];
|
|
381
|
+
}): boolean {
|
|
382
|
+
return investigationHealthConflictGroups(projection).length > 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The banner over a healthy verdict points at adverse Radar evidence. When the
|
|
387
|
+
* agent placed a "not a problem" note on every such card, the evidence is
|
|
388
|
+
* still shown but the reader has the agent's reason next to it, so the banner
|
|
389
|
+
* can say that instead of accusing evidence the agent addressed.
|
|
390
|
+
* Returns the titles of the explained cards, or null when any conflict is
|
|
391
|
+
* unexplained (or there is no conflict).
|
|
392
|
+
*/
|
|
393
|
+
export function investigationHealthConflictExplainedBy(
|
|
394
|
+
projection: { groups: readonly HealthConflictGroup[] },
|
|
395
|
+
caseItems:
|
|
396
|
+
| readonly {
|
|
397
|
+
role: string;
|
|
398
|
+
placement: "card" | "revision" | "source";
|
|
399
|
+
claim: string;
|
|
400
|
+
groupId?: string;
|
|
401
|
+
source?: { turnIndex: number };
|
|
402
|
+
}[]
|
|
403
|
+
| undefined,
|
|
404
|
+
): string[] | null {
|
|
405
|
+
const conflicting = investigationHealthConflictGroups(projection);
|
|
406
|
+
if (conflicting.length === 0 || !caseItems) return null;
|
|
407
|
+
// `observe` keeps a log stream read through two different calls in separate
|
|
408
|
+
// groups, so a same-named pod from another workload's call cannot inherit
|
|
409
|
+
// the target's relevance. That partition must not also decide whether the
|
|
410
|
+
// agent addressed the stream: it explained one card, and the conflict is
|
|
411
|
+
// recorded on its twin. Group ids that share an identity are the same
|
|
412
|
+
// underlying observation for this question.
|
|
413
|
+
const twins = new Map<string, Set<string>>();
|
|
414
|
+
for (const group of projection.groups) {
|
|
415
|
+
if (!group.id || !group.identity) continue;
|
|
416
|
+
const key = `${group.kind}\u0000${group.identity}`;
|
|
417
|
+
const ids = twins.get(key) ?? new Set<string>();
|
|
418
|
+
ids.add(group.id);
|
|
419
|
+
twins.set(key, ids);
|
|
420
|
+
}
|
|
421
|
+
const titles: string[] = [];
|
|
422
|
+
for (const group of conflicting) {
|
|
423
|
+
const sameStream =
|
|
424
|
+
(group.identity && twins.get(`${group.kind}\u0000${group.identity}`)) ||
|
|
425
|
+
new Set<string>([group.id ?? ""]);
|
|
426
|
+
const onGroup = caseItems.filter(
|
|
427
|
+
(item) => item.groupId && sameStream.has(item.groupId),
|
|
428
|
+
);
|
|
429
|
+
// The agent contradicting itself is not an explanation. If it also called
|
|
430
|
+
// this card a cause or a symptom, it is asserting the problem, and the
|
|
431
|
+
// reader must see the unqualified warning.
|
|
432
|
+
if (
|
|
433
|
+
onGroup.some((item) => item.role === "cause" || item.role === "symptom")
|
|
434
|
+
)
|
|
435
|
+
return null;
|
|
436
|
+
const explained = onGroup.some(
|
|
437
|
+
(item) =>
|
|
438
|
+
// Only a note the reader can actually see on the card in front of
|
|
439
|
+
// them addresses it. A `source` item renders away from the card and a
|
|
440
|
+
// `revision` item addressed a superseded read of it, so neither
|
|
441
|
+
// speaks to the state the banner is qualifying; an empty claim shows
|
|
442
|
+
// nothing at all, and the banner would be citing a note that is not
|
|
443
|
+
// there. None of this judges whether the agent is right — that is why
|
|
444
|
+
// the banner stays a warning either way.
|
|
445
|
+
item.placement === "card" &&
|
|
446
|
+
item.claim.trim() !== "" &&
|
|
447
|
+
// Only `benign` says this evidence does not indicate a live problem.
|
|
448
|
+
// `rules_out` excludes some other hypothesis and `demoted` says the
|
|
449
|
+
// card is peripheral — both true of a still-active problem — so
|
|
450
|
+
// neither reconciles a healthy verdict with evidence contradicting it.
|
|
451
|
+
item.role === "benign" &&
|
|
452
|
+
// An assessment cannot have addressed a reading taken after it. The
|
|
453
|
+
// twin lookup above deliberately crosses calls, because one log
|
|
454
|
+
// stream read twice lands in two groups; without this, it would also
|
|
455
|
+
// let a note about an earlier failure explain a different one that
|
|
456
|
+
// the same stream reported in a later turn.
|
|
457
|
+
!(
|
|
458
|
+
group.latest.source !== undefined &&
|
|
459
|
+
item.source !== undefined &&
|
|
460
|
+
group.latest.source.turnIndex > item.source.turnIndex
|
|
461
|
+
),
|
|
462
|
+
);
|
|
463
|
+
if (!explained) return null;
|
|
464
|
+
titles.push(group.latest.title ?? group.kind);
|
|
465
|
+
}
|
|
466
|
+
return titles;
|
|
467
|
+
}
|
|
468
|
+
|
|
347
469
|
export function investigationEndedBeforeConclusion(
|
|
348
470
|
status: RunSummary["status"],
|
|
349
471
|
lastTurn: Pick<Turn, "status" | "apply" | "explainAssessment"> | undefined,
|