@skyhook-io/radar-app 1.13.0 → 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 +55 -9
- package/src/RadarApp.tsx +3 -3
- package/src/api/diagnose.test.ts +268 -0
- package/src/api/diagnose.ts +117 -14
- package/src/components/diagnose/AISettings.tsx +10 -5
- package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
- package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
- package/src/components/diagnose/DiagnoseContext.tsx +228 -65
- package/src/components/diagnose/DiagnoseSurface.test.tsx +317 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +621 -132
- package/src/components/diagnose/Home.test.tsx +293 -0
- package/src/components/diagnose/Home.tsx +307 -108
- 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 +1911 -338
- 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 +1340 -536
- 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/execution/BatchExecutionView.render.test.tsx +35 -0
- package/src/components/execution/BatchExecutionView.tsx +2 -3
- package/src/components/execution/execution-definition.test.ts +19 -0
- package/src/components/execution/execution-definition.ts +2 -0
- package/src/components/gitops/GitOpsView.tsx +25 -3
- 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/nav/navigation.test.ts +26 -0
- package/src/components/nav/navigation.ts +10 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.2",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"yaml": "^2.9.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@skyhook-io/k8s-ui": ">=1.
|
|
44
|
+
"@skyhook-io/k8s-ui": ">=1.14.2",
|
|
45
45
|
"@tanstack/react-query": ">=5",
|
|
46
46
|
"@xyflow/react": ">=12.0.0",
|
|
47
47
|
"clsx": ">=2",
|
package/src/App.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import { CloudFunnelButton } from './components/CloudFunnelButton'
|
|
|
37
37
|
import { useNavCustomization } from './context/NavCustomization'
|
|
38
38
|
import type { FleetTakeoverTarget } from './context/NavCustomization'
|
|
39
39
|
import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
|
|
40
|
+
import { navigateFromPrimaryRail } from './components/nav/navigation'
|
|
40
41
|
import { useNavRailPinned } from './hooks/useNavRailPinned'
|
|
41
42
|
import { useMediaQuery } from './hooks/useMediaQuery'
|
|
42
43
|
import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
|
|
@@ -322,7 +323,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
322
323
|
// The AI panel is an absolute slot in the body frame (the column under the header):
|
|
323
324
|
// it reserves a right gutter on the CONTENT only, so the navbar + nav rail stay
|
|
324
325
|
// static. contentGutter is the docked panel width (0 when closed/overlay/maximized).
|
|
325
|
-
const {
|
|
326
|
+
const {
|
|
327
|
+
open: diagnoseOpen,
|
|
328
|
+
close: closeDiagnose,
|
|
329
|
+
contentGutter,
|
|
330
|
+
maximized: diagnoseMaximized,
|
|
331
|
+
} = useDiagnoseLayout()
|
|
326
332
|
// Hand off to a host-owned URL. The host's `onHostNavigate` (Radar Cloud's
|
|
327
333
|
// cross-tree swap) navigates same-document so the chrome morphs instead of
|
|
328
334
|
// cold-booting; without it we fall back to a hard `window.location` nav.
|
|
@@ -468,12 +474,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
468
474
|
|
|
469
475
|
const path = view === 'home' ? '/' : `/${view}`
|
|
470
476
|
|
|
471
|
-
// Start fresh — keep only cross-view params
|
|
477
|
+
// Start fresh — keep only cross-view params, discard view-specific ones.
|
|
478
|
+
// Read the live location because Diagnose owns `ai-run` through the History
|
|
479
|
+
// API; React Router's searchParams snapshot does not update for that write.
|
|
472
480
|
const newParams = new URLSearchParams()
|
|
473
|
-
const
|
|
481
|
+
const currentParams = new URLSearchParams(window.location.search)
|
|
482
|
+
const globalNamespaces = currentParams.get('namespaces')
|
|
474
483
|
if (globalNamespaces) {
|
|
475
484
|
newParams.set('namespaces', globalNamespaces)
|
|
476
485
|
}
|
|
486
|
+
const diagnoseRun = currentParams.get('ai-run')
|
|
487
|
+
if (diagnoseRun) {
|
|
488
|
+
newParams.set('ai-run', diagnoseRun)
|
|
489
|
+
}
|
|
477
490
|
|
|
478
491
|
// Add any new params
|
|
479
492
|
if (params) {
|
|
@@ -483,7 +496,18 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
483
496
|
}
|
|
484
497
|
|
|
485
498
|
navigate({ pathname: path, search: newParams.toString() })
|
|
486
|
-
}, [navigate,
|
|
499
|
+
}, [navigate, takeover, goHost])
|
|
500
|
+
|
|
501
|
+
// The standalone rail expresses intent to leave the full-width investigation
|
|
502
|
+
// workspace. Close it before routing so the destination is immediately visible;
|
|
503
|
+
// docked investigations stay open across views as a persistent side panel.
|
|
504
|
+
const handlePrimaryNavigate = useCallback((view: ExtendedMainView) => {
|
|
505
|
+
navigateFromPrimaryRail(
|
|
506
|
+
diagnoseOpen && diagnoseMaximized,
|
|
507
|
+
closeDiagnose,
|
|
508
|
+
() => setMainView(view),
|
|
509
|
+
)
|
|
510
|
+
}, [diagnoseOpen, diagnoseMaximized, closeDiagnose, setMainView])
|
|
487
511
|
|
|
488
512
|
// Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
|
|
489
513
|
// own fleet pages scoped to this cluster — owned by the host's left rail — so
|
|
@@ -1135,9 +1159,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1135
1159
|
setSelectedResource(null)
|
|
1136
1160
|
setSelectedHelmRelease(null)
|
|
1137
1161
|
|
|
1138
|
-
// Reset
|
|
1139
|
-
//
|
|
1140
|
-
|
|
1162
|
+
// Reset resource-specific params while retaining the durable investigation
|
|
1163
|
+
// focus. Diagnose resolves runs by id and owns whether the focused run is
|
|
1164
|
+
// still readable after the context switch.
|
|
1165
|
+
const nextParams = new URLSearchParams()
|
|
1166
|
+
const diagnoseRun = new URLSearchParams(window.location.search).get('ai-run')
|
|
1167
|
+
if (diagnoseRun) nextParams.set('ai-run', diagnoseRun)
|
|
1168
|
+
navigate({ pathname: location.pathname, search: nextParams.toString() }, { replace: true })
|
|
1141
1169
|
|
|
1142
1170
|
// Auto-unpause so the new cluster's topology loads immediately
|
|
1143
1171
|
setTopologyPaused(false)
|
|
@@ -1658,7 +1686,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1658
1686
|
{showNavRail && (
|
|
1659
1687
|
<PrimaryNavRail
|
|
1660
1688
|
activeView={navActiveView}
|
|
1661
|
-
onNavigate={
|
|
1689
|
+
onNavigate={handlePrimaryNavigate}
|
|
1662
1690
|
pinned={navRailEffectivePinned}
|
|
1663
1691
|
onTogglePinned={toggleNavRailPinned}
|
|
1664
1692
|
showPinToggle={!railForcedSlim}
|
|
@@ -2436,7 +2464,25 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2436
2464
|
below the header and right of the nav rail, sharing the frame with the
|
|
2437
2465
|
drawers above. Docked = right slot (pushes content via contentGutter);
|
|
2438
2466
|
maximized = fills the frame. */}
|
|
2439
|
-
{diagnoseOpen &&
|
|
2467
|
+
{diagnoseOpen && (
|
|
2468
|
+
<DiagnoseSurface
|
|
2469
|
+
topInset={chromeless ? 0 : APP_HEADER_HEIGHT}
|
|
2470
|
+
onOpenResource={(ref) => {
|
|
2471
|
+
const resource: SelectedResource = {
|
|
2472
|
+
kind: ref.kind,
|
|
2473
|
+
namespace: ref.namespace ?? '',
|
|
2474
|
+
name: ref.name,
|
|
2475
|
+
group: ref.group,
|
|
2476
|
+
}
|
|
2477
|
+
const path = relatedResourcePath(resource)
|
|
2478
|
+
if (path.startsWith('/workload/')) {
|
|
2479
|
+
navigate(path)
|
|
2480
|
+
return
|
|
2481
|
+
}
|
|
2482
|
+
navigateFromIssue(resource)
|
|
2483
|
+
}}
|
|
2484
|
+
/>
|
|
2485
|
+
)}
|
|
2440
2486
|
|
|
2441
2487
|
{/* Port Forward floating panel (indicator lives in header) */}
|
|
2442
2488
|
<PortForwardPanel />
|
package/src/RadarApp.tsx
CHANGED
|
@@ -106,10 +106,10 @@ export interface RadarAppProps {
|
|
|
106
106
|
*/
|
|
107
107
|
documentTitleSuffix?: string;
|
|
108
108
|
/**
|
|
109
|
-
* Injects a resource-level "
|
|
109
|
+
* Injects a resource-level "Investigate" action (e.g. an "Investigate with AI"
|
|
110
110
|
* button) into every resource detail action bar's right-aligned universal
|
|
111
111
|
* actions. The host returns the node to render given the resource context.
|
|
112
|
-
* Standalone Radar omits this and renders no
|
|
112
|
+
* Standalone Radar omits this and renders no Investigate button — OSS stays
|
|
113
113
|
* agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
|
|
114
114
|
*/
|
|
115
115
|
renderDiagnoseAction?: RenderDiagnoseAction;
|
|
@@ -228,7 +228,7 @@ export function RadarApp({
|
|
|
228
228
|
value={renderDiagnoseAction ?? defaultDiagnoseAction}
|
|
229
229
|
consentCopy={diagnoseConsent}
|
|
230
230
|
>
|
|
231
|
-
<DiagnoseProvider>
|
|
231
|
+
<DiagnoseProvider browserURLState={router !== "memory"}>
|
|
232
232
|
<App
|
|
233
233
|
manageDocumentTitle={manageDocumentTitle}
|
|
234
234
|
documentTitleSuffix={documentTitleSuffix}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createRun, subscribeRun, type DiagnoseStreamEvent } from "./diagnose";
|
|
3
|
+
|
|
4
|
+
type SSEListener = (event: { data: string; lastEventId: string }) => void;
|
|
5
|
+
|
|
6
|
+
class FakeEventSource {
|
|
7
|
+
static CLOSED = 2;
|
|
8
|
+
static instances: FakeEventSource[] = [];
|
|
9
|
+
|
|
10
|
+
readyState = 1;
|
|
11
|
+
onopen: (() => void) | null = null;
|
|
12
|
+
onerror: (() => void) | null = null;
|
|
13
|
+
closed = false;
|
|
14
|
+
private listeners = new Map<string, SSEListener>();
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
readonly url: string,
|
|
18
|
+
readonly options?: EventSourceInit,
|
|
19
|
+
) {
|
|
20
|
+
FakeEventSource.instances.push(this);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
addEventListener(type: string, listener: SSEListener) {
|
|
24
|
+
this.listeners.set(type, listener);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
emit(type: string, event: DiagnoseStreamEvent, lastEventId = "") {
|
|
28
|
+
this.listeners.get(type)?.({ data: JSON.stringify(event), lastEventId });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
close() {
|
|
32
|
+
this.closed = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("createRun", () => {
|
|
37
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
38
|
+
|
|
39
|
+
it.each(["argoproj.io", ""])(
|
|
40
|
+
"sends the target API group %j in the request body",
|
|
41
|
+
async (group) => {
|
|
42
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
43
|
+
ok: true,
|
|
44
|
+
json: async () => ({
|
|
45
|
+
id: "run-1",
|
|
46
|
+
kind: "Rollout",
|
|
47
|
+
group,
|
|
48
|
+
namespace: "prod",
|
|
49
|
+
name: "checkout",
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
53
|
+
|
|
54
|
+
await createRun(
|
|
55
|
+
{
|
|
56
|
+
kind: "Rollout",
|
|
57
|
+
group,
|
|
58
|
+
namespace: "prod",
|
|
59
|
+
name: "checkout",
|
|
60
|
+
},
|
|
61
|
+
{ agent: "codex" },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
|
65
|
+
expect(JSON.parse(String(init.body))).toEqual({
|
|
66
|
+
kind: "Rollout",
|
|
67
|
+
group,
|
|
68
|
+
namespace: "prod",
|
|
69
|
+
name: "checkout",
|
|
70
|
+
agent: "codex",
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("passes durable SSE sequence IDs and explanation origin through replay", () => {
|
|
77
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
78
|
+
const onEvent = vi.fn();
|
|
79
|
+
const close = subscribeRun("sequence-test", { onEvent });
|
|
80
|
+
const source = FakeEventSource.instances.at(-1)!;
|
|
81
|
+
source.emit("done", { type: "done" }, "42");
|
|
82
|
+
source.emit("turn", { type: "turn", explainAssessment: 42 }, "43");
|
|
83
|
+
expect(onEvent).toHaveBeenNthCalledWith(1, { type: "done" }, 42);
|
|
84
|
+
expect(onEvent).toHaveBeenNthCalledWith(
|
|
85
|
+
2,
|
|
86
|
+
{ type: "turn", explainAssessment: 42 },
|
|
87
|
+
43,
|
|
88
|
+
);
|
|
89
|
+
close();
|
|
90
|
+
vi.unstubAllGlobals();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("subscribeRun replay boundaries", () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
FakeEventSource.instances = [];
|
|
96
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
100
|
+
|
|
101
|
+
it("brackets every connection replay and forwards the completion marker", () => {
|
|
102
|
+
const starts = vi.fn();
|
|
103
|
+
const events: DiagnoseStreamEvent[] = [];
|
|
104
|
+
const cancel = subscribeRun("run-1", {
|
|
105
|
+
onReplayStart: starts,
|
|
106
|
+
onEvent: (event) => events.push(event),
|
|
107
|
+
});
|
|
108
|
+
const source = FakeEventSource.instances[0]!;
|
|
109
|
+
|
|
110
|
+
source.onopen?.();
|
|
111
|
+
source.emit("turn", { type: "turn" });
|
|
112
|
+
source.emit("replay_complete", { type: "replay_complete" });
|
|
113
|
+
source.onopen?.(); // EventSource reconnects through the same instance.
|
|
114
|
+
|
|
115
|
+
expect(starts).toHaveBeenCalledTimes(2);
|
|
116
|
+
expect(events.map((event) => event.type)).toEqual([
|
|
117
|
+
"turn",
|
|
118
|
+
"replay_complete",
|
|
119
|
+
]);
|
|
120
|
+
cancel();
|
|
121
|
+
expect(source.closed).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("distinguishes a durable closed sentinel from an unavailable stream", () => {
|
|
125
|
+
const durableClose = vi.fn();
|
|
126
|
+
subscribeRun("run-1", {
|
|
127
|
+
onEvent: vi.fn(),
|
|
128
|
+
onClosed: durableClose,
|
|
129
|
+
});
|
|
130
|
+
const closedSource = FakeEventSource.instances[0]!;
|
|
131
|
+
closedSource.emit("closed", { type: "closed" });
|
|
132
|
+
closedSource.readyState = FakeEventSource.CLOSED;
|
|
133
|
+
closedSource.onerror?.();
|
|
134
|
+
expect(durableClose).toHaveBeenCalledWith("run_closed");
|
|
135
|
+
expect(durableClose).toHaveBeenCalledTimes(1);
|
|
136
|
+
|
|
137
|
+
const unavailable = vi.fn();
|
|
138
|
+
subscribeRun("run-2", {
|
|
139
|
+
onEvent: vi.fn(),
|
|
140
|
+
onClosed: unavailable,
|
|
141
|
+
});
|
|
142
|
+
const source = FakeEventSource.instances[1]!;
|
|
143
|
+
source.readyState = FakeEventSource.CLOSED;
|
|
144
|
+
source.onerror?.();
|
|
145
|
+
expect(unavailable).toHaveBeenCalledWith("unavailable");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("forwards hydration failures while preserving only retryable reconnects", () => {
|
|
149
|
+
const retryEvents: DiagnoseStreamEvent[] = [];
|
|
150
|
+
subscribeRun("run-retry", {
|
|
151
|
+
onEvent: (event) => retryEvents.push(event),
|
|
152
|
+
});
|
|
153
|
+
const retrySource = FakeEventSource.instances[0]!;
|
|
154
|
+
retrySource.emit("history_unavailable", {
|
|
155
|
+
type: "history_unavailable",
|
|
156
|
+
error: "history store is busy",
|
|
157
|
+
retryable: true,
|
|
158
|
+
});
|
|
159
|
+
expect(retryEvents).toEqual([
|
|
160
|
+
{
|
|
161
|
+
type: "history_unavailable",
|
|
162
|
+
error: "history store is busy",
|
|
163
|
+
retryable: true,
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
expect(retrySource.closed).toBe(false);
|
|
167
|
+
|
|
168
|
+
const permanentEvents: DiagnoseStreamEvent[] = [];
|
|
169
|
+
subscribeRun("run-permanent", {
|
|
170
|
+
onEvent: (event) => permanentEvents.push(event),
|
|
171
|
+
});
|
|
172
|
+
const permanentSource = FakeEventSource.instances[1]!;
|
|
173
|
+
permanentSource.emit("history_unavailable", {
|
|
174
|
+
type: "history_unavailable",
|
|
175
|
+
error: "history cannot be decoded",
|
|
176
|
+
retryable: false,
|
|
177
|
+
});
|
|
178
|
+
expect(permanentEvents.map((event) => event.type)).toEqual([
|
|
179
|
+
"history_unavailable",
|
|
180
|
+
]);
|
|
181
|
+
expect(permanentSource.closed).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("ignores a trailing closed frame after a permanent history failure", () => {
|
|
185
|
+
const events: DiagnoseStreamEvent[] = [];
|
|
186
|
+
const onClosed = vi.fn();
|
|
187
|
+
subscribeRun("run-corrupt-history", {
|
|
188
|
+
onEvent: (event) => events.push(event),
|
|
189
|
+
onClosed,
|
|
190
|
+
});
|
|
191
|
+
const source = FakeEventSource.instances[0]!;
|
|
192
|
+
|
|
193
|
+
source.emit("history_unavailable", {
|
|
194
|
+
type: "history_unavailable",
|
|
195
|
+
error: "history cannot be decoded",
|
|
196
|
+
retryable: false,
|
|
197
|
+
});
|
|
198
|
+
// This frame can already be queued when history_unavailable closes the
|
|
199
|
+
// EventSource. It must not relabel the run as evicted in InvestigationView.
|
|
200
|
+
source.emit("closed", { type: "closed" });
|
|
201
|
+
|
|
202
|
+
expect(events).toEqual([
|
|
203
|
+
{
|
|
204
|
+
type: "history_unavailable",
|
|
205
|
+
error: "history cannot be decoded",
|
|
206
|
+
retryable: false,
|
|
207
|
+
},
|
|
208
|
+
]);
|
|
209
|
+
expect(onClosed).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("forwards evidence-backed apply outcomes on terminal events", () => {
|
|
213
|
+
const events: DiagnoseStreamEvent[] = [];
|
|
214
|
+
subscribeRun("run-apply", {
|
|
215
|
+
onEvent: (event) => events.push(event),
|
|
216
|
+
});
|
|
217
|
+
const source = FakeEventSource.instances[0]!;
|
|
218
|
+
source.emit("done", {
|
|
219
|
+
type: "done",
|
|
220
|
+
applyOutcome: "confirmed",
|
|
221
|
+
diagnosis: {
|
|
222
|
+
rootCause: "",
|
|
223
|
+
report: "Deployment updated.",
|
|
224
|
+
remediation: [],
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
source.emit("error", {
|
|
228
|
+
type: "error",
|
|
229
|
+
applyOutcome: "unknown",
|
|
230
|
+
error: "The write result was incomplete.",
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(events).toEqual([
|
|
234
|
+
expect.objectContaining({ type: "done", applyOutcome: "confirmed" }),
|
|
235
|
+
expect.objectContaining({ type: "error", applyOutcome: "unknown" }),
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("investigation start requests", () => {
|
|
241
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
242
|
+
|
|
243
|
+
const target = {
|
|
244
|
+
kind: "Deployment",
|
|
245
|
+
group: "apps",
|
|
246
|
+
namespace: "prod",
|
|
247
|
+
name: "payments",
|
|
248
|
+
issueId: "issue-1",
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
it.each([
|
|
252
|
+
["investigate further", target],
|
|
253
|
+
["start fresh", { ...target, fresh: true }],
|
|
254
|
+
])("preserves the %s intent on the wire", async (_name, request) => {
|
|
255
|
+
const fetch = vi
|
|
256
|
+
.fn()
|
|
257
|
+
.mockResolvedValue(new Response(JSON.stringify({ id: "new-run" })));
|
|
258
|
+
vi.stubGlobal("fetch", fetch);
|
|
259
|
+
await createRun(request, { agent: "hub" });
|
|
260
|
+
const init = fetch.mock.calls[0][1] as RequestInit;
|
|
261
|
+
expect(init.method).toBe("POST");
|
|
262
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
263
|
+
...request,
|
|
264
|
+
agent: "hub",
|
|
265
|
+
});
|
|
266
|
+
expect(JSON.parse(init.body as string).issueId).toBe("issue-1");
|
|
267
|
+
});
|
|
268
|
+
});
|