@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
@@ -0,0 +1,2703 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { projectInvestigationEvidence } from "./index";
3
+ import {
4
+ completeLogProof,
5
+ criticalIssue,
6
+ deployment,
7
+ evidenceRef,
8
+ groupsOf,
9
+ project,
10
+ target,
11
+ tool,
12
+ warningEvent,
13
+ } from "./evidenceFixtures";
14
+
15
+ describe("strict evidence adapters", () => {
16
+ it("renders resource scopes as identities, not argument order", () => {
17
+ const result = project([
18
+ tool(
19
+ "events",
20
+ "get_events",
21
+ { events: [warningEvent] },
22
+ {
23
+ summary: JSON.stringify({
24
+ group: "apps",
25
+ kind: "Deployment",
26
+ namespace: "shop",
27
+ name: "api",
28
+ }),
29
+ },
30
+ ),
31
+ ]);
32
+ const events = groupsOf(result.groups, "events")[0].latest;
33
+
34
+ expect(events.summary).toBe(
35
+ "BackOff: Back-off restarting failed container · Deployment shop/api",
36
+ );
37
+ expect(events.data).toMatchObject({
38
+ type: "events",
39
+ scope: "Deployment shop/api",
40
+ });
41
+ });
42
+
43
+ it("recognizes GitOps diagnose without inventing workload collection receipts", () => {
44
+ const result = projectInvestigationEvidence(
45
+ [
46
+ {
47
+ timeline: [
48
+ tool("gitops", "diagnose", {
49
+ resource: {
50
+ apiVersion: "argoproj.io/v1alpha1",
51
+ kind: "Application",
52
+ metadata: { namespace: "argocd", name: "shop" },
53
+ },
54
+ pods: 0,
55
+ gitopsDiagnosis: {
56
+ tool: "argocd",
57
+ sync: "OutOfSync",
58
+ health: "Degraded",
59
+ },
60
+ relatedIssues: [],
61
+ }),
62
+ ],
63
+ },
64
+ ],
65
+ {
66
+ kind: "Application",
67
+ group: "argoproj.io",
68
+ namespace: "argocd",
69
+ name: "shop",
70
+ },
71
+ );
72
+
73
+ expect(groupsOf(result.groups, "resource")).toHaveLength(1);
74
+ expect(groupsOf(result.groups, "resource")[0].latest).toMatchObject({
75
+ tier: "supporting",
76
+ tone: "warning",
77
+ data: {
78
+ type: "resource",
79
+ gitOpsDiagnosis: { sync: "OutOfSync", health: "Degraded" },
80
+ },
81
+ });
82
+ expect(groupsOf(result.groups, "receipt")).toHaveLength(0);
83
+ expect(result.limitations).toHaveLength(0);
84
+ });
85
+
86
+ it("renders the current network diagnose contract instead of rejecting it as a workload", () => {
87
+ const result = project([
88
+ tool("network", "diagnose", {
89
+ subject: { kind: "Service", namespace: "shop", name: "api" },
90
+ verdict: "broken",
91
+ reason: "No ready endpoints",
92
+ diagnosis: {
93
+ class: "fault",
94
+ severity: "critical",
95
+ summary: "The Service has no ready endpoints.",
96
+ route: "api:80",
97
+ nextAction: "Inspect the selected Pods.",
98
+ },
99
+ summary: {
100
+ tested: 1,
101
+ passed: 0,
102
+ failed: 1,
103
+ derived: 0,
104
+ skipped: 1,
105
+ headline: "One route failed and one could not be tested.",
106
+ },
107
+ routes: [
108
+ {
109
+ route: "api:80",
110
+ target: "api:8080",
111
+ outcome: "unreachable",
112
+ failedLayer: "tcp",
113
+ confidence: "real",
114
+ evidence: "connection refused",
115
+ },
116
+ ],
117
+ notTested: [{ route: "api:443", reason: "TLS secret unavailable" }],
118
+ }),
119
+ ]);
120
+
121
+ const network = groupsOf(result.groups, "network")[0];
122
+ expect(network.latest).toMatchObject({
123
+ tier: "context",
124
+ relevance: "broader",
125
+ tone: "error",
126
+ summary: "The Service has no ready endpoints.",
127
+ });
128
+ expect(result.limitations).toEqual([
129
+ expect.objectContaining({
130
+ source: "Network path coverage",
131
+ kind: "unknown",
132
+ }),
133
+ ]);
134
+ });
135
+
136
+ it("supports all current get_resource producer modes, including safe Secret detail", () => {
137
+ const result = project([
138
+ tool("bare", "get_resource", deployment),
139
+ tool("context", "get_resource", {
140
+ resource: deployment,
141
+ resourceContext: { tier: "basic" },
142
+ }),
143
+ tool("extras", "get_resource", {
144
+ resource: deployment,
145
+ resourceContext: { tier: "basic" },
146
+ events: [warningEvent],
147
+ recentChanges: [],
148
+ recentChangesSaturated: false,
149
+ recentChangesCoverageLimited: false,
150
+ }),
151
+ tool("secret-bare", "get_resource", {
152
+ kind: "Secret",
153
+ name: "api-credentials",
154
+ namespace: "shop",
155
+ type: "Opaque",
156
+ keys: ["MONGO_PASSWORD", "API_TOKEN"],
157
+ }),
158
+ tool("secret-wrapped", "get_resource", {
159
+ resource: {
160
+ kind: "Secret",
161
+ name: "api-credentials",
162
+ namespace: "shop",
163
+ type: "Opaque",
164
+ keys: ["MONGO_PASSWORD", "API_TOKEN"],
165
+ },
166
+ resourceContext: { tier: "basic" },
167
+ }),
168
+ ]);
169
+
170
+ const resources = groupsOf(result.groups, "resource");
171
+ expect(resources).toHaveLength(2);
172
+ const deploymentGroup = resources.find(
173
+ (group) =>
174
+ group.latest.data.type === "resource" &&
175
+ group.latest.data.resource.kind === "Deployment",
176
+ )!;
177
+ const secretGroup = resources.find(
178
+ (group) =>
179
+ group.latest.data.type === "resource" &&
180
+ group.latest.data.resource.kind === "Secret",
181
+ )!;
182
+ expect(deploymentGroup.observations.map((item) => item.revision)).toEqual([
183
+ 1, 2, 3,
184
+ ]);
185
+ expect(
186
+ deploymentGroup.observations.map((item) => item.source.stepId),
187
+ ).toEqual(["bare", "context", "extras"]);
188
+ expect(secretGroup.latest.data).toMatchObject({
189
+ type: "resource",
190
+ resource: {
191
+ apiVersion: "v1",
192
+ kind: "Secret",
193
+ metadata: { namespace: "shop", name: "api-credentials" },
194
+ keys: ["MONGO_PASSWORD", "API_TOKEN"],
195
+ },
196
+ });
197
+ expect(secretGroup.observations.map((item) => item.source.stepId)).toEqual([
198
+ "secret-bare",
199
+ "secret-wrapped",
200
+ ]);
201
+ expect(groupsOf(result.groups, "events")).toHaveLength(1);
202
+ expect(groupsOf(result.groups, "receipt")).toHaveLength(1);
203
+ });
204
+
205
+ it("keeps get_resource recent-change completeness explicit", () => {
206
+ const complete = project([
207
+ tool("complete", "get_resource", {
208
+ resource: deployment,
209
+ recentChanges: [],
210
+ recentChangesSaturated: false,
211
+ recentChangesCoverageLimited: false,
212
+ }),
213
+ ]);
214
+ expect(
215
+ groupsOf(complete.groups, "receipt").some(
216
+ (group) =>
217
+ group.latest.data.type === "receipt" &&
218
+ group.latest.data.checked === "changes",
219
+ ),
220
+ ).toBe(true);
221
+ expect(complete.limitations).toHaveLength(0);
222
+
223
+ const capped = project([
224
+ tool("capped", "get_resource", {
225
+ resource: deployment,
226
+ recentChanges: [
227
+ {
228
+ kind: "Deployment",
229
+ namespace: "shop",
230
+ name: "api",
231
+ changeType: "update",
232
+ timestamp: "2026-09-02T09:55:00Z",
233
+ summary: "image changed",
234
+ },
235
+ ],
236
+ recentChangesSaturated: true,
237
+ recentChangesCoverageLimited: false,
238
+ }),
239
+ ]);
240
+ expect(groupsOf(capped.groups, "changes")).toHaveLength(1);
241
+ expect(groupsOf(capped.groups, "receipt")).toHaveLength(0);
242
+ expect(capped.limitations).toEqual([
243
+ expect.objectContaining({
244
+ source: "Recent changes",
245
+ kind: "truncated",
246
+ message: expect.stringContaining("result limit was reached"),
247
+ }),
248
+ ]);
249
+
250
+ const filtered = project([
251
+ tool("filtered", "get_resource", {
252
+ resource: deployment,
253
+ recentChanges: [],
254
+ recentChangesSaturated: false,
255
+ recentChangesCoverageLimited: true,
256
+ }),
257
+ ]);
258
+ expect(groupsOf(filtered.groups, "receipt")).toHaveLength(0);
259
+ expect(filtered.limitations).toEqual([
260
+ expect.objectContaining({
261
+ source: "Recent changes",
262
+ kind: "unknown",
263
+ message: expect.stringContaining("Change history for"),
264
+ presentation: "history",
265
+ }),
266
+ ]);
267
+
268
+ const cappedAfterFiltering = project([
269
+ tool("capped-filtered", "get_resource", {
270
+ resource: deployment,
271
+ recentChangesSaturated: true,
272
+ recentChangesCoverageLimited: true,
273
+ }),
274
+ ]);
275
+ expect(groupsOf(cappedAfterFiltering.groups, "receipt")).toHaveLength(0);
276
+ expect(cappedAfterFiltering.limitations).toEqual([
277
+ expect.objectContaining({
278
+ source: "Recent changes",
279
+ kind: "truncated",
280
+ }),
281
+ expect.objectContaining({
282
+ source: "Recent changes",
283
+ kind: "unknown",
284
+ message: expect.stringContaining("Change history for"),
285
+ presentation: "history",
286
+ }),
287
+ ]);
288
+
289
+ for (const metadata of [
290
+ {},
291
+ {
292
+ recentChangesSaturated: "false",
293
+ recentChangesCoverageLimited: false,
294
+ },
295
+ {
296
+ recentChangesSaturated: false,
297
+ recentChangesCoverageLimited: "false",
298
+ },
299
+ { recentChangesSaturated: false },
300
+ { recentChangesCoverageLimited: false },
301
+ ]) {
302
+ const untrusted = project([
303
+ tool("untrusted", "get_resource", {
304
+ resource: deployment,
305
+ recentChanges: [],
306
+ ...metadata,
307
+ }),
308
+ ]);
309
+ expect(groupsOf(untrusted.groups, "receipt")).toHaveLength(0);
310
+ expect(untrusted.limitations).toEqual([
311
+ expect.objectContaining({
312
+ source: "Recent changes",
313
+ kind: "unknown",
314
+ }),
315
+ ]);
316
+ }
317
+ });
318
+
319
+ it("keeps namespace inventory adverse rows as broader context", () => {
320
+ const result = project([
321
+ tool("inventory", "list_resources", [
322
+ {
323
+ kind: "Deployment",
324
+ namespace: "shop",
325
+ name: "api",
326
+ status: "0/2 Ready",
327
+ ready: "0/2",
328
+ issue: "CrashLoopBackOff",
329
+ summaryContext: { health: "unhealthy", issueCount: 1 },
330
+ },
331
+ {
332
+ kind: "Deployment",
333
+ namespace: "shop",
334
+ name: "worker",
335
+ status: "Running",
336
+ summaryContext: { health: "healthy" },
337
+ },
338
+ ]),
339
+ ]);
340
+ const inventory = groupsOf(result.groups, "inventory")[0].latest;
341
+ expect(inventory).toMatchObject({
342
+ tier: "context",
343
+ relevance: "broader",
344
+ tone: "warning",
345
+ });
346
+ expect(inventory.data).toMatchObject({
347
+ type: "inventory",
348
+ resources: [
349
+ expect.objectContaining({ kind: "Deployment", name: "api" }),
350
+ expect.objectContaining({ kind: "Deployment", name: "worker" }),
351
+ ],
352
+ });
353
+ });
354
+
355
+ it("keeps unmatched rows from broad issue queries out of lead evidence", () => {
356
+ const unrelated = {
357
+ ...criticalIssue,
358
+ id: "issue-db-crash",
359
+ name: "db",
360
+ reason: "DatabaseCrashLoop",
361
+ };
362
+ const result = project([
363
+ tool(
364
+ "issues-broad",
365
+ "issues",
366
+ { issues: [unrelated], total: 1, total_matched: 1 },
367
+ { summary: JSON.stringify({ namespace: "shop" }) },
368
+ ),
369
+ ]);
370
+
371
+ const observation = groupsOf(result.groups, "issue")[0].latest;
372
+ expect(observation).toMatchObject({
373
+ tier: "context",
374
+ data: { type: "issue", relevance: "broader" },
375
+ });
376
+ });
377
+
378
+ it("treats API group as part of exact target identity", () => {
379
+ const coreServiceTarget = {
380
+ kind: "Service",
381
+ group: "",
382
+ namespace: "shop",
383
+ name: "api",
384
+ };
385
+ const knative = projectInvestigationEvidence(
386
+ [
387
+ {
388
+ timeline: [
389
+ tool(
390
+ "knative-service",
391
+ "get_resource",
392
+ {
393
+ apiVersion: "serving.knative.dev/v1",
394
+ kind: "Service",
395
+ metadata: { namespace: "shop", name: "api" },
396
+ },
397
+ {
398
+ summary: JSON.stringify({
399
+ group: "serving.knative.dev",
400
+ kind: "Service",
401
+ namespace: "shop",
402
+ name: "api",
403
+ }),
404
+ },
405
+ ),
406
+ ],
407
+ },
408
+ ],
409
+ coreServiceTarget,
410
+ );
411
+ expect(groupsOf(knative.groups, "resource")[0].latest).toMatchObject({
412
+ relevance: "broader",
413
+ tier: "context",
414
+ });
415
+
416
+ const core = projectInvestigationEvidence(
417
+ [
418
+ {
419
+ timeline: [
420
+ tool(
421
+ "core-service",
422
+ "get_resource",
423
+ {
424
+ apiVersion: "v1",
425
+ kind: "Service",
426
+ metadata: { namespace: "shop", name: "api" },
427
+ },
428
+ {
429
+ summary: JSON.stringify({
430
+ group: "",
431
+ kind: "Service",
432
+ namespace: "shop",
433
+ name: "api",
434
+ }),
435
+ },
436
+ ),
437
+ ],
438
+ },
439
+ ],
440
+ coreServiceTarget,
441
+ );
442
+ expect(groupsOf(core.groups, "resource")[0].latest.relevance).toBe(
443
+ "target",
444
+ );
445
+
446
+ const mismatchedIssue = projectInvestigationEvidence(
447
+ [
448
+ {
449
+ timeline: [
450
+ tool("knative-issue", "issues", {
451
+ issues: [
452
+ {
453
+ ...criticalIssue,
454
+ id: "knative-service-issue",
455
+ group: "serving.knative.dev",
456
+ kind: "Service",
457
+ },
458
+ ],
459
+ total: 1,
460
+ total_matched: 1,
461
+ }),
462
+ ],
463
+ },
464
+ ],
465
+ coreServiceTarget,
466
+ );
467
+ expect(groupsOf(mismatchedIssue.groups, "issue")[0].latest.relevance).toBe(
468
+ "broader",
469
+ );
470
+ });
471
+
472
+ it("attributes evidence to saved Hub runs with a plural resource kind", () => {
473
+ const result = projectInvestigationEvidence(
474
+ [
475
+ {
476
+ timeline: [
477
+ tool("deployment", "get_resource", deployment),
478
+ tool("wrong-group", "get_resource", {
479
+ ...deployment,
480
+ apiVersion: "example.com/v1",
481
+ }),
482
+ tool(
483
+ "plural-events",
484
+ "get_events",
485
+ { events: [warningEvent] },
486
+ {
487
+ summary: JSON.stringify({
488
+ kind: "deployments",
489
+ namespace: "shop",
490
+ name: "api",
491
+ }),
492
+ },
493
+ ),
494
+ tool("plural-issue", "issues", {
495
+ issues: [{ ...criticalIssue, kind: "deployments" }],
496
+ total: 1,
497
+ total_matched: 1,
498
+ }),
499
+ ],
500
+ },
501
+ ],
502
+ { ...target, kind: "deployments" },
503
+ );
504
+
505
+ const resources = groupsOf(result.groups, "resource");
506
+ expect(resources.map((group) => group.latest.relevance)).toEqual([
507
+ "target",
508
+ "broader",
509
+ ]);
510
+ expect(groupsOf(result.groups, "events")[0].latest.relevance).toBe(
511
+ "target",
512
+ );
513
+ expect(groupsOf(result.groups, "issue")[0].latest.relevance).toBe(
514
+ "target",
515
+ );
516
+ });
517
+
518
+ it("keeps colliding non-core plurals on their own API group", () => {
519
+ const podMetrics = {
520
+ apiVersion: "metrics.k8s.io/v1beta1",
521
+ kind: "PodMetrics",
522
+ metadata: { namespace: "shop", name: "api" },
523
+ };
524
+ const result = projectInvestigationEvidence(
525
+ [
526
+ {
527
+ timeline: [
528
+ tool("metrics-pod", "get_resource", podMetrics),
529
+ tool("core-pod", "get_resource", {
530
+ ...podMetrics,
531
+ apiVersion: "v1",
532
+ kind: "Pod",
533
+ }),
534
+ ],
535
+ },
536
+ ],
537
+ { ...target, kind: "pods", group: "metrics.k8s.io" },
538
+ );
539
+ expect(
540
+ groupsOf(result.groups, "resource").map(
541
+ (group) => group.latest.relevance,
542
+ ),
543
+ ).toEqual(["target", "broader"]);
544
+ });
545
+
546
+ it("matches an undiscovered irregular CRD without inventing its Kind", () => {
547
+ const result = projectInvestigationEvidence(
548
+ [
549
+ {
550
+ timeline: [
551
+ tool("database", "get_resource", {
552
+ apiVersion: "postgresql.cnpg.io/v1",
553
+ kind: "Database",
554
+ metadata: { namespace: "shop", name: "api" },
555
+ }),
556
+ ],
557
+ },
558
+ ],
559
+ { ...target, kind: "databases", group: "postgresql.cnpg.io" },
560
+ );
561
+ expect(groupsOf(result.groups, "resource")[0].latest.relevance).toBe(
562
+ "target",
563
+ );
564
+ });
565
+
566
+ it("treats an unexpressible event group as unspecified while honoring explicit groups", () => {
567
+ const eventsFor = (group?: string) =>
568
+ project([
569
+ tool(
570
+ `events-${group ?? "unspecified"}`,
571
+ "get_events",
572
+ { events: [warningEvent] },
573
+ {
574
+ summary: JSON.stringify({
575
+ ...(group === undefined ? {} : { group }),
576
+ kind: "Deployment",
577
+ namespace: "shop",
578
+ name: "api",
579
+ }),
580
+ },
581
+ ),
582
+ ]);
583
+
584
+ for (const group of [undefined, "apps"] as const) {
585
+ expect(
586
+ groupsOf(eventsFor(group).groups, "events")[0].latest,
587
+ ).toMatchObject({
588
+ relevance: "target",
589
+ tier: "supporting",
590
+ });
591
+ }
592
+ expect(groupsOf(eventsFor("").groups, "events")[0].latest).toMatchObject({
593
+ relevance: "broader",
594
+ tier: "context",
595
+ });
596
+ expect(
597
+ groupsOf(eventsFor("serving.knative.dev").groups, "events")[0].latest,
598
+ ).toMatchObject({ relevance: "broader", tier: "context" });
599
+ });
600
+
601
+ it("labels direct Pod startup, crash, and log evidence as target evidence", () => {
602
+ const podTarget = {
603
+ kind: "Pod",
604
+ group: "",
605
+ namespace: "shop",
606
+ name: "api-abc",
607
+ };
608
+ const result = projectInvestigationEvidence(
609
+ [
610
+ {
611
+ timeline: [
612
+ tool(
613
+ "diagnose-pod",
614
+ "diagnose",
615
+ {
616
+ resource: {
617
+ apiVersion: "v1",
618
+ kind: "Pod",
619
+ metadata: { namespace: "shop", name: "api-abc" },
620
+ },
621
+ resourceContext: { tier: "basic" },
622
+ startupBlockers: [
623
+ {
624
+ kind: "Pod",
625
+ name: "api-abc",
626
+ reason: "ImagePullBackOff",
627
+ severity: "critical",
628
+ message: "The image could not be pulled.",
629
+ },
630
+ ],
631
+ crashCause: [
632
+ {
633
+ pods: ["api-abc"],
634
+ container: "api",
635
+ state: "terminated",
636
+ reason: "Error",
637
+ exitCode: 1,
638
+ logLine: "FATAL: configuration missing",
639
+ logSource: "previous",
640
+ logLineSelection: "fatal_pattern",
641
+ },
642
+ ],
643
+ logsCurrent: [
644
+ {
645
+ pod: "api-abc",
646
+ container: "api",
647
+ logs: {
648
+ lines: ["FATAL: configuration missing"],
649
+ totalLines: 1,
650
+ matchedLines: 1,
651
+ fallback: false,
652
+ },
653
+ },
654
+ ],
655
+ },
656
+ {
657
+ summary: JSON.stringify({
658
+ group: "",
659
+ kind: "Pod",
660
+ namespace: "shop",
661
+ name: "api-abc",
662
+ }),
663
+ },
664
+ ),
665
+ ],
666
+ },
667
+ ],
668
+ podTarget,
669
+ );
670
+
671
+ for (const kind of ["startup", "crash", "logs"] as const) {
672
+ expect(groupsOf(result.groups, kind)[0].latest.relevance).toBe("target");
673
+ }
674
+ });
675
+
676
+ it("compares resource revisions without their tool-specific context", () => {
677
+ const result = project([
678
+ tool("diagnose", "diagnose", {
679
+ resource: deployment,
680
+ resourceContext: {
681
+ tier: "diagnostic",
682
+ workloadSummary: { replicas: { desired: 1, ready: 0 } },
683
+ },
684
+ gitOpsDiagnosis: { tool: "argocd", sync: "OutOfSync" },
685
+ }),
686
+ tool("read", "get_resource", {
687
+ resource: deployment,
688
+ context: { tier: "basic" },
689
+ }),
690
+ tool("changed", "get_resource", {
691
+ resource: { ...deployment, status: { readyReplicas: 1, replicas: 1 } },
692
+ context: { tier: "basic" },
693
+ }),
694
+ ]);
695
+ const group = groupsOf(result.groups, "resource")[0];
696
+ expect(group.observations.map((item) => item.changedFromPrevious)).toEqual([
697
+ false,
698
+ false,
699
+ true,
700
+ ]);
701
+ expect(group.observations.map((item) => item.source.stepId)).toEqual([
702
+ "diagnose",
703
+ "read",
704
+ "changed",
705
+ ]);
706
+ });
707
+
708
+ it("does not let a later broad query overwrite producer-established issue relevance", () => {
709
+ const relatedPodIssue = {
710
+ ...criticalIssue,
711
+ id: "issue-api-pod-crash",
712
+ kind: "Pod",
713
+ group: "",
714
+ name: "api-abc",
715
+ };
716
+ const result = project([
717
+ tool("diagnose-target", "diagnose", {
718
+ resource: deployment,
719
+ resourceContext: { tier: "basic" },
720
+ relatedIssues: [relatedPodIssue],
721
+ }),
722
+ tool("issues-broad", "issues", {
723
+ issues: [relatedPodIssue],
724
+ total: 1,
725
+ total_matched: 1,
726
+ }),
727
+ ]);
728
+
729
+ const group = groupsOf(result.groups, "issue")[0];
730
+ expect(group.observations).toHaveLength(2);
731
+ expect(group.observations[1]).toMatchObject({
732
+ relevance: "broader",
733
+ tier: "context",
734
+ changedFromPrevious: false,
735
+ source: { stepId: "issues-broad" },
736
+ });
737
+ expect(group.latest).toMatchObject({
738
+ relevance: "producer-related",
739
+ tier: "key",
740
+ source: { stepId: "diagnose-target" },
741
+ });
742
+
743
+ const reversed = project([
744
+ tool("issues-broad", "issues", {
745
+ issues: [relatedPodIssue],
746
+ total: 1,
747
+ total_matched: 1,
748
+ }),
749
+ tool("diagnose-target", "diagnose", {
750
+ resource: deployment,
751
+ resourceContext: { tier: "basic" },
752
+ relatedIssues: [relatedPodIssue],
753
+ }),
754
+ ]);
755
+ expect(groupsOf(reversed.groups, "issue")[0].latest).toMatchObject({
756
+ relevance: "producer-related",
757
+ tier: "key",
758
+ source: { stepId: "diagnose-target" },
759
+ });
760
+ });
761
+
762
+ it("keeps a changed broad payload in history without laundering it into the lead card", () => {
763
+ const relatedPodIssue = {
764
+ ...criticalIssue,
765
+ id: "issue-api-pod-crash",
766
+ kind: "Pod",
767
+ group: "",
768
+ name: "api-abc",
769
+ };
770
+ const result = project([
771
+ tool("diagnose-target", "diagnose", {
772
+ resource: deployment,
773
+ resourceContext: { tier: "basic" },
774
+ relatedIssues: [relatedPodIssue],
775
+ }),
776
+ tool("issues-broad", "issues", {
777
+ issues: [
778
+ {
779
+ ...relatedPodIssue,
780
+ reason: "ImagePullBackOff",
781
+ message: "A later broad read saw an image pull failure.",
782
+ },
783
+ ],
784
+ total: 1,
785
+ total_matched: 1,
786
+ }),
787
+ ]);
788
+
789
+ const group = groupsOf(result.groups, "issue")[0];
790
+ expect(group.observations[1]).toMatchObject({
791
+ changedFromPrevious: true,
792
+ title: "ImagePullBackOff",
793
+ relevance: "broader",
794
+ });
795
+ expect(group.latest).toMatchObject({
796
+ title: "CrashLoopBackOff",
797
+ relevance: "producer-related",
798
+ source: { stepId: "diagnose-target" },
799
+ });
800
+ });
801
+
802
+ it("partitions weak log, startup, and crash identities by proof scope", () => {
803
+ const evidence = {
804
+ resourceContext: { tier: "basic" },
805
+ startupBlockers: [
806
+ {
807
+ kind: "Pod",
808
+ name: "api-abc",
809
+ reason: "Unschedulable",
810
+ severity: "critical",
811
+ message: "No matching nodes",
812
+ },
813
+ ],
814
+ crashCause: [
815
+ {
816
+ pods: ["api-abc"],
817
+ container: "api",
818
+ state: "terminated",
819
+ reason: "Error",
820
+ exitCode: 1,
821
+ logLine: "FATAL: missing config",
822
+ logSource: "previous",
823
+ logLineSelection: "fatal_pattern",
824
+ },
825
+ ],
826
+ logsCurrent: [
827
+ {
828
+ pod: "api-abc",
829
+ container: "api",
830
+ logs: {
831
+ lines: ["FATAL: missing config"],
832
+ totalLines: 1,
833
+ matchedLines: 1,
834
+ fallback: false,
835
+ },
836
+ },
837
+ ],
838
+ };
839
+ const result = project([
840
+ tool("diagnose-target", "diagnose", {
841
+ ...evidence,
842
+ resource: deployment,
843
+ }),
844
+ tool(
845
+ "diagnose-sibling",
846
+ "diagnose",
847
+ {
848
+ ...evidence,
849
+ resource: {
850
+ ...deployment,
851
+ metadata: { namespace: "other", name: "worker" },
852
+ },
853
+ },
854
+ {
855
+ summary: JSON.stringify({
856
+ kind: "Deployment",
857
+ namespace: "other",
858
+ name: "worker",
859
+ }),
860
+ },
861
+ ),
862
+ ]);
863
+
864
+ for (const kind of ["logs", "startup", "crash"] as const) {
865
+ const groups = groupsOf(result.groups, kind);
866
+ expect(groups).toHaveLength(2);
867
+ expect(groups.map((group) => group.latest.relevance).sort()).toEqual([
868
+ "broader",
869
+ "producer-related",
870
+ ]);
871
+ }
872
+ });
873
+
874
+ it("keeps same-relevance log reads from different namespaces separate", () => {
875
+ const logs = {
876
+ lines: ["FATAL: missing config"],
877
+ totalLines: 1,
878
+ matchedLines: 1,
879
+ fallback: false,
880
+ };
881
+ const result = project([
882
+ tool("logs-shop", "get_pod_logs", logs, {
883
+ summary: JSON.stringify({
884
+ namespace: "shop",
885
+ name: "api-abc",
886
+ container: "api",
887
+ }),
888
+ }),
889
+ tool("logs-other", "get_pod_logs", logs, {
890
+ summary: JSON.stringify({
891
+ namespace: "other",
892
+ name: "api-abc",
893
+ container: "api",
894
+ }),
895
+ }),
896
+ ]);
897
+
898
+ const groups = groupsOf(result.groups, "logs");
899
+ expect(groups).toHaveLength(2);
900
+ expect(groups.every((group) => group.latest.relevance === "broader")).toBe(
901
+ true,
902
+ );
903
+ expect(groups.map((group) => group.observations.length)).toEqual([1, 1]);
904
+ });
905
+
906
+ it("does not promote a sibling diagnose bundle as evidence for the target", () => {
907
+ const sibling = {
908
+ ...deployment,
909
+ metadata: { namespace: "shop", name: "worker" },
910
+ };
911
+ const siblingIssue = {
912
+ ...criticalIssue,
913
+ id: "issue-worker-crash",
914
+ name: "worker",
915
+ reason: "WorkerCrashLoop",
916
+ };
917
+ const result = project([
918
+ tool(
919
+ "diagnose-sibling",
920
+ "diagnose",
921
+ {
922
+ resource: sibling,
923
+ resourceContext: {
924
+ tier: "basic",
925
+ issueSummary: {
926
+ count: 1,
927
+ highestSeverity: "critical",
928
+ topReason: "WorkerCrashLoop",
929
+ },
930
+ },
931
+ relatedIssues: [siblingIssue],
932
+ events: [warningEvent],
933
+ logsCurrent: [
934
+ {
935
+ pod: "worker-abc",
936
+ container: "worker",
937
+ logs: {
938
+ lines: ["FATAL worker crashed"],
939
+ totalLines: 1,
940
+ matchedLines: 1,
941
+ fallback: false,
942
+ },
943
+ },
944
+ ],
945
+ },
946
+ {
947
+ summary: JSON.stringify({
948
+ kind: "Deployment",
949
+ namespace: "shop",
950
+ name: "worker",
951
+ }),
952
+ },
953
+ ),
954
+ ]);
955
+
956
+ expect(result.groups).not.toHaveLength(0);
957
+ expect(
958
+ result.groups.every((group) => group.latest.tier === "context"),
959
+ ).toBe(true);
960
+ expect(
961
+ result.groups.every((group) => group.latest.relevance === "broader"),
962
+ ).toBe(true);
963
+ });
964
+
965
+ it("caps generic resource, event, log, and list results without proven target scope", () => {
966
+ const sibling = {
967
+ ...deployment,
968
+ metadata: { namespace: "shop", name: "worker" },
969
+ };
970
+ const result = project([
971
+ tool(
972
+ "resource-sibling",
973
+ "get_resource",
974
+ {
975
+ resource: sibling,
976
+ resourceContext: {
977
+ tier: "basic",
978
+ issueSummary: {
979
+ count: 1,
980
+ highestSeverity: "critical",
981
+ topReason: "Worker unavailable",
982
+ },
983
+ },
984
+ },
985
+ {
986
+ summary: JSON.stringify({
987
+ kind: "Deployment",
988
+ namespace: "shop",
989
+ name: "worker",
990
+ }),
991
+ },
992
+ ),
993
+ tool(
994
+ "events-namespace",
995
+ "get_events",
996
+ { events: [warningEvent] },
997
+ { summary: JSON.stringify({ namespace: "shop" }) },
998
+ ),
999
+ tool(
1000
+ "logs-child",
1001
+ "get_pod_logs",
1002
+ {
1003
+ lines: ["FATAL child Pod crashed"],
1004
+ totalLines: 1,
1005
+ matchedLines: 1,
1006
+ fallback: false,
1007
+ },
1008
+ {
1009
+ summary: JSON.stringify({
1010
+ namespace: "shop",
1011
+ name: "api-abc",
1012
+ container: "api",
1013
+ }),
1014
+ },
1015
+ ),
1016
+ tool(
1017
+ "list-namespace",
1018
+ "list_resources",
1019
+ [
1020
+ {
1021
+ kind: "Deployment",
1022
+ namespace: "shop",
1023
+ name: "api",
1024
+ summaryContext: { health: "unhealthy", issueCount: 1 },
1025
+ },
1026
+ ],
1027
+ { summary: JSON.stringify({ namespace: "shop" }) },
1028
+ ),
1029
+ ]);
1030
+
1031
+ for (const kind of ["resource", "events", "logs", "inventory"] as const) {
1032
+ const observation = groupsOf(result.groups, kind)[0].latest;
1033
+ expect(observation).toMatchObject({
1034
+ tier: "context",
1035
+ relevance: "broader",
1036
+ });
1037
+ }
1038
+ });
1039
+
1040
+ it("merges repeated observations without inflating evidence count", () => {
1041
+ const first = { ...criticalIssue, severity: "warning" };
1042
+ const second = { ...criticalIssue, message: "Now affecting all replicas." };
1043
+ const result = projectInvestigationEvidence(
1044
+ [
1045
+ {
1046
+ timeline: [
1047
+ tool("issues-a", "issues", {
1048
+ issues: [first],
1049
+ total: 1,
1050
+ total_matched: 1,
1051
+ }),
1052
+ ],
1053
+ },
1054
+ {
1055
+ verify: true,
1056
+ timeline: [
1057
+ tool("issues-b", "issues", {
1058
+ issues: [second],
1059
+ total: 1,
1060
+ total_matched: 1,
1061
+ }),
1062
+ ],
1063
+ },
1064
+ ],
1065
+ target,
1066
+ );
1067
+
1068
+ const issues = groupsOf(result.groups, "issue");
1069
+ expect(issues).toHaveLength(1);
1070
+ expect(issues[0].observations).toHaveLength(2);
1071
+ expect(issues[0].observations.map((item) => item.revision)).toEqual([1, 2]);
1072
+ expect(issues[0].observations.map((item) => item.source.id)).toEqual([
1073
+ "turn-0-step-issues-a",
1074
+ "turn-1-step-issues-b",
1075
+ ]);
1076
+ expect(issues[0].observations.map((item) => item.source.phase)).toEqual([
1077
+ "initial",
1078
+ "verification",
1079
+ ]);
1080
+ expect(
1081
+ issues[0].observations.map((item) => item.changedFromPrevious),
1082
+ ).toEqual([false, true]);
1083
+ expect(issues[0].latest.tier).toBe("key");
1084
+ expect(issues[0].latest.source.args).toContain('"namespace":"shop"');
1085
+ });
1086
+
1087
+ it("recomputes residual chronology after a cited verification is promoted", () => {
1088
+ const ref = evidenceRef("a", "b");
1089
+ const result = projectInvestigationEvidence(
1090
+ [
1091
+ {
1092
+ status: "done",
1093
+ timeline: [
1094
+ tool("initial", "issues", {
1095
+ issues: [criticalIssue],
1096
+ total: 1,
1097
+ total_matched: 1,
1098
+ }),
1099
+ ],
1100
+ },
1101
+ {
1102
+ status: "done",
1103
+ verify: true,
1104
+ timeline: [
1105
+ tool(
1106
+ "verification",
1107
+ "diagnose",
1108
+ {
1109
+ resource: deployment,
1110
+ resourceContext: { tier: "basic" },
1111
+ relatedIssues: [
1112
+ {
1113
+ ...criticalIssue,
1114
+ message: "The verification still sees the failure.",
1115
+ },
1116
+ ],
1117
+ },
1118
+ { evidenceRef: ref },
1119
+ ),
1120
+ ],
1121
+ },
1122
+ ],
1123
+ target,
1124
+ );
1125
+ const group = groupsOf(result.groups, "issue")[0];
1126
+ expect(
1127
+ group.observations.map((observation) => observation.historical),
1128
+ ).toEqual([true, false]);
1129
+ expect(group.historical).toBe(false);
1130
+ expect(group.latest.source.stepId).toBe("verification");
1131
+ expect(group.observations[0].source.stepId).toBe("initial");
1132
+ });
1133
+
1134
+ it("does not promote malformed change correlation or neutral DNS configuration", () => {
1135
+ const change = {
1136
+ kind: "Deployment",
1137
+ namespace: "shop",
1138
+ name: "api",
1139
+ changeType: "update",
1140
+ timestamp: "2026-09-02T09:55:00Z",
1141
+ summary: "image changed",
1142
+ };
1143
+ const result = project([
1144
+ tool("strict-semantics", "diagnose", {
1145
+ resource: deployment,
1146
+ resourceContext: { tier: "basic" },
1147
+ pods: 0,
1148
+ recentChanges: [change],
1149
+ changeContext: {
1150
+ changed: "false",
1151
+ what: "not a valid producer value",
1152
+ },
1153
+ dnsContext: {
1154
+ signals: [
1155
+ "api uses dnsPolicy=None",
1156
+ "api sets dnsConfig.nameservers",
1157
+ ],
1158
+ },
1159
+ events: [],
1160
+ }),
1161
+ ]);
1162
+
1163
+ expect(groupsOf(result.groups, "changes")[0].latest.tier).toBe("context");
1164
+ expect(groupsOf(result.groups, "dns")[0].latest).toMatchObject({
1165
+ tier: "context",
1166
+ tone: "info",
1167
+ title: "DNS configuration",
1168
+ });
1169
+ expect(result.limitations).toEqual(
1170
+ expect.arrayContaining([
1171
+ expect.objectContaining({ source: "Change correlation" }),
1172
+ ]),
1173
+ );
1174
+ });
1175
+
1176
+ it("keeps exact API identity for recent changes and rejects malformed identity", () => {
1177
+ const exactChange = {
1178
+ apiVersion: "apps/v1",
1179
+ kind: "Deployment",
1180
+ namespace: "shop",
1181
+ name: "api",
1182
+ changeType: "update",
1183
+ timestamp: "2026-09-02T09:55:00Z",
1184
+ };
1185
+ const exact = project([
1186
+ tool("exact-change", "get_changes", { changes: [exactChange] }),
1187
+ ]);
1188
+ const changes = groupsOf(exact.groups, "changes")[0].latest.data;
1189
+
1190
+ expect(changes.type).toBe("changes");
1191
+ if (changes.type === "changes") {
1192
+ expect(changes.changes[0].apiVersion).toBe("apps/v1");
1193
+ }
1194
+
1195
+ const malformed = project([
1196
+ tool("malformed-change", "get_changes", {
1197
+ changes: [{ ...exactChange, apiVersion: "" }],
1198
+ }),
1199
+ ]);
1200
+ expect(groupsOf(malformed.groups, "changes")).toHaveLength(0);
1201
+ expect(malformed.limitations).toEqual(
1202
+ expect.arrayContaining([
1203
+ expect.objectContaining({ source: "Recent changes" }),
1204
+ ]),
1205
+ );
1206
+ });
1207
+
1208
+ it("only moves domains actually rechecked by verification into Earlier evidence", () => {
1209
+ const relatedPodIssue = {
1210
+ ...criticalIssue,
1211
+ id: "issue-api-pod-crash",
1212
+ kind: "Pod",
1213
+ group: "",
1214
+ name: "api-abc",
1215
+ };
1216
+ const initial = {
1217
+ resource: deployment,
1218
+ resourceContext: { tier: "basic" },
1219
+ pods: 1,
1220
+ relatedIssues: [relatedPodIssue],
1221
+ crashCause: [
1222
+ {
1223
+ pods: ["api-abc"],
1224
+ container: "api",
1225
+ state: "terminated",
1226
+ reason: "Error",
1227
+ exitCode: 1,
1228
+ logLine: "FATAL: missing DATABASE_URL",
1229
+ logSource: "previous",
1230
+ logLineSelection: "fatal_pattern",
1231
+ },
1232
+ ],
1233
+ events: [warningEvent],
1234
+ recentChanges: [],
1235
+ };
1236
+ const resourceOnly = projectInvestigationEvidence(
1237
+ [
1238
+ { status: "done", timeline: [tool("initial", "diagnose", initial)] },
1239
+ {
1240
+ status: "done",
1241
+ verify: true,
1242
+ timeline: [tool("resource-only", "get_resource", deployment)],
1243
+ },
1244
+ ],
1245
+ target,
1246
+ );
1247
+ expect(groupsOf(resourceOnly.groups, "issue")[0].historical).toBe(false);
1248
+ expect(groupsOf(resourceOnly.groups, "crash")[0].historical).toBe(false);
1249
+
1250
+ const targetOnlyEventVerification = projectInvestigationEvidence(
1251
+ [
1252
+ { status: "done", timeline: [tool("initial", "diagnose", initial)] },
1253
+ {
1254
+ status: "done",
1255
+ verify: true,
1256
+ timeline: [
1257
+ tool("resource-events", "get_resource", {
1258
+ resource: deployment,
1259
+ resourceContext: { tier: "basic" },
1260
+ events: [],
1261
+ }),
1262
+ ],
1263
+ },
1264
+ ],
1265
+ target,
1266
+ );
1267
+ expect(
1268
+ groupsOf(targetOnlyEventVerification.groups, "events")[0].historical,
1269
+ ).toBe(false);
1270
+
1271
+ const fullVerification = projectInvestigationEvidence(
1272
+ [
1273
+ { status: "done", timeline: [tool("initial", "diagnose", initial)] },
1274
+ {
1275
+ status: "done",
1276
+ timeline: [
1277
+ tool("broad-reread", "issues", {
1278
+ issues: [relatedPodIssue],
1279
+ total: 1,
1280
+ total_matched: 1,
1281
+ }),
1282
+ ],
1283
+ },
1284
+ {
1285
+ status: "done",
1286
+ verify: true,
1287
+ timeline: [
1288
+ tool("verified", "diagnose", {
1289
+ resource: {
1290
+ ...deployment,
1291
+ status: { readyReplicas: 1, replicas: 1 },
1292
+ },
1293
+ resourceContext: {
1294
+ tier: "basic",
1295
+ workloadSummary: {
1296
+ replicas: {
1297
+ desired: 1,
1298
+ ready: 1,
1299
+ available: 1,
1300
+ unavailable: 0,
1301
+ },
1302
+ },
1303
+ },
1304
+ pods: 1,
1305
+ ...completeLogProof(),
1306
+ events: [],
1307
+ recentChanges: [],
1308
+ }),
1309
+ ],
1310
+ },
1311
+ ],
1312
+ target,
1313
+ );
1314
+ const verifiedIssue = groupsOf(fullVerification.groups, "issue")[0];
1315
+ expect(
1316
+ verifiedIssue.observations.map((observation) => observation.relevance),
1317
+ ).toEqual(["producer-related", "broader"]);
1318
+ expect(verifiedIssue.historical).toBe(true);
1319
+ expect(groupsOf(fullVerification.groups, "crash")[0].historical).toBe(true);
1320
+ expect(groupsOf(fullVerification.groups, "events")[0].historical).toBe(
1321
+ true,
1322
+ );
1323
+ expect(groupsOf(fullVerification.groups, "resource")[0].historical).toBe(
1324
+ false,
1325
+ );
1326
+
1327
+ const siblingVerification = projectInvestigationEvidence(
1328
+ [
1329
+ { status: "done", timeline: [tool("initial", "diagnose", initial)] },
1330
+ {
1331
+ status: "done",
1332
+ verify: true,
1333
+ timeline: [
1334
+ tool(
1335
+ "verified-sibling",
1336
+ "diagnose",
1337
+ {
1338
+ resource: {
1339
+ ...deployment,
1340
+ metadata: { namespace: "shop", name: "worker" },
1341
+ },
1342
+ pods: 1,
1343
+ events: [],
1344
+ recentChanges: [],
1345
+ },
1346
+ {
1347
+ summary: JSON.stringify({
1348
+ kind: "Deployment",
1349
+ namespace: "shop",
1350
+ name: "worker",
1351
+ }),
1352
+ },
1353
+ ),
1354
+ ],
1355
+ },
1356
+ ],
1357
+ target,
1358
+ );
1359
+ expect(groupsOf(siblingVerification.groups, "issue")[0].historical).toBe(
1360
+ false,
1361
+ );
1362
+ expect(groupsOf(siblingVerification.groups, "crash")[0].historical).toBe(
1363
+ false,
1364
+ );
1365
+ });
1366
+
1367
+ it("retires only semantic domains the verification actually covered", () => {
1368
+ const initial = {
1369
+ resource: deployment,
1370
+ pods: 1,
1371
+ relatedIssues: [criticalIssue],
1372
+ startupBlockers: [
1373
+ {
1374
+ kind: "Pod",
1375
+ name: "api-abc",
1376
+ reason: "ImagePullBackOff",
1377
+ severity: "critical",
1378
+ message: "The image could not be pulled.",
1379
+ },
1380
+ ],
1381
+ crashCause: [
1382
+ {
1383
+ pods: ["api-abc"],
1384
+ container: "api",
1385
+ state: "terminated",
1386
+ reason: "Error",
1387
+ exitCode: 1,
1388
+ logLine: "FATAL: missing DATABASE_URL",
1389
+ logSource: "previous",
1390
+ logLineSelection: "fatal_pattern",
1391
+ },
1392
+ ],
1393
+ dnsContext: { signals: ["DNS timeout in application logs"] },
1394
+ events: [],
1395
+ recentChanges: [],
1396
+ };
1397
+ const result = projectInvestigationEvidence(
1398
+ [
1399
+ { status: "done", timeline: [tool("initial", "diagnose", initial)] },
1400
+ {
1401
+ status: "done",
1402
+ verify: true,
1403
+ timeline: [
1404
+ tool("partial-verification", "diagnose", {
1405
+ resource: {
1406
+ ...deployment,
1407
+ status: { readyReplicas: 1, replicas: 1 },
1408
+ },
1409
+ pods: 1,
1410
+ relatedIssues: [],
1411
+ startupBlockers: [],
1412
+ logsError: "pods/log is forbidden",
1413
+ events: [],
1414
+ recentChanges: [],
1415
+ }),
1416
+ ],
1417
+ },
1418
+ ],
1419
+ target,
1420
+ );
1421
+
1422
+ expect(groupsOf(result.groups, "issue")[0].historical).toBe(true);
1423
+ expect(groupsOf(result.groups, "startup")[0].historical).toBe(true);
1424
+ expect(groupsOf(result.groups, "crash")[0].historical).toBe(false);
1425
+ expect(groupsOf(result.groups, "dns")[0].historical).toBe(false);
1426
+ });
1427
+
1428
+ it.each([
1429
+ ["pod selection was truncated", { selectionTruncated: true }],
1430
+ ["log content was truncated", { contentTruncated: true }],
1431
+ ])("does not retire crash evidence when %s", (_label, coveragePatch) => {
1432
+ const proof = completeLogProof();
1433
+ const result = projectInvestigationEvidence(
1434
+ [
1435
+ {
1436
+ status: "done",
1437
+ timeline: [
1438
+ tool("initial", "diagnose", {
1439
+ resource: deployment,
1440
+ pods: 1,
1441
+ crashCause: [
1442
+ {
1443
+ pods: ["api-abc"],
1444
+ container: "api",
1445
+ state: "terminated",
1446
+ reason: "Error",
1447
+ exitCode: 1,
1448
+ logLine: "FATAL: missing DATABASE_URL",
1449
+ logSource: "previous",
1450
+ logLineSelection: "fatal_pattern",
1451
+ },
1452
+ ],
1453
+ }),
1454
+ ],
1455
+ },
1456
+ {
1457
+ status: "done",
1458
+ verify: true,
1459
+ timeline: [
1460
+ tool("verification", "diagnose", {
1461
+ resource: deployment,
1462
+ pods: 1,
1463
+ ...proof,
1464
+ logCoverage: {
1465
+ ...proof.logCoverage,
1466
+ ...coveragePatch,
1467
+ },
1468
+ }),
1469
+ ],
1470
+ },
1471
+ ],
1472
+ target,
1473
+ );
1474
+
1475
+ expect(groupsOf(result.groups, "crash")[0].historical).toBe(false);
1476
+ });
1477
+
1478
+ it.each([
1479
+ ["one container", { container: "sidecar" }, "sidecar"],
1480
+ ["a recent time window", { since: "5m" }, "api"],
1481
+ ["a shorter log tail", { tail_lines: 20 }, "api"],
1482
+ ])(
1483
+ "does not retire target-wide crash evidence after checking only %s",
1484
+ (_label, queryPatch, container) => {
1485
+ const result = projectInvestigationEvidence(
1486
+ [
1487
+ {
1488
+ status: "done",
1489
+ timeline: [
1490
+ tool("initial", "diagnose", {
1491
+ resource: deployment,
1492
+ pods: 1,
1493
+ crashCause: [
1494
+ {
1495
+ pods: ["api-abc"],
1496
+ container: "api",
1497
+ state: "terminated",
1498
+ reason: "Error",
1499
+ exitCode: 1,
1500
+ logLine: "FATAL: missing DATABASE_URL",
1501
+ logSource: "previous",
1502
+ logLineSelection: "fatal_pattern",
1503
+ },
1504
+ ],
1505
+ }),
1506
+ ],
1507
+ },
1508
+ {
1509
+ status: "done",
1510
+ verify: true,
1511
+ timeline: [
1512
+ tool(
1513
+ "narrow-verification",
1514
+ "diagnose",
1515
+ {
1516
+ resource: deployment,
1517
+ pods: 1,
1518
+ ...completeLogProof(container),
1519
+ },
1520
+ {
1521
+ summary: JSON.stringify({
1522
+ group: "apps",
1523
+ kind: "Deployment",
1524
+ namespace: "shop",
1525
+ name: "api",
1526
+ ...queryPatch,
1527
+ }),
1528
+ },
1529
+ ),
1530
+ ],
1531
+ },
1532
+ ],
1533
+ target,
1534
+ );
1535
+
1536
+ expect(groupsOf(result.groups, "crash")[0].historical).toBe(false);
1537
+ },
1538
+ );
1539
+
1540
+ it("keeps retirement monotonic across later verification of another proof scope", () => {
1541
+ const initialIssue = {
1542
+ resource: deployment,
1543
+ resourceContext: { tier: "basic" },
1544
+ pods: 1,
1545
+ relatedIssues: [criticalIssue],
1546
+ events: [],
1547
+ recentChanges: [],
1548
+ };
1549
+ const result = projectInvestigationEvidence(
1550
+ [
1551
+ {
1552
+ status: "done",
1553
+ timeline: [tool("initial", "diagnose", initialIssue)],
1554
+ },
1555
+ {
1556
+ status: "done",
1557
+ verify: true,
1558
+ timeline: [
1559
+ tool("issue-cleared", "diagnose", {
1560
+ resource: {
1561
+ ...deployment,
1562
+ status: { readyReplicas: 1, replicas: 1 },
1563
+ },
1564
+ resourceContext: { tier: "basic" },
1565
+ pods: 1,
1566
+ relatedIssues: [],
1567
+ events: [],
1568
+ recentChanges: [],
1569
+ }),
1570
+ ],
1571
+ },
1572
+ {
1573
+ status: "done",
1574
+ verify: true,
1575
+ timeline: [tool("later-resource", "get_resource", deployment)],
1576
+ },
1577
+ ],
1578
+ target,
1579
+ );
1580
+
1581
+ expect(groupsOf(result.groups, "issue")[0].historical).toBe(true);
1582
+ });
1583
+
1584
+ it("retires an exact-target issue observed by issues after target diagnosis verifies it", () => {
1585
+ const result = projectInvestigationEvidence(
1586
+ [
1587
+ {
1588
+ status: "done",
1589
+ timeline: [
1590
+ tool(
1591
+ "issue-query",
1592
+ "issues",
1593
+ {
1594
+ issues: [criticalIssue],
1595
+ total: 1,
1596
+ total_matched: 1,
1597
+ },
1598
+ {
1599
+ summary: JSON.stringify({
1600
+ group: "apps",
1601
+ kind: "Deployment",
1602
+ namespace: "shop",
1603
+ name: "api",
1604
+ }),
1605
+ },
1606
+ ),
1607
+ ],
1608
+ },
1609
+ {
1610
+ status: "done",
1611
+ verify: true,
1612
+ timeline: [
1613
+ tool("verified", "diagnose", {
1614
+ resource: {
1615
+ ...deployment,
1616
+ status: { readyReplicas: 1, replicas: 1 },
1617
+ },
1618
+ resourceContext: { tier: "basic" },
1619
+ pods: 1,
1620
+ relatedIssues: [],
1621
+ events: [],
1622
+ recentChanges: [],
1623
+ }),
1624
+ ],
1625
+ },
1626
+ ],
1627
+ target,
1628
+ );
1629
+
1630
+ expect(groupsOf(result.groups, "issue")[0]).toMatchObject({
1631
+ historical: true,
1632
+ latest: { relevance: "target" },
1633
+ });
1634
+ });
1635
+
1636
+ it("treats Argo Rollout diagnose and verification as workload evidence", () => {
1637
+ const rolloutTarget = {
1638
+ kind: "Rollout",
1639
+ group: "argoproj.io",
1640
+ namespace: "shop",
1641
+ name: "api",
1642
+ };
1643
+ const rollout = {
1644
+ apiVersion: "argoproj.io/v1alpha1",
1645
+ kind: "Rollout",
1646
+ metadata: { namespace: "shop", name: "api" },
1647
+ status: { readyReplicas: 1, replicas: 1 },
1648
+ };
1649
+ const rolloutIssue = {
1650
+ ...criticalIssue,
1651
+ id: "issue-rollout-crash",
1652
+ kind: "Rollout",
1653
+ group: "argoproj.io",
1654
+ };
1655
+ const result = projectInvestigationEvidence(
1656
+ [
1657
+ {
1658
+ status: "done",
1659
+ timeline: [
1660
+ tool("rollout-initial", "diagnose", {
1661
+ resource: rollout,
1662
+ relatedIssues: [rolloutIssue],
1663
+ pods: 1,
1664
+ events: [],
1665
+ recentChanges: [],
1666
+ }),
1667
+ ],
1668
+ },
1669
+ {
1670
+ status: "done",
1671
+ verify: true,
1672
+ timeline: [
1673
+ tool("rollout-verified", "diagnose", {
1674
+ resource: rollout,
1675
+ relatedIssues: [],
1676
+ pods: 1,
1677
+ events: [],
1678
+ recentChanges: [],
1679
+ }),
1680
+ ],
1681
+ },
1682
+ ],
1683
+ rolloutTarget,
1684
+ );
1685
+
1686
+ expect(groupsOf(result.groups, "issue")[0].historical).toBe(true);
1687
+ expect(
1688
+ groupsOf(result.groups, "receipt").some(
1689
+ (group) =>
1690
+ group.latest.data.type === "receipt" &&
1691
+ group.latest.data.checked === "issues",
1692
+ ),
1693
+ ).toBe(true);
1694
+ });
1695
+
1696
+ it("retires previous-log evidence only for the pod and container rechecked", () => {
1697
+ const result = projectInvestigationEvidence(
1698
+ [
1699
+ {
1700
+ status: "done",
1701
+ timeline: [
1702
+ tool("initial", "diagnose", {
1703
+ resource: deployment,
1704
+ resourceContext: { tier: "basic" },
1705
+ pods: 2,
1706
+ logsPrevious: [
1707
+ {
1708
+ pod: "api-abc",
1709
+ container: "api",
1710
+ logs: {
1711
+ lines: ["FATAL: missing DATABASE_URL"],
1712
+ totalLines: 1,
1713
+ matchedLines: 1,
1714
+ fallback: false,
1715
+ },
1716
+ },
1717
+ {
1718
+ pod: "api-def",
1719
+ container: "api",
1720
+ logs: {
1721
+ lines: ["ERROR: database unavailable"],
1722
+ totalLines: 1,
1723
+ matchedLines: 1,
1724
+ fallback: false,
1725
+ },
1726
+ },
1727
+ ],
1728
+ events: [],
1729
+ recentChanges: [],
1730
+ }),
1731
+ ],
1732
+ },
1733
+ {
1734
+ status: "done",
1735
+ verify: true,
1736
+ timeline: [
1737
+ tool("verified", "diagnose", {
1738
+ resource: deployment,
1739
+ resourceContext: { tier: "basic" },
1740
+ pods: 2,
1741
+ expectedPreviousLogAbsences: [
1742
+ { pod: "api-abc", container: "api" },
1743
+ ],
1744
+ logsPrevious: [
1745
+ {
1746
+ pod: "api-abc",
1747
+ container: "api",
1748
+ error: "previous terminated container not found",
1749
+ },
1750
+ ],
1751
+ events: [],
1752
+ recentChanges: [],
1753
+ }),
1754
+ ],
1755
+ },
1756
+ ],
1757
+ target,
1758
+ );
1759
+
1760
+ const logGroups = groupsOf(result.groups, "logs");
1761
+ expect(
1762
+ logGroups.find(
1763
+ (group) =>
1764
+ group.latest.data.type === "logs" &&
1765
+ group.latest.data.pod === "api-abc",
1766
+ )?.historical,
1767
+ ).toBe(true);
1768
+ expect(
1769
+ logGroups.find(
1770
+ (group) =>
1771
+ group.latest.data.type === "logs" &&
1772
+ group.latest.data.pod === "api-def",
1773
+ )?.historical,
1774
+ ).toBe(false);
1775
+ });
1776
+
1777
+ it("keeps unknown tools in Activity and limits invalid known contracts", () => {
1778
+ const result = project([
1779
+ tool("other", "discover_metrics", { data: "ignored" }),
1780
+ tool("bad-events", "get_events", { events: [{ nope: true }] }),
1781
+ ]);
1782
+ expect(result.sources.map((source) => source.stepId)).toEqual([
1783
+ "bad-events",
1784
+ ]);
1785
+ expect(result.groups).toHaveLength(0);
1786
+ expect(result.limitations).toHaveLength(1);
1787
+ expect(result.limitations[0].source).toBe("Kubernetes events");
1788
+ expect(result.limitations[0].message).toContain(
1789
+ "couldn't summarize this investigation step",
1790
+ );
1791
+ });
1792
+
1793
+ it("never parses a transcript-truncated result", () => {
1794
+ const result = project([
1795
+ tool(
1796
+ "cut",
1797
+ "issues",
1798
+ { issues: [criticalIssue], total: 1, total_matched: 1 },
1799
+ {
1800
+ truncated: true,
1801
+ },
1802
+ ),
1803
+ ]);
1804
+ expect(result.groups).toHaveLength(0);
1805
+ expect(result.limitations[0]).toMatchObject({ kind: "truncated" });
1806
+ expect(result.limitations[0].source).toBe("Issue scan");
1807
+ expect(result.limitations[0].message).toContain(
1808
+ "Only part of this investigation result was saved",
1809
+ );
1810
+ });
1811
+
1812
+ it("projects non-empty older results but never turns unknown outcomes into receipts", () => {
1813
+ const result = project([
1814
+ tool(
1815
+ "old-data",
1816
+ "get_events",
1817
+ { events: [warningEvent] },
1818
+ { isError: undefined },
1819
+ ),
1820
+ tool("old-empty", "get_events", { events: [] }, { isError: undefined }),
1821
+ ]);
1822
+ expect(groupsOf(result.groups, "events")).toHaveLength(1);
1823
+ expect(groupsOf(result.groups, "receipt")).toHaveLength(0);
1824
+ expect(result.limitations).toHaveLength(1);
1825
+ expect(
1826
+ result.limitations[0].sources.map((source) => source.stepId),
1827
+ ).toEqual(["old-data", "old-empty"]);
1828
+ expect(result.coverage.checked).toBe(0);
1829
+ });
1830
+ });
1831
+
1832
+ describe("honest zero and partial-result states", () => {
1833
+ it("treats empty pod logs as unknown, never Checked", () => {
1834
+ const result = project([
1835
+ tool("logs", "get_pod_logs", {
1836
+ lines: [],
1837
+ totalLines: 0,
1838
+ matchedLines: 0,
1839
+ fallback: false,
1840
+ }),
1841
+ ]);
1842
+ expect(groupsOf(result.groups, "logs")).toHaveLength(0);
1843
+ expect(groupsOf(result.groups, "receipt")).toHaveLength(0);
1844
+ expect(result.limitations[0]).toMatchObject({ kind: "unknown" });
1845
+ expect(result.coverage.checked).toBe(0);
1846
+ });
1847
+
1848
+ it("keeps benign filtered request logs in Context", () => {
1849
+ const result = project([
1850
+ tool("logs", "get_pod_logs", {
1851
+ lines: [
1852
+ "GET /api/issues?severity=critical%2Cwarning HTTP/1.1 200",
1853
+ "GET /api/health HTTP/1.1 200",
1854
+ ],
1855
+ totalLines: 50,
1856
+ matchedLines: 2,
1857
+ fallback: false,
1858
+ }),
1859
+ ]);
1860
+
1861
+ expect(groupsOf(result.groups, "logs")[0].latest).toMatchObject({
1862
+ tier: "context",
1863
+ tone: "neutral",
1864
+ });
1865
+ });
1866
+
1867
+ it("classifies ANSI-colored failures after normalizing their log text", () => {
1868
+ const podTarget = {
1869
+ kind: "Pod",
1870
+ group: "",
1871
+ namespace: "shop",
1872
+ name: "api-abc",
1873
+ };
1874
+ const result = projectInvestigationEvidence(
1875
+ [
1876
+ {
1877
+ timeline: [
1878
+ tool(
1879
+ "logs",
1880
+ "get_pod_logs",
1881
+ {
1882
+ lines: ["\u001b[31mERROR\u001b[0m Authentication failed"],
1883
+ totalLines: 1,
1884
+ matchedLines: 1,
1885
+ fallback: false,
1886
+ },
1887
+ {
1888
+ summary: JSON.stringify({
1889
+ group: "",
1890
+ kind: "Pod",
1891
+ namespace: "shop",
1892
+ name: "api-abc",
1893
+ }),
1894
+ },
1895
+ ),
1896
+ ],
1897
+ },
1898
+ ],
1899
+ podTarget,
1900
+ );
1901
+ const observation = groupsOf(result.groups, "logs")[0].latest;
1902
+
1903
+ expect(observation).toMatchObject({ tier: "supporting", tone: "warning" });
1904
+ expect(observation.data).toMatchObject({
1905
+ type: "logs",
1906
+ logs: { lines: ["ERROR Authentication failed"] },
1907
+ });
1908
+ });
1909
+
1910
+ it("renders producer-proven previous-log absence as a compact Checked receipt", () => {
1911
+ const result = project([
1912
+ tool("diagnose", "diagnose", {
1913
+ resource: deployment,
1914
+ resourceContext: { tier: "basic" },
1915
+ pods: 1,
1916
+ expectedPreviousLogAbsences: [{ pod: "api-abc", container: "api" }],
1917
+ logsPrevious: [
1918
+ {
1919
+ pod: "api-abc",
1920
+ container: "api",
1921
+ error: "previous terminated container not found",
1922
+ },
1923
+ ],
1924
+ events: [],
1925
+ recentChanges: [],
1926
+ }),
1927
+ ]);
1928
+ expect(
1929
+ groupsOf(result.groups, "receipt").some(
1930
+ (group) =>
1931
+ group.latest.data.type === "receipt" &&
1932
+ group.latest.data.checked === "logs",
1933
+ ),
1934
+ ).toBe(true);
1935
+ expect(
1936
+ result.limitations.some((item) => item.source === "api-abc / api"),
1937
+ ).toBe(false);
1938
+ });
1939
+
1940
+ it("keeps uncorrelated changes in Context and promotes producer correlation only", () => {
1941
+ const change = {
1942
+ kind: "Deployment",
1943
+ namespace: "shop",
1944
+ name: "api",
1945
+ changeType: "update",
1946
+ timestamp: "2026-09-02T09:55:00Z",
1947
+ summary: "image changed",
1948
+ };
1949
+ const result = project([
1950
+ tool("plain", "get_changes", { changes: [change] }),
1951
+ tool("correlated", "diagnose", {
1952
+ resource: deployment,
1953
+ resourceContext: { tier: "basic" },
1954
+ pods: 0,
1955
+ recentChanges: [change],
1956
+ changeContext: {
1957
+ changed: true,
1958
+ what: "pod_template",
1959
+ evidence: "ReplicaSet revision advanced within the issue window.",
1960
+ },
1961
+ events: [],
1962
+ }),
1963
+ ]);
1964
+ expect(
1965
+ groupsOf(result.groups, "changes").map((group) => group.latest.tier),
1966
+ ).toEqual(["context", "supporting"]);
1967
+ expect(groupsOf(result.groups, "changes")[1].latest).toMatchObject({
1968
+ summary: "The workload's Pod template changed",
1969
+ data: {
1970
+ changeContext: {
1971
+ what: "The workload's Pod template changed",
1972
+ },
1973
+ },
1974
+ });
1975
+ });
1976
+
1977
+ it("creates receipts only for confirmed complete zero-result reads", () => {
1978
+ const result = project([
1979
+ tool("diagnose", "diagnose", {
1980
+ resource: deployment,
1981
+ resourceContext: { tier: "basic" },
1982
+ pods: 0,
1983
+ events: [],
1984
+ recentChanges: [],
1985
+ }),
1986
+ tool("events", "get_events", { events: [] }),
1987
+ tool("issues", "issues", { issues: [], total: 0, total_matched: 0 }),
1988
+ tool("changes", "get_changes", { changes: [] }),
1989
+ tool("inventory", "list_resources", []),
1990
+ ]);
1991
+ expect(
1992
+ groupsOf(result.groups, "receipt").map((group) => group.latest.data),
1993
+ ).toEqual(
1994
+ expect.arrayContaining([
1995
+ expect.objectContaining({ checked: "events" }),
1996
+ expect.objectContaining({ checked: "issues" }),
1997
+ expect.objectContaining({ checked: "changes" }),
1998
+ ]),
1999
+ );
2000
+ // list_resources uses [] for some RBAC-filtered reads, so its emptiness is
2001
+ // never a positive receipt even when the transport itself succeeded. The
2002
+ // events producer marks a denied namespace explicitly, so its empty result
2003
+ // is a receipt; the changes producer still has the ambiguity. The targeted
2004
+ // semantic diagnose bundle is authoritative because access is checked before
2005
+ // that workload path runs.
2006
+ expect(
2007
+ groupsOf(result.groups, "receipt").some(
2008
+ (group) =>
2009
+ group.latest.data.type === "receipt" &&
2010
+ group.latest.data.checked === "inventory",
2011
+ ),
2012
+ ).toBe(false);
2013
+ expect(result.limitations.map((item) => item.source)).toEqual(
2014
+ expect.arrayContaining(["Recent changes", "Resource inventory"]),
2015
+ );
2016
+ expect(result.limitations.map((item) => item.source)).not.toContain(
2017
+ "Events",
2018
+ );
2019
+ expect(result.coverage).toEqual({
2020
+ attempted: 5,
2021
+ projected: 3,
2022
+ limited: 2,
2023
+ checked: 1,
2024
+ });
2025
+ expect(
2026
+ groupsOf(result.groups, "receipt").find(
2027
+ (group) =>
2028
+ group.latest.data.type === "receipt" &&
2029
+ group.latest.data.checked === "events",
2030
+ )?.latest,
2031
+ ).toMatchObject({
2032
+ // The title and the scope beside it are the whole answer here; a body
2033
+ // would only restate them.
2034
+ title: "No warning events",
2035
+ data: { message: undefined },
2036
+ });
2037
+ });
2038
+
2039
+ it("treats RBAC-limited recent changes as incomplete without exposing counts", () => {
2040
+ const result = project([
2041
+ tool("diagnose", "diagnose", {
2042
+ resource: deployment,
2043
+ resourceContext: { tier: "basic" },
2044
+ pods: 0,
2045
+ events: [],
2046
+ recentChanges: [],
2047
+ recentChangesCoverageLimited: true,
2048
+ }),
2049
+ ]);
2050
+
2051
+ expect(
2052
+ groupsOf(result.groups, "receipt").some(
2053
+ (group) =>
2054
+ group.latest.data.type === "receipt" &&
2055
+ group.latest.data.checked === "changes",
2056
+ ),
2057
+ ).toBe(false);
2058
+ expect(result.limitations).toEqual(
2059
+ expect.arrayContaining([
2060
+ expect.objectContaining({
2061
+ source: "Recent changes",
2062
+ kind: "unknown",
2063
+ message: expect.stringContaining(
2064
+ "could not confirm permission to read every referenced source",
2065
+ ),
2066
+ }),
2067
+ ]),
2068
+ );
2069
+ expect(JSON.stringify(result)).not.toContain("2 recent changes");
2070
+ });
2071
+
2072
+ it("does not trust malformed recent-change coverage metadata", () => {
2073
+ const result = project([
2074
+ tool("diagnose", "diagnose", {
2075
+ resource: deployment,
2076
+ resourceContext: { tier: "basic" },
2077
+ pods: 0,
2078
+ events: [],
2079
+ recentChanges: [],
2080
+ recentChangesCoverageLimited: "yes",
2081
+ }),
2082
+ ]);
2083
+
2084
+ expect(
2085
+ groupsOf(result.groups, "receipt").some(
2086
+ (group) =>
2087
+ group.latest.data.type === "receipt" &&
2088
+ group.latest.data.checked === "changes",
2089
+ ),
2090
+ ).toBe(false);
2091
+ expect(result.limitations).toEqual(
2092
+ expect.arrayContaining([
2093
+ expect.objectContaining({ source: "Recent changes" }),
2094
+ ]),
2095
+ );
2096
+ });
2097
+
2098
+ it("does not call a partial empty read Checked", () => {
2099
+ const result = project([
2100
+ tool("issues", "issues", {
2101
+ issues: [],
2102
+ total: 0,
2103
+ total_matched: 0,
2104
+ visibility: {
2105
+ state: "degraded",
2106
+ impact: "Radar cannot read core workload resources.",
2107
+ },
2108
+ }),
2109
+ tool("changes", "get_changes", {
2110
+ changes: [],
2111
+ sourcesErrored: ["helm: permission denied"],
2112
+ }),
2113
+ tool("events", "get_events", {
2114
+ events: [],
2115
+ narrowHint: "returned 0 of 12 groups",
2116
+ }),
2117
+ ]);
2118
+ expect(groupsOf(result.groups, "receipt")).toHaveLength(0);
2119
+ expect(result.coverage.checked).toBe(0);
2120
+ expect(result.coverage.limited).toBe(3);
2121
+ });
2122
+
2123
+ it("preserves neighborhood evidence and every declared coverage gap", () => {
2124
+ const result = project([
2125
+ tool("neighbors", "get_neighborhood", {
2126
+ root: {
2127
+ kind: "Deployment",
2128
+ group: "apps",
2129
+ namespace: "shop",
2130
+ name: "api",
2131
+ },
2132
+ subgraph: {
2133
+ nodes: [
2134
+ {
2135
+ id: "deployment/shop/api",
2136
+ kind: "Deployment",
2137
+ name: "api",
2138
+ status: "unhealthy",
2139
+ data: { namespace: "shop" },
2140
+ },
2141
+ ],
2142
+ edges: [],
2143
+ },
2144
+ truncated: true,
2145
+ narrowHint: "subgraph capped at 25 nodes",
2146
+ omitted: [
2147
+ { field: "uses.secrets", reason: "rbac_denied" },
2148
+ { field: "referencedBy", reason: "budget_exceeded" },
2149
+ ],
2150
+ }),
2151
+ ]);
2152
+ expect(groupsOf(result.groups, "relationships")[0].latest).toMatchObject({
2153
+ tier: "context",
2154
+ relevance: "target",
2155
+ });
2156
+ expect(result.limitations.map((item) => item.kind)).toEqual([
2157
+ "truncated",
2158
+ "unknown",
2159
+ "truncated",
2160
+ ]);
2161
+ });
2162
+
2163
+ it("projects the bounded get_topology summary without inventing causality", () => {
2164
+ const result = project([
2165
+ tool("topology", "get_topology", {
2166
+ namespaces: [
2167
+ {
2168
+ namespace: "shop",
2169
+ chains: ["Ingress/store → Service/api → Deployment/api"],
2170
+ },
2171
+ ],
2172
+ problems: ["Deployment api: unhealthy"],
2173
+ stats: { nodes: 3, edges: 2 },
2174
+ }),
2175
+ ]);
2176
+ const topology = groupsOf(result.groups, "topology")[0].latest;
2177
+ expect(topology).toMatchObject({
2178
+ tier: "context",
2179
+ relevance: "broader",
2180
+ tone: "warning",
2181
+ });
2182
+ expect(topology.data).toMatchObject({
2183
+ type: "topology",
2184
+ stats: { nodes: 3, edges: 2 },
2185
+ problems: ["Deployment api: unhealthy"],
2186
+ warnings: [],
2187
+ });
2188
+ expect(topology.title).toBe("Resource topology");
2189
+ expect(result.limitations).toHaveLength(0);
2190
+ });
2191
+
2192
+ it("keeps summary topology scale metadata as explicit coverage limits", () => {
2193
+ const result = project([
2194
+ tool("topology", "get_topology", {
2195
+ namespaces: [],
2196
+ stats: { nodes: 0, edges: 0 },
2197
+ warnings: [
2198
+ "Cluster too large for all-namespace topology. Filter to a specific namespace.",
2199
+ ],
2200
+ largeCluster: true,
2201
+ hiddenKinds: ["ConfigMap", "PersistentVolumeClaim"],
2202
+ requiresNamespaceFilter: true,
2203
+ estimatedNodes: 2400,
2204
+ }),
2205
+ ]);
2206
+
2207
+ const topology = groupsOf(result.groups, "topology")[0].latest;
2208
+ expect(topology.tier).toBe("context");
2209
+ expect(topology.data).toMatchObject({
2210
+ type: "topology",
2211
+ stats: { nodes: 0, edges: 0 },
2212
+ warnings: [
2213
+ "Cluster too large for all-namespace topology. Filter to a specific namespace.",
2214
+ ],
2215
+ });
2216
+ expect(result.limitations).toEqual(
2217
+ expect.arrayContaining([
2218
+ expect.objectContaining({
2219
+ source: "Topology coverage",
2220
+ kind: "truncated",
2221
+ }),
2222
+ expect.objectContaining({
2223
+ source: "Topology scale",
2224
+ kind: "truncated",
2225
+ message: expect.stringContaining("namespace-scoped topology search"),
2226
+ }),
2227
+ ]),
2228
+ );
2229
+ expect(
2230
+ result.limitations.find((item) => item.source === "Topology scale")
2231
+ ?.message,
2232
+ ).toContain("ConfigMap, PersistentVolumeClaim");
2233
+ expect(
2234
+ result.limitations.find((item) => item.source === "Topology scale")
2235
+ ?.message,
2236
+ ).toContain("2400 estimated nodes");
2237
+ });
2238
+
2239
+ it("keeps graph topology collapse and discovery gaps explicit", () => {
2240
+ const result = project([
2241
+ tool("topology", "get_topology", {
2242
+ nodes: [
2243
+ {
2244
+ id: "deployment/shop/api",
2245
+ kind: "Deployment",
2246
+ name: "api",
2247
+ status: "healthy",
2248
+ },
2249
+ ],
2250
+ edges: [],
2251
+ largeCluster: true,
2252
+ hiddenKinds: ["ConfigMap"],
2253
+ estimatedNodes: 2600,
2254
+ summaryMode: true,
2255
+ crdDiscoveryStatus: "discovering",
2256
+ }),
2257
+ ]);
2258
+
2259
+ expect(groupsOf(result.groups, "topology")).toHaveLength(1);
2260
+ expect(result.limitations).toEqual(
2261
+ expect.arrayContaining([
2262
+ expect.objectContaining({
2263
+ source: "Topology scale",
2264
+ kind: "truncated",
2265
+ message: expect.stringContaining("Summary mode collapsed"),
2266
+ }),
2267
+ expect.objectContaining({
2268
+ source: "Custom Resource topology",
2269
+ kind: "unknown",
2270
+ message: expect.stringContaining("still in progress"),
2271
+ }),
2272
+ ]),
2273
+ );
2274
+ });
2275
+
2276
+ it("rejects malformed optional topology coverage metadata", () => {
2277
+ const result = project([
2278
+ tool("topology", "get_topology", {
2279
+ namespaces: [],
2280
+ stats: { nodes: 0, edges: 0 },
2281
+ summaryMode: "false",
2282
+ }),
2283
+ ]);
2284
+
2285
+ expect(groupsOf(result.groups, "topology")).toHaveLength(0);
2286
+ expect(result.limitations).toEqual([
2287
+ expect.objectContaining({
2288
+ source: "Topology coverage metadata",
2289
+ kind: "unknown",
2290
+ }),
2291
+ ]);
2292
+ });
2293
+ });
2294
+
2295
+ describe("card leads and deep-link subjects", () => {
2296
+ it("leads an events card with the newest warning's reason and message", () => {
2297
+ const projection = project([
2298
+ tool(
2299
+ "events",
2300
+ "get_events",
2301
+ {
2302
+ events: [
2303
+ {
2304
+ reason: "Scheduled",
2305
+ message: "Successfully assigned shop/api-abc to node-1",
2306
+ type: "Normal",
2307
+ count: 1,
2308
+ lastTimestamp: "2026-09-02T11:00:00Z",
2309
+ },
2310
+ warningEvent,
2311
+ {
2312
+ reason: "Unhealthy",
2313
+ message: "Readiness probe failed: connection refused",
2314
+ type: "Warning",
2315
+ count: 12,
2316
+ lastTimestamp: "2026-09-02T10:30:00Z",
2317
+ },
2318
+ ],
2319
+ },
2320
+ {
2321
+ summary: JSON.stringify({
2322
+ kind: "Deployment",
2323
+ namespace: "shop",
2324
+ name: "api",
2325
+ }),
2326
+ },
2327
+ ),
2328
+ ]);
2329
+ expect(groupsOf(projection.groups, "events")[0].latest.summary).toBe(
2330
+ "Unhealthy: Readiness probe failed: connection refused · 3 event groups · Deployment shop/api",
2331
+ );
2332
+ });
2333
+
2334
+ it("carries the producer's change subject so a card can open its Timeline", () => {
2335
+ const change = {
2336
+ kind: "Deployment",
2337
+ apiVersion: "apps/v1",
2338
+ namespace: "shop",
2339
+ name: "api",
2340
+ changeType: "update",
2341
+ timestamp: "2026-09-02T09:00:00Z",
2342
+ };
2343
+ const projection = project([
2344
+ tool(
2345
+ "changes",
2346
+ "get_changes",
2347
+ { changes: [change] },
2348
+ {
2349
+ summary: JSON.stringify({
2350
+ kind: "Deployment",
2351
+ namespace: "shop",
2352
+ name: "api",
2353
+ }),
2354
+ },
2355
+ ),
2356
+ tool(
2357
+ "changes-ns",
2358
+ "get_changes",
2359
+ { changes: [change] },
2360
+ { summary: JSON.stringify({ namespace: "shop" }) },
2361
+ ),
2362
+ tool("diagnose", "diagnose", {
2363
+ resource: deployment,
2364
+ resourceContext: { tier: "basic" },
2365
+ recentChanges: [change],
2366
+ }),
2367
+ tool("resource", "get_resource", {
2368
+ resource: {
2369
+ ...deployment,
2370
+ metadata: { namespace: "shop", name: "web" },
2371
+ },
2372
+ resourceContext: { tier: "basic" },
2373
+ recentChanges: [change],
2374
+ recentChangesSaturated: false,
2375
+ recentChangesCoverageLimited: false,
2376
+ }),
2377
+ ]);
2378
+ const changes = groupsOf(projection.groups, "changes").map(
2379
+ (group) => (group.latest.data as { subject?: unknown }).subject,
2380
+ );
2381
+ expect(changes).toEqual([
2382
+ { kind: "Deployment", namespace: "shop", name: "api" },
2383
+ undefined,
2384
+ { kind: "Deployment", namespace: "shop", name: "api" },
2385
+ { kind: "Deployment", namespace: "shop", name: "web" },
2386
+ ]);
2387
+ });
2388
+ });
2389
+
2390
+ describe("live-run follow-ups", () => {
2391
+ it("reads the producer's nil-slice null as an empty events or changes result", () => {
2392
+ const projection = project([
2393
+ tool(
2394
+ "events-none",
2395
+ "get_events",
2396
+ { events: null },
2397
+ {
2398
+ summary: JSON.stringify({
2399
+ kind: "Pod",
2400
+ namespace: "shop",
2401
+ name: "api",
2402
+ }),
2403
+ },
2404
+ ),
2405
+ tool(
2406
+ "changes-none",
2407
+ "get_changes",
2408
+ { changes: null },
2409
+ {
2410
+ summary: JSON.stringify({
2411
+ kind: "Deployment",
2412
+ namespace: "shop",
2413
+ name: "api",
2414
+ since: "24h",
2415
+ }),
2416
+ },
2417
+ ),
2418
+ ]);
2419
+ // A complete, successful events query that returned nothing is a checked
2420
+ // receipt for its scope; the producer marks a denied namespace separately.
2421
+ expect(
2422
+ projection.groups.map((group) => [
2423
+ group.latest.data.type,
2424
+ group.latest.title,
2425
+ ]),
2426
+ ).toEqual([["receipt", "No events in this window"]]);
2427
+ expect(
2428
+ projection.limitations.map((limitation) => [
2429
+ limitation.source,
2430
+ limitation.kind,
2431
+ ]),
2432
+ ).toEqual([["Recent changes", "unknown"]]);
2433
+ });
2434
+
2435
+ it("keeps one identity for a blocker whether one pod holds it or several", () => {
2436
+ const blocker = (name: string) => ({
2437
+ kind: "Pod",
2438
+ name,
2439
+ reason: "Unschedulable",
2440
+ severity: "critical",
2441
+ message: "1 node(s) no free host ports",
2442
+ });
2443
+ const bundle = (pods: string[]) => ({
2444
+ resource: {
2445
+ apiVersion: "apps/v1",
2446
+ kind: "DaemonSet",
2447
+ metadata: { namespace: "opencost", name: "node-exporter" },
2448
+ },
2449
+ resourceContext: { tier: "basic" },
2450
+ pods: pods.length,
2451
+ startupBlockers: pods.map(blocker),
2452
+ });
2453
+ // Two pods recover to one between diagnoses. The blocker is the same
2454
+ // finding, so it must update its card rather than open a second one
2455
+ // beside it saying the same thing.
2456
+ const projection = projectInvestigationEvidence(
2457
+ [
2458
+ {
2459
+ status: "done",
2460
+ timeline: [tool("first", "diagnose", bundle(["a", "b"]))],
2461
+ },
2462
+ {
2463
+ status: "done",
2464
+ verify: true,
2465
+ timeline: [tool("second", "diagnose", bundle(["a"]))],
2466
+ },
2467
+ ],
2468
+ {
2469
+ kind: "DaemonSet",
2470
+ group: "apps",
2471
+ namespace: "opencost",
2472
+ name: "node-exporter",
2473
+ },
2474
+ );
2475
+ const startup = projection.groups.filter(
2476
+ (group) => group.latest.data.type === "startup",
2477
+ );
2478
+ expect(startup).toHaveLength(1);
2479
+ expect(startup[0].observations).toHaveLength(2);
2480
+ expect(startup[0].latest.summary).toBe("1 node(s) no free host ports");
2481
+ });
2482
+
2483
+ it("merges identical startup blockers across pods into one card with the pod list", () => {
2484
+ const blocker = (
2485
+ name: string,
2486
+ message = "1 node(s) no free host ports",
2487
+ ) => ({
2488
+ kind: "Pod",
2489
+ name,
2490
+ reason: "Unschedulable",
2491
+ severity: "critical",
2492
+ message,
2493
+ });
2494
+ const projection = project([
2495
+ tool("diagnose", "diagnose", {
2496
+ resource: {
2497
+ apiVersion: "apps/v1",
2498
+ kind: "DaemonSet",
2499
+ metadata: { namespace: "opencost", name: "node-exporter" },
2500
+ },
2501
+ resourceContext: { tier: "basic" },
2502
+ pods: 3,
2503
+ startupBlockers: [
2504
+ blocker("node-exporter-a"),
2505
+ blocker("node-exporter-b"),
2506
+ blocker("node-exporter-c", "0/10 nodes: insufficient memory"),
2507
+ ],
2508
+ }),
2509
+ ]);
2510
+ const startup = projection.groups.filter(
2511
+ (group) => group.latest.data.type === "startup",
2512
+ );
2513
+ expect(
2514
+ startup.map((group) => [
2515
+ group.latest.summary,
2516
+ group.latest.data.type === "startup" ? group.latest.data.pods : null,
2517
+ ]),
2518
+ ).toEqual([
2519
+ [
2520
+ "2 pods · 1 node(s) no free host ports",
2521
+ ["node-exporter-a", "node-exporter-b"],
2522
+ ],
2523
+ // A blocker only one pod holds still carries that pod, so the pods a
2524
+ // later Prometheus query is proved against do not depend on how many
2525
+ // happened to share a reason.
2526
+ ["0/10 nodes: insufficient memory", ["node-exporter-c"]],
2527
+ ]);
2528
+ expect(startup[1].id).not.toBe(startup[0].id);
2529
+ });
2530
+
2531
+ it("folds pods' startup blocker into the classified issue that repeats it", () => {
2532
+ const blocker = (name: string) => ({
2533
+ kind: "Pod",
2534
+ name,
2535
+ reason: "Unschedulable",
2536
+ severity: "critical",
2537
+ message: "1 node(s) no free host ports",
2538
+ });
2539
+ const projection = project([
2540
+ tool("diagnose", "diagnose", {
2541
+ resource: {
2542
+ apiVersion: "apps/v1",
2543
+ kind: "DaemonSet",
2544
+ metadata: { namespace: "opencost", name: "node-exporter" },
2545
+ },
2546
+ resourceContext: { tier: "basic" },
2547
+ pods: 2,
2548
+ relatedIssues: [
2549
+ {
2550
+ id: "issue-unsched",
2551
+ severity: "critical",
2552
+ source: "scheduling",
2553
+ category: "unschedulable",
2554
+ category_group: "startup",
2555
+ grouping_scope: "workload",
2556
+ kind: "DaemonSet",
2557
+ namespace: "opencost",
2558
+ name: "node-exporter",
2559
+ reason: "Unschedulable",
2560
+ message: "1 node(s) no free host ports",
2561
+ },
2562
+ ],
2563
+ startupBlockers: [
2564
+ blocker("node-exporter-a"),
2565
+ blocker("node-exporter-b"),
2566
+ ],
2567
+ }),
2568
+ ]);
2569
+ expect(
2570
+ projection.groups.filter((group) => group.latest.data.type === "startup"),
2571
+ ).toHaveLength(0);
2572
+ const [issue] = projection.groups.filter(
2573
+ (group) => group.latest.data.type === "issue",
2574
+ );
2575
+ expect(
2576
+ issue.latest.data.type === "issue" && issue.latest.data.pods,
2577
+ ).toEqual(["node-exporter-a", "node-exporter-b"]);
2578
+ });
2579
+
2580
+ it("files a denied events namespace as an access limitation, not a receipt", () => {
2581
+ const projection = project([
2582
+ tool(
2583
+ "events-denied",
2584
+ "get_events",
2585
+ { events: [], accessDenied: true },
2586
+ {
2587
+ summary: JSON.stringify({ namespace: "locked", kind: "Pod" }),
2588
+ },
2589
+ ),
2590
+ ]);
2591
+ expect(projection.groups).toHaveLength(0);
2592
+ expect(
2593
+ projection.limitations.map((limitation) => [
2594
+ limitation.source,
2595
+ limitation.kind,
2596
+ ]),
2597
+ ).toEqual([["Events", "error"]]);
2598
+ expect(projection.limitations[0].message).toContain(
2599
+ "not readable with your permissions",
2600
+ );
2601
+ });
2602
+
2603
+ it("keeps a cluster-wide events read that was narrowed to readable namespaces from reading as a clean cluster", () => {
2604
+ const empty = project([
2605
+ tool(
2606
+ "events-partial-empty",
2607
+ "get_events",
2608
+ { events: [], partialScope: true, scopeNamespaces: ["alpha", "beta"] },
2609
+ { summary: JSON.stringify({}) },
2610
+ ),
2611
+ ]);
2612
+ const [receipt] = groupsOf(empty.groups, "receipt");
2613
+ expect(receipt.latest.title).toBe(
2614
+ "No events in the namespaces you can read",
2615
+ );
2616
+ const narrowedBody =
2617
+ receipt.latest.data.type === "receipt" ? receipt.latest.data.message : "";
2618
+ expect(narrowedBody).toContain("alpha, beta");
2619
+ // The load-bearing half: an empty answer from the namespaces a reader can
2620
+ // see must never read as an answer about the cluster.
2621
+ expect(narrowedBody).toContain("does not clear the cluster");
2622
+ expect(empty.limitations).toHaveLength(0);
2623
+
2624
+ const found = project([
2625
+ tool(
2626
+ "events-partial",
2627
+ "get_events",
2628
+ {
2629
+ events: [
2630
+ {
2631
+ type: "Warning",
2632
+ reason: "BackOff",
2633
+ message: "back-off restarting",
2634
+ count: 3,
2635
+ lastTimestamp: "2026-09-02T09:00:00Z",
2636
+ involvedObject: { kind: "Pod", namespace: "alpha", name: "api" },
2637
+ },
2638
+ ],
2639
+ partialScope: true,
2640
+ scopeNamespaces: ["alpha", "beta"],
2641
+ },
2642
+ { summary: JSON.stringify({}) },
2643
+ ),
2644
+ ]);
2645
+ expect(
2646
+ found.limitations.map((limitation) => [
2647
+ limitation.source,
2648
+ limitation.kind,
2649
+ ]),
2650
+ ).toEqual([["Events", "unknown"]]);
2651
+ expect(found.limitations[0].message).toContain("alpha, beta");
2652
+ });
2653
+
2654
+ it("keeps repeated change reads of one resource on one card and names the window", () => {
2655
+ const change = {
2656
+ kind: "Deployment",
2657
+ apiVersion: "apps/v1",
2658
+ namespace: "shop",
2659
+ name: "api",
2660
+ changeType: "update",
2661
+ timestamp: "2026-09-02T09:00:00Z",
2662
+ };
2663
+ const older = { ...change, timestamp: "2026-09-01T09:00:00Z" };
2664
+ const read = (
2665
+ id: string,
2666
+ args: Record<string, unknown>,
2667
+ changes: unknown[],
2668
+ ) =>
2669
+ tool(id, "get_changes", { changes }, { summary: JSON.stringify(args) });
2670
+ const projection = project([
2671
+ read(
2672
+ "changes-24h",
2673
+ { namespace: "shop", name: "api", since: "24h", kind: "Deployment" },
2674
+ [change],
2675
+ ),
2676
+ read(
2677
+ "changes-48h",
2678
+ {
2679
+ kind: "Deployment",
2680
+ namespace: "shop",
2681
+ name: "api",
2682
+ since: "48h",
2683
+ limit: 30,
2684
+ },
2685
+ [change, older],
2686
+ ),
2687
+ read("changes-ns", { namespace: "shop", since: "24h" }, [change]),
2688
+ ]);
2689
+ const changes = groupsOf(projection.groups, "changes");
2690
+ expect(changes.map((group) => group.identity)).toEqual([
2691
+ "changes:Deployment shop/api",
2692
+ "changes:namespace shop",
2693
+ ]);
2694
+ expect(changes[0].observations).toHaveLength(2);
2695
+ expect(changes[0].observations.map((o) => o.title)).toEqual([
2696
+ "Recent changes · last 24h",
2697
+ "Recent changes · last 48h",
2698
+ ]);
2699
+ expect(changes[0].latest.title).toBe("Recent changes · last 48h");
2700
+ expect(changes[0].latest.summary).toBe("2 changes · Deployment shop/api");
2701
+ expect(changes[1].latest.title).toBe("Recent changes · last 24h");
2702
+ });
2703
+ });