mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-11 08:37:16 +00:00
Show a banner for dead runs, which record no conclusion
A dead run's projection never gets conclusion.failure, so the banner never showed. Fall back to the run summary's lifecycle.error, where the server keeps a dead run's reason, and to a generic headline when that is empty too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
123e0944bd
commit
2e8d11b769
3 changed files with 44 additions and 6 deletions
|
|
@ -156,7 +156,10 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
() => new Map((providersQuery.data?.data ?? []).map((p) => [p.id, p.display_name || p.id])),
|
||||
[providersQuery.data],
|
||||
);
|
||||
const failure = buildRunFailureView(runStateQuery.data, providerNames);
|
||||
const failure = buildRunFailureView(runStateQuery.data, {
|
||||
providerNames,
|
||||
lifecycleError: runQuery.data?.lifecycle.error?.message,
|
||||
});
|
||||
|
||||
const handleLifecycleMutationResult = useCallback(
|
||||
(
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function failedRun(
|
|||
};
|
||||
}
|
||||
|
||||
const view = (state: State) => buildRunFailureView(state, NAMES);
|
||||
const view = (state: State) => buildRunFailureView(state, { providerNames: NAMES });
|
||||
|
||||
describe("buildRunFailureView", () => {
|
||||
test("names the step the run stopped at and the provider's real message", () => {
|
||||
|
|
@ -113,7 +113,20 @@ describe("buildRunFailureView", () => {
|
|||
expect(view(failedRun({ message: "denied at review" }, { reason: "approval_denied" }))?.headline).toBe("Approval was denied");
|
||||
expect(view(failedRun({ message: "budget of $5 reached" }, { reason: "budget_exhausted" }))?.headline).toBe("The run used up its budget");
|
||||
expect(view(failedRun({ message: "sandbox image not found" }, { reason: "sandbox_init_failed" }))?.headline).toBe("The run couldn't start");
|
||||
expect(view(failedRun({ message: "worker exited unexpectedly" }, { kind: "dead" }))?.headline).toBe("Worker exited unexpectedly");
|
||||
});
|
||||
|
||||
test("says why a dead run stopped, though a dead run records no conclusion", () => {
|
||||
const dead = {
|
||||
status: { kind: "dead" },
|
||||
conclusion: null,
|
||||
stages: stages(["build@1", "failed", "2026-10-10T18:05:42Z"]),
|
||||
} as unknown as State;
|
||||
expect(buildRunFailureView(dead, { lifecycleError: "worker exited unexpectedly" })).toMatchObject({
|
||||
stage: "build",
|
||||
headline: "Worker exited unexpectedly",
|
||||
message: "worker exited unexpectedly",
|
||||
});
|
||||
expect(buildRunFailureView(dead)?.headline).toBe("The run's worker stopped before the run finished");
|
||||
});
|
||||
|
||||
test("shows the end of a command's output, from whichever stream has it", () => {
|
||||
|
|
|
|||
|
|
@ -25,18 +25,27 @@ export interface RunFailureView {
|
|||
|
||||
type FailureState = Pick<RunProjection, "status" | "conclusion" | "stages">;
|
||||
|
||||
export interface RunFailureContext {
|
||||
/** Provider ids to display names, from the provider catalog. */
|
||||
providerNames?: ReadonlyMap<string, string>;
|
||||
/** The run summary's `lifecycle.error`, the only reason a dead run records. */
|
||||
lifecycleError?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the failure from run state (`conclusion.failure`); the run summary the
|
||||
* status popover reads doesn't carry the message or the command's output.
|
||||
* status popover reads doesn't carry the message or the command's output. A
|
||||
* dead run has no conclusion, so its reason comes from the summary instead.
|
||||
*/
|
||||
export function buildRunFailureView(
|
||||
state: FailureState | null | undefined,
|
||||
providerNames: ReadonlyMap<string, string> = new Map(),
|
||||
{ providerNames = new Map(), lifecycleError = null }: RunFailureContext = {},
|
||||
): RunFailureView | null {
|
||||
const kind = state?.status?.kind;
|
||||
if (!state || (kind !== "failed" && kind !== "dead")) return null;
|
||||
const failure = state.conclusion?.failure;
|
||||
if (!failure?.detail.message || failure.reason === FailureReason.CANCELLED) return null;
|
||||
if (!failure) return kind === "dead" ? deadRunView(state, lifecycleError) : null;
|
||||
if (!failure.detail.message || failure.reason === FailureReason.CANCELLED) return null;
|
||||
|
||||
const { message, category } = failure.detail;
|
||||
const output = outputTail(failure.detail.exec_output_tail);
|
||||
|
|
@ -54,6 +63,19 @@ export function buildRunFailureView(
|
|||
};
|
||||
}
|
||||
|
||||
/** A run whose worker went away before the run concluded. */
|
||||
function deadRunView(state: FailureState, lifecycleError: string | null): RunFailureView {
|
||||
const message = lifecycleError?.trim() || "The run ended without recording a reason.";
|
||||
return {
|
||||
stage: stoppedAt(state.stages),
|
||||
headline: lifecycleError?.trim() ? firstSentence(message) : "The run's worker stopped before the run finished",
|
||||
nextStep: `Open the failed step's logs for details. To run it again, ${RETRY}.`,
|
||||
message,
|
||||
provider: null,
|
||||
output: null,
|
||||
};
|
||||
}
|
||||
|
||||
type Advice = Pick<RunFailureView, "headline" | "nextStep">;
|
||||
|
||||
const RETRY = "choose Retry from Actions";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue