mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-02 07:12:34 +00:00
Merge pull request #1076 from jsom/failure-banner
Say why a failed run stopped, above its tabs
This commit is contained in:
commit
71abd4658f
8 changed files with 583 additions and 9 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import type { Model } from "@qltysh/fabro-api-client";
|
||||
import type { Model, Provider } from "@qltysh/fabro-api-client";
|
||||
|
||||
type ModelOfferingIdentity = Pick<Model, "provider" | "id">;
|
||||
|
||||
|
|
@ -6,6 +6,13 @@ export function modelOfferingKey(model: ModelOfferingIdentity): string {
|
|||
return `${model.provider}\u0000${model.id}`;
|
||||
}
|
||||
|
||||
/** Provider ids to the names the catalog displays, falling back to the id. */
|
||||
export function providerNamesById(
|
||||
providers: readonly Pick<Provider, "id" | "display_name">[],
|
||||
): Map<string, string> {
|
||||
return new Map(providers.map((p) => [p.id, p.display_name || p.id]));
|
||||
}
|
||||
|
||||
export function modelOfferingTestArgs(
|
||||
model: ModelOfferingIdentity,
|
||||
): [id: string, provider: string] {
|
||||
|
|
|
|||
|
|
@ -534,9 +534,10 @@ export function useServerSettings() {
|
|||
);
|
||||
}
|
||||
|
||||
export function useProviders() {
|
||||
/** The provider catalog; `enabled: false` skips the request. */
|
||||
export function useProviders({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
return useSWR<ProviderList>(
|
||||
queryKeys.providers.list(),
|
||||
enabled ? queryKeys.providers.list() : null,
|
||||
() => apiData(() => modelsApi.listProviders()),
|
||||
immutableOptions,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ mock.module("../lib/queries", () => ({
|
|||
isValidating: false,
|
||||
mutate: mock(() => Promise.resolve(null)),
|
||||
}),
|
||||
useProviders: () => ({ data: null }),
|
||||
}));
|
||||
|
||||
mock.module("../lib/run-events", () => ({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
|
|
@ -34,7 +35,8 @@ import {
|
|||
} from "../lib/mutations";
|
||||
import { useRunEvents } from "../lib/run-events";
|
||||
import { useRunToasts } from "../hooks/use-run-toasts";
|
||||
import { useRun, useRunQuestions, useRunState } from "../lib/queries";
|
||||
import { useProviders, useRun, useRunQuestions, useRunState } from "../lib/queries";
|
||||
import { providerNamesById } from "../lib/model-offerings";
|
||||
import {
|
||||
canApprove,
|
||||
deleteErrorMessage,
|
||||
|
|
@ -50,6 +52,8 @@ import {
|
|||
RunDetailAskFabroShell,
|
||||
RunDetailDockedControls,
|
||||
} from "./run-detail/docked-controls";
|
||||
import { buildRunFailureView } from "./run-detail/failure";
|
||||
import { RunFailureBanner } from "./run-detail/failure-banner";
|
||||
import { RunDetailHeader } from "./run-detail/header";
|
||||
import {
|
||||
createLifecycleToastState,
|
||||
|
|
@ -148,6 +152,16 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
|
||||
useRunEvents(params.id);
|
||||
useRunToasts(params.id);
|
||||
// Provider names only matter to a failed run's banner.
|
||||
const providersQuery = useProviders({ enabled: statusKind === "failed" || statusKind === "dead" });
|
||||
const providerNames = useMemo(
|
||||
() => providerNamesById(providersQuery.data?.data ?? []),
|
||||
[providersQuery.data],
|
||||
);
|
||||
const failure = buildRunFailureView(runStateQuery.data, {
|
||||
providerNames,
|
||||
lifecycleError: runQuery.data?.lifecycle.error?.message,
|
||||
});
|
||||
|
||||
const handleLifecycleMutationResult = useCallback(
|
||||
(
|
||||
|
|
@ -382,6 +396,8 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
}}
|
||||
/>
|
||||
|
||||
{failure ? <RunFailureBanner failure={failure} /> : null}
|
||||
|
||||
{retryDialogOpen && (
|
||||
<RetryRunDialog
|
||||
key={params.id}
|
||||
|
|
|
|||
62
apps/fabro-web/app/routes/run-detail/failure-banner.tsx
Normal file
62
apps/fabro-web/app/routes/run-detail/failure-banner.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { ChevronDownIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import type { RunFailureView } from "./failure";
|
||||
|
||||
export function RunFailureBanner({ failure }: { failure: RunFailureView }) {
|
||||
return (
|
||||
<section aria-label="Why this run failed" className="mb-6">
|
||||
<details className="group rounded-lg bg-panel-alt outline-1 -outline-offset-1 outline-line">
|
||||
<summary className="flex cursor-pointer list-none items-start gap-3 rounded-lg px-4 py-3.5 transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 [&::-webkit-details-marker]:hidden">
|
||||
<ExclamationTriangleIcon
|
||||
className="mt-0.5 size-4 shrink-0 text-coral"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm/5 font-medium text-fg">
|
||||
{failure.stage ? (
|
||||
<>
|
||||
Stopped at <span className="font-mono text-[0.8125rem]">{failure.stage}</span>
|
||||
<span className="mx-2 text-fg-muted" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{failure.headline}
|
||||
</span>
|
||||
<span className="mt-1 block text-sm/5 text-fg-3">{failure.nextStep}</span>
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 pt-0.5 text-xs font-medium text-fg-muted transition-colors group-hover:text-fg-3">
|
||||
<span className="group-open:hidden">Details</span>
|
||||
<span className="hidden group-open:inline">Hide</span>
|
||||
<ChevronDownIcon
|
||||
className="size-3.5 shrink-0 transition-transform group-open:-rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</summary>
|
||||
<dl className="space-y-3 px-4 pt-0.5 pb-4 pl-11">
|
||||
<div>
|
||||
<dt className="mb-1 font-mono text-[0.6875rem] tracking-wide text-fg-muted uppercase">
|
||||
{failure.provider ? "Provider message" : "Message"}
|
||||
</dt>
|
||||
<dd className="max-h-48 overflow-auto rounded-md bg-overlay px-3 py-2 font-mono text-xs/5 wrap-break-word whitespace-pre-wrap text-fg-3">
|
||||
{failure.message}
|
||||
</dd>
|
||||
</div>
|
||||
{failure.output ? (
|
||||
<div>
|
||||
<dt className="mb-1 font-mono text-[0.6875rem] tracking-wide text-fg-muted uppercase">
|
||||
Output
|
||||
</dt>
|
||||
<dd>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-overlay px-3 py-2 font-mono text-xs/5 text-fg-3">
|
||||
{failure.output}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
218
apps/fabro-web/app/routes/run-detail/failure.test.ts
Normal file
218
apps/fabro-web/app/routes/run-detail/failure.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import type { RunProjection } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { buildRunFailureView } from "./failure";
|
||||
|
||||
type State = Pick<RunProjection, "status" | "conclusion" | "stages">;
|
||||
|
||||
const NAMES = new Map([
|
||||
["anthropic", "Anthropic"],
|
||||
["openai", "OpenAI"],
|
||||
]);
|
||||
|
||||
const CREDIT =
|
||||
"model request failed (invalid_request): provider anthropic Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits. [provider anthropic, status 400, code invalid_request_error]";
|
||||
|
||||
function stages(...entries: Array<[string, string, string]>): State["stages"] {
|
||||
return Object.fromEntries(
|
||||
entries.map(([key, state, started_at]) => [key, { state, started_at } as State["stages"][string]]),
|
||||
);
|
||||
}
|
||||
|
||||
function failedRun(
|
||||
detail: Record<string, unknown>,
|
||||
{ reason = "workflow_error", kind = "failed", runStages = {} as State["stages"] } = {},
|
||||
): State {
|
||||
return {
|
||||
status: (kind === "failed" ? { kind, reason } : { kind }) as State["status"],
|
||||
conclusion: {
|
||||
failure: { reason, detail: { category: "deterministic", ...detail } },
|
||||
} as State["conclusion"],
|
||||
stages: runStages,
|
||||
};
|
||||
}
|
||||
|
||||
const view = (state: State) => buildRunFailureView(state, { providerNames: NAMES });
|
||||
|
||||
describe("buildRunFailureView", () => {
|
||||
test("names the step the run stopped at and the provider's real message", () => {
|
||||
const failure = view(
|
||||
failedRun({ message: CREDIT }, {
|
||||
runStages: stages(["pick@1", "succeeded", "2026-10-10T18:05:42.783Z"], ["spec@1", "failed", "2026-10-10T18:41:53.513Z"]),
|
||||
}),
|
||||
);
|
||||
expect(failure).toMatchObject({
|
||||
stage: "spec",
|
||||
headline: "Anthropic declined the request for billing reasons",
|
||||
provider: "anthropic",
|
||||
message: CREDIT,
|
||||
});
|
||||
expect(failure?.nextStep).toBe("Add credit or raise the spend limit with Anthropic, then choose Retry from Actions.");
|
||||
});
|
||||
|
||||
test("reads a spent quota as billing, though the provider answered 429", () => {
|
||||
const failure = view(
|
||||
failedRun({
|
||||
message: "model request failed (quota_exceeded): provider gemini Quota exceeded for metric generate_content_requests [provider gemini, status 429, code RESOURCE_EXHAUSTED]",
|
||||
}),
|
||||
);
|
||||
expect(failure?.headline).toBe("gemini declined the request for billing reasons");
|
||||
});
|
||||
|
||||
test("names an unknown provider by its id", () => {
|
||||
const failure = view(
|
||||
failedRun({ message: "model request failed (authentication): provider acme bad key [provider acme, status 401, code unauthorized]" }),
|
||||
);
|
||||
expect(failure?.headline).toBe("acme rejected the API key");
|
||||
});
|
||||
|
||||
test("reads auth, rate limits, timeouts and outages from the request's own status and kind", () => {
|
||||
const headline = (message: string) => view(failedRun({ message }))?.headline;
|
||||
expect(headline("model request failed (authentication): provider anthropic invalid x-api-key [provider anthropic, status 401, code authentication_error]"))
|
||||
.toBe("Anthropic rejected the API key");
|
||||
expect(headline("model request failed (access_denied): provider openai The model is not available to your organization [provider openai, status 403, code model_not_found]"))
|
||||
.toBe("OpenAI denied access to the request");
|
||||
expect(headline("model request failed (authentication): provider acme forbidden [provider acme, status 403, code forbidden]"))
|
||||
.toBe("acme rejected the API key");
|
||||
expect(headline("model request failed (rate_limit): provider openai Too many requests [provider openai, status 429, code rate_limit_exceeded]"))
|
||||
.toBe("OpenAI rate-limited the request");
|
||||
expect(headline("model request failed (server): provider anthropic Overloaded [provider anthropic, status 529, code overloaded_error]"))
|
||||
.toBe("Anthropic was unavailable");
|
||||
expect(headline("model request failed (timeout): provider openai request timed out"))
|
||||
.toBe("The request to OpenAI timed out");
|
||||
});
|
||||
|
||||
test("doesn't blame the provider for failures on Fabro's side", () => {
|
||||
const failure = (message: string) => view(failedRun({ message }));
|
||||
expect(failure("model request failed (configuration): provider anthropic is disabled in the catalog")).toMatchObject({
|
||||
headline: "Fabro couldn't send the request to Anthropic",
|
||||
nextStep: "Check Anthropic and the step's model under Settings → Models, then choose Retry from Actions.",
|
||||
});
|
||||
expect(failure("model request failed (model_selection): provider anthropic has no default model")?.headline)
|
||||
.toBe("Fabro couldn't send the request to Anthropic");
|
||||
expect(failure("model request failed (network): provider openai connection refused")?.headline)
|
||||
.toBe("The request never reached OpenAI");
|
||||
expect(failure("model request failed (resource_limit): provider openai response exceeded 16 MiB")?.headline)
|
||||
.toBe("Fabro stopped the request to OpenAI");
|
||||
});
|
||||
|
||||
test("falls back to the provider's status and code", () => {
|
||||
const failure = view(
|
||||
failedRun({
|
||||
message: "model request failed (invalid_request): provider openai Unsupported parameter: 'temperature' is not supported with this model. [provider openai, status 400, code unsupported_parameter]",
|
||||
}),
|
||||
);
|
||||
expect(failure?.headline).toBe("OpenAI returned 400 unsupported parameter");
|
||||
});
|
||||
|
||||
test("never reads a provider, status or billing problem out of a command's own words", () => {
|
||||
const failure = view(
|
||||
failedRun({
|
||||
message: "script exited with status 401",
|
||||
exec_output_tail: { stdout: "test billing::invoice ... FAILED\nretry the provider list later\n", stderr: null },
|
||||
}),
|
||||
);
|
||||
expect(failure).toMatchObject({
|
||||
headline: "The step's command exited with status 401",
|
||||
provider: null,
|
||||
nextStep: "Its output, under Details, usually says why.",
|
||||
});
|
||||
expect(view(failedRun({ message: "Ran 512 tests; line 503 failed." }))?.headline).toBe("Ran 512 tests; line 503 failed.");
|
||||
});
|
||||
|
||||
test("names no step when the run recovered from an earlier failed one", () => {
|
||||
const failure = view(
|
||||
failedRun({ message: "push rejected: remote branch moved" }, {
|
||||
reason: "publish_failed",
|
||||
runStages: stages(
|
||||
["toolchain@1", "failed", "2026-10-10T15:20:45Z"],
|
||||
["fixup@1", "succeeded", "2026-10-10T15:21:00Z"],
|
||||
["verify@1", "succeeded", "2026-10-10T15:25:00.500Z"],
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(failure?.stage).toBeNull();
|
||||
expect(failure?.headline).toBe("The steps finished, but publishing the result failed");
|
||||
});
|
||||
|
||||
test("labels a looped step with its visit, as the Stages sidebar does", () => {
|
||||
const runStages = stages(["review@1", "succeeded", "2026-10-10T15:20:00Z"], ["review@3", "failed", "2026-10-10T15:30:00Z"]);
|
||||
expect(view(failedRun({ message: "script exited with status 1" }, { runStages }))?.stage).toBe("review@3");
|
||||
});
|
||||
|
||||
test("names the failed branch of a fan-out, though a sibling started after it", () => {
|
||||
const runStages = stages(
|
||||
["checks@1", "failed", "2026-10-10T15:20:00.000Z"],
|
||||
["lint@1", "failed", "2026-10-10T15:20:00.010Z"],
|
||||
["test@1", "cancelled", "2026-10-10T15:20:00.013Z"],
|
||||
);
|
||||
runStages["lint@1"]!.parallel_branch_id = "checks@1:0";
|
||||
runStages["test@1"]!.parallel_branch_id = "checks@1:1";
|
||||
expect(view(failedRun({ message: "script exited with status 1" }, { runStages }))?.stage).toBe("lint");
|
||||
|
||||
runStages["report@1"] = { state: "succeeded", started_at: "2026-10-10T15:21:00Z" } as State["stages"][string];
|
||||
expect(view(failedRun({ message: "push rejected" }, { reason: "publish_failed", runStages }))?.stage).toBeNull();
|
||||
});
|
||||
|
||||
test("shows nothing for a cancelled run, and says what happened for other endings", () => {
|
||||
expect(view(failedRun({ message: "Run cancelled before worker launch completed" }, { reason: "cancelled" }))).toBeNull();
|
||||
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: "sandbox connection reset" }, { reason: "transient_infra" }))).toMatchObject({
|
||||
headline: "The step hit a temporary infrastructure error",
|
||||
nextStep: "Choose Retry from Actions in a few minutes.",
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const tail = (exec_output_tail: Record<string, unknown>) =>
|
||||
view(failedRun({ message: "script exited with status 1", exec_output_tail }))?.output;
|
||||
expect(tail({ stderr: "\n \n", stdout: "real error\n" })).toBe("real error");
|
||||
expect(tail({ stderr: "line one\r\nline two\r\n", stdout: null })).toBe("line one\nline two");
|
||||
expect(tail({ stdout: "10%\r50%\r100% done\n" })).toBe("100% done");
|
||||
const many = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
expect(tail({ stdout: many })?.split("\n")).toEqual(Array.from({ length: 12 }, (_, i) => `line ${i + 19}`));
|
||||
});
|
||||
|
||||
test("falls back to the output the failed step recorded", () => {
|
||||
const runStages = stages(["test@1", "failed", "2026-10-10T18:05:42Z"]);
|
||||
runStages["test@1"]!.output = "running 3 tests\ntest parse ... FAILED\n";
|
||||
const failure = view(failedRun({ message: "script exited with status 101" }, { runStages }));
|
||||
expect(failure).toMatchObject({
|
||||
output: "running 3 tests\ntest parse ... FAILED",
|
||||
nextStep: "Its output, under Details, usually says why.",
|
||||
});
|
||||
|
||||
runStages["test@1"]!.output = "blob://sha256/abc";
|
||||
expect(view(failedRun({ message: "script exited with status 101" }, { runStages }))?.output).toBeNull();
|
||||
|
||||
runStages["test@1"]!.output = "I will now write the spec.";
|
||||
expect(view(failedRun({ message: CREDIT }, { runStages }))?.output).toBeNull();
|
||||
});
|
||||
|
||||
test("cuts a long fallback headline without splitting a character", () => {
|
||||
const headline = view(failedRun({ message: `${"🔥".repeat(200)} and more` }))!.headline;
|
||||
expect(Array.from(headline)).toHaveLength(111);
|
||||
expect(headline.endsWith("🔥…")).toBe(true);
|
||||
});
|
||||
|
||||
test("shows nothing for runs that didn't fail", () => {
|
||||
expect(buildRunFailureView({ status: { kind: "succeeded" }, stages: {} } as State)).toBeNull();
|
||||
expect(buildRunFailureView(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
272
apps/fabro-web/app/routes/run-detail/failure.ts
Normal file
272
apps/fabro-web/app/routes/run-detail/failure.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import {
|
||||
FailureCategory,
|
||||
FailureReason,
|
||||
type ExecOutputTail,
|
||||
type RunProjection,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import { formatStageLabel } from "../../lib/stage-sidebar";
|
||||
|
||||
/** Lines of a failed command's output shown on the page. */
|
||||
const OUTPUT_LINES = 12;
|
||||
/** Fallback headlines are cut to this many characters. */
|
||||
const HEADLINE_CHARS = 110;
|
||||
|
||||
export interface RunFailureView {
|
||||
/** The step the run stopped at (`spec`, `review@3`), when it stopped at one. */
|
||||
stage: string | null;
|
||||
headline: string;
|
||||
nextStep: string;
|
||||
/** The recorded failure message, verbatim. */
|
||||
message: string;
|
||||
/** Provider id, when the failure is a model request the provider refused. */
|
||||
provider: string | null;
|
||||
/**
|
||||
* Last OUTPUT_LINES of the failed command's stderr, else its stdout, else
|
||||
* the output the step the run stopped at recorded.
|
||||
*/
|
||||
output: string | null;
|
||||
}
|
||||
|
||||
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. A
|
||||
* dead run has no conclusion, so its reason comes from the summary instead.
|
||||
*/
|
||||
export function buildRunFailureView(
|
||||
state: FailureState | null | undefined,
|
||||
{ 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) return kind === "dead" ? deadRunView(state, lifecycleError) : null;
|
||||
if (!failure.detail.message || failure.reason === FailureReason.CANCELLED) return null;
|
||||
|
||||
const { message, category } = failure.detail;
|
||||
const request = parseModelRequest(message);
|
||||
const stage = stoppedAt(state.stages);
|
||||
// A refused model request's message is the whole story; the step's
|
||||
// output would be the agent's own text.
|
||||
const output =
|
||||
outputTail(failure.detail.exec_output_tail) ??
|
||||
(request || !stage ? null : lastLines(state.stages[stage]?.output));
|
||||
const name = (id: string) => providerNames.get(id) ?? id;
|
||||
const advice =
|
||||
reasonAdvice(failure.reason, category) ??
|
||||
(request
|
||||
? requestAdvice(request, message, name(request.provider))
|
||||
: stepAdvice(message, failure.reason, category, output));
|
||||
return {
|
||||
stage: stageLabel(stage),
|
||||
...advice,
|
||||
message,
|
||||
provider: request?.provider ?? null,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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: stageLabel(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";
|
||||
|
||||
function reasonAdvice(reason: FailureReason, category: FailureCategory): Advice | null {
|
||||
if (reason === FailureReason.APPROVAL_DENIED) {
|
||||
return { headline: "Approval was denied", nextStep: `The run stops where it was denied. To run it again, ${RETRY}.` };
|
||||
}
|
||||
if (reason === FailureReason.BUDGET_EXHAUSTED || category === FailureCategory.BUDGET_EXHAUSTED) {
|
||||
return { headline: "The run used up its budget", nextStep: `Raise the run's budget, then ${RETRY}.` };
|
||||
}
|
||||
if (reason === FailureReason.TERMINATED) {
|
||||
return { headline: "The run was terminated", nextStep: `To run it again, ${RETRY}.` };
|
||||
}
|
||||
if (reason === FailureReason.PUBLISH_FAILED) {
|
||||
return {
|
||||
headline: "The steps finished, but publishing the result failed",
|
||||
nextStep: "The work is on the run branch. The message under Details says why publishing failed.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
reason === FailureReason.LAUNCH_FAILED ||
|
||||
reason === FailureReason.BOOTSTRAP_FAILED ||
|
||||
reason === FailureReason.SANDBOX_INIT_FAILED
|
||||
) {
|
||||
return { headline: "The run couldn't start", nextStep: "The message under Details says why." };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ModelRequest {
|
||||
provider: string;
|
||||
kind: string | null;
|
||||
status: number | null;
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused model request, as the server records it:
|
||||
* `model request failed (<kind>): provider <id> <text> [provider <id>, status <n>, code <code>]`.
|
||||
* Only this shape is read, so numbers and words in a command's output can't
|
||||
* pass for a provider's status or name.
|
||||
*/
|
||||
function parseModelRequest(message: string): ModelRequest | null {
|
||||
const lead = /^model request failed \(([a-z_]+)\): provider (\S+)/i.exec(message);
|
||||
const tail = /\[provider ([^,\]]+), status (\d{3})(?:, code ([^\]]+))?\]\s*$/i.exec(message);
|
||||
const provider = tail?.[1] ?? lead?.[2];
|
||||
if (!provider) return null;
|
||||
return {
|
||||
provider: provider.toLowerCase(),
|
||||
kind: lead?.[1]?.toLowerCase() ?? null,
|
||||
status: tail ? Number(tail[2]) : null,
|
||||
code: tail?.[3] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function requestAdvice({ kind, status, code }: ModelRequest, message: string, who: string): Advice {
|
||||
// These kinds fail on Fabro's side, before or without the provider's say.
|
||||
if (kind === "configuration" || kind === "model_selection") {
|
||||
return {
|
||||
headline: `Fabro couldn't send the request to ${who}`,
|
||||
nextStep: `Check ${who} and the step's model under Settings → Models, then ${RETRY}.`,
|
||||
};
|
||||
}
|
||||
if (kind === "network") {
|
||||
return {
|
||||
headline: `The request never reached ${who}`,
|
||||
nextStep: "Check that the Fabro server can reach the provider, then choose Retry from Actions.",
|
||||
};
|
||||
}
|
||||
if (kind === "middleware" || kind === "resource_limit") {
|
||||
return { headline: `Fabro stopped the request to ${who}`, nextStep: "The message, under Details, says why." };
|
||||
}
|
||||
if (
|
||||
kind === "quota_exceeded" ||
|
||||
status === 402 ||
|
||||
/credit balance|insufficient[_ ]quota|payment required|billing|spend limit/i.test(message)
|
||||
) {
|
||||
return {
|
||||
headline: `${who} declined the request for billing reasons`,
|
||||
nextStep: `Add credit or raise the spend limit with ${who}, then ${RETRY}.`,
|
||||
};
|
||||
}
|
||||
if (kind === "authentication" || status === 401) {
|
||||
return { headline: `${who} rejected the API key`, nextStep: `Check the key under Settings → Secrets, then ${RETRY}.` };
|
||||
}
|
||||
if (kind === "access_denied" || status === 403) {
|
||||
return {
|
||||
headline: `${who} denied access to the request`,
|
||||
nextStep: `The key works, but ${who} doesn't allow this account to make the call. ${who}'s message, under Details, says why.`,
|
||||
};
|
||||
}
|
||||
if (kind === "rate_limit" || status === 429) {
|
||||
return { headline: `${who} rate-limited the request`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." };
|
||||
}
|
||||
if (kind === "timeout" || /timed out/i.test(message)) {
|
||||
return { headline: `The request to ${who} timed out`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." };
|
||||
}
|
||||
if ((status !== null && status >= 500) || /overloaded/i.test(message)) {
|
||||
return { headline: `${who} was unavailable`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." };
|
||||
}
|
||||
const what = [status, code?.replace(/_/g, " ")].filter(Boolean).join(" ");
|
||||
return {
|
||||
headline: what ? `${who} returned ${what}` : `${who} rejected the request`,
|
||||
nextStep: `${who}'s message, under Details, says why.`,
|
||||
};
|
||||
}
|
||||
|
||||
// A transient model request failure keeps requestAdvice's wording, which
|
||||
// names the provider, so transient infrastructure is checked here.
|
||||
function stepAdvice(
|
||||
message: string,
|
||||
reason: FailureReason,
|
||||
category: FailureCategory,
|
||||
output: string | null,
|
||||
): Advice {
|
||||
const exit = /exited with (?:status|code) (\d+)/i.exec(message);
|
||||
if (exit) {
|
||||
return {
|
||||
headline: `The step's command exited with status ${exit[1]}`,
|
||||
nextStep: output ? "Its output, under Details, usually says why." : "Open the step's logs for its output.",
|
||||
};
|
||||
}
|
||||
if (reason === FailureReason.TRANSIENT_INFRA || category === FailureCategory.TRANSIENT_INFRA) {
|
||||
return { headline: "The step hit a temporary infrastructure error", nextStep: "Choose Retry from Actions in a few minutes." };
|
||||
}
|
||||
return { headline: firstSentence(message), nextStep: "Open the failed step's logs for details." };
|
||||
}
|
||||
|
||||
/**
|
||||
* The key of the failed step the run stopped at: the latest failed step that
|
||||
* no later step went on from. A step that started later in a sibling branch
|
||||
* of the same fan-out ran beside it, not after it, so it doesn't count; an
|
||||
* earlier failure that a later step recovered from isn't named.
|
||||
*/
|
||||
function stoppedAt(stages: FailureState["stages"]): string | null {
|
||||
const started = Object.entries(stages).flatMap(([key, stage]) => {
|
||||
const at = stage.started_at ? Date.parse(stage.started_at) : NaN;
|
||||
if (Number.isNaN(at)) return [];
|
||||
const branch = stage.parallel_branch_id ?? null;
|
||||
const group = branch && branch.includes(":") ? branch.slice(0, branch.lastIndexOf(":")) : null;
|
||||
return [{ key, at, branch, group, failed: stage.state === "failed" }];
|
||||
});
|
||||
let stop: (typeof started)[number] | null = null;
|
||||
for (const step of started) {
|
||||
if (!step.failed) continue;
|
||||
const wentOn = started.some(
|
||||
(later) =>
|
||||
later.at > step.at &&
|
||||
later.group !== step.key &&
|
||||
!(step.group !== null && later.group === step.group && later.branch !== step.branch),
|
||||
);
|
||||
if (!wentOn && (!stop || step.at > stop.at)) stop = step;
|
||||
}
|
||||
return stop?.key ?? null;
|
||||
}
|
||||
|
||||
/** A stage key (`node@visit`) as the Stages sidebar labels it. */
|
||||
function stageLabel(key: string | null): string | null {
|
||||
if (!key) return null;
|
||||
const at = key.lastIndexOf("@");
|
||||
if (at <= 0) return key;
|
||||
return formatStageLabel({ name: key.slice(0, at), visit: Number.parseInt(key.slice(at + 1), 10) || 1 });
|
||||
}
|
||||
|
||||
function outputTail(tail: ExecOutputTail | null | undefined): string | null {
|
||||
return lastLines([tail?.stderr, tail?.stdout].find((text) => text?.trim()));
|
||||
}
|
||||
|
||||
function lastLines(stream: string | null | undefined): string | null {
|
||||
// An offloaded output is a `blob://` reference, not text to show.
|
||||
if (!stream?.trim() || stream.startsWith("blob://")) return null;
|
||||
// Carriage-return progress lines keep only what was drawn last.
|
||||
const lines = stream.trimEnd().split(/\r?\n/).map((line) => line.slice(line.lastIndexOf("\r") + 1));
|
||||
return lines.slice(-OUTPUT_LINES).join("\n");
|
||||
}
|
||||
|
||||
function firstSentence(message: string): string {
|
||||
const sentence = message.split(/(?<=[.!?])\s+/)[0]!.trim().replace(/\s+/g, " ");
|
||||
const chars = Array.from(sentence.charAt(0).toUpperCase() + sentence.slice(1));
|
||||
return chars.length > HEADLINE_CHARS ? `${chars.slice(0, HEADLINE_CHARS).join("").trimEnd()}…` : chars.join("");
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import { formatContextWindow, formatTokensPerSecond } from "../lib/format";
|
|||
import {
|
||||
modelOfferingKey,
|
||||
modelOfferingTestArgs,
|
||||
providerNamesById,
|
||||
} from "../lib/model-offerings";
|
||||
import { useDebouncedValue } from "../hooks/effects";
|
||||
|
||||
|
|
@ -244,11 +245,7 @@ function ModelsSection({ providers }: { providers: Provider[] }) {
|
|||
|
||||
const { data, isLoading } = useModels(providerFilter, debouncedSearch);
|
||||
|
||||
const providerNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const p of providers) map.set(p.id, p.display_name || p.id);
|
||||
return map;
|
||||
}, [providers]);
|
||||
const providerNameById = useMemo(() => providerNamesById(providers), [providers]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = (data?.data ?? []).filter((m) => m.configured);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue