@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,695 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { DiagnoseError } from "../../api/diagnose";
|
|
3
|
+
import {
|
|
4
|
+
canOfferInvestigationApply,
|
|
5
|
+
initialInvestigationPane,
|
|
6
|
+
investigationEvidenceAnnouncement,
|
|
7
|
+
investigationEvidenceShouldMarkUnread,
|
|
8
|
+
investigationApplyCompletionEffects,
|
|
9
|
+
investigationApplyAttemptVerified,
|
|
10
|
+
investigationAssessmentNeedsCurrentStateVerification,
|
|
11
|
+
investigationApplyRejectionIsDefinitive,
|
|
12
|
+
investigationApplyTerminalNeedsClusterRefresh,
|
|
13
|
+
investigationTurnWithTerminalEvent,
|
|
14
|
+
investigationClosedEventIsLive,
|
|
15
|
+
investigationClosedRunIsUnavailable,
|
|
16
|
+
investigationEvidenceInputsEqual,
|
|
17
|
+
investigationEvidenceCoverageLimited,
|
|
18
|
+
investigationEvidenceConflictsWithHealthy,
|
|
19
|
+
investigationEndedBeforeConclusion,
|
|
20
|
+
investigationHistoryUnavailablePresentation,
|
|
21
|
+
investigationInteractionsBlocked,
|
|
22
|
+
investigationIsReadOnly,
|
|
23
|
+
investigationPaneCenteredScrollTop,
|
|
24
|
+
} from "./investigationState";
|
|
25
|
+
|
|
26
|
+
describe("investigation terminal presentation", () => {
|
|
27
|
+
it("centers source navigation inside its pane and clamps at both ends", () => {
|
|
28
|
+
expect(
|
|
29
|
+
investigationPaneCenteredScrollTop({
|
|
30
|
+
scrollTop: 400,
|
|
31
|
+
viewportHeight: 600,
|
|
32
|
+
contentHeight: 2_000,
|
|
33
|
+
targetTop: 500,
|
|
34
|
+
targetHeight: 100,
|
|
35
|
+
}),
|
|
36
|
+
).toBe(650);
|
|
37
|
+
expect(
|
|
38
|
+
investigationPaneCenteredScrollTop({
|
|
39
|
+
scrollTop: 0,
|
|
40
|
+
viewportHeight: 600,
|
|
41
|
+
contentHeight: 2_000,
|
|
42
|
+
targetTop: 20,
|
|
43
|
+
targetHeight: 40,
|
|
44
|
+
}),
|
|
45
|
+
).toBe(0);
|
|
46
|
+
expect(
|
|
47
|
+
investigationPaneCenteredScrollTop({
|
|
48
|
+
scrollTop: 1_300,
|
|
49
|
+
viewportHeight: 600,
|
|
50
|
+
contentHeight: 2_000,
|
|
51
|
+
targetTop: 580,
|
|
52
|
+
targetHeight: 80,
|
|
53
|
+
}),
|
|
54
|
+
).toBe(1_400);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("opens successful and stale runs on Evidence", () => {
|
|
58
|
+
expect(initialInvestigationPane("done")).toBe("evidence");
|
|
59
|
+
expect(initialInvestigationPane("stale")).toBe("evidence");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("opens running and ended-early runs on Activity", () => {
|
|
63
|
+
expect(initialInvestigationPane("running")).toBe("activity");
|
|
64
|
+
expect(initialInvestigationPane("error")).toBe("activity");
|
|
65
|
+
expect(initialInvestigationPane("stopped")).toBe("activity");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("marks new evidence unread only when Findings is actually hidden", () => {
|
|
69
|
+
expect(
|
|
70
|
+
investigationEvidenceShouldMarkUnread({
|
|
71
|
+
hasNewLiveSource: true,
|
|
72
|
+
selectedPane: "activity",
|
|
73
|
+
evidencePaneVisible: false,
|
|
74
|
+
}),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
expect(
|
|
77
|
+
investigationEvidenceShouldMarkUnread({
|
|
78
|
+
hasNewLiveSource: true,
|
|
79
|
+
selectedPane: "activity",
|
|
80
|
+
evidencePaneVisible: true,
|
|
81
|
+
}),
|
|
82
|
+
).toBe(false);
|
|
83
|
+
expect(
|
|
84
|
+
investigationEvidenceShouldMarkUnread({
|
|
85
|
+
hasNewLiveSource: true,
|
|
86
|
+
selectedPane: "evidence",
|
|
87
|
+
evidencePaneVisible: true,
|
|
88
|
+
}),
|
|
89
|
+
).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("announces hidden and scrolled-away evidence without duplicating updates", () => {
|
|
93
|
+
expect(
|
|
94
|
+
investigationEvidenceAnnouncement({
|
|
95
|
+
unreadEvidence: true,
|
|
96
|
+
evidenceUpdateAvailable: true,
|
|
97
|
+
}),
|
|
98
|
+
).toBe("New evidence available");
|
|
99
|
+
expect(
|
|
100
|
+
investigationEvidenceAnnouncement({
|
|
101
|
+
unreadEvidence: false,
|
|
102
|
+
evidenceUpdateAvailable: true,
|
|
103
|
+
}),
|
|
104
|
+
).toBe("New evidence available in Findings.");
|
|
105
|
+
expect(
|
|
106
|
+
investigationEvidenceAnnouncement({
|
|
107
|
+
unreadEvidence: false,
|
|
108
|
+
evidenceUpdateAvailable: false,
|
|
109
|
+
}),
|
|
110
|
+
).toBe("");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("makes stale and unavailable runs read-only", () => {
|
|
114
|
+
expect(investigationIsReadOnly("stale", false)).toBe(true);
|
|
115
|
+
expect(investigationIsReadOnly("done", true)).toBe(true);
|
|
116
|
+
expect(investigationIsReadOnly("done", false)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("distinguishes retrying from permanent history failures", () => {
|
|
120
|
+
expect(
|
|
121
|
+
investigationHistoryUnavailablePresentation({
|
|
122
|
+
error: "history store is busy.",
|
|
123
|
+
retryable: true,
|
|
124
|
+
}),
|
|
125
|
+
).toMatchObject({
|
|
126
|
+
loading: true,
|
|
127
|
+
title: expect.stringContaining("temporarily"),
|
|
128
|
+
});
|
|
129
|
+
expect(
|
|
130
|
+
investigationHistoryUnavailablePresentation({
|
|
131
|
+
error: "history cannot be decoded",
|
|
132
|
+
retryable: false,
|
|
133
|
+
}),
|
|
134
|
+
).toEqual({
|
|
135
|
+
loading: false,
|
|
136
|
+
title: "Saved history is unavailable",
|
|
137
|
+
detail: "history cannot be decoded",
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does not call a completed apply failure an incomplete investigation", () => {
|
|
142
|
+
expect(
|
|
143
|
+
investigationEndedBeforeConclusion("error", {
|
|
144
|
+
status: "error",
|
|
145
|
+
apply: true,
|
|
146
|
+
}),
|
|
147
|
+
).toBe(false);
|
|
148
|
+
expect(
|
|
149
|
+
investigationEndedBeforeConclusion("error", {
|
|
150
|
+
status: "error",
|
|
151
|
+
apply: false,
|
|
152
|
+
}),
|
|
153
|
+
).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it.each(["error", "stopped"] as const)(
|
|
157
|
+
"does not discard the assessment after an explanation is %s",
|
|
158
|
+
(status) => {
|
|
159
|
+
expect(
|
|
160
|
+
investigationEndedBeforeConclusion(status, {
|
|
161
|
+
status: "error",
|
|
162
|
+
explainAssessment: 2,
|
|
163
|
+
}),
|
|
164
|
+
).toBe(false);
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("investigation evidence projection stability", () => {
|
|
170
|
+
it("treats zero projected producer evidence as limited coverage", () => {
|
|
171
|
+
expect(
|
|
172
|
+
investigationEvidenceCoverageLimited({
|
|
173
|
+
limitations: [],
|
|
174
|
+
coverage: { attempted: 0, projected: 0, limited: 0, checked: 0 },
|
|
175
|
+
sources: [],
|
|
176
|
+
groups: [],
|
|
177
|
+
}),
|
|
178
|
+
).toBe(true);
|
|
179
|
+
expect(
|
|
180
|
+
investigationEvidenceCoverageLimited({
|
|
181
|
+
limitations: [],
|
|
182
|
+
coverage: { attempted: 1, projected: 1, limited: 0, checked: 0 },
|
|
183
|
+
sources: [
|
|
184
|
+
{
|
|
185
|
+
id: "resource",
|
|
186
|
+
tool: "get_resource",
|
|
187
|
+
confirmedSuccess: true,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
groups: [
|
|
191
|
+
{
|
|
192
|
+
latest: {
|
|
193
|
+
relevance: "target",
|
|
194
|
+
source: { id: "resource" },
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
}),
|
|
199
|
+
).toBe(true);
|
|
200
|
+
expect(
|
|
201
|
+
investigationEvidenceCoverageLimited({
|
|
202
|
+
limitations: [],
|
|
203
|
+
coverage: { attempted: 1, projected: 1, limited: 0, checked: 0 },
|
|
204
|
+
sources: [
|
|
205
|
+
{
|
|
206
|
+
id: "diagnose",
|
|
207
|
+
tool: "diagnose",
|
|
208
|
+
confirmedSuccess: true,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
groups: [
|
|
212
|
+
{
|
|
213
|
+
latest: {
|
|
214
|
+
relevance: "target",
|
|
215
|
+
source: { id: "diagnose" },
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
}),
|
|
220
|
+
).toBe(false);
|
|
221
|
+
expect(
|
|
222
|
+
investigationEvidenceCoverageLimited({
|
|
223
|
+
limitations: [],
|
|
224
|
+
coverage: { attempted: 1, projected: 1, limited: 0, checked: 0 },
|
|
225
|
+
sources: [
|
|
226
|
+
{
|
|
227
|
+
id: "diagnose",
|
|
228
|
+
tool: "diagnose",
|
|
229
|
+
confirmedSuccess: true,
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
groups: [
|
|
233
|
+
{
|
|
234
|
+
latest: {
|
|
235
|
+
relevance: "broader",
|
|
236
|
+
source: { id: "diagnose" },
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
}),
|
|
241
|
+
).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("qualifies a healthy conclusion when active adverse Radar evidence disagrees", () => {
|
|
245
|
+
const group = (
|
|
246
|
+
kind: string,
|
|
247
|
+
tier: "key" | "supporting" | "context",
|
|
248
|
+
relevance: "target" | "producer-related" | "broader" = "target",
|
|
249
|
+
historical = false,
|
|
250
|
+
) => ({
|
|
251
|
+
kind,
|
|
252
|
+
historical,
|
|
253
|
+
latest: { tier, relevance, tone: "warning" },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(
|
|
257
|
+
investigationEvidenceConflictsWithHealthy({
|
|
258
|
+
groups: [group("issue", "supporting")],
|
|
259
|
+
}),
|
|
260
|
+
).toBe(true);
|
|
261
|
+
expect(
|
|
262
|
+
investigationEvidenceConflictsWithHealthy({
|
|
263
|
+
groups: [group("logs", "supporting")],
|
|
264
|
+
}),
|
|
265
|
+
).toBe(true);
|
|
266
|
+
expect(
|
|
267
|
+
investigationEvidenceConflictsWithHealthy({
|
|
268
|
+
groups: [group("events", "supporting")],
|
|
269
|
+
}),
|
|
270
|
+
).toBe(true);
|
|
271
|
+
expect(
|
|
272
|
+
investigationEvidenceConflictsWithHealthy({
|
|
273
|
+
groups: [group("resource", "context")],
|
|
274
|
+
}),
|
|
275
|
+
).toBe(false);
|
|
276
|
+
expect(
|
|
277
|
+
investigationEvidenceConflictsWithHealthy({
|
|
278
|
+
groups: [group("issue", "supporting", "broader")],
|
|
279
|
+
}),
|
|
280
|
+
).toBe(false);
|
|
281
|
+
expect(
|
|
282
|
+
investigationEvidenceConflictsWithHealthy({
|
|
283
|
+
groups: [group("issue", "supporting", "target", true)],
|
|
284
|
+
}),
|
|
285
|
+
).toBe(false);
|
|
286
|
+
expect(
|
|
287
|
+
investigationEvidenceConflictsWithHealthy({
|
|
288
|
+
groups: [group("changes", "supporting")],
|
|
289
|
+
}),
|
|
290
|
+
).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
it("ignores reasoning-only transcript updates while retaining completed tool identity", () => {
|
|
293
|
+
const completedTool = {
|
|
294
|
+
kind: "tool" as const,
|
|
295
|
+
id: "events-1",
|
|
296
|
+
tool: "get_events",
|
|
297
|
+
status: "done",
|
|
298
|
+
result: '{"events":[]}',
|
|
299
|
+
isError: false,
|
|
300
|
+
};
|
|
301
|
+
const previous = [
|
|
302
|
+
{
|
|
303
|
+
status: "running" as const,
|
|
304
|
+
timeline: [
|
|
305
|
+
completedTool,
|
|
306
|
+
{ kind: "thinking" as const, text: "Checking pods" },
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
];
|
|
310
|
+
const next = [
|
|
311
|
+
{
|
|
312
|
+
status: "running" as const,
|
|
313
|
+
timeline: [
|
|
314
|
+
completedTool,
|
|
315
|
+
{ kind: "thinking" as const, text: "Checking pods and owners" },
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
expect(investigationEvidenceInputsEqual(previous, next)).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("invalidates when a completed tool record or verification status changes", () => {
|
|
324
|
+
const completedTool = {
|
|
325
|
+
kind: "tool" as const,
|
|
326
|
+
id: "events-1",
|
|
327
|
+
tool: "get_events",
|
|
328
|
+
status: "done",
|
|
329
|
+
result: '{"events":[]}',
|
|
330
|
+
isError: false,
|
|
331
|
+
};
|
|
332
|
+
const previous = [
|
|
333
|
+
{ verify: true, status: "running" as const, timeline: [completedTool] },
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
expect(
|
|
337
|
+
investigationEvidenceInputsEqual(previous, [
|
|
338
|
+
{
|
|
339
|
+
...previous[0],
|
|
340
|
+
timeline: [{ ...completedTool, result: '{"events":[{}]}' }],
|
|
341
|
+
},
|
|
342
|
+
]),
|
|
343
|
+
).toBe(false);
|
|
344
|
+
expect(
|
|
345
|
+
investigationEvidenceInputsEqual(previous, [
|
|
346
|
+
{ ...previous[0], status: "done" as const },
|
|
347
|
+
]),
|
|
348
|
+
).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("checks every turn after an evidence-free prefix", () => {
|
|
352
|
+
const firstTurn = {
|
|
353
|
+
status: "done" as const,
|
|
354
|
+
timeline: [],
|
|
355
|
+
};
|
|
356
|
+
const completedTool = {
|
|
357
|
+
kind: "tool" as const,
|
|
358
|
+
id: "pods-1",
|
|
359
|
+
tool: "get_pods",
|
|
360
|
+
status: "done",
|
|
361
|
+
result: '{"pods":[]}',
|
|
362
|
+
isError: false,
|
|
363
|
+
};
|
|
364
|
+
const previous = [
|
|
365
|
+
firstTurn,
|
|
366
|
+
{ status: "running" as const, timeline: [completedTool] },
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
expect(
|
|
370
|
+
investigationEvidenceInputsEqual(previous, [
|
|
371
|
+
firstTurn,
|
|
372
|
+
{ status: "done" as const, timeline: [completedTool] },
|
|
373
|
+
]),
|
|
374
|
+
).toBe(false);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("investigation action gating", () => {
|
|
379
|
+
it("retains apply mutation truth across replayed and live terminal events", () => {
|
|
380
|
+
const applyTurn = {
|
|
381
|
+
apply: true,
|
|
382
|
+
timeline: [],
|
|
383
|
+
diagnosis: null,
|
|
384
|
+
error: null,
|
|
385
|
+
status: "running" as const,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
expect(
|
|
389
|
+
investigationTurnWithTerminalEvent(
|
|
390
|
+
applyTurn,
|
|
391
|
+
{
|
|
392
|
+
type: "done",
|
|
393
|
+
diagnosis: {
|
|
394
|
+
rootCause: "",
|
|
395
|
+
report: "Deployment updated.",
|
|
396
|
+
remediation: [],
|
|
397
|
+
},
|
|
398
|
+
applyOutcome: "confirmed",
|
|
399
|
+
},
|
|
400
|
+
false,
|
|
401
|
+
),
|
|
402
|
+
).toMatchObject({
|
|
403
|
+
status: "done",
|
|
404
|
+
applyOutcome: "confirmed",
|
|
405
|
+
animateResult: false,
|
|
406
|
+
});
|
|
407
|
+
expect(
|
|
408
|
+
investigationTurnWithTerminalEvent(
|
|
409
|
+
applyTurn,
|
|
410
|
+
{
|
|
411
|
+
type: "error",
|
|
412
|
+
error: "The mutation result could not be established.",
|
|
413
|
+
applyOutcome: "unknown",
|
|
414
|
+
},
|
|
415
|
+
true,
|
|
416
|
+
),
|
|
417
|
+
).toMatchObject({
|
|
418
|
+
status: "error",
|
|
419
|
+
error: "The mutation result could not be established.",
|
|
420
|
+
applyOutcome: "unknown",
|
|
421
|
+
animateResult: true,
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("blocks actions until replay is complete and while a request or verification handoff is pending", () => {
|
|
426
|
+
const ready = {
|
|
427
|
+
streamReady: true,
|
|
428
|
+
busy: false,
|
|
429
|
+
requestPending: false,
|
|
430
|
+
readOnly: false,
|
|
431
|
+
verificationPending: false,
|
|
432
|
+
};
|
|
433
|
+
expect(investigationInteractionsBlocked(ready)).toBe(false);
|
|
434
|
+
expect(
|
|
435
|
+
investigationInteractionsBlocked({ ...ready, streamReady: false }),
|
|
436
|
+
).toBe(true);
|
|
437
|
+
expect(
|
|
438
|
+
investigationInteractionsBlocked({ ...ready, requestPending: true }),
|
|
439
|
+
).toBe(true);
|
|
440
|
+
expect(
|
|
441
|
+
investigationInteractionsBlocked({
|
|
442
|
+
...ready,
|
|
443
|
+
verificationPending: true,
|
|
444
|
+
}),
|
|
445
|
+
).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("does not re-offer an assessment after any apply attempt", () => {
|
|
449
|
+
const base = {
|
|
450
|
+
currentAssessmentIdx: 0,
|
|
451
|
+
lastRemediationIdx: 0,
|
|
452
|
+
lastApplyAttemptIdx: -1,
|
|
453
|
+
localApplyAttemptAssessmentIdx: -1,
|
|
454
|
+
interactionsBlocked: false,
|
|
455
|
+
hosted: false,
|
|
456
|
+
hasNewerEvidence: false,
|
|
457
|
+
};
|
|
458
|
+
expect(canOfferInvestigationApply(base)).toBe(true);
|
|
459
|
+
expect(
|
|
460
|
+
canOfferInvestigationApply({ ...base, hasNewerEvidence: true }),
|
|
461
|
+
).toBe(false);
|
|
462
|
+
expect(canOfferInvestigationApply({ ...base, hosted: true })).toBe(false);
|
|
463
|
+
expect(
|
|
464
|
+
canOfferInvestigationApply({ ...base, interactionsBlocked: true }),
|
|
465
|
+
).toBe(false);
|
|
466
|
+
expect(
|
|
467
|
+
canOfferInvestigationApply({
|
|
468
|
+
...base,
|
|
469
|
+
localApplyAttemptAssessmentIdx: 0,
|
|
470
|
+
}),
|
|
471
|
+
).toBe(false);
|
|
472
|
+
expect(
|
|
473
|
+
canOfferInvestigationApply({
|
|
474
|
+
...base,
|
|
475
|
+
lastApplyAttemptIdx: 0,
|
|
476
|
+
}),
|
|
477
|
+
).toBe(false);
|
|
478
|
+
expect(
|
|
479
|
+
canOfferInvestigationApply({
|
|
480
|
+
...base,
|
|
481
|
+
currentAssessmentIdx: 2,
|
|
482
|
+
lastRemediationIdx: 2,
|
|
483
|
+
lastApplyAttemptIdx: 1,
|
|
484
|
+
localApplyAttemptAssessmentIdx: 0,
|
|
485
|
+
}),
|
|
486
|
+
).toBe(true);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("clears a pessimistic local attempt only after a later verification assessment", () => {
|
|
490
|
+
const attempt = {
|
|
491
|
+
localApplyAttemptAssessmentIdx: 3,
|
|
492
|
+
currentAssessmentIdx: 4,
|
|
493
|
+
currentAssessmentIsVerification: false,
|
|
494
|
+
};
|
|
495
|
+
expect(investigationApplyAttemptVerified(attempt)).toBe(false);
|
|
496
|
+
expect(
|
|
497
|
+
investigationApplyAttemptVerified({
|
|
498
|
+
...attempt,
|
|
499
|
+
currentAssessmentIsVerification: true,
|
|
500
|
+
}),
|
|
501
|
+
).toBe(true);
|
|
502
|
+
expect(
|
|
503
|
+
investigationApplyAttemptVerified({
|
|
504
|
+
...attempt,
|
|
505
|
+
currentAssessmentIdx: 3,
|
|
506
|
+
currentAssessmentIsVerification: true,
|
|
507
|
+
}),
|
|
508
|
+
).toBe(false);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("marks an assessment pre-change until structured verification replaces a possible write", () => {
|
|
512
|
+
const base = {
|
|
513
|
+
currentAssessmentIdx: 2,
|
|
514
|
+
lastApplyAttemptIdx: -1,
|
|
515
|
+
lastApplyOutcome: undefined,
|
|
516
|
+
localApplyAttemptAssessmentIdx: -1,
|
|
517
|
+
};
|
|
518
|
+
expect(investigationAssessmentNeedsCurrentStateVerification(base)).toBe(
|
|
519
|
+
false,
|
|
520
|
+
);
|
|
521
|
+
expect(
|
|
522
|
+
investigationAssessmentNeedsCurrentStateVerification({
|
|
523
|
+
...base,
|
|
524
|
+
localApplyAttemptAssessmentIdx: 2,
|
|
525
|
+
}),
|
|
526
|
+
).toBe(true);
|
|
527
|
+
expect(
|
|
528
|
+
investigationAssessmentNeedsCurrentStateVerification({
|
|
529
|
+
...base,
|
|
530
|
+
lastApplyAttemptIdx: 3,
|
|
531
|
+
lastApplyOutcome: "confirmed",
|
|
532
|
+
}),
|
|
533
|
+
).toBe(true);
|
|
534
|
+
expect(
|
|
535
|
+
investigationAssessmentNeedsCurrentStateVerification({
|
|
536
|
+
...base,
|
|
537
|
+
lastApplyAttemptIdx: 3,
|
|
538
|
+
lastApplyOutcome: "unknown",
|
|
539
|
+
}),
|
|
540
|
+
).toBe(true);
|
|
541
|
+
expect(
|
|
542
|
+
investigationAssessmentNeedsCurrentStateVerification({
|
|
543
|
+
...base,
|
|
544
|
+
lastApplyAttemptIdx: 3,
|
|
545
|
+
lastApplyOutcome: "failed",
|
|
546
|
+
localApplyAttemptAssessmentIdx: 2,
|
|
547
|
+
}),
|
|
548
|
+
).toBe(false);
|
|
549
|
+
expect(
|
|
550
|
+
investigationAssessmentNeedsCurrentStateVerification({
|
|
551
|
+
...base,
|
|
552
|
+
currentAssessmentIdx: 4,
|
|
553
|
+
lastApplyAttemptIdx: 3,
|
|
554
|
+
lastApplyOutcome: "confirmed",
|
|
555
|
+
}),
|
|
556
|
+
).toBe(false);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("restores Apply only when the server definitively rejects the request", () => {
|
|
560
|
+
expect(
|
|
561
|
+
investigationApplyRejectionIsDefinitive(
|
|
562
|
+
new DiagnoseError(409, "Run is no longer writable"),
|
|
563
|
+
),
|
|
564
|
+
).toBe(true);
|
|
565
|
+
expect(
|
|
566
|
+
investigationApplyRejectionIsDefinitive(
|
|
567
|
+
new DiagnoseError(500, "Apply outcome is unknown"),
|
|
568
|
+
),
|
|
569
|
+
).toBe(false);
|
|
570
|
+
expect(
|
|
571
|
+
investigationApplyRejectionIsDefinitive(new TypeError("fetch failed")),
|
|
572
|
+
).toBe(false);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("does not refresh live state or await verification for replayed apply completion", () => {
|
|
576
|
+
expect(
|
|
577
|
+
investigationApplyCompletionEffects({
|
|
578
|
+
live: false,
|
|
579
|
+
applyStartedLive: false,
|
|
580
|
+
stale: false,
|
|
581
|
+
}),
|
|
582
|
+
).toEqual({ refreshClusterState: false, verificationPending: false });
|
|
583
|
+
expect(
|
|
584
|
+
investigationApplyCompletionEffects({
|
|
585
|
+
live: true,
|
|
586
|
+
applyStartedLive: false,
|
|
587
|
+
stale: false,
|
|
588
|
+
}),
|
|
589
|
+
).toEqual({ refreshClusterState: true, verificationPending: true });
|
|
590
|
+
expect(
|
|
591
|
+
investigationApplyCompletionEffects({
|
|
592
|
+
live: true,
|
|
593
|
+
applyStartedLive: false,
|
|
594
|
+
stale: true,
|
|
595
|
+
}),
|
|
596
|
+
).toEqual({ refreshClusterState: true, verificationPending: false });
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("refreshes and preserves verification handoff when a live apply completes during replay", () => {
|
|
600
|
+
expect(
|
|
601
|
+
investigationApplyCompletionEffects({
|
|
602
|
+
live: false,
|
|
603
|
+
applyStartedLive: true,
|
|
604
|
+
stale: false,
|
|
605
|
+
}),
|
|
606
|
+
).toEqual({ refreshClusterState: true, verificationPending: true });
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("does not refresh current-cluster queries for a historical apply error replay", () => {
|
|
610
|
+
expect(
|
|
611
|
+
investigationApplyTerminalNeedsClusterRefresh({
|
|
612
|
+
localApplyRequestPending: false,
|
|
613
|
+
streamedApplyPending: true,
|
|
614
|
+
streamedApplyStartedLive: false,
|
|
615
|
+
terminalEventIsLive: false,
|
|
616
|
+
}),
|
|
617
|
+
).toBe(false);
|
|
618
|
+
expect(
|
|
619
|
+
investigationApplyTerminalNeedsClusterRefresh({
|
|
620
|
+
localApplyRequestPending: false,
|
|
621
|
+
streamedApplyPending: true,
|
|
622
|
+
streamedApplyStartedLive: false,
|
|
623
|
+
terminalEventIsLive: true,
|
|
624
|
+
}),
|
|
625
|
+
).toBe(true);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("distinguishes context-switch closure from eviction", () => {
|
|
629
|
+
expect(
|
|
630
|
+
investigationClosedRunIsUnavailable({
|
|
631
|
+
reason: "run_closed",
|
|
632
|
+
subscribedRunStatus: "running",
|
|
633
|
+
}),
|
|
634
|
+
).toBe(false);
|
|
635
|
+
expect(
|
|
636
|
+
investigationClosedRunIsUnavailable({
|
|
637
|
+
reason: "run_closed",
|
|
638
|
+
subscribedRunStatus: "stale",
|
|
639
|
+
}),
|
|
640
|
+
).toBe(false);
|
|
641
|
+
for (const subscribedRunStatus of ["done", "error", "stopped"] as const) {
|
|
642
|
+
expect(
|
|
643
|
+
investigationClosedRunIsUnavailable({
|
|
644
|
+
reason: "run_closed",
|
|
645
|
+
subscribedRunStatus,
|
|
646
|
+
}),
|
|
647
|
+
).toBe(true);
|
|
648
|
+
}
|
|
649
|
+
expect(
|
|
650
|
+
investigationClosedRunIsUnavailable({
|
|
651
|
+
reason: "unavailable",
|
|
652
|
+
subscribedRunStatus: "running",
|
|
653
|
+
}),
|
|
654
|
+
).toBe(true);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("does not refresh for replayed close but preserves live and local ambiguity", () => {
|
|
658
|
+
const replayedCloseIsLive = investigationClosedEventIsLive({
|
|
659
|
+
reason: "run_closed",
|
|
660
|
+
subscribedRunStatus: "stale",
|
|
661
|
+
// Retained stale streams send replay_complete before their closed sentinel.
|
|
662
|
+
replayComplete: true,
|
|
663
|
+
});
|
|
664
|
+
const replayedClose = {
|
|
665
|
+
localApplyRequestPending: false,
|
|
666
|
+
streamedApplyPending: true,
|
|
667
|
+
streamedApplyStartedLive: false,
|
|
668
|
+
terminalEventIsLive: replayedCloseIsLive,
|
|
669
|
+
};
|
|
670
|
+
expect(replayedCloseIsLive).toBe(false);
|
|
671
|
+
expect(investigationApplyTerminalNeedsClusterRefresh(replayedClose)).toBe(
|
|
672
|
+
false,
|
|
673
|
+
);
|
|
674
|
+
expect(
|
|
675
|
+
investigationClosedEventIsLive({
|
|
676
|
+
reason: "run_closed",
|
|
677
|
+
subscribedRunStatus: "running",
|
|
678
|
+
replayComplete: true,
|
|
679
|
+
}),
|
|
680
|
+
).toBe(true);
|
|
681
|
+
expect(
|
|
682
|
+
investigationApplyTerminalNeedsClusterRefresh({
|
|
683
|
+
...replayedClose,
|
|
684
|
+
streamedApplyStartedLive: true,
|
|
685
|
+
}),
|
|
686
|
+
).toBe(true);
|
|
687
|
+
expect(
|
|
688
|
+
investigationApplyTerminalNeedsClusterRefresh({
|
|
689
|
+
...replayedClose,
|
|
690
|
+
streamedApplyPending: false,
|
|
691
|
+
localApplyRequestPending: true,
|
|
692
|
+
}),
|
|
693
|
+
).toBe(true);
|
|
694
|
+
});
|
|
695
|
+
});
|