@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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/RadarApp.tsx +5 -0
  3. package/src/api/client.ts +4 -0
  4. package/src/api/diagnose.ts +61 -15
  5. package/src/components/ConnectionErrorView.test.tsx +30 -0
  6. package/src/components/ConnectionErrorView.tsx +31 -19
  7. package/src/components/diagnose/AgentCase.tsx +131 -0
  8. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  9. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  10. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  11. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  12. package/src/components/diagnose/InvestigationView.tsx +222 -5
  13. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  14. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  15. package/src/components/diagnose/investigationCase.ts +439 -0
  16. package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
  17. package/src/components/diagnose/investigationEvidence.ts +831 -43
  18. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  19. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  20. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  21. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  22. package/src/components/diagnose/investigationMetrics.ts +393 -0
  23. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  24. package/src/components/diagnose/investigationState.test.ts +322 -0
  25. package/src/components/diagnose/investigationState.ts +147 -25
  26. package/src/components/diagnose/parts.test.tsx +482 -0
  27. package/src/components/diagnose/parts.tsx +418 -41
  28. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  29. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  30. package/src/components/resources/PodFilePreview.tsx +394 -0
  31. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  32. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  33. package/src/context/DiagnoseCustomization.tsx +14 -2
  34. package/src/index.ts +1 -0
  35. package/src/utils/shell-safe.test.ts +25 -1
  36. package/src/utils/shell-safe.ts +16 -0
@@ -3,13 +3,17 @@ import {
3
3
  defaultConditionTone,
4
4
  displayKind,
5
5
  englishPlural,
6
+ hpaStateLabel,
7
+ hpaStateLevel,
6
8
  kindToPlural,
7
9
  stripAnsi,
10
+ type HPADiagnosisState,
8
11
  type Issue,
9
12
  type IssueRecentChange,
10
13
  type Topology,
11
14
  } from "@skyhook-io/k8s-ui";
12
15
  import { fnv1a32 } from "@skyhook-io/k8s-ui/utils/structure-hash";
16
+ import type { TimeSeries } from "@skyhook-io/k8s-ui/components/charts";
13
17
  import { apiVersionToGroup } from "../../utils/navigation";
14
18
 
15
19
  import {
@@ -28,6 +32,7 @@ import {
28
32
  } from "./diagnoseEvidenceTypes";
29
33
  import type { RootCauseEvidence } from "../../api/diagnose";
30
34
  import { investigationResourceEvidenceSummary } from "./investigationResourceEvidenceModel";
35
+ import { metricsUnitForExpression } from "./investigationMetrics";
31
36
 
32
37
  /**
33
38
  * The projection deliberately consumes only the small, structural portion of
@@ -92,7 +97,8 @@ export type InvestigationEvidenceKind =
92
97
  | "receipt"
93
98
  | "alerts"
94
99
  | "helm"
95
- | "permissions";
100
+ | "permissions"
101
+ | "metrics";
96
102
 
97
103
  type InvestigationSemanticDomain = "issue" | "startup" | "crash" | "dns";
98
104
 
@@ -425,7 +431,13 @@ export type InvestigationEvidenceData =
425
431
  checked:
426
432
  "issues" | "events" | "changes" | "inventory" | "logs" | "alerts";
427
433
  scope: string;
428
- message: string;
434
+ /**
435
+ * Only when there is something to add. The card already shows the title
436
+ * and the scope, so a line that restates either costs the reader a read
437
+ * and returns nothing; this carries the reason the answer is what it is,
438
+ * or the limit of what it proves.
439
+ */
440
+ message?: string;
429
441
  }
430
442
  | {
431
443
  type: "alerts";
@@ -445,7 +457,40 @@ export type InvestigationEvidenceData =
445
457
  truncated?: boolean;
446
458
  usedByPods?: string[];
447
459
  podsTotal?: number;
448
- };
460
+ }
461
+ | InvestigationMetricsEvidence;
462
+ /** One PromQL vector selector as the producer tokenized it. */
463
+ export interface InvestigationMetricsSelector {
464
+ metric: string;
465
+ matchers: Array<{ label: string; op: string; value: string }>;
466
+ }
467
+
468
+ /**
469
+ * A Prometheus result captured during the investigation. `origin` says which
470
+ * producer ran the query; the shape is shared so every metrics card renders
471
+ * the same way. `subject` is set only when the query's selectors are all
472
+ * scoped to the investigation target.
473
+ */
474
+ export interface InvestigationMetricsEvidence {
475
+ type: "metrics";
476
+ origin: "query" | "diagnose";
477
+ query: string;
478
+ mode: "range" | "instant";
479
+ start?: string;
480
+ end?: string;
481
+ step?: string;
482
+ unit?: string;
483
+ label?: string;
484
+ series: TimeSeries[];
485
+ truncated: boolean;
486
+ summary?: unknown;
487
+ note?: string;
488
+ selectors?: InvestigationMetricsSelector[];
489
+ selectorsUnknown?: boolean;
490
+ subject?: DiagnosisResourceRef;
491
+ pods?: number;
492
+ partial?: boolean;
493
+ }
449
494
 
450
495
  export interface InvestigationEvidenceObservation {
451
496
  source: InvestigationEvidenceSource;
@@ -635,6 +680,8 @@ export function investigationEvidenceSubjectRef(
635
680
  namespace: data.subject.namespace,
636
681
  name: data.subject.name,
637
682
  };
683
+ case "metrics":
684
+ return data.subject;
638
685
  default:
639
686
  return undefined;
640
687
  }
@@ -671,6 +718,16 @@ export function investigationEvidenceSourceDomId(sourceId: string): string {
671
718
 
672
719
  const investigationEvidenceRefRe = /^ev_[a-z2-7]{26,128}_[a-z2-7]{26,128}$/;
673
720
 
721
+ export function isInvestigationEvidenceRef(value: string): boolean {
722
+ return investigationEvidenceRefRe.test(value);
723
+ }
724
+
725
+ export function investigationSourceArgs(
726
+ source: InvestigationEvidenceSource,
727
+ ): Record<string, unknown> | undefined {
728
+ return record(source.args ? parseJSON(source.args) : undefined);
729
+ }
730
+
674
731
  export function resolveInvestigationRootCauseEvidence(
675
732
  projection: InvestigationEvidenceProjection,
676
733
  evidence: RootCauseEvidence | undefined,
@@ -1270,6 +1327,7 @@ const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
1270
1327
  get_prometheus_rules: "Alert rules",
1271
1328
  get_helm_release: "Helm release",
1272
1329
  get_subject_permissions: "Permissions",
1330
+ query_prometheus: "Prometheus query",
1273
1331
  };
1274
1332
 
1275
1333
  function investigationResultLabel(source: InvestigationEvidenceSource): string {
@@ -1363,6 +1421,13 @@ class ProjectionBuilder {
1363
1421
  Set<InvestigationSemanticDomain>
1364
1422
  >();
1365
1423
 
1424
+ /**
1425
+ * Pods a target-scoped diagnose bundle listed as the workload's own, by
1426
+ * controller ownership. Membership for an agent's pod-level Prometheus
1427
+ * query is proved against this set, never inferred from a name.
1428
+ */
1429
+ readonly establishedTargetPods = new Set<string>();
1430
+
1366
1431
  private readonly groupByIdentity = new Map<
1367
1432
  string,
1368
1433
  InvestigationEvidenceGroup
@@ -1585,7 +1650,7 @@ function addNarrowHint(
1585
1650
  source,
1586
1651
  label,
1587
1652
  label +
1588
- " was narrowed to keep this investigation bounded. Additional matching evidence may exist.",
1653
+ " returned part of the matching results to keep this investigation fast. More may exist.",
1589
1654
  "truncated",
1590
1655
  );
1591
1656
  }
@@ -1608,7 +1673,7 @@ function addResourceContextLimitations(
1608
1673
  builder.limit(
1609
1674
  source,
1610
1675
  omitted.field,
1611
- `Resource context omitted: ${omitted.reason.replaceAll("_", " ")}.`,
1676
+ `Radar left out part of the resource context (${omitted.reason.replaceAll("_", " ")}).`,
1612
1677
  omitted.reason === "budget_exceeded" ? "truncated" : "unknown",
1613
1678
  );
1614
1679
  }
@@ -1617,7 +1682,7 @@ function addResourceContextLimitations(
1617
1682
  builder.limit(
1618
1683
  source,
1619
1684
  "Relationships",
1620
- `Referenced-by relationships were truncated (${shown} of ${context.referencedBy.total} returned).`,
1685
+ `Radar returned ${shown} of ${context.referencedBy.total} referenced-by relationships.`,
1621
1686
  "truncated",
1622
1687
  );
1623
1688
  }
@@ -1625,7 +1690,7 @@ function addResourceContextLimitations(
1625
1690
  builder.limit(
1626
1691
  source,
1627
1692
  "Application references",
1628
- "Additional stale Secret environment reference groups were omitted.",
1693
+ "Radar returned part of the stale Secret references.",
1629
1694
  "truncated",
1630
1695
  );
1631
1696
  }
@@ -1640,7 +1705,7 @@ function addIssueLimitations(
1640
1705
  builder.limit(
1641
1706
  source,
1642
1707
  `Radar Issue ${value.id}`,
1643
- "The affected-resource member list was truncated.",
1708
+ "Radar returned part of the affected-resource list.",
1644
1709
  "truncated",
1645
1710
  );
1646
1711
  }
@@ -1661,11 +1726,16 @@ function resourceObservationSummary(
1661
1726
  if (gitOps?.sync) return `Sync ${gitOps.sync}`;
1662
1727
  if (gitOps?.suspended) return "Reconciliation suspended";
1663
1728
  const replicas = context?.workloadSummary?.replicas;
1729
+ const adverseScaler = adverseScalerStates(context)[0];
1664
1730
  if (replicas?.desired !== undefined) {
1665
1731
  const desired = replicas.desired;
1666
1732
  const ready = replicas.ready ?? 0;
1667
- return `${ready}/${desired} replicas ready`;
1733
+ const readiness = `${ready}/${desired} replicas ready`;
1734
+ return adverseScaler
1735
+ ? `${readiness} · HPA: ${hpaStateLabel(adverseScaler)}`
1736
+ : readiness;
1668
1737
  }
1738
+ if (adverseScaler) return `HPA: ${hpaStateLabel(adverseScaler)}`;
1669
1739
  if (context?.statusSummary?.phase) return context.statusSummary.phase;
1670
1740
  if (context?.issueSummary?.topReason) return context.issueSummary.topReason;
1671
1741
  return (
@@ -1675,6 +1745,22 @@ function resourceObservationSummary(
1675
1745
  );
1676
1746
  }
1677
1747
 
1748
+ // A scaler that cannot act (no metrics, cannot read the target) or is pinned
1749
+ // at its ceiling is a captured Radar fact about the workload, so it lifts the
1750
+ // card the way an adverse condition does. Ordinary scale-ups and scale-downs
1751
+ // are the autoscaler working and stay as detail.
1752
+ function adverseScalerStates(
1753
+ context: InvestigationResourceContext | undefined,
1754
+ ): HPADiagnosisState[] {
1755
+ return (context?.scaledBy ?? []).flatMap((scaler) => {
1756
+ const state = scaler.hpaSummary?.state;
1757
+ if (!state) return [];
1758
+ return hpaStateLevel(state) === "unhealthy" || state === "limited_max"
1759
+ ? [state]
1760
+ : [];
1761
+ });
1762
+ }
1763
+
1678
1764
  function addResourceObservation(
1679
1765
  builder: ProjectionBuilder,
1680
1766
  source: InvestigationEvidenceSource,
@@ -1712,7 +1798,11 @@ function addResourceObservation(
1712
1798
  gitOpsDiagnosis.operationPhase?.toLowerCase() ?? "",
1713
1799
  )),
1714
1800
  );
1715
- const hasAdverseState = replicaShortfall || adverseCondition || gitOpsAdverse;
1801
+ const hasAdverseState =
1802
+ replicaShortfall ||
1803
+ adverseCondition ||
1804
+ gitOpsAdverse ||
1805
+ adverseScalerStates(context).length > 0;
1716
1806
  const intendedTier: InvestigationEvidenceTier =
1717
1807
  critical && !hasDetailedCriticalIssue
1718
1808
  ? "key"
@@ -1833,9 +1923,8 @@ function addEvents(
1833
1923
  complete = true,
1834
1924
  emptyIsAuthoritative = false,
1835
1925
  relevance: InvestigationEvidenceRelevance = "broader",
1836
- emptyReceipt: { title: string; message: string } = {
1837
- title: "No matching warning events",
1838
- message: "The warning-event query completed and returned no groups.",
1926
+ emptyReceipt: { title: string; message?: string } = {
1927
+ title: "No warning events",
1839
1928
  },
1840
1929
  ): void {
1841
1930
  const scope = scopeFromArgs(source);
@@ -1933,13 +2022,12 @@ function addChanges(
1933
2022
  tier: evidenceTierForRelevance("checked", relevance),
1934
2023
  relevance,
1935
2024
  tone: "neutral",
1936
- title: "No tracked recent changes",
2025
+ title: "No recorded changes in this window",
1937
2026
  summary: scope,
1938
2027
  data: {
1939
2028
  type: "receipt",
1940
2029
  checked: "changes",
1941
2030
  scope,
1942
- message: "The requested change window returned no tracked changes.",
1943
2031
  },
1944
2032
  });
1945
2033
  return;
@@ -2272,6 +2360,7 @@ function adaptDiagnose(
2272
2360
  });
2273
2361
  const relatedRelevance: InvestigationEvidenceRelevance =
2274
2362
  bundleRelevance === "target" ? "producer-related" : "broader";
2363
+
2275
2364
  const bundledRowRelevance = (
2276
2365
  kind: string,
2277
2366
  name: string,
@@ -2381,14 +2470,12 @@ function adaptDiagnose(
2381
2470
  tier: evidenceTierForRelevance("checked", bundleRelevance),
2382
2471
  relevance: bundleRelevance,
2383
2472
  tone: "neutral",
2384
- title: "No classified workload issues",
2473
+ title: "Radar's diagnosis found no live issues",
2385
2474
  summary: scope,
2386
2475
  data: {
2387
2476
  type: "receipt",
2388
2477
  checked: "issues",
2389
2478
  scope,
2390
- message:
2391
- "Radar's workload diagnosis completed without a classified live issue for this resource.",
2392
2479
  },
2393
2480
  });
2394
2481
  }
@@ -2407,9 +2494,14 @@ function adaptDiagnose(
2407
2494
  if (Array.isArray(blockersRaw)) {
2408
2495
  for (const { blocker, pods, foldedInto } of blockerGroups) {
2409
2496
  if (foldedInto) continue;
2497
+ // One blocker keeps one identity however many pods share it. Keying a
2498
+ // merged card differently from a single-pod one made a re-diagnose that
2499
+ // crossed one pod look like a new fact, so the same reason appeared
2500
+ // twice; a Pod blocker is the same finding whether it holds one pod or
2501
+ // nine.
2410
2502
  const grouped = blocker.kind === "Pod" && pods.length > 1;
2411
2503
  builder.observe(
2412
- grouped
2504
+ blocker.kind === "Pod"
2413
2505
  ? `startup:Pod:${blocker.reason}:${fnv1a32(blocker.message).toString(36)}`
2414
2506
  : `startup:${blocker.kind}:${blocker.name}:${blocker.reason}`,
2415
2507
  "startup",
@@ -2428,7 +2520,11 @@ function adaptDiagnose(
2428
2520
  data: {
2429
2521
  type: "startup",
2430
2522
  blocker,
2431
- ...(grouped ? { pods } : {}),
2523
+ // Always carry the pods, whether one or nine: they are what puts
2524
+ // this workload's pods into the set a later Prometheus query is
2525
+ // proved against, and a merged card that dropped them quietly
2526
+ // weakened attribution elsewhere.
2527
+ pods,
2432
2528
  subject: grouped
2433
2529
  ? undefined
2434
2530
  : (() => {
@@ -2502,7 +2598,7 @@ function adaptDiagnose(
2502
2598
  builder.limit(
2503
2599
  source,
2504
2600
  "Crash evidence",
2505
- "Additional crash-cause candidates were omitted.",
2601
+ "Radar returned part of the crash-cause candidates.",
2506
2602
  "truncated",
2507
2603
  );
2508
2604
  }
@@ -2553,7 +2649,7 @@ function adaptDiagnose(
2553
2649
  tier: evidenceTierForRelevance("checked", logRelevance),
2554
2650
  relevance: logRelevance,
2555
2651
  tone: "neutral",
2556
- title: "No previous container instance expected",
2652
+ title: "No previous logs expected",
2557
2653
  summary: `${item.pod} / ${item.container}`,
2558
2654
  data: {
2559
2655
  type: "receipt",
@@ -2785,6 +2881,21 @@ function adaptDiagnose(
2785
2881
  if (nonEmptyString(value.recentChangesError)) {
2786
2882
  builder.limit(source, "Recent changes", value.recentChangesError, "error");
2787
2883
  }
2884
+
2885
+ addDiagnoseMetrics(
2886
+ builder,
2887
+ source,
2888
+ value.metrics,
2889
+ {
2890
+ kind: resource.kind,
2891
+ ...(apiVersionToGroup(resource.apiVersion)
2892
+ ? { group: apiVersionToGroup(resource.apiVersion) }
2893
+ : {}),
2894
+ namespace: resource.metadata.namespace,
2895
+ name: resource.metadata.name,
2896
+ },
2897
+ bundleRelevance,
2898
+ );
2788
2899
  if (value.recentChangesSaturated === true) {
2789
2900
  builder.limit(
2790
2901
  source,
@@ -2973,14 +3084,12 @@ function adaptIssues(
2973
3084
  tier: evidenceTierForRelevance("checked", relevance),
2974
3085
  relevance,
2975
3086
  tone: "neutral",
2976
- title: "No matching live issues",
3087
+ title: "No live issues matched this search",
2977
3088
  summary: scope,
2978
3089
  data: {
2979
3090
  type: "receipt",
2980
3091
  checked: "issues",
2981
3092
  scope,
2982
- message:
2983
- "Radar's live-issue query completed and returned no matching issues.",
2984
3093
  },
2985
3094
  });
2986
3095
  } else {
@@ -3190,7 +3299,7 @@ function adaptListResources(
3190
3299
  builder.limit(
3191
3300
  source,
3192
3301
  "Resource inventory",
3193
- `Radar found no matching resources for ${scope}, but access restrictions may have hidden some results.`,
3302
+ `Radar found no matching resources for ${scope}. Anything you do not have permission to read was not searched.`,
3194
3303
  "unknown",
3195
3304
  );
3196
3305
  return;
@@ -3248,6 +3357,18 @@ function adaptEvents(
3248
3357
  );
3249
3358
  return;
3250
3359
  }
3360
+ // A cluster-wide read the producer narrowed to the caller's namespaces
3361
+ // answers for those alone, so neither its empty receipt nor its card may
3362
+ // stand for the cluster.
3363
+ const narrowedTo = narrowedEventScope(value);
3364
+ if (narrowedTo && events.length > 0) {
3365
+ builder.limit(
3366
+ source,
3367
+ "Events",
3368
+ `This cluster-wide events read covered only the namespaces you can read (${narrowedTo}).`,
3369
+ "unknown",
3370
+ );
3371
+ }
3251
3372
  addEvents(
3252
3373
  builder,
3253
3374
  source,
@@ -3256,12 +3377,33 @@ function adaptEvents(
3256
3377
  !nonEmptyString(value.narrowHint),
3257
3378
  true,
3258
3379
  sourceArgsRelevance(builder, source),
3259
- {
3260
- title: "No events matched",
3261
- message:
3262
- "The events query completed and returned nothing for this scope. Events outside its window or filters are not covered; a namespace you cannot read also returns nothing.",
3263
- },
3380
+ narrowedTo
3381
+ ? {
3382
+ title: "No events in the namespaces you can read",
3383
+ message: `Read ${narrowedTo}. Namespaces outside your permissions were not read, so this does not clear the cluster.`,
3384
+ }
3385
+ : {
3386
+ title: "No events in this window",
3387
+ message:
3388
+ "Events outside this window or its filters are not covered, and a namespace you cannot read also returns nothing.",
3389
+ },
3390
+ );
3391
+ }
3392
+
3393
+ /**
3394
+ * Names the namespaces a cluster-wide events read was actually narrowed to,
3395
+ * or undefined when the read covered everything the query asked for.
3396
+ */
3397
+ function narrowedEventScope(
3398
+ value: Record<string, unknown>,
3399
+ ): string | undefined {
3400
+ if (value.partialScope !== true) return undefined;
3401
+ const namespaces = (stringArray(value.scopeNamespaces) ?? []).filter(
3402
+ nonEmptyString,
3264
3403
  );
3404
+ return namespaces.length > 0
3405
+ ? namespaces.join(", ")
3406
+ : "the namespaces you can read";
3265
3407
  }
3266
3408
 
3267
3409
  function adaptPodLogs(
@@ -3586,11 +3728,13 @@ function adaptWorkloadLogs(
3586
3728
  return;
3587
3729
  }
3588
3730
  if (!source.confirmedSuccess) return;
3731
+ // The producer's own sentence when it gave one; otherwise nothing, because
3732
+ // "no pods to read logs from" is already the whole answer.
3589
3733
  const message = nonEmptyString(value.emptyMessage)
3590
3734
  ? value.emptyMessage
3591
3735
  : nonEmptyString(value.logs)
3592
3736
  ? value.logs
3593
- : "The workload resolved no pods, so there were no log streams to read.";
3737
+ : undefined;
3594
3738
  builder.observe(
3595
3739
  `workload-logs:${previous ? "previous" : "current"}:${scope}`,
3596
3740
  "receipt",
@@ -3607,7 +3751,7 @@ function adaptWorkloadLogs(
3607
3751
  return;
3608
3752
  }
3609
3753
  const logsRaw = value.logs;
3610
- const noStreams = `No log streams were returned for the ${value.pods} resolved pod${value.pods === 1 ? "" : "s"}, so Radar could not evaluate them.`;
3754
+ const noStreams = `Radar found ${value.pods} pod${value.pods === 1 ? "" : "s"} but got no logs from ${value.pods === 1 ? "it" : "them"}, so the logs were not checked.`;
3611
3755
  if (!Array.isArray(logsRaw)) {
3612
3756
  if (logsRaw === undefined || logsRaw === null) {
3613
3757
  builder.limit(source, "Workload logs", noStreams, "unknown");
@@ -3675,7 +3819,7 @@ function stringRecord(value: unknown): Record<string, string> | undefined {
3675
3819
  function producerEstablishedTargetPods(
3676
3820
  builder: ProjectionBuilder,
3677
3821
  ): Set<string> {
3678
- const pods = new Set<string>();
3822
+ const pods = new Set<string>(builder.establishedTargetPods);
3679
3823
  const namespace = builder.target.namespace;
3680
3824
  if (!namespace) return pods;
3681
3825
  for (const group of builder.groups) {
@@ -3686,12 +3830,11 @@ function producerEstablishedTargetPods(
3686
3830
  pods.add(data.pod);
3687
3831
  } else if (data.type === "crash" && data.namespace === namespace) {
3688
3832
  for (const pod of data.crash.pods) pods.add(pod);
3689
- } else if (
3690
- data.type === "startup" &&
3691
- data.subject?.kind === "Pod" &&
3692
- data.subject.namespace === namespace
3693
- ) {
3694
- pods.add(data.subject.name);
3833
+ } else if (data.type === "startup" && data.blocker.kind === "Pod") {
3834
+ // The blocker names the pods it holds whether the card merged them or
3835
+ // not; the subject is only set when there is exactly one, so reading
3836
+ // it alone lost the whole set the moment a second pod appeared.
3837
+ for (const pod of data.pods ?? []) pods.add(pod);
3695
3838
  }
3696
3839
  }
3697
3840
  }
@@ -4002,7 +4145,7 @@ function adaptPrometheusRules(
4002
4145
  tier: "checked",
4003
4146
  relevance: "target",
4004
4147
  tone: "neutral",
4005
- title: `No ${stateFilter} alert rules name this ${displayKind(builder.target.kind)}`,
4148
+ title: `No ${stateFilter} alerts matched this ${displayKind(builder.target.kind)}`,
4006
4149
  summary: identity,
4007
4150
  data: {
4008
4151
  type: "receipt",
@@ -4486,6 +4629,578 @@ function adaptSubjectPermissions(
4486
4629
  }
4487
4630
  }
4488
4631
 
4632
+ function timeSeries(value: unknown): TimeSeries | undefined {
4633
+ const item = record(value);
4634
+ if (!item || !Array.isArray(item.dataPoints)) return undefined;
4635
+ const labels = record(item.labels) ?? {};
4636
+ if (Object.values(labels).some((label) => typeof label !== "string")) {
4637
+ return undefined;
4638
+ }
4639
+ const dataPoints: TimeSeries["dataPoints"] = [];
4640
+ for (const raw of item.dataPoints) {
4641
+ const point = record(raw);
4642
+ if (!point || typeof point.timestamp !== "number") return undefined;
4643
+ if (
4644
+ point.value !== undefined &&
4645
+ point.value !== null &&
4646
+ typeof point.value !== "number"
4647
+ ) {
4648
+ return undefined;
4649
+ }
4650
+ dataPoints.push({
4651
+ timestamp: point.timestamp,
4652
+ value: typeof point.value === "number" ? point.value : null,
4653
+ });
4654
+ }
4655
+ return { labels: labels as Record<string, string>, dataPoints };
4656
+ }
4657
+
4658
+ function metricsSelector(
4659
+ value: unknown,
4660
+ ): InvestigationMetricsSelector | undefined {
4661
+ const item = record(value);
4662
+ if (!item || typeof item.metric !== "string" || !Array.isArray(item.matchers))
4663
+ return undefined;
4664
+ const matchers: InvestigationMetricsSelector["matchers"] = [];
4665
+ for (const raw of item.matchers) {
4666
+ const matcher = record(raw);
4667
+ if (
4668
+ !matcher ||
4669
+ typeof matcher.label !== "string" ||
4670
+ typeof matcher.op !== "string" ||
4671
+ typeof matcher.value !== "string"
4672
+ ) {
4673
+ return undefined;
4674
+ }
4675
+ matchers.push({
4676
+ label: matcher.label,
4677
+ op: matcher.op,
4678
+ value: matcher.value,
4679
+ });
4680
+ }
4681
+ return { metric: item.metric, matchers };
4682
+ }
4683
+
4684
+ /**
4685
+ * How one selector relates to the investigated resource. "target": it names
4686
+ * the target itself (an identity label, or pods a producer established as
4687
+ * the target's own). "related": it mentions the target's namespace and
4688
+ * something in it the target cannot be proved to own (a pod named by prefix,
4689
+ * a sibling workload, an owner series). "namespace": only the namespace.
4690
+ * "foreign": another namespace or none.
4691
+ */
4692
+ type SelectorVerdict = "target" | "related" | "namespace" | "foreign";
4693
+
4694
+ const VERDICT_RANK: Record<SelectorVerdict, number> = {
4695
+ foreign: 3,
4696
+ target: 2,
4697
+ related: 1,
4698
+ namespace: 0,
4699
+ };
4700
+
4701
+ /**
4702
+ * A regex matcher names the target only in the exact forms the plan admits,
4703
+ * with the name's regex metacharacters (a dot in a Kubernetes name) escaped
4704
+ * or absent. "api-?.*" also selects "apiworker-…" and an unescaped "api.v2"
4705
+ * also selects "api-v2", so neither may claim the target.
4706
+ */
4707
+ function regexNamesExactly(
4708
+ value: string,
4709
+ name: string,
4710
+ suffix: string,
4711
+ ): boolean {
4712
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4713
+ return value === `${escaped}${suffix}`;
4714
+ }
4715
+
4716
+ /**
4717
+ * The pod names an exact-set regex `^(a|b)$` (or the unanchored `a|b`, which
4718
+ * Prometheus anchors) lists. A dot must be escaped to count: `^(api.v2-0)$`
4719
+ * also selects `api-v2-0`, so it names more than the pod it appears to and
4720
+ * cannot prove membership. Undefined for any other shape.
4721
+ */
4722
+ function exactPodSet(value: string): string[] | undefined {
4723
+ const body =
4724
+ value.startsWith("^(") && value.endsWith(")$") ? value.slice(2, -2) : value;
4725
+ if (body === "" || /[^A-Za-z0-9\-|\\.]/.test(body)) return undefined;
4726
+ // Every dot has to arrive escaped; an unescaped one is a wildcard.
4727
+ if (/(^|[^\\])\./.test(body.replace(/\\\\/g, ""))) return undefined;
4728
+ const names = body.split("|").map((name) => name.replace(/\\\./g, "."));
4729
+ return names.every((name) => /^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(name))
4730
+ ? names
4731
+ : undefined;
4732
+ }
4733
+
4734
+ /**
4735
+ * Whether a workload identity label names the target: the label
4736
+ * kube-state-metrics uses for the kind (`deployment`, `job_name`, …) or a
4737
+ * generic `workload` label, with the exact name.
4738
+ */
4739
+ function identityLabelNamesTarget(
4740
+ target: InvestigationEvidenceTarget,
4741
+ label: string,
4742
+ value: string,
4743
+ exact: boolean,
4744
+ ): boolean {
4745
+ const kind = target.kind.toLowerCase();
4746
+ const workloadLabel = WORKLOAD_LABEL_BY_KIND[kind];
4747
+ // `deployment=api` states the kind in the label itself; `workload=api` does
4748
+ // not, so a `workload_type` naming another kind vetoes it in
4749
+ // classifySelector, which is the only place that sees the whole selector.
4750
+ if (label !== workloadLabel && label !== "workload") return false;
4751
+ return exact
4752
+ ? value === target.name
4753
+ : regexNamesExactly(value, target.name, "");
4754
+ }
4755
+
4756
+ function classifyMatcher(
4757
+ target: InvestigationEvidenceTarget,
4758
+ matcher: InvestigationMetricsSelector["matchers"][number],
4759
+ establishedPods: ReadonlySet<string>,
4760
+ ): SelectorVerdict {
4761
+ const { label, op, value } = matcher;
4762
+ const kind = target.kind.toLowerCase();
4763
+ if (label === "pod") {
4764
+ if (kind === "pod") {
4765
+ if (op === "=") return value === target.name ? "target" : "related";
4766
+ if (op === "=~") {
4767
+ if (regexNamesExactly(value, target.name, "")) return "target";
4768
+ // `^(name)$` is the form the diagnose prompt asks the agent to write,
4769
+ // so a Pod target has to recognise its own name in it. The set must
4770
+ // name nothing else: a query covering the target and a neighbour is
4771
+ // not evidence about the target alone.
4772
+ const names = exactPodSet(value);
4773
+ return names && names.every((name) => name === target.name)
4774
+ ? "target"
4775
+ : "related";
4776
+ }
4777
+ return "related";
4778
+ }
4779
+ if (op === "=") return establishedPods.has(value) ? "target" : "related";
4780
+ if (op === "=~") {
4781
+ const names = exactPodSet(value);
4782
+ if (names && names.every((name) => establishedPods.has(name))) {
4783
+ return "target";
4784
+ }
4785
+ return "related";
4786
+ }
4787
+ return "related";
4788
+ }
4789
+ if (label === "namespace" || label === "container") return "namespace";
4790
+ if (op === "=" && identityLabelNamesTarget(target, label, value, true)) {
4791
+ return "target";
4792
+ }
4793
+ if (op === "=~" && identityLabelNamesTarget(target, label, value, false)) {
4794
+ return "target";
4795
+ }
4796
+ // Owner series (kube_pod_owner, kube_replicaset_owner) and every other
4797
+ // label in the namespace: about the namespace's things, not proved to be
4798
+ // the target's.
4799
+ return "related";
4800
+ }
4801
+
4802
+ function classifySelector(
4803
+ target: InvestigationEvidenceTarget,
4804
+ selector: InvestigationMetricsSelector,
4805
+ establishedPods: ReadonlySet<string>,
4806
+ ): SelectorVerdict {
4807
+ // `workload` is generic, so a `workload_type` naming another kind means
4808
+ // the series is about a different workload that happens to share a name.
4809
+ // A regex counts when it names an exact set, the same way the namespace
4810
+ // matcher below does: `workload_type=~"statefulset"` excludes a Deployment
4811
+ // as plainly as `=` does, and reading only `=` let a sibling's chart take
4812
+ // the target's identity. A set that includes the target kind is no conflict,
4813
+ // and an inexact regex says nothing either way.
4814
+ const workloadType = selector.matchers.find(
4815
+ (matcher) =>
4816
+ matcher.label === "workload_type" &&
4817
+ (matcher.op === "=" || matcher.op === "=~"),
4818
+ );
4819
+ const workloadTypeNames =
4820
+ workloadType === undefined
4821
+ ? undefined
4822
+ : workloadType.op === "="
4823
+ ? [workloadType.value]
4824
+ : exactPodSet(workloadType.value);
4825
+ const workloadTypeConflicts =
4826
+ workloadTypeNames !== undefined &&
4827
+ workloadTypeNames.length > 0 &&
4828
+ !workloadTypeNames.some(
4829
+ (name) => name.toLowerCase() === target.kind.toLowerCase(),
4830
+ );
4831
+ // Prometheus anchors regex matchers, so namespace=~"shop" is exact too.
4832
+ const inNamespace = selector.matchers.some(
4833
+ (matcher) =>
4834
+ matcher.label === "namespace" &&
4835
+ ((matcher.op === "=" && matcher.value === target.namespace) ||
4836
+ (matcher.op === "=~" &&
4837
+ regexNamesExactly(matcher.value, target.namespace ?? "", ""))),
4838
+ );
4839
+ if (!inNamespace) return "foreign";
4840
+ let verdict: SelectorVerdict = "namespace";
4841
+ for (const matcher of selector.matchers) {
4842
+ if (workloadTypeConflicts && matcher.label === "workload") continue;
4843
+ const candidate = classifyMatcher(target, matcher, establishedPods);
4844
+ if (candidate === "foreign") return "foreign";
4845
+ if (VERDICT_RANK[candidate] > VERDICT_RANK[verdict]) verdict = candidate;
4846
+ }
4847
+ return verdict;
4848
+ }
4849
+
4850
+ /**
4851
+ * A metrics result is about the target only when every selector names it:
4852
+ * the target namespace plus pods a producer established as the target's own
4853
+ * or an identity label with its exact name. A selector that only mentions
4854
+ * something in the namespace the target cannot be proved to own (a pod
4855
+ * prefix, a sibling, an owner join) makes the result producer-related: kept,
4856
+ * not promoted, no change markers. A namespace-only query, a bare or foreign
4857
+ * selector, or an inventory the producer could not extract is broader.
4858
+ */
4859
+ export function metricsScope(
4860
+ target: InvestigationEvidenceTarget,
4861
+ selectors: readonly InvestigationMetricsSelector[],
4862
+ selectorsUnknown: boolean,
4863
+ establishedPods: ReadonlySet<string> = new Set(),
4864
+ ): InvestigationEvidenceRelevance {
4865
+ if (selectorsUnknown || selectors.length === 0 || !target.namespace) {
4866
+ return "broader";
4867
+ }
4868
+ let allTarget = true;
4869
+ let anyRelated = false;
4870
+ for (const selector of selectors) {
4871
+ const verdict = classifySelector(target, selector, establishedPods);
4872
+ if (verdict === "foreign") return "broader";
4873
+ if (verdict !== "target") allTarget = false;
4874
+ if (verdict === "related") anyRelated = true;
4875
+ }
4876
+ if (allTarget) return "target";
4877
+ return anyRelated ? "producer-related" : "broader";
4878
+ }
4879
+
4880
+ function metricsWindowLabel(data: {
4881
+ mode: "range" | "instant";
4882
+ start?: string;
4883
+ end?: string;
4884
+ step?: string;
4885
+ }): string | undefined {
4886
+ if (data.mode !== "range" || !data.start || !data.end) return undefined;
4887
+ const startMs = Date.parse(data.start);
4888
+ const endMs = Date.parse(data.end);
4889
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs)
4890
+ return undefined;
4891
+ const minutes = Math.round((endMs - startMs) / 60_000);
4892
+ const window =
4893
+ minutes >= 60 * 24 * 2
4894
+ ? `${Math.round(minutes / (60 * 24))}d`
4895
+ : minutes >= 120
4896
+ ? `${Math.round(minutes / 60)}h`
4897
+ : `${minutes}m`;
4898
+ return data.step
4899
+ ? `${window} window · ${data.step} step`
4900
+ : `${window} window`;
4901
+ }
4902
+
4903
+ /**
4904
+ * One half of a Go↔TS contract: `diagnoseMetricsCategories` in
4905
+ * internal/mcp/tools_diagnose_metrics.go decides which categories the producer
4906
+ * captures, and a series whose category is missing here is discarded. Change
4907
+ * both together.
4908
+ */
4909
+ const DIAGNOSE_METRICS_LABELS: Record<string, string> = {
4910
+ cpu: "CPU usage",
4911
+ memory: "Memory working set",
4912
+ restarts: "Restarts",
4913
+ };
4914
+
4915
+ /**
4916
+ * The vitals `diagnose` captured inside the same call: one chart per category
4917
+ * over the exact pods the bundle covers. They take the bundle's relevance, so a
4918
+ * neighbour's diagnose never charts as evidence for the target, and they carry
4919
+ * the diagnosed resource as their subject so recorded changes can be marked
4920
+ * on them. An absent field means Prometheus was not available or the read was
4921
+ * not permitted, which is not a collection failure the producer reported.
4922
+ */
4923
+ function addDiagnoseMetrics(
4924
+ builder: ProjectionBuilder,
4925
+ source: InvestigationEvidenceSource,
4926
+ raw: unknown,
4927
+ subject: DiagnosisResourceRef,
4928
+ relevance: InvestigationEvidenceRelevance,
4929
+ ): void {
4930
+ if (raw === undefined) return;
4931
+ const value = record(raw);
4932
+ const window = record(value?.window);
4933
+ if (
4934
+ !value ||
4935
+ !window ||
4936
+ !nonEmptyString(window.start) ||
4937
+ !nonEmptyString(window.end) ||
4938
+ !nonEmptyString(window.step) ||
4939
+ !(Date.parse(window.end) > Date.parse(window.start)) ||
4940
+ !nonNegativeInteger(value.pods) ||
4941
+ (value.partial !== undefined && typeof value.partial !== "boolean") ||
4942
+ (value.omittedPods !== undefined &&
4943
+ !nonNegativeInteger(value.omittedPods)) ||
4944
+ (value.error !== undefined && typeof value.error !== "string") ||
4945
+ !Array.isArray(value.series)
4946
+ ) {
4947
+ invalidPayload(builder, source, "Workload metrics");
4948
+ return;
4949
+ }
4950
+ const scope = scopeFromArgs(source);
4951
+ const partial = value.partial === true;
4952
+ // The pods the chart covers: the ones the workload controlled when the
4953
+ // bundle was captured, which a rollout during the window can miss.
4954
+ const podsLabel = partial
4955
+ ? `first ${value.pods} of ${typeof value.omittedPods === "number" ? value.pods + value.omittedPods : "the"} current pods`
4956
+ : `${value.pods} current pod${value.pods === 1 ? "" : "s"}`;
4957
+ const windowLabel = metricsWindowLabel({
4958
+ mode: "range",
4959
+ start: window.start,
4960
+ end: window.end,
4961
+ step: window.step,
4962
+ });
4963
+ for (const rawEntry of value.series) {
4964
+ const entry = record(rawEntry);
4965
+ const rawSeries = entry?.series;
4966
+ const series = Array.isArray(rawSeries)
4967
+ ? rawSeries
4968
+ .map(timeSeries)
4969
+ .filter((item): item is TimeSeries => Boolean(item))
4970
+ : undefined;
4971
+ if (
4972
+ !entry ||
4973
+ !nonEmptyString(entry.category) ||
4974
+ !Object.hasOwn(DIAGNOSE_METRICS_LABELS, entry.category) ||
4975
+ !nonEmptyString(entry.query) ||
4976
+ !nonEmptyString(entry.unit) ||
4977
+ !Array.isArray(rawSeries) ||
4978
+ !series ||
4979
+ series.length !== rawSeries.length
4980
+ ) {
4981
+ invalidPayload(builder, source, "Workload metrics");
4982
+ continue;
4983
+ }
4984
+ const label = DIAGNOSE_METRICS_LABELS[entry.category];
4985
+ const data: InvestigationMetricsEvidence = {
4986
+ type: "metrics",
4987
+ origin: "diagnose",
4988
+ query: entry.query,
4989
+ mode: "range",
4990
+ start: window.start,
4991
+ end: window.end,
4992
+ step: window.step,
4993
+ unit: entry.unit,
4994
+ label,
4995
+ series,
4996
+ truncated: false,
4997
+ subject,
4998
+ pods: value.pods,
4999
+ partial,
5000
+ };
5001
+ builder.observe(
5002
+ `metrics:diagnose:${subject.group ?? ""}:${subject.kind}:${subject.namespace ?? ""}:${subject.name}:${entry.category}`,
5003
+ "metrics",
5004
+ source,
5005
+ {
5006
+ tier: evidenceTierForRelevance("supporting", relevance),
5007
+ relevance,
5008
+ tone: "neutral",
5009
+ title: `${label} · ${scope}`,
5010
+ summary: [
5011
+ series.length === 0 ? "No samples in the window" : podsLabel,
5012
+ windowLabel,
5013
+ partial ? "partial pod set" : undefined,
5014
+ ]
5015
+ .filter((part): part is string => Boolean(part))
5016
+ .join(" · "),
5017
+ data,
5018
+ },
5019
+ );
5020
+ }
5021
+ if (nonEmptyString(value.error)) {
5022
+ builder.limit(source, "Workload metrics", value.error, "error");
5023
+ }
5024
+ }
5025
+
5026
+ // Radar's reading of a metric name, so an agent-written query gets a title a
5027
+ // reader can scan instead of the raw series name. Deliberately small and exact:
5028
+ // an unrecognised metric keeps its own name rather than being handed a meaning
5029
+ // Radar cannot derive from it.
5030
+ const METRIC_FAMILY_LABELS: Record<string, string> = {
5031
+ container_cpu_usage_seconds_total: "CPU usage",
5032
+ container_cpu_cfs_throttled_seconds_total: "CPU throttling",
5033
+ container_memory_working_set_bytes: "Memory working set",
5034
+ container_memory_usage_bytes: "Memory usage",
5035
+ container_memory_rss: "Memory RSS",
5036
+ kube_pod_container_status_restarts_total: "Restarts",
5037
+ container_network_receive_bytes_total: "Network received",
5038
+ container_network_transmit_bytes_total: "Network transmitted",
5039
+ container_fs_usage_bytes: "Filesystem usage",
5040
+ up: "Scrape target up",
5041
+ };
5042
+
5043
+ function adaptQueryPrometheus(
5044
+ builder: ProjectionBuilder,
5045
+ source: InvestigationEvidenceSource,
5046
+ payload: unknown,
5047
+ ): void {
5048
+ const value = record(payload);
5049
+ const mode = value?.type;
5050
+ if (
5051
+ !value ||
5052
+ !nonEmptyString(value.query) ||
5053
+ (mode !== "range" && mode !== "instant") ||
5054
+ !Array.isArray(value.series) ||
5055
+ !Array.isArray(value.selectors) ||
5056
+ (value.selectorsUnknown !== undefined &&
5057
+ typeof value.selectorsUnknown !== "boolean") ||
5058
+ (value.truncated !== undefined && typeof value.truncated !== "boolean")
5059
+ ) {
5060
+ invalidPayload(builder, source, "Prometheus query");
5061
+ return;
5062
+ }
5063
+ const series = value.series
5064
+ .map(timeSeries)
5065
+ .filter((item): item is TimeSeries => Boolean(item));
5066
+ const selectors = value.selectors
5067
+ .map(metricsSelector)
5068
+ .filter((item): item is InvestigationMetricsSelector => Boolean(item));
5069
+ if (
5070
+ series.length !== value.series.length ||
5071
+ selectors.length !== value.selectors.length
5072
+ ) {
5073
+ invalidPayload(builder, source, "Prometheus query");
5074
+ return;
5075
+ }
5076
+ const selectorsUnknown = value.selectorsUnknown === true;
5077
+ const relevance = metricsScope(
5078
+ builder.target,
5079
+ selectors,
5080
+ selectorsUnknown,
5081
+ producerEstablishedTargetPods(builder),
5082
+ );
5083
+ const start = nonEmptyString(value.start) ? value.start : undefined;
5084
+ const end = nonEmptyString(value.end) ? value.end : undefined;
5085
+ const step = nonEmptyString(value.step) ? value.step : undefined;
5086
+ const note = nonEmptyString(value.note) ? value.note : undefined;
5087
+ if (value.truncated === true) {
5088
+ const summary = record(value.summary);
5089
+ const cardinality = record(summary?.labelCardinality);
5090
+ const widest = cardinality
5091
+ ? Object.entries(cardinality).find(
5092
+ ([, count]) => typeof count === "number",
5093
+ )
5094
+ : undefined;
5095
+ const parts = [
5096
+ typeof summary?.seriesCount === "number"
5097
+ ? `${summary.seriesCount} series`
5098
+ : undefined,
5099
+ typeof summary?.totalDataPoints === "number"
5100
+ ? `${summary.totalDataPoints} samples`
5101
+ : undefined,
5102
+ widest ? `${widest[1]} distinct ${widest[0]} values` : undefined,
5103
+ ].filter((part): part is string => Boolean(part));
5104
+ builder.limit(
5105
+ source,
5106
+ "Prometheus query",
5107
+ `The result of \`${value.query}\` was too large to keep${parts.length ? ` (${parts.join(", ")})` : ""}, so Radar cannot chart it.${note ? ` ${note}` : ""}`,
5108
+ "truncated",
5109
+ );
5110
+ return;
5111
+ }
5112
+ const windowLabel = metricsWindowLabel({ mode, start, end, step });
5113
+ // One metric names the card; the title is Radar's reading of the query,
5114
+ // never the agent's, so a wrong claim cannot become the card's identity.
5115
+ // A bare matcher block such as `{namespace="shop"}` names no metric.
5116
+ const metricNames = [
5117
+ ...new Set(
5118
+ selectors.flatMap((selector) =>
5119
+ selector.metric.trim() ? [selector.metric.trim()] : [],
5120
+ ),
5121
+ ),
5122
+ ];
5123
+ // A recognised family names the card; the scope is added only when Radar
5124
+ // already decided the query is about the target, so the title never widens
5125
+ // or narrows what the relevance check concluded.
5126
+ const family =
5127
+ metricNames.length === 1 ? METRIC_FAMILY_LABELS[metricNames[0]] : undefined;
5128
+ const scope =
5129
+ relevance === "target"
5130
+ ? `${displayKind(builder.target.kind)} ${builder.target.namespace ? `${builder.target.namespace}/` : ""}${builder.target.name}`
5131
+ : undefined;
5132
+ const title = family
5133
+ ? scope
5134
+ ? `${family} · ${scope}`
5135
+ : family
5136
+ : metricNames.length === 1
5137
+ ? metricNames[0]
5138
+ : mode === "range"
5139
+ ? "Prometheus metrics"
5140
+ : "Prometheus values";
5141
+ const data: InvestigationMetricsEvidence = {
5142
+ type: "metrics",
5143
+ origin: "query",
5144
+ query: value.query,
5145
+ mode,
5146
+ start,
5147
+ end,
5148
+ step,
5149
+ unit: metricsUnitForExpression(value.query, selectors),
5150
+ series,
5151
+ truncated: false,
5152
+ note,
5153
+ selectors,
5154
+ selectorsUnknown,
5155
+ subject:
5156
+ relevance === "target"
5157
+ ? {
5158
+ kind: builder.target.kind,
5159
+ ...(builder.target.group ? { group: builder.target.group } : {}),
5160
+ namespace: builder.target.namespace,
5161
+ name: builder.target.name,
5162
+ }
5163
+ : undefined,
5164
+ };
5165
+ builder.observe(
5166
+ `metrics:${mode}:${value.query}:${start ?? ""}:${end ?? ""}:${step ?? ""}`,
5167
+ "metrics",
5168
+ source,
5169
+ {
5170
+ tier: evidenceTierForRelevance("supporting", relevance),
5171
+ relevance,
5172
+ tone: "neutral",
5173
+ title,
5174
+ summary: [
5175
+ // When a family named the card, the metric name moves here so it stays
5176
+ // visible; otherwise it is already the title.
5177
+ family
5178
+ ? metricNames[0]
5179
+ : metricNames.length === 1
5180
+ ? "Prometheus"
5181
+ : undefined,
5182
+ series.length === 0 ? "No series matched" : `${series.length} series`,
5183
+ windowLabel,
5184
+ ]
5185
+ .filter((part): part is string => Boolean(part))
5186
+ .join(" · "),
5187
+ data,
5188
+ },
5189
+ );
5190
+ }
5191
+
5192
+ /**
5193
+ * Adapters whose verdict depends on which pods a producer established as the
5194
+ * target's. They run after every other tool in the transcript, so the answer
5195
+ * does not depend on whether the agent happened to query Prometheus before or
5196
+ * after the read that named the pods. Their source order is captured before
5197
+ * they are queued, so deferring the classification does not reorder evidence.
5198
+ */
5199
+ const MEMBERSHIP_DEPENDENT_ADAPTERS = new Set([
5200
+ "query_prometheus",
5201
+ "get_prometheus_rules",
5202
+ ]);
5203
+
4489
5204
  const ADAPTERS: Record<
4490
5205
  string,
4491
5206
  (
@@ -4507,15 +5222,72 @@ const ADAPTERS: Record<
4507
5222
  get_prometheus_rules: adaptPrometheusRules,
4508
5223
  get_helm_release: adaptHelmRelease,
4509
5224
  get_subject_permissions: adaptSubjectPermissions,
5225
+ query_prometheus: adaptQueryPrometheus,
4510
5226
  };
4511
5227
 
5228
+ /**
5229
+ * The pods every diagnose of the target listed as its own, gathered before
5230
+ * anything is classified. A metrics query is target evidence when it names
5231
+ * pods a producer established, and that must not depend on whether the agent
5232
+ * happened to run diagnose before or after the query: the same investigation
5233
+ * would otherwise read differently for the same facts.
5234
+ */
5235
+ function collectEstablishedTargetPods(
5236
+ builder: ProjectionBuilder,
5237
+ turns: readonly InvestigationEvidenceTurn[],
5238
+ ): void {
5239
+ for (const turn of turns) {
5240
+ for (const item of turn.timeline) {
5241
+ if (
5242
+ item.kind !== "tool" ||
5243
+ item.tool !== "diagnose" ||
5244
+ item.radarEvidence !== true ||
5245
+ item.status !== "done" ||
5246
+ // Confirmed success, the same test the sources use. This set is what
5247
+ // proves a Prometheus selector is about the target, so a result that
5248
+ // never said it succeeded must not put pods into it.
5249
+ item.isError !== false ||
5250
+ !nonEmptyString(item.result)
5251
+ ) {
5252
+ continue;
5253
+ }
5254
+ const value = record(parseJSON(item.result));
5255
+ const resource = kubernetesResource(value?.resource);
5256
+ if (!value || !resource || !Array.isArray(value.podNames)) continue;
5257
+ if (
5258
+ relevanceForResource(builder, {
5259
+ kind: resource.kind,
5260
+ group: apiVersionToGroup(resource.apiVersion),
5261
+ namespace: resource.metadata.namespace,
5262
+ name: resource.metadata.name,
5263
+ }) !== "target"
5264
+ ) {
5265
+ continue;
5266
+ }
5267
+ for (const pod of value.podNames) {
5268
+ if (nonEmptyString(pod)) builder.establishedTargetPods.add(pod);
5269
+ }
5270
+ }
5271
+ }
5272
+ }
5273
+
4512
5274
  export function projectInvestigationEvidence(
4513
5275
  turns: readonly InvestigationEvidenceTurn[],
4514
5276
  target: InvestigationEvidenceTarget,
4515
5277
  ): InvestigationEvidenceProjection {
4516
5278
  const builder = new ProjectionBuilder(target);
5279
+ collectEstablishedTargetPods(builder, turns);
4517
5280
  const evidenceRefSources: InvestigationEvidenceSource[] = [];
4518
5281
  const citableSources: InvestigationEvidenceSource[] = [];
5282
+ const deferred: {
5283
+ adapt: (
5284
+ builder: ProjectionBuilder,
5285
+ source: InvestigationEvidenceSource,
5286
+ payload: unknown,
5287
+ ) => void;
5288
+ source: InvestigationEvidenceSource;
5289
+ payload: unknown;
5290
+ }[] = [];
4519
5291
  let order = 0;
4520
5292
  for (const [turnIndex, turn] of turns.entries()) {
4521
5293
  for (const [timelineIndex, item] of turn.timeline.entries()) {
@@ -4590,7 +5362,11 @@ export function projectInvestigationEvidence(
4590
5362
  invalidPayload(builder, source);
4591
5363
  continue;
4592
5364
  }
4593
- adapt(builder, source, payload);
5365
+ if (MEMBERSHIP_DEPENDENT_ADAPTERS.has(item.tool)) {
5366
+ deferred.push({ adapt, source, payload });
5367
+ } else {
5368
+ adapt(builder, source, payload);
5369
+ }
4594
5370
  if (item.isError !== false) {
4595
5371
  builder.limit(
4596
5372
  source,
@@ -4602,6 +5378,13 @@ export function projectInvestigationEvidence(
4602
5378
  }
4603
5379
  }
4604
5380
 
5381
+ // Membership is settled now: every read that could name one of the target's
5382
+ // pods has been adapted, so these see the same set whatever order the agent
5383
+ // worked in.
5384
+ for (const { adapt, source, payload } of deferred) {
5385
+ adapt(builder, source, payload);
5386
+ }
5387
+
4605
5388
  const tierRank: Record<InvestigationEvidenceTier, number> = {
4606
5389
  key: 0,
4607
5390
  supporting: 1,
@@ -4770,7 +5553,12 @@ export function projectInvestigationEvidence(
4770
5553
 
4771
5554
  return {
4772
5555
  groups: builder.groups,
4773
- limitations: builder.limitations,
5556
+ // Qualifications read in the order the investigation produced them, which
5557
+ // is the transcript's order and not the order the adapters happened to
5558
+ // run in: the membership-dependent ones are adapted last.
5559
+ limitations: [...builder.limitations].sort(
5560
+ (a, b) => a.firstOrder - b.firstOrder,
5561
+ ),
4774
5562
  sources: builder.sources,
4775
5563
  evidenceRefSources,
4776
5564
  citableSources,