@skyhook-io/radar-app 1.12.3 → 1.13.1
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 +5 -5
- package/src/App.tsx +37 -8
- package/src/RadarApp.tsx +1 -1
- package/src/api/client.ts +38 -6
- package/src/api/diagnose.ts +45 -5
- package/src/components/cost/ApplicationCostTab.test.ts +45 -0
- package/src/components/cost/ApplicationCostTab.tsx +115 -64
- package/src/components/cost/CostTrendChart.test.ts +65 -0
- package/src/components/cost/CostTrendChart.tsx +107 -17
- package/src/components/cost/CostView.test.ts +36 -1
- package/src/components/cost/CostView.tsx +133 -51
- package/src/components/cost/CurrentAllocationUse.tsx +8 -4
- package/src/components/cost/WorkloadCostTab.test.ts +50 -0
- package/src/components/cost/WorkloadCostTab.tsx +109 -61
- package/src/components/cost/source.test.ts +33 -0
- package/src/components/cost/source.ts +100 -0
- package/src/components/diagnose/AISettings.tsx +9 -4
- package/src/components/diagnose/DiagnoseContext.tsx +216 -48
- package/src/components/diagnose/DiagnoseSurface.test.tsx +118 -5
- package/src/components/diagnose/DiagnoseSurface.tsx +190 -32
- package/src/components/diagnose/Home.tsx +93 -64
- package/src/components/diagnose/InvestigationView.tsx +190 -124
- package/src/components/diagnose/parts.test.tsx +12 -4
- package/src/components/diagnose/parts.tsx +29 -17
- 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/CostCard.tsx +3 -2
- package/src/components/nav/navigation.test.ts +26 -0
- package/src/components/nav/navigation.ts +10 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
- package/src/components/rightsizing/copy.test.ts +19 -0
- package/src/components/settings/SettingsDialog.tsx +666 -103
- package/src/components/settings/settings-state.test.ts +42 -0
- package/src/components/settings/settings-state.ts +39 -0
- package/src/index.css +11 -1
|
@@ -17,11 +17,13 @@ import {
|
|
|
17
17
|
} from "react";
|
|
18
18
|
import {
|
|
19
19
|
fetchAgents,
|
|
20
|
+
getRun,
|
|
20
21
|
listRuns,
|
|
21
22
|
createRun,
|
|
22
23
|
recordConsent,
|
|
23
24
|
DiagnoseError,
|
|
24
25
|
} from "../../api/diagnose";
|
|
26
|
+
import { getApiBase } from "../../api/config";
|
|
25
27
|
import {
|
|
26
28
|
type RunSummary,
|
|
27
29
|
type AgentInfo,
|
|
@@ -86,6 +88,7 @@ interface DiagnoseCtx {
|
|
|
86
88
|
approveConsent: () => void;
|
|
87
89
|
cancelConsent: () => void;
|
|
88
90
|
refreshRuns: () => void;
|
|
91
|
+
updateRunSummary: (run: RunSummary) => void;
|
|
89
92
|
dismissError: () => void;
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -95,6 +98,7 @@ interface DiagnoseCtx {
|
|
|
95
98
|
// churns the business context) doesn't re-render the whole shell.
|
|
96
99
|
interface DiagnoseLayoutCtx {
|
|
97
100
|
open: boolean;
|
|
101
|
+
close: () => void;
|
|
98
102
|
contentGutter: number; // px right-gutter for the content area when docked (0 = overlay/closed)
|
|
99
103
|
maximized: boolean;
|
|
100
104
|
setMaximized: Dispatch<SetStateAction<boolean>>;
|
|
@@ -186,7 +190,58 @@ function writeStored(key: string, value: string) {
|
|
|
186
190
|
}
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
|
|
193
|
+
function runIDFromLocation(browserURLState: boolean): string | null {
|
|
194
|
+
if (!diagnoseURLStateEnabled(browserURLState)) return null;
|
|
195
|
+
try {
|
|
196
|
+
return new URLSearchParams(window.location.search).get("ai-run");
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function writeRunIDToLocation(
|
|
203
|
+
id: string | null,
|
|
204
|
+
push: boolean,
|
|
205
|
+
browserURLState: boolean,
|
|
206
|
+
) {
|
|
207
|
+
if (!diagnoseURLStateEnabled(browserURLState)) return;
|
|
208
|
+
try {
|
|
209
|
+
const url = new URL(window.location.href);
|
|
210
|
+
if (id) url.searchParams.set("ai-run", id);
|
|
211
|
+
else url.searchParams.delete("ai-run");
|
|
212
|
+
window.history[push ? "pushState" : "replaceState"](
|
|
213
|
+
window.history.state,
|
|
214
|
+
"",
|
|
215
|
+
`${url.pathname}${url.search}${url.hash}`,
|
|
216
|
+
);
|
|
217
|
+
// History API writes do not notify BrowserRouter. Replaying the new state as
|
|
218
|
+
// popstate keeps every later navigate/setSearchParams call on the same live
|
|
219
|
+
// query string instead of letting a stale router snapshot erase ai-run.
|
|
220
|
+
window.dispatchEvent(
|
|
221
|
+
new PopStateEvent("popstate", { state: window.history.state }),
|
|
222
|
+
);
|
|
223
|
+
} catch {
|
|
224
|
+
/* URL APIs unavailable — panel state still works for this session */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Fleet pages host the panel against a cluster-scoped API while remaining on a
|
|
229
|
+
// hub route such as /issues. Writing ?ai-run there would create a non-reloadable
|
|
230
|
+
// pseudo-link. The embedded /c/:id Radar tree and local Radar own their route and
|
|
231
|
+
// therefore keep the query as navigation state; Fleet copies run.radarUrl instead.
|
|
232
|
+
function diagnoseURLStateEnabled(browserURLState: boolean): boolean {
|
|
233
|
+
if (!browserURLState) return false;
|
|
234
|
+
const clusterScopedAPI = /\/c\/[^/]+\/api\/?$/.test(getApiBase());
|
|
235
|
+
return !clusterScopedAPI || /^\/c\/[^/]+/.test(window.location.pathname);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function DiagnoseProvider({
|
|
239
|
+
children,
|
|
240
|
+
browserURLState = true,
|
|
241
|
+
}: {
|
|
242
|
+
children: ReactNode;
|
|
243
|
+
browserURLState?: boolean;
|
|
244
|
+
}) {
|
|
190
245
|
const [available, setAvailable] = useState(false);
|
|
191
246
|
const [eligible, setEligible] = useState(false);
|
|
192
247
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
@@ -206,6 +261,26 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
206
261
|
const [open, setOpen] = useState(false);
|
|
207
262
|
const [view, setView] = useState<DiagnoseView>("home");
|
|
208
263
|
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
|
264
|
+
const activeRunIdRef = useRef(activeRunId);
|
|
265
|
+
activeRunIdRef.current = activeRunId;
|
|
266
|
+
// A missing/revoked durable link is terminal until the user explicitly
|
|
267
|
+
// navigates to it again. Keep polling the bounded history list (so a newly
|
|
268
|
+
// shared run can reappear), but do not hammer the exact 404 endpoint every
|
|
269
|
+
// four seconds while the unavailable state is already on screen.
|
|
270
|
+
const unavailableRunIDsRef = useRef(new Set<string>());
|
|
271
|
+
// Tracks whether the panel focus belongs to browser history. Fleet opens the
|
|
272
|
+
// same panel on /issues, where URL state is deliberately disabled; a generic
|
|
273
|
+
// panel launch must never be closed by the deep-link synchronization effect.
|
|
274
|
+
const urlRunIdRef = useRef(runIDFromLocation(browserURLState));
|
|
275
|
+
const writeFocusedRunID = useCallback(
|
|
276
|
+
(id: string | null, push: boolean) => {
|
|
277
|
+
// Set this before writeRunIDToLocation's synthetic popstate so our own
|
|
278
|
+
// listener can distinguish a programmatic close/home write from Back.
|
|
279
|
+
if (diagnoseURLStateEnabled(browserURLState)) urlRunIdRef.current = id;
|
|
280
|
+
writeRunIDToLocation(id, push, browserURLState);
|
|
281
|
+
},
|
|
282
|
+
[browserURLState],
|
|
283
|
+
);
|
|
209
284
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
|
210
285
|
const [runsLoaded, setRunsLoaded] = useState(false);
|
|
211
286
|
const [runsLoadFailed, setRunsLoadFailed] = useState(false);
|
|
@@ -332,28 +407,79 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
332
407
|
return () => window.removeEventListener("resize", onResize);
|
|
333
408
|
}, []);
|
|
334
409
|
|
|
335
|
-
const refreshRuns = useCallback(() => {
|
|
410
|
+
const refreshRuns = useCallback(async () => {
|
|
336
411
|
if (!available) return;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
412
|
+
try {
|
|
413
|
+
const r = await listRuns();
|
|
414
|
+
const focusedID = activeRunIdRef.current;
|
|
415
|
+
let focusedRun: RunSummary | null = null;
|
|
416
|
+
let retainFocusedSnapshot = false;
|
|
417
|
+
const listedFocusedRun = focusedID
|
|
418
|
+
? r.runs.find((run) => run.id === focusedID)
|
|
419
|
+
: undefined;
|
|
420
|
+
if (focusedID && listedFocusedRun) {
|
|
421
|
+
unavailableRunIDsRef.current.delete(focusedID);
|
|
422
|
+
} else if (focusedID && !unavailableRunIDsRef.current.has(focusedID)) {
|
|
423
|
+
try {
|
|
424
|
+
// A stable deep link can target a retained run older than the bounded
|
|
425
|
+
// history page. Refresh it directly too: its status and capabilities
|
|
426
|
+
// must not freeze at the first snapshot while the panel stays open.
|
|
427
|
+
focusedRun = await getRun(focusedID);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
// The bounded list is still authoritative for everything else. A
|
|
430
|
+
// missing/revoked focused run falls out and renders unavailable;
|
|
431
|
+
// transient direct-fetch failures keep the last useful snapshot.
|
|
432
|
+
const unavailable =
|
|
433
|
+
error instanceof DiagnoseError && error.status === 404;
|
|
434
|
+
if (unavailable) unavailableRunIDsRef.current.add(focusedID);
|
|
435
|
+
retainFocusedSnapshot = !unavailable;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
setRuns((prev) => {
|
|
439
|
+
let nextRuns = r.runs;
|
|
440
|
+
if (focusedRun) nextRuns = [focusedRun, ...nextRuns];
|
|
441
|
+
if (focusedID && retainFocusedSnapshot) {
|
|
442
|
+
const previous = prev.find((run) => run.id === focusedID);
|
|
443
|
+
if (previous) nextRuns = [previous, ...nextRuns];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// openRun/start can focus and insert a run while the direct fetch above
|
|
447
|
+
// is in flight. Preserve that newer focus instead of replacing it with
|
|
448
|
+
// the snapshot for the run that was active when this refresh started.
|
|
449
|
+
const currentFocusedID = activeRunIdRef.current;
|
|
450
|
+
if (
|
|
451
|
+
currentFocusedID &&
|
|
452
|
+
currentFocusedID !== focusedID &&
|
|
453
|
+
!nextRuns.some((run) => run.id === currentFocusedID)
|
|
454
|
+
) {
|
|
455
|
+
const current = prev.find((run) => run.id === currentFocusedID);
|
|
456
|
+
if (current) nextRuns = [current, ...nextRuns];
|
|
457
|
+
}
|
|
458
|
+
return nextRuns;
|
|
349
459
|
});
|
|
460
|
+
setRunsLoaded(true);
|
|
461
|
+
setRunsLoadFailed(false);
|
|
462
|
+
setHistoryDegraded(!!r.historyDegraded);
|
|
463
|
+
} catch {
|
|
464
|
+
// Leave runsLoaded false (a missing-run verdict needs a real list) but
|
|
465
|
+
// record the failure so the panel can say "retrying" instead of
|
|
466
|
+
// pretending nothing happened. The 4s poll keeps retrying while open.
|
|
467
|
+
setRunsLoadFailed(true);
|
|
468
|
+
}
|
|
350
469
|
}, [available]);
|
|
470
|
+
const updateRunSummary = useCallback((run: RunSummary) => {
|
|
471
|
+
setRuns((prev) =>
|
|
472
|
+
prev.some((item) => item.id === run.id)
|
|
473
|
+
? prev.map((item) => (item.id === run.id ? run : item))
|
|
474
|
+
: [run, ...prev],
|
|
475
|
+
);
|
|
476
|
+
}, []);
|
|
351
477
|
|
|
352
|
-
// A content-stable signature of the resources with a live
|
|
478
|
+
// A content-stable signature of the resources with a live investigation,
|
|
353
479
|
// so the per-resource Diagnose buttons can show a "running" indicator even with the
|
|
354
480
|
// panel closed — and only re-render when the set actually changes, not every poll.
|
|
355
481
|
const runningSig = runs
|
|
356
|
-
.filter((r) => r.status === "running")
|
|
482
|
+
.filter((r) => r.status === "running" || r.status === "stopping")
|
|
357
483
|
.map((r) => runTargetKey(r.kind, r.namespace, r.name))
|
|
358
484
|
.sort()
|
|
359
485
|
.join("|");
|
|
@@ -397,6 +523,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
397
523
|
if (seq !== startSeqRef.current) return;
|
|
398
524
|
setActiveRunId(run.id);
|
|
399
525
|
setView("investigation");
|
|
526
|
+
writeFocusedRunID(run.id, true);
|
|
400
527
|
})
|
|
401
528
|
.catch((e) => {
|
|
402
529
|
if (seq !== startSeqRef.current) return;
|
|
@@ -431,52 +558,90 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
431
558
|
},
|
|
432
559
|
[consentSurface, hosted],
|
|
433
560
|
);
|
|
434
|
-
const openRun = useCallback(
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
561
|
+
const openRun = useCallback(
|
|
562
|
+
(id: string) => {
|
|
563
|
+
unavailableRunIDsRef.current.delete(id);
|
|
564
|
+
setStartError(null);
|
|
565
|
+
setActiveRunId(id);
|
|
566
|
+
setView("investigation");
|
|
567
|
+
setOpen(true);
|
|
568
|
+
writeFocusedRunID(id, true);
|
|
569
|
+
// A Fleet issue can point at a retained automatic run older than the
|
|
570
|
+
// bounded history page. Resolve the exact id instead of waiting for list.
|
|
571
|
+
getRun(id)
|
|
572
|
+
.then(updateRunSummary)
|
|
573
|
+
.catch((error) => {
|
|
574
|
+
if (error instanceof DiagnoseError && error.status === 404) {
|
|
575
|
+
unavailableRunIDsRef.current.add(id);
|
|
576
|
+
}
|
|
577
|
+
// The loaded-list state renders the durable unavailable message.
|
|
578
|
+
});
|
|
579
|
+
},
|
|
580
|
+
[updateRunSummary, writeFocusedRunID],
|
|
581
|
+
);
|
|
440
582
|
|
|
441
|
-
//
|
|
442
|
-
// the
|
|
443
|
-
//
|
|
583
|
+
// The run id is durable navigation state: direct loads and popstate focus the
|
|
584
|
+
// exact run, and the query remains in place so the address bar is copyable.
|
|
585
|
+
// Fetch by id rather than assuming the bounded recent list contains it.
|
|
444
586
|
useEffect(() => {
|
|
445
|
-
if (!available) return;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
null
|
|
455
|
-
""
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
587
|
+
if (!available || !diagnoseURLStateEnabled(browserURLState)) return;
|
|
588
|
+
const focusFromLocation = (fromPopState: boolean) => {
|
|
589
|
+
const id = runIDFromLocation(browserURLState);
|
|
590
|
+
if (!id) {
|
|
591
|
+
// A missing id on first mount is ordinary. On popstate it means "back
|
|
592
|
+
// out of this investigation" only when the focused run was itself
|
|
593
|
+
// installed by URL history; unrelated synthetic popstate events must
|
|
594
|
+
// not tear down a panel opened by an in-app action.
|
|
595
|
+
if (fromPopState && urlRunIdRef.current) {
|
|
596
|
+
setActiveRunId(null);
|
|
597
|
+
setView("home");
|
|
598
|
+
setOpen(false);
|
|
599
|
+
}
|
|
600
|
+
urlRunIdRef.current = null;
|
|
601
|
+
return;
|
|
460
602
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
603
|
+
urlRunIdRef.current = id;
|
|
604
|
+
unavailableRunIDsRef.current.delete(id);
|
|
605
|
+
setStartError(null);
|
|
606
|
+
setActiveRunId(id);
|
|
607
|
+
setView("investigation");
|
|
608
|
+
setOpen(true);
|
|
609
|
+
getRun(id)
|
|
610
|
+
.then(updateRunSummary)
|
|
611
|
+
.catch((error) => {
|
|
612
|
+
if (error instanceof DiagnoseError && error.status === 404) {
|
|
613
|
+
unavailableRunIDsRef.current.add(id);
|
|
614
|
+
}
|
|
615
|
+
// The list load owns the final missing/degraded state and keeps retrying.
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
focusFromLocation(false);
|
|
619
|
+
const onPopState = () => focusFromLocation(true);
|
|
620
|
+
window.addEventListener("popstate", onPopState);
|
|
621
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
622
|
+
}, [available, browserURLState, updateRunSummary]);
|
|
466
623
|
// Leaving the detail pane drops the failure that belonged to it. startError
|
|
467
624
|
// renders as the entire pane (maximized home still shows `detail`), where a
|
|
468
625
|
// message about a resource you just navigated away from has nothing to attach
|
|
469
626
|
// to and no way to be dismissed.
|
|
470
627
|
const openHome = useCallback(() => {
|
|
628
|
+
unavailableRunIDsRef.current.clear();
|
|
471
629
|
setView("home");
|
|
472
630
|
setStartError(null);
|
|
473
631
|
setOpen(true);
|
|
474
|
-
|
|
632
|
+
writeFocusedRunID(null, false);
|
|
633
|
+
}, [writeFocusedRunID]);
|
|
475
634
|
const goHome = useCallback(() => {
|
|
635
|
+
unavailableRunIDsRef.current.clear();
|
|
476
636
|
setView("home");
|
|
477
637
|
setStartError(null);
|
|
478
|
-
|
|
479
|
-
|
|
638
|
+
writeFocusedRunID(null, false);
|
|
639
|
+
}, [writeFocusedRunID]);
|
|
640
|
+
const close = useCallback(() => {
|
|
641
|
+
unavailableRunIDsRef.current.clear();
|
|
642
|
+
setOpen(false);
|
|
643
|
+
writeFocusedRunID(null, false);
|
|
644
|
+
}, [writeFocusedRunID]);
|
|
480
645
|
const consentBusyRef = useRef(false);
|
|
481
646
|
const approveConsent = useCallback(() => {
|
|
482
647
|
if (consentBusyRef.current) return;
|
|
@@ -557,6 +722,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
557
722
|
approveConsent,
|
|
558
723
|
cancelConsent,
|
|
559
724
|
refreshRuns,
|
|
725
|
+
updateRunSummary,
|
|
560
726
|
dismissError,
|
|
561
727
|
};
|
|
562
728
|
|
|
@@ -567,6 +733,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
567
733
|
const layout = useMemo<DiagnoseLayoutCtx>(
|
|
568
734
|
() => ({
|
|
569
735
|
open,
|
|
736
|
+
close,
|
|
570
737
|
contentGutter,
|
|
571
738
|
maximized,
|
|
572
739
|
setMaximized,
|
|
@@ -579,6 +746,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
579
746
|
}),
|
|
580
747
|
[
|
|
581
748
|
open,
|
|
749
|
+
close,
|
|
582
750
|
contentGutter,
|
|
583
751
|
maximized,
|
|
584
752
|
width,
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { canStartNewInvestigation } from "./DiagnoseSurface";
|
|
2
|
+
import { canCopyRunLink, canStartNewInvestigation } from "./DiagnoseSurface";
|
|
3
|
+
import {
|
|
4
|
+
canContinueInvestigation,
|
|
5
|
+
canStopInvestigation,
|
|
6
|
+
} from "./InvestigationView";
|
|
3
7
|
import type { RunSummary } from "../../api/diagnose";
|
|
4
8
|
|
|
5
9
|
// The "new investigation" button dispatches an agent and spends the user's own
|
|
@@ -34,9 +38,25 @@ describe("canStartNewInvestigation", () => {
|
|
|
34
38
|
|
|
35
39
|
it("stays hidden while a turn is in flight", () => {
|
|
36
40
|
// A start would be handed back the live run, so the button does nothing.
|
|
37
|
-
expect(
|
|
38
|
-
false,
|
|
39
|
-
);
|
|
41
|
+
expect(
|
|
42
|
+
canStartNewInvestigation("investigation", run("running"), false),
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("stays hidden while a stopped turn is draining", () => {
|
|
47
|
+
expect(
|
|
48
|
+
canStartNewInvestigation("investigation", run("stopping"), false),
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("offers a separate human investigation while an automatic run is in flight", () => {
|
|
53
|
+
expect(
|
|
54
|
+
canStartNewInvestigation(
|
|
55
|
+
"investigation",
|
|
56
|
+
{ ...run("running"), trigger: "background" },
|
|
57
|
+
false,
|
|
58
|
+
),
|
|
59
|
+
).toBe(true);
|
|
40
60
|
});
|
|
41
61
|
|
|
42
62
|
it("stays hidden on a stale run", () => {
|
|
@@ -63,8 +83,101 @@ describe("canStartNewInvestigation", () => {
|
|
|
63
83
|
expect(canStartNewInvestigation("investigation", run("error"), false)).toBe(
|
|
64
84
|
true,
|
|
65
85
|
);
|
|
66
|
-
expect(
|
|
86
|
+
expect(
|
|
87
|
+
canStartNewInvestigation("investigation", run("stopped"), false),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("canStopInvestigation", () => {
|
|
93
|
+
it("lets the terminal transcript outrank a lagging running summary", () => {
|
|
94
|
+
expect(canStopInvestigation(run("running"), false, false, "done")).toBe(
|
|
95
|
+
false,
|
|
96
|
+
);
|
|
97
|
+
expect(canStopInvestigation(run("running"), false, false, "error")).toBe(
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keeps Stop available for an active human turn", () => {
|
|
103
|
+
expect(canStopInvestigation(run("running"), true, false, "running")).toBe(
|
|
67
104
|
true,
|
|
68
105
|
);
|
|
69
106
|
});
|
|
107
|
+
|
|
108
|
+
it("does not offer another Stop while the server drains the turn", () => {
|
|
109
|
+
expect(canStopInvestigation(run("stopping"), true, false, "running")).toBe(
|
|
110
|
+
false,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("never lets a missing or automatic run expose Stop", () => {
|
|
115
|
+
expect(canStopInvestigation(run("running"), true, true, "running")).toBe(
|
|
116
|
+
false,
|
|
117
|
+
);
|
|
118
|
+
expect(
|
|
119
|
+
canStopInvestigation(
|
|
120
|
+
{ ...run("running"), trigger: "background" },
|
|
121
|
+
true,
|
|
122
|
+
false,
|
|
123
|
+
"running",
|
|
124
|
+
),
|
|
125
|
+
).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("canContinueInvestigation", () => {
|
|
130
|
+
it("lets a terminal transcript outrank only a lagging running human summary", () => {
|
|
131
|
+
expect(
|
|
132
|
+
canContinueInvestigation(
|
|
133
|
+
{ ...run("running"), canContinue: false },
|
|
134
|
+
"done",
|
|
135
|
+
),
|
|
136
|
+
).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("keeps genuinely read-only and sessionless investigations read-only", () => {
|
|
140
|
+
expect(
|
|
141
|
+
canContinueInvestigation(
|
|
142
|
+
{
|
|
143
|
+
...run("running"),
|
|
144
|
+
trigger: "background",
|
|
145
|
+
canContinue: false,
|
|
146
|
+
},
|
|
147
|
+
"done",
|
|
148
|
+
),
|
|
149
|
+
).toBe(false);
|
|
150
|
+
expect(
|
|
151
|
+
canContinueInvestigation({ ...run("done"), canContinue: false }, "done"),
|
|
152
|
+
).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does not offer a follow-up after the retained run disappears", () => {
|
|
156
|
+
expect(
|
|
157
|
+
canContinueInvestigation(
|
|
158
|
+
{ ...run("running"), canContinue: false },
|
|
159
|
+
"error",
|
|
160
|
+
true,
|
|
161
|
+
),
|
|
162
|
+
).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("canCopyRunLink", () => {
|
|
167
|
+
it("does not expose collaboration UI for an OSS run", () => {
|
|
168
|
+
expect(canCopyRunLink(run("done"))).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("exposes the copy action only for a canonical hosted URL", () => {
|
|
172
|
+
expect(
|
|
173
|
+
canCopyRunLink({
|
|
174
|
+
...run("done"),
|
|
175
|
+
radarUrl: "/c/cluster-1?org=org-1&ai-run=r1",
|
|
176
|
+
}),
|
|
177
|
+
).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("treats an empty hosted URL as unavailable", () => {
|
|
181
|
+
expect(canCopyRunLink({ ...run("done"), radarUrl: "" })).toBe(false);
|
|
182
|
+
});
|
|
70
183
|
});
|