@skyhook-io/radar-app 1.14.3 → 1.14.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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/api/client.ts +2 -0
  3. package/src/api/diagnose.ts +7 -1
  4. package/src/components/diagnose/AIMarkdown.tsx +8 -3
  5. package/src/components/diagnose/ActivityTurn.tsx +1 -1
  6. package/src/components/diagnose/AssessmentCard.tsx +269 -238
  7. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +47 -0
  8. package/src/components/diagnose/InvestigationEvidencePane.tsx +15 -2
  9. package/src/components/diagnose/InvestigationEvidenceSections.tsx +1 -1
  10. package/src/components/diagnose/InvestigationView.tsx +32 -24
  11. package/src/components/diagnose/investigationCase.test.tsx +83 -0
  12. package/src/components/diagnose/investigationCase.ts +3 -2
  13. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +22 -0
  14. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
  15. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
  16. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +2 -0
  17. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +60 -0
  18. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +7 -3
  19. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +1 -1
  20. package/src/components/diagnose/investigationEvidence/observations.ts +10 -5
  21. package/src/components/diagnose/investigationEvidence/types.ts +1 -1
  22. package/src/components/diagnose/investigationState.test.ts +2 -2
  23. package/src/components/diagnose/investigationState.ts +2 -2
  24. package/src/components/diagnose/investigationStory.ts +1 -1
  25. package/src/components/diagnose/parts.test.tsx +153 -2
  26. package/src/components/home/mcpToolCatalog.ts +27 -0
  27. package/src/components/rightsizing/RightsizingScanView.tsx +194 -63
  28. package/src/components/rightsizing/empty-state.test.tsx +67 -0
  29. package/src/components/rightsizing/model.test.ts +118 -1
  30. package/src/components/rightsizing/model.ts +36 -13
  31. package/src/components/rightsizing/notices.test.tsx +175 -0
  32. package/src/components/ui/Markdown.tsx +33 -6
  33. package/src/components/ui/Omnibar.tsx +82 -69
  34. package/src/components/workload/WorkloadView.tsx +7 -1
@@ -34,6 +34,53 @@ import { groupEvidenceCoverage } from "./investigationEvidencePresentation";
34
34
  import { metricsChangeMarkers } from "./investigationMetrics";
35
35
 
36
36
  const onViewSource = vi.fn();
37
+
38
+ describe("coverage already summarized by the assessment", () => {
39
+ it("removes only the repeated collapsed preview, retaining source details and new gaps", () => {
40
+ const projection = project(
41
+ tool(
42
+ "logs-denied",
43
+ "get_pod_logs",
44
+ { error: "pods/log is forbidden" },
45
+ { isError: true },
46
+ ),
47
+ tool("issues-cut", "issues", {}, { truncated: true }),
48
+ );
49
+ const groups = groupEvidenceCoverage(projection.limitations);
50
+ const line = `${groups[0].label}: ${groups[0].summary}`;
51
+ const renderStory = (summarizedLimits: string[], summary = "Assessment") =>
52
+ renderToStaticMarkup(
53
+ <InvestigationEvidencePane
54
+ projection={projection}
55
+ story={{ summary, report: "Analysis [[radar:evidence=0]]" }}
56
+ summarizedLimits={summarizedLimits}
57
+ collecting={false}
58
+ animateGroupIds={new Set()}
59
+ onViewSource={onViewSource}
60
+ onViewActivity={() => {}}
61
+ />,
62
+ );
63
+ const preview = (html: string) =>
64
+ html.match(
65
+ /<button[^>]*aria-controls="investigation-evidence-coverage"[\s\S]*?<\/button>/,
66
+ )?.[0] ?? "";
67
+ const firstSummary = renderToStaticMarkup(<>{groups[0].summary}</>);
68
+ expect(preview(renderStory([]))).toContain(firstSummary);
69
+ const html = renderStory([line]);
70
+ expect(preview(html)).not.toContain(firstSummary);
71
+ expect(preview(html)).toContain(groups[1].summary);
72
+ expect(html).toContain('aria-label="View result for Container logs"');
73
+ expect(html).toContain("Evidence coverage update:");
74
+ const allSummarized = renderStory(
75
+ groups.map((g) => `${g.label}: ${g.summary}`),
76
+ );
77
+ expect(preview(allSummarized)).not.toContain("line-clamp-2");
78
+ expect(preview(allSummarized)).toContain("Evidence coverage is incomplete");
79
+ for (const summary of ["", " "]) {
80
+ expect(preview(renderStory([line], summary))).toContain(firstSummary);
81
+ }
82
+ });
83
+ });
37
84
  const target = {
38
85
  kind: "Deployment",
39
86
  group: "apps",
@@ -71,6 +71,7 @@ export function InvestigationEvidencePane({
71
71
  afterEvidence,
72
72
  recordNotes,
73
73
  storyShell = false,
74
+ summarizedLimits = [],
74
75
  revealRequest,
75
76
  onRevealReady,
76
77
  }: {
@@ -85,7 +86,11 @@ export function InvestigationEvidencePane({
85
86
  * `evidence` array. Absent on backends and runs without the story contract,
86
87
  * which keep the previous layout.
87
88
  */
88
- story?: { report: string; evidence?: DiagnosisEvidenceItem[] };
89
+ story?: {
90
+ summary?: string;
91
+ report: string;
92
+ evidence?: DiagnosisEvidenceItem[];
93
+ };
89
94
  /** Open the story fully instead of the bounded preview — when the layout has room for it. */
90
95
  collecting: boolean;
91
96
  animateGroupIds: ReadonlySet<string>;
@@ -105,6 +110,8 @@ export function InvestigationEvidencePane({
105
110
  recordNotes?: ReactNode;
106
111
  /** No verdict yet, but the story shape is coming: keep its cards in place. */
107
112
  storyShell?: boolean;
113
+ /** Coverage lines already visible in the current assessment's Still open list. */
114
+ summarizedLimits?: readonly string[];
108
115
  /** Explicit Activity → Findings navigation, including repeat clicks. */
109
116
  revealRequest?: { sourceId: string; requestId: number };
110
117
  onRevealReady?: (sourceId: string) => void;
@@ -279,6 +286,12 @@ export function InvestigationEvidencePane({
279
286
  const limitationSummary = coverageGroups
280
287
  .map((group) => `${group.label}: ${group.summary}`)
281
288
  .join(" · ");
289
+ const additionalLimitations = coverageGroups
290
+ .map((group) => `${group.label}: ${group.summary}`)
291
+ .filter(
292
+ (line) => !story?.summary?.trim() || !summarizedLimits.includes(line),
293
+ )
294
+ .join(" · ");
282
295
  // Resolves a story placement to the agent item it names, or says why it
283
296
  // cannot: an index Radar does not know, an item the server could not bind,
284
297
  // or a bound item that matches no single observation. Only the last two
@@ -657,7 +670,7 @@ export function InvestigationEvidencePane({
657
670
  <CoverageStrip
658
671
  groups={coverageGroups}
659
672
  visibleGroupIds={new Set(partition.collectionByGroup.keys())}
660
- summary={limitationSummary}
673
+ summary={additionalLimitations}
661
674
  onViewSource={onViewSource}
662
675
  open={coverageOpen}
663
676
  onOpenChange={setCoverageOpen}
@@ -318,7 +318,7 @@ export function CoverageStrip({
318
318
  ? "Change history is limited"
319
319
  : "Evidence coverage is incomplete"}
320
320
  </span>
321
- {!historyOnly && !open ? (
321
+ {!historyOnly && !open && summary ? (
322
322
  <span className="line-clamp-2 text-xs text-theme-text-tertiary [overflow-wrap:anywhere]">
323
323
  {summary}
324
324
  </span>
@@ -176,19 +176,17 @@ export function InvestigationView({
176
176
  onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
177
177
  }) {
178
178
  const { kind, namespace, name } = run;
179
- // Apply is off for hosted agents (read-only server-side). Keyed on the selected
180
- // agent, which matches run.agent unless a deployment mixes hosted + local agents.
181
- const {
182
- refreshRuns,
183
- openInvestigation,
184
- startError,
185
- dismissError,
186
- hosted,
187
- agents,
188
- } = useDiagnose();
189
- const explanationEnabled = supportsAssessmentExplanation(
190
- agents.find((agent) => agent.name === run.agent),
191
- );
179
+ const { refreshRuns, openInvestigation, startError, dismissError, agents } =
180
+ useDiagnose();
181
+ // Capabilities are the declared ones of the agent that ran this run, not
182
+ // the picker's: a reopened run keeps the backend it was made with.
183
+ const runAgent = agents.find((agent) => agent.name === run.agent);
184
+ const explanationEnabled = supportsAssessmentExplanation(runAgent);
185
+ const canApply = runAgent?.apply === true;
186
+ // Read through a ref by the stream callback, which lives as long as the
187
+ // run and would otherwise keep the value from before the agents loaded.
188
+ const verifiesAfterApplyRef = useRef(false);
189
+ verifiesAfterApplyRef.current = runAgent?.verification === true;
192
190
  // Investigate again means look again, so it asks for a new session explicitly and only
193
191
  // carries the issue forward — being handed the previous answer is the one
194
192
  // thing someone clicking this doesn't want.
@@ -536,10 +534,13 @@ export function InvestigationView({
536
534
  });
537
535
  pendingApplyStartedLiveRef.current = false;
538
536
  if (effects.refreshClusterState) refreshClusterState();
539
- // A successful apply is one compound server-owned job. Its next
540
- // durable event is the automatic read-only verification turn; hold
541
- // the controls through that adjacent event so there is no idle flash.
542
- if (effects.verificationPending) setVerificationPending(true);
537
+ // On a backend that verifies, a successful apply is one compound
538
+ // server-owned job whose next durable event is the automatic
539
+ // read-only verification turn; hold the controls through that
540
+ // adjacent event so there is no idle flash. A backend that
541
+ // declares no verification sends no such turn, so nothing waits.
542
+ if (effects.verificationPending && verifiesAfterApplyRef.current)
543
+ setVerificationPending(true);
543
544
  }
544
545
  if (live || (isApply && applyStartedLive)) refreshRuns();
545
546
  break;
@@ -787,7 +788,8 @@ export function InvestigationView({
787
788
  assessment: Turn,
788
789
  ): AssessmentExplanation | undefined => {
789
790
  const sequence = assessment.resultSequence;
790
- if (!sequence || !assessment.diagnosis?.rootCause) return undefined;
791
+ if (!sequence || !investigationIsAssessmentTurn(assessment))
792
+ return undefined;
791
793
  const saved = investigationExplanation(turns, sequence);
792
794
  if ((readOnly || !explanationEnabled) && saved.status === "idle")
793
795
  return undefined;
@@ -1299,13 +1301,15 @@ export function InvestigationView({
1299
1301
  (rootCauseEvidenceResolution?.links.length ||
1300
1302
  investigationCase?.items.length ||
1301
1303
  currentAssessment.diagnosis.unlinkedEvidence ||
1302
- currentAssessment.diagnosis.evidenceMalformed) ? (
1304
+ currentAssessment.diagnosis.evidenceMalformed ||
1305
+ currentAssessment.diagnosis.omittedEntries) ? (
1303
1306
  <AssessmentSources
1304
1307
  renderedGroupIds={visibleEvidenceGroupIds}
1305
1308
  resolution={rootCauseEvidenceResolution}
1306
1309
  investigationCase={investigationCase}
1307
1310
  unlinkedEvidence={currentAssessment.diagnosis.unlinkedEvidence}
1308
1311
  evidenceMalformed={currentAssessment.diagnosis.evidenceMalformed}
1312
+ omittedEntries={currentAssessment.diagnosis.omittedEntries}
1309
1313
  onViewSource={viewActivitySource}
1310
1314
  />
1311
1315
  ) : undefined;
@@ -1313,7 +1317,8 @@ export function InvestigationView({
1313
1317
  storyShape &&
1314
1318
  (investigationCase?.items.some((item) => item.placement === "source") ||
1315
1319
  currentAssessment?.diagnosis?.unlinkedEvidence ||
1316
- currentAssessment?.diagnosis?.evidenceMalformed)
1320
+ currentAssessment?.diagnosis?.evidenceMalformed ||
1321
+ currentAssessment?.diagnosis?.omittedEntries)
1317
1322
  ? assessmentSourcesNode
1318
1323
  : undefined;
1319
1324
  const assessmentLimits = useMemo(() => {
@@ -1345,8 +1350,7 @@ export function InvestigationView({
1345
1350
  );
1346
1351
  // While the first assessment is still running the pane keeps the story
1347
1352
  // shape, so the page fills in rather than rearranging when the verdict lands.
1348
- const storyShell =
1349
- !hosted && !currentAssessment && lastTurn?.status === "running";
1353
+ const storyShell = !currentAssessment && lastTurn?.status === "running";
1350
1354
  const currentAssessmentEvidenceConflict =
1351
1355
  currentAssessment?.diagnosis?.healthy === true &&
1352
1356
  investigationEvidenceConflictsWithHealthy(projection);
@@ -1660,7 +1664,7 @@ export function InvestigationView({
1660
1664
  lastApplyAttemptIdx,
1661
1665
  localApplyAttemptAssessmentIdx,
1662
1666
  interactionsBlocked,
1663
- hosted,
1667
+ canApply,
1664
1668
  hasNewerEvidence: hasEvidenceCollectedAfterAssessment,
1665
1669
  })
1666
1670
  ? requestApply
@@ -1966,7 +1970,8 @@ export function InvestigationView({
1966
1970
  return turnCase.items.length ||
1967
1971
  turnResolution?.links.length ||
1968
1972
  turn.diagnosis.unlinkedEvidence ||
1969
- turn.diagnosis.evidenceMalformed ? (
1973
+ turn.diagnosis.evidenceMalformed ||
1974
+ turn.diagnosis.omittedEntries ? (
1970
1975
  <AssessmentSources
1971
1976
  renderedGroupIds={visibleEvidenceGroupIds}
1972
1977
  resolution={turnResolution}
@@ -1977,6 +1982,7 @@ export function InvestigationView({
1977
1982
  evidenceMalformed={
1978
1983
  turn.diagnosis.evidenceMalformed
1979
1984
  }
1985
+ omittedEntries={turn.diagnosis.omittedEntries}
1980
1986
  readOnly
1981
1987
  onViewSource={viewActivitySource}
1982
1988
  />
@@ -2356,12 +2362,14 @@ export function InvestigationView({
2356
2362
  story={
2357
2363
  storyShape && currentAssessment?.diagnosis
2358
2364
  ? {
2365
+ summary: currentAssessment.diagnosis.summary,
2359
2366
  report: currentAssessment.diagnosis.report,
2360
2367
  evidence: currentAssessment.diagnosis.evidence,
2361
2368
  }
2362
2369
  : undefined
2363
2370
  }
2364
2371
  storyShell={storyShell}
2372
+ summarizedLimits={storyShape ? assessmentLimits : undefined}
2365
2373
  collecting={
2366
2374
  explanationRequest?.status !== "running" &&
2367
2375
  (requestPending ||
@@ -21,6 +21,7 @@ import {
21
21
  } from "./investigationCase";
22
22
  import { AssessmentSources, ResultCard, assessmentSourceRows } from "./parts";
23
23
  import { namedInventoryRows } from "./investigationEvidence/bodies/inventory";
24
+ import { LogsBody } from "./investigationEvidence/bodies/streams";
24
25
  import type { Diagnosis, DiagnosisEvidenceItem } from "../../api/diagnose";
25
26
 
26
27
  const onViewSource = vi.fn();
@@ -1072,6 +1073,33 @@ describe("agent case placement (D-1, D-1b)", () => {
1072
1073
  ]);
1073
1074
  });
1074
1075
 
1076
+ it.each(["issue", "issues"])(
1077
+ "pins a %s citation to the issue observation",
1078
+ (observation) => {
1079
+ const resolved = resolveInvestigationCase(
1080
+ projection,
1081
+ {
1082
+ evidence: [
1083
+ linked(ref, "symptom", "The issue is the symptom.", {
1084
+ kind: "Deployment",
1085
+ group: "apps",
1086
+ namespace: "shop",
1087
+ name: "api",
1088
+ observation,
1089
+ }),
1090
+ ],
1091
+ },
1092
+ 0,
1093
+ );
1094
+ expect(resolved.items[0].placement).toBe("card");
1095
+ expect(
1096
+ projection.groups.find(
1097
+ (group) => group.id === resolved.items[0].groupId,
1098
+ )?.kind,
1099
+ ).toBe("issue");
1100
+ },
1101
+ );
1102
+
1075
1103
  it("pins a subject-bearing item to exactly the observation it names", () => {
1076
1104
  const resolved = resolveInvestigationCase(
1077
1105
  projection,
@@ -1187,6 +1215,61 @@ describe("agent case placement (D-1, D-1b)", () => {
1187
1215
  ]);
1188
1216
  });
1189
1217
 
1218
+ it.each([
1219
+ ["resolved", "app", "app", "card"],
1220
+ ["wrong container", "proxy", "app", "source"],
1221
+ ["unknown container", undefined, "app", "source"],
1222
+ ] as const)(
1223
+ "places a named log claim only against its %s stream",
1224
+ (_name, container, claimedContainer, placement) => {
1225
+ const ref = evidenceRef("a", "b");
1226
+ const projection = project(
1227
+ tool(
1228
+ "logs",
1229
+ "get_pod_logs",
1230
+ {
1231
+ container,
1232
+ lines: ["ERROR missing configuration"],
1233
+ totalLines: 1,
1234
+ matchedLines: 1,
1235
+ fallback: false,
1236
+ },
1237
+ {
1238
+ summary: JSON.stringify({ namespace: "shop", name: "api-abc" }),
1239
+ evidenceRef: ref,
1240
+ },
1241
+ ),
1242
+ );
1243
+ const resolved = resolveInvestigationCase(
1244
+ projection,
1245
+ {
1246
+ evidence: [
1247
+ linked(ref, "cause", "The application is missing configuration.", {
1248
+ kind: "Pod",
1249
+ namespace: "shop",
1250
+ name: "api-abc",
1251
+ container: claimedContainer,
1252
+ observation: "logs",
1253
+ }),
1254
+ ],
1255
+ },
1256
+ 0,
1257
+ );
1258
+ expect(resolved.items[0].placement).toBe(placement);
1259
+ if (placement === "card") {
1260
+ expect(resolved.items[0].groupId).toBe(projection.groups[0].id);
1261
+ }
1262
+ if (container === undefined) {
1263
+ const data = projection.groups[0].latest.data;
1264
+ expect(data.type).toBe("logs");
1265
+ if (data.type !== "logs") throw new Error("expected log evidence");
1266
+ const html = renderToStaticMarkup(<LogsBody data={data} />);
1267
+ expect(html).toContain("container unknown");
1268
+ expect(html).not.toContain("default container");
1269
+ }
1270
+ },
1271
+ );
1272
+
1190
1273
  it("binds to the exact earlier read and renders on its revision row, not the card head", () => {
1191
1274
  const first = evidenceRef("a", "c");
1192
1275
  const second = evidenceRef("a", "d");
@@ -54,7 +54,7 @@ export interface InvestigationCaseResolution {
54
54
  }
55
55
 
56
56
  /**
57
- * One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and the
57
+ * One half of a Go↔TS contract: `Roles` in pkg/investigation/verdict.go and the
58
58
  * DiagnosisEvidenceRole union in api/diagnose.ts must list exactly these roles.
59
59
  * A role the parser accepts but this set omits is bound server-side and then
60
60
  * silently dropped here. Change all three together.
@@ -388,7 +388,8 @@ function observationMatchesSubject(
388
388
  // "metrics" alone cannot name one; "metrics:<category>" picks the chart
389
389
  // whose identity ends in that category. An agent-run query is one chart,
390
390
  // so a qualifier on it carries no meaning.
391
- const [kind, qualifier] = subject.observation.toLowerCase().split(":", 2);
391
+ const [word, qualifier] = subject.observation.toLowerCase().split(":", 2);
392
+ const kind = word === "issues" ? "issue" : word;
392
393
  if (
393
394
  kind !== observation.data.type &&
394
395
  !(kind === "resource" && RESOURCE_SHAPED.has(observation.data.type))
@@ -434,6 +434,28 @@ describe("posture scans that did not finish", () => {
434
434
  });
435
435
 
436
436
  describe("listing cards (helm releases, packages, search)", () => {
437
+ it.each([null, []])("accepts an empty Helm result: %j", (payload) => {
438
+ const projection = project([
439
+ tool("helm", "list_helm_releases", payload, {
440
+ summary: JSON.stringify({ namespace: "shop" }),
441
+ }),
442
+ ]);
443
+ expect(projection.groups).toHaveLength(0);
444
+ expect(projection.limitations).toEqual([
445
+ expect.objectContaining({
446
+ source: "Resource inventory",
447
+ kind: "unknown",
448
+ }),
449
+ ]);
450
+ });
451
+
452
+ it.each([{}, "", 0])("rejects malformed Helm results: %j", (payload) => {
453
+ const projection = project([tool("helm", "list_helm_releases", payload)]);
454
+ expect(projection.limitations).toEqual([
455
+ expect.objectContaining({ source: "Helm releases", kind: "unknown" }),
456
+ ]);
457
+ });
458
+
437
459
  it("lists Helm releases with their status and chart", () => {
438
460
  const projection = project([
439
461
  tool(
@@ -264,7 +264,7 @@ describe("diagnose metrics evidence", () => {
264
264
  expect(groups.map((group) => group.latest.title)).toEqual([
265
265
  "CPU usage · Deployment shop/api",
266
266
  "Memory working set · Deployment shop/api",
267
- "Restarts · Deployment shop/api",
267
+ "Restarts in trailing 1h · Deployment shop/api",
268
268
  ]);
269
269
  for (const group of groups) {
270
270
  expect(group.latest.relevance).toBe("target");
@@ -289,6 +289,12 @@ describe("diagnose metrics evidence", () => {
289
289
  expect(investigationEvidenceSubjectRef(data)).toEqual(data.subject);
290
290
  }
291
291
  const [cpu, , restarts] = groups;
292
+ expect(restarts.latest.title).toContain("Restarts in trailing 1h");
293
+ expect(restarts.latest.data).toMatchObject({
294
+ type: "metrics",
295
+ label: "Restarts in trailing 1h",
296
+ unit: "count",
297
+ });
292
298
  expect(cpu.latest.summary).toBe("2 current pods · 60m window · 1m2s step");
293
299
  const cpuData = cpu.latest.data;
294
300
  if (cpuData.type !== "metrics") throw new Error("expected metrics");
@@ -562,7 +568,7 @@ describe("diagnose metrics evidence", () => {
562
568
  ]);
563
569
  const groups = groupsOf(projection.groups, "metrics");
564
570
  expect(groups.map((group) => group.latest.title)).toEqual([
565
- "Restarts · Deployment shop/api",
571
+ "Restarts in trailing 1h · Deployment shop/api",
566
572
  ]);
567
573
  expect(
568
574
  projection.limitations.some(
@@ -821,7 +821,7 @@ export function adaptDiagnose(
821
821
  const DIAGNOSE_METRICS_LABELS: Record<string, string> = {
822
822
  cpu: "CPU usage",
823
823
  memory: "Memory working set",
824
- restarts: "Restarts",
824
+ restarts: "Restarts in trailing 1h",
825
825
  };
826
826
  /**
827
827
  * The vitals `diagnose` captured inside the same call: one chart per category
@@ -13,6 +13,8 @@ export function adaptListHelmReleases(
13
13
  source: InvestigationEvidenceSource,
14
14
  payload: unknown,
15
15
  ): void {
16
+ // Retained runs may contain the Helm producer's nil-slice encoding.
17
+ if (payload === null) payload = [];
16
18
  if (!Array.isArray(payload)) {
17
19
  invalidPayload(builder, source);
18
20
  return;
@@ -13,6 +13,66 @@ import {
13
13
  workloadLogsPayload,
14
14
  } from "../evidenceFixtures";
15
15
 
16
+ describe("pod logs container identity", () => {
17
+ const payload = {
18
+ lines: ["ERROR missing configuration"],
19
+ totalLines: 1,
20
+ matchedLines: 1,
21
+ fallback: false,
22
+ };
23
+
24
+ it.each([
25
+ ["resolved identity", { ...payload, container: "app" }, {}, "app"],
26
+ [
27
+ "producer identity wins",
28
+ { ...payload, container: "app" },
29
+ { container: "proxy" },
30
+ "app",
31
+ ],
32
+ ["saved explicit request", payload, { container: "app" }, "app"],
33
+ ["saved unspecified request", payload, {}, undefined],
34
+ ])("uses %s", (_name, result, args, expected) => {
35
+ const projection = project([
36
+ tool("logs", "get_pod_logs", result, {
37
+ summary: JSON.stringify({ namespace: "shop", name: "api", ...args }),
38
+ }),
39
+ ]);
40
+ const observation = groupsOf(projection.groups, "logs")[0].latest;
41
+ expect(observation.data).toMatchObject({
42
+ type: "logs",
43
+ container: expected,
44
+ });
45
+ expect(observation.title).toContain(expected ?? "container unknown");
46
+ });
47
+
48
+ it("does not merge separate unknown streams or current and previous instances", () => {
49
+ const projection = project([
50
+ tool("unknown-1", "get_pod_logs", payload),
51
+ tool("unknown-2", "get_pod_logs", payload),
52
+ tool("current", "get_pod_logs", { ...payload, container: "app" }),
53
+ tool(
54
+ "previous",
55
+ "get_pod_logs",
56
+ { ...payload, container: "app" },
57
+ {
58
+ summary: JSON.stringify({
59
+ namespace: "shop",
60
+ name: "api",
61
+ previous: true,
62
+ }),
63
+ },
64
+ ),
65
+ ]);
66
+ const groups = groupsOf(projection.groups, "logs");
67
+ expect(groups).toHaveLength(4);
68
+ expect(new Set(groups.map((group) => group.identity)).size).toBe(4);
69
+ expect(groups.slice(2).map((group) => group.identity)).toEqual([
70
+ "logs:current:api:app",
71
+ "logs:previous:api:app",
72
+ ]);
73
+ });
74
+ });
75
+
16
76
  // The get_workload_logs response shape, with the stream rows fetchPodLogs
17
77
  // emits per pod and container.
18
78
 
@@ -39,9 +39,13 @@ export function adaptPodLogs(
39
39
  const warnings = stringArray(value?.warnings) ?? [];
40
40
  const args = record(source.args ? parseJSON(source.args) : undefined);
41
41
  const pod = nonEmptyString(args?.name) ? args.name : "Pod";
42
- const container = nonEmptyString(args?.container)
43
- ? args.container
44
- : "default container";
42
+ // Saved results may omit container identity while their arguments explicitly
43
+ // name the requested container. Otherwise its identity is unknown.
44
+ const container = nonEmptyString(value?.container)
45
+ ? value.container
46
+ : nonEmptyString(args?.container)
47
+ ? args.container
48
+ : undefined;
45
49
  addLogs(
46
50
  builder,
47
51
  source,
@@ -33,7 +33,7 @@ export function LogsBody({
33
33
  {condensed ? null : (
34
34
  <>
35
35
  <Badge tone="structural" size="sm">
36
- {data.pod} / {data.container}
36
+ {data.pod} / {data.container ?? "container unknown"}
37
37
  </Badge>
38
38
  <Badge severity="neutral" size="sm">
39
39
  {data.previous ? "previous instance" : "current instance"}
@@ -193,6 +193,8 @@ const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
193
193
  list_packages: "Installed packages",
194
194
  search: "Search results",
195
195
  discover_metrics: "Metric discovery",
196
+ get_cost: "Cost",
197
+ get_rightsizing: "Rightsizing",
196
198
  };
197
199
  export function investigationResultLabel(
198
200
  source: InvestigationEvidenceSource,
@@ -910,19 +912,20 @@ export function changesSubjectFromArgs(
910
912
  export function addLogs(
911
913
  builder: ProjectionBuilder,
912
914
  source: InvestigationEvidenceSource,
913
- value: DiagnosisPodLogEntry,
915
+ value: Omit<DiagnosisPodLogEntry, "container"> & { container?: string },
914
916
  previous: boolean,
915
917
  warnings: string[] = [],
916
918
  relevance: InvestigationEvidenceRelevance = "broader",
917
919
  namespace?: string,
918
920
  ): void {
919
921
  const lines = (value.logs?.lines ?? []).map((line) => stripAnsi(line));
922
+ const containerLabel = value.container ?? "container unknown";
920
923
  const normalizedWarnings = warnings.map((warning) => stripAnsi(warning));
921
924
  const normalizedError = value.error ? stripAnsi(value.error) : undefined;
922
925
  if (lines.length === 0) {
923
926
  builder.limit(
924
927
  source,
925
- `${value.pod} / ${value.container}`,
928
+ `${value.pod} / ${containerLabel}`,
926
929
  value.error ||
927
930
  "No log lines were available. This does not mean the container is healthy.",
928
931
  value.error ? "error" : "unknown",
@@ -939,7 +942,9 @@ export function addLogs(
939
942
  ),
940
943
  );
941
944
  const selectedEvidence = value.logs?.fallback !== true && diagnosticSignal;
942
- const identity = `logs:${previous ? "previous" : "current"}:${value.pod}:${value.container}`;
945
+ // Unknown streams from separate reads need not belong to the same container.
946
+ const streamIdentity = value.container ?? `unknown:${source.id}`;
947
+ const identity = `logs:${previous ? "previous" : "current"}:${value.pod}:${streamIdentity}`;
943
948
  builder.observe(identity, "logs", source, {
944
949
  // FilterLogs' raw-tail fallback is useful provenance, but the producer did
945
950
  // not select it as diagnostic signal. Keep it in Context; only filtered
@@ -950,7 +955,7 @@ export function addLogs(
950
955
  ),
951
956
  relevance,
952
957
  tone: selectedEvidence || normalizedError ? "warning" : "neutral",
953
- title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${value.container}`,
958
+ title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${containerLabel}`,
954
959
  summary:
955
960
  lines.length > 0
956
961
  ? `${lines.length} selected line${lines.length === 1 ? "" : "s"}`
@@ -969,7 +974,7 @@ export function addLogs(
969
974
  if (normalizedError) {
970
975
  builder.limit(
971
976
  source,
972
- `${value.pod} / ${value.container}`,
977
+ `${value.pod} / ${containerLabel}`,
973
978
  normalizedError,
974
979
  "error",
975
980
  );
@@ -407,7 +407,7 @@ export type InvestigationEvidenceData =
407
407
  | {
408
408
  type: "logs";
409
409
  pod: string;
410
- container: string;
410
+ container?: string;
411
411
  /** Namespace the producing call actually read; absent when unstated. */
412
412
  namespace?: string;
413
413
  previous: boolean;
@@ -611,14 +611,14 @@ describe("investigation action gating", () => {
611
611
  lastApplyAttemptIdx: -1,
612
612
  localApplyAttemptAssessmentIdx: -1,
613
613
  interactionsBlocked: false,
614
- hosted: false,
614
+ canApply: true,
615
615
  hasNewerEvidence: false,
616
616
  };
617
617
  expect(canOfferInvestigationApply(base)).toBe(true);
618
618
  expect(
619
619
  canOfferInvestigationApply({ ...base, hasNewerEvidence: true }),
620
620
  ).toBe(false);
621
- expect(canOfferInvestigationApply({ ...base, hosted: true })).toBe(false);
621
+ expect(canOfferInvestigationApply({ ...base, canApply: false })).toBe(false);
622
622
  expect(
623
623
  canOfferInvestigationApply({ ...base, interactionsBlocked: true }),
624
624
  ).toBe(false);
@@ -76,7 +76,7 @@ export function canOfferInvestigationApply(input: {
76
76
  lastApplyAttemptIdx: number;
77
77
  localApplyAttemptAssessmentIdx: number;
78
78
  interactionsBlocked: boolean;
79
- hosted: boolean;
79
+ canApply: boolean;
80
80
  hasNewerEvidence: boolean;
81
81
  }): boolean {
82
82
  return (
@@ -87,7 +87,7 @@ export function canOfferInvestigationApply(input: {
87
87
  input.localApplyAttemptAssessmentIdx,
88
88
  ) &&
89
89
  !input.interactionsBlocked &&
90
- !input.hosted &&
90
+ input.canApply &&
91
91
  !input.hasNewerEvidence
92
92
  );
93
93
  }