@skyhook-io/radar-app 1.13.4 → 1.14.0

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 (230) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/RadarApp.tsx +5 -0
  4. package/src/api/client.authRedirect.test.ts +12 -1
  5. package/src/api/client.prometheusStatus.test.ts +24 -0
  6. package/src/api/client.ts +325 -16
  7. package/src/api/diagnose.ts +62 -15
  8. package/src/api/drain-plan.test.ts +86 -0
  9. package/src/api/quotas.test.ts +26 -0
  10. package/src/api/quotas.ts +20 -0
  11. package/src/api/workloadMetrics.test.ts +40 -0
  12. package/src/api/workloadMetrics.ts +68 -0
  13. package/src/components/CloudConnectFlow.tsx +179 -42
  14. package/src/components/CloudFunnelButton.tsx +107 -36
  15. package/src/components/ConnectionErrorView.test.tsx +30 -0
  16. package/src/components/ConnectionErrorView.tsx +31 -19
  17. package/src/components/SyncProgressPanel.tsx +73 -0
  18. package/src/components/audit/AuditView.tsx +2 -0
  19. package/src/components/cloudConnectHandoff.test.ts +140 -0
  20. package/src/components/cloudConnectHandoff.ts +124 -0
  21. package/src/components/diagnose/AgentCase.tsx +131 -0
  22. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  23. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  24. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  25. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  26. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  27. package/src/components/diagnose/InvestigationView.tsx +222 -5
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  29. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  30. package/src/components/diagnose/investigationCase.ts +439 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  41. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  42. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  43. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  44. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  45. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  46. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  48. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  49. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  50. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  51. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  52. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  53. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  54. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  55. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  56. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  57. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  58. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  59. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  60. package/src/components/diagnose/investigationMetrics.ts +393 -0
  61. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  62. package/src/components/diagnose/investigationState.test.ts +322 -0
  63. package/src/components/diagnose/investigationState.ts +147 -25
  64. package/src/components/diagnose/parts.test.tsx +482 -0
  65. package/src/components/diagnose/parts.tsx +418 -41
  66. package/src/components/diagnose/target.test.ts +19 -0
  67. package/src/components/diagnose/target.ts +3 -2
  68. package/src/components/gitops/GitOpsView.tsx +7 -0
  69. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  70. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  71. package/src/components/resource/PrometheusCharts.tsx +4 -3
  72. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +245 -103
  74. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  75. package/src/components/resource/RightsizingStrip.tsx +15 -3
  76. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  77. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  78. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  79. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  80. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  81. package/src/components/resource/workloadMetricValues.ts +30 -0
  82. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  83. package/src/components/resources/PodFilePreview.tsx +394 -0
  84. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  85. package/src/components/resources/ResourcesView.tsx +44 -6
  86. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  87. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  88. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  89. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  90. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  91. package/src/components/settings/SettingsDialog.tsx +94 -14
  92. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  93. package/src/components/traffic/TrafficGraph.tsx +6 -6
  94. package/src/components/traffic/TrafficView.tsx +10 -9
  95. package/src/components/traffic/trafficFilters.test.ts +28 -1
  96. package/src/components/traffic/trafficFilters.ts +31 -0
  97. package/src/components/workload/WorkloadView.tsx +19 -8
  98. package/src/context/ConnectionContext.tsx +48 -3
  99. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  100. package/src/context/DiagnoseCustomization.tsx +15 -2
  101. package/src/index.css +17 -0
  102. package/src/index.ts +1 -0
  103. package/src/utils/navigation.ts +1 -1
  104. package/src/utils/shell-safe.test.ts +25 -1
  105. package/src/utils/shell-safe.ts +16 -0
  106. package/src/utils/topology-namespace.test.ts +64 -0
  107. package/src/utils/topology-namespace.ts +18 -0
  108. package/src/components/diagnose/investigationEvidence.test.ts +0 -5202
  109. package/src/components/diagnose/investigationEvidence.ts +0 -4784
  110. package/src/components/gitops/GitOpsActions.tsx +0 -1
  111. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  112. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  113. package/src/components/gitops/SyncCountdown.tsx +0 -1
  114. package/src/components/gitops/index.ts +0 -4
  115. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  116. package/src/components/logs/JsonLogLine.tsx +0 -1
  117. package/src/components/logs/LogCore.tsx +0 -2
  118. package/src/components/logs/useLogBuffer.ts +0 -2
  119. package/src/components/logs/useLogSearch.ts +0 -1
  120. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  121. package/src/components/resources/drawer-components.tsx +0 -1
  122. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  170. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  171. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  172. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  173. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  174. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  175. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  176. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  177. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  178. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  179. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  180. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  181. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  182. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  183. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  184. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  185. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  186. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  187. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  188. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  189. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  190. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  191. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  192. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  193. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  194. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  195. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  196. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  197. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  198. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  199. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  200. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  201. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  202. package/src/components/resources/renderers/index.ts +0 -94
  203. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  204. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  205. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  206. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  207. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  208. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  209. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  210. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  211. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  212. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  213. package/src/components/resources/resource-utils-argo.ts +0 -2
  214. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  215. package/src/components/resources/resource-utils-eso.ts +0 -2
  216. package/src/components/resources/resource-utils-flux.ts +0 -2
  217. package/src/components/resources/resource-utils-istio.ts +0 -2
  218. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  219. package/src/components/resources/resource-utils-knative.ts +0 -2
  220. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  221. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  222. package/src/components/resources/resource-utils-traefik.ts +0 -1
  223. package/src/components/resources/resource-utils-trivy.ts +0 -2
  224. package/src/components/resources/resource-utils-velero.ts +0 -2
  225. package/src/components/shared/EditableYamlView.tsx +0 -24
  226. package/src/components/timeline/DiffViewer.tsx +0 -1
  227. package/src/components/traffic/index.ts +0 -3
  228. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  229. package/src/components/ui/MetricsChart.tsx +0 -1
  230. package/src/components/ui/ResourceBar.tsx +0 -1
@@ -1,4784 +0,0 @@
1
- import {
2
- CORE_RESOURCES,
3
- defaultConditionTone,
4
- displayKind,
5
- englishPlural,
6
- kindToPlural,
7
- stripAnsi,
8
- type Issue,
9
- type IssueRecentChange,
10
- type Topology,
11
- } from "@skyhook-io/k8s-ui";
12
- import { fnv1a32 } from "@skyhook-io/k8s-ui/utils/structure-hash";
13
- import { apiVersionToGroup } from "../../utils/navigation";
14
-
15
- import {
16
- diagnosisSeverityTone,
17
- type DiagnosisChangeContext,
18
- type DiagnosisCrashCause,
19
- type DiagnosisDNSContext,
20
- type DiagnosisEvidenceLimitationBase,
21
- type DiagnosisEvidenceTone,
22
- type DiagnosisFilteredLogs,
23
- type DiagnosisPodContainerRef,
24
- type DiagnosisPodLogEntry,
25
- type DiagnosisResourceContext,
26
- type DiagnosisResourceRef,
27
- type DiagnosisStartupBlocker,
28
- } from "./diagnoseEvidenceTypes";
29
- import type { RootCauseEvidence } from "../../api/diagnose";
30
- import { investigationResourceEvidenceSummary } from "./investigationResourceEvidenceModel";
31
-
32
- /**
33
- * The projection deliberately consumes only the small, structural portion of
34
- * Turn that it needs. TimelineItem is private to parts.tsx today; Turn[] is
35
- * structurally assignable to this type without coupling evidence extraction to
36
- * the transcript renderer.
37
- */
38
- export interface InvestigationEvidenceTurn {
39
- timeline: readonly InvestigationEvidenceTimelineItem[];
40
- question?: string;
41
- apply?: boolean;
42
- verify?: boolean;
43
- status?: "running" | "done" | "error";
44
- }
45
-
46
- /** The resource the investigation was opened for. */
47
- export interface InvestigationEvidenceTarget {
48
- kind: string;
49
- /** Kubernetes API group; empty means core. */
50
- group: string;
51
- namespace?: string;
52
- name: string;
53
- }
54
-
55
- export type InvestigationEvidencePhase =
56
- "initial" | "followup" | "verification" | "apply";
57
-
58
- export type InvestigationEvidenceTimelineItem =
59
- | { kind: "thinking"; text: string }
60
- | {
61
- kind: "tool";
62
- id: string;
63
- tool: string;
64
- status: string;
65
- summary?: string;
66
- result?: string;
67
- evidenceRef?: string;
68
- radarEvidence?: boolean;
69
- truncated?: boolean;
70
- isError?: boolean;
71
- };
72
-
73
- export type InvestigationEvidenceTier =
74
- "key" | "supporting" | "context" | "checked";
75
-
76
- export type InvestigationEvidenceRelevance =
77
- "target" | "producer-related" | "broader";
78
-
79
- export type InvestigationEvidenceKind =
80
- | "issue"
81
- | "startup"
82
- | "crash"
83
- | "resource"
84
- | "logs"
85
- | "events"
86
- | "changes"
87
- | "dns"
88
- | "network"
89
- | "relationships"
90
- | "topology"
91
- | "inventory"
92
- | "receipt"
93
- | "alerts"
94
- | "helm"
95
- | "permissions";
96
-
97
- type InvestigationSemanticDomain = "issue" | "startup" | "crash" | "dns";
98
-
99
- export interface InvestigationEvidenceSource {
100
- /** DOM-safe stable identity derived from turn index + the agent step ID. */
101
- id: string;
102
- turnIndex: number;
103
- timelineIndex: number;
104
- stepId: string;
105
- tool: string;
106
- /** The exact agent-emitted tool input shown in Activity. */
107
- args?: string;
108
- /** Stable flattened transcript order. */
109
- order: number;
110
- /** Which chronological phase of the run produced this source. */
111
- phase: InvestigationEvidencePhase;
112
- /** True only when the agent transport explicitly marked the tool result successful. */
113
- confirmedSuccess: boolean;
114
- /** Server-issued, turn-scoped identity for this exact retained result. */
115
- evidenceRef?: string;
116
- /**
117
- * The highest-priority evidence group produced by this call. Evidence panes
118
- * use it for one unique Activity → Evidence anchor even when a bundle fans
119
- * out into several cards.
120
- */
121
- primaryGroupId?: string;
122
- }
123
-
124
- export interface InvestigationResourceContext extends DiagnosisResourceContext {
125
- issueSummary?: {
126
- count: number;
127
- highestSeverity?: string;
128
- topReason?: string;
129
- bySource?: Record<string, number>;
130
- };
131
- auditSummary?: {
132
- count: number;
133
- highestSeverity?: string;
134
- topFinding?: string;
135
- };
136
- policySummary?: unknown;
137
- podSummary?: unknown;
138
- }
139
-
140
- export interface InvestigationGitOpsDiagnosis {
141
- tool: "argocd" | "flux";
142
- sync?: string;
143
- health?: string;
144
- operationPhase?: string;
145
- suspended?: boolean;
146
- ready?: string;
147
- appliedRevision?: string;
148
- }
149
-
150
- export interface InvestigationKubernetesResource {
151
- apiVersion: string;
152
- kind: string;
153
- metadata: {
154
- name: string;
155
- namespace?: string;
156
- [key: string]: unknown;
157
- };
158
- spec?: unknown;
159
- status?: unknown;
160
- summaryContext?: unknown;
161
- [key: string]: unknown;
162
- }
163
-
164
- /** Current `list_resources` row shape (`ai/context.ResourceSummary`). */
165
- export interface InvestigationResourceSummary {
166
- kind: string;
167
- name: string;
168
- namespace?: string;
169
- status?: string;
170
- ready?: string;
171
- issue?: string;
172
- age?: string;
173
- terminating?: boolean;
174
- restarts?: number;
175
- lastTerminatedReason?: string;
176
- lastRestartedAge?: string;
177
- summaryContext?: {
178
- health?: string;
179
- issueCount?: number;
180
- managedBy?: {
181
- kind: string;
182
- source: string;
183
- name: string;
184
- namespace?: string;
185
- };
186
- };
187
- [key: string]: unknown;
188
- }
189
-
190
- export interface InvestigationEventEvidence {
191
- reason: string;
192
- message: string;
193
- type: string;
194
- count: number;
195
- lastTimestamp: string;
196
- }
197
-
198
- export interface InvestigationTopologyNode {
199
- id: string;
200
- kind: string;
201
- name: string;
202
- status?: string;
203
- data?: Record<string, unknown> | null;
204
- }
205
-
206
- export interface InvestigationTopologyEdge {
207
- id?: string;
208
- source: string;
209
- target: string;
210
- type: string;
211
- label?: string;
212
- }
213
-
214
- export interface InvestigationNetworkRoute {
215
- route: string;
216
- target?: string;
217
- outcome: string;
218
- failedLayer?: string;
219
- confidence?: string;
220
- evidence?: string;
221
- benign?: boolean;
222
- }
223
-
224
- export interface InvestigationNetworkEvidence {
225
- subject: DiagnosisResourceRef;
226
- verdict: "healthy" | "degraded" | "broken" | "unknown";
227
- reason?: string;
228
- diagnosis?: {
229
- class?: string;
230
- severity?: string;
231
- summary: string;
232
- route?: string;
233
- nextAction?: string;
234
- };
235
- summary: {
236
- tested: number;
237
- passed: number;
238
- failed: number;
239
- derived?: number;
240
- skipped: number;
241
- headline: string;
242
- };
243
- routes: InvestigationNetworkRoute[];
244
- }
245
-
246
- /** One Prometheus rule as `get_prometheus_rules` flattens it (group stamped on). */
247
- export interface InvestigationAlertRule {
248
- group: string;
249
- name: string;
250
- type: string;
251
- state?: string;
252
- health?: string;
253
- query?: string;
254
- labels: Record<string, string>;
255
- }
256
-
257
- /** One active instance of an alerting rule, with its own label set. */
258
- export interface InvestigationAlertInstance {
259
- state: string;
260
- activeAt?: string;
261
- value?: string;
262
- labels: Record<string, string>;
263
- /** The instance's labels name the investigated resource. */
264
- namesTarget: boolean;
265
- }
266
-
267
- export interface InvestigationHelmOperation {
268
- kind: string;
269
- status: string;
270
- message: string;
271
- revision?: number;
272
- failedRevision?: number;
273
- rollbackRevision?: number;
274
- updated?: string;
275
- }
276
-
277
- export interface InvestigationHelmOwnedResource {
278
- kind: string;
279
- apiVersion?: string;
280
- name: string;
281
- namespace: string;
282
- status?: string;
283
- ready?: string;
284
- message?: string;
285
- summary?: string;
286
- issue?: string;
287
- }
288
-
289
- export interface InvestigationHelmRelease {
290
- name: string;
291
- namespace: string;
292
- /** Set only when Helm stores the release metadata elsewhere. */
293
- storageNamespace?: string;
294
- chart: string;
295
- chartVersion: string;
296
- appVersion?: string;
297
- status: string;
298
- revision: number;
299
- updated: string;
300
- description?: string;
301
- resourceHealth?: string;
302
- healthIssue?: string;
303
- healthSummary?: string;
304
- managedByFluxHelmRelease?: string;
305
- lastOperation?: InvestigationHelmOperation;
306
- resources: InvestigationHelmOwnedResource[];
307
- }
308
-
309
- export interface InvestigationPermissionSubject {
310
- kind: string;
311
- namespace?: string;
312
- name: string;
313
- }
314
-
315
- export interface InvestigationAccessCheck {
316
- verb: string;
317
- group?: string;
318
- resource: string;
319
- subresource?: string;
320
- /** Empty means a cluster-scoped resource or cluster-wide request. */
321
- namespace: string;
322
- resourceName?: string;
323
- allowed: boolean;
324
- denied: boolean;
325
- reason?: string;
326
- evaluationError?: string;
327
- }
328
-
329
- export interface InvestigationPermissionBinding {
330
- bindingKind: string;
331
- bindingNamespace?: string;
332
- bindingName: string;
333
- roleKind: string;
334
- roleNamespace?: string;
335
- roleName: string;
336
- rulesCount: number;
337
- inheritedFromGroup?: string;
338
- }
339
-
340
- export type InvestigationEvidenceData =
341
- | {
342
- type: "issue";
343
- issue: Issue;
344
- /**
345
- * A broad `issues` query can return failures from other resources. Keep
346
- * those factual observations, but do not let agent selection alone imply
347
- * that they explain the resource under investigation.
348
- */
349
- relevance: "target" | "producer-related" | "broader";
350
- /** Pods whose startup blocker repeats this issue word for word. */
351
- pods?: string[];
352
- }
353
- | {
354
- type: "startup";
355
- blocker: DiagnosisStartupBlocker;
356
- /** Exact blocker object when the diagnosis producer established it. */
357
- subject?: DiagnosisResourceRef;
358
- /**
359
- * Every pod the producer reported with this exact blocker. A DaemonSet
360
- * with seven pending pods is one finding, not seven; the card carries the
361
- * pod list instead of repeating itself.
362
- */
363
- pods?: string[];
364
- }
365
- | {
366
- type: "crash";
367
- crash: DiagnosisCrashCause;
368
- /** Namespace of the producing check's subject; pods live there. */
369
- namespace?: string;
370
- }
371
- | {
372
- type: "resource";
373
- resource: InvestigationKubernetesResource;
374
- resourceContext?: InvestigationResourceContext;
375
- warnings: string[];
376
- gitOpsDiagnosis?: InvestigationGitOpsDiagnosis;
377
- }
378
- | {
379
- type: "logs";
380
- pod: string;
381
- container: string;
382
- /** Namespace the producing call actually read; absent when unstated. */
383
- namespace?: string;
384
- previous: boolean;
385
- logs?: DiagnosisFilteredLogs;
386
- warnings: string[];
387
- error?: string;
388
- }
389
- | {
390
- type: "events";
391
- events: InvestigationEventEvidence[];
392
- scope: string;
393
- }
394
- | {
395
- type: "changes";
396
- changes: IssueRecentChange[];
397
- scope: string;
398
- changeContext?: DiagnosisChangeContext;
399
- /** The resource whose change history the producer read, when it named one. */
400
- subject?: { kind?: string; namespace?: string; name: string };
401
- }
402
- | { type: "dns"; dns: DiagnosisDNSContext }
403
- | { type: "network"; network: InvestigationNetworkEvidence }
404
- | {
405
- type: "relationships";
406
- root: DiagnosisResourceRef;
407
- nodes: InvestigationTopologyNode[];
408
- edges: InvestigationTopologyEdge[];
409
- truncated: boolean;
410
- }
411
- | {
412
- type: "topology";
413
- stats: { nodes: number; edges: number };
414
- namespaces: Array<{ namespace: string; chains: string[] }>;
415
- problems: string[];
416
- warnings: string[];
417
- }
418
- | {
419
- type: "inventory";
420
- resources: InvestigationResourceSummary[];
421
- scope: string;
422
- }
423
- | {
424
- type: "receipt";
425
- checked:
426
- "issues" | "events" | "changes" | "inventory" | "logs" | "alerts";
427
- scope: string;
428
- message: string;
429
- }
430
- | {
431
- type: "alerts";
432
- rule: InvestigationAlertRule;
433
- instances: InvestigationAlertInstance[];
434
- annotations: Record<string, string>;
435
- }
436
- | { type: "helm"; release: InvestigationHelmRelease }
437
- | {
438
- type: "permissions";
439
- subject: InvestigationPermissionSubject;
440
- /** Present for the access-check response shape. */
441
- accessCheck?: InvestigationAccessCheck;
442
- /** Present for the subject-permissions response shape. */
443
- bindings?: InvestigationPermissionBinding[];
444
- flatRulesCount?: number;
445
- truncated?: boolean;
446
- usedByPods?: string[];
447
- podsTotal?: number;
448
- };
449
-
450
- export interface InvestigationEvidenceObservation {
451
- source: InvestigationEvidenceSource;
452
- revision: number;
453
- /** This exact observation predates a later successful verification of its proof scope. */
454
- historical: boolean;
455
- /** Whether this semantic item differs from its immediately previous observation. */
456
- changedFromPrevious: boolean;
457
- /**
458
- * How this producer-backed observation relates to the resource being
459
- * investigated. Broader observations remain useful context, but agent
460
- * selection alone must never promote them as support for this target.
461
- */
462
- relevance: InvestigationEvidenceRelevance;
463
- tier: InvestigationEvidenceTier;
464
- tone: DiagnosisEvidenceTone;
465
- title: string;
466
- summary?: string;
467
- data: InvestigationEvidenceData;
468
- }
469
-
470
- export interface InvestigationEvidenceGroup {
471
- /** Stable DOM-safe identity for this semantic evidence item. */
472
- id: string;
473
- /** Raw deterministic identity used to merge repeated observations. */
474
- identity: string;
475
- kind: InvestigationEvidenceKind;
476
- /** Latest observation predates the most recent completed verification turn. */
477
- historical: boolean;
478
- firstOrder: number;
479
- observations: InvestigationEvidenceObservation[];
480
- /** Strongest-provenance observation, newest when provenance is equal. */
481
- latest: InvestigationEvidenceObservation;
482
- /** Newest observation regardless of proof strength; used for chronology. */
483
- chronologicalLatest: InvestigationEvidenceObservation;
484
- }
485
-
486
- export interface InvestigationEvidenceLimitation extends DiagnosisEvidenceLimitationBase {
487
- /** A qualified history result, not a failed collection. */
488
- presentation?: "history";
489
- firstOrder: number;
490
- sources: InvestigationEvidenceSource[];
491
- }
492
-
493
- export interface InvestigationEvidenceCoverage {
494
- /** Completed calls to a tool with a typed evidence adapter. */
495
- attempted: number;
496
- /** Adapted calls that contributed at least one evidence group. */
497
- projected: number;
498
- /** Adapted calls with a producer-declared or transport limitation. */
499
- limited: number;
500
- /** Calls that produced a strict, successful zero-result receipt. */
501
- checked: number;
502
- }
503
-
504
- export interface InvestigationEvidenceProjection {
505
- groups: InvestigationEvidenceGroup[];
506
- limitations: InvestigationEvidenceLimitation[];
507
- sources: InvestigationEvidenceSource[];
508
- /** Every retained tool item carrying a server-issued ref, eligible or not. */
509
- evidenceRefSources: InvestigationEvidenceSource[];
510
- /** Complete confirmed-success sources eligible for server-authored links. */
511
- citableSources: InvestigationEvidenceSource[];
512
- coverage: InvestigationEvidenceCoverage;
513
- }
514
-
515
- export interface InvestigationRootCauseEvidenceLink {
516
- source: InvestigationEvidenceSource;
517
- /** Canonical semantic group containing this source’s primary observation. */
518
- originalGroupId?: string;
519
- }
520
-
521
- export interface InvestigationRootCauseEvidenceResolution {
522
- status: "linked" | "missing" | "invalid";
523
- links: InvestigationRootCauseEvidenceLink[];
524
- }
525
-
526
- function exactDiagnosisResourceRef(ref: {
527
- kind?: unknown;
528
- group?: unknown;
529
- namespace?: unknown;
530
- name?: unknown;
531
- }): DiagnosisResourceRef | undefined {
532
- const { kind, name } = ref;
533
- if (
534
- !nonEmptyString(kind) ||
535
- kind.trim() !== kind ||
536
- !nonEmptyString(name) ||
537
- name.trim() !== name ||
538
- (ref.group !== undefined &&
539
- (typeof ref.group !== "string" || ref.group.trim() !== ref.group)) ||
540
- (ref.namespace !== undefined &&
541
- (typeof ref.namespace !== "string" ||
542
- ref.namespace.trim() !== ref.namespace))
543
- ) {
544
- return undefined;
545
- }
546
- const group = ref.group || undefined;
547
- const namespace = ref.namespace || undefined;
548
- const knownKinds = CORE_RESOURCES.filter(
549
- (resource) =>
550
- resource.kind.toLowerCase() === kind.toLowerCase() &&
551
- (group === undefined || resource.group === group),
552
- );
553
- const knownScopes = new Set(
554
- knownKinds.map((resource) => resource.namespaced),
555
- );
556
- // This is deliberately only a negative guard. Unknown kinds/groups may be
557
- // cluster-scoped CRDs, so suppress the link only when Radar's existing
558
- // resource metadata identifies the kind's scope without ambiguity.
559
- if (knownScopes.size === 1) {
560
- if (knownScopes.has(true) && !namespace) return undefined;
561
- if (knownScopes.has(false) && namespace) return undefined;
562
- }
563
- return {
564
- kind,
565
- name,
566
- ...(group ? { group } : {}),
567
- ...(namespace ? { namespace } : {}),
568
- };
569
- }
570
-
571
- /**
572
- * The Kubernetes resource an evidence item is about, when the producer payload
573
- * states one unambiguously. Pod-shaped evidence resolves only through the
574
- * namespace its producing check actually read; the investigation target's
575
- * namespace is deliberately never borrowed.
576
- */
577
- export function investigationEvidenceSubjectRef(
578
- data: InvestigationEvidenceData,
579
- ): DiagnosisResourceRef | undefined {
580
- const ref = ((): DiagnosisResourceRef | undefined => {
581
- switch (data.type) {
582
- case "issue":
583
- return {
584
- kind: data.issue.kind,
585
- group: data.issue.group,
586
- namespace: data.issue.namespace,
587
- name: data.issue.name,
588
- };
589
- case "startup":
590
- return data.subject;
591
- case "resource": {
592
- const apiVersion = data.resource.apiVersion;
593
- const group = apiVersionToGroup(apiVersion);
594
- return {
595
- kind: data.resource.kind,
596
- group: group || undefined,
597
- namespace: data.resource.metadata.namespace,
598
- name: data.resource.metadata.name,
599
- };
600
- }
601
- case "logs":
602
- if (!data.namespace) return undefined;
603
- return { kind: "Pod", namespace: data.namespace, name: data.pod };
604
- case "crash":
605
- if (data.crash.pods.length !== 1 || !data.namespace) return undefined;
606
- return {
607
- kind: "Pod",
608
- namespace: data.namespace,
609
- name: data.crash.pods[0],
610
- };
611
- case "network":
612
- return data.network.subject;
613
- case "relationships":
614
- return data.root;
615
- case "helm":
616
- // A resource ref cannot carry the storage namespace, and opening the
617
- // release by name alone would read the wrong storage.
618
- if (
619
- data.release.storageNamespace !== undefined &&
620
- data.release.storageNamespace !== data.release.namespace
621
- ) {
622
- return undefined;
623
- }
624
- return {
625
- kind: "HelmRelease",
626
- group: "helm.sh",
627
- namespace: data.release.namespace,
628
- name: data.release.name,
629
- };
630
- case "permissions":
631
- // Users and Groups are principals, not Kubernetes objects.
632
- if (data.subject.kind !== "ServiceAccount") return undefined;
633
- return {
634
- kind: "ServiceAccount",
635
- namespace: data.subject.namespace,
636
- name: data.subject.name,
637
- };
638
- default:
639
- return undefined;
640
- }
641
- })();
642
- return ref ? exactDiagnosisResourceRef(ref) : undefined;
643
- }
644
-
645
- function domToken(value: string): string {
646
- // Underscore is reserved as the escape delimiter, so raw input can never
647
- // impersonate an encoded code point ("/" vs. the literal "_x2f_"). The
648
- // empty sentinel is safe for the same reason: a literal "_empty_" is escaped.
649
- if (!value) return "_empty_";
650
- return Array.from(value, (character) =>
651
- /[A-Za-z0-9-]/.test(character)
652
- ? character
653
- : `_x${character.codePointAt(0)!.toString(16)}_`,
654
- ).join("");
655
- }
656
-
657
- export function investigationEvidenceSourceId(
658
- turnIndex: number,
659
- stepId: string,
660
- ): string {
661
- return `turn-${turnIndex}-step-${domToken(stepId)}`;
662
- }
663
-
664
- export function investigationActivitySourceDomId(sourceId: string): string {
665
- return `investigation-activity-${sourceId}`;
666
- }
667
-
668
- export function investigationEvidenceSourceDomId(sourceId: string): string {
669
- return `investigation-evidence-${sourceId}`;
670
- }
671
-
672
- const investigationEvidenceRefRe = /^ev_[a-z2-7]{26,128}_[a-z2-7]{26,128}$/;
673
-
674
- export function resolveInvestigationRootCauseEvidence(
675
- projection: InvestigationEvidenceProjection,
676
- evidence: RootCauseEvidence | undefined,
677
- assessmentTurnIndex: number,
678
- ): InvestigationRootCauseEvidenceResolution {
679
- if (!evidence || evidence.status === "missing") {
680
- return { status: "missing", links: [] };
681
- }
682
- if (evidence.status !== "linked") {
683
- return { status: "invalid", links: [] };
684
- }
685
- const refs = evidence.refs;
686
- if (
687
- !refs ||
688
- refs.length < 1 ||
689
- refs.length > 3 ||
690
- new Set(refs).size !== refs.length ||
691
- refs.some((ref) => !investigationEvidenceRefRe.test(ref)) ||
692
- refs.some((ref) => ref.split("_")[1] !== refs[0].split("_")[1])
693
- ) {
694
- return { status: "invalid", links: [] };
695
- }
696
-
697
- const byRef = new Map<string, InvestigationEvidenceSource[]>();
698
- for (const source of projection.evidenceRefSources) {
699
- if (source.turnIndex !== assessmentTurnIndex) continue;
700
- if (!source.evidenceRef) continue;
701
- const matches = byRef.get(source.evidenceRef) ?? [];
702
- matches.push(source);
703
- byRef.set(source.evidenceRef, matches);
704
- }
705
- const citableSourceIds = new Set(
706
- projection.citableSources
707
- .filter((source) => source.turnIndex === assessmentTurnIndex)
708
- .map((source) => source.id),
709
- );
710
- const links: InvestigationRootCauseEvidenceLink[] = [];
711
- for (const ref of refs) {
712
- const matches = byRef.get(ref);
713
- // Match the server's fail-closed binding: every current-turn occurrence
714
- // counts before success/completeness eligibility is considered.
715
- if (matches?.length !== 1 || !citableSourceIds.has(matches[0].id)) {
716
- return { status: "invalid", links: [] };
717
- }
718
- const source = matches[0];
719
- const originalGroup = source.primaryGroupId
720
- ? projection.groups.find((group) => group.id === source.primaryGroupId)
721
- : undefined;
722
- links.push({
723
- source,
724
- originalGroupId: originalGroup?.observations.some(
725
- (observation) => observation.source.id === source.id,
726
- )
727
- ? originalGroup.id
728
- : undefined,
729
- });
730
- }
731
- return { status: "linked", links };
732
- }
733
-
734
- export function investigationEvidenceStepIdsByTurn(
735
- projection: InvestigationEvidenceProjection,
736
- visibleGroupIds?: ReadonlySet<string>,
737
- ): Map<number, Set<string>> {
738
- const byTurn = new Map<number, Set<string>>();
739
- const linkedSourceIds = new Set<string>();
740
- for (const group of projection.groups) {
741
- if (visibleGroupIds && !visibleGroupIds.has(group.id)) continue;
742
- for (const observation of group.observations) {
743
- if (visibleGroupIds && observation.source.primaryGroupId !== group.id)
744
- continue;
745
- linkedSourceIds.add(observation.source.id);
746
- }
747
- }
748
- for (const limitation of projection.limitations) {
749
- for (const source of limitation.sources) linkedSourceIds.add(source.id);
750
- }
751
- const navigableSources = new Map(
752
- [...projection.sources, ...projection.citableSources].map((source) => [
753
- source.id,
754
- source,
755
- ]),
756
- );
757
- for (const source of navigableSources.values()) {
758
- if (!linkedSourceIds.has(source.id)) continue;
759
- const stepIds = byTurn.get(source.turnIndex) ?? new Set<string>();
760
- stepIds.add(source.stepId);
761
- byTurn.set(source.turnIndex, stepIds);
762
- }
763
- return byTurn;
764
- }
765
-
766
- function stableHash(value: string): string {
767
- // FNV-1a keeps long resource/log identities out of DOM IDs. The raw identity
768
- // remains the Map key, so a hash collision can never merge evidence.
769
- return fnv1a32(value).toString(36);
770
- }
771
-
772
- function record(value: unknown): Record<string, unknown> | undefined {
773
- return value !== null && typeof value === "object" && !Array.isArray(value)
774
- ? (value as Record<string, unknown>)
775
- : undefined;
776
- }
777
-
778
- function nonEmptyString(value: unknown): value is string {
779
- return typeof value === "string" && value.trim().length > 0;
780
- }
781
-
782
- function stringArray(value: unknown): string[] | undefined {
783
- return Array.isArray(value) && value.every((item) => typeof item === "string")
784
- ? value
785
- : undefined;
786
- }
787
-
788
- function parseJSON(value: string): unknown {
789
- try {
790
- return JSON.parse(value);
791
- } catch {
792
- return undefined;
793
- }
794
- }
795
-
796
- function kubernetesResource(
797
- value: unknown,
798
- ): InvestigationKubernetesResource | undefined {
799
- const resource = record(value);
800
- const metadata = record(resource?.metadata);
801
- // Core Secrets intentionally use Radar's current safe detail contract rather
802
- // than a Kubernetes object: identity + type + key names, with no values. Make
803
- // that producer shape canonical for the projection instead of rejecting the
804
- // exact evidence the agent saw.
805
- if (
806
- resource?.kind === "Secret" &&
807
- !metadata &&
808
- nonEmptyString(resource.name) &&
809
- (resource.namespace === undefined ||
810
- typeof resource.namespace === "string") &&
811
- (resource.type === undefined || typeof resource.type === "string") &&
812
- Array.isArray(resource.keys) &&
813
- resource.keys.every((key) => typeof key === "string")
814
- ) {
815
- return {
816
- ...resource,
817
- apiVersion: "v1",
818
- metadata: {
819
- name: resource.name,
820
- namespace: resource.namespace as string | undefined,
821
- ...(record(resource.labels) ? { labels: resource.labels } : {}),
822
- ...(record(resource.annotations)
823
- ? { annotations: resource.annotations }
824
- : {}),
825
- },
826
- } as InvestigationKubernetesResource;
827
- }
828
- if (
829
- !resource ||
830
- !nonEmptyString(resource.apiVersion) ||
831
- !nonEmptyString(resource.kind) ||
832
- !metadata ||
833
- !nonEmptyString(metadata.name)
834
- ) {
835
- return undefined;
836
- }
837
- return resource as unknown as InvestigationKubernetesResource;
838
- }
839
-
840
- function gitOpsDiagnosis(
841
- value: unknown,
842
- ): InvestigationGitOpsDiagnosis | undefined {
843
- const candidate = record(value);
844
- if (
845
- !candidate ||
846
- (candidate.tool !== "argocd" && candidate.tool !== "flux")
847
- ) {
848
- return undefined;
849
- }
850
- for (const field of [
851
- "sync",
852
- "health",
853
- "operationPhase",
854
- "ready",
855
- "appliedRevision",
856
- ] as const) {
857
- if (candidate[field] !== undefined && typeof candidate[field] !== "string")
858
- return undefined;
859
- }
860
- if (
861
- candidate.suspended !== undefined &&
862
- typeof candidate.suspended !== "boolean"
863
- )
864
- return undefined;
865
- return candidate as unknown as InvestigationGitOpsDiagnosis;
866
- }
867
-
868
- function resourceSummary(
869
- value: unknown,
870
- ): InvestigationResourceSummary | undefined {
871
- const candidate = record(value);
872
- if (
873
- !candidate ||
874
- !nonEmptyString(candidate.kind) ||
875
- !nonEmptyString(candidate.name)
876
- ) {
877
- return undefined;
878
- }
879
- if (
880
- candidate.namespace !== undefined &&
881
- typeof candidate.namespace !== "string"
882
- ) {
883
- return undefined;
884
- }
885
- for (const field of ["status", "ready", "issue", "age"] as const) {
886
- if (
887
- candidate[field] !== undefined &&
888
- typeof candidate[field] !== "string"
889
- ) {
890
- return undefined;
891
- }
892
- }
893
- if (
894
- (candidate.terminating !== undefined &&
895
- typeof candidate.terminating !== "boolean") ||
896
- (candidate.restarts !== undefined && typeof candidate.restarts !== "number")
897
- ) {
898
- return undefined;
899
- }
900
- const summaryContext = record(candidate.summaryContext);
901
- if (
902
- candidate.summaryContext !== undefined &&
903
- (!summaryContext ||
904
- (summaryContext.health !== undefined &&
905
- typeof summaryContext.health !== "string") ||
906
- (summaryContext.issueCount !== undefined &&
907
- typeof summaryContext.issueCount !== "number"))
908
- ) {
909
- return undefined;
910
- }
911
- return candidate as unknown as InvestigationResourceSummary;
912
- }
913
-
914
- function issue(value: unknown): Issue | undefined {
915
- const candidate = record(value);
916
- if (
917
- !candidate ||
918
- !nonEmptyString(candidate.id) ||
919
- (candidate.severity !== "critical" && candidate.severity !== "warning") ||
920
- !nonEmptyString(candidate.kind) ||
921
- !nonEmptyString(candidate.name) ||
922
- !nonEmptyString(candidate.reason)
923
- ) {
924
- return undefined;
925
- }
926
- return candidate as unknown as Issue;
927
- }
928
-
929
- function recentChange(value: unknown): IssueRecentChange | undefined {
930
- const candidate = record(value);
931
- if (
932
- !candidate ||
933
- !nonEmptyString(candidate.kind) ||
934
- (candidate.apiVersion !== undefined &&
935
- !nonEmptyString(candidate.apiVersion)) ||
936
- !nonEmptyString(candidate.name) ||
937
- !nonEmptyString(candidate.changeType) ||
938
- !nonEmptyString(candidate.timestamp)
939
- ) {
940
- return undefined;
941
- }
942
- return candidate as unknown as IssueRecentChange;
943
- }
944
-
945
- function diagnosisChangeContext(
946
- value: unknown,
947
- ): DiagnosisChangeContext | undefined {
948
- const candidate = record(value);
949
- if (!candidate || typeof candidate.changed !== "boolean") return undefined;
950
- for (const field of ["what", "when", "evidence"] as const) {
951
- if (candidate[field] !== undefined && typeof candidate[field] !== "string")
952
- return undefined;
953
- }
954
- return {
955
- changed: candidate.changed,
956
- ...(typeof candidate.what === "string"
957
- ? {
958
- what:
959
- candidate.what === "pod_template"
960
- ? "The workload's Pod template changed"
961
- : candidate.what,
962
- }
963
- : {}),
964
- ...(typeof candidate.when === "string" ? { when: candidate.when } : {}),
965
- ...(typeof candidate.evidence === "string"
966
- ? { evidence: candidate.evidence }
967
- : {}),
968
- };
969
- }
970
-
971
- function event(value: unknown): InvestigationEventEvidence | undefined {
972
- const candidate = record(value);
973
- if (
974
- !candidate ||
975
- !nonEmptyString(candidate.reason) ||
976
- !nonEmptyString(candidate.message) ||
977
- !nonEmptyString(candidate.type) ||
978
- typeof candidate.count !== "number" ||
979
- !nonEmptyString(candidate.lastTimestamp)
980
- ) {
981
- return undefined;
982
- }
983
- return candidate as unknown as InvestigationEventEvidence;
984
- }
985
-
986
- function filteredLogs(value: unknown): DiagnosisFilteredLogs | undefined {
987
- const candidate = record(value);
988
- if (
989
- !candidate ||
990
- (!Array.isArray(candidate.lines) && candidate.lines !== null) ||
991
- (Array.isArray(candidate.lines) &&
992
- !candidate.lines.every((line) => typeof line === "string")) ||
993
- typeof candidate.totalLines !== "number" ||
994
- typeof candidate.matchedLines !== "number" ||
995
- typeof candidate.fallback !== "boolean"
996
- ) {
997
- return undefined;
998
- }
999
- return {
1000
- ...(candidate as unknown as DiagnosisFilteredLogs),
1001
- lines: Array.isArray(candidate.lines)
1002
- ? candidate.lines.map((line) => stripAnsi(line))
1003
- : candidate.lines,
1004
- } as DiagnosisFilteredLogs;
1005
- }
1006
-
1007
- function resourceRef(value: unknown): DiagnosisResourceRef | undefined {
1008
- const candidate = record(value);
1009
- if (
1010
- !candidate ||
1011
- !nonEmptyString(candidate.kind) ||
1012
- !nonEmptyString(candidate.name)
1013
- ) {
1014
- return undefined;
1015
- }
1016
- return candidate as unknown as DiagnosisResourceRef;
1017
- }
1018
-
1019
- function networkRoute(value: unknown): InvestigationNetworkRoute | undefined {
1020
- const candidate = record(value);
1021
- if (
1022
- !candidate ||
1023
- !nonEmptyString(candidate.route) ||
1024
- !nonEmptyString(candidate.outcome)
1025
- ) {
1026
- return undefined;
1027
- }
1028
- for (const field of [
1029
- "target",
1030
- "failedLayer",
1031
- "confidence",
1032
- "evidence",
1033
- ] as const) {
1034
- if (candidate[field] !== undefined && typeof candidate[field] !== "string")
1035
- return undefined;
1036
- }
1037
- if (candidate.benign !== undefined && typeof candidate.benign !== "boolean")
1038
- return undefined;
1039
- return candidate as unknown as InvestigationNetworkRoute;
1040
- }
1041
-
1042
- function networkEvidence(
1043
- value: Record<string, unknown>,
1044
- ): InvestigationNetworkEvidence | undefined {
1045
- const subject = resourceRef(value.subject);
1046
- const summary = record(value.summary);
1047
- const verdict = value.verdict;
1048
- const routesRaw = value.routes === undefined ? [] : value.routes;
1049
- if (
1050
- !subject ||
1051
- (verdict !== "healthy" &&
1052
- verdict !== "degraded" &&
1053
- verdict !== "broken" &&
1054
- verdict !== "unknown") ||
1055
- !summary ||
1056
- typeof summary.tested !== "number" ||
1057
- typeof summary.passed !== "number" ||
1058
- typeof summary.failed !== "number" ||
1059
- typeof summary.skipped !== "number" ||
1060
- !nonEmptyString(summary.headline) ||
1061
- (summary.derived !== undefined && typeof summary.derived !== "number") ||
1062
- !Array.isArray(routesRaw)
1063
- ) {
1064
- return undefined;
1065
- }
1066
- const routes = routesRaw
1067
- .map(networkRoute)
1068
- .filter((route): route is InvestigationNetworkRoute => Boolean(route));
1069
- if (routes.length !== routesRaw.length) return undefined;
1070
- const diagnosis = record(value.diagnosis);
1071
- if (
1072
- diagnosis &&
1073
- (!nonEmptyString(diagnosis.summary) ||
1074
- ["class", "severity", "route", "nextAction"].some(
1075
- (field) =>
1076
- diagnosis[field] !== undefined &&
1077
- typeof diagnosis[field] !== "string",
1078
- ))
1079
- ) {
1080
- return undefined;
1081
- }
1082
- if (value.reason !== undefined && typeof value.reason !== "string")
1083
- return undefined;
1084
- return {
1085
- subject,
1086
- verdict,
1087
- reason: value.reason as string | undefined,
1088
- diagnosis: diagnosis as
1089
- InvestigationNetworkEvidence["diagnosis"] | undefined,
1090
- summary: summary as unknown as InvestigationNetworkEvidence["summary"],
1091
- routes,
1092
- };
1093
- }
1094
-
1095
- function topologyNode(value: unknown): InvestigationTopologyNode | undefined {
1096
- const candidate = record(value);
1097
- if (
1098
- !candidate ||
1099
- !nonEmptyString(candidate.id) ||
1100
- !nonEmptyString(candidate.kind) ||
1101
- !nonEmptyString(candidate.name)
1102
- ) {
1103
- return undefined;
1104
- }
1105
- return candidate as unknown as InvestigationTopologyNode;
1106
- }
1107
-
1108
- function topologyEdge(value: unknown): InvestigationTopologyEdge | undefined {
1109
- const candidate = record(value);
1110
- if (
1111
- !candidate ||
1112
- !nonEmptyString(candidate.source) ||
1113
- !nonEmptyString(candidate.target) ||
1114
- !nonEmptyString(candidate.type)
1115
- ) {
1116
- return undefined;
1117
- }
1118
- return candidate as unknown as InvestigationTopologyEdge;
1119
- }
1120
-
1121
- type TopologyPartiality = {
1122
- warnings: string[];
1123
- largeCluster: boolean;
1124
- hiddenKinds: string[];
1125
- requiresNamespaceFilter: boolean;
1126
- crdDiscoveryStatus?: NonNullable<Topology["crdDiscoveryStatus"]>;
1127
- estimatedNodes?: number;
1128
- summaryMode: boolean;
1129
- };
1130
-
1131
- /**
1132
- * Both get_topology wire shapes carry the same completeness metadata. Keep the
1133
- * adapter strict: silently dropping a malformed flag would make a partial graph
1134
- * look complete in Evidence.
1135
- */
1136
- function topologyPartiality(
1137
- value: Record<string, unknown>,
1138
- ): TopologyPartiality | undefined {
1139
- const warnings =
1140
- value.warnings === undefined ? [] : stringArray(value.warnings);
1141
- const hiddenKinds =
1142
- value.hiddenKinds === undefined ? [] : stringArray(value.hiddenKinds);
1143
- const discovery = value.crdDiscoveryStatus;
1144
- const estimatedNodes = value.estimatedNodes;
1145
- if (
1146
- !warnings ||
1147
- !hiddenKinds ||
1148
- (value.largeCluster !== undefined &&
1149
- typeof value.largeCluster !== "boolean") ||
1150
- (value.requiresNamespaceFilter !== undefined &&
1151
- typeof value.requiresNamespaceFilter !== "boolean") ||
1152
- (value.summaryMode !== undefined &&
1153
- typeof value.summaryMode !== "boolean") ||
1154
- (discovery !== undefined &&
1155
- discovery !== "idle" &&
1156
- discovery !== "discovering" &&
1157
- discovery !== "ready") ||
1158
- (estimatedNodes !== undefined &&
1159
- (typeof estimatedNodes !== "number" ||
1160
- !Number.isSafeInteger(estimatedNodes) ||
1161
- estimatedNodes < 0))
1162
- ) {
1163
- return undefined;
1164
- }
1165
- return {
1166
- warnings,
1167
- largeCluster: value.largeCluster === true,
1168
- hiddenKinds,
1169
- requiresNamespaceFilter: value.requiresNamespaceFilter === true,
1170
- crdDiscoveryStatus: discovery as
1171
- NonNullable<Topology["crdDiscoveryStatus"]> | undefined,
1172
- estimatedNodes,
1173
- summaryMode: value.summaryMode === true,
1174
- };
1175
- }
1176
-
1177
- function addTopologyLimitations(
1178
- builder: ProjectionBuilder,
1179
- source: InvestigationEvidenceSource,
1180
- partiality: TopologyPartiality,
1181
- ): void {
1182
- for (const warning of partiality.warnings) {
1183
- const normalized = warning.toLowerCase();
1184
- builder.limit(
1185
- source,
1186
- "Topology coverage",
1187
- warning,
1188
- normalized.includes("large graph") || normalized.includes("too large")
1189
- ? "truncated"
1190
- : "unknown",
1191
- );
1192
- }
1193
-
1194
- const scaleDetails: string[] = [];
1195
- const estimate = partiality.estimatedNodes
1196
- ? ` (about ${partiality.estimatedNodes} estimated nodes)`
1197
- : "";
1198
- if (partiality.requiresNamespaceFilter) {
1199
- scaleDetails.push(
1200
- `The all-namespace topology was not built because the cluster is too large${estimate}; run a namespace-scoped topology search to collect a smaller graph.`,
1201
- );
1202
- } else if (partiality.largeCluster) {
1203
- scaleDetails.push(
1204
- `Large-cluster optimizations were active${estimate}; high-cardinality detail may be grouped.`,
1205
- );
1206
- }
1207
- if (partiality.hiddenKinds.length > 0) {
1208
- scaleDetails.push(
1209
- `Resource kinds omitted by the large-cluster optimization: ${partiality.hiddenKinds.join(", ")}.`,
1210
- );
1211
- }
1212
- if (partiality.summaryMode) {
1213
- scaleDetails.push(
1214
- "Summary mode collapsed individual Pods into workload or Service counts.",
1215
- );
1216
- }
1217
- if (scaleDetails.length > 0) {
1218
- builder.limit(
1219
- source,
1220
- "Topology scale",
1221
- scaleDetails.join(" "),
1222
- "truncated",
1223
- );
1224
- }
1225
-
1226
- if (
1227
- partiality.crdDiscoveryStatus === "idle" ||
1228
- partiality.crdDiscoveryStatus === "discovering"
1229
- ) {
1230
- builder.limit(
1231
- source,
1232
- "Custom Resource topology",
1233
- partiality.crdDiscoveryStatus === "idle"
1234
- ? "Custom Resource discovery had not started when this topology was captured; Custom Resource nodes and relationships may be missing."
1235
- : "Custom Resource discovery was still in progress when this topology was captured; Custom Resource nodes and relationships may be missing.",
1236
- "unknown",
1237
- );
1238
- }
1239
- }
1240
-
1241
- function scopeFromArgs(source: InvestigationEvidenceSource): string {
1242
- if (!source.args) return "requested scope";
1243
- const args = record(parseJSON(source.args));
1244
- if (!args) return "requested scope";
1245
- const kind = nonEmptyString(args.kind) ? displayKind(args.kind) : undefined;
1246
- const namespace = nonEmptyString(args.namespace) ? args.namespace : undefined;
1247
- const name = nonEmptyString(args.name) ? args.name : undefined;
1248
- if (kind && name) {
1249
- return kind + " " + (namespace ? namespace + "/" : "") + name;
1250
- }
1251
- if (kind && namespace) return kind + " resources in " + namespace;
1252
- if (kind) return kind + " resources";
1253
- if (namespace && name) return namespace + "/" + name;
1254
- if (namespace) return "namespace " + namespace;
1255
- if (name) return name;
1256
- return "requested scope";
1257
- }
1258
-
1259
- const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
1260
- diagnose: "Workload diagnosis",
1261
- issues: "Issue scan",
1262
- get_resource: "Resource details",
1263
- list_resources: "Resource inventory",
1264
- get_events: "Kubernetes events",
1265
- get_pod_logs: "Container logs",
1266
- get_changes: "Recent changes",
1267
- get_neighborhood: "Relationships",
1268
- get_topology: "Topology",
1269
- get_workload_logs: "Workload logs",
1270
- get_prometheus_rules: "Alert rules",
1271
- get_helm_release: "Helm release",
1272
- get_subject_permissions: "Permissions",
1273
- };
1274
-
1275
- function investigationResultLabel(source: InvestigationEvidenceSource): string {
1276
- return INVESTIGATION_RESULT_LABELS[source.tool] ?? "Investigation result";
1277
- }
1278
-
1279
- function resourceMatchesTarget(
1280
- target: InvestigationEvidenceTarget,
1281
- resource: {
1282
- kind: string;
1283
- group?: string;
1284
- namespace?: string;
1285
- name: string;
1286
- },
1287
- ): boolean {
1288
- return (
1289
- resource.kind.toLowerCase() === target.kind.toLowerCase() &&
1290
- (resource.group ?? "").toLowerCase() === target.group.toLowerCase() &&
1291
- (resource.namespace ?? "") === (target.namespace ?? "") &&
1292
- resource.name === target.name
1293
- );
1294
- }
1295
-
1296
- function relevanceForResource(
1297
- builder: ProjectionBuilder,
1298
- resource: {
1299
- kind: string;
1300
- group?: string;
1301
- namespace?: string;
1302
- name: string;
1303
- },
1304
- ): InvestigationEvidenceRelevance {
1305
- return resourceMatchesTarget(builder.target, resource) ? "target" : "broader";
1306
- }
1307
-
1308
- function sourceArgsRelevance(
1309
- builder: ProjectionBuilder,
1310
- source: InvestigationEvidenceSource,
1311
- impliedKind?: string,
1312
- ): InvestigationEvidenceRelevance {
1313
- const args = record(source.args ? parseJSON(source.args) : undefined);
1314
- const kind = nonEmptyString(args?.kind) ? args.kind : impliedKind;
1315
- if (!kind || !nonEmptyString(args?.name)) return "broader";
1316
- return relevanceForResource(builder, {
1317
- kind,
1318
- // get_events/get_changes/issues cannot express an API group today. An
1319
- // omitted group is therefore unspecified, not proof that the caller meant
1320
- // the core API group. Use the known investigation target for that missing
1321
- // dimension; if a producer does provide a group, exact matching still
1322
- // applies (including an explicitly empty core group).
1323
- group: typeof args?.group === "string" ? args.group : builder.target.group,
1324
- namespace: nonEmptyString(args?.namespace) ? args.namespace : undefined,
1325
- name: args.name,
1326
- });
1327
- }
1328
-
1329
- function evidenceTierForRelevance(
1330
- intended: InvestigationEvidenceTier,
1331
- relevance: InvestigationEvidenceRelevance,
1332
- ): InvestigationEvidenceTier {
1333
- return relevance === "broader" ? "context" : intended;
1334
- }
1335
-
1336
- const DIAGNOSABLE_WORKLOAD_KINDS = new Set([
1337
- "pod",
1338
- "deployment",
1339
- "statefulset",
1340
- "daemonset",
1341
- "rollout",
1342
- ]);
1343
-
1344
- function isDiagnosableWorkloadKind(kind: string): boolean {
1345
- return DIAGNOSABLE_WORKLOAD_KINDS.has(kind.toLowerCase());
1346
- }
1347
-
1348
- function previousFromArgs(source: InvestigationEvidenceSource): boolean {
1349
- return (
1350
- record(source.args ? parseJSON(source.args) : undefined)?.previous === true
1351
- );
1352
- }
1353
-
1354
- class ProjectionBuilder {
1355
- readonly groups: InvestigationEvidenceGroup[] = [];
1356
- readonly sources: InvestigationEvidenceSource[] = [];
1357
- readonly limitations: InvestigationEvidenceLimitation[] = [];
1358
- readonly projectedSources = new Set<string>();
1359
- readonly checkedSources = new Set<string>();
1360
- readonly limitedSources = new Set<string>();
1361
- readonly semanticCoverageBySource = new Map<
1362
- string,
1363
- Set<InvestigationSemanticDomain>
1364
- >();
1365
-
1366
- private readonly groupByIdentity = new Map<
1367
- string,
1368
- InvestigationEvidenceGroup
1369
- >();
1370
- private readonly limitationByIdentity = new Map<
1371
- string,
1372
- InvestigationEvidenceLimitation
1373
- >();
1374
-
1375
- constructor(readonly target: InvestigationEvidenceTarget) {}
1376
-
1377
- addSource(source: InvestigationEvidenceSource): void {
1378
- this.sources.push(source);
1379
- }
1380
-
1381
- coverSemantic(
1382
- source: InvestigationEvidenceSource,
1383
- domain: InvestigationSemanticDomain,
1384
- ): void {
1385
- const covered = this.semanticCoverageBySource.get(source.id);
1386
- if (covered) {
1387
- covered.add(domain);
1388
- } else {
1389
- this.semanticCoverageBySource.set(source.id, new Set([domain]));
1390
- }
1391
- }
1392
-
1393
- observe(
1394
- identity: string,
1395
- kind: InvestigationEvidenceKind,
1396
- source: InvestigationEvidenceSource,
1397
- observation: Omit<
1398
- InvestigationEvidenceObservation,
1399
- "source" | "revision" | "historical" | "changedFromPrevious" | "relevance"
1400
- > & { relevance: InvestigationEvidenceRelevance },
1401
- ): void {
1402
- // Logs and synthesized startup/crash evidence do not carry a stable object
1403
- // UID (and logs do not carry namespace in the producer row). Keep different
1404
- // proof scopes separate so a same-named sibling can never inherit stronger
1405
- // target provenance merely by colliding on its display identity.
1406
- const partitionByRelevance =
1407
- kind === "logs" ||
1408
- kind === "startup" ||
1409
- kind === "crash" ||
1410
- (kind === "receipt" && identity.startsWith("previous-log-absence:"));
1411
- const mapKey = groupKey(
1412
- kind,
1413
- identity,
1414
- partitionByRelevance
1415
- ? `${observation.relevance}\u0000${scopeFromArgs(source)}`
1416
- : undefined,
1417
- );
1418
- let group = this.groupByIdentity.get(mapKey);
1419
- const previousObservation = group?.observations.at(-1);
1420
- const changedFromPrevious = previousObservation
1421
- ? evidenceSemanticSnapshot(previousObservation) !==
1422
- evidenceSemanticSnapshot(observation)
1423
- : false;
1424
- const next: InvestigationEvidenceObservation = {
1425
- ...observation,
1426
- source,
1427
- revision: group ? group.observations.length + 1 : 1,
1428
- historical: false,
1429
- changedFromPrevious,
1430
- };
1431
- if (!group) {
1432
- group = {
1433
- id: `evidence-${kind}-${stableHash(mapKey)}`,
1434
- identity,
1435
- kind,
1436
- historical: false,
1437
- firstOrder: source.order,
1438
- observations: [],
1439
- latest: next,
1440
- chronologicalLatest: next,
1441
- };
1442
- this.groupByIdentity.set(mapKey, group);
1443
- this.groups.push(group);
1444
- }
1445
- group.observations.push(next);
1446
- group.chronologicalLatest = next;
1447
- const relevanceRank: Record<InvestigationEvidenceRelevance, number> = {
1448
- target: 0,
1449
- "producer-related": 1,
1450
- broader: 2,
1451
- };
1452
- // A broad inventory/read can re-observe an item whose relationship to the
1453
- // target was already established by a scoped producer. Keep the broad read
1454
- // in revision history, but never let weaker provenance replace the card's
1455
- // authoritative observation. Equal provenance still advances normally.
1456
- // An alert rule's relevance is derived from its live instances, so a later
1457
- // read of the same rule is a state transition, not weaker provenance.
1458
- if (
1459
- kind === "alerts" ||
1460
- relevanceRank[next.relevance] <= relevanceRank[group.latest.relevance]
1461
- ) {
1462
- group.latest = next;
1463
- }
1464
- this.projectedSources.add(source.id);
1465
- if (next.tier === "checked") this.checkedSources.add(source.id);
1466
- }
1467
-
1468
- /** Relevance of the current card for an unpartitioned identity, if observed. */
1469
- latestRelevance(
1470
- kind: InvestigationEvidenceKind,
1471
- identity: string,
1472
- ): InvestigationEvidenceRelevance | undefined {
1473
- return this.groupByIdentity.get(groupKey(kind, identity))?.latest.relevance;
1474
- }
1475
-
1476
- limit(
1477
- source: InvestigationEvidenceSource,
1478
- label: string,
1479
- message: string | undefined,
1480
- kind: DiagnosisEvidenceLimitationBase["kind"],
1481
- presentation?: InvestigationEvidenceLimitation["presentation"],
1482
- ): void {
1483
- if (!message?.trim()) return;
1484
- const normalized = message.trim();
1485
- const key = `${kind}\u0000${presentation ?? ""}\u0000${label}\u0000${normalized}`;
1486
- const existing = this.limitationByIdentity.get(key);
1487
- if (existing) {
1488
- if (!existing.sources.some((item) => item.id === source.id)) {
1489
- existing.sources.push(source);
1490
- }
1491
- } else {
1492
- const limitation: InvestigationEvidenceLimitation = {
1493
- source: label,
1494
- message: normalized,
1495
- kind,
1496
- ...(presentation ? { presentation } : {}),
1497
- firstOrder: source.order,
1498
- sources: [source],
1499
- };
1500
- this.limitationByIdentity.set(key, limitation);
1501
- this.limitations.push(limitation);
1502
- }
1503
- this.limitedSources.add(source.id);
1504
- }
1505
- }
1506
-
1507
- /**
1508
- * The one place a group's map key is built. NUL separates the parts so no
1509
- * kind or identity text can collide with another kind's key.
1510
- */
1511
- function groupKey(
1512
- kind: InvestigationEvidenceKind,
1513
- identity: string,
1514
- partition?: string,
1515
- ): string {
1516
- return `${kind}\u0000${identity}${partition === undefined ? "" : `\u0000${partition}`}`;
1517
- }
1518
-
1519
- export function evidenceSemanticSnapshot(
1520
- observation: Pick<
1521
- InvestigationEvidenceObservation,
1522
- "tone" | "title" | "summary" | "data"
1523
- >,
1524
- ): string {
1525
- if (observation.data.type === "resource") {
1526
- // Tool-specific context can change the summary/tone without changing the
1527
- // object. Keep that context in history, not in the resource-change signal.
1528
- return JSON.stringify({
1529
- type: "resource",
1530
- resource: observation.data.resource,
1531
- });
1532
- }
1533
- const data =
1534
- observation.data.type === "alerts"
1535
- ? {
1536
- type: observation.data.type,
1537
- // An instance's sampled value and activation time move on every
1538
- // evaluation; the finding is which instances exist and their state.
1539
- rule: observation.data.rule,
1540
- instances: observation.data.instances.map((instance) => ({
1541
- state: instance.state,
1542
- labels: instance.labels,
1543
- })),
1544
- annotations: observation.data.annotations,
1545
- }
1546
- : observation.data.type === "issue"
1547
- ? {
1548
- type: observation.data.type,
1549
- // These are the finding and details shown in the evidence card.
1550
- // Collection timestamps and detector bookkeeping do not change it.
1551
- issue: {
1552
- kind: observation.data.issue.kind,
1553
- group: observation.data.issue.group,
1554
- namespace: observation.data.issue.namespace,
1555
- name: observation.data.issue.name,
1556
- reason: observation.data.issue.reason,
1557
- severity: observation.data.issue.severity,
1558
- cause: observation.data.issue.cause,
1559
- message: observation.data.issue.message,
1560
- },
1561
- }
1562
- : observation.data;
1563
- return JSON.stringify({
1564
- tone: observation.tone,
1565
- title: observation.title,
1566
- summary: observation.summary,
1567
- data,
1568
- });
1569
- }
1570
-
1571
- function contextFrom(value: unknown): InvestigationResourceContext | undefined {
1572
- const candidate = record(value);
1573
- if (!candidate || !nonEmptyString(candidate.tier)) return undefined;
1574
- return candidate as unknown as InvestigationResourceContext;
1575
- }
1576
-
1577
- function addNarrowHint(
1578
- builder: ProjectionBuilder,
1579
- source: InvestigationEvidenceSource,
1580
- value: Record<string, unknown>,
1581
- ): void {
1582
- if (nonEmptyString(value.narrowHint)) {
1583
- const label = investigationResultLabel(source);
1584
- builder.limit(
1585
- source,
1586
- label,
1587
- label +
1588
- " was narrowed to keep this investigation bounded. Additional matching evidence may exist.",
1589
- "truncated",
1590
- );
1591
- }
1592
- }
1593
-
1594
- function addResourceContextLimitations(
1595
- builder: ProjectionBuilder,
1596
- source: InvestigationEvidenceSource,
1597
- context: InvestigationResourceContext | undefined,
1598
- ): void {
1599
- if (!context) return;
1600
- for (const value of Array.isArray(context.omitted) ? context.omitted : []) {
1601
- const omitted = record(value);
1602
- if (
1603
- !omitted ||
1604
- !nonEmptyString(omitted.field) ||
1605
- !nonEmptyString(omitted.reason)
1606
- )
1607
- continue;
1608
- builder.limit(
1609
- source,
1610
- omitted.field,
1611
- `Resource context omitted: ${omitted.reason.replaceAll("_", " ")}.`,
1612
- omitted.reason === "budget_exceeded" ? "truncated" : "unknown",
1613
- );
1614
- }
1615
- if (context.referencedBy?.truncated) {
1616
- const shown = context.referencedBy.items?.length ?? 0;
1617
- builder.limit(
1618
- source,
1619
- "Relationships",
1620
- `Referenced-by relationships were truncated (${shown} of ${context.referencedBy.total} returned).`,
1621
- "truncated",
1622
- );
1623
- }
1624
- if (context.appReferences?.staleSecretEnvTruncated) {
1625
- builder.limit(
1626
- source,
1627
- "Application references",
1628
- "Additional stale Secret environment reference groups were omitted.",
1629
- "truncated",
1630
- );
1631
- }
1632
- }
1633
-
1634
- function addIssueLimitations(
1635
- builder: ProjectionBuilder,
1636
- source: InvestigationEvidenceSource,
1637
- value: Issue,
1638
- ): void {
1639
- if (value.members_truncated) {
1640
- builder.limit(
1641
- source,
1642
- `Radar Issue ${value.id}`,
1643
- "The affected-resource member list was truncated.",
1644
- "truncated",
1645
- );
1646
- }
1647
- }
1648
-
1649
- function resourceObservationSummary(
1650
- resource: InvestigationKubernetesResource,
1651
- context: InvestigationResourceContext | undefined,
1652
- warnings: string[],
1653
- gitOps?: InvestigationGitOpsDiagnosis,
1654
- detailedIssueShown = false,
1655
- ): string | undefined {
1656
- if (!detailedIssueShown && context?.issueSummary?.topReason) {
1657
- return context.issueSummary.topReason;
1658
- }
1659
- if (gitOps?.health) return `Health ${gitOps.health}`;
1660
- if (gitOps?.ready) return `Ready ${gitOps.ready}`;
1661
- if (gitOps?.sync) return `Sync ${gitOps.sync}`;
1662
- if (gitOps?.suspended) return "Reconciliation suspended";
1663
- const replicas = context?.workloadSummary?.replicas;
1664
- if (replicas?.desired !== undefined) {
1665
- const desired = replicas.desired;
1666
- const ready = replicas.ready ?? 0;
1667
- return `${ready}/${desired} replicas ready`;
1668
- }
1669
- if (context?.statusSummary?.phase) return context.statusSummary.phase;
1670
- if (context?.issueSummary?.topReason) return context.issueSummary.topReason;
1671
- return (
1672
- investigationResourceEvidenceSummary(resource) ||
1673
- warnings[0] ||
1674
- resource.metadata.namespace
1675
- );
1676
- }
1677
-
1678
- function addResourceObservation(
1679
- builder: ProjectionBuilder,
1680
- source: InvestigationEvidenceSource,
1681
- resource: InvestigationKubernetesResource,
1682
- context: InvestigationResourceContext | undefined,
1683
- warnings: string[],
1684
- gitOpsDiagnosis: InvestigationGitOpsDiagnosis | undefined,
1685
- hasDetailedCriticalIssue: boolean,
1686
- relevance: InvestigationEvidenceRelevance,
1687
- ): void {
1688
- const issueSummary = context?.issueSummary;
1689
- const severity = issueSummary?.highestSeverity ?? "";
1690
- const critical = severity.toLowerCase() === "critical";
1691
- const hasLiveIssue = (issueSummary?.count ?? 0) > 0;
1692
- const replicas = context?.workloadSummary?.replicas;
1693
- const desired = replicas?.desired;
1694
- const ready = replicas ? (replicas.ready ?? 0) : undefined;
1695
- const available = replicas ? (replicas.available ?? 0) : undefined;
1696
- const replicaShortfall =
1697
- desired !== undefined &&
1698
- desired > 0 &&
1699
- ((ready !== undefined && ready < desired) ||
1700
- (available !== undefined && available < desired) ||
1701
- (replicas?.unavailable ?? 0) > 0);
1702
- const adverseCondition = (context?.statusSummary?.conditions ?? []).some(
1703
- (condition) => defaultConditionTone(condition) === "fail",
1704
- );
1705
- const gitOpsAdverse = Boolean(
1706
- gitOpsDiagnosis &&
1707
- (gitOpsDiagnosis.health?.toLowerCase() === "degraded" ||
1708
- gitOpsDiagnosis.health?.toLowerCase() === "missing" ||
1709
- gitOpsDiagnosis.sync?.toLowerCase() === "outofsync" ||
1710
- gitOpsDiagnosis.ready?.toLowerCase().startsWith("false") ||
1711
- ["failed", "error"].includes(
1712
- gitOpsDiagnosis.operationPhase?.toLowerCase() ?? "",
1713
- )),
1714
- );
1715
- const hasAdverseState = replicaShortfall || adverseCondition || gitOpsAdverse;
1716
- const intendedTier: InvestigationEvidenceTier =
1717
- critical && !hasDetailedCriticalIssue
1718
- ? "key"
1719
- : hasLiveIssue
1720
- ? "supporting"
1721
- : hasAdverseState
1722
- ? "supporting"
1723
- : "context";
1724
- const tier = evidenceTierForRelevance(intendedTier, relevance);
1725
- const tone = hasLiveIssue
1726
- ? diagnosisSeverityTone(severity)
1727
- : hasAdverseState
1728
- ? "warning"
1729
- : warnings.length > 0
1730
- ? "warning"
1731
- : "neutral";
1732
- const namespace = resource.metadata.namespace;
1733
- const identity = `${resource.apiVersion}:${resource.kind}:${namespace ?? ""}:${resource.metadata.name}`;
1734
- builder.observe(identity, "resource", source, {
1735
- tier,
1736
- relevance,
1737
- tone,
1738
- title: `${resource.kind} ${namespace ? `${namespace}/` : ""}${resource.metadata.name}`,
1739
- summary: resourceObservationSummary(
1740
- resource,
1741
- context,
1742
- warnings,
1743
- gitOpsDiagnosis,
1744
- hasDetailedCriticalIssue,
1745
- ),
1746
- data: {
1747
- type: "resource",
1748
- resource,
1749
- resourceContext: context,
1750
- warnings,
1751
- gitOpsDiagnosis,
1752
- },
1753
- });
1754
- addResourceContextLimitations(builder, source, context);
1755
- }
1756
-
1757
- function addIssueObservation(
1758
- builder: ProjectionBuilder,
1759
- source: InvestigationEvidenceSource,
1760
- value: Issue,
1761
- producerRelevance: InvestigationEvidenceRelevance = "broader",
1762
- pods?: string[],
1763
- ): void {
1764
- const matchesTarget = resourceMatchesTarget(builder.target, {
1765
- kind: value.kind,
1766
- group: value.group ?? "",
1767
- namespace: value.namespace,
1768
- name: value.name,
1769
- });
1770
- const relevance = matchesTarget ? "target" : producerRelevance;
1771
- builder.observe(`issue:${value.id}`, "issue", source, {
1772
- tier:
1773
- relevance === "broader"
1774
- ? "context"
1775
- : value.severity === "critical"
1776
- ? "key"
1777
- : "supporting",
1778
- tone: diagnosisSeverityTone(value.severity),
1779
- relevance,
1780
- title: value.reason,
1781
- summary: value.cause || value.message,
1782
- data: {
1783
- type: "issue",
1784
- issue: value,
1785
- relevance,
1786
- ...(pods && pods.length > 0 ? { pods } : {}),
1787
- },
1788
- });
1789
- addIssueLimitations(builder, source, value);
1790
- }
1791
-
1792
- function startupBlocker(value: unknown): DiagnosisStartupBlocker | undefined {
1793
- const candidate = record(value);
1794
- if (
1795
- !candidate ||
1796
- !nonEmptyString(candidate.kind) ||
1797
- !nonEmptyString(candidate.name) ||
1798
- !nonEmptyString(candidate.reason) ||
1799
- !nonEmptyString(candidate.severity) ||
1800
- !nonEmptyString(candidate.message)
1801
- ) {
1802
- return undefined;
1803
- }
1804
- return candidate as unknown as DiagnosisStartupBlocker;
1805
- }
1806
-
1807
- function crashCause(value: unknown): DiagnosisCrashCause | undefined {
1808
- const candidate = record(value);
1809
- if (
1810
- !candidate ||
1811
- !Array.isArray(candidate.pods) ||
1812
- !candidate.pods.every((pod) => typeof pod === "string") ||
1813
- !nonEmptyString(candidate.container) ||
1814
- !nonEmptyString(candidate.state) ||
1815
- typeof candidate.exitCode !== "number" ||
1816
- !nonEmptyString(candidate.logLine) ||
1817
- !nonEmptyString(candidate.logSource) ||
1818
- !nonEmptyString(candidate.logLineSelection)
1819
- ) {
1820
- return undefined;
1821
- }
1822
- return {
1823
- ...(candidate as unknown as DiagnosisCrashCause),
1824
- logLine: stripAnsi(candidate.logLine as string),
1825
- };
1826
- }
1827
-
1828
- function addEvents(
1829
- builder: ProjectionBuilder,
1830
- source: InvestigationEvidenceSource,
1831
- values: InvestigationEventEvidence[],
1832
- identity: string,
1833
- complete = true,
1834
- emptyIsAuthoritative = false,
1835
- 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.",
1839
- },
1840
- ): void {
1841
- const scope = scopeFromArgs(source);
1842
- if (values.length === 0) {
1843
- if (!source.confirmedSuccess || !complete) return;
1844
- if (!emptyIsAuthoritative) {
1845
- builder.limit(
1846
- source,
1847
- "Events",
1848
- "No events were returned. This result does not establish that no events occurred.",
1849
- "unknown",
1850
- );
1851
- return;
1852
- }
1853
- builder.observe(identity, "receipt", source, {
1854
- tier: evidenceTierForRelevance("checked", relevance),
1855
- relevance,
1856
- tone: "neutral",
1857
- title: emptyReceipt.title,
1858
- summary: scope,
1859
- data: {
1860
- type: "receipt",
1861
- checked: "events",
1862
- scope,
1863
- message: emptyReceipt.message,
1864
- },
1865
- });
1866
- return;
1867
- }
1868
- const lead = leadEvent(values);
1869
- builder.observe(identity, "events", source, {
1870
- tier: evidenceTierForRelevance(
1871
- values.some((item) => item.type.toLowerCase() === "warning")
1872
- ? "supporting"
1873
- : "context",
1874
- relevance,
1875
- ),
1876
- relevance,
1877
- tone: values.some((item) => item.type.toLowerCase() === "warning")
1878
- ? "warning"
1879
- : "info",
1880
- title: "Kubernetes events",
1881
- summary: `${lead.reason}: ${lead.message}${
1882
- values.length > 1 ? ` · ${values.length} event groups` : ""
1883
- } · ${scope}`,
1884
- data: { type: "events", events: values, scope },
1885
- });
1886
- }
1887
-
1888
- /**
1889
- * The newest warning, else the newest event. The card lead must be what
1890
- * happened, not how many groups the producer returned.
1891
- */
1892
- function leadEvent(
1893
- values: InvestigationEventEvidence[],
1894
- ): InvestigationEventEvidence {
1895
- const newest = (items: InvestigationEventEvidence[]) =>
1896
- items.reduce((best, item) =>
1897
- Date.parse(item.lastTimestamp) > Date.parse(best.lastTimestamp)
1898
- ? item
1899
- : best,
1900
- );
1901
- const warnings = values.filter(
1902
- (item) => item.type.toLowerCase() === "warning",
1903
- );
1904
- return newest(warnings.length > 0 ? warnings : values);
1905
- }
1906
-
1907
- function addChanges(
1908
- builder: ProjectionBuilder,
1909
- source: InvestigationEvidenceSource,
1910
- values: IssueRecentChange[],
1911
- identity: string,
1912
- changeContext?: DiagnosisChangeContext,
1913
- complete = true,
1914
- emptyIsAuthoritative = false,
1915
- relevance: InvestigationEvidenceRelevance = "broader",
1916
- subject?: { kind?: string; namespace?: string; name: string },
1917
- window?: string,
1918
- ): void {
1919
- const scope = scopeFromArgs(source);
1920
- if (values.length === 0 && !changeContext?.changed) {
1921
- if (!source.confirmedSuccess || !complete) return;
1922
- if (!emptyIsAuthoritative) {
1923
- builder.limit(
1924
- source,
1925
- "Recent changes",
1926
- `No changes were returned for ${scope}. This result does not establish a complete change history.`,
1927
- "unknown",
1928
- "history",
1929
- );
1930
- return;
1931
- }
1932
- builder.observe(identity, "receipt", source, {
1933
- tier: evidenceTierForRelevance("checked", relevance),
1934
- relevance,
1935
- tone: "neutral",
1936
- title: "No tracked recent changes",
1937
- summary: scope,
1938
- data: {
1939
- type: "receipt",
1940
- checked: "changes",
1941
- scope,
1942
- message: "The requested change window returned no tracked changes.",
1943
- },
1944
- });
1945
- return;
1946
- }
1947
- builder.observe(identity, "changes", source, {
1948
- // A producer-correlated change supports the diagnosis. A merely recent
1949
- // edit is chronology/context and must not imply causality by proximity.
1950
- tier: evidenceTierForRelevance(
1951
- changeContext?.changed ? "supporting" : "context",
1952
- relevance,
1953
- ),
1954
- relevance,
1955
- tone: "info",
1956
- title: `Recent changes${window ? ` · last ${window}` : ""}`,
1957
- summary: changeContext?.changed
1958
- ? changeContext.what === "The workload's Pod template changed" &&
1959
- changeContext.when
1960
- ? `Pod template changed; newest ReplicaSet created ${changeContext.when} ago`
1961
- : `${changeContext.what || "A workload change was observed"}${changeContext.when ? ` · ${changeContext.when} ago` : ""}`
1962
- : `${values.length} change${values.length === 1 ? "" : "s"} · ${scope}`,
1963
- data: {
1964
- type: "changes",
1965
- changes: values,
1966
- scope,
1967
- changeContext,
1968
- subject,
1969
- },
1970
- });
1971
- }
1972
-
1973
- function changesSubjectFromArgs(
1974
- source: InvestigationEvidenceSource,
1975
- ): { kind?: string; namespace?: string; name: string } | undefined {
1976
- const args = record(source.args ? parseJSON(source.args) : undefined);
1977
- if (!nonEmptyString(args?.name)) return undefined;
1978
- return {
1979
- ...(nonEmptyString(args.kind) ? { kind: args.kind } : {}),
1980
- ...(nonEmptyString(args.namespace) ? { namespace: args.namespace } : {}),
1981
- name: args.name,
1982
- };
1983
- }
1984
-
1985
- function addLogs(
1986
- builder: ProjectionBuilder,
1987
- source: InvestigationEvidenceSource,
1988
- value: DiagnosisPodLogEntry,
1989
- previous: boolean,
1990
- warnings: string[] = [],
1991
- relevance: InvestigationEvidenceRelevance = "broader",
1992
- namespace?: string,
1993
- ): void {
1994
- const lines = (value.logs?.lines ?? []).map((line) => stripAnsi(line));
1995
- const normalizedWarnings = warnings.map((warning) => stripAnsi(warning));
1996
- const normalizedError = value.error ? stripAnsi(value.error) : undefined;
1997
- if (lines.length === 0) {
1998
- builder.limit(
1999
- source,
2000
- `${value.pod} / ${value.container}`,
2001
- value.error ||
2002
- "No log lines were available. This does not mean the container is healthy.",
2003
- value.error ? "error" : "unknown",
2004
- );
2005
- return;
2006
- }
2007
- // A producer-filtered excerpt is a candidate, not proof that its contents are
2008
- // adverse. Query strings and routine request logs can contain words such as
2009
- // "warning" or "critical" and still be successful traffic. Promote only an
2010
- // explicit failure/error signature; keep benign excerpts available in Context.
2011
- const diagnosticSignal = [...lines, ...normalizedWarnings].some((line) =>
2012
- /(?:\b(?:error|exception|failed|failure|fatal|panic|crash|denied|refused|timeout|timed out|unhealthy|oomkill|back-?off)\b|\s5\d\d(?:\s|$))/i.test(
2013
- line,
2014
- ),
2015
- );
2016
- const selectedEvidence = value.logs?.fallback !== true && diagnosticSignal;
2017
- const identity = `logs:${previous ? "previous" : "current"}:${value.pod}:${value.container}`;
2018
- builder.observe(identity, "logs", source, {
2019
- // FilterLogs' raw-tail fallback is useful provenance, but the producer did
2020
- // not select it as diagnostic signal. Keep it in Context; only filtered
2021
- // excerpts are Supporting evidence.
2022
- tier: evidenceTierForRelevance(
2023
- selectedEvidence ? "supporting" : "context",
2024
- relevance,
2025
- ),
2026
- relevance,
2027
- tone: selectedEvidence || normalizedError ? "warning" : "neutral",
2028
- title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${value.container}`,
2029
- summary:
2030
- lines.length > 0
2031
- ? `${lines.length} selected line${lines.length === 1 ? "" : "s"}`
2032
- : "No log lines available",
2033
- data: {
2034
- type: "logs",
2035
- pod: value.pod,
2036
- container: value.container,
2037
- namespace,
2038
- previous,
2039
- logs: value.logs ? { ...value.logs, lines } : undefined,
2040
- warnings: normalizedWarnings,
2041
- error: normalizedError,
2042
- },
2043
- });
2044
- if (normalizedError) {
2045
- builder.limit(
2046
- source,
2047
- `${value.pod} / ${value.container}`,
2048
- normalizedError,
2049
- "error",
2050
- );
2051
- }
2052
- }
2053
-
2054
- function podContainerRef(value: unknown): DiagnosisPodContainerRef | undefined {
2055
- const candidate = record(value);
2056
- if (
2057
- !candidate ||
2058
- !nonEmptyString(candidate.pod) ||
2059
- !nonEmptyString(candidate.container)
2060
- ) {
2061
- return undefined;
2062
- }
2063
- return candidate as unknown as DiagnosisPodContainerRef;
2064
- }
2065
-
2066
- function parseLogEntry(value: unknown): DiagnosisPodLogEntry | undefined {
2067
- const candidate = record(value);
2068
- if (
2069
- !candidate ||
2070
- !nonEmptyString(candidate.pod) ||
2071
- !nonEmptyString(candidate.container)
2072
- ) {
2073
- return undefined;
2074
- }
2075
- if (candidate.logs !== undefined && !filteredLogs(candidate.logs))
2076
- return undefined;
2077
- if (candidate.error !== undefined && typeof candidate.error !== "string")
2078
- return undefined;
2079
- return candidate as unknown as DiagnosisPodLogEntry;
2080
- }
2081
-
2082
- function nonNegativeInteger(value: unknown): value is number {
2083
- return typeof value === "number" && Number.isInteger(value) && value >= 0;
2084
- }
2085
-
2086
- function diagnoseCrashQueryCoversTarget(
2087
- source: InvestigationEvidenceSource,
2088
- ): boolean {
2089
- const args = record(source.args ? parseJSON(source.args) : undefined);
2090
- if (!args) return false;
2091
-
2092
- // Crash retirement is target-wide. It therefore requires the producer's
2093
- // normal all-container, full-time-window read. A caller-selected container,
2094
- // since window, or shorter-than-default tail can validate that slice only;
2095
- // it cannot clear a smoking gun from a stream it never revisited.
2096
- if (nonEmptyString(args.container) || nonEmptyString(args.since))
2097
- return false;
2098
- if (args.tail_lines === undefined) return true;
2099
- return (
2100
- nonNegativeInteger(args.tail_lines) &&
2101
- (args.tail_lines === 0 || args.tail_lines >= 100)
2102
- );
2103
- }
2104
-
2105
- /**
2106
- * A missing crash candidate is meaningful only when diagnose actually read the
2107
- * complete log surface that its crash classifier consumes. Keep this tied to
2108
- * the current producer contract: a partial pod sample, a capped response, or a
2109
- * failed stream is absence of evidence and must not clear earlier crash proof.
2110
- */
2111
- function diagnoseCrashCoverageComplete(
2112
- value: Record<string, unknown>,
2113
- ): boolean {
2114
- if (
2115
- !nonNegativeInteger(value.pods) ||
2116
- value.pods === 0 ||
2117
- value.logsError !== undefined ||
2118
- (value.crashCauseTruncated !== undefined &&
2119
- typeof value.crashCauseTruncated !== "boolean") ||
2120
- value.crashCauseTruncated === true
2121
- ) {
2122
- return false;
2123
- }
2124
-
2125
- const coverage = record(value.logCoverage);
2126
- if (
2127
- !coverage ||
2128
- !nonNegativeInteger(coverage.resolvedPods) ||
2129
- !nonNegativeInteger(coverage.selectedPods) ||
2130
- !nonNegativeInteger(coverage.shownLines) ||
2131
- !nonNegativeInteger(coverage.totalLines) ||
2132
- !nonNegativeInteger(coverage.shownPods) ||
2133
- !nonNegativeInteger(coverage.totalPods) ||
2134
- coverage.resolvedPods !== value.pods ||
2135
- coverage.selectedPods !== coverage.resolvedPods ||
2136
- (coverage.selectionTruncated !== undefined &&
2137
- typeof coverage.selectionTruncated !== "boolean") ||
2138
- coverage.selectionTruncated === true ||
2139
- (coverage.contentTruncated !== undefined &&
2140
- typeof coverage.contentTruncated !== "boolean") ||
2141
- coverage.contentTruncated === true ||
2142
- coverage.shownLines !== coverage.totalLines ||
2143
- coverage.shownPods !== coverage.totalPods
2144
- ) {
2145
- return false;
2146
- }
2147
-
2148
- const currentRaw = value.logsCurrent;
2149
- const previousRaw = value.logsPrevious;
2150
- if (
2151
- !Array.isArray(currentRaw) ||
2152
- currentRaw.length === 0 ||
2153
- !Array.isArray(previousRaw)
2154
- ) {
2155
- return false;
2156
- }
2157
-
2158
- const current = currentRaw.map(parseLogEntry);
2159
- const previous = previousRaw.map(parseLogEntry);
2160
- if (
2161
- current.some(
2162
- (entry) => !entry || entry.error !== undefined || !entry.logs,
2163
- ) ||
2164
- previous.some((entry) => !entry || !entry.logs)
2165
- ) {
2166
- return false;
2167
- }
2168
-
2169
- const currentEntries = current as DiagnosisPodLogEntry[];
2170
- const previousEntries = previous as DiagnosisPodLogEntry[];
2171
- const streamKey = (entry: DiagnosisPodContainerRef) =>
2172
- `${entry.pod}\u0000${entry.container}`;
2173
- const currentKeys = new Set(currentEntries.map(streamKey));
2174
- const previousKeys = new Set(previousEntries.map(streamKey));
2175
- if (
2176
- currentKeys.size !== current.length ||
2177
- previousKeys.size !== previous.length ||
2178
- currentKeys.size !== previousKeys.size ||
2179
- [...currentKeys].some((key) => !previousKeys.has(key))
2180
- ) {
2181
- return false;
2182
- }
2183
-
2184
- const absencesRaw = value.expectedPreviousLogAbsences;
2185
- if (absencesRaw !== undefined && !Array.isArray(absencesRaw)) return false;
2186
- const absences = Array.isArray(absencesRaw)
2187
- ? absencesRaw.map(podContainerRef)
2188
- : [];
2189
- if (absences.some((entry) => !entry)) return false;
2190
- const absenceEntries = absences as DiagnosisPodContainerRef[];
2191
- const absenceKeys = new Set(absenceEntries.map(streamKey));
2192
- if ([...absenceKeys].some((key) => !previousKeys.has(key))) return false;
2193
-
2194
- return previousEntries.every((entry) => {
2195
- if (entry.error === undefined) return true;
2196
- return nonEmptyString(entry.error) && absenceKeys.has(streamKey(entry));
2197
- });
2198
- }
2199
-
2200
- function invalidPayload(
2201
- builder: ProjectionBuilder,
2202
- source: InvestigationEvidenceSource,
2203
- section = investigationResultLabel(source),
2204
- ): void {
2205
- builder.limit(
2206
- source,
2207
- section,
2208
- "Radar couldn't summarize this investigation step. Review it in Activity.",
2209
- "unknown",
2210
- );
2211
- }
2212
-
2213
- function adaptDiagnose(
2214
- builder: ProjectionBuilder,
2215
- source: InvestigationEvidenceSource,
2216
- payload: unknown,
2217
- ): void {
2218
- const value = record(payload);
2219
- if (value) {
2220
- const network = networkEvidence(value);
2221
- if (network) {
2222
- const relevance = relevanceForResource(builder, network.subject);
2223
- const adverse =
2224
- network.verdict === "broken" || network.verdict === "degraded";
2225
- builder.observe(
2226
- `network:${network.subject.group ?? ""}:${network.subject.kind}:${network.subject.namespace ?? ""}:${network.subject.name}`,
2227
- "network",
2228
- source,
2229
- {
2230
- tier: evidenceTierForRelevance(
2231
- adverse ? "supporting" : "context",
2232
- relevance,
2233
- ),
2234
- relevance,
2235
- tone:
2236
- network.verdict === "broken"
2237
- ? "error"
2238
- : network.verdict === "degraded"
2239
- ? "warning"
2240
- : network.verdict === "healthy"
2241
- ? "info"
2242
- : "neutral",
2243
- title: `${network.subject.kind} path · ${network.subject.namespace ? `${network.subject.namespace}/` : ""}${network.subject.name}`,
2244
- summary:
2245
- network.diagnosis?.summary ||
2246
- network.reason ||
2247
- network.summary.headline,
2248
- data: { type: "network", network },
2249
- },
2250
- );
2251
- if (network.summary.skipped > 0) {
2252
- builder.limit(
2253
- source,
2254
- "Network path coverage",
2255
- `${network.summary.skipped} intended route${network.summary.skipped === 1 ? " was" : "s were"} not tested. ${network.summary.headline}`,
2256
- "unknown",
2257
- );
2258
- }
2259
- return;
2260
- }
2261
- }
2262
- const resource = kubernetesResource(value?.resource);
2263
- if (!value || !resource) {
2264
- invalidPayload(builder, source);
2265
- return;
2266
- }
2267
- const bundleRelevance = relevanceForResource(builder, {
2268
- kind: resource.kind,
2269
- group: apiVersionToGroup(resource.apiVersion),
2270
- namespace: resource.metadata.namespace,
2271
- name: resource.metadata.name,
2272
- });
2273
- const relatedRelevance: InvestigationEvidenceRelevance =
2274
- bundleRelevance === "target" ? "producer-related" : "broader";
2275
- const bundledRowRelevance = (
2276
- kind: string,
2277
- name: string,
2278
- ): InvestigationEvidenceRelevance => {
2279
- if (bundleRelevance !== "target") return "broader";
2280
- const sameAsRoot =
2281
- kind.toLowerCase() === resource.kind.toLowerCase() &&
2282
- name === resource.metadata.name;
2283
- const group = sameAsRoot
2284
- ? apiVersionToGroup(resource.apiVersion)
2285
- : kind.toLowerCase() === "pod"
2286
- ? ""
2287
- : undefined;
2288
- return resourceMatchesTarget(builder.target, {
2289
- kind,
2290
- group,
2291
- namespace: resource.metadata.namespace,
2292
- name,
2293
- })
2294
- ? "target"
2295
- : "producer-related";
2296
- };
2297
- addNarrowHint(builder, source, value);
2298
-
2299
- const relatedRaw = value.relatedIssues;
2300
- const related = Array.isArray(relatedRaw)
2301
- ? relatedRaw.map(issue).filter((item): item is Issue => Boolean(item))
2302
- : [];
2303
- const relatedValid =
2304
- relatedRaw === undefined ||
2305
- (Array.isArray(relatedRaw) && related.length === relatedRaw.length);
2306
- if (!relatedValid) {
2307
- invalidPayload(builder, source, "Classified issues");
2308
- }
2309
- const blockersRaw = value.startupBlockers;
2310
- let blockersValid = blockersRaw === undefined || Array.isArray(blockersRaw);
2311
- // Pods of one workload usually share a blocker word for word. Merge those
2312
- // into one finding with the pod list; anything else keeps its own card and
2313
- // its own identity so saved runs match as before.
2314
- const blockerGroups: Array<{
2315
- blocker: DiagnosisStartupBlocker;
2316
- pods: string[];
2317
- foldedInto?: Issue;
2318
- }> = [];
2319
- if (Array.isArray(blockersRaw)) {
2320
- const merged = new Map<string, (typeof blockerGroups)[number]>();
2321
- for (const raw of blockersRaw) {
2322
- const blocker = startupBlocker(raw);
2323
- if (!blocker) {
2324
- blockersValid = false;
2325
- invalidPayload(builder, source, "Startup evidence");
2326
- continue;
2327
- }
2328
- const key =
2329
- blocker.kind === "Pod"
2330
- ? `${blocker.reason} ${blocker.severity} ${blocker.message}`
2331
- : undefined;
2332
- const existing = key ? merged.get(key) : undefined;
2333
- if (existing) {
2334
- if (!existing.pods.includes(blocker.name)) {
2335
- existing.pods.push(blocker.name);
2336
- }
2337
- continue;
2338
- }
2339
- const entry = { blocker, pods: [blocker.name] };
2340
- if (key) merged.set(key, entry);
2341
- blockerGroups.push(entry);
2342
- }
2343
- }
2344
- for (const item of related) {
2345
- // `diagnose` is itself scoped to one resource and declares these rows as
2346
- // related evidence. That producer contract is stronger than a broad
2347
- // `issues` query, even when the related row is a child Pod.
2348
- // A classified issue and the pods' startup blocker are the same fact
2349
- // seen from the workload and from its pods; one card carries both.
2350
- const folded = blockerGroups.find(
2351
- (group) =>
2352
- !group.foldedInto &&
2353
- group.blocker.kind === "Pod" &&
2354
- group.blocker.reason === item.reason &&
2355
- (group.blocker.message === item.message ||
2356
- group.blocker.message === item.cause),
2357
- );
2358
- if (folded) folded.foldedInto = item;
2359
- addIssueObservation(builder, source, item, relatedRelevance, folded?.pods);
2360
- }
2361
-
2362
- const context = contextFrom(value.resourceContext);
2363
- if (value.resourceContext !== undefined && !context) {
2364
- invalidPayload(builder, source, "Resource context");
2365
- }
2366
- const gitOps =
2367
- value.gitopsDiagnosis === undefined
2368
- ? undefined
2369
- : gitOpsDiagnosis(value.gitopsDiagnosis);
2370
- if (value.gitopsDiagnosis !== undefined && !gitOps) {
2371
- invalidPayload(builder, source, "GitOps status");
2372
- }
2373
- if (
2374
- source.confirmedSuccess &&
2375
- related.length === 0 &&
2376
- relatedValid &&
2377
- isDiagnosableWorkloadKind(resource.kind)
2378
- ) {
2379
- const scope = scopeFromArgs(source);
2380
- builder.observe(`issues:diagnose:${scope}`, "receipt", source, {
2381
- tier: evidenceTierForRelevance("checked", bundleRelevance),
2382
- relevance: bundleRelevance,
2383
- tone: "neutral",
2384
- title: "No classified workload issues",
2385
- summary: scope,
2386
- data: {
2387
- type: "receipt",
2388
- checked: "issues",
2389
- scope,
2390
- message:
2391
- "Radar's workload diagnosis completed without a classified live issue for this resource.",
2392
- },
2393
- });
2394
- }
2395
- const warnings = stringArray(value.warnings) ?? [];
2396
- addResourceObservation(
2397
- builder,
2398
- source,
2399
- resource,
2400
- context,
2401
- warnings,
2402
- gitOps,
2403
- related.some((item) => item.severity === "critical"),
2404
- bundleRelevance,
2405
- );
2406
-
2407
- if (Array.isArray(blockersRaw)) {
2408
- for (const { blocker, pods, foldedInto } of blockerGroups) {
2409
- if (foldedInto) continue;
2410
- const grouped = blocker.kind === "Pod" && pods.length > 1;
2411
- builder.observe(
2412
- grouped
2413
- ? `startup:Pod:${blocker.reason}:${fnv1a32(blocker.message).toString(36)}`
2414
- : `startup:${blocker.kind}:${blocker.name}:${blocker.reason}`,
2415
- "startup",
2416
- source,
2417
- {
2418
- tier: evidenceTierForRelevance(
2419
- "key",
2420
- bundledRowRelevance(blocker.kind, blocker.name),
2421
- ),
2422
- relevance: bundledRowRelevance(blocker.kind, blocker.name),
2423
- tone: diagnosisSeverityTone(blocker.severity),
2424
- title: blocker.reason,
2425
- summary: grouped
2426
- ? `${pods.length} pods · ${blocker.message}`
2427
- : blocker.message,
2428
- data: {
2429
- type: "startup",
2430
- blocker,
2431
- ...(grouped ? { pods } : {}),
2432
- subject: grouped
2433
- ? undefined
2434
- : (() => {
2435
- const namespace = resource.metadata.namespace;
2436
- if (!namespace) return undefined;
2437
- const rootGroup = apiVersionToGroup(resource.apiVersion);
2438
- const group =
2439
- blocker.kind === resource.kind
2440
- ? rootGroup
2441
- : blocker.kind === "Pod"
2442
- ? ""
2443
- : blocker.kind === "ReplicaSet"
2444
- ? "apps"
2445
- : undefined;
2446
- if (group === undefined) return undefined;
2447
- return {
2448
- kind: blocker.kind,
2449
- ...(group ? { group } : {}),
2450
- namespace,
2451
- name: blocker.name,
2452
- };
2453
- })(),
2454
- },
2455
- },
2456
- );
2457
- }
2458
- } else if (blockersRaw !== undefined) {
2459
- invalidPayload(builder, source, "Startup evidence");
2460
- }
2461
-
2462
- const crashesRaw = value.crashCause;
2463
- let crashesValid =
2464
- (crashesRaw === undefined || Array.isArray(crashesRaw)) &&
2465
- (value.crashCauseTruncated === undefined ||
2466
- typeof value.crashCauseTruncated === "boolean");
2467
- if (Array.isArray(crashesRaw)) {
2468
- for (const raw of crashesRaw) {
2469
- const crash = crashCause(raw);
2470
- if (!crash) {
2471
- crashesValid = false;
2472
- invalidPayload(builder, source, "Crash evidence");
2473
- continue;
2474
- }
2475
- const pods = [...crash.pods].sort().join(",");
2476
- const crashRelevance =
2477
- crash.pods.length === 1
2478
- ? bundledRowRelevance("Pod", crash.pods[0])
2479
- : relatedRelevance;
2480
- builder.observe(
2481
- `crash:${pods}:${crash.container}:${crash.state}:${crash.reason ?? ""}`,
2482
- "crash",
2483
- source,
2484
- {
2485
- tier: evidenceTierForRelevance("key", crashRelevance),
2486
- relevance: crashRelevance,
2487
- tone: "error",
2488
- title: `${crash.container} ${crash.reason || crash.state}`,
2489
- summary: crash.logLine,
2490
- data: {
2491
- type: "crash",
2492
- crash,
2493
- namespace: resource.metadata.namespace,
2494
- },
2495
- },
2496
- );
2497
- }
2498
- } else if (crashesRaw !== undefined) {
2499
- invalidPayload(builder, source, "Crash evidence");
2500
- }
2501
- if (value.crashCauseTruncated === true) {
2502
- builder.limit(
2503
- source,
2504
- "Crash evidence",
2505
- "Additional crash-cause candidates were omitted.",
2506
- "truncated",
2507
- );
2508
- }
2509
-
2510
- if (
2511
- source.confirmedSuccess &&
2512
- bundleRelevance === "target" &&
2513
- isDiagnosableWorkloadKind(resource.kind)
2514
- ) {
2515
- if (relatedValid) builder.coverSemantic(source, "issue");
2516
- if (blockersValid) builder.coverSemantic(source, "startup");
2517
- if (
2518
- crashesValid &&
2519
- diagnoseCrashQueryCoversTarget(source) &&
2520
- diagnoseCrashCoverageComplete(value)
2521
- ) {
2522
- builder.coverSemantic(source, "crash");
2523
- }
2524
- // DNSContext is emitted only for positive symptoms/configuration. The
2525
- // producer has no explicit negative DNS coverage receipt, so its absence
2526
- // cannot safely retire earlier DNS evidence.
2527
- }
2528
-
2529
- const expectedAbsencesRaw = value.expectedPreviousLogAbsences;
2530
- const expectedAbsences = Array.isArray(expectedAbsencesRaw)
2531
- ? expectedAbsencesRaw
2532
- .map(podContainerRef)
2533
- .filter((item): item is DiagnosisPodContainerRef => Boolean(item))
2534
- : [];
2535
- if (
2536
- expectedAbsencesRaw !== undefined &&
2537
- (!Array.isArray(expectedAbsencesRaw) ||
2538
- expectedAbsences.length !== expectedAbsencesRaw.length)
2539
- ) {
2540
- invalidPayload(builder, source, "Previous-log status");
2541
- }
2542
- const expectedAbsenceKeys = new Set(
2543
- expectedAbsences.map((item) => `${item.pod}\u0000${item.container}`),
2544
- );
2545
- if (source.confirmedSuccess) {
2546
- for (const item of expectedAbsences) {
2547
- const logRelevance = bundledRowRelevance("Pod", item.pod);
2548
- builder.observe(
2549
- `previous-log-absence:${item.pod}:${item.container}`,
2550
- "receipt",
2551
- source,
2552
- {
2553
- tier: evidenceTierForRelevance("checked", logRelevance),
2554
- relevance: logRelevance,
2555
- tone: "neutral",
2556
- title: "No previous container instance expected",
2557
- summary: `${item.pod} / ${item.container}`,
2558
- data: {
2559
- type: "receipt",
2560
- checked: "logs",
2561
- scope: `${item.pod} / ${item.container}`,
2562
- message:
2563
- "Captured container status shows zero restarts and no prior termination, so a previous log stream should not exist.",
2564
- },
2565
- },
2566
- );
2567
- }
2568
- }
2569
-
2570
- for (const [field, previous] of [
2571
- ["logsCurrent", false],
2572
- ["logsPrevious", true],
2573
- ] as const) {
2574
- const raw = value[field];
2575
- if (Array.isArray(raw)) {
2576
- for (const item of raw) {
2577
- const entry = parseLogEntry(item);
2578
- if (
2579
- entry &&
2580
- previous &&
2581
- expectedAbsenceKeys.has(`${entry.pod}\u0000${entry.container}`) &&
2582
- (entry.logs?.lines?.length ?? 0) === 0
2583
- ) {
2584
- continue;
2585
- }
2586
- if (entry) {
2587
- addLogs(
2588
- builder,
2589
- source,
2590
- entry,
2591
- previous,
2592
- [],
2593
- bundledRowRelevance("Pod", entry.pod),
2594
- resource.metadata.namespace,
2595
- );
2596
- } else
2597
- invalidPayload(
2598
- builder,
2599
- source,
2600
- previous ? "Previous logs" : "Current logs",
2601
- );
2602
- }
2603
- if (raw.length === 0 && field === "logsCurrent") {
2604
- builder.limit(
2605
- source,
2606
- "Current logs",
2607
- "No container logs were available, so Radar could not evaluate them.",
2608
- "unknown",
2609
- );
2610
- }
2611
- } else if (raw !== undefined) {
2612
- invalidPayload(
2613
- builder,
2614
- source,
2615
- previous ? "Previous logs" : "Current logs",
2616
- );
2617
- } else if (
2618
- field === "logsCurrent" &&
2619
- typeof value.pods === "number" &&
2620
- value.pods > 0 &&
2621
- !nonEmptyString(value.logsError)
2622
- ) {
2623
- // Empty slices are omitted by the Go producer. With resolved pods this
2624
- // means the read yielded no stream rows, not that logs proved anything.
2625
- builder.limit(
2626
- source,
2627
- "Current logs",
2628
- "No container logs were available, so Radar could not evaluate them.",
2629
- "unknown",
2630
- );
2631
- }
2632
- }
2633
- if (nonEmptyString(value.logsError)) {
2634
- builder.limit(source, "Logs", value.logsError, "error");
2635
- }
2636
- const logCoverage = record(value.logCoverage);
2637
- if (logCoverage?.selectionTruncated === true) {
2638
- const selected =
2639
- typeof logCoverage.selectedPods === "number"
2640
- ? logCoverage.selectedPods
2641
- : "some";
2642
- const resolved =
2643
- typeof logCoverage.resolvedPods === "number"
2644
- ? logCoverage.resolvedPods
2645
- : "the resolved";
2646
- builder.limit(
2647
- source,
2648
- "Log pod coverage",
2649
- `Log collection selected ${selected} of ${resolved} pods.`,
2650
- "truncated",
2651
- );
2652
- }
2653
- if (logCoverage?.contentTruncated === true) {
2654
- const shown =
2655
- typeof logCoverage.shownLines === "number"
2656
- ? logCoverage.shownLines
2657
- : "a subset of";
2658
- const total =
2659
- typeof logCoverage.totalLines === "number"
2660
- ? logCoverage.totalLines
2661
- : "the returned";
2662
- builder.limit(
2663
- source,
2664
- "Log excerpt coverage",
2665
- `The response includes ${shown} of ${total} selected log lines after the size limit; these are excerpts, not the container's full log history.`,
2666
- "truncated",
2667
- );
2668
- }
2669
-
2670
- const eventsRaw = value.events;
2671
- if (Array.isArray(eventsRaw)) {
2672
- const events = eventsRaw
2673
- .map(event)
2674
- .filter((item): item is InvestigationEventEvidence => Boolean(item));
2675
- if (events.length === eventsRaw.length) {
2676
- addEvents(
2677
- builder,
2678
- source,
2679
- events,
2680
- `events:diagnose:${scopeFromArgs(source)}`,
2681
- typeof value.eventsTotalGroups !== "number" ||
2682
- value.eventsTotalGroups <= events.length,
2683
- true,
2684
- relatedRelevance,
2685
- );
2686
- } else {
2687
- invalidPayload(builder, source, "Events");
2688
- }
2689
- } else if (
2690
- eventsRaw === undefined &&
2691
- value.gitopsDiagnosis === undefined &&
2692
- !nonEmptyString(value.eventsError)
2693
- ) {
2694
- addEvents(
2695
- builder,
2696
- source,
2697
- [],
2698
- `events:diagnose:${scopeFromArgs(source)}`,
2699
- true,
2700
- true,
2701
- relatedRelevance,
2702
- );
2703
- } else if (eventsRaw !== undefined) {
2704
- invalidPayload(builder, source, "Events");
2705
- }
2706
- if (nonEmptyString(value.eventsError)) {
2707
- builder.limit(source, "Events", value.eventsError, "error");
2708
- }
2709
- if (
2710
- typeof value.eventsTotalGroups === "number" &&
2711
- Array.isArray(eventsRaw) &&
2712
- value.eventsTotalGroups > eventsRaw.length
2713
- ) {
2714
- builder.limit(
2715
- source,
2716
- "Events",
2717
- `Radar received ${eventsRaw.length} of ${value.eventsTotalGroups} event groups.`,
2718
- "truncated",
2719
- );
2720
- }
2721
-
2722
- const changesRaw = value.recentChanges;
2723
- const changesCoverageLimitedRaw = value.recentChangesCoverageLimited;
2724
- const changesCoverageLimitedValid =
2725
- changesCoverageLimitedRaw === undefined ||
2726
- typeof changesCoverageLimitedRaw === "boolean";
2727
- const changesCoverageLimited = changesCoverageLimitedRaw === true;
2728
- if (!changesCoverageLimitedValid) {
2729
- invalidPayload(builder, source, "Recent changes");
2730
- }
2731
- const changeContext =
2732
- value.changeContext === undefined
2733
- ? undefined
2734
- : diagnosisChangeContext(value.changeContext);
2735
- if (value.changeContext !== undefined && !changeContext) {
2736
- invalidPayload(builder, source, "Change correlation");
2737
- }
2738
- const diagnoseChangesSubject = {
2739
- kind: resource.kind,
2740
- namespace: resource.metadata.namespace,
2741
- name: resource.metadata.name,
2742
- };
2743
- if (Array.isArray(changesRaw)) {
2744
- const changes = changesRaw
2745
- .map(recentChange)
2746
- .filter((item): item is IssueRecentChange => Boolean(item));
2747
- if (changes.length === changesRaw.length) {
2748
- addChanges(
2749
- builder,
2750
- source,
2751
- changes,
2752
- `changes:diagnose:${scopeFromArgs(source)}`,
2753
- changeContext,
2754
- value.recentChangesSaturated !== true &&
2755
- !changesCoverageLimited &&
2756
- changesCoverageLimitedValid,
2757
- true,
2758
- relatedRelevance,
2759
- diagnoseChangesSubject,
2760
- );
2761
- } else {
2762
- invalidPayload(builder, source, "Recent changes");
2763
- }
2764
- } else if (
2765
- changesRaw === undefined &&
2766
- value.gitopsDiagnosis === undefined &&
2767
- !nonEmptyString(value.recentChangesError)
2768
- ) {
2769
- addChanges(
2770
- builder,
2771
- source,
2772
- [],
2773
- `changes:diagnose:${scopeFromArgs(source)}`,
2774
- changeContext,
2775
- value.recentChangesSaturated !== true &&
2776
- !changesCoverageLimited &&
2777
- changesCoverageLimitedValid,
2778
- true,
2779
- relatedRelevance,
2780
- diagnoseChangesSubject,
2781
- );
2782
- } else if (changesRaw !== undefined) {
2783
- invalidPayload(builder, source, "Recent changes");
2784
- }
2785
- if (nonEmptyString(value.recentChangesError)) {
2786
- builder.limit(source, "Recent changes", value.recentChangesError, "error");
2787
- }
2788
- if (value.recentChangesSaturated === true) {
2789
- builder.limit(
2790
- source,
2791
- "Recent changes",
2792
- "The recent-change result limit was reached; additional changes may exist in the requested window.",
2793
- "truncated",
2794
- );
2795
- }
2796
- if (changesCoverageLimited) {
2797
- builder.limit(
2798
- source,
2799
- "Recent changes",
2800
- "Recent-change coverage is limited because Radar could not confirm permission to read every referenced source. The visible result cannot prove that no recent changes exist.",
2801
- "unknown",
2802
- );
2803
- }
2804
-
2805
- const dns = record(value.dnsContext);
2806
- if (dns) {
2807
- const signals = stringArray(dns.signals) ?? [];
2808
- const findings = Array.isArray(dns.coreDNSFindings)
2809
- ? dns.coreDNSFindings
2810
- : [];
2811
- if (signals.length > 0 || findings.length > 0) {
2812
- const adverse =
2813
- findings.length > 0 ||
2814
- signals.some((signal) =>
2815
- /\b(error|failed|failures?|timeouts?|nxdomain|servfail)\b/i.test(
2816
- signal,
2817
- ),
2818
- );
2819
- builder.observe(`dns:${scopeFromArgs(source)}`, "dns", source, {
2820
- tier: evidenceTierForRelevance(
2821
- adverse ? "supporting" : "context",
2822
- relatedRelevance,
2823
- ),
2824
- relevance: relatedRelevance,
2825
- tone: adverse ? "warning" : "info",
2826
- title: adverse ? "DNS failure signals" : "DNS configuration",
2827
- summary:
2828
- signals[0] ||
2829
- `${findings.length} CoreDNS finding${findings.length === 1 ? "" : "s"}`,
2830
- data: { type: "dns", dns: dns as unknown as DiagnosisDNSContext },
2831
- });
2832
- }
2833
- } else if (value.dnsContext !== undefined) {
2834
- invalidPayload(builder, source, "DNS context");
2835
- }
2836
- }
2837
-
2838
- function adaptIssues(
2839
- builder: ProjectionBuilder,
2840
- source: InvestigationEvidenceSource,
2841
- payload: unknown,
2842
- ): void {
2843
- const value = record(payload);
2844
- const raw = value?.issues;
2845
- if (
2846
- !value ||
2847
- !Array.isArray(raw) ||
2848
- typeof value.total !== "number" ||
2849
- typeof value.total_matched !== "number"
2850
- ) {
2851
- invalidPayload(builder, source);
2852
- return;
2853
- }
2854
- const issues = raw.map(issue).filter((item): item is Issue => Boolean(item));
2855
- if (issues.length !== raw.length) {
2856
- invalidPayload(builder, source);
2857
- return;
2858
- }
2859
- addNarrowHint(builder, source, value);
2860
- if (
2861
- value.total_matched > issues.length &&
2862
- !nonEmptyString(value.narrowHint)
2863
- ) {
2864
- builder.limit(
2865
- source,
2866
- "Issues",
2867
- `The query returned ${issues.length} of ${value.total_matched} matching issues.`,
2868
- "truncated",
2869
- );
2870
- }
2871
- if (typeof value.filter_errors === "number" && value.filter_errors > 0) {
2872
- builder.limit(
2873
- source,
2874
- "Issues filter",
2875
- nonEmptyString(value.filter_error_sample)
2876
- ? value.filter_error_sample
2877
- : `${value.filter_errors} issue rows could not be evaluated by the filter.`,
2878
- "error",
2879
- );
2880
- }
2881
- if (value.recent_changes_truncated === true) {
2882
- builder.limit(
2883
- source,
2884
- "Issue-related changes",
2885
- "The issue response omitted some recent changes.",
2886
- "truncated",
2887
- );
2888
- }
2889
- if (value.correlation_truncated === true) {
2890
- builder.limit(
2891
- source,
2892
- "Issue change correlation",
2893
- "Change correlation was not evaluated for every returned issue.",
2894
- "truncated",
2895
- );
2896
- }
2897
- const visibility = record(value.visibility);
2898
- if (
2899
- visibility &&
2900
- nonEmptyString(visibility.state) &&
2901
- visibility.state !== "ok"
2902
- ) {
2903
- builder.limit(
2904
- source,
2905
- "Issue visibility",
2906
- nonEmptyString(visibility.impact)
2907
- ? visibility.impact
2908
- : `Radar reported ${visibility.state} visibility for this issue query.`,
2909
- "unknown",
2910
- );
2911
- }
2912
- const issueChangesRaw = value.recent_changes;
2913
- if (Array.isArray(issueChangesRaw)) {
2914
- const changes = issueChangesRaw
2915
- .map(recentChange)
2916
- .filter((item): item is IssueRecentChange => Boolean(item));
2917
- if (changes.length === issueChangesRaw.length) {
2918
- addChanges(
2919
- builder,
2920
- source,
2921
- changes,
2922
- `changes:issues:${source.args ?? scopeFromArgs(source)}`,
2923
- undefined,
2924
- value.recent_changes_truncated !== true,
2925
- );
2926
- } else {
2927
- invalidPayload(builder, source, "Issue-related changes");
2928
- }
2929
- } else if (issueChangesRaw !== undefined) {
2930
- invalidPayload(builder, source, "Issue-related changes");
2931
- }
2932
- const clusterDNS = record(record(value.cluster_context)?.dns);
2933
- if (clusterDNS) {
2934
- const signals = stringArray(clusterDNS.signals) ?? [];
2935
- const findings = Array.isArray(clusterDNS.findings)
2936
- ? clusterDNS.findings
2937
- : [];
2938
- if (signals.length > 0 || findings.length > 0) {
2939
- builder.observe(`dns:issues:${scopeFromArgs(source)}`, "dns", source, {
2940
- tier: "context",
2941
- relevance: "broader",
2942
- tone: "warning",
2943
- title: "Cluster DNS signals",
2944
- summary:
2945
- signals[0] ||
2946
- `${findings.length} CoreDNS finding${findings.length === 1 ? "" : "s"}`,
2947
- data: {
2948
- type: "dns",
2949
- dns: {
2950
- signals,
2951
- coreDNSFindings: findings as DiagnosisDNSContext["coreDNSFindings"],
2952
- },
2953
- },
2954
- });
2955
- }
2956
- }
2957
- if (issues.length === 0) {
2958
- if (!source.confirmedSuccess || builder.limitedSources.has(source.id))
2959
- return;
2960
- const args = record(source.args ? parseJSON(source.args) : undefined);
2961
- if (!nonEmptyString(args?.namespace)) {
2962
- builder.limit(
2963
- source,
2964
- "Issues",
2965
- "Radar found no matching issues, but the search covered only namespaces the current user can access.",
2966
- "unknown",
2967
- );
2968
- return;
2969
- }
2970
- const scope = scopeFromArgs(source);
2971
- const relevance = sourceArgsRelevance(builder, source);
2972
- builder.observe(`issues:${source.args ?? scope}`, "receipt", source, {
2973
- tier: evidenceTierForRelevance("checked", relevance),
2974
- relevance,
2975
- tone: "neutral",
2976
- title: "No matching live issues",
2977
- summary: scope,
2978
- data: {
2979
- type: "receipt",
2980
- checked: "issues",
2981
- scope,
2982
- message:
2983
- "Radar's live-issue query completed and returned no matching issues.",
2984
- },
2985
- });
2986
- } else {
2987
- for (const item of issues) addIssueObservation(builder, source, item);
2988
- }
2989
- }
2990
-
2991
- function adaptGetResource(
2992
- builder: ProjectionBuilder,
2993
- source: InvestigationEvidenceSource,
2994
- payload: unknown,
2995
- ): void {
2996
- // Current producer modes are discriminated by the resource's own identity,
2997
- // never by guessing an undocumented legacy wrapper:
2998
- // 1. bare Kubernetes resource
2999
- // 2. {resource, resourceContext, warnings}
3000
- // 3. the same wrapper with requested extras.
3001
- // Core Secrets use the producer's deliberately value-free detail shape,
3002
- // normalized by kubernetesResource above.
3003
- const bare = kubernetesResource(payload);
3004
- const wrapper = record(payload);
3005
- const resource = bare ?? kubernetesResource(wrapper?.resource);
3006
- if (!resource) {
3007
- invalidPayload(builder, source);
3008
- return;
3009
- }
3010
- const value = bare ? undefined : wrapper;
3011
- const context = value ? contextFrom(value.resourceContext) : undefined;
3012
- if (value?.resourceContext !== undefined && !context) {
3013
- invalidPayload(builder, source, "Resource context");
3014
- }
3015
- const warnings = stringArray(value?.warnings) ?? [];
3016
- const relevance = relevanceForResource(builder, {
3017
- kind: resource.kind,
3018
- group: apiVersionToGroup(resource.apiVersion),
3019
- namespace: resource.metadata.namespace,
3020
- name: resource.metadata.name,
3021
- });
3022
- addResourceObservation(
3023
- builder,
3024
- source,
3025
- resource,
3026
- context,
3027
- warnings,
3028
- undefined,
3029
- false,
3030
- relevance,
3031
- );
3032
- if (!value) return;
3033
- addNarrowHint(builder, source, value);
3034
-
3035
- const errors: Array<[string, string]> = [
3036
- ["eventsError", "Events"],
3037
- ["recentChangesError", "Recent changes"],
3038
- ["metricsError", "Metrics"],
3039
- ["revisionsError", "Revisions"],
3040
- ["includeError", "Requested include"],
3041
- ];
3042
- for (const [field, label] of errors) {
3043
- if (nonEmptyString(value[field])) {
3044
- builder.limit(source, label, value[field] as string, "error");
3045
- }
3046
- }
3047
-
3048
- if (Array.isArray(value.events)) {
3049
- const events = value.events
3050
- .map(event)
3051
- .filter((item): item is InvestigationEventEvidence => Boolean(item));
3052
- if (events.length === value.events.length) {
3053
- addEvents(
3054
- builder,
3055
- source,
3056
- events,
3057
- `events:get-resource:${scopeFromArgs(source)}`,
3058
- (typeof value.eventsTotalGroups !== "number" ||
3059
- value.eventsTotalGroups <= events.length) &&
3060
- !nonEmptyString(value.eventsError),
3061
- true,
3062
- relevance,
3063
- );
3064
- if (
3065
- typeof value.eventsTotalGroups === "number" &&
3066
- value.eventsTotalGroups > events.length
3067
- ) {
3068
- builder.limit(
3069
- source,
3070
- "Events",
3071
- `Radar received ${events.length} of ${value.eventsTotalGroups} event groups.`,
3072
- "truncated",
3073
- );
3074
- }
3075
- } else {
3076
- invalidPayload(builder, source, "Events");
3077
- }
3078
- }
3079
- const resourceChangesSubject = {
3080
- kind: resource.kind,
3081
- namespace: resource.metadata.namespace,
3082
- name: resource.metadata.name,
3083
- };
3084
- const recentChangesRaw = value.recentChanges;
3085
- const recentChangesSaturatedRaw = value.recentChangesSaturated;
3086
- const recentChangesCoverageLimitedRaw = value.recentChangesCoverageLimited;
3087
- const hasRecentChangesResult =
3088
- recentChangesRaw !== undefined ||
3089
- recentChangesSaturatedRaw !== undefined ||
3090
- recentChangesCoverageLimitedRaw !== undefined;
3091
- const recentChangesMetadataValid =
3092
- typeof recentChangesSaturatedRaw === "boolean" &&
3093
- typeof recentChangesCoverageLimitedRaw === "boolean";
3094
- if (hasRecentChangesResult && !recentChangesMetadataValid) {
3095
- invalidPayload(builder, source, "Recent changes");
3096
- }
3097
- if (Array.isArray(recentChangesRaw)) {
3098
- const changes = recentChangesRaw
3099
- .map(recentChange)
3100
- .filter((item): item is IssueRecentChange => Boolean(item));
3101
- if (changes.length === recentChangesRaw.length) {
3102
- addChanges(
3103
- builder,
3104
- source,
3105
- changes,
3106
- `changes:get-resource:${scopeFromArgs(source)}`,
3107
- undefined,
3108
- recentChangesMetadataValid &&
3109
- recentChangesSaturatedRaw === false &&
3110
- recentChangesCoverageLimitedRaw === false &&
3111
- !nonEmptyString(value.recentChangesError),
3112
- true,
3113
- relevance,
3114
- resourceChangesSubject,
3115
- );
3116
- } else {
3117
- invalidPayload(builder, source, "Recent changes");
3118
- }
3119
- } else if (
3120
- recentChangesRaw === undefined &&
3121
- recentChangesMetadataValid &&
3122
- !nonEmptyString(value.recentChangesError)
3123
- ) {
3124
- addChanges(
3125
- builder,
3126
- source,
3127
- [],
3128
- `changes:get-resource:${scopeFromArgs(source)}`,
3129
- undefined,
3130
- recentChangesSaturatedRaw === false &&
3131
- recentChangesCoverageLimitedRaw === false,
3132
- true,
3133
- relevance,
3134
- resourceChangesSubject,
3135
- );
3136
- } else if (recentChangesRaw !== undefined) {
3137
- invalidPayload(builder, source, "Recent changes");
3138
- }
3139
- if (recentChangesSaturatedRaw === true) {
3140
- builder.limit(
3141
- source,
3142
- "Recent changes",
3143
- "The recent-change result limit was reached; additional changes may exist in the requested window.",
3144
- "truncated",
3145
- );
3146
- }
3147
- if (recentChangesCoverageLimitedRaw === true) {
3148
- builder.limit(
3149
- source,
3150
- "Recent changes",
3151
- `Change history for ${scopeFromArgs(source)} is incomplete.`,
3152
- "unknown",
3153
- "history",
3154
- );
3155
- }
3156
- }
3157
-
3158
- function adaptListResources(
3159
- builder: ProjectionBuilder,
3160
- source: InvestigationEvidenceSource,
3161
- payload: unknown,
3162
- ): void {
3163
- if (!Array.isArray(payload)) {
3164
- invalidPayload(builder, source);
3165
- return;
3166
- }
3167
- const resources = payload
3168
- .map(resourceSummary)
3169
- .filter((item): item is InvestigationResourceSummary => Boolean(item));
3170
- if (resources.length !== payload.length) {
3171
- invalidPayload(builder, source);
3172
- return;
3173
- }
3174
- const scope = scopeFromArgs(source);
3175
- const args = record(parseJSON(source.args ?? ""));
3176
- const kinds = [...new Set(resources.map((resource) => resource.kind))];
3177
- const noun =
3178
- kinds.length === 1
3179
- ? kindToPlural(kinds[0]) === kinds[0].toLowerCase()
3180
- ? displayKind(kinds[0])
3181
- : englishPlural(displayKind(kinds[0]))
3182
- : "Resources";
3183
- const namespace = nonEmptyString(args?.namespace)
3184
- ? args.namespace
3185
- : undefined;
3186
- const title = namespace ? `${noun} in ${namespace}` : noun;
3187
- if (resources.length === 0) {
3188
- // list_resources intentionally returns [] for some RBAC-filtered reads;
3189
- // even a successful transport outcome therefore cannot prove absence.
3190
- builder.limit(
3191
- source,
3192
- "Resource inventory",
3193
- `Radar found no matching resources for ${scope}, but access restrictions may have hidden some results.`,
3194
- "unknown",
3195
- );
3196
- return;
3197
- }
3198
- const hasAdverseResource = resources.some((resource) => {
3199
- const health = resource.summaryContext?.health?.toLowerCase();
3200
- return (
3201
- health === "unhealthy" ||
3202
- health === "degraded" ||
3203
- (resource.summaryContext?.issueCount ?? 0) > 0
3204
- );
3205
- });
3206
- builder.observe(`inventory:${source.args ?? scope}`, "inventory", source, {
3207
- tier: "context",
3208
- relevance: "broader",
3209
- tone: hasAdverseResource ? "warning" : "neutral",
3210
- title,
3211
- summary: `${resources.length} returned${nonEmptyString(args?.group) ? ` · ${args.group}` : ""}`,
3212
- data: { type: "inventory", resources, scope },
3213
- });
3214
- }
3215
-
3216
- function adaptEvents(
3217
- builder: ProjectionBuilder,
3218
- source: InvestigationEvidenceSource,
3219
- payload: unknown,
3220
- ): void {
3221
- const value = record(payload);
3222
- // The producer serializes an empty result as a nil slice, which is null.
3223
- const eventsRaw = value?.events === null ? [] : value?.events;
3224
- if (!value || !Array.isArray(eventsRaw)) {
3225
- invalidPayload(builder, source);
3226
- return;
3227
- }
3228
- const events = eventsRaw
3229
- .map(event)
3230
- .filter((item): item is InvestigationEventEvidence => Boolean(item));
3231
- if (events.length !== eventsRaw.length) {
3232
- invalidPayload(builder, source);
3233
- return;
3234
- }
3235
- addNarrowHint(builder, source, value);
3236
- // The producer answers a namespace the caller cannot read with an empty
3237
- // list and marks it. Only that case is a coverage gap. Any other complete,
3238
- // successful empty read answers the question it asked and is filed as a
3239
- // checked receipt; a gap there would contradict an events card from another
3240
- // call in the same turn. The receipt names the one remaining ambiguity for
3241
- // producers that predate the marker.
3242
- if (value.accessDenied === true) {
3243
- builder.limit(
3244
- source,
3245
- "Events",
3246
- `Events in ${scopeFromArgs(source)} are not readable with your permissions.`,
3247
- "error",
3248
- );
3249
- return;
3250
- }
3251
- addEvents(
3252
- builder,
3253
- source,
3254
- events,
3255
- `events:${source.args ?? scopeFromArgs(source)}`,
3256
- !nonEmptyString(value.narrowHint),
3257
- true,
3258
- 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
- },
3264
- );
3265
- }
3266
-
3267
- function adaptPodLogs(
3268
- builder: ProjectionBuilder,
3269
- source: InvestigationEvidenceSource,
3270
- payload: unknown,
3271
- ): void {
3272
- const value = record(payload);
3273
- const logs = filteredLogs(payload) ?? filteredLogs(value);
3274
- if (!logs) {
3275
- invalidPayload(builder, source);
3276
- return;
3277
- }
3278
- if (value) addNarrowHint(builder, source, value);
3279
- const warnings = stringArray(value?.warnings) ?? [];
3280
- const args = record(source.args ? parseJSON(source.args) : undefined);
3281
- const pod = nonEmptyString(args?.name) ? args.name : "Pod";
3282
- const container = nonEmptyString(args?.container)
3283
- ? args.container
3284
- : "default container";
3285
- addLogs(
3286
- builder,
3287
- source,
3288
- { pod, container, logs },
3289
- previousFromArgs(source),
3290
- warnings,
3291
- sourceArgsRelevance(builder, source, "Pod"),
3292
- nonEmptyString(args?.namespace) ? args.namespace : undefined,
3293
- );
3294
- }
3295
-
3296
- function adaptChanges(
3297
- builder: ProjectionBuilder,
3298
- source: InvestigationEvidenceSource,
3299
- payload: unknown,
3300
- ): void {
3301
- const value = record(payload);
3302
- // The producer serializes an empty result as a nil slice, which is null.
3303
- const changesRaw = value?.changes === null ? [] : value?.changes;
3304
- if (!value || !Array.isArray(changesRaw)) {
3305
- invalidPayload(builder, source, "Recent changes");
3306
- return;
3307
- }
3308
- const changes = changesRaw
3309
- .map(recentChange)
3310
- .filter((item): item is IssueRecentChange => Boolean(item));
3311
- if (changes.length !== changesRaw.length) {
3312
- invalidPayload(builder, source, "Recent changes");
3313
- return;
3314
- }
3315
- addNarrowHint(builder, source, value);
3316
- let sourceErrors = 0;
3317
- if (Array.isArray(value.sourcesErrored)) {
3318
- for (const sourceError of value.sourcesErrored) {
3319
- if (nonEmptyString(sourceError)) {
3320
- sourceErrors += 1;
3321
- builder.limit(source, "Recent changes", sourceError, "error");
3322
- }
3323
- }
3324
- } else if (value.sourcesErrored !== undefined) {
3325
- invalidPayload(builder, source, "Recent changes source coverage");
3326
- }
3327
- // Reads of one resource's history are revisions of one card however the
3328
- // window or cap differs; the window is stated in the title instead.
3329
- const args = record(source.args ? parseJSON(source.args) : undefined);
3330
- addChanges(
3331
- builder,
3332
- source,
3333
- changes,
3334
- `changes:${scopeFromArgs(source)}`,
3335
- undefined,
3336
- !nonEmptyString(value.narrowHint) && sourceErrors === 0,
3337
- false,
3338
- sourceArgsRelevance(builder, source),
3339
- changesSubjectFromArgs(source),
3340
- nonEmptyString(args?.since) ? args.since : undefined,
3341
- );
3342
- }
3343
-
3344
- function adaptNeighborhood(
3345
- builder: ProjectionBuilder,
3346
- source: InvestigationEvidenceSource,
3347
- payload: unknown,
3348
- ): void {
3349
- const value = record(payload);
3350
- const root = resourceRef(value?.root);
3351
- const subgraph = record(value?.subgraph);
3352
- if (
3353
- !value ||
3354
- !root ||
3355
- !subgraph ||
3356
- !Array.isArray(subgraph.nodes) ||
3357
- !Array.isArray(subgraph.edges) ||
3358
- typeof value.truncated !== "boolean"
3359
- ) {
3360
- invalidPayload(builder, source);
3361
- return;
3362
- }
3363
- const nodes = subgraph.nodes
3364
- .map(topologyNode)
3365
- .filter((item): item is InvestigationTopologyNode => Boolean(item));
3366
- const edges = subgraph.edges
3367
- .map(topologyEdge)
3368
- .filter((item): item is InvestigationTopologyEdge => Boolean(item));
3369
- if (
3370
- nodes.length !== subgraph.nodes.length ||
3371
- edges.length !== subgraph.edges.length
3372
- ) {
3373
- invalidPayload(builder, source);
3374
- return;
3375
- }
3376
- addNarrowHint(builder, source, value);
3377
- if (value.truncated === true && !nonEmptyString(value.narrowHint)) {
3378
- builder.limit(
3379
- source,
3380
- "Relationships",
3381
- "The relationship view reached its resource limit and may be incomplete.",
3382
- "truncated",
3383
- );
3384
- }
3385
- for (const raw of Array.isArray(value.omitted) ? value.omitted : []) {
3386
- const omitted = record(raw);
3387
- if (
3388
- !omitted ||
3389
- !nonEmptyString(omitted.field) ||
3390
- !nonEmptyString(omitted.reason)
3391
- )
3392
- continue;
3393
- builder.limit(
3394
- source,
3395
- omitted.field,
3396
- `Relationship context omitted: ${omitted.reason.replaceAll("_", " ")}.`,
3397
- omitted.reason === "budget_exceeded" ? "truncated" : "unknown",
3398
- );
3399
- }
3400
- builder.observe(
3401
- `relationships:${root.group ?? ""}:${root.kind}:${root.namespace ?? ""}:${root.name}`,
3402
- "relationships",
3403
- source,
3404
- {
3405
- tier: "context",
3406
- relevance: relevanceForResource(builder, {
3407
- kind: root.kind,
3408
- group: root.group ?? "",
3409
- namespace: root.namespace,
3410
- name: root.name,
3411
- }),
3412
- tone: "info",
3413
- title: `Relationships around ${root.kind} ${root.name}`,
3414
- summary: `${nodes.length} nodes · ${edges.length} relationships`,
3415
- data: {
3416
- type: "relationships",
3417
- root,
3418
- nodes,
3419
- edges,
3420
- truncated: value.truncated,
3421
- },
3422
- },
3423
- );
3424
- }
3425
-
3426
- function adaptTopology(
3427
- builder: ProjectionBuilder,
3428
- source: InvestigationEvidenceSource,
3429
- payload: unknown,
3430
- ): void {
3431
- const value = record(payload);
3432
- if (!value) {
3433
- invalidPayload(builder, source);
3434
- return;
3435
- }
3436
- const partiality = topologyPartiality(value);
3437
- if (!partiality) {
3438
- invalidPayload(builder, source, "Topology coverage metadata");
3439
- return;
3440
- }
3441
- const stats = record(value.stats);
3442
- if (
3443
- stats &&
3444
- typeof stats.nodes === "number" &&
3445
- typeof stats.edges === "number" &&
3446
- Array.isArray(value.namespaces)
3447
- ) {
3448
- const namespaces = value.namespaces.flatMap((raw) => {
3449
- const namespace = record(raw);
3450
- const chains = stringArray(namespace?.chains);
3451
- return namespace && nonEmptyString(namespace.namespace) && chains
3452
- ? [{ namespace: namespace.namespace, chains }]
3453
- : [];
3454
- });
3455
- if (namespaces.length !== value.namespaces.length) {
3456
- invalidPayload(builder, source);
3457
- return;
3458
- }
3459
- const problems =
3460
- value.problems === undefined ? [] : stringArray(value.problems);
3461
- if (!problems) {
3462
- invalidPayload(builder, source, "Topology problems");
3463
- return;
3464
- }
3465
- addTopologyLimitations(builder, source, partiality);
3466
- builder.observe(
3467
- `topology:${source.args ?? scopeFromArgs(source)}`,
3468
- "topology",
3469
- source,
3470
- {
3471
- tier: "context",
3472
- relevance: "broader",
3473
- tone: problems.length > 0 ? "warning" : "info",
3474
- title: "Resource topology",
3475
- summary: `${stats.nodes} nodes · ${stats.edges} relationships`,
3476
- data: {
3477
- type: "topology",
3478
- stats: { nodes: stats.nodes, edges: stats.edges },
3479
- namespaces,
3480
- problems,
3481
- warnings: partiality.warnings,
3482
- },
3483
- },
3484
- );
3485
- return;
3486
- }
3487
-
3488
- if (!Array.isArray(value.nodes) || !Array.isArray(value.edges)) {
3489
- invalidPayload(builder, source);
3490
- return;
3491
- }
3492
- const nodes = value.nodes
3493
- .map(topologyNode)
3494
- .filter((item): item is InvestigationTopologyNode => Boolean(item));
3495
- const edges = value.edges
3496
- .map(topologyEdge)
3497
- .filter((item): item is InvestigationTopologyEdge => Boolean(item));
3498
- if (
3499
- nodes.length !== value.nodes.length ||
3500
- edges.length !== value.edges.length
3501
- ) {
3502
- invalidPayload(builder, source);
3503
- return;
3504
- }
3505
- const problems = nodes
3506
- .filter((node) => node.status === "unhealthy" || node.status === "degraded")
3507
- .map((node) => `${node.kind} ${node.name}: ${node.status}`);
3508
- addTopologyLimitations(builder, source, partiality);
3509
- builder.observe(
3510
- `topology:${source.args ?? scopeFromArgs(source)}`,
3511
- "topology",
3512
- source,
3513
- {
3514
- tier: "context",
3515
- relevance: "broader",
3516
- tone: problems.length > 0 ? "warning" : "info",
3517
- title: "Resource topology",
3518
- summary: `${nodes.length} nodes · ${edges.length} relationships`,
3519
- data: {
3520
- type: "topology",
3521
- stats: { nodes: nodes.length, edges: edges.length },
3522
- namespaces: [],
3523
- problems,
3524
- warnings: partiality.warnings,
3525
- },
3526
- },
3527
- );
3528
- }
3529
-
3530
- const WORKLOAD_LOG_KINDS: Readonly<Record<string, string>> = {
3531
- deployments: "Deployment",
3532
- statefulsets: "StatefulSet",
3533
- daemonsets: "DaemonSet",
3534
- rollouts: "Rollout",
3535
- jobs: "Job",
3536
- workflows: "Workflow",
3537
- };
3538
-
3539
- /** `get_workload_logs` states what it read as `<plural>/<namespace>/<name>`. */
3540
- function workloadLogsSubject(
3541
- value: string,
3542
- ): { kind: string; namespace: string; name: string } | undefined {
3543
- const parts = value.split("/");
3544
- if (parts.length !== 3) return undefined;
3545
- const kind = WORKLOAD_LOG_KINDS[parts[0]];
3546
- if (!kind || !parts[1] || !parts[2]) return undefined;
3547
- return { kind, namespace: parts[1], name: parts[2] };
3548
- }
3549
-
3550
- function adaptWorkloadLogs(
3551
- builder: ProjectionBuilder,
3552
- source: InvestigationEvidenceSource,
3553
- payload: unknown,
3554
- ): void {
3555
- const value = record(payload);
3556
- const workload = nonEmptyString(value?.workload)
3557
- ? workloadLogsSubject(value.workload)
3558
- : undefined;
3559
- if (!value || !workload || !nonNegativeInteger(value.pods)) {
3560
- invalidPayload(builder, source);
3561
- return;
3562
- }
3563
- // The producer names the workload without an API group; as with other
3564
- // group-less producers, the investigation target supplies that dimension.
3565
- const workloadRelevance = relevanceForResource(builder, {
3566
- ...workload,
3567
- group: builder.target.group,
3568
- });
3569
- const scope = `${displayKind(workload.kind)} ${workload.namespace}/${workload.name}`;
3570
- const previous = previousFromArgs(source);
3571
- addNarrowHint(builder, source, value);
3572
- if (nonEmptyString(value.logsError)) {
3573
- builder.limit(source, "Workload logs", value.logsError, "error");
3574
- return;
3575
- }
3576
- if (value.pods === 0) {
3577
- // The producer replaces the stream list with a message when it resolved
3578
- // no pods; any other shape is not this response.
3579
- if (
3580
- typeof value.logs !== "string" ||
3581
- (value.emptyMessage !== undefined &&
3582
- typeof value.emptyMessage !== "string") ||
3583
- value.narrowHint !== undefined
3584
- ) {
3585
- invalidPayload(builder, source);
3586
- return;
3587
- }
3588
- if (!source.confirmedSuccess) return;
3589
- const message = nonEmptyString(value.emptyMessage)
3590
- ? value.emptyMessage
3591
- : nonEmptyString(value.logs)
3592
- ? value.logs
3593
- : "The workload resolved no pods, so there were no log streams to read.";
3594
- builder.observe(
3595
- `workload-logs:${previous ? "previous" : "current"}:${scope}`,
3596
- "receipt",
3597
- source,
3598
- {
3599
- tier: evidenceTierForRelevance("checked", workloadRelevance),
3600
- relevance: workloadRelevance,
3601
- tone: "neutral",
3602
- title: "No pods to read logs from",
3603
- summary: scope,
3604
- data: { type: "receipt", checked: "logs", scope, message },
3605
- },
3606
- );
3607
- return;
3608
- }
3609
- 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.`;
3611
- if (!Array.isArray(logsRaw)) {
3612
- if (logsRaw === undefined || logsRaw === null) {
3613
- builder.limit(source, "Workload logs", noStreams, "unknown");
3614
- } else {
3615
- invalidPayload(builder, source);
3616
- }
3617
- return;
3618
- }
3619
- const warnings = stringArray(value.warnings) ?? [];
3620
- for (const raw of logsRaw) {
3621
- const entry = parseLogEntry(raw);
3622
- if (!entry) {
3623
- invalidPayload(
3624
- builder,
3625
- source,
3626
- previous ? "Previous logs" : "Current logs",
3627
- );
3628
- continue;
3629
- }
3630
- // A Pod target is named exactly by its own row; a workload target relates
3631
- // to its rows the way diagnose relates to the pods it resolved.
3632
- const rowRelevance: InvestigationEvidenceRelevance = resourceMatchesTarget(
3633
- builder.target,
3634
- {
3635
- kind: "Pod",
3636
- group: "",
3637
- namespace: workload.namespace,
3638
- name: entry.pod,
3639
- },
3640
- )
3641
- ? "target"
3642
- : workloadRelevance === "target"
3643
- ? "producer-related"
3644
- : "broader";
3645
- addLogs(
3646
- builder,
3647
- source,
3648
- entry,
3649
- previous,
3650
- warnings,
3651
- rowRelevance,
3652
- workload.namespace,
3653
- );
3654
- }
3655
- if (logsRaw.length === 0) {
3656
- builder.limit(source, "Workload logs", noStreams, "unknown");
3657
- }
3658
- }
3659
-
3660
- function stringRecord(value: unknown): Record<string, string> | undefined {
3661
- const candidate = record(value);
3662
- if (!candidate) return undefined;
3663
- return Object.values(candidate).every((item) => typeof item === "string")
3664
- ? (candidate as Record<string, string>)
3665
- : undefined;
3666
- }
3667
-
3668
- /**
3669
- * Pods a producer has already tied to the investigated workload in this
3670
- * projection: the streams diagnose or a workload-logs read resolved for it,
3671
- * its crash candidates, and its Pod startup blockers. A pod name matched
3672
- * against this set is producer-established membership, never inference from
3673
- * a name prefix, which a sibling such as `api-worker` would also satisfy.
3674
- */
3675
- function producerEstablishedTargetPods(
3676
- builder: ProjectionBuilder,
3677
- ): Set<string> {
3678
- const pods = new Set<string>();
3679
- const namespace = builder.target.namespace;
3680
- if (!namespace) return pods;
3681
- for (const group of builder.groups) {
3682
- for (const observation of group.observations) {
3683
- if (observation.relevance === "broader") continue;
3684
- const data = observation.data;
3685
- if (data.type === "logs" && data.namespace === namespace) {
3686
- pods.add(data.pod);
3687
- } else if (data.type === "crash" && data.namespace === namespace) {
3688
- 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);
3695
- }
3696
- }
3697
- }
3698
- return pods;
3699
- }
3700
-
3701
- // kube-state-metrics label names for the owning workload.
3702
- const WORKLOAD_LABEL_BY_KIND: Readonly<Record<string, string>> = {
3703
- deployment: "deployment",
3704
- statefulset: "statefulset",
3705
- daemonset: "daemonset",
3706
- rollout: "rollout",
3707
- job: "job_name",
3708
- cronjob: "cronjob",
3709
- };
3710
-
3711
- /**
3712
- * Whether a Prometheus label set names the investigated resource: the target
3713
- * namespace plus a label identifying it directly, or a `pod` label naming a
3714
- * pod a producer already tied to it.
3715
- */
3716
- function labelsNameTarget(
3717
- target: InvestigationEvidenceTarget,
3718
- labels: Record<string, string>,
3719
- targetPods: ReadonlySet<string>,
3720
- ): boolean {
3721
- if (!target.namespace || labels.namespace !== target.namespace) return false;
3722
- const kind = target.kind.toLowerCase();
3723
- if (kind === "pod") return labels.pod === target.name;
3724
- const workloadLabel = WORKLOAD_LABEL_BY_KIND[kind];
3725
- if (workloadLabel && labels[workloadLabel] === target.name) return true;
3726
- if (
3727
- labels.workload === target.name &&
3728
- (labels.workload_type === undefined ||
3729
- labels.workload_type.toLowerCase() === kind)
3730
- ) {
3731
- return true;
3732
- }
3733
- return labels.pod !== undefined && targetPods.has(labels.pod);
3734
- }
3735
-
3736
- /**
3737
- * The label set places the instance in another namespace outright. Only a
3738
- * namespaced target can be excluded this way; for a cluster-scoped target
3739
- * every namespace label is "different" and proves nothing.
3740
- */
3741
- function labelsPlaceElsewhere(
3742
- target: InvestigationEvidenceTarget,
3743
- labels: Record<string, string>,
3744
- ): boolean {
3745
- return (
3746
- Boolean(target.namespace) &&
3747
- nonEmptyString(labels.namespace) &&
3748
- labels.namespace !== target.namespace
3749
- );
3750
- }
3751
-
3752
- function alertRule(value: unknown):
3753
- | {
3754
- rule: InvestigationAlertRule;
3755
- instances: Omit<InvestigationAlertInstance, "namesTarget">[];
3756
- annotations: Record<string, string>;
3757
- }
3758
- | undefined {
3759
- const candidate = record(value);
3760
- if (
3761
- !candidate ||
3762
- !nonEmptyString(candidate.group) ||
3763
- !nonEmptyString(candidate.name) ||
3764
- !nonEmptyString(candidate.type) ||
3765
- typeof candidate.query !== "string"
3766
- ) {
3767
- return undefined;
3768
- }
3769
- for (const field of ["state", "health"] as const) {
3770
- if (candidate[field] !== undefined && typeof candidate[field] !== "string")
3771
- return undefined;
3772
- }
3773
- const labels =
3774
- candidate.labels === undefined ? {} : stringRecord(candidate.labels);
3775
- const annotations =
3776
- candidate.annotations === undefined
3777
- ? {}
3778
- : stringRecord(candidate.annotations);
3779
- if (!labels || !annotations) return undefined;
3780
- const alertsRaw = candidate.alerts === undefined ? [] : candidate.alerts;
3781
- if (!Array.isArray(alertsRaw)) return undefined;
3782
- const instances: Omit<InvestigationAlertInstance, "namesTarget">[] = [];
3783
- for (const raw of alertsRaw) {
3784
- const instance = record(raw);
3785
- const instanceLabels =
3786
- instance?.labels === undefined ? {} : stringRecord(instance.labels);
3787
- if (
3788
- !instance ||
3789
- !nonEmptyString(instance.state) ||
3790
- !instanceLabels ||
3791
- (instance.activeAt !== undefined &&
3792
- typeof instance.activeAt !== "string") ||
3793
- (instance.value !== undefined && typeof instance.value !== "string")
3794
- ) {
3795
- return undefined;
3796
- }
3797
- instances.push({
3798
- state: instance.state,
3799
- activeAt: instance.activeAt as string | undefined,
3800
- value: instance.value as string | undefined,
3801
- labels: instanceLabels,
3802
- });
3803
- }
3804
- return {
3805
- rule: {
3806
- group: candidate.group,
3807
- name: candidate.name,
3808
- type: candidate.type,
3809
- state: candidate.state as string | undefined,
3810
- health: candidate.health as string | undefined,
3811
- query: candidate.query,
3812
- labels,
3813
- },
3814
- instances,
3815
- annotations,
3816
- };
3817
- }
3818
-
3819
- function targetIdentity(target: InvestigationEvidenceTarget): string {
3820
- return `${displayKind(target.kind)} ${target.namespace ? `${target.namespace}/` : ""}${target.name}`;
3821
- }
3822
-
3823
- function adaptPrometheusRules(
3824
- builder: ProjectionBuilder,
3825
- source: InvestigationEvidenceSource,
3826
- payload: unknown,
3827
- ): void {
3828
- const value = record(payload);
3829
- if (
3830
- !value ||
3831
- !Array.isArray(value.rules) ||
3832
- typeof value.count !== "number" ||
3833
- value.count !== value.rules.length ||
3834
- (value.truncated !== undefined && typeof value.truncated !== "boolean") ||
3835
- (value.note !== undefined && typeof value.note !== "string")
3836
- ) {
3837
- invalidPayload(builder, source);
3838
- return;
3839
- }
3840
- const rules = value.rules
3841
- .map(alertRule)
3842
- .filter((item): item is NonNullable<typeof item> => Boolean(item));
3843
- if (rules.length !== value.rules.length) {
3844
- invalidPayload(builder, source);
3845
- return;
3846
- }
3847
- const args = record(source.args ? parseJSON(source.args) : undefined);
3848
- const stateFilter = nonEmptyString(args?.state)
3849
- ? args.state.toLowerCase()
3850
- : undefined;
3851
- const typeFilter = nonEmptyString(args?.type)
3852
- ? args.type.toLowerCase()
3853
- : undefined;
3854
- const truncated = value.truncated === true;
3855
- if (truncated) {
3856
- builder.limit(
3857
- source,
3858
- "Alert rules",
3859
- `The rule list was capped, so additional matching rules may exist${nonEmptyString(value.note) ? `; ${value.note}` : ""}.`,
3860
- "truncated",
3861
- );
3862
- }
3863
- const identity = targetIdentity(builder.target);
3864
- const targetPods = producerEstablishedTargetPods(builder);
3865
- // Rule names are not unique within a group, and group names are unique
3866
- // only per rule file, which the producer does not return. The expression
3867
- // and static labels distinguish rules; identical rows within one response
3868
- // cannot be told apart at all, so they stay scoped to this read rather
3869
- // than inherit each other's history in whatever order they arrive.
3870
- const ruleDefinition = (rule: InvestigationAlertRule) =>
3871
- `alerts:${rule.group}:${rule.name}:${stableHash(
3872
- JSON.stringify([rule.query, Object.entries(rule.labels).sort()]),
3873
- )}`;
3874
- const definitionCounts = new Map<string, number>();
3875
- for (const { rule } of rules) {
3876
- const definition = ruleDefinition(rule);
3877
- definitionCounts.set(
3878
- definition,
3879
- (definitionCounts.get(definition) ?? 0) + 1,
3880
- );
3881
- }
3882
- const seenRuleIdentities = new Map<string, number>();
3883
- let namesTarget = false;
3884
- let healthGap = false;
3885
- let alertingCount = 0;
3886
- let everyInstanceElsewhere = true;
3887
- for (const { rule, instances: rawInstances, annotations } of rules) {
3888
- // Recording rules carry no state and never describe a resource.
3889
- if (rule.type.toLowerCase() !== "alerting") continue;
3890
- alertingCount += 1;
3891
- const instances = rawInstances.map((instance) => ({
3892
- ...instance,
3893
- namesTarget: labelsNameTarget(
3894
- builder.target,
3895
- instance.labels,
3896
- targetPods,
3897
- ),
3898
- }));
3899
- if (
3900
- instances.length === 0 ||
3901
- instances.some(
3902
- (instance) => !labelsPlaceElsewhere(builder.target, instance.labels),
3903
- )
3904
- ) {
3905
- everyInstanceElsewhere = false;
3906
- }
3907
- const definition = ruleDefinition(rule);
3908
- const ambiguous = (definitionCounts.get(definition) ?? 0) > 1;
3909
- const ordinal = seenRuleIdentities.get(definition) ?? 0;
3910
- seenRuleIdentities.set(definition, ordinal + 1);
3911
- const ruleIdentity = ambiguous
3912
- ? `${definition}:${source.id}:${ordinal}`
3913
- : definition;
3914
- const previousRelevance = ambiguous
3915
- ? undefined
3916
- : builder.latestRelevance("alerts", ruleIdentity);
3917
- const relevance: InvestigationEvidenceRelevance = instances.some(
3918
- (instance) => instance.namesTarget,
3919
- )
3920
- ? "target"
3921
- : labelsNameTarget(builder.target, rule.labels, targetPods)
3922
- ? "producer-related"
3923
- : // The same rule read again with no instances left has resolved for
3924
- // the target it named before; keep that provenance so the
3925
- // resolution stays visible instead of the stale firing card.
3926
- instances.length === 0 &&
3927
- previousRelevance !== undefined &&
3928
- previousRelevance !== "broader"
3929
- ? previousRelevance
3930
- : "broader";
3931
- if (relevance !== "broader") namesTarget = true;
3932
- const state = (rule.state ?? "").toLowerCase();
3933
- const active = state === "firing" || state === "pending";
3934
- const severity = (rule.labels.severity ?? "").toLowerCase();
3935
- const targetInstances = instances.filter(
3936
- (instance) => instance.namesTarget,
3937
- ).length;
3938
- builder.observe(ruleIdentity, "alerts", source, {
3939
- tier: evidenceTierForRelevance(
3940
- active ? "supporting" : "context",
3941
- relevance,
3942
- ),
3943
- relevance,
3944
- tone:
3945
- state === "firing"
3946
- ? severity === "critical"
3947
- ? "error"
3948
- : "alert"
3949
- : state === "pending"
3950
- ? "warning"
3951
- : "neutral",
3952
- title: `${rule.name}${state ? ` ${state}` : ""}`,
3953
- summary:
3954
- instances.length === 0
3955
- ? `No active instances · ${rule.group}`
3956
- : `${instances.length} active instance${instances.length === 1 ? "" : "s"}${
3957
- targetInstances > 0
3958
- ? `, ${targetInstances} naming ${identity}`
3959
- : ""
3960
- } · ${rule.group}`,
3961
- data: { type: "alerts", rule, instances, annotations },
3962
- });
3963
- const health = (rule.health ?? "").toLowerCase();
3964
- if (health === "err") {
3965
- healthGap = true;
3966
- builder.limit(
3967
- source,
3968
- `Alert rule ${rule.name}`,
3969
- "Prometheus reported an evaluation error for this rule, so its state may be stale or missing.",
3970
- "error",
3971
- );
3972
- } else if (health !== "ok") {
3973
- // Anything but a reported "ok" evaluation, including an unrecognized
3974
- // value, is an unevaluated rule for the purpose of a negative.
3975
- healthGap = true;
3976
- builder.limit(
3977
- source,
3978
- `Alert rule ${rule.name}`,
3979
- "Prometheus has not reported a successful evaluation for this rule, so its state is unknown.",
3980
- "unknown",
3981
- );
3982
- }
3983
- }
3984
- // A negative is only as strong as the query: an active-state filter over
3985
- // every alerting rule (a name or group filter never looked at the rest), a
3986
- // complete list, every rule evaluated, and every returned instance placed
3987
- // in another namespace outright. An instance this projection merely fails
3988
- // to recognize is not evidence of absence.
3989
- if (
3990
- source.confirmedSuccess &&
3991
- !truncated &&
3992
- !namesTarget &&
3993
- !healthGap &&
3994
- typeFilter !== "record" &&
3995
- !nonEmptyString(args?.name) &&
3996
- !nonEmptyString(args?.group) &&
3997
- (stateFilter === "firing" || stateFilter === "pending") &&
3998
- (alertingCount === 0 || everyInstanceElsewhere)
3999
- ) {
4000
- const filter = `state=${stateFilter}`;
4001
- builder.observe(`alerts:receipt:${filter}`, "receipt", source, {
4002
- tier: "checked",
4003
- relevance: "target",
4004
- tone: "neutral",
4005
- title: `No ${stateFilter} alert rules name this ${displayKind(builder.target.kind)}`,
4006
- summary: identity,
4007
- data: {
4008
- type: "receipt",
4009
- checked: "alerts",
4010
- scope: identity,
4011
- message:
4012
- alertingCount === 0
4013
- ? `0 alerting rules returned (filter: ${filter}).`
4014
- : `${alertingCount} alerting rule${alertingCount === 1 ? "" : "s"} returned (filter: ${filter}), every instance in another namespace; none names ${identity}.`,
4015
- },
4016
- });
4017
- }
4018
- }
4019
-
4020
- function helmOwnedResource(
4021
- value: unknown,
4022
- ): InvestigationHelmOwnedResource | undefined {
4023
- const candidate = record(value);
4024
- if (
4025
- !candidate ||
4026
- !nonEmptyString(candidate.kind) ||
4027
- !nonEmptyString(candidate.name) ||
4028
- typeof candidate.namespace !== "string"
4029
- ) {
4030
- return undefined;
4031
- }
4032
- for (const field of [
4033
- "apiVersion",
4034
- "status",
4035
- "ready",
4036
- "message",
4037
- "summary",
4038
- "issue",
4039
- ] as const) {
4040
- if (candidate[field] !== undefined && typeof candidate[field] !== "string")
4041
- return undefined;
4042
- }
4043
- return candidate as unknown as InvestigationHelmOwnedResource;
4044
- }
4045
-
4046
- function helmOperation(value: unknown): InvestigationHelmOperation | undefined {
4047
- const candidate = record(value);
4048
- if (
4049
- !candidate ||
4050
- !nonEmptyString(candidate.kind) ||
4051
- !nonEmptyString(candidate.status) ||
4052
- typeof candidate.message !== "string"
4053
- ) {
4054
- return undefined;
4055
- }
4056
- for (const field of [
4057
- "revision",
4058
- "failedRevision",
4059
- "rollbackRevision",
4060
- ] as const) {
4061
- if (candidate[field] !== undefined && typeof candidate[field] !== "number")
4062
- return undefined;
4063
- }
4064
- if (candidate.updated !== undefined && typeof candidate.updated !== "string")
4065
- return undefined;
4066
- return candidate as unknown as InvestigationHelmOperation;
4067
- }
4068
-
4069
- function adaptHelmRelease(
4070
- builder: ProjectionBuilder,
4071
- source: InvestigationEvidenceSource,
4072
- payload: unknown,
4073
- ): void {
4074
- const value = record(payload);
4075
- if (
4076
- !value ||
4077
- !nonEmptyString(value.name) ||
4078
- !nonEmptyString(value.namespace) ||
4079
- typeof value.chart !== "string" ||
4080
- typeof value.chartVersion !== "string" ||
4081
- !nonEmptyString(value.status) ||
4082
- typeof value.revision !== "number" ||
4083
- typeof value.updated !== "string"
4084
- ) {
4085
- invalidPayload(builder, source);
4086
- return;
4087
- }
4088
- for (const field of [
4089
- "appVersion",
4090
- "description",
4091
- "storageNamespace",
4092
- "resourceHealth",
4093
- "healthIssue",
4094
- "healthSummary",
4095
- "managedByFluxHelmRelease",
4096
- ] as const) {
4097
- if (value[field] !== undefined && typeof value[field] !== "string") {
4098
- invalidPayload(builder, source);
4099
- return;
4100
- }
4101
- }
4102
- const resourcesRaw =
4103
- value.resources === undefined || value.resources === null
4104
- ? []
4105
- : value.resources;
4106
- if (!Array.isArray(resourcesRaw)) {
4107
- invalidPayload(builder, source, "Helm resources");
4108
- return;
4109
- }
4110
- const resources = resourcesRaw
4111
- .map(helmOwnedResource)
4112
- .filter((item): item is InvestigationHelmOwnedResource => Boolean(item));
4113
- if (resources.length !== resourcesRaw.length) {
4114
- invalidPayload(builder, source, "Helm resources");
4115
- return;
4116
- }
4117
- const lastOperation =
4118
- value.lastOperation === undefined
4119
- ? undefined
4120
- : helmOperation(value.lastOperation);
4121
- if (value.lastOperation !== undefined && !lastOperation) {
4122
- invalidPayload(builder, source, "Helm operation");
4123
- return;
4124
- }
4125
- const release: InvestigationHelmRelease = {
4126
- name: value.name,
4127
- namespace: value.namespace,
4128
- chart: value.chart,
4129
- chartVersion: value.chartVersion,
4130
- appVersion: value.appVersion as string | undefined,
4131
- status: value.status,
4132
- revision: value.revision,
4133
- updated: value.updated,
4134
- description: value.description as string | undefined,
4135
- ...(nonEmptyString(value.storageNamespace)
4136
- ? { storageNamespace: value.storageNamespace }
4137
- : {}),
4138
- resourceHealth: value.resourceHealth as string | undefined,
4139
- healthIssue: value.healthIssue as string | undefined,
4140
- healthSummary: value.healthSummary as string | undefined,
4141
- managedByFluxHelmRelease: value.managedByFluxHelmRelease as
4142
- string | undefined,
4143
- lastOperation,
4144
- resources,
4145
- };
4146
- // The release manages the target when the target is among the resources
4147
- // Helm rendered for it; a release is never the investigated object itself.
4148
- // Without an API version the owned row cannot tell colliding kinds apart
4149
- // (CNPG vs CAPI Cluster), so it establishes nothing.
4150
- const relevance: InvestigationEvidenceRelevance = resources.some(
4151
- (owned) =>
4152
- owned.apiVersion !== undefined &&
4153
- resourceMatchesTarget(builder.target, {
4154
- kind: owned.kind,
4155
- group: apiVersionToGroup(owned.apiVersion),
4156
- namespace: owned.namespace || undefined,
4157
- name: owned.name,
4158
- }),
4159
- )
4160
- ? "producer-related"
4161
- : "broader";
4162
- const status = release.status.toLowerCase();
4163
- const operationFailed =
4164
- lastOperation?.status === "failed" ||
4165
- lastOperation?.status === "stuck_pending";
4166
- const adverse =
4167
- status !== "deployed" || Boolean(release.healthIssue) || operationFailed;
4168
- const chartLabel = `${release.chart}${release.chartVersion ? ` ${release.chartVersion}` : ""}`;
4169
- // Helm keys a release by where its metadata is stored; two releases with
4170
- // one name and namespace can live in different storage namespaces.
4171
- const storage = release.storageNamespace ?? release.namespace;
4172
- builder.observe(
4173
- `helm:${storage}:${release.namespace}:${release.name}`,
4174
- "helm",
4175
- source,
4176
- {
4177
- tier: evidenceTierForRelevance(
4178
- adverse ? "supporting" : "context",
4179
- relevance,
4180
- ),
4181
- relevance,
4182
- tone:
4183
- status.includes("failed") || lastOperation?.status === "failed"
4184
- ? "error"
4185
- : adverse
4186
- ? "warning"
4187
- : "info",
4188
- title: `Helm release ${release.namespace}/${release.name}`,
4189
- summary: release.healthIssue
4190
- ? `${release.status} · ${release.healthIssue}`
4191
- : `${chartLabel} · ${release.status} · revision ${release.revision}`,
4192
- data: { type: "helm", release },
4193
- },
4194
- );
4195
- // Every comparison the producer attempted and could not finish, not just the
4196
- // values read. Four of these are Cloud-role denials, so dropping any of them
4197
- // lets a reader see a release card whose comparison was withheld and take it
4198
- // for complete. The labels follow the producer's own fields: `diff` is the
4199
- // manifest, `valuesDiff` the values — naming one for the other sends a reader
4200
- // to the wrong thing.
4201
- for (const [field, label] of [
4202
- ["valuesError", "Helm values"],
4203
- ["valuesDiffError", "Helm values diff"],
4204
- ["diffError", "Helm manifest diff"],
4205
- ["notesDiffError", "Helm notes diff"],
4206
- ["resourceDiffError", "Helm resource diff"],
4207
- ] as const) {
4208
- const message = value[field];
4209
- if (nonEmptyString(message)) {
4210
- builder.limit(source, label, message, "error");
4211
- }
4212
- }
4213
- }
4214
-
4215
- function permissionSubject(
4216
- value: unknown,
4217
- ): InvestigationPermissionSubject | undefined {
4218
- const candidate = record(value);
4219
- if (
4220
- !candidate ||
4221
- !nonEmptyString(candidate.kind) ||
4222
- !nonEmptyString(candidate.name) ||
4223
- (candidate.namespace !== undefined &&
4224
- typeof candidate.namespace !== "string")
4225
- ) {
4226
- return undefined;
4227
- }
4228
- return {
4229
- kind: candidate.kind,
4230
- name: candidate.name,
4231
- ...(nonEmptyString(candidate.namespace)
4232
- ? { namespace: candidate.namespace }
4233
- : {}),
4234
- };
4235
- }
4236
-
4237
- function accessCheck(value: unknown): InvestigationAccessCheck | undefined {
4238
- const candidate = record(value);
4239
- if (
4240
- !candidate ||
4241
- !nonEmptyString(candidate.verb) ||
4242
- !nonEmptyString(candidate.resource) ||
4243
- typeof candidate.namespace !== "string" ||
4244
- typeof candidate.allowed !== "boolean" ||
4245
- (candidate.denied !== undefined && typeof candidate.denied !== "boolean")
4246
- ) {
4247
- return undefined;
4248
- }
4249
- for (const field of [
4250
- "group",
4251
- "subresource",
4252
- "resourceName",
4253
- "reason",
4254
- "evaluationError",
4255
- ] as const) {
4256
- if (candidate[field] !== undefined && typeof candidate[field] !== "string")
4257
- return undefined;
4258
- }
4259
- return {
4260
- ...(candidate as unknown as InvestigationAccessCheck),
4261
- denied: candidate.denied === true,
4262
- };
4263
- }
4264
-
4265
- function permissionBinding(
4266
- value: unknown,
4267
- ): InvestigationPermissionBinding | undefined {
4268
- const candidate = record(value);
4269
- if (
4270
- !candidate ||
4271
- !nonEmptyString(candidate.bindingKind) ||
4272
- !nonEmptyString(candidate.bindingName) ||
4273
- !nonEmptyString(candidate.roleKind) ||
4274
- !nonEmptyString(candidate.roleName) ||
4275
- !nonNegativeInteger(candidate.rulesCount)
4276
- ) {
4277
- return undefined;
4278
- }
4279
- for (const field of [
4280
- "bindingNamespace",
4281
- "roleNamespace",
4282
- "inheritedFromGroup",
4283
- ] as const) {
4284
- if (candidate[field] !== undefined && typeof candidate[field] !== "string")
4285
- return undefined;
4286
- }
4287
- return candidate as unknown as InvestigationPermissionBinding;
4288
- }
4289
-
4290
- // Kinds whose spec carries a PodSpec, by API group. Anything else with a
4291
- // `spec.template.spec` (an ApplicationSet, for one) templates something that
4292
- // is not a Pod and runs as no account at all.
4293
- const POD_TEMPLATE_KINDS: Readonly<Record<string, readonly string[]>> = {
4294
- "": ["Pod"],
4295
- apps: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"],
4296
- batch: ["Job", "CronJob"],
4297
- "argoproj.io": ["Rollout"],
4298
- };
4299
-
4300
- function podSpecServiceAccount(
4301
- resource: InvestigationKubernetesResource,
4302
- ): string | undefined {
4303
- const group = apiVersionToGroup(resource.apiVersion);
4304
- if (!POD_TEMPLATE_KINDS[group]?.includes(resource.kind)) return undefined;
4305
- const spec = record(resource.spec);
4306
- const podSpec =
4307
- resource.kind === "Pod"
4308
- ? spec
4309
- : resource.kind === "CronJob"
4310
- ? record(
4311
- record(record(record(spec?.jobTemplate)?.spec)?.template)?.spec,
4312
- )
4313
- : record(record(spec?.template)?.spec);
4314
- if (!podSpec) return undefined;
4315
- // An unset serviceAccountName runs as the namespace's `default` account.
4316
- return nonEmptyString(podSpec.serviceAccountName)
4317
- ? podSpec.serviceAccountName
4318
- : "default";
4319
- }
4320
-
4321
- /**
4322
- * The ServiceAccount the target runs as, read from a resource observation of
4323
- * the target in the same turn. Only the captured resource can state this; the
4324
- * permission subject is never assumed related by name alone.
4325
- */
4326
- function targetServiceAccountName(
4327
- builder: ProjectionBuilder,
4328
- source: InvestigationEvidenceSource,
4329
- ): string | undefined {
4330
- let newest: InvestigationEvidenceObservation | undefined;
4331
- for (const group of builder.groups) {
4332
- if (group.kind !== "resource") continue;
4333
- for (const observation of group.observations) {
4334
- if (
4335
- observation.source.turnIndex !== source.turnIndex ||
4336
- observation.relevance !== "target" ||
4337
- observation.data.type !== "resource" ||
4338
- (newest && observation.source.order < newest.source.order)
4339
- ) {
4340
- continue;
4341
- }
4342
- newest = observation;
4343
- }
4344
- }
4345
- if (!newest || newest.data.type !== "resource") return undefined;
4346
- return (
4347
- newest.data.resourceContext?.uses?.serviceAccount?.name ??
4348
- podSpecServiceAccount(newest.data.resource)
4349
- );
4350
- }
4351
-
4352
- function adaptSubjectPermissions(
4353
- builder: ProjectionBuilder,
4354
- source: InvestigationEvidenceSource,
4355
- payload: unknown,
4356
- ): void {
4357
- const value = record(payload);
4358
- const subject = value ? permissionSubject(value.subject) : undefined;
4359
- if (!value || !subject) {
4360
- invalidPayload(builder, source);
4361
- return;
4362
- }
4363
- // Only a captured resource can tie a principal to the target. Any other
4364
- // subject the agent chose to check is context, however adverse the answer.
4365
- const relevance: InvestigationEvidenceRelevance =
4366
- subject.kind === "ServiceAccount" &&
4367
- subject.namespace !== undefined &&
4368
- subject.namespace === builder.target.namespace &&
4369
- targetServiceAccountName(builder, source) === subject.name
4370
- ? "target"
4371
- : "broader";
4372
- const subjectLabel = `${displayKind(subject.kind)} ${subject.namespace ? `${subject.namespace}/` : ""}${subject.name}`;
4373
- const subjectKey = `${subject.kind}:${subject.namespace ?? ""}:${subject.name}`;
4374
-
4375
- if (value.accessCheck !== undefined) {
4376
- const check = accessCheck(value.accessCheck);
4377
- if (!check) {
4378
- invalidPayload(builder, source, "Access check");
4379
- return;
4380
- }
4381
- const resourceLabel = `${check.resource}${check.subresource ? `/${check.subresource}` : ""}${check.group ? `.${check.group}` : ""}`;
4382
- // An authorizer that could not decide has established neither answer. The
4383
- // evaluation error already reaches the coverage strip below, but a card
4384
- // reading "cannot verb resource" is the part an operator acts on, and a
4385
- // webhook returning partial data is not a denial.
4386
- // Only when the authorizer decided nothing. Kubernetes returns this error
4387
- // alongside a real verdict too — one webhook failing while another allows —
4388
- // and rewriting a decided allow or deny as "could not check" loses the
4389
- // answer and puts an allow in the warning list.
4390
- const unresolved =
4391
- nonEmptyString(check.evaluationError) && !check.allowed && !check.denied;
4392
- const denied = !check.allowed && !unresolved;
4393
- const verdict = unresolved
4394
- ? `Could not be evaluated: ${check.evaluationError}`
4395
- : check.reason ||
4396
- (check.allowed
4397
- ? "Allowed by RBAC"
4398
- : check.denied
4399
- ? "Explicitly denied"
4400
- : "No RBAC rule allows it");
4401
- builder.observe(
4402
- `permissions:check:${subjectKey}:${check.verb}:${check.group ?? ""}:${check.resource}:${check.subresource ?? ""}:${check.namespace}:${check.resourceName ?? ""}`,
4403
- "permissions",
4404
- source,
4405
- {
4406
- tier: evidenceTierForRelevance(
4407
- denied || unresolved ? "supporting" : "context",
4408
- relevance,
4409
- ),
4410
- relevance,
4411
- tone: denied || unresolved ? "warning" : "info",
4412
- title: unresolved
4413
- ? `Could not check whether ${subjectLabel} can ${check.verb} ${resourceLabel}`
4414
- : `${subjectLabel} ${denied ? "cannot" : "can"} ${check.verb} ${resourceLabel}`,
4415
- summary: `${verdict} · ${check.namespace ? `namespace ${check.namespace}` : "cluster-wide"}${check.resourceName ? ` · ${check.resourceName}` : ""}`,
4416
- data: { type: "permissions", subject, accessCheck: check },
4417
- },
4418
- );
4419
- if (nonEmptyString(check.evaluationError)) {
4420
- builder.limit(source, "Permissions", check.evaluationError, "error");
4421
- }
4422
- return;
4423
- }
4424
-
4425
- const bindingsRaw = value.bindings;
4426
- if (!Array.isArray(bindingsRaw)) {
4427
- invalidPayload(builder, source);
4428
- return;
4429
- }
4430
- const bindings = bindingsRaw
4431
- .map(permissionBinding)
4432
- .filter((item): item is InvestigationPermissionBinding => Boolean(item));
4433
- if (
4434
- bindings.length !== bindingsRaw.length ||
4435
- !Array.isArray(value.flatRules) ||
4436
- !value.flatRules.every((rule) => stringArray(record(rule)?.verbs)) ||
4437
- (value.truncated !== undefined && typeof value.truncated !== "boolean") ||
4438
- (value.podsTotal !== undefined && !nonNegativeInteger(value.podsTotal)) ||
4439
- (value.usedByPods !== undefined && !stringArray(value.usedByPods))
4440
- ) {
4441
- invalidPayload(builder, source);
4442
- return;
4443
- }
4444
- const truncated = value.truncated === true;
4445
- const flatRulesCount = value.flatRules.length;
4446
- const usedByPods = stringArray(value.usedByPods) ?? [];
4447
- builder.observe(`permissions:subject:${subjectKey}`, "permissions", source, {
4448
- tier: evidenceTierForRelevance("context", relevance),
4449
- relevance,
4450
- tone: "info",
4451
- title: `Permissions of ${subjectLabel}`,
4452
- summary: `${bindings.length} binding${bindings.length === 1 ? "" : "s"} · ${flatRulesCount}${truncated ? "+" : ""} effective rule${flatRulesCount === 1 && !truncated ? "" : "s"}`,
4453
- data: {
4454
- type: "permissions",
4455
- subject,
4456
- bindings,
4457
- flatRulesCount,
4458
- truncated,
4459
- usedByPods,
4460
- podsTotal: value.podsTotal as number | undefined,
4461
- },
4462
- });
4463
- if (truncated) {
4464
- builder.limit(
4465
- source,
4466
- "Permissions",
4467
- nonEmptyString(value.narrowHint)
4468
- ? value.narrowHint
4469
- : "The effective rule list was truncated; it is not the subject's complete permission set.",
4470
- "truncated",
4471
- );
4472
- }
4473
- if (nonEmptyString(value.subjectWarning)) {
4474
- builder.limit(source, "Permissions", value.subjectWarning, "unknown");
4475
- }
4476
- if (
4477
- typeof value.podsTotal === "number" &&
4478
- value.podsTotal > usedByPods.length
4479
- ) {
4480
- builder.limit(
4481
- source,
4482
- "Permissions",
4483
- `Only ${usedByPods.length} of ${value.podsTotal} pods running as this subject were listed.`,
4484
- "truncated",
4485
- );
4486
- }
4487
- }
4488
-
4489
- const ADAPTERS: Record<
4490
- string,
4491
- (
4492
- builder: ProjectionBuilder,
4493
- source: InvestigationEvidenceSource,
4494
- payload: unknown,
4495
- ) => void
4496
- > = {
4497
- diagnose: adaptDiagnose,
4498
- issues: adaptIssues,
4499
- get_resource: adaptGetResource,
4500
- list_resources: adaptListResources,
4501
- get_events: adaptEvents,
4502
- get_pod_logs: adaptPodLogs,
4503
- get_changes: adaptChanges,
4504
- get_neighborhood: adaptNeighborhood,
4505
- get_topology: adaptTopology,
4506
- get_workload_logs: adaptWorkloadLogs,
4507
- get_prometheus_rules: adaptPrometheusRules,
4508
- get_helm_release: adaptHelmRelease,
4509
- get_subject_permissions: adaptSubjectPermissions,
4510
- };
4511
-
4512
- export function projectInvestigationEvidence(
4513
- turns: readonly InvestigationEvidenceTurn[],
4514
- target: InvestigationEvidenceTarget,
4515
- ): InvestigationEvidenceProjection {
4516
- const builder = new ProjectionBuilder(target);
4517
- const evidenceRefSources: InvestigationEvidenceSource[] = [];
4518
- const citableSources: InvestigationEvidenceSource[] = [];
4519
- let order = 0;
4520
- for (const [turnIndex, turn] of turns.entries()) {
4521
- for (const [timelineIndex, item] of turn.timeline.entries()) {
4522
- const itemOrder = order;
4523
- order += 1;
4524
- if (item.kind !== "tool") continue;
4525
- // Full-local agents may load user MCP servers whose bare tool names collide
4526
- // with Radar's. Only results matched by the server to the active private
4527
- // transport ledger may enter the surface labelled "Radar evidence".
4528
- if (item.radarEvidence !== true) continue;
4529
- const source: InvestigationEvidenceSource = {
4530
- id: investigationEvidenceSourceId(turnIndex, item.id),
4531
- turnIndex,
4532
- timelineIndex,
4533
- stepId: item.id,
4534
- tool: item.tool,
4535
- args: item.summary,
4536
- order: itemOrder,
4537
- phase: turn.verify
4538
- ? "verification"
4539
- : turn.apply
4540
- ? "apply"
4541
- : turn.question
4542
- ? "followup"
4543
- : "initial",
4544
- confirmedSuccess: item.status === "done" && item.isError === false,
4545
- evidenceRef: item.evidenceRef,
4546
- };
4547
- if (item.evidenceRef) evidenceRefSources.push(source);
4548
- if (item.status !== "done") continue;
4549
- if (
4550
- item.evidenceRef &&
4551
- investigationEvidenceRefRe.test(item.evidenceRef) &&
4552
- item.isError === false &&
4553
- !item.truncated &&
4554
- nonEmptyString(item.result)
4555
- ) {
4556
- citableSources.push(source);
4557
- }
4558
- const adapt = ADAPTERS[item.tool];
4559
- if (!adapt) continue;
4560
- builder.addSource(source);
4561
- if (item.isError === true) {
4562
- builder.limit(
4563
- source,
4564
- investigationResultLabel(source),
4565
- item.result || "This investigation step failed.",
4566
- "error",
4567
- );
4568
- continue;
4569
- }
4570
- if (item.truncated) {
4571
- builder.limit(
4572
- source,
4573
- investigationResultLabel(source),
4574
- "Only part of this investigation result was saved, so Radar could not summarize it here.",
4575
- "truncated",
4576
- );
4577
- continue;
4578
- }
4579
- if (!nonEmptyString(item.result)) {
4580
- builder.limit(
4581
- source,
4582
- investigationResultLabel(source),
4583
- "This investigation step did not return details Radar could summarize.",
4584
- "unknown",
4585
- );
4586
- continue;
4587
- }
4588
- const payload = parseJSON(item.result);
4589
- if (payload === undefined) {
4590
- invalidPayload(builder, source);
4591
- continue;
4592
- }
4593
- adapt(builder, source, payload);
4594
- if (item.isError !== false) {
4595
- builder.limit(
4596
- source,
4597
- investigationResultLabel(source),
4598
- "Radar cannot confirm whether this investigation step completed successfully. Available evidence is shown, but an empty result cannot confirm that nothing was found.",
4599
- "unknown",
4600
- );
4601
- }
4602
- }
4603
- }
4604
-
4605
- const tierRank: Record<InvestigationEvidenceTier, number> = {
4606
- key: 0,
4607
- supporting: 1,
4608
- context: 2,
4609
- checked: 3,
4610
- };
4611
- const primaryBySource = new Map<
4612
- string,
4613
- { groupId: string; rank: number; order: number }
4614
- >();
4615
- for (const group of builder.groups) {
4616
- for (const observation of group.observations) {
4617
- const candidate = {
4618
- groupId: group.id,
4619
- rank: tierRank[observation.tier],
4620
- order: group.firstOrder,
4621
- };
4622
- const current = primaryBySource.get(observation.source.id);
4623
- if (
4624
- !current ||
4625
- candidate.rank < current.rank ||
4626
- (candidate.rank === current.rank && candidate.order < current.order)
4627
- ) {
4628
- primaryBySource.set(observation.source.id, candidate);
4629
- }
4630
- }
4631
- }
4632
- for (const source of builder.sources) {
4633
- source.primaryGroupId = primaryBySource.get(source.id)?.groupId;
4634
- }
4635
-
4636
- // The investigation target, rather than the producer tool, is the proof
4637
- // boundary for semantic diagnosis domains. A later successful target
4638
- // diagnosis can therefore retire an exact-target issue first observed by
4639
- // `issues`, while a broad or sibling read still cannot clear it.
4640
- const targetProofScope = [
4641
- builder.target.group.toLowerCase(),
4642
- builder.target.kind.toLowerCase(),
4643
- builder.target.namespace ?? "",
4644
- builder.target.name,
4645
- ].join("/");
4646
- const semanticCoverageKey = (kind: InvestigationEvidenceKind) =>
4647
- `semantic:${kind}:${targetProofScope}`;
4648
- const collectionCoverageKey = (
4649
- kind: InvestigationEvidenceKind,
4650
- identity: string,
4651
- ) => `collection:${kind}:${identity}`;
4652
- const previousLogCoverageKey = (
4653
- source: InvestigationEvidenceSource,
4654
- podContainer: string,
4655
- ) => `previous-log:${source.tool}:${scopeFromArgs(source)}:${podContainer}`;
4656
- const retirementKey = (
4657
- group: InvestigationEvidenceGroup,
4658
- observation: InvestigationEvidenceObservation,
4659
- ): string | undefined => {
4660
- switch (group.kind) {
4661
- case "issue":
4662
- case "startup":
4663
- case "crash":
4664
- case "dns":
4665
- return semanticCoverageKey(group.kind);
4666
- case "events":
4667
- case "changes":
4668
- return collectionCoverageKey(group.kind, group.identity);
4669
- case "logs":
4670
- return group.identity.startsWith("logs:previous:")
4671
- ? previousLogCoverageKey(
4672
- observation.source,
4673
- group.identity.slice("logs:previous:".length),
4674
- )
4675
- : undefined;
4676
- case "network":
4677
- return collectionCoverageKey(group.kind, group.identity);
4678
- default:
4679
- return undefined;
4680
- }
4681
- };
4682
-
4683
- // Each completed verification contributes only the exact proof scopes its
4684
- // successful producers covered. Keep every verification: supersession is
4685
- // monotonic until a newer relevant observation reopens that semantic item.
4686
- const verificationCoverage: Array<{
4687
- turnIndex: number;
4688
- keys: Set<string>;
4689
- }> = [];
4690
- turns.forEach((turn, turnIndex) => {
4691
- if (!turn.verify || turn.status !== "done") return;
4692
- const keys = new Set<string>();
4693
- for (const group of builder.groups) {
4694
- for (const observation of group.observations) {
4695
- if (
4696
- observation.source.turnIndex !== turnIndex ||
4697
- !observation.source.confirmedSuccess ||
4698
- observation.relevance === "broader"
4699
- ) {
4700
- continue;
4701
- }
4702
- if (observation.data.type === "receipt") {
4703
- switch (observation.data.checked) {
4704
- case "issues":
4705
- keys.add(semanticCoverageKey("issue"));
4706
- break;
4707
- case "events":
4708
- keys.add(collectionCoverageKey("events", group.identity));
4709
- break;
4710
- case "changes":
4711
- keys.add(collectionCoverageKey("changes", group.identity));
4712
- break;
4713
- case "logs":
4714
- if (group.identity.startsWith("previous-log-absence:")) {
4715
- keys.add(
4716
- previousLogCoverageKey(
4717
- observation.source,
4718
- group.identity.slice("previous-log-absence:".length),
4719
- ),
4720
- );
4721
- }
4722
- break;
4723
- case "inventory":
4724
- case "alerts":
4725
- break;
4726
- }
4727
- continue;
4728
- }
4729
- if (
4730
- observation.source.tool === "diagnose" &&
4731
- observation.data.type === "resource"
4732
- ) {
4733
- for (const kind of builder.semanticCoverageBySource.get(
4734
- observation.source.id,
4735
- ) ?? []) {
4736
- keys.add(semanticCoverageKey(kind));
4737
- }
4738
- }
4739
- if (
4740
- observation.source.tool === "diagnose" &&
4741
- group.kind === "network"
4742
- ) {
4743
- keys.add(collectionCoverageKey("network", group.identity));
4744
- }
4745
- }
4746
- }
4747
- verificationCoverage.push({ turnIndex, keys });
4748
- });
4749
-
4750
- for (const group of builder.groups) {
4751
- for (const observation of group.observations) {
4752
- const key =
4753
- observation.relevance !== "broader"
4754
- ? retirementKey(group, observation)
4755
- : undefined;
4756
- observation.historical = Boolean(
4757
- key &&
4758
- verificationCoverage.some(
4759
- (verification) =>
4760
- verification.turnIndex > observation.source.turnIndex &&
4761
- verification.keys.has(key),
4762
- ),
4763
- );
4764
- }
4765
- const latestRelevantObservation = [...group.observations]
4766
- .reverse()
4767
- .find((observation) => observation.relevance !== "broader");
4768
- group.historical = latestRelevantObservation?.historical ?? false;
4769
- }
4770
-
4771
- return {
4772
- groups: builder.groups,
4773
- limitations: builder.limitations,
4774
- sources: builder.sources,
4775
- evidenceRefSources,
4776
- citableSources,
4777
- coverage: {
4778
- attempted: builder.sources.length,
4779
- projected: builder.projectedSources.size,
4780
- limited: builder.limitedSources.size,
4781
- checked: builder.checkedSources.size,
4782
- },
4783
- };
4784
- }