@skyhook-io/radar-app 1.14.0 → 1.14.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 (63) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +6 -4
  3. package/src/api/client.ts +3 -0
  4. package/src/api/diagnose.ts +31 -1
  5. package/src/components/CloudConnectFlow.tsx +173 -13
  6. package/src/components/CloudFunnelButton.tsx +4 -2
  7. package/src/components/ConnectionErrorView.tsx +9 -10
  8. package/src/components/DebugOverlay.tsx +10 -6
  9. package/src/components/cloudConnectHandoff.test.ts +109 -4
  10. package/src/components/cloudConnectHandoff.ts +154 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +19 -26
  12. package/src/components/diagnose/AgentCase.tsx +18 -5
  13. package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
  14. package/src/components/diagnose/AnalysisStory.tsx +564 -0
  15. package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
  16. package/src/components/diagnose/DiagnoseContext.tsx +25 -8
  17. package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
  18. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
  19. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
  20. package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
  21. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
  22. package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
  23. package/src/components/diagnose/InvestigationView.tsx +493 -529
  24. package/src/components/diagnose/investigationCase.test.tsx +267 -129
  25. package/src/components/diagnose/investigationCase.ts +122 -77
  26. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +27 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +28 -0
  28. package/src/components/diagnose/investigationEvidence/builder.ts +11 -2
  29. package/src/components/diagnose/investigationEvidence/identity.test.ts +25 -7
  30. package/src/components/diagnose/investigationEvidence/identity.ts +4 -4
  31. package/src/components/diagnose/investigationEvidence/observations.ts +24 -0
  32. package/src/components/diagnose/investigationEvidence/projection.test.ts +36 -3
  33. package/src/components/diagnose/investigationEvidence/types.ts +2 -0
  34. package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
  35. package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
  36. package/src/components/diagnose/investigationState.test.ts +316 -58
  37. package/src/components/diagnose/investigationState.ts +257 -44
  38. package/src/components/diagnose/investigationStory.test.ts +161 -0
  39. package/src/components/diagnose/investigationStory.ts +207 -0
  40. package/src/components/diagnose/parts.test.tsx +9 -6
  41. package/src/components/diagnose/parts.tsx +977 -193
  42. package/src/components/diagnose/storyParts.test.tsx +426 -0
  43. package/src/components/diagnose/toolCallLabel.test.ts +51 -0
  44. package/src/components/diagnose/toolCallLabel.ts +135 -0
  45. package/src/components/diagnose/useDisclosureReveal.ts +10 -11
  46. package/src/components/helm/HelmCompareRoute.tsx +18 -4
  47. package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
  48. package/src/components/helm/InstallWizard.tsx +8 -3
  49. package/src/components/home/MCPSetupDialog.tsx +15 -9
  50. package/src/components/portforward/PortForwardManager.tsx +6 -13
  51. package/src/components/resource/PrometheusChartsGrid.tsx +3 -3
  52. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +3 -3
  53. package/src/components/resource/WorkloadMetricsSection.tsx +15 -21
  54. package/src/components/resources/PodFilePreview.tsx +3 -3
  55. package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
  56. package/src/components/settings/SettingsDialog.tsx +4 -2
  57. package/src/components/ui/CommandPalette.tsx +10 -6
  58. package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
  59. package/src/components/ui/Disclosure.tsx +47 -0
  60. package/src/components/ui/Markdown.tsx +23 -11
  61. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
  62. package/src/components/ui/UpdateNotification.tsx +18 -2
  63. package/src/index.css +19 -6
@@ -33,6 +33,8 @@ export interface InvestigationCaseItem {
33
33
  index: number;
34
34
  role: DiagnosisEvidenceRole;
35
35
  claim: string;
36
+ /** What the agent says this result does not cover. */
37
+ gap?: string;
36
38
  subject?: DiagnosisEvidenceSubject;
37
39
  source: InvestigationEvidenceSource;
38
40
  placement: InvestigationCasePlacement;
@@ -51,65 +53,6 @@ export interface InvestigationCaseResolution {
51
53
  ruledOut: InvestigationCaseRuledOut[];
52
54
  }
53
55
 
54
- /**
55
- * Notes the agent put on cards in earlier assessments stay on those cards
56
- * when a later turn takes over the pane's case, unless the later turn
57
- * addressed the same card; a follow-up about one chart must not strip the
58
- * initial assessment's reading from everything else. Ordering and the
59
- * ruled-out list follow the live turn alone, and source-placed notes stay
60
- * listed under their own assessment. `earlier` is newest first.
61
- */
62
- export function mergeInvestigationCases(
63
- live: InvestigationCaseResolution | undefined,
64
- earlier: readonly (InvestigationCaseResolution | undefined)[],
65
- ): InvestigationCaseResolution | undefined {
66
- const liveItems = live?.items ?? [];
67
- const covered = new Set(
68
- liveItems.flatMap((item) => (item.groupId ? [item.groupId] : [])),
69
- );
70
- const carried: InvestigationCaseItem[] = [];
71
- for (const resolution of earlier) {
72
- // The newest assessment that spoke about a group wins it outright, and it
73
- // wins with everything it said: a card note and a pinned revision note are
74
- // two readings of one group, not rivals. Coverage is therefore taken after
75
- // the whole resolution, not as each of its items is carried.
76
- const takenHere = new Set<string>();
77
- for (const item of resolution?.items ?? []) {
78
- if (item.placement === "source" || !item.groupId) continue;
79
- if (covered.has(item.groupId)) continue;
80
- takenHere.add(item.groupId);
81
- carried.push(item);
82
- }
83
- for (const groupId of takenHere) covered.add(groupId);
84
- }
85
- if (carried.length === 0) return live;
86
- return { items: [...liveItems, ...carried], ruledOut: live?.ruledOut ?? [] };
87
- }
88
-
89
- /**
90
- * The subset of one assessment's items that survived into the case the pane
91
- * actually renders. Matching is by value, not object identity: the merge is
92
- * fed freshly resolved copies of every turn, so an identity test would report
93
- * that an assessment's own note had vanished the moment any other turn was
94
- * re-resolved.
95
- */
96
- export function investigationCaseItemsStillRendered(
97
- assessmentItems: readonly InvestigationCaseItem[] | undefined,
98
- renderedItems: readonly InvestigationCaseItem[] | undefined,
99
- ): InvestigationCaseItem[] {
100
- if (!assessmentItems?.length || !renderedItems?.length) return [];
101
- const key = (item: InvestigationCaseItem) =>
102
- [
103
- item.index,
104
- item.source.id,
105
- item.placement,
106
- item.groupId ?? "",
107
- item.observation ? investigationCaseObservationKey(item.observation) : "",
108
- ].join("\u0000");
109
- const rendered = new Set(renderedItems.map(key));
110
- return assessmentItems.filter((item) => rendered.has(key(item)));
111
- }
112
-
113
56
  /**
114
57
  * One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and the
115
58
  * DiagnosisEvidenceRole union in api/diagnose.ts must list exactly these roles.
@@ -134,10 +77,12 @@ export function investigationCaseObservationKey(
134
77
 
135
78
  /**
136
79
  * Resolves the agent's case for one assessment. Refs are re-validated against
137
- * the assessment turn exactly like root-cause refs; an item whose ref does not
138
- * resolve is dropped on its own. Placement follows the observation the
139
- * (ref, subject) pair names: exactly one match pins the claim to that
140
- * observation, anything else falls back to the source row.
80
+ * the run up to and including the assessment turn, exactly like root-cause
81
+ * refs — a revised assessment may rest on a result read in an earlier turn,
82
+ * and the observation it resolves to is that earlier read, never a later one.
83
+ * An item whose ref does not resolve is dropped on its own. Placement follows
84
+ * the observation the (ref, subject) pair names: exactly one match pins the
85
+ * claim to that observation, anything else falls back to the source row.
141
86
  */
142
87
  export function resolveInvestigationCase(
143
88
  projection: InvestigationEvidenceProjection,
@@ -149,17 +94,17 @@ export function resolveInvestigationCase(
149
94
  ): InvestigationCaseResolution {
150
95
  const evidence = diagnosis?.evidence ?? [];
151
96
  if (evidence.length === 0) return { items: [], ruledOut: [] };
97
+ const targetPods = new Set(projection.targetPods);
152
98
  const byRef = new Map<string, InvestigationEvidenceSource[]>();
153
99
  for (const source of projection.evidenceRefSources) {
154
- if (source.turnIndex !== assessmentTurnIndex || !source.evidenceRef)
155
- continue;
100
+ if (source.turnIndex > assessmentTurnIndex || !source.evidenceRef) continue;
156
101
  const matches = byRef.get(source.evidenceRef) ?? [];
157
102
  matches.push(source);
158
103
  byRef.set(source.evidenceRef, matches);
159
104
  }
160
105
  const citableSourceIds = new Set(
161
106
  projection.citableSources
162
- .filter((source) => source.turnIndex === assessmentTurnIndex)
107
+ .filter((source) => source.turnIndex <= assessmentTurnIndex)
163
108
  .map((source) => source.id),
164
109
  );
165
110
  const items: InvestigationCaseItem[] = [];
@@ -188,6 +133,9 @@ export function resolveInvestigationCase(
188
133
  index,
189
134
  role: entry.role,
190
135
  claim: typeof entry.claim === "string" ? entry.claim.trim() : "",
136
+ ...(typeof entry.gap === "string" && entry.gap.trim()
137
+ ? { gap: entry.gap.trim() }
138
+ : {}),
191
139
  ...(subject ? { subject } : {}),
192
140
  source,
193
141
  placement: "source",
@@ -200,7 +148,12 @@ export function resolveInvestigationCase(
200
148
  (observation) =>
201
149
  observation.source.id === source.id &&
202
150
  (!subject ||
203
- observationMatchesSubject(group, observation, subject)),
151
+ observationMatchesSubject(
152
+ group,
153
+ observation,
154
+ subject,
155
+ targetPods,
156
+ )),
204
157
  )
205
158
  .map((observation) => ({ group, observation })),
206
159
  );
@@ -241,11 +194,11 @@ export function resolveInvestigationCase(
241
194
  function validCaseSubject(
242
195
  value: DiagnosisEvidenceSubject | undefined,
243
196
  ): DiagnosisEvidenceSubject | undefined {
244
- if (!value || !nonEmptyString(value.kind) || !nonEmptyString(value.name))
245
- return undefined;
197
+ if (!value || !nonEmptyString(value.kind)) return undefined;
246
198
  const optional = (field: unknown) =>
247
199
  field === undefined || typeof field === "string";
248
200
  if (
201
+ !optional(value.name) ||
249
202
  !optional(value.group) ||
250
203
  !optional(value.namespace) ||
251
204
  !optional(value.container) ||
@@ -268,6 +221,8 @@ interface ObservationSubjectIdentity {
268
221
  name: string;
269
222
  container?: string;
270
223
  stream?: "current" | "previous";
224
+ /** A listing names a kind (and scope) but no single object. */
225
+ listing?: boolean;
271
226
  }
272
227
 
273
228
  /**
@@ -319,17 +274,44 @@ function observationSubjectIdentity(
319
274
  };
320
275
  }
321
276
  const args = investigationSourceArgs(observation.source);
322
- if (!args || !nonEmptyString(args.kind) || !nonEmptyString(args.name))
323
- return undefined;
277
+ // A listing whose call names no kind (list_namespaces) is still a listing
278
+ // of the one kind its rows carry.
279
+ if (data.type === "inventory" && !nonEmptyString(args?.kind)) {
280
+ const kinds = new Set(data.resources.map((resource) => resource.kind));
281
+ if (kinds.size !== 1) return undefined;
282
+ return {
283
+ kind: [...kinds][0],
284
+ namespace: nonEmptyString(args?.namespace) ? args.namespace : undefined,
285
+ name: "",
286
+ listing: true,
287
+ };
288
+ }
289
+ if (!args || !nonEmptyString(args.kind)) return undefined;
324
290
  return {
325
291
  kind: args.kind,
326
292
  group: nonEmptyString(args.group) ? args.group : undefined,
327
293
  namespace: nonEmptyString(args.namespace) ? args.namespace : undefined,
328
- name: args.name,
294
+ name: nonEmptyString(args.name) ? args.name : "",
295
+ ...(nonEmptyString(args.name) ? {} : { listing: true }),
329
296
  };
330
297
  }
331
298
 
332
- function sameKind(left: string, right: string): boolean {
299
+ // No CRD lives in a built-in API group: the core group under its three
300
+ // spellings, the four legacy unsuffixed groups, or any group under k8s.io.
301
+ const LEGACY_BUILT_IN_GROUPS = new Set([
302
+ "",
303
+ "core",
304
+ "v1",
305
+ "apps",
306
+ "batch",
307
+ "autoscaling",
308
+ "policy",
309
+ ]);
310
+ function isBuiltInGroup(group: string): boolean {
311
+ return LEGACY_BUILT_IN_GROUPS.has(group) || group.endsWith(".k8s.io");
312
+ }
313
+
314
+ export function sameKind(left: string, right: string): boolean {
333
315
  return pluralToKind(left).toLowerCase() === pluralToKind(right).toLowerCase();
334
316
  }
335
317
 
@@ -342,6 +324,7 @@ function observationMatchesSubject(
342
324
  group: InvestigationEvidenceGroup,
343
325
  observation: InvestigationEvidenceObservation,
344
326
  subject: DiagnosisEvidenceSubject,
327
+ targetPods: ReadonlySet<string>,
345
328
  ): boolean {
346
329
  if (subject.observation !== undefined) {
347
330
  // A diagnose bundle captures several vitals charts for one resource, so
@@ -349,7 +332,10 @@ function observationMatchesSubject(
349
332
  // whose identity ends in that category. An agent-run query is one chart,
350
333
  // so a qualifier on it carries no meaning.
351
334
  const [kind, qualifier] = subject.observation.toLowerCase().split(":", 2);
352
- if (kind !== observation.data.type) return false;
335
+ // A listing is resources too: "resource" names an inventory card as well.
336
+ const inventoryAsResource =
337
+ kind === "resource" && observation.data.type === "inventory";
338
+ if (kind !== observation.data.type && !inventoryAsResource) return false;
353
339
  if (
354
340
  qualifier !== undefined &&
355
341
  observation.data.type === "metrics" &&
@@ -359,6 +345,10 @@ function observationMatchesSubject(
359
345
  return false;
360
346
  }
361
347
  }
348
+ // One rules result holds many rules and none states a resource; the rule's
349
+ // name is the handle that tells them apart.
350
+ if (observation.data.type === "alerts" && subject.name !== undefined)
351
+ return observation.data.rule.name === subject.name;
362
352
  const identities = observationIdentities(observation);
363
353
  // An observation that states no resource of its own (an agent-run query
364
354
  // whose selectors do not name the target exactly, a topology summary) can
@@ -366,7 +356,7 @@ function observationMatchesSubject(
366
356
  // the rest.
367
357
  if (identities.length === 0) return subject.observation !== undefined;
368
358
  return identities.some((identity) =>
369
- identityMatchesSubject(identity, observation, subject),
359
+ identityMatchesSubject(identity, observation, subject, targetPods),
370
360
  );
371
361
  }
372
362
 
@@ -397,17 +387,72 @@ function observationIdentities(
397
387
  return identities;
398
388
  }
399
389
 
390
+ // Pod names are DNS subdomains (letters, digits, hyphens, dots), so a name
391
+ // is a whole word between characters that cannot be part of one; "api"
392
+ // inside "api-other" names another Pod.
393
+ function namesPod(text: string, name: string): boolean {
394
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
395
+ return new RegExp(`(^|[^A-Za-z0-9.-])${escaped}(?![A-Za-z0-9.-])`).test(text);
396
+ }
397
+
400
398
  function identityMatchesSubject(
401
399
  identity: ObservationSubjectIdentity,
402
400
  observation: InvestigationEvidenceObservation,
403
401
  subject: DiagnosisEvidenceSubject,
402
+ targetPods: ReadonlySet<string>,
404
403
  ): boolean {
405
- if (!sameKind(identity.kind, subject.kind) || identity.name !== subject.name)
404
+ // "The events of namespace X" names a namespace-scoped read whose
405
+ // observation inherits the resource its call was asked about; the agent
406
+ // naming the namespace instead is not wrong, so a stated evidence kind plus
407
+ // a matching namespace is enough for that shape.
408
+ if (
409
+ subject.observation !== undefined &&
410
+ sameKind(subject.kind, "Namespace") &&
411
+ identity.namespace !== undefined &&
412
+ identity.namespace === subject.name
413
+ )
414
+ return true;
415
+ // A workload read collects its pods' events, so "the events of Pod X" names
416
+ // the events observation a diagnose bundle or workload call inherited from
417
+ // the workload; the ref already fixes the call, uniqueness fixes the card.
418
+ if (
419
+ subject.observation !== undefined &&
420
+ observation.data.type === "events" &&
421
+ sameKind(subject.kind, "Pod") &&
422
+ !sameKind(identity.kind, "Pod") &&
423
+ (identity.namespace === undefined ||
424
+ subject.namespace === undefined ||
425
+ identity.namespace === subject.namespace) &&
426
+ // The Pod has to be the target's own on events the target's read produced,
427
+ // or named whole by an event: a Pod nothing here establishes is not this card.
428
+ (subject.name === undefined ||
429
+ (targetPods.has(subject.name) && observation.relevance !== "broader") ||
430
+ observation.data.events.some((event) =>
431
+ namesPod(event.message, subject.name!),
432
+ ))
433
+ )
434
+ return true;
435
+ if (!sameKind(identity.kind, subject.kind)) return false;
436
+ // A subject with no name (a listing cited for what it does not contain) is
437
+ // a wildcard that uniqueness still gates. A listing has no name of its own; the agent naming the entry it means
438
+ // ("ConfigMap kube-root-ca.crt" in the ConfigMaps of a namespace) still
439
+ // points at that listing, whether or not the listing holds the entry: an
440
+ // absent entry is often the point, and the card says which it is.
441
+ if (
442
+ !identity.listing &&
443
+ subject.name !== undefined &&
444
+ identity.name !== subject.name
445
+ )
406
446
  return false;
447
+ // A built-in group on a core kind ("apps" on a Pod) is the agent
448
+ // misremembering the API, not naming a different object: no CRD lives in a
449
+ // built-in group, so nothing else could be meant. A vendor group on a core
450
+ // kind (a Knative Service) still names a different object.
407
451
  if (
408
452
  subject.group !== undefined &&
409
453
  identity.group !== undefined &&
410
- subject.group.toLowerCase() !== identity.group.toLowerCase()
454
+ subject.group.toLowerCase() !== identity.group.toLowerCase() &&
455
+ !(identity.group === "" && isBuiltInGroup(subject.group.toLowerCase()))
411
456
  ) {
412
457
  return false;
413
458
  }
@@ -27,6 +27,33 @@ describe("resource cards scaled by an HPA", () => {
27
27
  },
28
28
  });
29
29
 
30
+ it("states a workload's readiness from the resource when the read carried no context", () => {
31
+ const result = project([
32
+ tool(
33
+ "res",
34
+ "get_resource",
35
+ {
36
+ resource: {
37
+ apiVersion: "apps/v1",
38
+ kind: "Deployment",
39
+ metadata: { namespace: "shop", name: "api" },
40
+ spec: { replicas: 1 },
41
+ status: { replicas: 1, readyReplicas: 0 },
42
+ },
43
+ },
44
+ {
45
+ summary: JSON.stringify({
46
+ kind: "deployment",
47
+ namespace: "shop",
48
+ name: "api",
49
+ }),
50
+ },
51
+ ),
52
+ ]);
53
+ const card = groupsOf(result.groups, "resource")[0].latest;
54
+ expect(card.summary).toBe("0/1 replicas ready");
55
+ });
56
+
30
57
  it("lifts a healthy workload whose autoscaler cannot act", () => {
31
58
  const result = project([scaled("metrics_unavailable")]);
32
59
  const card = groupsOf(result.groups, "resource")[0].latest;
@@ -198,6 +198,34 @@ export function adaptGetResource(
198
198
  );
199
199
  }
200
200
  }
201
+ // A namespace listing is an inventory like any other; its entries carry no
202
+ // kind of their own, so the card gets one.
203
+ export function adaptListNamespaces(
204
+ builder: ProjectionBuilder,
205
+ source: InvestigationEvidenceSource,
206
+ payload: unknown,
207
+ ): void {
208
+ if (!Array.isArray(payload)) {
209
+ invalidPayload(builder, source);
210
+ return;
211
+ }
212
+ adaptListResources(
213
+ builder,
214
+ source,
215
+ payload.map((entry) => {
216
+ const item = record(entry);
217
+ return item
218
+ ? {
219
+ kind: "Namespace",
220
+ name: item.name,
221
+ status: item.status,
222
+ terminating: item.status === "Terminating",
223
+ }
224
+ : entry;
225
+ }),
226
+ );
227
+ }
228
+
201
229
  export function adaptListResources(
202
230
  builder: ProjectionBuilder,
203
231
  source: InvestigationEvidenceSource,
@@ -1,4 +1,7 @@
1
- import { apiVersionToGroup, knownKindForPluralWithGroup } from "../../../utils/navigation";
1
+ import {
2
+ apiVersionToGroup,
3
+ knownKindForPluralWithGroup,
4
+ } from "../../../utils/navigation";
2
5
  import { adaptChanges } from "./adapters/changes";
3
6
  import { adaptDiagnose } from "./adapters/diagnose";
4
7
  import { adaptEvents } from "./adapters/events";
@@ -10,7 +13,11 @@ import {
10
13
  adaptPrometheusRules,
11
14
  adaptQueryPrometheus,
12
15
  } from "./adapters/prometheus";
13
- import { adaptGetResource, adaptListResources } from "./adapters/resource";
16
+ import {
17
+ adaptGetResource,
18
+ adaptListResources,
19
+ adaptListNamespaces,
20
+ } from "./adapters/resource";
14
21
  import { adaptNeighborhood, adaptTopology } from "./adapters/topology";
15
22
  import {
16
23
  investigationEvidenceRefRe,
@@ -58,6 +65,7 @@ const ADAPTERS: Record<
58
65
  issues: adaptIssues,
59
66
  get_resource: adaptGetResource,
60
67
  list_resources: adaptListResources,
68
+ list_namespaces: adaptListNamespaces,
61
69
  get_events: adaptEvents,
62
70
  get_pod_logs: adaptPodLogs,
63
71
  get_changes: adaptChanges,
@@ -408,6 +416,7 @@ export function projectInvestigationEvidence(
408
416
  sources: builder.sources,
409
417
  evidenceRefSources,
410
418
  citableSources,
419
+ targetPods: [...builder.establishedTargetPods],
411
420
  coverage: {
412
421
  attempted: builder.sources.length,
413
422
  projected: builder.projectedSources.size,
@@ -471,7 +471,7 @@ describe("root-cause evidence resolution", () => {
471
471
  ).toBeUndefined();
472
472
  });
473
473
 
474
- it("fails closed for malformed, unmatched, prior-turn, failed, or partial refs", () => {
474
+ it("fails closed for malformed, unmatched, later-turn, failed, or partial refs", () => {
475
475
  const currentRef = evidenceRef("a", "b");
476
476
  const priorRef = evidenceRef("c", "d");
477
477
  const failedRef = evidenceRef("a", "e");
@@ -493,9 +493,27 @@ describe("root-cause evidence resolution", () => {
493
493
  ],
494
494
  );
495
495
 
496
+ // A result read in an earlier turn of the same run is citable — a revised
497
+ // assessment may rest on it — and resolves to that earlier read.
498
+ expect(
499
+ resolveInvestigationRootCauseEvidence(
500
+ projection,
501
+ { status: "linked", refs: [priorRef] },
502
+ 1,
503
+ ),
504
+ ).toMatchObject({ status: "linked", links: [{ source: { stepId: "old" } }] });
505
+ // A result read AFTER the assessment turn is not: the assessment could not
506
+ // have seen it.
507
+ expect(
508
+ resolveInvestigationRootCauseEvidence(
509
+ projection,
510
+ { status: "linked", refs: [currentRef] },
511
+ 0,
512
+ ),
513
+ ).toEqual({ status: "invalid", links: [] });
514
+
496
515
  for (const evidence of [
497
516
  { status: "linked" as const, refs: [evidenceRef("a", "z")] },
498
- { status: "linked" as const, refs: [priorRef] },
499
517
  { status: "linked" as const, refs: [failedRef] },
500
518
  { status: "linked" as const, refs: [partialRef] },
501
519
  { status: "linked" as const, refs: [currentRef, currentRef] },
@@ -531,7 +549,7 @@ describe("root-cause evidence resolution", () => {
531
549
  ).toEqual({ status: "invalid", links: [] });
532
550
  });
533
551
 
534
- it("counts ineligible duplicate refs only in the assessment turn", () => {
552
+ it("counts duplicate refs across every turn up to the assessment", () => {
535
553
  const ref = evidenceRef("a", "b");
536
554
  const currentDuplicate = project(
537
555
  [tool("prior", "get_resource", deployment, { evidenceRef: ref })],
@@ -562,16 +580,16 @@ describe("root-cause evidence resolution", () => {
562
580
  ],
563
581
  [tool("current-only", "get_resource", deployment, { evidenceRef: ref })],
564
582
  );
583
+ // A server-issued ref names one payload in one scope; the same ref in two
584
+ // turns can only be a replay, so run-wide uniqueness is the rule — the
585
+ // same rule the server binds by.
565
586
  expect(
566
587
  resolveInvestigationRootCauseEvidence(
567
588
  priorDuplicateOnly,
568
589
  { status: "linked", refs: [ref] },
569
590
  1,
570
591
  ),
571
- ).toMatchObject({
572
- status: "linked",
573
- links: [{ source: { stepId: "current-only" } }],
574
- });
592
+ ).toEqual({ status: "invalid", links: [] });
575
593
  });
576
594
 
577
595
  it("rejects a replay that claims refs from different investigation scopes are linked", () => {
@@ -189,7 +189,7 @@ export function resolveInvestigationRootCauseEvidence(
189
189
 
190
190
  const byRef = new Map<string, InvestigationEvidenceSource[]>();
191
191
  for (const source of projection.evidenceRefSources) {
192
- if (source.turnIndex !== assessmentTurnIndex) continue;
192
+ if (source.turnIndex > assessmentTurnIndex) continue;
193
193
  if (!source.evidenceRef) continue;
194
194
  const matches = byRef.get(source.evidenceRef) ?? [];
195
195
  matches.push(source);
@@ -197,14 +197,14 @@ export function resolveInvestigationRootCauseEvidence(
197
197
  }
198
198
  const citableSourceIds = new Set(
199
199
  projection.citableSources
200
- .filter((source) => source.turnIndex === assessmentTurnIndex)
200
+ .filter((source) => source.turnIndex <= assessmentTurnIndex)
201
201
  .map((source) => source.id),
202
202
  );
203
203
  const links: InvestigationRootCauseEvidenceLink[] = [];
204
204
  for (const ref of refs) {
205
205
  const matches = byRef.get(ref);
206
- // Match the server's fail-closed binding: every current-turn occurrence
207
- // counts before success/completeness eligibility is considered.
206
+ // Match the server's fail-closed binding: every occurrence up to this
207
+ // turn counts before success/completeness eligibility is considered.
208
208
  if (matches?.length !== 1 || !citableSourceIds.has(matches[0].id)) {
209
209
  return { status: "invalid", links: [] };
210
210
  }
@@ -174,6 +174,7 @@ const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
174
174
  issues: "Issue scan",
175
175
  get_resource: "Resource details",
176
176
  list_resources: "Resource inventory",
177
+ list_namespaces: "Namespace inventory",
177
178
  get_events: "Kubernetes events",
178
179
  get_pod_logs: "Container logs",
179
180
  get_changes: "Recent changes",
@@ -585,11 +586,34 @@ function resourceObservationSummary(
585
586
  if (context?.statusSummary?.phase) return context.statusSummary.phase;
586
587
  if (context?.issueSummary?.topReason) return context.issueSummary.topReason;
587
588
  return (
589
+ workloadReadinessFromResource(resource) ||
588
590
  investigationResourceEvidenceSummary(resource) ||
589
591
  warnings[0] ||
590
592
  resource.metadata.namespace
591
593
  );
592
594
  }
595
+
596
+ // A workload read that carried no context still states its own readiness;
597
+ // a row that names only its namespace says nothing.
598
+ function workloadReadinessFromResource(
599
+ resource: InvestigationKubernetesResource,
600
+ ): string | undefined {
601
+ const spec = record(resource.spec);
602
+ const status = record(resource.status);
603
+ const count = (value: unknown): number | undefined =>
604
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
605
+ if (resource.kind === "Deployment" || resource.kind === "StatefulSet") {
606
+ const desired = count(spec?.replicas) ?? (spec ? 1 : undefined);
607
+ if (desired === undefined) return undefined;
608
+ return `${count(status?.readyReplicas) ?? 0}/${desired} replicas ready`;
609
+ }
610
+ if (resource.kind === "DaemonSet") {
611
+ const desired = count(status?.desiredNumberScheduled);
612
+ if (desired === undefined) return undefined;
613
+ return `${count(status?.numberReady) ?? 0}/${desired} pods ready`;
614
+ }
615
+ return undefined;
616
+ }
593
617
  // A scaler that cannot act (no metrics, cannot read the target) or is pinned
594
618
  // at its ceiling is a captured Radar fact about the workload, so it lifts the
595
619
  // card the way an adverse condition does. Ordinary scale-ups and scale-downs
@@ -510,9 +510,7 @@ describe("strict evidence adapters", () => {
510
510
  expect(groupsOf(result.groups, "events")[0].latest.relevance).toBe(
511
511
  "target",
512
512
  );
513
- expect(groupsOf(result.groups, "issue")[0].latest.relevance).toBe(
514
- "target",
515
- );
513
+ expect(groupsOf(result.groups, "issue")[0].latest.relevance).toBe("target");
516
514
  });
517
515
 
518
516
  it("keeps colliding non-core plurals on their own API group", () => {
@@ -2701,3 +2699,38 @@ describe("live-run follow-ups", () => {
2701
2699
  expect(changes[1].latest.title).toBe("Recent changes · last 24h");
2702
2700
  });
2703
2701
  });
2702
+
2703
+ describe("namespace listings", () => {
2704
+ it("renders a list_namespaces result as an inventory card of Namespaces", () => {
2705
+ const projection = projectInvestigationEvidence(
2706
+ [
2707
+ {
2708
+ timeline: [
2709
+ tool(
2710
+ "namespaces",
2711
+ "list_namespaces",
2712
+ [
2713
+ {
2714
+ name: "capi-system",
2715
+ status: "Terminating",
2716
+ labels: { a: "b" },
2717
+ },
2718
+ { name: "shop", status: "Active" },
2719
+ ],
2720
+ { summary: "{}" },
2721
+ ),
2722
+ ],
2723
+ },
2724
+ ],
2725
+ { kind: "Deployment", group: "apps", namespace: "shop", name: "api" },
2726
+ );
2727
+ const card = projection.groups.find((group) => group.kind === "inventory");
2728
+ expect(card?.latest.title).toBe("Namespaces");
2729
+ expect(card?.latest.summary).toBe("2 returned");
2730
+ expect(
2731
+ card?.latest.data.type === "inventory"
2732
+ ? card.latest.data.resources.map((r) => r.name)
2733
+ : [],
2734
+ ).toEqual(["capi-system", "shop"]);
2735
+ });
2736
+ });
@@ -500,6 +500,8 @@ export interface InvestigationEvidenceProjection {
500
500
  /** Complete confirmed-success sources eligible for server-authored links. */
501
501
  citableSources: InvestigationEvidenceSource[];
502
502
  coverage: InvestigationEvidenceCoverage;
503
+ /** Pods a producer established as the target's own, by name. */
504
+ targetPods: string[];
503
505
  }
504
506
  export interface InvestigationRootCauseEvidenceLink {
505
507
  source: InvestigationEvidenceSource;
@@ -428,6 +428,8 @@ describe("coverage presentation", () => {
428
428
  limitation("Container logs", "Connection timed out", "error"),
429
429
  ]);
430
430
  expect(groups[0].hasError).toBe(true);
431
+ expect(groups[0].bookkeepingOnly).toBe(false);
432
+ expect(groups[1].bookkeepingOnly).toBe(true);
431
433
  expect(groups[0].summary).toContain("Forbidden");
432
434
  expect(groups[0].summary).toContain("Connection timed out");
433
435
  expect(groups[0].limitations).toHaveLength(3);
@@ -105,6 +105,8 @@ export interface EvidenceCoverageGroup {
105
105
  limitations: InvestigationEvidenceLimitation[];
106
106
  hasError: boolean;
107
107
  historyOnly: boolean;
108
+ /** Every entry is a sampling cap or a history bound: bookkeeping for the record, not an open question. */
109
+ bookkeepingOnly: boolean;
108
110
  }
109
111
 
110
112
  /** Group the presentation, not the underlying limitations or health qualification. */
@@ -158,6 +160,7 @@ export function groupEvidenceCoverage(
158
160
  limitations: entries,
159
161
  hasError: errors.length > 0,
160
162
  historyOnly,
163
+ bookkeepingOnly: ordinaryLimits,
161
164
  };
162
165
  })
163
166
  .sort(