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