@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,451 @@
|
|
|
1
|
+
// Pure presentation decisions over the durable transcript and evidence projection.
|
|
2
|
+
// Keep React/DOM orchestration in InvestigationView; these rules have no UI runtime.
|
|
3
|
+
import {
|
|
4
|
+
DiagnoseError,
|
|
5
|
+
type DiagnoseStreamEvent,
|
|
6
|
+
type RunSummary,
|
|
7
|
+
} from "../../api/diagnose";
|
|
8
|
+
import type { Turn } from "./parts";
|
|
9
|
+
import type {
|
|
10
|
+
InvestigationEvidenceProjection,
|
|
11
|
+
InvestigationEvidenceTurn,
|
|
12
|
+
} from "./investigationEvidence";
|
|
13
|
+
|
|
14
|
+
export function initialInvestigationPane(
|
|
15
|
+
status: RunSummary["status"],
|
|
16
|
+
): "activity" | "evidence" {
|
|
17
|
+
return status === "done" || status === "stale" ? "evidence" : "activity";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function investigationEvidenceShouldMarkUnread({
|
|
21
|
+
hasNewLiveSource,
|
|
22
|
+
selectedPane,
|
|
23
|
+
evidencePaneVisible,
|
|
24
|
+
}: {
|
|
25
|
+
hasNewLiveSource: boolean;
|
|
26
|
+
selectedPane: "activity" | "evidence";
|
|
27
|
+
evidencePaneVisible: boolean;
|
|
28
|
+
}): boolean {
|
|
29
|
+
return (
|
|
30
|
+
hasNewLiveSource && selectedPane === "activity" && !evidencePaneVisible
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function investigationEvidenceAnnouncement({
|
|
35
|
+
unreadEvidence,
|
|
36
|
+
evidenceUpdateAvailable,
|
|
37
|
+
}: {
|
|
38
|
+
unreadEvidence: boolean;
|
|
39
|
+
evidenceUpdateAvailable: boolean;
|
|
40
|
+
}): string {
|
|
41
|
+
if (unreadEvidence) return "New evidence available";
|
|
42
|
+
if (evidenceUpdateAvailable) {
|
|
43
|
+
return "New evidence available in Findings.";
|
|
44
|
+
}
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function investigationIsReadOnly(
|
|
49
|
+
status: RunSummary["status"],
|
|
50
|
+
gone: boolean,
|
|
51
|
+
): boolean {
|
|
52
|
+
return status === "stale" || gone;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function investigationInteractionsBlocked(input: {
|
|
56
|
+
streamReady: boolean;
|
|
57
|
+
busy: boolean;
|
|
58
|
+
requestPending: boolean;
|
|
59
|
+
readOnly: boolean;
|
|
60
|
+
verificationPending: boolean;
|
|
61
|
+
}): boolean {
|
|
62
|
+
return (
|
|
63
|
+
!input.streamReady ||
|
|
64
|
+
input.busy ||
|
|
65
|
+
input.requestPending ||
|
|
66
|
+
input.readOnly ||
|
|
67
|
+
input.verificationPending
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function canOfferInvestigationApply(input: {
|
|
72
|
+
currentAssessmentIdx: number;
|
|
73
|
+
lastRemediationIdx: number;
|
|
74
|
+
lastApplyAttemptIdx: number;
|
|
75
|
+
localApplyAttemptAssessmentIdx: number;
|
|
76
|
+
interactionsBlocked: boolean;
|
|
77
|
+
hosted: boolean;
|
|
78
|
+
hasNewerEvidence: boolean;
|
|
79
|
+
}): boolean {
|
|
80
|
+
return (
|
|
81
|
+
input.currentAssessmentIdx === input.lastRemediationIdx &&
|
|
82
|
+
input.currentAssessmentIdx >
|
|
83
|
+
Math.max(
|
|
84
|
+
input.lastApplyAttemptIdx,
|
|
85
|
+
input.localApplyAttemptAssessmentIdx,
|
|
86
|
+
) &&
|
|
87
|
+
!input.interactionsBlocked &&
|
|
88
|
+
!input.hosted &&
|
|
89
|
+
!input.hasNewerEvidence
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function investigationApplyAttemptVerified(input: {
|
|
94
|
+
localApplyAttemptAssessmentIdx: number;
|
|
95
|
+
currentAssessmentIdx: number;
|
|
96
|
+
currentAssessmentIsVerification: boolean;
|
|
97
|
+
}): boolean {
|
|
98
|
+
return (
|
|
99
|
+
input.localApplyAttemptAssessmentIdx >= 0 &&
|
|
100
|
+
input.currentAssessmentIdx > input.localApplyAttemptAssessmentIdx &&
|
|
101
|
+
input.currentAssessmentIsVerification
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function investigationAssessmentNeedsCurrentStateVerification(input: {
|
|
106
|
+
currentAssessmentIdx: number;
|
|
107
|
+
lastApplyAttemptIdx: number;
|
|
108
|
+
lastApplyOutcome: Turn["applyOutcome"];
|
|
109
|
+
localApplyAttemptAssessmentIdx: number;
|
|
110
|
+
}): boolean {
|
|
111
|
+
if (input.currentAssessmentIdx < 0) return false;
|
|
112
|
+
|
|
113
|
+
// Once the durable apply turn exists it is authoritative for the local
|
|
114
|
+
// pessimistic marker. A producer-confirmed failure means no write occurred;
|
|
115
|
+
// every other outcome (including a running/missing outcome) leaves the
|
|
116
|
+
// pre-write assessment unsafe to present as current.
|
|
117
|
+
if (input.lastApplyAttemptIdx > input.currentAssessmentIdx) {
|
|
118
|
+
return input.lastApplyOutcome !== "failed";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return input.localApplyAttemptAssessmentIdx >= input.currentAssessmentIdx;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function investigationApplyRejectionIsDefinitive(
|
|
125
|
+
error: unknown,
|
|
126
|
+
): error is DiagnoseError {
|
|
127
|
+
return error instanceof DiagnoseError && error.status < 500;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function investigationApplyCompletionEffects(input: {
|
|
131
|
+
live: boolean;
|
|
132
|
+
applyStartedLive: boolean;
|
|
133
|
+
stale: boolean;
|
|
134
|
+
}): {
|
|
135
|
+
refreshClusterState: boolean;
|
|
136
|
+
verificationPending: boolean;
|
|
137
|
+
} {
|
|
138
|
+
const belongsToThisLiveView = input.live || input.applyStartedLive;
|
|
139
|
+
return {
|
|
140
|
+
refreshClusterState: belongsToThisLiveView,
|
|
141
|
+
verificationPending: belongsToThisLiveView && !input.stale,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function investigationTurnWithTerminalEvent(
|
|
146
|
+
turn: Turn,
|
|
147
|
+
event: Pick<
|
|
148
|
+
DiagnoseStreamEvent,
|
|
149
|
+
"type" | "diagnosis" | "error" | "applyOutcome"
|
|
150
|
+
>,
|
|
151
|
+
animateResult: boolean,
|
|
152
|
+
): Turn {
|
|
153
|
+
if (event.type === "done") {
|
|
154
|
+
return {
|
|
155
|
+
...turn,
|
|
156
|
+
diagnosis: event.diagnosis ?? null,
|
|
157
|
+
error: null,
|
|
158
|
+
status: "done",
|
|
159
|
+
applyOutcome: event.applyOutcome,
|
|
160
|
+
animateResult,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (event.type === "error" && turn.status === "running") {
|
|
164
|
+
return {
|
|
165
|
+
...turn,
|
|
166
|
+
error: event.error || "The investigation failed.",
|
|
167
|
+
status: "error",
|
|
168
|
+
applyOutcome: event.applyOutcome,
|
|
169
|
+
animateResult,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return turn;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function investigationApplyTerminalNeedsClusterRefresh(input: {
|
|
176
|
+
localApplyRequestPending: boolean;
|
|
177
|
+
streamedApplyPending: boolean;
|
|
178
|
+
streamedApplyStartedLive: boolean;
|
|
179
|
+
terminalEventIsLive: boolean;
|
|
180
|
+
}): boolean {
|
|
181
|
+
return (
|
|
182
|
+
input.localApplyRequestPending ||
|
|
183
|
+
(input.streamedApplyPending &&
|
|
184
|
+
(input.streamedApplyStartedLive || input.terminalEventIsLive))
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function investigationClosedEventIsLive(input: {
|
|
189
|
+
reason: "run_closed" | "unavailable";
|
|
190
|
+
subscribedRunStatus: RunSummary["status"];
|
|
191
|
+
replayComplete: boolean;
|
|
192
|
+
}): boolean {
|
|
193
|
+
// A retained stale run replays `replay_complete` immediately before its
|
|
194
|
+
// durable `closed` sentinel. The replay flag is therefore already true when
|
|
195
|
+
// that historical close arrives; the status captured when this subscription
|
|
196
|
+
// opened is what distinguishes it from a run that closed while being watched.
|
|
197
|
+
if (input.reason === "run_closed") {
|
|
198
|
+
return input.subscribedRunStatus !== "stale";
|
|
199
|
+
}
|
|
200
|
+
return input.replayComplete;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function investigationClosedRunIsUnavailable(input: {
|
|
204
|
+
reason: "run_closed" | "unavailable";
|
|
205
|
+
subscribedRunStatus: RunSummary["status"];
|
|
206
|
+
}): boolean {
|
|
207
|
+
if (input.reason === "unavailable") return true;
|
|
208
|
+
// A running stream is finalized only when its cluster context changes; its
|
|
209
|
+
// refreshed summary will be stale, not gone. Retained stale streams likewise
|
|
210
|
+
// end with their durable closed sentinel. Other terminal runs can close only
|
|
211
|
+
// after retention eviction, so those are genuinely unavailable.
|
|
212
|
+
return !["running", "stale"].includes(input.subscribedRunStatus);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isCompletedEvidenceTool(
|
|
216
|
+
item: InvestigationEvidenceTurn["timeline"][number],
|
|
217
|
+
) {
|
|
218
|
+
return item.kind === "tool" && item.status === "done";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Reasoning reveal replaces a Turn every 150 ms, but completed tool records are
|
|
223
|
+
* immutable. Compare only the fields the evidence projector consumes so those
|
|
224
|
+
* cosmetic transcript updates do not repeatedly parse every retained payload.
|
|
225
|
+
*/
|
|
226
|
+
export function investigationEvidenceInputsEqual(
|
|
227
|
+
previous: readonly InvestigationEvidenceTurn[],
|
|
228
|
+
next: readonly InvestigationEvidenceTurn[],
|
|
229
|
+
): boolean {
|
|
230
|
+
if (previous.length !== next.length) return false;
|
|
231
|
+
for (let turnIndex = 0; turnIndex < previous.length; turnIndex += 1) {
|
|
232
|
+
const a = previous[turnIndex];
|
|
233
|
+
const b = next[turnIndex];
|
|
234
|
+
if (
|
|
235
|
+
a.question !== b.question ||
|
|
236
|
+
a.apply !== b.apply ||
|
|
237
|
+
a.verify !== b.verify ||
|
|
238
|
+
a.status !== b.status
|
|
239
|
+
) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let aIndex = 0;
|
|
244
|
+
let bIndex = 0;
|
|
245
|
+
while (true) {
|
|
246
|
+
while (
|
|
247
|
+
aIndex < a.timeline.length &&
|
|
248
|
+
!isCompletedEvidenceTool(a.timeline[aIndex])
|
|
249
|
+
) {
|
|
250
|
+
aIndex += 1;
|
|
251
|
+
}
|
|
252
|
+
while (
|
|
253
|
+
bIndex < b.timeline.length &&
|
|
254
|
+
!isCompletedEvidenceTool(b.timeline[bIndex])
|
|
255
|
+
) {
|
|
256
|
+
bIndex += 1;
|
|
257
|
+
}
|
|
258
|
+
const aDone = aIndex >= a.timeline.length;
|
|
259
|
+
const bDone = bIndex >= b.timeline.length;
|
|
260
|
+
if (aDone || bDone) {
|
|
261
|
+
if (aDone !== bDone) return false;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
if (aIndex !== bIndex || a.timeline[aIndex] !== b.timeline[bIndex]) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
aIndex += 1;
|
|
268
|
+
bIndex += 1;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function investigationEvidenceCoverageLimited(
|
|
275
|
+
projection: Pick<
|
|
276
|
+
InvestigationEvidenceProjection,
|
|
277
|
+
"limitations" | "coverage"
|
|
278
|
+
> & {
|
|
279
|
+
sources: readonly {
|
|
280
|
+
id: string;
|
|
281
|
+
tool: string;
|
|
282
|
+
confirmedSuccess: boolean;
|
|
283
|
+
}[];
|
|
284
|
+
groups: readonly {
|
|
285
|
+
latest: {
|
|
286
|
+
relevance: "target" | "producer-related" | "broader";
|
|
287
|
+
source: { id: string };
|
|
288
|
+
};
|
|
289
|
+
}[];
|
|
290
|
+
},
|
|
291
|
+
): boolean {
|
|
292
|
+
const completeDiagnosisSourceIds = new Set(
|
|
293
|
+
projection.sources
|
|
294
|
+
.filter((source) => source.tool === "diagnose" && source.confirmedSuccess)
|
|
295
|
+
.map((source) => source.id),
|
|
296
|
+
);
|
|
297
|
+
const hasTargetDiagnosis = projection.groups.some(
|
|
298
|
+
(group) =>
|
|
299
|
+
group.latest.relevance !== "broader" &&
|
|
300
|
+
completeDiagnosisSourceIds.has(group.latest.source.id),
|
|
301
|
+
);
|
|
302
|
+
return (
|
|
303
|
+
projection.limitations.length > 0 ||
|
|
304
|
+
projection.coverage.projected === 0 ||
|
|
305
|
+
!hasTargetDiagnosis
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const HEALTH_CONFLICT_EVIDENCE_KINDS = new Set([
|
|
310
|
+
"issue",
|
|
311
|
+
"startup",
|
|
312
|
+
"crash",
|
|
313
|
+
"resource",
|
|
314
|
+
"logs",
|
|
315
|
+
"events",
|
|
316
|
+
"dns",
|
|
317
|
+
"network",
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A model-authored all-clear must not overrule active adverse Radar evidence.
|
|
322
|
+
* Context-only warnings (for example a Helm ownership advisory) and ordinary
|
|
323
|
+
* recent changes are deliberately excluded: they are useful context, not proof
|
|
324
|
+
* that the investigated resource is unhealthy.
|
|
325
|
+
*/
|
|
326
|
+
export function investigationEvidenceConflictsWithHealthy(projection: {
|
|
327
|
+
groups: readonly {
|
|
328
|
+
historical: boolean;
|
|
329
|
+
kind: string;
|
|
330
|
+
latest: {
|
|
331
|
+
relevance: "target" | "producer-related" | "broader";
|
|
332
|
+
tier: "key" | "supporting" | "context" | "checked";
|
|
333
|
+
tone: string;
|
|
334
|
+
};
|
|
335
|
+
}[];
|
|
336
|
+
}): boolean {
|
|
337
|
+
return projection.groups.some(
|
|
338
|
+
(group) =>
|
|
339
|
+
!group.historical &&
|
|
340
|
+
group.latest.relevance !== "broader" &&
|
|
341
|
+
(group.latest.tier === "key" || group.latest.tier === "supporting") &&
|
|
342
|
+
(group.latest.tone === "warning" || group.latest.tone === "error") &&
|
|
343
|
+
HEALTH_CONFLICT_EVIDENCE_KINDS.has(group.kind),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function investigationEndedBeforeConclusion(
|
|
348
|
+
status: RunSummary["status"],
|
|
349
|
+
lastTurn: Pick<Turn, "status" | "apply" | "explainAssessment"> | undefined,
|
|
350
|
+
): boolean {
|
|
351
|
+
return (
|
|
352
|
+
(status === "error" || status === "stopped") &&
|
|
353
|
+
(lastTurn === undefined ||
|
|
354
|
+
(lastTurn.status === "error" &&
|
|
355
|
+
lastTurn.apply !== true &&
|
|
356
|
+
!lastTurn.explainAssessment))
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface InvestigationHistoryUnavailableState {
|
|
361
|
+
error: string;
|
|
362
|
+
retryable: boolean;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function investigationHistoryUnavailablePresentation(
|
|
366
|
+
state: InvestigationHistoryUnavailableState,
|
|
367
|
+
): { title: string; detail: string; loading: boolean } {
|
|
368
|
+
if (state.retryable) {
|
|
369
|
+
const error = state.error.trim();
|
|
370
|
+
return {
|
|
371
|
+
title: "Saved history is temporarily unavailable",
|
|
372
|
+
detail: error
|
|
373
|
+
? `${error}${/[.!?]$/.test(error) ? " " : ". "}Radar is retrying without discarding this run.`
|
|
374
|
+
: "Radar is retrying without discarding this run.",
|
|
375
|
+
loading: true,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
title: "Saved history is unavailable",
|
|
380
|
+
detail:
|
|
381
|
+
state.error || "Radar could not restore this run from its saved history.",
|
|
382
|
+
loading: false,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function investigationPaneCenteredScrollTop({
|
|
387
|
+
scrollTop,
|
|
388
|
+
viewportHeight,
|
|
389
|
+
contentHeight,
|
|
390
|
+
targetTop,
|
|
391
|
+
targetHeight,
|
|
392
|
+
}: {
|
|
393
|
+
scrollTop: number;
|
|
394
|
+
viewportHeight: number;
|
|
395
|
+
contentHeight: number;
|
|
396
|
+
/** Target top relative to the scroll viewport, before this adjustment. */
|
|
397
|
+
targetTop: number;
|
|
398
|
+
targetHeight: number;
|
|
399
|
+
}): number {
|
|
400
|
+
const centered =
|
|
401
|
+
scrollTop + targetTop - Math.max(0, (viewportHeight - targetHeight) / 2);
|
|
402
|
+
return Math.max(0, Math.min(centered, contentHeight - viewportHeight));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function canStopInvestigation(
|
|
406
|
+
run: RunSummary,
|
|
407
|
+
busy: boolean,
|
|
408
|
+
gone: boolean,
|
|
409
|
+
latestTurnStatus?: Turn["status"],
|
|
410
|
+
): boolean {
|
|
411
|
+
// The transcript is fresher than the polled run summary. Once it has a
|
|
412
|
+
// terminal frame, a lagging/failed summary refresh must not resurrect Stop.
|
|
413
|
+
const transcriptTerminal =
|
|
414
|
+
latestTurnStatus === "done" || latestTurnStatus === "error";
|
|
415
|
+
return (
|
|
416
|
+
run.trigger !== "background" &&
|
|
417
|
+
run.status !== "stale" &&
|
|
418
|
+
run.status !== "stopping" &&
|
|
419
|
+
!gone &&
|
|
420
|
+
!transcriptTerminal &&
|
|
421
|
+
(busy || run.status === "running")
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function canContinueInvestigation(
|
|
426
|
+
run: RunSummary,
|
|
427
|
+
latestTurnStatus?: Turn["status"],
|
|
428
|
+
gone = false,
|
|
429
|
+
): boolean {
|
|
430
|
+
const transcriptTerminal =
|
|
431
|
+
latestTurnStatus === "done" || latestTurnStatus === "error";
|
|
432
|
+
const summaryIsLaggingTerminalTranscript =
|
|
433
|
+
run.status === "running" &&
|
|
434
|
+
run.trigger !== "background" &&
|
|
435
|
+
transcriptTerminal;
|
|
436
|
+
return (
|
|
437
|
+
!gone &&
|
|
438
|
+
run.status !== "stale" &&
|
|
439
|
+
run.status !== "stopping" &&
|
|
440
|
+
(run.canContinue !== false || summaryIsLaggingTerminalTranscript)
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function canInvestigateFurther(run: RunSummary, gone = false): boolean {
|
|
445
|
+
return (
|
|
446
|
+
!gone &&
|
|
447
|
+
run.trigger === "background" &&
|
|
448
|
+
run.status === "done" &&
|
|
449
|
+
!!run.issueId
|
|
450
|
+
);
|
|
451
|
+
}
|