Merge remote-tracking branch 'origin/main' into feat/infer-command-node-from-script

# Conflicts:
#	docs/public/workflows/stages-and-nodes.mdx
#	lib/foundation/fabro-types/src/graph.rs
This commit is contained in:
Bryan Helmkamp 2026-07-29 10:40:26 -04:00
commit cb24f47b59
No known key found for this signature in database
285 changed files with 18491 additions and 4649 deletions

View file

@ -11,10 +11,6 @@ leak-timeout = "500ms"
filter = "package(fabro-server)"
slow-timeout = { period = "5s", terminate-after = 4 }
[[profile.default.overrides]]
filter = "package(fabro-server) & test(all_spec_routes_are_routable)"
slow-timeout = { period = "15s", terminate-after = 4 }
[[profile.default.overrides]]
filter = "package(fabro-workflow)"
slow-timeout = { period = "2s", terminate-after = 3 }

View file

@ -6,6 +6,9 @@ GEMINI_API_KEY=
INCEPTION_API_KEY=
KIMI_API_KEY=
MINIMAX_API_KEY=
MODAL_KIMI_K3_BASE_URL=
MODAL_TOKEN_ID=
MODAL_TOKEN_SECRET=
OPENAI_API_KEY=
OPENROUTER_API_KEY=
POOLSIDE_API_KEY=

105
Cargo.lock generated
View file

@ -2239,7 +2239,7 @@ dependencies = [
[[package]]
name = "fabro-acp"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-tokio",
@ -2258,7 +2258,7 @@ dependencies = [
[[package]]
name = "fabro-agent"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2304,7 +2304,7 @@ dependencies = [
[[package]]
name = "fabro-api"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"chrono",
"fabro-automation",
@ -2327,7 +2327,7 @@ dependencies = [
[[package]]
name = "fabro-auth"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2352,7 +2352,7 @@ dependencies = [
[[package]]
name = "fabro-automation"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2371,11 +2371,11 @@ dependencies = [
[[package]]
name = "fabro-build-support"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
[[package]]
name = "fabro-checkpoint"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -2391,7 +2391,7 @@ dependencies = [
[[package]]
name = "fabro-cli"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2493,7 +2493,7 @@ dependencies = [
[[package]]
name = "fabro-client"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -2522,7 +2522,7 @@ dependencies = [
[[package]]
name = "fabro-config"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2552,7 +2552,7 @@ dependencies = [
[[package]]
name = "fabro-core"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"async-trait",
"fabro-types",
@ -2567,7 +2567,7 @@ dependencies = [
[[package]]
name = "fabro-db"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2579,7 +2579,7 @@ dependencies = [
[[package]]
name = "fabro-dev"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2598,7 +2598,7 @@ dependencies = [
[[package]]
name = "fabro-dump"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -2612,7 +2612,7 @@ dependencies = [
[[package]]
name = "fabro-environment"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2634,7 +2634,7 @@ dependencies = [
[[package]]
name = "fabro-github"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -2656,7 +2656,7 @@ dependencies = [
[[package]]
name = "fabro-graphviz"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -2671,7 +2671,7 @@ dependencies = [
[[package]]
name = "fabro-hooks"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"async-trait",
"fabro-agent",
@ -2694,7 +2694,7 @@ dependencies = [
[[package]]
name = "fabro-http"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"fabro-static",
"http 1.4.0",
@ -2704,7 +2704,7 @@ dependencies = [
[[package]]
name = "fabro-install"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -2723,7 +2723,7 @@ dependencies = [
[[package]]
name = "fabro-interview"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"async-trait",
"dialoguer",
@ -2738,7 +2738,7 @@ dependencies = [
[[package]]
name = "fabro-llm"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2779,7 +2779,7 @@ dependencies = [
[[package]]
name = "fabro-macros"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"clap",
"fabro-options-metadata",
@ -2790,7 +2790,7 @@ dependencies = [
[[package]]
name = "fabro-manifest"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"fabro-api",
@ -2808,7 +2808,7 @@ dependencies = [
[[package]]
name = "fabro-mcp"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2828,7 +2828,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-server"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2855,7 +2855,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-store"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"chrono",
"fabro-db",
@ -2873,7 +2873,7 @@ dependencies = [
[[package]]
name = "fabro-model"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"fabro-static",
"http 1.4.0",
@ -2889,7 +2889,7 @@ dependencies = [
[[package]]
name = "fabro-oauth"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2911,7 +2911,7 @@ dependencies = [
[[package]]
name = "fabro-options-metadata"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"serde",
"serde_json",
@ -2919,7 +2919,7 @@ dependencies = [
[[package]]
name = "fabro-proc"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"cc",
"libc",
@ -2928,7 +2928,7 @@ dependencies = [
[[package]]
name = "fabro-redact"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"aho-corasick",
"ref-cast",
@ -2944,7 +2944,7 @@ dependencies = [
[[package]]
name = "fabro-sandbox"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2988,7 +2988,7 @@ dependencies = [
[[package]]
name = "fabro-server"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3028,6 +3028,7 @@ dependencies = [
"fabro-spa",
"fabro-static",
"fabro-store",
"fabro-template",
"fabro-test",
"fabro-tool",
"fabro-types",
@ -3080,7 +3081,7 @@ dependencies = [
[[package]]
name = "fabro-slack"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-interview",
@ -3102,18 +3103,18 @@ dependencies = [
[[package]]
name = "fabro-spa"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"rust-embed",
]
[[package]]
name = "fabro-static"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
[[package]]
name = "fabro-store"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"async-trait",
"bytes",
@ -3143,7 +3144,7 @@ dependencies = [
[[package]]
name = "fabro-telemetry"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -3169,7 +3170,7 @@ dependencies = [
[[package]]
name = "fabro-template"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -3183,7 +3184,7 @@ dependencies = [
[[package]]
name = "fabro-test"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -3208,7 +3209,7 @@ dependencies = [
[[package]]
name = "fabro-tool"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3229,7 +3230,7 @@ dependencies = [
[[package]]
name = "fabro-tracker"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3243,7 +3244,7 @@ dependencies = [
[[package]]
name = "fabro-types"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"chrono",
"clap",
@ -3258,6 +3259,7 @@ dependencies = [
"shlex",
"strum 0.28.0",
"tempfile",
"thiserror 2.0.18",
"toml 0.8.23",
"ulid",
"url",
@ -3265,7 +3267,7 @@ dependencies = [
[[package]]
name = "fabro-util"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"console 0.15.11",
@ -3288,7 +3290,7 @@ dependencies = [
[[package]]
name = "fabro-validate"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"fabro-acp",
"fabro-graphviz",
@ -3301,7 +3303,7 @@ dependencies = [
[[package]]
name = "fabro-variable"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -3318,7 +3320,7 @@ dependencies = [
[[package]]
name = "fabro-vault"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -3337,7 +3339,7 @@ dependencies = [
[[package]]
name = "fabro-workflow"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -3393,6 +3395,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"shlex",
"strum 0.28.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
@ -8501,7 +8504,7 @@ dependencies = [
[[package]]
name = "twin-github"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"axum",
"base64",
@ -8520,7 +8523,7 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
dependencies = [
"anyhow",
"async-stream",

View file

@ -11,7 +11,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.305.0-nightly.3"
version = "0.309.0-nightly.0"
license = "MIT"
[workspace.dependencies]

View file

@ -4,9 +4,14 @@ import { SWRConfig } from "swr";
import {
type ApiQuestion,
QuestionType,
ReviewTargetKind,
} from "@qltysh/fabro-api-client";
import { InterviewDock } from "./interview-dock";
import {
contextPreview,
InterviewDock,
shouldStackOptions,
} from "./interview-dock";
import { displayLabel } from "./interview-label";
import { generatedAxios } from "../lib/api-client";
@ -74,6 +79,58 @@ describe("InterviewDock", () => {
expect(text).toContain("Awaiting input");
});
test("renders the review target as the link in the question", () => {
const url =
"https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef";
const tree = render(
<InterviewDock
runId="run-1"
questions={[
makeQuestion({
text: "Review the Quarry review exercise document, then choose the next action.",
review_target: {
label: "Quarry review exercise",
url,
kind: ReviewTargetKind.DOCUMENT,
},
}),
]}
/>,
);
expect(textContent(tree.toJSON())).toContain(
"Review the Quarry review exercise document, then choose the next action.",
);
const links = tree.root.findAllByType("a");
expect(links).toHaveLength(1);
expect(links[0].props.href).toBe(url);
expect(links[0].props.target).toBe("_blank");
expect(links[0].props.rel).toBe("noopener noreferrer");
expect(links[0].props.referrerPolicy).toBe("no-referrer");
});
test("does not link an unsafe review target received from the API", () => {
const fallback = "Review the document, then choose the next action.";
const tree = render(
<InterviewDock
runId="run-1"
questions={[
makeQuestion({
text: fallback,
review_target: {
label: "Unsafe target",
url: "javascript:alert(1)",
kind: ReviewTargetKind.DOCUMENT,
},
}),
]}
/>,
);
expect(textContent(tree.toJSON())).toContain(fallback);
expect(tree.root.findAllByType("a")).toHaveLength(0);
});
test("yes/no question shows two buttons", () => {
const tree = render(
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
@ -236,6 +293,112 @@ describe("InterviewDock", () => {
expect(text).toContain("Context from preceding stage");
expect(text).toContain("1. Deploy");
});
test("context starts closed so it costs one line, not a standing panel", () => {
const question = makeQuestion({
context_display: "Plan:\n1. Deploy\n2. Verify",
});
const tree = render(
<InterviewDock runId="run-1" questions={[question]} />,
);
const details = tree.root.findAllByType("details");
expect(details).toHaveLength(1);
expect(details[0]!.props.open).toBeFalsy();
});
test("omits the question type subtitle that the answer buttons already state", () => {
const question = makeQuestion({
question_type: QuestionType.MULTIPLE_CHOICE,
options: [{ key: "A", label: "[A] Approve" }],
});
const tree = render(
<InterviewDock runId="run-1" questions={[question]} />,
);
expect(textContent(tree.toJSON())).not.toContain("Pick one");
});
test("collapsing hides the answer controls but keeps the header", () => {
const tree = render(
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
);
const toggle = tree.root.findByProps({ "aria-label": "Collapse Interview question" });
act(() => {
toggle.props.onClick();
});
const expanded = tree.root.findByProps({
"aria-label": "Expand Interview question",
});
expect(expanded.props["aria-expanded"]).toBe(false);
expect(textContent(tree.toJSON())).toContain("Awaiting input");
});
test("a queued question arrives expanded even after the panel was collapsed", () => {
const questions = [
makeQuestion({ id: "q-1", stage: "stage-a" }),
makeQuestion({ id: "q-2", stage: "stage-b" }),
];
const tree = render(<InterviewDock runId="run-1" questions={questions} />);
act(() => {
tree.root
.findByProps({ "aria-label": "Collapse Interview question" })
.props.onClick();
});
act(() => {
buttonsByText(tree)["1 more pending"]!.props.onClick();
});
const toggle = tree.root.findByProps({
"aria-label": "Collapse Interview question",
});
expect(toggle.props["aria-expanded"]).toBe(true);
});
});
describe("shouldStackOptions", () => {
test("keeps short labels as a wrapping row", () => {
expect(
shouldStackOptions([
{ key: "a", label: "Approve" },
{ key: "b", label: "Revise" },
]),
).toBe(false);
});
test("stacks once a label is too long to sit in a pill", () => {
expect(
shouldStackOptions([
{ key: "a", label: "Approve" },
{ key: "b", label: "Review complete; sync the current Markdown document" },
]),
).toBe(true);
});
test("stacks when any option carries a description", () => {
expect(
shouldStackOptions([
{ key: "a", label: "Approve", description: "Deploy the current patch" },
]),
).toBe(true);
});
});
describe("contextPreview", () => {
test("uses the first non-empty line", () => {
expect(contextPreview("\n\nPlan is ready\nSecond line")).toBe("Plan is ready");
});
test("truncates a long first line", () => {
const preview = contextPreview("x".repeat(200));
expect(preview).toHaveLength(61);
expect(preview.endsWith("…")).toBe(true);
});
test("returns an empty string for blank context", () => {
expect(contextPreview(" \n ")).toBe("");
});
});
describe("displayLabel", () => {

View file

@ -1,14 +1,8 @@
import { useCallback, useState } from "react";
import {
useCallback,
useState,
type FormEvent,
type KeyboardEvent,
} from "react";
import {
ArrowPathIcon,
ArrowRightIcon,
ArrowUturnLeftIcon,
CheckIcon,
ChevronRightIcon,
} from "@heroicons/react/20/solid";
import { QuestionType } from "@qltysh/fabro-api-client";
import type {
@ -22,16 +16,31 @@ import {
} from "../lib/mutations";
import { ApiError } from "../lib/api-client";
import { displayLabel } from "./interview-label";
import { ErrorMessage } from "./ui";
import {
ReviewTargetQuestion,
safeReviewTarget,
} from "./review-target-question";
import {
DockComposer,
RunDockShell,
DOCK_CHOICE_BUTTON,
DOCK_CHOICE_BUTTON_SELECTED,
DOCK_HEADER_BUTTON,
} from "./run-dock";
import { Spinner } from "./state";
import {
ErrorMessage,
PRIMARY_BUTTON_CLASS,
} from "./ui";
const PRIMARY_BUTTON =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500 px-3.5 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500";
/**
* Options stack into a list once a label is long enough that a row of pills
* would wrap mid-sentence.
*/
const STACK_LABEL_LENGTH = 40;
const CHOICE_BUTTON =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-overlay px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60";
const CHOICE_BUTTON_SELECTED =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500/15 px-3.5 py-2 text-sm font-medium text-fg outline-1 -outline-offset-1 outline-teal-500/60 transition-colors hover:bg-teal-500/20 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
/** Shared by the plain question text and the review target rendering. */
const QUESTION_TEXT = "max-w-[78ch] text-base/6 font-medium text-pretty text-fg";
type SubmitInterviewAnswer = SubmitInterviewAnswerArg["answer"];
@ -51,6 +60,8 @@ export function InterviewDock({ runId, questions }: InterviewDockProps) {
const moreCount = questions.length - 1;
return (
// Keyed by question id, so a new question always arrives expanded with an
// empty composer. A collapsed panel can never silently block a run.
<InterviewQuestionDock
key={question.id}
runId={runId}
@ -76,112 +87,115 @@ function InterviewQuestionDock({
}) {
const submitMutation = useSubmitInterviewAnswer(runId);
const [error, setError] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false);
const submitting = submitMutation.isMutating;
const reviewTarget = safeReviewTarget(question.review_target);
const submit = useCallback(
async (answer: SubmitInterviewAnswer) => {
setError(null);
try {
await submitMutation.trigger({ questionId: question.id, answer });
return true;
} catch (caught) {
setError(interviewSubmitErrorMessage(caught));
return false;
}
},
[question.id, submitMutation],
);
return (
<section aria-label="Interview question">
<DockHeader
stage={question.stage}
moreCount={moreCount}
onCycle={onCycle}
/>
<div className="space-y-5 px-5 py-4 sm:px-6">
<div>
<p className="text-pretty text-base/6 font-medium text-fg">
{question.text}
</p>
<p className="mt-1 text-xs/5 text-fg-muted">
{questionTypeLabel(question.question_type)}
</p>
</div>
{question.context_display && (
<ContextPanel text={question.context_display} />
)}
<QuestionBody
question={question}
submitting={submitting}
onSubmit={submit}
/>
{error && <ErrorMessage message={error} />}
</div>
</section>
);
}
function DockHeader({
stage,
moreCount,
onCycle,
}: {
stage: string;
moreCount: number;
onCycle: () => void;
}) {
return (
<div className="flex items-center justify-between gap-3 border-b border-line px-5 py-2.5">
<div className="flex min-w-0 items-center gap-2 text-sm">
<PulseDot />
<span className="font-medium text-fg-2">Awaiting input</span>
{stage && (
<>
<span className="text-fg-muted" aria-hidden="true">
·
</span>
<span className="truncate font-mono text-xs text-fg-3">{stage}</span>
</>
)}
</div>
{moreCount > 0 && (
<button
type="button"
onClick={onCycle}
className="inline-flex shrink-0 items-center gap-1 rounded-md bg-overlay px-2 py-1 text-xs font-medium text-fg-2 outline-1 -outline-offset-1 outline-line-strong hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<span className="tabular-nums">{moreCount}</span> more pending
<ArrowRightIcon className="size-3" aria-hidden="true" />
</button>
)}
</div>
);
}
function PulseDot() {
return (
<span className="relative flex size-2 items-center justify-center" aria-hidden="true">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber/60" />
<span className="relative inline-flex size-2 rounded-full bg-amber" />
</span>
<RunDockShell
label="Interview question"
tone="waiting"
status="Awaiting input"
stage={question.stage}
peek={question.text}
collapsed={collapsed}
onCollapsedChange={setCollapsed}
headerActions={
moreCount > 0 && (
<button
type="button"
onClick={onCycle}
className={DOCK_HEADER_BUTTON}
>
<span className="tabular-nums">{moreCount}</span> more pending
<ArrowRightIcon className="size-3" aria-hidden="true" />
</button>
)
}
body={
<>
{reviewTarget ? (
<ReviewTargetQuestion
target={reviewTarget}
className={QUESTION_TEXT}
/>
) : (
<p className={QUESTION_TEXT}>{question.text}</p>
)}
{question.context_display && (
<ContextPanel text={question.context_display} />
)}
</>
}
actions={
<>
<QuestionBody
question={question}
submitting={submitting}
onSubmit={submit}
/>
{error && <ErrorMessage message={error} />}
</>
}
/>
);
}
/**
* Context arrives collapsed. It repeats material the operator has usually
* already read in the stage stream above, so it earns a line rather than a
* standing panel.
*/
function ContextPanel({ text }: { text: string }) {
return (
<div className="rounded-lg bg-panel-alt p-4 outline-1 -outline-offset-1 outline-line">
<p className="mb-1.5 font-mono text-[0.6875rem] tracking-wide text-fg-muted uppercase">
<details className="group rounded-lg bg-panel-alt outline-1 -outline-offset-1 outline-line">
<summary className="flex cursor-pointer list-none items-center gap-1.5 rounded-lg px-3 py-1.5 font-mono text-[0.6875rem] tracking-wide text-fg-muted uppercase transition-colors hover:text-fg-3 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 [&::-webkit-details-marker]:hidden">
<ChevronRightIcon
className="size-3 shrink-0 transition-transform group-open:rotate-90"
aria-hidden="true"
/>
Context from preceding stage
</p>
<div className="max-h-40 overflow-y-auto text-sm/6 text-fg-2">
<span className="ml-auto truncate pl-3 font-sans text-xs tracking-normal normal-case group-open:hidden">
{contextPreview(text)}
</span>
</summary>
<div className="px-3 pb-2.5 text-sm/6 text-fg-2">
<pre className="font-sans whitespace-pre-wrap">{text}</pre>
</div>
</div>
</details>
);
}
/** First line of the context, for the collapsed summary. */
export function contextPreview(text: string): string {
let lineStart = 0;
while (lineStart < text.length) {
const newline = text.indexOf("\n", lineStart);
const lineEnd = newline === -1 ? text.length : newline;
const line = text.slice(lineStart, lineEnd).trim();
if (line) {
return line.length > 60 ? `${line.slice(0, 60).trimEnd()}…` : line;
}
if (newline === -1) break;
lineStart = newline + 1;
}
return "";
}
function QuestionBody({
question,
submitting,
@ -189,7 +203,7 @@ function QuestionBody({
}: {
question: ApiQuestion;
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
}) {
switch (question.question_type) {
case QuestionType.YES_NO:
@ -215,11 +229,10 @@ function QuestionBody({
);
case QuestionType.FREEFORM:
return (
<FreeformBody
<FreeformAnswer
submitting={submitting}
onSubmit={onSubmit}
placeholder="Write your response…"
submitLabel="Send"
/>
);
default:
@ -232,7 +245,7 @@ function YesNoBody({
onSubmit,
}: {
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
@ -242,7 +255,7 @@ function YesNoBody({
aria-label="Answer no"
disabled={submitting}
onClick={() => void onSubmit({ kind: "no" })}
className={CHOICE_BUTTON}
className={DOCK_CHOICE_BUTTON}
>
No
</button>
@ -251,9 +264,13 @@ function YesNoBody({
aria-label="Answer yes"
disabled={submitting}
onClick={() => void onSubmit({ kind: "yes" })}
className={PRIMARY_BUTTON}
className={PRIMARY_BUTTON_CLASS}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
{submitting ? (
<Spinner className="size-4" />
) : (
<CheckIcon className="size-4" aria-hidden="true" />
)}
Yes
</button>
</div>
@ -265,7 +282,7 @@ function ConfirmationBody({
onSubmit,
}: {
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
@ -273,15 +290,35 @@ function ConfirmationBody({
type="button"
disabled={submitting}
onClick={() => void onSubmit({ kind: "yes" })}
className={PRIMARY_BUTTON}
className={PRIMARY_BUTTON_CLASS}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
{submitting ? (
<Spinner className="size-4" />
) : (
<CheckIcon className="size-4" aria-hidden="true" />
)}
Confirm
</button>
</div>
);
}
/**
* Long labels wrap badly as pills, so they become a stacked list instead.
*/
export function shouldStackOptions(options: InterviewOption[]): boolean {
return options.some(
(option) =>
option.label.length > STACK_LABEL_LENGTH || Boolean(option.description),
);
}
function optionListClass(stacked: boolean): string {
return stacked
? "flex flex-col items-stretch gap-2"
: "flex flex-wrap items-center gap-2";
}
function ChoiceBody({
options,
allowFreeform,
@ -291,19 +328,23 @@ function ChoiceBody({
options: InterviewOption[];
allowFreeform: boolean;
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
}) {
const stacked = shouldStackOptions(options);
return (
<div className="space-y-4">
<div className="space-y-2.5">
{options.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<div className={optionListClass(stacked)}>
{options.map((option) => (
<button
key={option.key}
type="button"
disabled={submitting}
onClick={() => void onSubmit({ kind: "selected", option_key: option.key })}
className={CHOICE_BUTTON}
className={
stacked ? `${DOCK_CHOICE_BUTTON} justify-start` : DOCK_CHOICE_BUTTON
}
>
<OptionLabel option={option} />
</button>
@ -311,7 +352,7 @@ function ChoiceBody({
</div>
)}
{allowFreeform && (
<FreeformBody
<FreeformAnswer
submitting={submitting}
onSubmit={onSubmit}
placeholder={
@ -319,8 +360,6 @@ function ChoiceBody({
? "Or write a custom response…"
: "Write your response…"
}
submitLabel="Send"
divider={options.length > 0}
/>
)}
</div>
@ -334,7 +373,7 @@ function MultiSelectBody({
}: {
options: InterviewOption[];
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
@ -352,11 +391,14 @@ function MultiSelectBody({
if (selected.has(option.key)) selectedKeys.push(option.key);
}
const stacked = shouldStackOptions(options);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="space-y-2.5">
<div className={optionListClass(stacked)}>
{options.map((option) => {
const isSelected = selected.has(option.key);
const base = isSelected ? DOCK_CHOICE_BUTTON_SELECTED : DOCK_CHOICE_BUTTON;
return (
<button
key={option.key}
@ -364,7 +406,7 @@ function MultiSelectBody({
disabled={submitting}
aria-pressed={isSelected}
onClick={() => toggle(option.key)}
className={isSelected ? CHOICE_BUTTON_SELECTED : CHOICE_BUTTON}
className={stacked ? `${base} justify-start` : base}
>
{isSelected && <CheckIcon className="size-3.5" aria-hidden="true" />}
<OptionLabel option={option} />
@ -380,9 +422,13 @@ function MultiSelectBody({
type="button"
disabled={submitting || selectedKeys.length === 0}
onClick={() => void onSubmit({ kind: "multi_selected", option_keys: selectedKeys })}
className={PRIMARY_BUTTON}
className={PRIMARY_BUTTON_CLASS}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
{submitting ? (
<Spinner className="size-4" />
) : (
<CheckIcon className="size-4" aria-hidden="true" />
)}
Submit selection
</button>
</div>
@ -390,82 +436,23 @@ function MultiSelectBody({
);
}
function FreeformBody({
function FreeformAnswer({
submitting,
onSubmit,
placeholder,
submitLabel,
divider = false,
}: {
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
placeholder: string;
submitLabel: string;
divider?: boolean;
}) {
const [value, setValue] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed || submitting) return;
await onSubmit({ kind: "text", text: trimmed });
setValue("");
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
const form = event.currentTarget.form;
if (form) form.requestSubmit();
}
}
const disabled = submitting || value.trim().length === 0;
return (
<form onSubmit={handleSubmit} className="space-y-2">
{divider && (
<div className="flex items-center gap-3" aria-hidden="true">
<span className="h-px flex-1 bg-line" />
<span className="text-xs text-fg-muted">or</span>
<span className="h-px flex-1 bg-line" />
</div>
)}
<div className="flex items-end gap-2">
<label className="sr-only" htmlFor="interview-freeform-answer">
Your response
</label>
<textarea
id="interview-freeform-answer"
name="answer"
aria-label="Interview answer"
rows={1}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={submitting}
className="block w-full resize-none rounded-lg bg-panel-alt px-3.5 py-2.5 text-base/6 text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 disabled:opacity-60 sm:text-sm/5"
/>
<button type="submit" disabled={disabled} className={PRIMARY_BUTTON}>
{submitting ? (
<Spinner />
) : (
<ArrowUturnLeftIcon
className="size-3.5 -scale-x-100"
aria-hidden="true"
/>
)}
{submitLabel}
</button>
</div>
<p className="text-xs text-fg-muted">
Press <kbd className="rounded bg-overlay px-1 font-mono text-[0.6875rem]">Enter</kbd> to
send · <kbd className="rounded bg-overlay px-1 font-mono text-[0.6875rem]">Shift</kbd>+
<kbd className="rounded bg-overlay px-1 font-mono text-[0.6875rem]">Enter</kbd> for a new line
</p>
</form>
<DockComposer
onSubmit={(text) => onSubmit({ kind: "text", text })}
placeholder={placeholder}
submitLabel="Send"
submitting={submitting}
ariaLabel="Interview answer"
/>
);
}
@ -482,27 +469,6 @@ function OptionLabel({ option }: { option: InterviewOption }) {
);
}
function Spinner() {
return <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />;
}
function questionTypeLabel(type: QuestionType): string {
switch (type) {
case QuestionType.YES_NO:
return "Yes or no";
case QuestionType.CONFIRMATION:
return "Confirmation required";
case QuestionType.MULTIPLE_CHOICE:
return "Pick one";
case QuestionType.MULTI_SELECT:
return "Pick one or more";
case QuestionType.FREEFORM:
return "Freeform response";
default:
return "";
}
}
function interviewSubmitErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.requestId

View file

@ -0,0 +1,59 @@
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
import type { ReviewTarget } from "@qltysh/fabro-api-client";
/**
* Re-check the URL before putting it in an `href`. The server already rejects
* unsafe targets (see `ReviewTarget::new` in
* `lib/foundation/fabro-types/src/interview.rs`), but React does not sanitize
* `href`, so a `javascript:` URL reaching this component would execute. Length
* and control-character limits stay server-side; they cannot affect the DOM.
*/
export function safeReviewTarget(
target: ReviewTarget | null | undefined,
): ReviewTarget | null {
if (!target?.url || !target.label) return null;
try {
const parsed = new URL(target.url);
const safe =
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
Boolean(parsed.host) &&
!parsed.username &&
!parsed.password;
return safe ? target : null;
} catch {
return null;
}
}
/**
* The review question sentence, with the target label as an external link.
* Mirrors `ReviewTarget::question_text_with_link` in
* `lib/foundation/fabro-types/src/interview.rs`.
*/
export function ReviewTargetQuestion({
target,
className,
}: {
target: ReviewTarget;
className?: string;
}) {
return (
<p className={className}>
Review the{" "}
<a
href={target.url}
target="_blank"
rel="noopener noreferrer"
referrerPolicy="no-referrer"
className="inline-flex items-baseline gap-1 font-semibold text-teal-300 underline decoration-teal-500/50 underline-offset-2 transition-colors hover:text-fg focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<span>{target.label}</span>
<ArrowTopRightOnSquareIcon
className="size-3 shrink-0 self-center"
aria-hidden="true"
/>
</a>{" "}
{target.kind}, then choose the next action.
</p>
);
}

View file

@ -0,0 +1,118 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { setupReactTestEnv } from "../lib/test-utils";
import { DockComposer } from "./run-dock";
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
let teardownReactEnv: (() => void) | undefined;
beforeEach(() => {
teardownReactEnv = setupReactTestEnv();
});
afterEach(() => {
for (const renderer of mountedRenderers.splice(0)) {
act(() => renderer.unmount());
}
teardownReactEnv?.();
teardownReactEnv = undefined;
});
describe("DockComposer", () => {
test("describes its keyboard behavior to assistive technology", () => {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<DockComposer
onSubmit={() => Promise.resolve(true)}
placeholder="Write a message"
submitLabel="Send"
submitting={false}
ariaLabel="Message"
/>,
);
});
mountedRenderers.push(renderer);
const textarea = renderer.root.findByType("textarea");
const instruction = renderer.root.findByProps({
id: textarea.props["aria-describedby"],
});
expect(instruction.children.join("")).toBe(
"Press Enter to send. Press Shift+Enter for a new line.",
);
expect(textarea.props.name).toBeUndefined();
});
test("does not submit Enter while an IME composition is active", async () => {
const onSubmit = mock(() => Promise.resolve(true));
const preventDefault = mock(() => undefined);
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<DockComposer
onSubmit={onSubmit}
placeholder="Write a message"
submitLabel="Send"
submitting={false}
ariaLabel="Message"
/>,
);
});
mountedRenderers.push(renderer);
const textarea = renderer.root.findByType("textarea");
act(() => textarea.props.onChange({ target: { value: "draft" } }));
await act(async () => {
textarea.props.onKeyDown({
key: "Enter",
shiftKey: false,
nativeEvent: { isComposing: true },
preventDefault,
});
});
expect(preventDefault).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
test("submits Enter after composition ends", async () => {
const onSubmit = mock(() => Promise.resolve(true));
const preventDefault = mock(() => undefined);
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<DockComposer
onSubmit={onSubmit}
placeholder="Write a message"
submitLabel="Send"
submitting={false}
ariaLabel="Message"
/>,
);
});
mountedRenderers.push(renderer);
const textarea = renderer.root.findByType("textarea");
act(() => textarea.props.onChange({ target: { value: " ready " } }));
await act(async () => {
textarea.props.onKeyDown({
key: "Enter",
shiftKey: false,
nativeEvent: { isComposing: false },
preventDefault,
});
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith("ready");
});
});

View file

@ -0,0 +1,317 @@
import {
useId,
useState,
type FormEvent,
type KeyboardEvent,
type ReactNode,
type Ref,
} from "react";
import {
ArrowUturnLeftIcon,
ChevronUpIcon,
} from "@heroicons/react/20/solid";
import { classNames } from "../lib/class-names";
import { Spinner } from "./state";
import {
INPUT_CLASS,
PRIMARY_BUTTON_CLASS,
} from "./ui";
/**
* Shared chrome for the two controls docked at the bottom of the run detail
* route: the interview question panel and the steering composer.
*
* The shell is three zones. The header is always visible and doubles as the
* collapsed bar. The body scrolls. The actions stay pinned, so the controls
* needed to answer or send never scroll out of reach.
*
* Collapsed state is owned by the caller. Each dock has its own rule for when
* a collapsed panel must reopen — a new question for the interview, a run
* waiting for steering for the composer — and those rules are clearer next to
* the state they depend on.
*/
/** Ceiling on the expanded dock, so a long body cannot take the page. */
const DOCK_MAX_HEIGHT = "max-h-[60vh]";
export const DOCK_HEADER_BUTTON =
"inline-flex shrink-0 items-center gap-1.5 rounded-md bg-overlay px-2 py-1 text-xs font-medium text-fg-2 outline-1 -outline-offset-1 outline-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-overlay disabled:hover:text-fg-2";
export const DOCK_CHOICE_BUTTON =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-overlay px-3.5 py-2 text-left text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60";
export const DOCK_CHOICE_BUTTON_SELECTED =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500/15 px-3.5 py-2 text-left text-sm font-medium text-fg outline-1 -outline-offset-1 outline-teal-500/60 transition-colors hover:bg-teal-500/20 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
/**
* How the dock signals its state.
*
* - `waiting` pulses amber: the run is blocked on the operator, as expected.
* - `alert` pulses amber and colors the label too, for a run that went off
* its normal path and is stuck until someone acts.
* - `idle` is a resting control with no pending demand.
*/
export type DockTone = "waiting" | "alert" | "idle";
export interface RunDockShellProps {
/** Accessible name for the docked region. */
label: string;
className?: string;
tone: DockTone;
/** Short state phrase, e.g. "Awaiting input". */
status: string;
/** Stage name shown in mono beside the status. */
stage?: string | null;
/** One-line summary shown only while collapsed. */
peek?: string | null;
/** Run-level controls, rendered beside the collapse toggle. */
headerActions?: ReactNode;
/** Scrolling zone. Omit when there is nothing to scroll. */
body?: ReactNode;
/** Pinned zone. */
actions: ReactNode;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
}
export function RunDockShell({
label,
className,
tone,
status,
stage,
peek,
headerActions,
body,
actions,
collapsed,
onCollapsedChange,
}: RunDockShellProps) {
const contentId = useId();
return (
<section
aria-label={label}
className={classNames(
"flex flex-col",
!collapsed && DOCK_MAX_HEIGHT,
className,
)}
>
<div className="flex shrink-0 items-center gap-2.5 px-5 py-2 sm:px-6">
<StatusDot tone={tone} />
{/* A live region: the dock changing to a state that needs the
operator has to reach assistive tech, not only the eye. */}
<span
role="status"
className={`shrink-0 text-sm font-medium ${
tone === "alert" ? "text-amber" : "text-fg-2"
}`}
>
{status}
</span>
{stage && (
<>
<span className="shrink-0 text-fg-muted" aria-hidden="true">
·
</span>
<span className="min-w-0 truncate font-mono text-xs text-fg-3">
{stage}
</span>
</>
)}
{collapsed && peek ? (
<span className="min-w-0 flex-1 truncate text-sm text-fg-3">
· {peek}
</span>
) : (
<span className="flex-1" />
)}
{headerActions}
<button
type="button"
onClick={() => onCollapsedChange(!collapsed)}
aria-expanded={!collapsed}
aria-controls={contentId}
aria-label={collapsed ? `Expand ${label}` : `Collapse ${label}`}
className="inline-flex size-6.5 shrink-0 items-center justify-center rounded-md text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<ChevronUpIcon
className={`size-4 transition-transform duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${
collapsed ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
</button>
</div>
{/* Hidden rather than unmounted, so a half-written message survives a
collapse. The display utility is swapped rather than layered, so two
display classes cannot collide in the cascade. */}
<div
id={contentId}
className={collapsed ? "hidden" : "flex min-h-0 flex-1 flex-col"}
>
{body && (
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto border-t border-line px-5 pt-3.5 pb-1 sm:px-6">
{body}
</div>
)}
<div
className={classNames(
"flex max-h-[50%] shrink-0 flex-col gap-2.5 overflow-y-auto px-5 pt-2.5 pb-3.5 sm:px-6",
!body && "border-t border-line",
)}
>
{actions}
</div>
</div>
</section>
);
}
function StatusDot({ tone }: { tone: DockTone }) {
if (tone === "idle") {
return (
<span
className="size-2 shrink-0 rounded-full bg-fg-muted"
aria-hidden="true"
/>
);
}
return (
<span
className="relative flex size-2 shrink-0 items-center justify-center"
aria-hidden="true"
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber/60" />
<span className="relative inline-flex size-2 rounded-full bg-amber" />
</span>
);
}
export interface DockComposerProps {
/**
* Sends the trimmed text. Resolve `true` to clear the box; resolve `false`
* to keep what the operator typed, so a failed send is not lost.
*/
onSubmit: (text: string) => Promise<boolean>;
placeholder: string;
submitLabel: string;
pendingLabel?: string;
submitting: boolean;
disabled?: boolean;
ariaLabel: string;
className?: string;
maxLength?: number;
textareaRef?: Ref<HTMLTextAreaElement>;
}
/**
* The single composer used by both docks: the interview's freeform answer and
* the steering message. Enter sends, Shift+Enter breaks the line, and the
* hint for that only appears on focus — inside the row, so revealing it does
* not shift the layout.
*/
export function DockComposer({
onSubmit,
placeholder,
submitLabel,
pendingLabel,
submitting,
disabled = false,
ariaLabel,
className,
maxLength,
textareaRef,
}: DockComposerProps) {
const [value, setValue] = useState("");
const fieldId = useId();
const instructionId = `${fieldId}-instruction`;
const trimmed = value.trim();
const composerDisabled = disabled || submitting;
const canSend = trimmed.length > 0 && !composerDisabled;
async function send() {
if (!canSend) return;
const cleared = await onSubmit(trimmed);
if (cleared) setValue("");
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void send();
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void send();
}
}
return (
<form
onSubmit={handleSubmit}
className={classNames("group flex items-end gap-2", className)}
>
<label className="sr-only" htmlFor={fieldId}>
{ariaLabel}
</label>
<p id={instructionId} className="sr-only">
Press Enter to send. Press Shift+Enter for a new line.
</p>
<textarea
id={fieldId}
ref={textareaRef}
aria-label={ariaLabel}
aria-describedby={instructionId}
rows={1}
value={value}
maxLength={maxLength}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={composerDisabled}
className={`${INPUT_CLASS} min-w-0 flex-1 resize-none disabled:opacity-60`}
/>
<p
aria-hidden="true"
className="pointer-events-none hidden shrink-0 items-center gap-1 pb-2.5 text-xs whitespace-nowrap text-fg-muted opacity-0 transition-opacity group-focus-within:opacity-100 md:flex"
>
<kbd className="rounded bg-overlay px-1 font-mono text-[0.6875rem]">
Enter
</kbd>
send
<kbd className="rounded bg-overlay px-1 font-mono text-[0.6875rem]">
Shift
</kbd>
+
<kbd className="rounded bg-overlay px-1 font-mono text-[0.6875rem]">
Enter
</kbd>
newline
</p>
<button
type="submit"
disabled={!canSend}
className={PRIMARY_BUTTON_CLASS}
>
{submitting ? (
<Spinner className="size-4" />
) : (
<ArrowUturnLeftIcon
className="size-3.5 -scale-x-100"
aria-hidden="true"
/>
)}
{submitting && pendingLabel ? pendingLabel : submitLabel}
</button>
</form>
);
}

View file

@ -115,8 +115,10 @@ export function RunTableRow({
</td>
)}
{show("size") && (
<td className="whitespace-nowrap px-3 py-2.5 text-center">
{run.size != null && <SizeChip size={run.size} />}
<td className="relative z-10 px-3 py-2.5 text-center whitespace-nowrap">
{run.size != null && (
<SizeChip size={run.size} totalUsdMicros={run.totalUsdMicros} />
)}
</td>
)}
{show("changes") && (

View file

@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { setupReactTestEnv } from "../lib/test-utils";
import { SizeChip } from "./size-chip";
import { Tooltip } from "./ui";
let teardownReactTestEnv: (() => void) | undefined;
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
function render(element: React.ReactElement): TestRenderer.ReactTestRenderer {
let renderer: TestRenderer.ReactTestRenderer | undefined;
act(() => {
renderer = TestRenderer.create(element);
});
mountedRenderers.push(renderer!);
return renderer!;
}
function tooltipLabel(element: React.ReactElement): string {
return render(element).root.findByType(Tooltip).props.label as string;
}
describe("SizeChip", () => {
beforeEach(() => {
teardownReactTestEnv = setupReactTestEnv();
});
afterEach(() => {
act(() => {
for (const renderer of mountedRenderers.splice(0)) {
renderer.unmount();
}
});
teardownReactTestEnv?.();
teardownReactTestEnv = undefined;
});
test("renders the size letter", () => {
expect(JSON.stringify(render(<SizeChip size="M" />).toJSON())).toContain("M");
});
test("appends the cost to the tooltip", () => {
expect(tooltipLabel(<SizeChip size="M" totalUsdMicros={12_340_000} />))
.toBe("Size M · $12.34");
});
test("omits the cost when the run has no billing yet", () => {
expect(tooltipLabel(<SizeChip size="M" />)).toBe("Size M");
expect(tooltipLabel(<SizeChip size="M" totalUsdMicros={null} />)).toBe("Size M");
});
test("calls out the tiers that warrant attention", () => {
expect(tooltipLabel(<SizeChip size="L" totalUsdMicros={150_000_000} />))
.toBe("Size L (risky) · $150.00");
expect(tooltipLabel(<SizeChip size="XL" />)).toBe("Size XL (unhealthy)");
});
});

View file

@ -1,3 +1,4 @@
import { memo } from "react";
import type { RunSize } from "@qltysh/fabro-api-client";
import { formatUsdMicros } from "../lib/format";
@ -11,7 +12,7 @@ const SIZE_TONE: Record<RunSize, { className: string; note: string | null }> = {
XL: { className: "bg-coral/15 text-coral", note: "unhealthy" },
};
export function SizeChip({
export const SizeChip = memo(function SizeChip({
size,
totalUsdMicros,
}: {
@ -19,10 +20,10 @@ export function SizeChip({
totalUsdMicros?: number | null;
}) {
const tone = SIZE_TONE[size];
const billed = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)} billed` : "";
const amount = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)}` : "";
const tooltip = tone.note != null
? `Size ${size} (${tone.note})${billed}`
: `Size ${size}${billed}`;
? `Size ${size} (${tone.note})${amount}`
: `Size ${size}${amount}`;
return (
<Tooltip label={tooltip}>
<span className={`rounded px-1.5 py-0.5 font-mono text-xs font-bold tabular-nums ${tone.className}`}>
@ -30,4 +31,4 @@ export function SizeChip({
</span>
</Tooltip>
);
}
});

View file

@ -9,6 +9,7 @@ import { StagePopover } from "./stage-popover";
import { deriveStageSummary } from "./stage-popover-summary";
import type { Stage } from "../lib/stage-sidebar";
import { generatedAxios } from "../lib/api-client";
import { makeStage as baseMakeStage } from "../lib/test-utils";
function makeEvent(overrides: Partial<EventEnvelope>): EventEnvelope {
return {
@ -22,20 +23,13 @@ function makeEvent(overrides: Partial<EventEnvelope>): EventEnvelope {
}
function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
status: "succeeded",
duration: "1m 30s",
startedAt: "2026-05-24T11:58:30Z",
providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" },
return baseMakeStage({
status: "succeeded",
duration: "1m 30s",
startedAt: "2026-05-24T11:58:30Z",
providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" },
...overrides,
};
});
}
describe("deriveStageSummary", () => {

View file

@ -3,6 +3,7 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils";
import { makeBilledTokenCounts } from "../../lib/test-fixtures";
import type { Stage } from "../stage-sidebar";
import { FanInResults } from "./fan-in-results";
@ -22,6 +23,7 @@ const fanInStage: Stage = {
visit: 1,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
billing: makeBilledTokenCounts(),
};
function event(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {

View file

@ -98,6 +98,33 @@ describe("parseHumanInterviewPairs", () => {
});
});
test("preserves a typed review target from started events", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "interview.started",
properties: {
question_id: "q-1",
question:
"Review the Quarry review exercise document, then choose the next action.",
question_type: "multiple_choice",
review_target: {
label: "Quarry review exercise",
url: "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef",
kind: "document",
},
},
}),
];
const pairs = parseHumanInterviewPairs(events);
expect(pairs[0].question.reviewTarget).toEqual({
label: "Quarry review exercise",
url: "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef",
kind: "document",
});
});
test("captures timeout and interrupted resolutions", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
@ -172,14 +199,48 @@ describe("parseParallelOverview", () => {
failureCount: 1,
durationMs: 12000,
results: [
{ id: "branch-a", status: "succeeded" },
{ id: "branch-b", status: "succeeded" },
{ id: "branch-c", status: "failed" },
{ id: "branch-a", index: null, itemLabel: null, status: "succeeded" },
{ id: "branch-b", index: null, itemLabel: null, status: "succeeded" },
{ id: "branch-c", index: null, itemLabel: null, status: "failed" },
],
isComplete: true,
});
});
test("parses dynamic item identity from results", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "parallel.completed",
properties: {
duration_ms: 20,
success_count: 2,
failure_count: 0,
results: [
{
id: "reviewer",
index: 0,
item_label: "auth",
status: "succeeded",
context_updates: {},
},
{
id: "reviewer",
index: 1,
item_label: "api",
status: "succeeded",
context_updates: {},
},
],
},
}),
];
expect(parseParallelOverview(events).results).toEqual([
{ id: "reviewer", index: 0, itemLabel: "auth", status: "succeeded" },
{ id: "reviewer", index: 1, itemLabel: "api", status: "succeeded" },
]);
});
test("reports in-flight when only the started event is present", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {

View file

@ -1,7 +1,14 @@
import { StageOutcome } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { ReviewTargetKind, StageOutcome } from "@qltysh/fabro-api-client";
import type { EventEnvelope, ReviewTarget } from "@qltysh/fabro-api-client";
import { getArray, getNumber, getObject, getString, type UnknownRecord } from "../../lib/unknown";
import {
getArray,
getNumber,
getObject,
getString,
isRecord,
type UnknownRecord,
} from "../../lib/unknown";
const STAGE_OUTCOMES: ReadonlySet<string> = new Set(Object.values(StageOutcome));
@ -25,6 +32,7 @@ export interface HumanQuestion {
allowFreeform: boolean;
timeoutSeconds: number | null;
contextDisplay: string | null;
reviewTarget: ReviewTarget | null;
}
export type HumanResolution =
@ -75,6 +83,15 @@ function parseInterviewOptions(value: unknown): InterviewOption[] {
return out;
}
function parseReviewTarget(value: unknown): ReviewTarget | null {
if (!isRecord(value)) return null;
const label = getString(value, "label");
const url = getString(value, "url");
const kind = getString(value, "kind");
if (!label || !url || kind !== ReviewTargetKind.DOCUMENT) return null;
return { label, url, kind };
}
/**
* Pair `interview.started` events with the matching `interview.completed`,
* `.timeout`, or `.interrupted` resolution by `question_id`. Unanswered
@ -98,6 +115,7 @@ export function parseHumanInterviewPairs(events: EventEnvelope[]): HumanIntervie
allowFreeform: props.allow_freeform === true,
timeoutSeconds: getNumber(props, "timeout_seconds") ?? null,
contextDisplay: getString(props, "context_display") ?? null,
reviewTarget: parseReviewTarget(props.review_target),
},
resolution: null,
});
@ -147,6 +165,8 @@ export function parseHumanInterviewPairs(events: EventEnvelope[]): HumanIntervie
/** Identity and outcome of one branch, parsed from `parallel.completed`. */
export interface ParallelBranchSummary {
id: string;
index: number | null;
itemLabel: string | null;
status: StageOutcome;
}
@ -187,9 +207,11 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview
const record = entry && typeof entry === "object" ? (entry as UnknownRecord) : null;
if (!record) return null;
const id = getString(record, "id");
const index = getNumber(record, "index") ?? null;
const itemLabel = getString(record, "item_label") ?? null;
const status = asStageOutcome(getString(record, "status"));
if (!id || !status) return null;
return { id, status } satisfies ParallelBranchSummary;
return { id, index, itemLabel, status } satisfies ParallelBranchSummary;
})
.filter((r): r is ParallelBranchSummary => r != null);
if (branchCount == null) branchCount = results.length;

View file

@ -10,6 +10,10 @@ import {
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import {
ReviewTargetQuestion,
safeReviewTarget,
} from "../review-target-question";
import { Tooltip } from "../ui";
import { formatAbsoluteTs, formatDurationMs } from "../../lib/format";
import { ACTIVE_STAGE_STATES } from "../../lib/stage-sidebar";
@ -148,6 +152,7 @@ function QuestionBlock({
stageActive: boolean;
}) {
const { question, resolution } = pair;
const reviewTarget = safeReviewTarget(question.reviewTarget);
return (
<article className="space-y-3">
<header className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
@ -168,7 +173,14 @@ function QuestionBlock({
</header>
<div className="rounded-lg bg-panel p-4 outline-1 -outline-offset-1 outline-line">
<Markdown content={question.question} />
{reviewTarget ? (
<ReviewTargetQuestion
target={reviewTarget}
className="text-sm/6 text-fg-2"
/>
) : (
<Markdown content={question.question} />
)}
{question.contextDisplay && (
<div className="mt-3 border-t border-line pt-3 text-xs text-fg-muted">
<Markdown content={question.contextDisplay} />

View file

@ -1,9 +1,15 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { StageOutcome, StageState } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils";
import {
makeEventEnvelope,
makeStage as baseMakeStage,
setupReactTestEnv,
textContent,
} from "../../lib/test-utils";
import type { Stage } from "../stage-sidebar";
import { ParallelChildren } from "./parallel-children";
@ -13,35 +19,88 @@ beforeEach(() => {
});
afterEach(() => teardown());
const parallelStage: Stage = {
function makeStage(overrides: Partial<Stage> = {}): Stage {
return baseMakeStage({
id: "stage@1",
name: "stage",
nodeId: "stage",
graphVisit: 1,
startedAt: "2026-04-09T12:00:00Z",
...overrides,
});
}
const parallelStage = makeStage({
id: "fork@1",
name: "fork",
handler: "parallel",
status: "succeeded",
status: StageState.RUNNING,
duration: "12s",
nodeId: "fork",
visit: 1,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
};
});
function event(partial: Partial<EventEnvelope>): EventEnvelope {
return makeEventEnvelope(partial.seq ?? 1, { event: "parallel.completed", ...partial });
function branchStage(
name: string,
index: number,
status: StageState,
groupId = "fork@1",
visit = 1,
): Stage {
return makeStage({
id: `${name}@${visit}`,
name,
nodeId: name,
visit,
status,
parallelGroupId: groupId,
parallelBranchIndex: index,
});
}
function renderParallel(events: EventEnvelope[]): TestRenderer.ReactTestRenderer {
function event(partial: Partial<EventEnvelope>): EventEnvelope {
return makeEventEnvelope(partial.seq ?? 1, {
event: "parallel.completed",
stage_id: "fork@1",
...partial,
});
}
function startedEvent(branchCount: number): EventEnvelope {
return event({
event: "parallel.started",
properties: { branch_count: branchCount },
});
}
function completedEvent(results: Array<{ id: string; status: StageOutcome }>): EventEnvelope {
const countOf = (status: StageOutcome) =>
results.filter((result) => result.status === status).length;
return event({
seq: 2,
event: "parallel.completed",
properties: {
duration_ms: 12000,
success_count: countOf(StageOutcome.SUCCEEDED),
failure_count: countOf(StageOutcome.FAILED),
results: results.map((result) => ({ ...result, context_updates: {} })),
},
});
}
function renderParallel(
events: EventEnvelope[],
allStages: Stage[],
stage = parallelStage,
): TestRenderer.ReactTestRenderer {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<MemoryRouter>
<ParallelChildren
stage={parallelStage}
stage={stage}
events={events}
runId="run-1"
allStages={[
{ ...parallelStage, id: "branch-a@1", name: "branch-a", nodeId: "branch-a", handler: "agent" },
{ ...parallelStage, id: "branch-b@1", name: "branch-b", nodeId: "branch-b", handler: "agent", status: "failed" },
]}
allStages={allStages}
/>
</MemoryRouter>,
);
@ -49,37 +108,209 @@ function renderParallel(events: EventEnvelope[]): TestRenderer.ReactTestRenderer
return renderer;
}
function branchRowText(renderer: TestRenderer.ReactTestRenderer): string[] {
return renderer.root.findAllByType("li").map(textContent);
}
function hrefs(renderer: TestRenderer.ReactTestRenderer): string[] {
return renderer.root.findAllByType("a").map((link) => link.props.href);
}
function statValue(renderer: TestRenderer.ReactTestRenderer, label: string): string {
return textContent(renderer.root.findByProps({ "data-stat": label }));
}
describe("ParallelChildren", () => {
test("renders branch status and stage links without checkout metadata", () => {
const renderer = renderParallel([
event({
event: "parallel.started",
properties: { branch_count: 2 },
}),
event({
seq: 2,
event: "parallel.completed",
properties: {
duration_ms: 12000,
success_count: 1,
failure_count: 1,
results: [
{ id: "branch-a", status: "succeeded", context_updates: {} },
{ id: "branch-b", status: "failed", context_updates: {} },
],
},
}),
test("renders live branch names, statuses, counts, and stage links", () => {
const renderer = renderParallel(
[startedEvent(2)],
[
branchStage("review_glm", 0, StageState.SUCCEEDED),
branchStage("review_opus", 1, StageState.RUNNING),
],
);
const rows = branchRowText(renderer);
expect(rows).toHaveLength(2);
expect(rows[0]).toContain("Succeeded");
expect(rows[0]).toContain("review_glm");
expect(rows[1]).toContain("Running");
expect(rows[1]).toContain("review_opus");
expect(hrefs(renderer)).toEqual([
"/runs/run-1/stages/review_glm@1",
"/runs/run-1/stages/review_opus@1",
]);
expect(statValue(renderer, "Succeeded")).toBe("1");
expect(statValue(renderer, "Failed")).toBe("0");
});
test("keeps looped fork links scoped to the selected fork visit", () => {
const renderer = renderParallel(
[startedEvent(1)],
[
branchStage("review_glm", 0, StageState.SUCCEEDED, "fork@1", 1),
branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2),
],
);
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@1"]);
});
test("shows a late-starting branch when lower indexes have no stage yet", () => {
// Branches queued behind `max_parallel` reserve no stage identity, so the
// observed indexes are sparse. Sizing the list by entry count would drop
// the only running branch.
const renderer = renderParallel([], [branchStage("review_opus", 2, StageState.RUNNING)]);
expect(branchRowText(renderer)).toEqual([
"PendingBranch 1",
"PendingBranch 2",
"Runningreview_opus",
]);
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_opus@1"]);
expect(statValue(renderer, "Branches")).toBe("3");
});
test("renders branches with no stage or result yet as pending placeholders", () => {
const renderer = renderParallel([startedEvent(3)], [branchStage("review_glm", 0, StageState.RUNNING)]);
expect(branchRowText(renderer)).toEqual([
"Runningreview_glm",
"PendingBranch 2",
"PendingBranch 3",
]);
expect(statValue(renderer, "Succeeded")).toBe("0");
expect(statValue(renderer, "Failed")).toBe("0");
});
test("labels a re-entered branch with its visit, matching the sidebar", () => {
const renderer = renderParallel(
[startedEvent(1)],
[branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2)],
makeStage({ id: "fork@2", name: "fork", handler: "parallel", visit: 2 }),
);
expect(branchRowText(renderer)).toEqual(["Runningreview_glm@2"]);
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@2"]);
});
test("keeps duplicate branch targets in index order and only links recorded stages", () => {
const renderer = renderParallel(
[
startedEvent(2),
completedEvent([
{ id: "review", status: StageOutcome.FAILED },
{ id: "review", status: StageOutcome.FAILED },
]),
],
[branchStage("review", 0, StageState.SUCCEEDED)],
);
const rows = branchRowText(renderer);
expect(rows).toHaveLength(2);
expect(rows[0]).toContain("Succeeded");
expect(rows[1]).toContain("Failed");
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review@1"]);
});
test("renders a completed result without a matching stage as an unlinked row", () => {
const renderer = renderParallel(
[
startedEvent(1),
completedEvent([{ id: "legacy_branch", status: StageOutcome.SUCCEEDED }]),
],
[],
);
expect(branchRowText(renderer)).toEqual(["Succeededlegacy_branch"]);
expect(hrefs(renderer)).toEqual([]);
});
test("counts partial and skipped branches as neither succeeded nor failed", () => {
const allStages = [
branchStage("partial", 0, StageState.PARTIALLY_SUCCEEDED),
branchStage("skipped", 1, StageState.SKIPPED),
];
const running = renderParallel([startedEvent(2)], allStages);
const completed = renderParallel(
[
startedEvent(2),
completedEvent([
{ id: "partial", status: StageOutcome.PARTIALLY_SUCCEEDED },
{ id: "skipped", status: StageOutcome.SKIPPED },
]),
],
allStages,
);
expect([
statValue(running, "Succeeded"),
statValue(running, "Failed"),
]).toEqual(["0", "0"]);
expect([
statValue(completed, "Succeeded"),
statValue(completed, "Failed"),
]).toEqual(["0", "0"]);
});
test("uses item labels and avoids ambiguous id-only links", () => {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<MemoryRouter>
<ParallelChildren
stage={parallelStage}
events={[
event({
properties: {
duration_ms: 100,
success_count: 2,
failure_count: 0,
results: [
{
id: "reviewer",
index: 0,
item_label: "auth",
status: "succeeded",
context_updates: {},
},
{
id: "reviewer",
index: 1,
item_label: "api",
status: "succeeded",
context_updates: {},
},
],
},
}),
]}
runId="run-1"
allStages={[
{
...parallelStage,
id: "reviewer@1",
name: "reviewer",
nodeId: "reviewer",
handler: "agent",
},
{
...parallelStage,
id: "reviewer@2",
name: "reviewer",
nodeId: "reviewer",
handler: "agent",
},
]}
/>
</MemoryRouter>,
);
});
const rendered = JSON.stringify(renderer.toJSON());
expect(rendered).toContain("branch-a");
expect(rendered).toContain("Succeeded");
expect(rendered).toContain("branch-b");
expect(rendered).toContain("Failed");
const hrefs = renderer.root.findAllByType("a").map((link) => link.props.href);
expect(hrefs).toEqual([
"/runs/run-1/stages/branch-a@1",
"/runs/run-1/stages/branch-b@1",
]);
expect(rendered).toContain("auth");
expect(rendered).toContain("api");
expect(rendered).toContain("reviewer");
expect(renderer.root.findAllByType("a")).toHaveLength(0);
});
});

View file

@ -5,15 +5,24 @@ import { StageState } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { formatStageLabel, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { formatDurationMs } from "../../lib/format";
import { StageMetaBar } from "./meta-bar";
import { parseParallelOverview } from "./helpers";
import type { ParallelBranchSummary } from "./helpers";
/** Branch row view state: completed outcomes plus a synthesized in-flight row. */
/** Branch row view state sourced from a live branch stage or completed result. */
interface BranchRow {
id: string;
label: string;
/**
* Secondary text, set only when `label` is a `for_each` item name. Every
* branch of one fan-out runs the same template node, so the node name is
* context rather than identity.
*/
detail: string | null;
status: StageState;
/** Null when no stage backs this branch yet, which also means it is unlinkable. */
stageId: string | null;
}
function StatItem({
@ -32,31 +41,38 @@ function StatItem({
<span className="text-[10px] font-medium uppercase tracking-[0.16em] text-fg-muted">
{label}
</span>
<span className={`font-mono text-xl tabular-nums ${toneClass}`}>{value}</span>
<span data-stat={label} className={`font-mono text-xl tabular-nums ${toneClass}`}>
{value}
</span>
</div>
);
}
function ChildRow({
result,
stageHref,
row,
runId,
}: {
result: BranchRow;
stageHref: string | null;
row: BranchRow;
runId: string;
}) {
const tone = stageStatusTone(result.status);
const tone = stageStatusTone(row.status);
const inner = (
<>
<span
className={`inline-flex w-24 shrink-0 justify-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${tone}`}
>
{stageStatusLabel(result.status)}
{stageStatusLabel(row.status)}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-sm text-fg-3">
{result.id}
<span className="min-w-0 flex flex-1 items-baseline gap-2">
<span className="truncate font-mono text-sm text-fg-3">{row.label}</span>
{row.detail && (
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
{row.detail}
</span>
)}
</span>
{stageHref && (
{row.stageId && (
<ArrowTopRightOnSquareIcon
className="size-3.5 shrink-0 text-fg-muted transition-colors group-hover:text-fg-2"
aria-hidden="true"
@ -67,9 +83,9 @@ function ChildRow({
return (
<li className="flex items-center gap-3 px-4 py-2.5">
{stageHref ? (
{row.stageId ? (
<Link
to={stageHref}
to={`/runs/${runId}/stages/${row.stageId}`}
className="group flex flex-1 items-center gap-3 rounded -m-1 p-1 transition-colors hover:bg-overlay focus-visible:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500"
>
{inner}
@ -94,40 +110,93 @@ export function ParallelChildren({
}) {
const overview = useMemo(() => parseParallelOverview(events), [events]);
// Map node_id -> latest stage_id so we can deep-link branches.
const latestStageByNode = useMemo(() => {
const latest = new Map<string, Stage>();
for (const s of allStages) {
const prev = latest.get(s.nodeId);
if (!prev || s.visit > prev.visit) latest.set(s.nodeId, s);
const stagesByBranchIndex = useMemo(() => {
const byIndex = new Map<number, Stage>();
for (const candidate of allStages) {
if (
candidate.parallelGroupId === stage.id
&& candidate.parallelBranchIndex != null
) {
byIndex.set(candidate.parallelBranchIndex, candidate);
}
}
return new Map(Array.from(latest.entries()).map(([nodeId, s]) => [nodeId, s.id]));
}, [allStages]);
return byIndex;
}, [allStages, stage.id]);
const items: BranchRow[] = overview.results.length > 0
? overview.results
: overview.branchCount && overview.branchCount > 0
? Array.from({ length: overview.branchCount }, (_, i) => ({
id: `branch ${i + 1}`,
status: StageState.RUNNING,
}))
: [];
// Key results by their own `index` rather than array position, so a row
// lines up with the branch stage carrying the same index.
const resultsByIndex = useMemo(() => {
const byIndex = new Map<number, ParallelBranchSummary>();
overview.results.forEach((result, position) => {
byIndex.set(result.index ?? position, result);
});
return byIndex;
}, [overview.results]);
// Branch indexes are sparse: a branch queued behind `max_parallel` has no
// stage identity yet, and one cancelled while queued never gets one. Size the
// list from the highest index seen so a late-starting branch is never hidden.
const branchCount = Math.max(
overview.branchCount ?? 0,
...Array.from(resultsByIndex.keys(), (index) => index + 1),
...Array.from(stagesByBranchIndex.keys(), (index) => index + 1),
);
const rows = Array.from({ length: branchCount }, (_, index): BranchRow => {
// A `for_each` branch is named by its item. Without that name every row of
// one fan-out would read as the same template node.
const result = resultsByIndex.get(index);
const itemLabel = result?.itemLabel ?? null;
// A live branch stage is the freshest source; fall back to the completed
// event's result for runs whose branches predate parallel identity.
const branchStage = stagesByBranchIndex.get(index);
if (branchStage) {
const stageLabel = formatStageLabel(branchStage);
return {
label: itemLabel ?? stageLabel,
detail: itemLabel ? stageLabel : null,
status: branchStage.status,
stageId: branchStage.id,
};
}
if (result) {
return {
label: itemLabel ?? result.id,
detail: itemLabel ? result.id : null,
status: result.status,
stageId: null,
};
}
return {
label: `Branch ${index + 1}`,
detail: null,
status: StageState.PENDING,
stageId: null,
};
});
// Count what is on screen, so the tiles can never contradict the rows.
let successCount = 0;
let failureCount = 0;
for (const row of rows) {
if (row.status === StageState.SUCCEEDED) successCount += 1;
else if (row.status === StageState.FAILED) failureCount += 1;
}
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
<StageMetaBar stage={stage} />
<section className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-lg bg-panel p-5 outline-1 -outline-offset-1 outline-line sm:grid-cols-4">
<StatItem label="Branches" value={overview.branchCount ?? "—"} />
<StatItem label="Branches" value={branchCount || "—"} />
<StatItem
label="Succeeded"
value={overview.successCount ?? (overview.isComplete ? 0 : "—")}
value={successCount}
tone="success"
/>
<StatItem
label="Failed"
value={overview.failureCount ?? (overview.isComplete ? 0 : "—")}
tone={overview.failureCount && overview.failureCount > 0 ? "danger" : "default"}
value={failureCount}
tone={failureCount > 0 ? "danger" : "default"}
/>
<StatItem
label="Duration"
@ -139,21 +208,13 @@ export function ParallelChildren({
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-muted">
Branches
</h3>
{items.length === 0 ? (
{rows.length === 0 ? (
<p className="text-sm text-fg-muted">No branches recorded yet.</p>
) : (
<ul className="divide-y divide-line rounded-lg bg-panel outline-1 -outline-offset-1 outline-line">
{items.map((result, i) => {
const stageId = latestStageByNode.get(result.id);
const href = stageId ? `/runs/${runId}/stages/${stageId}` : null;
return (
<ChildRow
key={`${result.id}-${i}`}
result={result}
stageHref={href}
/>
);
})}
{rows.map((row, index) => (
<ChildRow key={index} row={row} runId={runId} />
))}
</ul>
)}
</section>

View file

@ -2,24 +2,9 @@ import { describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { makeStage } from "../lib/test-utils";
import { StageSidebar, type Stage } from "./stage-sidebar";
function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
status: "running",
duration: "--",
startedAt: null,
providerUsed: null,
...overrides,
};
}
function renderSidebar(stages: Stage[]): string {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

View file

@ -1,22 +1,146 @@
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";
import { createRef } from "react";
import TestRenderer, { act } from "react-test-renderer";
import { setupReactTestEnv } from "../lib/test-utils";
let steerPending = false;
let interruptPending = false;
const steerTrigger = mock(() => Promise.resolve(undefined));
const interruptTrigger = mock(() => Promise.resolve(undefined));
mock.module("../lib/mutations", () => ({
useSteerRun: () => ({
isMutating: steerPending,
trigger: steerTrigger,
}),
useInterruptRun: () => ({
isMutating: interruptPending,
trigger: interruptTrigger,
}),
}));
const {
isInterruptDisabled,
SteerWaitingStatus,
} from "./steer-bar";
isSteerDockCollapsed,
SteerBar,
steerStatusLabel,
} = await import("./steer-bar");
type SteerBarHandle = import("./steer-bar").SteerBarHandle;
mock.restore();
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
let teardownReactEnv: (() => void) | undefined;
function textFromNode(node: TestRenderer.ReactTestInstance): string {
return node.children
.map((child) =>
typeof child === "string" ? child : textFromNode(child),
)
.join("");
}
beforeEach(() => {
teardownReactEnv = setupReactTestEnv();
steerPending = false;
interruptPending = false;
steerTrigger.mockClear();
interruptTrigger.mockClear();
});
afterEach(() => {
for (const renderer of mountedRenderers.splice(0)) {
act(() => renderer.unmount());
}
teardownReactEnv?.();
teardownReactEnv = undefined;
});
describe("SteerBar", () => {
test("shows durable waiting state and prevents a second interrupt", () => {
test("prevents a second interrupt while one is in flight or already settled", () => {
expect(isInterruptDisabled(true, false)).toBe(true);
expect(isInterruptDisabled(false, true)).toBe(true);
expect(isInterruptDisabled(false, false)).toBe(false);
});
const html = renderToStaticMarkup(
createElement(SteerWaitingStatus, { waitingForSteer: true }),
test("names the durable waiting state in the dock header", () => {
expect(steerStatusLabel(true)).toBe("Interrupted — waiting for steering");
expect(steerStatusLabel(false)).toBe("Steering");
});
test("reopens the dock while the run waits for steering", () => {
expect(isSteerDockCollapsed(true, false)).toBe(true);
expect(isSteerDockCollapsed(false, false)).toBe(false);
// Collapsing cannot hide a run that is blocked on the operator.
expect(isSteerDockCollapsed(true, true)).toBe(false);
expect(isSteerDockCollapsed(false, true)).toBe(false);
});
test("the focus handle expands a collapsed dock before focusing", async () => {
const focus = mock(() => undefined);
const ref = createRef<SteerBarHandle>();
let renderer!: TestRenderer.ReactTestRenderer;
await act(async () => {
renderer = TestRenderer.create(
<SteerBar ref={ref} runId="run-1" />,
{
createNodeMock: (element) =>
element.type === "textarea" ? { focus } : null,
},
);
});
mountedRenderers.push(renderer);
act(() => {
renderer.root
.findByProps({ "aria-label": "Collapse Steer running agent" })
.props.onClick();
});
expect(
renderer.root.findByProps({
"aria-label": "Expand Steer running agent",
}),
).toBeDefined();
act(() => ref.current?.focus());
expect(
renderer.root.findByProps({
"aria-label": "Collapse Steer running agent",
}),
).toBeDefined();
expect(focus).not.toHaveBeenCalled();
await act(
async () =>
await new Promise((resolve) => {
setTimeout(resolve, 0);
}),
);
expect(html).toContain('role="status"');
expect(html).toContain("Interrupted — waiting for steering");
expect(focus).toHaveBeenCalledTimes(1);
});
test("interrupt progress disables the composer without calling it sending", async () => {
interruptPending = true;
let renderer!: TestRenderer.ReactTestRenderer;
await act(async () => {
renderer = TestRenderer.create(<SteerBar runId="run-1" />);
});
mountedRenderers.push(renderer);
const submit = renderer.root.findByProps({ type: "submit" });
expect(submit.props.disabled).toBe(true);
expect(textFromNode(submit)).toBe("Send");
expect(
renderer.root
.findAllByType("button")
.map(textFromNode),
).toContain("Interrupting…");
});
});

View file

@ -2,15 +2,22 @@ import {
useImperativeHandle,
useRef,
useState,
type FormEvent,
type KeyboardEvent,
type Ref,
} from "react";
import { StopIcon } from "@heroicons/react/20/solid";
import { ApiError } from "../lib/api-client";
import { classNames } from "../lib/class-names";
import { useInterruptRun, useSteerRun } from "../lib/mutations";
import {
DockComposer,
RunDockShell,
DOCK_HEADER_BUTTON,
} from "./run-dock";
import { ErrorMessage } from "./ui";
const STEER_MAX_LENGTH = 8192;
export interface SteerBarProps {
runId: string;
waitingForSteer?: boolean;
@ -28,17 +35,20 @@ export function isInterruptDisabled(
return waitingForSteer || mutationPending;
}
export function SteerWaitingStatus({
waitingForSteer,
}: {
waitingForSteer: boolean;
}) {
if (!waitingForSteer) return null;
return (
<p role="status" className="mt-2 text-xs text-amber">
Interrupted — waiting for steering
</p>
);
/**
* A run that is waiting for steering needs the operator, so the dock reopens
* itself and stays open until the wait clears. Derived rather than stored, so
* collapsing during the wait cannot hide the prompt.
*/
export function isSteerDockCollapsed(
collapsePreferred: boolean,
waitingForSteer: boolean,
): boolean {
return collapsePreferred && !waitingForSteer;
}
export function steerStatusLabel(waitingForSteer: boolean): string {
return waitingForSteer ? "Interrupted — waiting for steering" : "Steering";
}
export function SteerBar({
@ -46,31 +56,38 @@ export function SteerBar({
waitingForSteer = false,
ref,
}: SteerBarProps) {
const [text, setText] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [collapsePreferred, setCollapsePreferred] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const steer = useSteerRun(runId);
const interrupt = useInterruptRun(runId);
const pending = steer.isMutating || interrupt.isMutating;
const interruptDisabled = isInterruptDisabled(waitingForSteer, pending);
const collapsed = isSteerDockCollapsed(collapsePreferred, waitingForSteer);
useImperativeHandle(ref, () => ({
focus() {
textareaRef.current?.focus();
},
}));
useImperativeHandle(
ref,
() => ({
focus() {
if (!collapsed) {
textareaRef.current?.focus();
return;
}
setCollapsePreferred(false);
setTimeout(() => textareaRef.current?.focus(), 0);
},
}),
[collapsed],
);
const trimmed = text.trim();
const canSend = trimmed.length > 0 && !pending;
async function sendSteering() {
if (!canSend) return;
async function sendSteering(text: string) {
setErrorMessage(null);
try {
await steer.trigger({ text: trimmed, interrupt: false });
setText("");
await steer.trigger({ text, interrupt: false });
return true;
} catch (err) {
setErrorMessage(formatSteerError(err));
return false;
}
}
@ -84,59 +101,47 @@ export function SteerBar({
}
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
void sendSteering();
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void sendSteering();
}
}
return (
<form
onSubmit={handleSubmit}
aria-label="Steer running agent"
className="mx-auto max-w-4xl px-4 py-3 sm:px-6 lg:px-8"
>
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Steer the agent…"
rows={1}
maxLength={8192}
aria-label="Steering message"
className="flex-1 resize-none rounded-md bg-overlay px-3 py-2 text-sm text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500"
/>
<RunDockShell
label="Steer running agent"
tone={waitingForSteer ? "alert" : "idle"}
status={steerStatusLabel(waitingForSteer)}
peek="Send a message to the running agent"
collapsed={collapsed}
onCollapsedChange={setCollapsePreferred}
headerActions={
// Interrupt acts on the run, not on the message being composed, so it
// sits with the other run-level controls instead of in the composer.
<button
type="button"
onClick={() => void fireInterrupt()}
disabled={interruptDisabled}
className="inline-flex shrink-0 items-center gap-2 rounded-md bg-overlay px-3 py-2 text-sm font-medium text-amber outline-1 -outline-offset-1 outline-amber/40 transition-colors hover:bg-amber/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber disabled:cursor-not-allowed disabled:opacity-60"
className={classNames(
DOCK_HEADER_BUTTON,
"text-amber outline-amber/40 hover:bg-amber/15 hover:text-amber focus-visible:outline-amber disabled:hover:bg-overlay disabled:hover:text-amber",
)}
>
<StopIcon className="size-3" aria-hidden="true" />
{interrupt.isMutating ? "Interrupting…" : "Interrupt"}
</button>
<button
type="submit"
disabled={!canSend}
className="inline-flex shrink-0 items-center justify-center rounded-md bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"
>
{steer.isMutating ? "Sending…" : "Send"}
</button>
</div>
{errorMessage && (
<div className="mt-2">
<ErrorMessage message={errorMessage} />
</div>
)}
<SteerWaitingStatus waitingForSteer={waitingForSteer} />
</form>
}
actions={
<>
<DockComposer
onSubmit={sendSteering}
placeholder="Steer the agent…"
submitLabel="Send"
pendingLabel="Sending…"
submitting={steer.isMutating}
disabled={pending}
ariaLabel="Steering message"
maxLength={STEER_MAX_LENGTH}
textareaRef={textareaRef}
/>
{errorMessage && <ErrorMessage message={errorMessage} />}
</>
}
/>
);
}

View file

@ -103,6 +103,17 @@ describe("mapRunListItem", () => {
expect(mapRunListItem(summary).title).toBe("Untitled run");
});
test("carries the billed total so the size chip can show it on hover", () => {
expect(mapRunListItem(makeRun()).totalUsdMicros).toBe(500000);
});
test("leaves the billed total undefined for runs without terminal billing", () => {
expect(mapRunListItem(makeRun({ billing: null })).totalUsdMicros).toBeUndefined();
expect(
mapRunListItem(makeRun({ billing: { total_usd_micros: null } })).totalUsdMicros,
).toBeUndefined();
});
});
describe("mapRunToRunItem", () => {

View file

@ -46,6 +46,7 @@ export interface RunItem {
createdBy: Principal;
lastEventAt?: string;
size?: RunSize;
totalUsdMicros?: number;
}
export const columnStatuses = [
@ -119,6 +120,7 @@ export function mapRunListItem(item: Run): RunItem {
additions: item.diff?.additions,
deletions: item.diff?.deletions,
size: item.size,
totalUsdMicros: item.billing?.total_usd_micros ?? undefined,
};
}

View file

@ -0,0 +1,28 @@
import type { BilledTokenCounts } from "@qltysh/fabro-api-client";
export interface BillingTokenBucket {
label: string;
value: number;
}
export function billableOutputTokens(billing: BilledTokenCounts): number {
return billing.output_tokens + billing.reasoning_tokens;
}
/** The disjoint token buckets shown in every billing breakdown. */
export function billingTokenBuckets(billing: BilledTokenCounts): BillingTokenBucket[] {
return [
{ label: "Cache read", value: billing.cache_read_tokens },
{ label: "Cache creation", value: billing.cache_write_tokens },
{ label: "Uncached", value: billing.input_tokens },
{ label: "Output", value: billableOutputTokens(billing) },
];
}
export function hasBillingUsage(billing: BilledTokenCounts): boolean {
return (
billing.total_tokens !== 0 ||
(billing.total_usd_micros ?? 0) !== 0 ||
billingTokenBuckets(billing).some((bucket) => bucket.value !== 0)
);
}

View file

@ -0,0 +1,5 @@
export function classNames(
...classes: Array<string | false | null | undefined>
): string {
return classes.filter(Boolean).join(" ");
}

View file

@ -1,3 +1,4 @@
import { useCallback } from "react";
import useSWR, { type SWRConfiguration } from "swr";
import type {
ApiQuestion,
@ -73,6 +74,7 @@ import {
type RunFileSelection,
type RunGraphDirection,
} from "./query-keys";
import { isTerminalRunStatus } from "./run-actions";
const immutableOptions: SWRConfiguration = {
revalidateIfStale: false,
@ -179,10 +181,21 @@ export function useRunsPage(opts: RunsPageOptions = {}, enabled = true) {
);
}
export function useRun(id: string | undefined) {
export function useRun(id: string | undefined, refreshInterval?: number) {
const pollingInterval = useCallback(
(run: Run | null | undefined) =>
refreshInterval &&
run?.timestamps.started_at &&
!isTerminalRunStatus(run.lifecycle.status.kind)
? refreshInterval
: 0,
[refreshInterval],
);
return useSWR<Run | null>(
id ? queryKeys.runs.detail(id) : null,
() => apiNullableData(() => runsApi.retrieveRun(id!)),
refreshInterval ? { refreshInterval: pollingInterval } : undefined,
);
}

View file

@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import { queryKeys } from "./query-keys";
import { queryKeysForRunEvent } from "./run-events";
describe("queryKeys", () => {
test("uses semantic tuples as stable SWR keys and keeps SSE URLs explicit", () => {
@ -61,52 +60,4 @@ describe("queryKeys", () => {
expect(queryKeys.runs.attachUrl("run 1")).toBe("/api/v1/runs/run%201/attach");
});
test("event-mapped keys match query hook resources", () => {
expect(queryKeysForRunEvent("run-1", "checkpoint.completed")).toEqual(
[
...queryKeys.runs.filesAllScopes("run-1"),
queryKeys.runs.commits("run-1"),
],
);
expect(queryKeysForRunEvent("run-1", "stage.completed", "stage-1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.stageEvents("run-1", "stage-1"),
queryKeys.runs.stageContextWindow("run-1", "stage-1"),
]);
expect(queryKeysForRunEvent("run-1", "run.title.updated")).toEqual([
queryKeys.runs.detail("run-1"),
]);
});
test("agent activity events invalidate per-stage resources", () => {
for (const event of [
"stage.prompt",
"agent.tool.started",
"agent.tool.completed",
"command.started",
"command.completed",
]) {
expect(queryKeysForRunEvent("run-1", event, "stage-1")).toEqual([
queryKeys.runs.stageEvents("run-1", "stage-1"),
queryKeys.runs.stageContextWindow("run-1", "stage-1"),
]);
}
expect(queryKeysForRunEvent("run-1", "agent.message", "stage-1")).toEqual([
queryKeys.runs.state("run-1"),
queryKeys.runs.stageEvents("run-1", "stage-1"),
queryKeys.runs.stageContextWindow("run-1", "stage-1"),
]);
});
test("agent message without a node_id still invalidates projected state", () => {
expect(queryKeysForRunEvent("run-1", "agent.message")).toEqual([
queryKeys.runs.state("run-1"),
]);
});
});

View file

@ -111,10 +111,7 @@ export async function deleteRuns(
request?: Request,
): Promise<BatchDeleteRunsResponse> {
try {
// See `batchRunLifecycleAction` for the `as unknown as` rationale:
// openapi-generator types `uniqueItems` arrays as `Set<T>` while the wire
// contract is a JSON array.
const body = { run_ids: runIds, force } as unknown as BatchDeleteRunsRequest;
const body: BatchDeleteRunsRequest = { run_ids: runIds, force };
return await apiData(() => runsApi.batchDeleteRuns(body, requestSignalOptions(request)));
} catch (error) {
throw lifecycleActionErrorFromError(error);
@ -284,10 +281,7 @@ async function batchRunLifecycleAction(
request?: Request,
): Promise<BatchRunLifecycleResponse> {
try {
// openapi-generator's TypeScript client represents `uniqueItems` arrays as
// Set<T>, but the HTTP wire contract is still a JSON array. Keep an array
// here so Axios serializes the request body correctly.
const body = { run_ids: runIds } as unknown as BatchRunLifecycleRequest;
const body: BatchRunLifecycleRequest = { run_ids: runIds };
switch (action) {
case "archive":
return await apiData(() => runsApi.batchArchiveRuns(body, requestSignalOptions(request)));

View file

@ -85,6 +85,8 @@ describe("queryKeysForRunEvent", () => {
test("interrupt settlement invalidates projected control state and stage activity", () => {
expect(queryKeysForRunEvent("run-1", "agent.round.interrupted", "nap@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "nap@1"),
@ -92,6 +94,36 @@ describe("queryKeysForRunEvent", () => {
]);
});
test("parallel branch lifecycle invalidates the stages list backing live branch rows", () => {
// Branches bypass stage.started/stage.completed, so these events are the
// only signal that a branch row's status changed.
expect(queryKeysForRunEvent("run-1", "parallel.branch.started", "review_glm@1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.stageEvents("run-1", "review_glm@1"),
]);
expect(queryKeysForRunEvent("run-1", "parallel.branch.completed", "review_glm@1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.stageEvents("run-1", "review_glm@1"),
]);
});
test("fork lifecycle invalidates run-scoped resources without a stage id", () => {
for (const event of ["parallel.started", "parallel.completed"]) {
expect(queryKeysForRunEvent("run-1", event)).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
]);
}
});
test("cancel requests invalidate the durable run summary", () => {
expect(queryKeysForRunEvent("run-1", "run.cancel.requested")).toEqual([
queryKeys.runs.detail("run-1"),
@ -129,10 +161,16 @@ describe("queryKeysForRunEvent", () => {
test("every inference projection transition invalidates live run state", () => {
for (const event of [
"agent.llm.started",
"agent.llm.first_output",
"agent.llm.retry",
"agent.error",
]) {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
]);
}
for (const event of ["agent.llm.first_output", "agent.llm.retry"]) {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.state("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
@ -141,15 +179,47 @@ describe("queryKeysForRunEvent", () => {
expect(
queryKeysForRunEvent("run-1", "agent.message", "code@1"),
).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
queryKeys.runs.stageContextWindow("run-1", "code@1"),
]);
expect(queryKeysForRunEvent("run-1", "agent.session.ended")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.billing("run-1"),
]);
});
test("ACP timing events invalidate live summaries and stage events", () => {
for (const event of [
"agent.acp.started",
"agent.acp.completed",
"agent.acp.cancelled",
"agent.acp.timed_out",
]) {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
]);
}
});
test("tool timing events invalidate live summaries and stage resources", () => {
for (const event of ["agent.tool.started", "agent.tool.completed"]) {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
queryKeys.runs.stageContextWindow("run-1", "code@1"),
]);
}
});
test("watchdog timeout refreshes the stage events for that stage", () => {
expect(
queryKeysForRunEvent("run-1", "watchdog.timeout", "code@1"),

View file

@ -88,6 +88,17 @@ export const STAGE_ACTIVITY_EVENT_TYPES = [
] as const;
export type StageActivityEventType = (typeof STAGE_ACTIVITY_EVENT_TYPES)[number];
const STAGE_ACTIVITY_EVENTS = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
// Parallel branches bypass the engine's `stage.started` / `stage.completed`
// lifecycle (the parallel handler dispatches each branch directly), so
// `STAGE_EVENTS` never fires for them. Without this set the stages list never
// refetches while a fork runs and branch rows stay frozen at their first
// observed state.
const PARALLEL_EVENTS = new Set([
"parallel.started",
"parallel.branch.started",
"parallel.branch.completed",
"parallel.completed",
]);
const INTERVIEW_EVENTS = new Set([
"interview.started",
"interview.completed",
@ -118,6 +129,22 @@ const INFERENCE_EVENTS = new Set([
"agent.error",
"agent.session.ended",
]);
const INFERENCE_TIMING_EVENTS = new Set([
"agent.llm.started",
"agent.message",
"agent.error",
"agent.session.ended",
]);
const TOOL_TIMING_EVENTS = new Set([
"agent.tool.started",
"agent.tool.completed",
]);
const ACP_TIMING_EVENTS = new Set([
"agent.acp.started",
"agent.acp.completed",
"agent.acp.cancelled",
"agent.acp.timed_out",
]);
// Todo / task mutation events refresh `getRunState` consumers (so per-stage
// todo projections update live) and the run events list.
const TODO_EVENTS = new Set([
@ -126,6 +153,14 @@ const TODO_EVENTS = new Set([
"todo.deleted",
]);
function liveTimingKeys(runId: string): Key[] {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
queryKeys.runs.billing(runId),
];
}
export function queryKeysForRunEvent(
runId: string,
event: string,
@ -179,11 +214,30 @@ export function queryKeysForRunEvent(
return keys;
}
if (PARALLEL_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
if (STEERING_EVENTS.has(event)) {
const keys: Key[] = [queryKeys.runs.events(runId, 1000)];
if (AGENT_CONTROL_STATE_EVENTS.has(event)) {
keys.unshift(queryKeys.runs.state(runId));
}
if (event === "agent.round.interrupted") {
keys.unshift(
queryKeys.runs.detail(runId),
queryKeys.runs.billing(runId),
);
}
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
keys.push(queryKeys.runs.stageContextWindow(runId, stageId));
@ -192,7 +246,9 @@ export function queryKeysForRunEvent(
}
if (INFERENCE_EVENTS.has(event)) {
const keys: Key[] = [queryKeys.runs.state(runId)];
const keys = INFERENCE_TIMING_EVENTS.has(event)
? liveTimingKeys(runId)
: [queryKeys.runs.state(runId)];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
if (event === "agent.message") {
@ -202,6 +258,23 @@ export function queryKeysForRunEvent(
return keys;
}
if (TOOL_TIMING_EVENTS.has(event)) {
const keys = liveTimingKeys(runId);
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
keys.push(queryKeys.runs.stageContextWindow(runId, stageId));
}
return keys;
}
if (ACP_TIMING_EVENTS.has(event)) {
const keys = liveTimingKeys(runId);
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
if (event === "watchdog.timeout") {
return stageId ? [queryKeys.runs.stageEvents(runId, stageId)] : [];
}

View file

@ -3,21 +3,11 @@ import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fa
import type { Stage } from "../components/stage-sidebar";
import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar";
import { makeBilledTokenCounts } from "./test-fixtures";
import { makeStage as baseMakeStage } from "./test-utils";
function makeStage(nodeId: string, visit: number, status: StageState): Stage {
return {
id: `${nodeId}@${visit}`,
name: nodeId,
handler: "agent",
nodeId,
visit,
graphVisit: null,
resumedFromStageId: null,
status,
duration: "--",
startedAt: null,
providerUsed: null,
};
return baseMakeStage({ id: `${nodeId}@${visit}`, name: nodeId, nodeId, visit, status });
}
describe("mapRunStagesToSidebarStages", () => {
@ -38,6 +28,15 @@ describe("mapRunStagesToSidebarStages", () => {
model: "gpt-5.5",
reasoning_effort: "high",
},
billing: makeBilledTokenCounts({
input_tokens: 28_640,
output_tokens: 7_550,
total_tokens: 43_690,
reasoning_tokens: 1_200,
cache_read_tokens: 4_800,
cache_write_tokens: 1_500,
total_usd_micros: 720_000,
}),
},
{
id: "apply-changes@2",
@ -46,6 +45,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "running",
node_id: "apply",
visit: 2,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -64,6 +64,10 @@ describe("mapRunStagesToSidebarStages", () => {
model: "gpt-5.5",
reasoning_effort: "high",
});
// Each visit keeps its own tokens and cost, so the stage popover never
// shows a sibling visit's usage.
expect(result[0].billing.total_usd_micros).toBe(720_000);
expect(result[1].billing.total_usd_micros).toBeUndefined();
expect(formatStageLabel(result[0])).toBe("Apply Changes");
expect(result[1].id).toBe("apply-changes@2");
@ -83,6 +87,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "start",
visit: 1,
billing: makeBilledTokenCounts(),
},
{
id: "verify@1",
@ -91,6 +96,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
},
{
id: "exit@1",
@ -99,6 +105,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "exit",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -118,6 +125,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "running",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -137,6 +145,7 @@ describe("mapRunStagesToSidebarStages", () => {
node_id: "work",
visit: 1,
graph_visit: 1,
billing: makeBilledTokenCounts(),
},
{
id: "work@2",
@ -147,6 +156,7 @@ describe("mapRunStagesToSidebarStages", () => {
visit: 2,
graph_visit: 1,
resumed_from_stage_id: "work@1",
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -172,6 +182,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -182,6 +193,28 @@ describe("mapRunStagesToSidebarStages", () => {
expect(result[0].resumedFromStageId).toBeNull();
});
test("maps parallel branch identity without parsing it in the client", () => {
const stages: PaginatedRunStageList = {
data: [
{
id: "review_opus@2",
name: "review_opus",
handler: "agent",
status: "running",
node_id: "review_opus",
visit: 2,
parallel_group_id: "review_fork@1",
parallel_branch_index: 3,
},
],
meta: { has_more: false },
};
const result = mapRunStagesToSidebarStages(stages);
expect(result[0].parallelGroupId).toBe("review_fork@1");
expect(result[0].parallelBranchIndex).toBe(3);
});
test("preserves the authoritative handler for renderer dispatch", () => {
const stages: PaginatedRunStageList = {
data: [
@ -192,6 +225,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "pending",
node_id: "approval",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },

View file

@ -1,5 +1,6 @@
import { StageState } from "@qltysh/fabro-api-client";
import type {
BilledTokenCounts,
PaginatedRunStageList,
StageHandler,
StageModelUsage,
@ -25,8 +26,17 @@ export interface Stage {
graphVisit: number | null;
/** StageId of the prior execution superseded by this resumed replay, if any. */
resumedFromStageId: string | null;
/** Exact StageId of the parent parallel execution, if this is a branch. */
parallelGroupId: string | null;
/** Zero-based outgoing-edge index within the parent parallel execution. */
parallelBranchIndex: number | null;
startedAt: string | null;
providerUsed: StageModelUsage | null;
/**
* Tokens and cost for this visit alone, priced the same way the Billing tab
* prices its per-node rows. All-zero counts mean the stage called no model.
*/
billing: BilledTokenCounts;
}
export const ACTIVE_STAGE_STATES: ReadonlySet<StageState> = new Set([
@ -96,12 +106,15 @@ export function mapRunStagesToSidebarStages(
visit: stage.visit,
graphVisit: stage.graph_visit ?? null,
resumedFromStageId: stage.resumed_from_stage_id ?? null,
parallelGroupId: stage.parallel_group_id ?? null,
parallelBranchIndex: stage.parallel_branch_index ?? null,
status: stage.status,
duration: stage.wall_time_ms != null
? formatDurationMs(stage.wall_time_ms)
: "--",
startedAt: stage.started_at ?? null,
providerUsed: stage.provider_used ?? null,
billing: stage.billing,
});
}
return stages;

View file

@ -1,4 +1,4 @@
import type { Principal } from "@qltysh/fabro-api-client";
import type { BilledTokenCounts, Principal } from "@qltysh/fabro-api-client";
export const TEST_PRINCIPAL: Principal = {
kind: "user",
@ -6,3 +6,17 @@ export const TEST_PRINCIPAL: Principal = {
login: "test",
auth_method: "dev_token",
};
export function makeBilledTokenCounts(
overrides: Partial<BilledTokenCounts> = {},
): BilledTokenCounts {
return {
cache_read_tokens: 0,
cache_write_tokens: 0,
input_tokens: 0,
output_tokens: 0,
reasoning_tokens: 0,
total_tokens: 0,
...overrides,
};
}

View file

@ -2,6 +2,9 @@ import { createElement, type ReactNode } from "react";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import type { Stage } from "./stage-sidebar";
import { makeBilledTokenCounts } from "./test-fixtures";
const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const;
/**
@ -55,6 +58,38 @@ export function makeEventEnvelope(
} as EventEnvelope;
}
/** Flatten a rendered subtree to its visible text. */
export function textContent(node: TestRenderer.ReactTestInstance): string {
return node.children
.map((child) => (typeof child === "string" ? child : textContent(child)))
.join("");
}
/**
* Build a sidebar `Stage` fixture; override any field via `overrides`. Kept
* here so widening `Stage` updates every fixture at once — test files are
* excluded from typecheck, so a per-file copy silently goes stale instead.
*/
export function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
parallelGroupId: null,
parallelBranchIndex: null,
status: "running",
duration: "--",
startedAt: null,
providerUsed: null,
billing: makeBilledTokenCounts(),
...overrides,
};
}
export function renderHook<T>(
hook: () => T,
options: { wrapper: React.ComponentType<{ children: ReactNode }> },

View file

@ -1,14 +1,18 @@
import { useMemo } from "react";
import { useParams } from "react-router";
import { ArrowDownTrayIcon, PaperClipIcon } from "@heroicons/react/24/outline";
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react";
import { ArrowDownTrayIcon, ChevronRightIcon, PaperClipIcon } from "@heroicons/react/24/outline";
import type { RunArtifactEntry } from "@qltysh/fabro-api-client";
import { EmptyState, ErrorState, LoadingState } from "../components/state";
import { StageSidebar } from "../components/stage-sidebar";
import { stageArtifactDownloadUrl } from "../lib/api-client";
import { formatBytes } from "../lib/format";
import { plural } from "../lib/plural";
import { useRunArtifacts, useRunStages } from "../lib/queries";
import { formatStageLabel, mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
import type { ArtifactFile, ArtifactVersion } from "./run-artifacts/group";
import { groupArtifactsByFile } from "./run-artifacts/group";
export const handle = { wide: true };
@ -25,7 +29,12 @@ export default function RunArtifacts() {
<div className="flex gap-6">
<StageSidebar stages={stages} runId={id!} activeLink="artifacts" />
<div className="min-w-0 flex-1">
<RunArtifactsBody runId={id!} artifactsQuery={artifactsQuery} stages={stages} />
<RunArtifactsBody
runId={id!}
artifactsQuery={artifactsQuery}
stagesQuery={stagesQuery}
stages={stages}
/>
</div>
</div>
);
@ -34,86 +43,40 @@ export default function RunArtifacts() {
function RunArtifactsBody({
runId,
artifactsQuery,
stagesQuery,
stages,
}: {
runId: string;
artifactsQuery: ReturnType<typeof useRunArtifacts>;
stagesQuery: ReturnType<typeof useRunStages>;
stages: ReturnType<typeof mapRunStagesToSidebarStages>;
}) {
if (artifactsQuery.error) {
const error = artifactsQuery.error ?? stagesQuery.error;
if (error) {
return (
<ErrorState
title="Couldn't load artifacts"
description={errorMessage(artifactsQuery.error)}
onRetry={() => void artifactsQuery.mutate()}
description={errorMessage(error)}
onRetry={() => {
if (artifactsQuery.error) void artifactsQuery.mutate();
if (stagesQuery.error) void stagesQuery.mutate();
}}
/>
);
}
if (artifactsQuery.data === undefined) {
if (artifactsQuery.data === undefined || stagesQuery.data === undefined) {
return <LoadingState label="Loading artifacts…" />;
}
const entries = artifactsQuery.data?.data ?? [];
if (entries.length === 0) {
return (
<EmptyState
icon={PaperClipIcon}
title="No artifacts captured"
description="No stage in this run produced any artifacts."
/>
);
}
return <ArtifactList runId={runId} entries={entries} stages={stages} />;
return (
<ArtifactFiles
runId={runId}
entries={artifactsQuery.data?.data ?? []}
stages={stages}
/>
);
}
interface StageGroup {
key: string;
stageId: string;
retry: number;
label: string;
entries: RunArtifactEntry[];
totalBytes: number;
}
function groupArtifacts(
entries: readonly RunArtifactEntry[],
stages: ReturnType<typeof mapRunStagesToSidebarStages>,
): StageGroup[] {
const stageLabels = new Map<string, string>();
for (const stage of stages) {
stageLabels.set(stage.id, formatStageLabel(stage));
}
const groups = new Map<string, StageGroup>();
for (const entry of entries) {
const key = `${entry.stage_id}#${entry.retry}`;
const existing = groups.get(key);
if (existing) {
existing.entries.push(entry);
existing.totalBytes += entry.size;
} else {
groups.set(key, {
key,
stageId: entry.stage_id,
retry: entry.retry,
label: stageLabels.get(entry.stage_id) ?? entry.node_slug,
entries: [entry],
totalBytes: entry.size,
});
}
}
for (const group of groups.values()) {
group.entries.sort((a, b) => a.relative_path.localeCompare(b.relative_path));
}
const sortedGroups = Array.from(groups.values());
sortedGroups.sort((a, b) => {
const labelCmp = a.label.localeCompare(b.label);
return labelCmp !== 0 ? labelCmp : a.retry - b.retry;
});
return sortedGroups;
}
function ArtifactList({
function ArtifactFiles({
runId,
entries,
stages,
@ -122,97 +85,209 @@ function ArtifactList({
entries: readonly RunArtifactEntry[];
stages: ReturnType<typeof mapRunStagesToSidebarStages>;
}) {
const groups = useMemo(() => groupArtifacts(entries, stages), [entries, stages]);
const totalBytes = useMemo(
() => entries.reduce((sum, entry) => sum + entry.size, 0),
[entries],
);
const files = useMemo(() => groupArtifactsByFile(entries, stages), [entries, stages]);
if (files.length === 0) {
return (
<EmptyState
icon={PaperClipIcon}
title="No artifacts captured"
description="No stage in this run produced any artifacts."
/>
);
}
return <ArtifactList runId={runId} files={files} />;
}
function ArtifactList({ runId, files }: { runId: string; files: readonly ArtifactFile[] }) {
const { captures, latestBytes, storedBytes } = useMemo(() => {
let captures = 0;
let latestBytes = 0;
let storedBytes = 0;
for (const file of files) {
captures += file.versions.length;
latestBytes += file.versions[0].size;
for (const version of file.versions) storedBytes += version.size;
}
return { captures, latestBytes, storedBytes };
}, [files]);
// Only mention versions once some file actually has more than one.
const versioned = captures > files.length;
return (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<h2 className="text-sm font-medium text-fg">
{entries.length} {entries.length === 1 ? "artifact" : "artifacts"}
{files.length} {plural(files.length, "file", "files")}
{versioned && (
<span className="font-normal text-fg-muted">
{" "}
· {captures} {plural(captures, "version", "versions")}
</span>
)}
</h2>
<span className="text-xs tabular-nums text-fg-muted">
{formatBytes(totalBytes)} total
<span className="text-xs text-fg-muted tabular-nums">
{versioned
? `${formatBytes(latestBytes)} latest · ${formatBytes(storedBytes)} stored`
: `${formatBytes(latestBytes)} total`}
</span>
</div>
{groups.map((group) => (
<StageGroupCard key={group.key} runId={runId} group={group} />
))}
<section className="overflow-hidden rounded-md border border-line bg-panel-alt">
{files.map((file) => (
<ArtifactFileRow key={file.path} runId={runId} file={file} />
))}
</section>
</div>
);
}
function StageGroupCard({ runId, group }: { runId: string; group: StageGroup }) {
function ArtifactFileRow({ runId, file }: { runId: string; file: ArtifactFile }) {
const hasEarlier = file.versions.length > 1;
const latest = file.versions[0];
return (
<section className="overflow-hidden rounded-md border border-line bg-panel-alt">
<header className="flex items-baseline justify-between border-b border-line px-4 py-2.5">
<div className="flex items-baseline gap-2">
<h3 className="text-sm font-medium text-fg">{group.label}</h3>
{group.retry > 0 && (
<span className="rounded bg-overlay px-1.5 py-0.5 text-[11px] font-medium text-fg-3">
retry {group.retry}
<Disclosure as="div" className="border-t border-line first:border-t-0">
{({ open }) => (
<>
<div className="flex items-center gap-2 px-3 py-2.5 sm:gap-4 sm:px-4">
{hasEarlier ? (
<DisclosureButton className="group shrink-0 rounded-md p-1 text-fg-3 transition-colors hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500">
<span className="sr-only">
{open ? "Hide" : "Show"} earlier versions of {file.name}
</span>
<ChevronRightIcon
className="size-3.5 transition-transform group-data-open:rotate-90"
aria-hidden="true"
/>
</DisclosureButton>
) : (
<span className="size-5 shrink-0" aria-hidden="true" />
)}
<span className="min-w-0 flex-1" title={file.path}>
<span className="block truncate font-mono text-xs">
<span className="text-fg-muted">{file.dir}</span>
<span className="text-fg-2">{file.name}</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-fg-3 md:hidden">
<VersionLabel version={latest} />
</span>
</span>
{hasEarlier && (
<span className="hidden shrink-0 rounded-full bg-overlay-strong px-2 py-0.5 text-[11px] text-fg-3 lg:inline">
{file.versions.length}{" "}
{plural(file.versions.length, "version", "versions")}
</span>
)}
<span className="hidden max-w-48 shrink-0 truncate text-xs text-fg-3 md:inline">
<VersionLabel version={latest} />
</span>
<span className="shrink-0 text-xs text-fg-muted tabular-nums">
{formatBytes(latest.size)}
</span>
<DownloadLink runId={runId} file={file} version={latest} />
</div>
{hasEarlier && (
<DisclosurePanel
as="ul"
className="border-t border-line bg-black/15 py-1"
>
<EarlierVersions runId={runId} file={file} />
</DisclosurePanel>
)}
</div>
<span className="text-xs tabular-nums text-fg-muted">
{group.entries.length} {group.entries.length === 1 ? "file" : "files"}
{" · "}
{formatBytes(group.totalBytes)}
</span>
</header>
<ul className="divide-y divide-line">
{group.entries.map((entry) => (
<ArtifactRow
key={`${group.key}#${entry.relative_path}`}
runId={runId}
entry={entry}
/>
))}
</ul>
</section>
</>
)}
</Disclosure>
);
}
function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry }) {
function EarlierVersions({ runId, file }: { runId: string; file: ArtifactFile }) {
return (
<>
{file.versions.map((version, index) =>
index === 0 ? null : (
<li
key={`${version.stageId}#${version.retry}`}
className="flex items-center gap-2 py-1.5 pr-3 pl-10 hover:bg-overlay sm:gap-4 sm:pr-4 sm:pl-14"
>
<span className="min-w-0 flex-1 truncate text-xs text-fg-3">
<VersionLabel version={version} />
</span>
<span className="shrink-0 text-xs text-fg-muted tabular-nums">
{formatBytes(version.size)}
</span>
<SizeDelta delta={version.delta} />
<DownloadLink runId={runId} file={file} version={version} />
</li>
),
)}
</>
);
}
function VersionLabel({ version }: { version: ArtifactVersion }) {
const attempt = attemptLabel(version);
return (
<>
{version.stageLabel}
{attempt && <span className="ml-2 text-fg-muted">{attempt}</span>}
</>
);
}
function attemptLabel(version: ArtifactVersion): string | null {
return version.retry > 1 ? `attempt ${version.retry}` : null;
}
function SizeDelta({ delta }: { delta: number | null }) {
if (delta === null) {
return <span className="shrink-0 text-[11px] text-fg-muted tabular-nums">first</span>;
}
const tone = delta < 0 ? "text-amber" : "text-mint";
const sign = delta < 0 ? "−" : "+";
return (
<span className={`shrink-0 text-[11px] ${tone} tabular-nums`}>
{sign}
{formatBytes(Math.abs(delta))}
</span>
);
}
function DownloadLink({
runId,
file,
version,
}: {
runId: string;
file: ArtifactFile;
version: ArtifactVersion;
}) {
const href = stageArtifactDownloadUrl(
runId,
entry.stage_id,
entry.relative_path,
entry.retry,
version.stageId,
file.path,
version.retry,
);
const attempt = attemptLabel(version);
const source = attempt ? `${version.stageLabel}, ${attempt}` : version.stageLabel;
return (
<li className="flex items-center gap-4 px-4 py-2">
<span
className="flex-1 truncate font-mono text-xs text-fg-2"
title={entry.relative_path}
>
{entry.relative_path}
</span>
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{formatBytes(entry.size)}
</span>
<a
href={href}
download={basename(entry.relative_path)}
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
>
<ArrowDownTrayIcon className="size-3.5" aria-hidden="true" />
Download
</a>
</li>
<a
href={href}
download={file.name}
aria-label={`Download ${file.name} from ${source}`}
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
>
<ArrowDownTrayIcon className="size-3.5" aria-hidden="true" />
<span className="hidden sm:inline">Download</span>
</a>
);
}
function basename(path: string): string {
const idx = path.lastIndexOf("/");
return idx >= 0 ? path.slice(idx + 1) : path;
}
function errorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined;
}

View file

@ -0,0 +1,192 @@
import { describe, expect, test } from "bun:test";
import { StageHandler, StageState } from "@qltysh/fabro-api-client";
import type { RunArtifactEntry } from "@qltysh/fabro-api-client";
import type { Stage } from "../../lib/stage-sidebar";
import { groupArtifactsByFile, splitArtifactPath } from "./group";
function stage(nodeId: string, startedAt: string | null, visit = 1): Stage {
return {
id: `${nodeId}@${visit}`,
name: nodeId,
handler: StageHandler.AGENT,
nodeId,
visit,
graphVisit: null,
resumedFromStageId: null,
status: StageState.SUCCEEDED,
duration: "1s",
startedAt,
providerUsed: null,
};
}
function artifact(
nodeSlug: string,
path: string,
size: number,
retry = 1,
visit = 1,
): RunArtifactEntry {
return {
stage_id: `${nodeSlug}@${visit}`,
node_slug: nodeSlug,
retry,
relative_path: path,
size,
};
}
/** Mirrors run 01KYJ8ZR0N: one report rewritten by four stages. */
const REPORT = ".ai/reports/2026-07-27-wrk-002-instance-lifecycle.md";
const STAGES: Stage[] = [
stage("start", "2026-07-27T17:12:08Z"),
stage("plan", "2026-07-27T17:21:44Z"),
stage("implement_plan", "2026-07-27T17:44:18Z"),
stage("simplify", "2026-07-27T18:43:11Z"),
stage("consolidate_reviews", "2026-07-27T19:44:26Z"),
stage("fix_review_findings", "2026-07-27T19:49:22Z"),
];
describe("splitArtifactPath", () => {
test("splits a nested path into directory prefix and filename", () => {
expect(splitArtifactPath(".ai/reports/run.md")).toEqual({
dir: ".ai/reports/",
name: "run.md",
});
});
test("leaves a root-level path without a directory", () => {
expect(splitArtifactPath("README.md")).toEqual({ dir: "", name: "README.md" });
});
});
describe("groupArtifactsByFile", () => {
test("collapses repeated captures of one path into a single file", () => {
const files = groupArtifactsByFile(
[
artifact("consolidate_reviews", REPORT, 14323),
artifact("fix_review_findings", REPORT, 17483),
artifact("implement_plan", REPORT, 8422),
artifact("simplify", REPORT, 13162),
],
STAGES,
);
expect(files).toHaveLength(1);
expect(files[0].path).toBe(REPORT);
expect(files[0].dir).toBe(".ai/reports/");
expect(files[0].name).toBe("2026-07-27-wrk-002-instance-lifecycle.md");
expect(files[0].versions).toHaveLength(4);
});
test("orders versions newest first using the API stage order", () => {
const stages = [
stage("implement_plan", "2026-07-27T20:00:00Z"),
stage("simplify", "2026-07-27T18:43:11Z"),
];
const files = groupArtifactsByFile(
[
artifact("implement_plan", REPORT, 8422),
artifact("simplify", REPORT, 13162),
],
stages,
);
expect(files[0].versions.map((v) => v.stageLabel)).toEqual([
"simplify",
"implement_plan",
]);
expect(files[0].versions[0].size).toBe(13162);
});
test.each([
["equal", "2026-07-27T18:43:11Z", "2026-07-27T18:43:11Z"],
["missing", null, null],
])("preserves API order when stage timestamps are %s", (_case, firstAt, secondAt) => {
const stages = [
stage("implement_plan", firstAt),
stage("simplify", secondAt),
];
const files = groupArtifactsByFile(
[
artifact("simplify", REPORT, 13162),
artifact("implement_plan", REPORT, 8422),
],
stages,
);
expect(files[0].versions.map((v) => v.stageLabel)).toEqual([
"simplify",
"implement_plan",
]);
});
test("reports the byte change each capture introduced, oldest capture first", () => {
const files = groupArtifactsByFile(
[
artifact("implement_plan", REPORT, 8422),
artifact("simplify", REPORT, 13162),
artifact("consolidate_reviews", REPORT, 14323),
artifact("fix_review_findings", REPORT, 17483),
],
STAGES,
);
// versions are newest-first, so deltas read 17483-14323, 14323-13162, ...
expect(files[0].versions.map((v) => v.delta)).toEqual([3160, 1161, 4740, null]);
});
test("drops captures from graph control nodes", () => {
const files = groupArtifactsByFile(
[
artifact("start", ".ai/reports/pre-existing.md", 12402),
artifact("plan", ".ai/plans/plan.md", 21749),
],
STAGES,
);
expect(files.map((file) => file.path)).toEqual([".ai/plans/plan.md"]);
});
test("sorts files by their most recent capture", () => {
const files = groupArtifactsByFile(
[
artifact("plan", ".ai/plans/plan.md", 21749),
artifact("fix_review_findings", REPORT, 17483),
artifact("simplify", ".ai/reviews/bugs.xml", 5231),
],
STAGES,
);
expect(files.map((file) => file.path)).toEqual([
REPORT,
".ai/reviews/bugs.xml",
".ai/plans/plan.md",
]);
});
test("keeps retries of one stage as separate ordered versions", () => {
const files = groupArtifactsByFile(
[
artifact("simplify", REPORT, 13162, 2),
artifact("simplify", REPORT, 9000, 1),
],
STAGES,
);
expect(files[0].versions.map((v) => v.retry)).toEqual([2, 1]);
expect(files[0].versions[0].size).toBe(13162);
expect(files[0].versions.map((v) => v.delta)).toEqual([4162, null]);
});
test("returns no files when every capture came from a control node", () => {
const files = groupArtifactsByFile(
[artifact("start", ".ai/reports/pre-existing.md", 12402)],
STAGES,
);
expect(files).toEqual([]);
});
});

View file

@ -0,0 +1,104 @@
import type { RunArtifactEntry } from "@qltysh/fabro-api-client";
import { isVisibleStage } from "../../data/runs";
import type { Stage } from "../../lib/stage-sidebar";
import { formatStageLabel } from "../../lib/stage-sidebar";
/** One capture of a file, written by a single stage attempt. */
export interface ArtifactVersion {
stageId: string;
stageLabel: string;
retry: number;
size: number;
/** Byte change this capture introduced; null for the first capture. */
delta: number | null;
}
/** One artifact path together with its capture history, newest first. */
export interface ArtifactFile {
path: string;
/** Directory prefix including the trailing slash, or "" at the root. */
dir: string;
name: string;
versions: readonly [ArtifactVersion, ...ArtifactVersion[]];
}
export function splitArtifactPath(path: string): { dir: string; name: string } {
const idx = path.lastIndexOf("/");
return idx >= 0
? { dir: path.slice(0, idx + 1), name: path.slice(idx + 1) }
: { dir: "", name: path };
}
interface StageInfo {
label: string;
order: number;
}
/** Stage display data keyed by ID, preserving the API's event order. */
function stageInfoById(stages: readonly Stage[]): Map<string, StageInfo> {
const info = new Map<string, StageInfo>();
stages.forEach((stage, order) => {
info.set(stage.id, { label: formatStageLabel(stage), order });
});
return info;
}
/**
* Collapse raw `(stage, retry, path)` capture keys into one entry per file,
* carrying the ordered history of every capture of that path.
*
* Captures from graph control nodes (`start`, `exit`) are dropped: those nodes
* run no work, so anything they match is a pre-existing workspace file rather
* than something the run produced.
*/
export function groupArtifactsByFile(
entries: readonly RunArtifactEntry[],
stages: readonly Stage[],
): ArtifactFile[] {
const stageInfo = stageInfoById(stages);
const byPath = new Map<string, [ArtifactVersion, ...ArtifactVersion[]]>();
for (const entry of entries) {
if (!isVisibleStage(entry.node_slug)) continue;
const info = stageInfo.get(entry.stage_id);
const version: ArtifactVersion = {
stageId: entry.stage_id,
stageLabel: info?.label ?? entry.node_slug,
retry: entry.retry,
size: entry.size,
delta: null,
};
const bucket = byPath.get(entry.relative_path);
if (bucket) bucket.push(version);
else byPath.set(entry.relative_path, [version]);
}
const files: Array<{ file: ArtifactFile; order: number }> = [];
for (const [path, versions] of byPath) {
// Oldest first, so each version's delta is the change that capture introduced.
versions.sort(
(a, b) =>
(stageInfo.get(a.stageId)?.order ?? -1) -
(stageInfo.get(b.stageId)?.order ?? -1) ||
a.retry - b.retry ||
a.stageId.localeCompare(b.stageId),
);
versions.forEach((version, index) => {
version.delta = index === 0 ? null : version.size - versions[index - 1].size;
});
versions.reverse();
const latest = versions[0];
const { dir, name } = splitArtifactPath(path);
files.push({
file: { path, dir, name, versions },
order: stageInfo.get(latest.stageId)?.order ?? -1,
});
}
// Most recently written file first — the page answers "what just happened?".
files.sort((a, b) => b.order - a.order || a.file.path.localeCompare(b.file.path));
return files.map((entry) => entry.file);
}

View file

@ -2,11 +2,12 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
import TestRenderer from "react-test-renderer";
import type {
BilledTokenCounts,
RunBilling,
StageTiming,
} from "@qltysh/fabro-api-client";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
function stageTiming(wall_time_ms = 0, inference_time_ms = 0, tool_time_ms = 0): StageTiming {
return {
wall_time_ms,
@ -24,25 +25,12 @@ mock.module("../lib/queries", () => ({
const { default: RunBillingRoute } = await import("./run-billing");
function zeroBilling(overrides: Partial<BilledTokenCounts> = {}): BilledTokenCounts {
return {
cache_read_tokens: 0,
cache_write_tokens: 0,
input_tokens: 0,
output_tokens: 0,
reasoning_tokens: 0,
total_tokens: 0,
total_usd_micros: null,
...overrides,
};
}
function billing(overrides: Partial<RunBilling> = {}): RunBilling {
return {
stages: [],
totals: {
timing: stageTiming(),
...zeroBilling(),
...makeBilledTokenCounts(),
},
by_model: [],
...overrides,
@ -86,21 +74,21 @@ describe("RunBilling", () => {
{
stage: { id: "start", name: "start" },
model: null,
billing: zeroBilling(),
billing: makeBilledTokenCounts(),
timing: stageTiming(),
state: "succeeded",
},
{
stage: { id: "command", name: "command" },
model: null,
billing: zeroBilling(),
billing: makeBilledTokenCounts(),
timing: stageTiming(61000),
state: "succeeded",
},
],
totals: {
timing: stageTiming(61000),
...zeroBilling(),
...makeBilledTokenCounts(),
},
}),
);
@ -121,7 +109,7 @@ describe("RunBilling", () => {
{
stage: { id: "start", name: "start" },
model: null,
billing: zeroBilling(),
billing: makeBilledTokenCounts(),
timing: stageTiming(),
state: "succeeded",
},
@ -131,7 +119,7 @@ describe("RunBilling", () => {
provider: "anthropic",
model_id: "claude-sonnet-4-5",
},
billing: zeroBilling({
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -143,7 +131,7 @@ describe("RunBilling", () => {
],
totals: {
timing: stageTiming(42000),
...zeroBilling({
...makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -157,7 +145,7 @@ describe("RunBilling", () => {
model_id: "claude-sonnet-4-5",
},
stages: 1,
billing: zeroBilling({
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -204,7 +192,7 @@ describe("RunBilling", () => {
model_id: "claude-opus-4-6",
speed: "fast",
},
billing: zeroBilling({
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -217,7 +205,7 @@ describe("RunBilling", () => {
],
totals: {
timing: stageTiming(),
...zeroBilling({
...makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -232,7 +220,7 @@ describe("RunBilling", () => {
speed: "fast",
},
stages: 1,
billing: zeroBilling({
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -268,4 +256,4 @@ describe("RunBilling", () => {
Date.now = originalNow;
}
});
});
});

View file

@ -2,6 +2,11 @@ import { Fragment, useMemo } from "react";
import { EmptyState } from "../components/state";
import { Tooltip } from "../components/ui";
import {
billableOutputTokens,
billingTokenBuckets,
hasBillingUsage,
} from "../lib/billing";
import {
formatDurationMs,
formatTokenCount,
@ -11,6 +16,7 @@ import { useRunBilling } from "../lib/queries";
import { IN_FLIGHT_STAGE_STATES } from "../lib/stage-sidebar";
import { useTickingNow } from "../lib/time";
import type {
BilledTokenCounts,
BillingModelRef,
RunBilling,
RunBillingStage,
@ -39,23 +45,15 @@ function isInFlight(stage: RunBillingStage): boolean {
function isVisibleRow(row: MappedStageRow): boolean {
if (row.inFlight) return true;
return (
(row.inputTokens ?? 0) > 0 ||
(row.outputTokens ?? 0) > 0 ||
(row.totalUsdMicros ?? 0) > 0
);
return row.billing != null && hasBillingUsage(row.billing);
}
interface MappedStageRow {
stage: string;
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
cacheReadTokens: number | null;
cacheWriteTokens: number | null;
wallTimeMs: number;
totalUsdMicros: number | null | undefined;
inFlight: boolean;
stage: string;
model: string | null;
billing: BilledTokenCounts | null;
wallTimeMs: number;
inFlight: boolean;
}
function liveWallTimeMs(stage: RunBillingStage, now: number): number {
@ -73,49 +71,28 @@ export const handle = { wide: true };
function mapStageRow(stage: RunBillingStage, wallTimeMs: number): MappedStageRow {
const hasModel = stage.model != null;
return {
stage: stage.stage.name,
model: formatModelRef(stage.model),
inputTokens: hasModel ? stage.billing.input_tokens : null,
outputTokens: hasModel
? stage.billing.output_tokens + stage.billing.reasoning_tokens
: null,
cacheReadTokens: hasModel ? stage.billing.cache_read_tokens : null,
cacheWriteTokens: hasModel ? stage.billing.cache_write_tokens : null,
stage: stage.stage.name,
model: formatModelRef(stage.model),
billing: hasModel ? stage.billing : null,
wallTimeMs,
totalUsdMicros: stage.billing.total_usd_micros,
inFlight: isInFlight(stage),
inFlight: isInFlight(stage),
};
}
/** Hover breakdown of the disjoint token buckets behind an `in / out` count. */
function TokenBreakdown({
cacheReadTokens,
cacheWriteTokens,
inputTokens,
outputTokens,
}: {
cacheReadTokens: number;
cacheWriteTokens: number;
inputTokens: number;
outputTokens: number;
}) {
const rows = [
{ label: "Cache read", value: cacheReadTokens },
{ label: "Cache creation", value: cacheWriteTokens },
{ label: "Uncached", value: inputTokens },
{ label: "Output", value: outputTokens },
];
function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) {
const buckets = billingTokenBuckets(billing);
return (
<div className="min-w-44 py-0.5">
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
<div className="border-line text-fg-2 mb-1.5 border-b pb-1 font-medium">
Tokens in / out
</div>
<dl className="grid grid-cols-[1fr_auto] gap-x-6 gap-y-1">
{rows.map((row) => (
<Fragment key={row.label}>
<dt className="text-fg-3">{row.label}</dt>
<dd className="text-right font-mono tabular-nums text-fg">
{formatTokens(row.value)}
{buckets.map((bucket) => (
<Fragment key={bucket.label}>
<dt className="text-fg-3">{bucket.label}</dt>
<dd className="text-fg text-right font-mono tabular-nums">
{formatTokens(bucket.value)}
</dd>
</Fragment>
))}
@ -128,42 +105,16 @@ function TokenBreakdown({
* Renders an `input / output` token count. When the row has model usage,
* hovering the count reveals the cache breakdown.
*/
function TokensCell({
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
}: {
inputTokens: number | null;
outputTokens: number | null;
cacheReadTokens: number | null;
cacheWriteTokens: number | null;
}) {
function TokensCell({ billing }: { billing: BilledTokenCounts | null }) {
const display = (
<>
{formatTokens(inputTokens)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(outputTokens)}
{formatTokens(billing?.input_tokens)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(billing ? billableOutputTokens(billing) : null)}
</>
);
if (
inputTokens == null ||
outputTokens == null ||
cacheReadTokens == null ||
cacheWriteTokens == null
) {
return display;
}
if (!billing) return display;
return (
<Tooltip
label={
<TokenBreakdown
cacheReadTokens={cacheReadTokens}
cacheWriteTokens={cacheWriteTokens}
inputTokens={inputTokens}
outputTokens={outputTokens}
/>
}
>
<Tooltip label={<TokenBreakdown billing={billing} />}>
<span>{display}</span>
</Tooltip>
);
@ -189,15 +140,14 @@ export default function RunBilling({ params }: { params: { id: string } }) {
if (!billing) return [];
return billing.by_model
.map((entry) => ({
model: formatModelRef(entry.model) ?? EMPTY_VALUE,
stages: entry.stages,
inputTokens: entry.billing.input_tokens,
outputTokens: entry.billing.output_tokens + entry.billing.reasoning_tokens,
cacheReadTokens: entry.billing.cache_read_tokens,
cacheWriteTokens: entry.billing.cache_write_tokens,
totalUsdMicros: entry.billing.total_usd_micros,
model: formatModelRef(entry.model) ?? EMPTY_VALUE,
stages: entry.stages,
billing: entry.billing,
}))
.sort((a, b) => (b.totalUsdMicros ?? -1) - (a.totalUsdMicros ?? -1));
.sort(
(a, b) =>
(b.billing.total_usd_micros ?? -1) - (a.billing.total_usd_micros ?? -1),
);
}, [billing]);
// Re-derive only the in-flight rows on each tick; everything else stays put.
@ -218,16 +168,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
: (billing?.totals.timing.wall_time_ms ?? 0);
const hasLlmStages = (billing?.by_model.length ?? 0) > 0;
const totalInput = hasLlmStages ? (billing?.totals.input_tokens ?? null) : null;
const totalOutput = hasLlmStages && billing
? billing.totals.output_tokens + billing.totals.reasoning_tokens
: null;
const totalCacheRead = hasLlmStages
? (billing?.totals.cache_read_tokens ?? null)
: null;
const totalCacheWrite = hasLlmStages
? (billing?.totals.cache_write_tokens ?? null)
: null;
const totalBilling = hasLlmStages && billing ? billing.totals : null;
const totalUsdMicros = billing?.totals.total_usd_micros;
const modelStageCount = modelBreakdown.reduce((sum, row) => sum + row.stages, 0);
const visibleRows = rows.filter(isVisibleRow);
@ -268,18 +209,13 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{row.model ?? EMPTY_VALUE}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
<TokensCell
inputTokens={row.inputTokens}
outputTokens={row.outputTokens}
cacheReadTokens={row.cacheReadTokens}
cacheWriteTokens={row.cacheWriteTokens}
/>
<TokensCell billing={row.billing} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatDurationMs(row.wallTimeMs)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicrosOrDash(row.totalUsdMicros)}
{formatUsdMicrosOrDash(row.billing?.total_usd_micros)}
</td>
</tr>
))}
@ -289,12 +225,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
<td className="px-4 py-3 font-medium text-fg">Total</td>
<td className="px-4 py-3 text-xs text-fg-muted">All models</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
<TokensCell
inputTokens={totalInput}
outputTokens={totalOutput}
cacheReadTokens={totalCacheRead}
cacheWriteTokens={totalCacheWrite}
/>
<TokensCell billing={totalBilling} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatDurationMs(totalWallTimeMs)}
@ -328,15 +259,10 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{row.stages}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
<TokensCell
inputTokens={row.inputTokens}
outputTokens={row.outputTokens}
cacheReadTokens={row.cacheReadTokens}
cacheWriteTokens={row.cacheWriteTokens}
/>
<TokensCell billing={row.billing} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicrosOrDash(row.totalUsdMicros)}
{formatUsdMicrosOrDash(row.billing.total_usd_micros)}
</td>
</tr>
))}
@ -348,12 +274,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{modelStageCount}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
<TokensCell
inputTokens={totalInput}
outputTokens={totalOutput}
cacheReadTokens={totalCacheRead}
cacheWriteTokens={totalCacheWrite}
/>
<TokensCell billing={totalBilling} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatUsdMicrosOrDash(totalUsdMicros)}

View file

@ -183,13 +183,33 @@ import {
lifecycleActionVisibility,
} from "./run-detail/lifecycle-toasts";
const { default: RunDetail } = await import("./run-detail");
const {
default: RunDetail,
resolveDockClearance,
} = await import("./run-detail");
mock.restore();
type LifecycleToastState = import("./run-detail/lifecycle-toasts").LifecycleToastState;
type RunDetailActionResult = import("./run-detail/lifecycle-toasts").RunDetailActionResult;
const h = createElement;
describe("resolveDockClearance", () => {
test("uses only a current dock measurement", () => {
const measurement = { identity: "run_1:steer", height: 108 };
expect(resolveDockClearance(null, measurement, false)).toBe("0px");
expect(
resolveDockClearance("run_1:interview", measurement, true),
).toBe("18rem");
expect(resolveDockClearance("run_2:steer", measurement, false)).toBe(
"5rem",
);
expect(resolveDockClearance("run_1:steer", measurement, false)).toBe(
"108px",
);
});
});
function makeRunSummary({
status = "succeeded",
diffSummary = null as any,
@ -841,7 +861,7 @@ describe("RunDetail full-height child routes", () => {
});
const statuses = renderer.root.findAll(
(node) => node.type === "p" && node.props.role === "status",
(node) => node.props.role === "status",
);
expect(statuses.map(textFromTestNode)).toContain(
"Interrupted — waiting for steering",

View file

@ -19,6 +19,7 @@ import {
} from "../components/ui";
import { mutateRunListCaches } from "../lib/board-cache";
import { useDemoMode } from "../lib/demo-mode";
import { useTickingNow } from "../lib/time";
import { useSWRConfig } from "swr";
import {
useArchiveRun,
@ -56,10 +57,7 @@ import {
lifecycleActionVisibility,
updateLifecycleToastState,
} from "./run-detail/lifecycle-toasts";
import {
buildRunDetailRun,
useTickingNow,
} from "./run-detail/model";
import { buildRunDetailRun } from "./run-detail/model";
import {
buildRunDetailTabs,
childRouteLayoutFlags,
@ -69,8 +67,27 @@ import {
export const handle = { hideHeader: true };
const RUN_TIMING_REFRESH_INTERVAL_MS = 30_000;
type LifecycleTrigger = () => Promise<LifecycleMutationResult | undefined>;
export interface DockMeasurement {
identity: string;
height: number;
}
export function resolveDockClearance(
dockIdentity: string | null,
measurement: DockMeasurement | null,
hasPendingQuestions: boolean,
): string {
if (dockIdentity === null) return "0px";
if (measurement?.identity === dockIdentity) {
return `${measurement.height}px`;
}
return hasPendingQuestions ? "18rem" : "5rem";
}
export function meta({ data }: any) {
const run = data?.run;
return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }];
@ -78,7 +95,7 @@ export function meta({ data }: any) {
export default function RunDetail({ params }: { params: { id: string } }) {
const demoMode = useDemoMode();
const runQuery = useRun(params.id);
const runQuery = useRun(params.id, RUN_TIMING_REFRESH_INTERVAL_MS);
const runStateQuery = useRunState(params.id);
const summary = runQuery.data;
const run = summary ? buildRunDetailRun(summary) : null;
@ -102,6 +119,8 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const { mutate } = useSWRConfig();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletePending, setDeletePending] = useState(false);
const [dockMeasurement, setDockMeasurement] =
useState<DockMeasurement | null>(null);
const { push, dismiss } = useToast();
const lifecycleToastStateRef = useRef(createLifecycleToastState());
const filesCount = runQuery.data?.diff?.files_changed ?? null;
@ -116,7 +135,10 @@ export default function RunDetail({ params }: { params: { id: string } }) {
childrenCount,
});
const steerBarRef = useRef<SteerBarHandle | null>(null);
const now = useTickingNow(30_000);
const now = useTickingNow(
summary != null && summary.timestamps.completed_at == null,
RUN_TIMING_REFRESH_INTERVAL_MS,
);
const { fullHeight, hideSteerBar } = childRouteLayoutFlags(matches);
useRunEvents(params.id);
@ -144,6 +166,15 @@ export default function RunDetail({ params }: { params: { id: string } }) {
},
[handleLifecycleMutationResult],
);
const handleDockHeightChange = useCallback(
(identity: string, height: number | null) => {
setDockMeasurement((current) => {
if (height !== null) return { identity, height };
return current?.identity === identity ? null : current;
});
},
[],
);
if (runQuery.isLoading && !run) {
return <div className="py-12" />;
@ -305,7 +336,19 @@ export default function RunDetail({ params }: { params: { id: string } }) {
: []),
],
};
const dockClearance = hasPendingQuestions ? "18rem" : "5rem";
// Reserve exactly the dock's rendered height. The constants are only the
// first frame, before the dock has been measured; a fixed reservation lets
// a tall question panel cover the content it is asking about.
const dockIdentity = hasPendingQuestions
? `${params.id}:interview`
: hideSteerBar
? null
: `${params.id}:steer`;
const dockClearance = resolveDockClearance(
dockIdentity,
dockMeasurement,
hasPendingQuestions,
);
const rootStyle = {
"--fabro-interview-dock-clearance": dockClearance,
} as CSSProperties;
@ -364,14 +407,16 @@ export default function RunDetail({ params }: { params: { id: string } }) {
/>
<RunDetailDockedControls
key={dockIdentity ?? "hidden"}
runId={params.id}
hideSteerBar={hideSteerBar}
dockIdentity={dockIdentity}
hasPendingQuestions={hasPendingQuestions}
pendingQuestions={pendingQuestions}
sidebarWidth={sidebarWidth}
isResizing={isResizing}
steerBarRef={steerBarRef}
waitingForSteer={waitingForSteer}
onHeightChange={handleDockHeightChange}
/>
</div>
)}

View file

@ -0,0 +1,158 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";
import { createElement, createRef } from "react";
import TestRenderer, { act } from "react-test-renderer";
import { setupReactTestEnv } from "../../lib/test-utils";
mock.module("../../components/interview-dock", () => ({
InterviewDock: () => createElement("div", null, "Interview"),
}));
mock.module("../../components/steer-bar", () => ({
SteerBar: () => createElement("div", null, "Steer"),
}));
const { RunDetailDockedControls } = await import("./docked-controls");
mock.restore();
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
const observerCallbacks: ResizeObserverCallback[] = [];
const dockNode = { offsetHeight: 999 };
let originalResizeObserver: typeof ResizeObserver | undefined;
let teardownReactEnv: (() => void) | undefined;
function resizeEntry(blockSize: number): ResizeObserverEntry {
return {
borderBoxSize: [{ blockSize, inlineSize: 600 }],
} as unknown as ResizeObserverEntry;
}
function renderDock({
identity,
hasPendingQuestions = false,
onHeightChange,
}: {
identity: string | null;
hasPendingQuestions?: boolean;
onHeightChange: (identity: string, height: number | null) => void;
}) {
return (
<RunDetailDockedControls
key={identity ?? "hidden"}
runId="run-1"
dockIdentity={identity}
hasPendingQuestions={hasPendingQuestions}
pendingQuestions={[]}
sidebarWidth={0}
isResizing={false}
steerBarRef={createRef()}
waitingForSteer={false}
onHeightChange={onHeightChange}
/>
);
}
beforeEach(() => {
teardownReactEnv = setupReactTestEnv();
observerCallbacks.length = 0;
originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = class ResizeObserver {
constructor(callback: ResizeObserverCallback) {
observerCallbacks.push(callback);
}
observe() {}
unobserve() {}
disconnect() {}
} as typeof ResizeObserver;
});
afterEach(() => {
for (const renderer of mountedRenderers.splice(0)) {
act(() => renderer.unmount());
}
if (originalResizeObserver) {
globalThis.ResizeObserver = originalResizeObserver;
} else {
delete (globalThis as { ResizeObserver?: typeof ResizeObserver })
.ResizeObserver;
}
teardownReactEnv?.();
teardownReactEnv = undefined;
});
describe("RunDetailDockedControls", () => {
test("reports border-box height only when the height changes", () => {
const onHeightChange = mock(
(_identity: string, _height: number | null) => undefined,
);
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
renderDock({ identity: "run-1:steer", onHeightChange }),
{
createNodeMock: () => dockNode,
},
);
});
mountedRenderers.push(renderer);
const callback = observerCallbacks[0]!;
act(() => callback([resizeEntry(108)], {} as ResizeObserver));
act(() => callback([resizeEntry(108)], {} as ResizeObserver));
act(() => callback([resizeEntry(120)], {} as ResizeObserver));
expect(onHeightChange).toHaveBeenCalledTimes(2);
expect(onHeightChange.mock.calls).toEqual([
["run-1:steer", 108],
["run-1:steer", 120],
]);
});
test("clears the old identity when the dock changes or hides", () => {
const onHeightChange = mock(
(_identity: string, _height: number | null) => undefined,
);
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
renderDock({ identity: "run-1:steer", onHeightChange }),
{
createNodeMock: () => dockNode,
},
);
});
mountedRenderers.push(renderer);
act(() =>
observerCallbacks[0]!([resizeEntry(108)], {} as ResizeObserver),
);
act(() => {
renderer.update(
renderDock({
identity: "run-1:interview",
hasPendingQuestions: true,
onHeightChange,
}),
);
});
act(() =>
observerCallbacks[1]!([resizeEntry(220)], {} as ResizeObserver),
);
act(() => {
renderer.update(renderDock({ identity: null, onHeightChange }));
});
expect(onHeightChange.mock.calls).toEqual([
["run-1:steer", 108],
["run-1:steer", null],
["run-1:interview", 220],
["run-1:interview", null],
]);
});
});

View file

@ -1,10 +1,14 @@
import {
useCallback,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { SparklesIcon } from "@heroicons/react/20/solid";
import { useResizeObserver } from "../../hooks/effects";
import AskFabroSidebar, {
SIDEBAR_WIDTH,
} from "../../components/chats/ask-fabro-sidebar";
@ -14,12 +18,12 @@ import {
SECONDARY_BUTTON_CLASS,
Tooltip,
} from "../../components/ui";
import { classNames } from "../../lib/class-names";
import {
AskFabroUnavailableReasonEnum,
type ApiQuestion,
type AskFabro,
} from "@qltysh/fabro-api-client";
import { classNames } from "./model";
const ASK_FABRO_UNAVAILABLE_TOOLTIPS: Record<
AskFabroUnavailableReasonEnum,
@ -127,32 +131,73 @@ function AskFabroTriggerButton({
export function RunDetailDockedControls({
runId,
hideSteerBar,
dockIdentity,
hasPendingQuestions,
pendingQuestions,
sidebarWidth,
isResizing,
steerBarRef,
waitingForSteer,
onHeightChange,
}: {
runId: string;
hideSteerBar: boolean;
dockIdentity: string | null;
hasPendingQuestions: boolean;
pendingQuestions: ApiQuestion[];
sidebarWidth: number;
isResizing: boolean;
steerBarRef: RefObject<SteerBarHandle | null>;
waitingForSteer: boolean;
/**
* Reports the dock's rendered height so the page can reserve exactly that
* much room beneath the scrolling content.
*/
onHeightChange: (identity: string, height: number | null) => void;
}) {
if (hideSteerBar && !hasPendingQuestions) return null;
const dockRef = useRef<HTMLDivElement | null>(null);
const reportedHeightRef = useRef<number | null>(null);
const visible = dockIdentity !== null;
const reportHeight = useCallback(
(height: number | null) => {
if (dockIdentity === null || reportedHeightRef.current === height) return;
reportedHeightRef.current = height;
onHeightChange(dockIdentity, height);
},
[dockIdentity, onHeightChange],
);
const setDockRef = useCallback(
(node: HTMLDivElement | null) => {
dockRef.current = node;
if (node === null) reportHeight(null);
},
[reportHeight],
);
useResizeObserver(
dockRef,
(entries) => {
const entry = entries[0];
if (!entry) return;
const borderBoxSize = Array.isArray(entry.borderBoxSize)
? entry.borderBoxSize[0]
: (entry.borderBoxSize as unknown as ResizeObserverSize);
reportHeight(
borderBoxSize?.blockSize ?? dockRef.current?.offsetHeight ?? null,
);
},
visible,
);
if (!visible) return null;
return (
<div
className={`fixed bottom-0 left-0 z-30 border-t border-line bg-page ${
isResizing
? ""
: "transition-[right] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]"
}`}
ref={setDockRef}
className={classNames(
"fixed bottom-0 left-0 z-30 border-t border-line bg-page",
!isResizing &&
"transition-[right] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]",
)}
style={{ right: sidebarWidth }}
>
{hasPendingQuestions ? (

View file

@ -35,10 +35,11 @@ import {
formatDurationMs,
formatRelativeTime,
} from "../../lib/format";
import { classNames } from "../../lib/class-names";
import { useRunPullRequest } from "../../lib/queries";
import { sandboxRuntime } from "../../lib/run-sandbox-lifecycle";
import { ActionsMenu, type ActionsMenuProps } from "./actions";
import { classNames, type RunDetailRun } from "./model";
import type { RunDetailRun } from "./model";
export interface RunDetailHeaderActions {
approval: {
@ -326,6 +327,7 @@ function DurationPopover({
}) {
const endMs = completedAt != null ? Date.parse(completedAt) : now;
const sinceCreatedMs = Math.max(0, endMs - Date.parse(createdAt));
const isRunning = completedAt == null;
return (
<>
<PopoverHeader>Duration</PopoverHeader>
@ -335,8 +337,14 @@ function DurationPopover({
<dd className="mt-0.5 font-mono text-fg">{formatDurationMs(sinceCreatedMs)}</dd>
</div>
<div>
<dt className="text-fg-3">Active (inference + tools)</dt>
<dt className="text-fg-3">
Active (inference + tools){isRunning ? " — estimated" : ""}
</dt>
<dd className="mt-0.5 font-mono text-fg">{formatDurationMs(timing.active_time_ms)}</dd>
<dd className="mt-0.5 text-fg-3">
{formatDurationMs(timing.inference_time_ms)} inference ·{" "}
{formatDurationMs(timing.tool_time_ms)} tools
</dd>
</div>
</dl>
</>

View file

@ -1,6 +1,3 @@
import { useState } from "react";
import { useInterval } from "../../hooks/effects";
import {
isRunStatus,
mapRunToRunItem,
@ -8,16 +5,6 @@ import {
type Run,
} from "../../data/runs";
export function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
export function useTickingNow(intervalMs: number): number {
const [now, setNow] = useState(() => Date.now());
useInterval(() => setNow(Date.now()), intervalMs);
return now;
}
export type RunDetailRun = ReturnType<typeof mapRunToRunItem> & {
statusLabel: string;
statusDot: string;

View file

@ -1,7 +1,7 @@
import { Link, Outlet, type UIMatch } from "react-router";
import { classNames } from "../../lib/class-names";
import { sandboxTabVisible, type MaybeSandbox } from "../../lib/run-sandbox-lifecycle";
import { classNames } from "./model";
interface RunDetailTabDefinition {
name: string;

View file

@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { StageState } from "@qltysh/fabro-api-client";
import type { Stage } from "../lib/stage-sidebar";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
import { StageChatView } from "./run-stages";
function stage(overrides: Partial<Stage> = {}): Stage {
@ -18,6 +19,7 @@ function stage(overrides: Partial<Stage> = {}): Stage {
resumedFromStageId: null,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
billing: makeBilledTokenCounts(),
...overrides,
};
}

View file

@ -1,9 +1,14 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReasoningOutput } from "@qltysh/fabro-api-client";
import type {
BilledTokenCounts,
ReasoningOutput,
StageModelUsage,
} from "@qltysh/fabro-api-client";
import { EventDetails } from "./run-stages";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
import { EventDetails, ModelUsagePopover } from "./run-stages";
const RUN_START = "2026-04-09T12:00:00Z";
@ -72,3 +77,77 @@ describe("EventDetails reasoning", () => {
expect(html).toContain(`${"x".repeat(280)}…`);
});
});
const PROVIDER_USED: StageModelUsage = {
mode: "agent",
provider: "moonshot",
model: "kimi-k3",
reasoning_effort: "max",
};
function popoverMarkup(counts: BilledTokenCounts): string {
return renderToStaticMarkup(
<ModelUsagePopover providerUsed={PROVIDER_USED} billing={counts} />,
);
}
describe("ModelUsagePopover billing", () => {
test("shows the visit's token buckets and cost next to the model", () => {
const html = popoverMarkup(
makeBilledTokenCounts({
input_tokens: 28_640,
output_tokens: 7_550,
reasoning_tokens: 1_200,
cache_read_tokens: 4_800,
cache_write_tokens: 1_500,
total_tokens: 43_690,
total_usd_micros: 720_000,
}),
);
expect(html).toContain("kimi-k3");
expect(html).toContain("Cache read");
expect(html).toContain("4.8k");
expect(html).toContain("Cache creation");
expect(html).toContain("1.5k");
expect(html).toContain("Uncached");
expect(html).toContain("28.6k");
// Output folds in reasoning tokens, matching the Billing tab.
expect(html).toContain("Output");
expect(html).toContain("8.8k");
expect(html).toContain("Cost");
expect(html).toContain("$0.72");
});
test("omits the token section for a stage that called no model", () => {
const html = popoverMarkup(makeBilledTokenCounts());
expect(html).toContain("kimi-k3");
expect(html).not.toContain("Tokens");
expect(html).not.toContain("Cost");
});
test("still shows tokens when nothing priced the stage", () => {
const html = popoverMarkup(
makeBilledTokenCounts({
input_tokens: 1_000,
output_tokens: 500,
total_tokens: 1_500,
}),
);
expect(html).toContain("Uncached");
expect(html).toContain("1.0k");
expect(html).not.toContain("Cost");
});
test("shows a provider-reported cost when token counts are unavailable", () => {
const html = popoverMarkup(
makeBilledTokenCounts({ total_usd_micros: 720_000 }),
);
expect(html).toContain("kimi-k3");
expect(html).toContain("Cost");
expect(html).toContain("$0.72");
});
});

View file

@ -67,7 +67,9 @@ import {
formatBytes,
formatDurationMs,
formatTokenCount,
formatUsdMicros,
} from "../lib/format";
import { billingTokenBuckets, hasBillingUsage } from "../lib/billing";
import { plural } from "../lib/plural";
import {
useRun,
@ -93,6 +95,7 @@ import {
type UnknownRecord,
} from "../lib/unknown";
import type {
BilledTokenCounts,
EventEnvelope,
ReasoningOutput,
StageHandler,
@ -866,10 +869,42 @@ export function formatStageModelUsageLabel(
return effort ? `${model}[${effort}]` : model;
}
function ModelUsagePopover({
const POPOVER_NUMBER = "block text-right font-mono tabular-nums";
/** Tokens and cost for this stage visit alone. */
function StageBillingRows({ billing }: { billing: BilledTokenCounts }) {
if (!hasBillingUsage(billing)) return null;
const buckets = billingTokenBuckets(billing);
const cost = formatUsdMicros(billing.total_usd_micros);
return (
<div className="mt-3">
<PopoverHeader>Tokens</PopoverHeader>
<PopoverRows>
{buckets.map((bucket) => (
<PopoverRow key={bucket.label} label={bucket.label}>
<span className={POPOVER_NUMBER}>
{bucket.value === 0
? "0"
: formatTokenCount(bucket.value, { compactDecimal: true })}
</span>
</PopoverRow>
))}
{cost && (
<PopoverRow label="Cost">
<span className={POPOVER_NUMBER}>{cost}</span>
</PopoverRow>
)}
</PopoverRows>
</div>
);
}
export function ModelUsagePopover({
providerUsed,
billing,
}: {
providerUsed: StageModelUsage;
billing: BilledTokenCounts;
}) {
return (
<>
@ -892,6 +927,7 @@ function ModelUsagePopover({
<PopoverRow label="Speed">{providerUsed.speed}</PopoverRow>
)}
</PopoverRows>
<StageBillingRows billing={billing} />
</>
);
}
@ -1905,6 +1941,7 @@ function EventsToolbar({
filteredCount,
totalCount,
providerUsed,
billing,
events,
runId,
stageId,
@ -1924,6 +1961,7 @@ function EventsToolbar({
filteredCount: number;
totalCount: number;
providerUsed: StageModelUsage | null;
billing: BilledTokenCounts;
events: EventEnvelope[];
runId: string;
stageId: string;
@ -2004,7 +2042,9 @@ function EventsToolbar({
className={`inline-flex items-center gap-1.5 text-xs text-fg-muted ${
showFilters ? "" : "ml-auto"
}`}
content={<ModelUsagePopover providerUsed={providerUsed} />}
content={
<ModelUsagePopover providerUsed={providerUsed} billing={billing} />
}
>
<CpuChipIcon className="size-3.5" aria-hidden="true" />
<span className="font-mono">{modelUsageLabel}</span>
@ -2359,6 +2399,7 @@ function RunStageActivityStage({
effectiveTab === "primary" ? turns.length : debugEvents.length
}
providerUsed={selectedStage.providerUsed}
billing={selectedStage.billing}
events={stageEventsQuery.data ?? []}
runId={runId}
stageId={selectedStageId}

View file

@ -26,6 +26,7 @@ import { ciConfig, columnForRun, columnStatusDisplay, columnStatuses, deriveCiSt
import type { CiStatus, CheckRun, CheckStatus, RunItem } from "../data/runs";
import { EmptyState } from "../components/state";
import { PullRequestChip } from "../components/pull-request-chip";
import { SizeChip } from "../components/size-chip";
import {
summarizeBatchLifecycleAction,
} from "../components/runs-list/batch-lifecycle";
@ -345,7 +346,7 @@ function PrCard({
// All inline footer metadata on PrCard belongs in this one row. Adding a new
// piece as a sibling `<div>` below the card body recreates a recurring bug
// where stats stack onto separate lines instead of sitting next to elapsed/actions.
// where stats stack onto separate lines instead of sitting next to size/actions.
function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) {
const hasActions = actions != null && actions.length > 0;
const hasStats =
@ -354,7 +355,7 @@ function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) {
(pr.additions != null && pr.additions !== 0) ||
(pr.deletions != null && pr.deletions !== 0);
if (!hasStats && !hasActions && pr.elapsed == null) return null;
if (!hasStats && !hasActions && pr.size == null) return null;
return (
<div className="mt-3 flex items-center gap-3 font-mono text-xs">
@ -416,9 +417,9 @@ function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) {
))}
</div>
)}
{pr.elapsed != null && (
<span className={`text-fg-muted ${hasActions ? "" : "ml-auto"}`}>
{pr.elapsed}
{pr.size != null && (
<span className={hasActions ? "inline-flex" : "ml-auto inline-flex"}>
<SizeChip size={pr.size} totalUsdMicros={pr.totalUsdMicros} />
</span>
)}
</div>

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<path d="M121.683 75.25L149.997 124L91.4816 224.75C90.3128 226.757 88.155 228 85.8174 228H32.9664C31.7976 228 30.6778 227.691 29.697 227.131C28.7161 226.57 27.8906 225.758 27.3021 224.75L0.876625 179.25C-0.292208 177.243 -0.292208 174.765 0.876625 172.75L57.512 75.25C58.0923 74.2425 58.9259 73.43 59.9068 72.8694C60.8876 72.3088 62.0074 72 63.1762 72H116.027C118.365 72 120.523 73.2431 121.692 75.25H121.683ZM299.125 172.75L242.49 75.25C241.91 74.2425 241.076 73.43 240.095 72.8694C239.114 72.3088 237.995 72 236.826 72H183.975C181.637 72 179.479 73.2431 178.311 75.25L149.997 124L208.512 224.75C209.681 226.757 211.839 228 214.177 228H267.027C268.196 228 269.316 227.691 270.297 227.131C271.278 226.57 272.103 225.758 272.692 224.75L299.117 179.25C300.286 177.243 300.286 174.765 299.117 172.75H299.125Z" fill="#62DE61"/>
</svg>

After

Width:  |  Height:  |  Size: 917 B

View file

@ -2116,6 +2116,7 @@ These legacy events may appear in older run logs. Current CLI backend runs do no
"properties": {
"pr_url": "https://github.com/org/repo/pull/42",
"pr_number": 42,
"head_sha": "d34db33f",
"draft": true
}
}
@ -2125,6 +2126,7 @@ These legacy events may appear in older run logs. Current CLI backend runs do no
|----------|------|-------------|
| `pr_url` | string | Pull request URL |
| `pr_number` | number | Pull request number |
| `head_sha` | string (optional) | Verified commit SHA at the remote PR head; absent on older events |
| `draft` | boolean | Whether the PR is a draft |
### `pull_request.linked`

View file

@ -7,8 +7,10 @@ This document defines Fabro's parallel fan-out (`shape=component`) and fan-in
## 1. Execution model
A parallel node dispatches one branch for each outgoing edge. A branch executes
the single target node on that edge; parallel branches are not subgraph walks.
A static parallel node dispatches one branch for each outgoing edge. A
`for_each` parallel node has one outgoing template edge and dispatches one
branch for each item in a runtime JSON array. A branch executes the single
target node on that edge; parallel branches are not subgraph walks.
Every branch:
- receives an independent fork of the parent workflow context;
@ -22,6 +24,18 @@ once and defaults to 4. The parallel node always waits for every branch task,
even when a branch fails or run cancellation begins. There is no early-success
join mode.
`for_each` sources use flat context lookup: try the declared key, then strip a
leading `context.` and try again. Inline arrays and managed `blob://` or
`file://` JSON references are accepted. The template target is limited to an
agent or prompt node, and nested `for_each` is rejected.
A source array above 1000 items fails deterministically before
`parallel.started`. The array is runtime data, often model-produced, so its
length is not something a workflow author reviewed. Each branch forks the
parent context once it holds a `max_parallel` slot, so live memory tracks
`max_parallel` rather than item count — the limit guards the queue of pending
branch tasks and the plan itself.
The parent context is not used as shared mutable branch state. A branch can
change its context fork without exposing those changes as top-level values to
other branches or to the parent.
@ -55,15 +69,19 @@ The shared result type is:
```rust
ParallelBranchResult {
id: String,
index: Option<usize>,
item_label: Option<String>,
status: StageOutcome,
context_updates: BTreeMap<String, serde_json::Value>,
}
```
The parallel handler stores one result per outgoing edge in
`parallel.results`. Results preserve outgoing-edge order, independent of branch
completion order. `parallel.branch_count` stores the number of dispatched
branches.
The parallel handler stores one result per outgoing edge or runtime item in
`parallel.results`. Results preserve outgoing-edge or input order, independent
of branch completion order. New results always contain `index`; it is optional
only so records written before indexed identity still deserialize.
`item_label` is set for `for_each` from item `name`, then `label`, then index.
`parallel.branch_count` stores the number of dispatched branches.
`context_updates` includes changes made in the branch context and updates
returned by the branch outcome. This applies to successful and failed branches,
@ -79,7 +97,19 @@ The parallel stage outcome is:
- `succeeded` when every branch succeeds;
- `failed` when every branch fails;
- `partially_succeeded` for mixed outcomes, partial outcomes, and zero branches.
- `partially_succeeded` for mixed outcomes, partial outcomes, and a static
fan-out with zero branches;
- `succeeded` for a valid `for_each` source with zero items.
For `for_each`, a missing key, missing blob, invalid JSON, or non-array fails
before `parallel.started`. A valid empty array emits paired parallel events
with count zero and jumps directly to the template target's fan-in.
A dry run reaches the fan-out before any upstream node has produced the array,
so an absent or unusable source stands in one placeholder item and the template
target is simulated once. Graph-shape mistakes — a non-string source, the wrong
edge count, a non-LLM target, nested `for_each` — still fail under `--dry-run`,
because catching those is what a dry run is for.
## 4. Artifacts and downstream context
@ -123,14 +153,23 @@ winner, restore files, or choose workspace state.
Parallel execution emits:
- `parallel.started` with `visit` and `branch_count`;
- `parallel.branch.started` with stable branch identity and index;
- `parallel.branch.completed` with index, duration, and status;
- `parallel.branch.started` with stable branch identity, index, and optional
item label;
- `parallel.branch.completed` with index, optional item label, duration, and
status;
- `parallel.completed` with counts and the ordered typed result array.
Every branch task emits one terminal branch completion event, including handler
failure, cancellation before semaphore acquisition, panic, or join failure.
The final typed array is also projected into
`StageProjection.parallel_results`.
`StageProjection.parallel_results`. Raw runtime items are recorded once in the
existing `stage.prompt` event and are not duplicated in branch events or
results.
Branch attempts use the same artifact, panic, and executor-timeout envelope as
ordinary nodes, wrapped in a branch-local retry loop. A retry preserves its
branch identity, stage scope, item label, context fork, and result index. It
releases the concurrency permit during backoff and reacquires it before the
next attempt. Generic graph lifecycle callbacks, edge selection, thread reuse,
and per-item checkpoints are intentionally excluded.
## 7. Cancellation

View file

@ -155,9 +155,16 @@ git diff --check
- Inference time is Fabro-observed LLM request/stream elapsed time, not
provider-reported model-only compute time.
- LLM retry backoff, queueing outside a request/stream, human waits, steering
waits, and scheduler gaps are wall time but not active time.
- Active timing is finalized-event based in v1; live active-time ticking can be
added later if it becomes necessary.
- Queueing outside a request/stream, human waits, steering waits, and scheduler
gaps are wall time but not active time. Retry delay inside an open LLM request
bracket follows the executor stopwatch and counts as inference time.
- ~~Active timing is finalized-event based in v1; live active-time ticking can
be added later if it becomes necessary.~~ **Superseded 2026-07-25.** It became
necessary: a run parked in one long agent stage reported ~12% of its wall time
as active, because in-flight stages contributed nothing. Stage projections now
accumulate inference and tool brackets from the event log and expose
`StageProjection::live_timing(now)`, the active-time twin of
`live_wall_time_ms`. Finalized values remain authoritative and still replace
the live estimate at terminal events. Implemented in PR #647.
- No compatibility layer is required for existing API clients or stored run
event data.

View file

@ -218,7 +218,7 @@ provider = "s3"
disk_cache = true
[server.slatedb.s3]
bucket = "{{ env.SLATEDB_BUCKET }}"
bucket = "fabro-production"
region = "us-east-1"
```
@ -387,8 +387,11 @@ fabro secret set GEMINI_API_KEY AI...
| `INCEPTION_API_KEY` | Inception (Mercury) |
| `POOLSIDE_API_KEY` | Poolside (Laguna) |
| `OPENROUTER_API_KEY` | OpenRouter (when enabled) |
| `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` | Modal (when enabled) |
| `FIREWORKS_API_KEY` | Fireworks AI (when enabled) |
Modal requires both vault tokens. Its provider definition resolves them into the `Modal-Key` and `Modal-Secret` request headers.
### Sandbox and tools
These optional server integrations are vault-only:
@ -463,7 +466,7 @@ GitHub App mode stores these secrets in the vault. `fabro install` writes them a
### Slack integration (optional)
Slack credentials are server-level secrets. Add `[server.integrations.slack]` to enable one Slack connection that is shared by human interview prompts and run lifecycle notifications. `server.integrations.slack.default_channel` is an optional literal channel name used only as the default destination for interview prompts; it does not interpolate `{{ env.* }}`. Lifecycle notifications use `[run.notifications.<name>.slack].channel` in run or workflow configuration.
Slack credentials are server-level secrets. Add `[server.integrations.slack]` to enable one Slack connection that is shared by human interview prompts and run lifecycle notifications. `server.integrations.slack.default_channel` is an optional literal channel name used only as the default destination for interview prompts; it does not interpolate. Lifecycle notifications use `[run.notifications.<name>.slack].channel` in run or workflow configuration.
Fabro resolves these from the vault only. When `[server.integrations.slack]` is present and both credentials are present, startup logs `Slack integration enabled` and then the Slack Socket Mode connection status. If the Slack config table is absent or `enabled = false`, startup logs `Slack integration disabled by server configuration`. If the table is present but either credential is missing or empty, startup logs `Slack integration disabled; missing credentials` with the missing variable names.

View file

@ -28,19 +28,19 @@ POST the event context as JSON to an HTTP endpoint. Useful for webhooks, externa
event = "run_complete"
type = "http"
url = "https://hooks.example.com/done"
allowed_env_vars = ["API_KEY"]
[hooks.headers]
Authorization = "Bearer {{ env.API_KEY }}"
X-Deployment-Environment = "{{ vars.DEPLOY_ENV }}"
```
| Field | Description |
|---|---|
| `url` | The endpoint to POST to. Must use `https://` unless `tls = "off"`. Supports `{{ env.NAME }}` interpolation. |
| `headers` | Optional HTTP headers. Values support `{{ env.NAME }}` interpolation, scoped to the names in `allowed_env_vars`. A token for any other env var fails to resolve and the hook blocks (fail-closed). |
| `allowed_env_vars` | Allowlist of environment variable names a header may read via `{{ env.NAME }}`. Empty (the default) means no env vars may be interpolated into headers. |
| `url` | The endpoint to POST to. Must use `https://` unless `tls = "off"`. Supports `{{ vars.NAME }}` interpolation. |
| `headers` | Optional HTTP headers. Values support `{{ vars.NAME }}` interpolation. A token that is still unresolved when the hook fires blocks it (fail-closed), so a header is never sent half-rendered. |
| `tls` | TLS mode: `"verify"` (default), `"no_verify"`, or `"off"`. |
`{{ vars.NAME }}` is substituted when the run is created. Use variables only for non-sensitive metadata. Do not store tokens, API keys, or other credentials in variables or literal hook configuration. `{{ env.NAME }}` and `{{ secrets.NAME }}` are not available in hooks.
### Prompt
A single-turn LLM call that evaluates the event context and returns an `ok`/`block` decision. The model responds with structured JSON.

View file

@ -149,12 +149,11 @@ Inline transport fields can interpolate values at the run boundary:
| Syntax | Resolution time |
|---|---|
| `{{ vars.NAME }}` | When the server creates the run, using that run's variable snapshot |
| `{{ env.NAME }}` | When the worker launches the MCP transport |
| `{{ secrets.NAME }}` | When the worker launches the MCP transport, using a token secret from the server vault |
Interpolation applies to stdio and sandbox commands and env values, plus HTTP URLs and headers. Variable tokens are replaced in the created run configuration. Worker-time environment and secret expressions remain in persisted configuration, while resolved secret values do not. A missing environment variable, missing secret, or non-token secret fails MCP startup instead of passing an unresolved token to the transport.
Interpolation applies to stdio and sandbox commands and env values, plus HTTP URLs and headers. Variable tokens are replaced in the created run configuration. Secret expressions remain in persisted configuration, while resolved secret values do not. A missing or non-token secret fails MCP startup instead of passing an unresolved token to the transport. `{{ env.* }}` is unsupported and also fails before launch.
Standalone `fabro exec` can resolve `{{ env.* }}` from its process environment, but it has no server vault. A `{{ secrets.* }}` reference therefore fails with an explicit error in standalone execution.
Standalone `fabro exec` has no server vault, so a `{{ secrets.* }}` reference fails with an explicit error in standalone execution.
## Transports

View file

@ -7941,8 +7941,13 @@ components:
model:
type: string
description: |
Catalog model ID or alias. The server selects among ready
providers and stores the canonical model ID.
Catalog model ID or alias, optionally qualified as
`provider:selector`. A provider-qualified selector may be a
canonical model ID, alias, or provider API ID. A value counts as
qualified only when the text before the first `:` names a known
provider, so model IDs containing a colon stay whole. Legacy
`provider/model` references remain accepted. The server stores the
canonical model ID.
provider:
$ref: "#/components/schemas/ProviderId"
description: Optional provider pin. Provider-qualified model references remain accepted for compatibility.
@ -8894,6 +8899,7 @@ components:
type: string
enum:
- workflow_error
- publish_failed
- cancelled
- approval_denied
- terminated
@ -9597,6 +9603,11 @@ components:
type: ["string", "null"]
description: Optional contextual text shown alongside the question.
example: Latest draft
review_target:
description: Optional validated external resource that is the primary subject of this review question.
oneOf:
- $ref: "#/components/schemas/ReviewTarget"
- type: "null"
QuestionType:
description: The interaction type of a human-in-the-loop question.
@ -9951,10 +9962,9 @@ components:
Durable identity of one execution of a parallel node, formatted as
"{node_id}@{visit}".
parallel_branch_id:
type: ["string", "null"]
description: >
Durable identity of one branch within a parallel execution,
formatted as "{parallel_group_id}:{index}".
oneOf:
- $ref: "#/components/schemas/ParallelBranchId"
- type: "null"
session_id:
type: ["string", "null"]
parent_session_id:
@ -10607,6 +10617,17 @@ components:
properties:
id:
type: string
index:
type: integer
minimum: 0
description: >-
Zero-based input item or outgoing-edge position. Absent only on
parallel results written before indexed branch identity was added.
item_label:
type: string
description: >-
Human-readable for_each item identity, derived from name, then
label, then the zero-based input index.
status:
$ref: "#/components/schemas/StageOutcome"
context_updates:
@ -10652,6 +10673,10 @@ components:
items:
$ref: "#/components/schemas/ParallelBranchResult"
description: Ordered per-branch results produced by a parallel stage.
parallel_branch_id:
oneOf:
- $ref: "#/components/schemas/ParallelBranchId"
- type: "null"
output:
type: ["string", "null"]
output_bytes:
@ -10673,7 +10698,38 @@ components:
- type: "null"
description: |
Per-attempt timing breakdown for the latest terminal attempt:
wall time plus the active inference/tool breakdown.
wall time plus the active inference/tool breakdown. Null while the
stage is still in flight; the live estimate is derived from
`live_inference_ms`, `live_tool_ms`, and any open bracket.
live_inference_ms:
type: integer
format: uint64
minimum: 0
default: 0
description: |
Inference time accumulated from closed brackets during the current
attempt. Live estimate only — the authoritative value arrives with
the terminal event and lands in `timing`. Excludes the currently
open bracket, whose span is measured from `inference.started_at`.
example: 78230
live_tool_ms:
type: integer
format: uint64
minimum: 0
default: 0
description: |
Tool time accumulated from closed tool batches during the current
attempt. A batch spans the first dispatched call through the
completion that drains the last outstanding one, so tools running
concurrently within a turn are counted once.
example: 7588
tool_batch:
oneOf:
- $ref: "#/components/schemas/StageToolBatchProjection"
- type: "null"
description: |
Open tool batch: when the batch started and which calls have not
yet reported completion.
usage:
$ref: "#/components/schemas/BilledTokenCounts"
model:
@ -10727,6 +10783,13 @@ components:
Open inference bracket, if the event log contains one. Present means
a model request was dispatched and no closing event has been seen —
not that the model is computing right now.
acp_started_at:
type: ["string", "null"]
format: date-time
description: >
Start of an external ACP agent process, if one is running. ACP
agents do not expose Fabro's internal LLM brackets, so the process
lifetime supplies their live inference estimate.
agent_control:
$ref: "#/components/schemas/AgentControlState"
description: Whether the agent is executing normally or waiting for steering after an interrupt.
@ -10734,6 +10797,37 @@ components:
$ref: "#/components/schemas/StageState"
description: Lifecycle state of the stage projection.
StageToolBatchProjection:
description: >
One open tool batch: tool calls dispatched together that have not all
reported completion. `open_call_ids` is a set rather than a count so a
duplicated completion in a replayed log cannot drain the batch early.
type: object
required:
- session_id
- started_at
- open_call_ids
properties:
session_id:
type: string
description: >
Root agent session that dispatched the batch. Transitions are
gated on it so delayed events from a replaced session cannot
mutate the current batch.
started_at:
type: string
format: date-time
description: >
When the batch opened — the first dispatched call observed while no
other calls were outstanding.
open_call_ids:
type: array
minItems: 1
uniqueItems: true
items:
type: string
description: Calls dispatched but not yet completed, by tool call id.
StageInferenceProjection:
description: >
One open inference bracket: a dispatched LLM request that has not yet
@ -11102,6 +11196,36 @@ components:
type: ["string", "null"]
description: Optional untrusted model-authored option preview captured for clients.
ReviewTargetKind:
description: The type of resource presented for human review.
type: string
enum:
- document
ReviewTarget:
description: A validated external resource presented as the primary subject of a human review question.
type: object
required:
- label
- url
- kind
properties:
label:
type: string
minLength: 1
maxLength: 200
description: Human-readable link label.
example: Quarry review exercise
url:
type: string
format: uri
minLength: 1
maxLength: 2048
description: Absolute HTTP or HTTPS URL opened by the reviewer.
example: https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef
kind:
$ref: "#/components/schemas/ReviewTargetKind"
InterviewQuestionRecord:
description: Storage shape of an interview question recorded in the event log.
type: object
@ -11131,6 +11255,10 @@ components:
format: double
context_display:
type: ["string", "null"]
review_target:
oneOf:
- $ref: "#/components/schemas/ReviewTarget"
- type: "null"
PendingInterviewRecord:
description: Pending interview question plus the time it entered the unresolved set.
@ -12244,9 +12372,17 @@ components:
observed LLM request/stream elapsed time; `tool_time_ms` is tool or
command execution elapsed time; `active_time_ms` equals
`inference_time_ms + tool_time_ms`.
For a terminal stage these come from the worker's own stopwatch and are
authoritative. For a stage still in flight they are a live estimate
reconstructed from the event log, and `active_time_ms` is clamped to
`wall_time_ms`. The estimate is replaced by the authoritative
breakdown when the stage reaches a terminal event.
type: object
required:
- wall_time_ms
- inference_time_ms
- tool_time_ms
- active_time_ms
properties:
wall_time_ms:
@ -12278,9 +12414,16 @@ components:
Timing rollup for an entire run. Active fields sum work across stage
visits, so `active_time_ms` can exceed `wall_time_ms` when parallel
branches run concurrently.
For a running run, stages still in flight contribute a live estimate
rather than nothing, so wall and active both advance continuously.
Unlike `StageTiming`, active is not clamped to wall here — concurrent
branches can legitimately sum past run wall time.
type: object
required:
- wall_time_ms
- inference_time_ms
- tool_time_ms
- active_time_ms
properties:
wall_time_ms:
@ -12570,6 +12713,13 @@ components:
type: string
example: verify@2
ParallelBranchId:
description: >-
Durable identity of one branch within a parallel execution, in
`{parallel_group_id}:{index}` form.
type: string
example: review_fork@3:1
StageState:
description: Lifecycle projection state of a workflow stage.
type: string
@ -12609,6 +12759,7 @@ components:
- status
- node_id
- visit
- billing
properties:
id:
$ref: "#/components/schemas/StageId"
@ -12658,6 +12809,22 @@ components:
StageId of the prior post-checkpoint execution superseded by this
replay after the run was resumed.
example: verify@1
parallel_group_id:
allOf:
- $ref: "#/components/schemas/StageId"
description: >-
Exact StageId of the parent parallel execution. Clients can compare
this directly with the `id` of a parallel stage. Omitted for stages
that are not parallel branches.
example: review_fork@1
parallel_branch_index:
type: integer
format: uint32
minimum: 0
description: >-
Zero-based outgoing-edge index within the parent parallel
execution. Omitted for stages that are not parallel branches.
example: 1
provider_used:
oneOf:
- $ref: "#/components/schemas/StageModelUsage"
@ -12668,6 +12835,15 @@ components:
format: date-time
description: Wall-clock time the latest attempt of this stage started, if known.
example: "2026-04-29T12:34:56Z"
billing:
$ref: "#/components/schemas/BilledTokenCounts"
description: >-
Token counts for this stage execution alone. `total_usd_micros` is
the provider-reported cost when there is one, otherwise the server
catalog's price for these tokens — the same pricing the
`/runs/{id}/billing` rows use. All-zero counts mean the stage made
no model calls. Unlike the billing rows, which sum every visit of a
node, this covers only this visit.
# ── File Diff Schemas ──────────────────────────────────────────────
@ -13961,7 +14137,7 @@ components:
$ref: "#/components/schemas/RunNamespace"
InterpString:
description: Resolved config string that may contain env interpolation tokens.
description: Config string that can contain typed interpolation tokens.
type: string
StringMap:
@ -14119,6 +14295,16 @@ components:
ModelRef:
type: string
description: |
A fallback model reference. Bare values name a provider, canonical
model ID, or alias. Provider-qualified values use
`provider:selector`; the selector may be a canonical model ID, alias,
or provider API ID and may contain `/` or additional colons. A value
is treated as qualified only when the text before the first `:` names
a known provider, so model IDs that contain a colon — ollama
`name:tag` values, Bedrock inference-profile IDs — stay whole. Legacy
`provider/model` references remain accepted.
example: openrouter:moonshotai/kimi-k3
RunModelSettings:
type: object
@ -14169,7 +14355,7 @@ components:
script-vs-argv distinction via the `type` discriminator: a `script`
is a raw shell snippet kept verbatim, while a `command` is an argv
whose elements are shell-quoted and joined at the run boundary (after
`{{ env.* }}` resolution) so an interpolated value cannot inject shell
`{{ secrets.* }}` resolution) so an interpolated value cannot inject shell
syntax. Optional per-step `env` is shared by both shapes.
type: object
required: [type]
@ -14574,17 +14760,9 @@ components:
- type: "null"
description: >-
Optional HTTP headers for an http hook. Values support
`{{ env.NAME }}` interpolation, scoped to the names listed in
`allowed_env_vars`; a token for any other env var fails to resolve
and the hook blocks (fail-closed).
allowed_env_vars:
type: array
items:
type: string
description: >-
Allowlist of environment variable names that an http hook header may
read via `{{ env.NAME }}`. An empty list (the default) permits no env
vars in headers.
`{{ vars.NAME }}` interpolation, substituted when the run is
created; a token left unresolved at fire time blocks the hook
(fail-closed).
tls:
$ref: "#/components/schemas/TlsMode"
prompt:

View file

@ -84,7 +84,7 @@ aliases = ["gateway"]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}"
x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}"
x-portkey-config = "@bedrock-prod"
[llm.providers.proxy.models."team-code-large"]
@ -153,7 +153,7 @@ Historical built-in catalog keys that exposed provider API IDs remain accepted a
Model roles are separate: `default = true` controls normal model selection for workflow execution, while `small_default = true` marks the provider's small/cheap utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's normal default.
Provider auth is declared in `[llm.providers.<id>.auth]` with ordered `env:<NAME>` or `vault:<NAME>` refs. The primary auth header defaults to `bearer`; override with `header = { custom = "Header-Name" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers.<id>.auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only interpolation headers and no API-key auth — go in `extra_headers` as literal text, `{{ env.NAME }}` tokens, or `{{ secrets.NAME }}` tokens. Put credentials in secrets and reference them with `{{ secrets.NAME }}` instead of a bare literal.
Provider auth is declared in `[llm.providers.<id>.auth]` with ordered `env:<NAME>` or `vault:<NAME>` refs. The primary auth header defaults to `bearer`; override with `header = { custom = "Header-Name" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers.<id>.auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only interpolation headers and no API-key auth — go in `extra_headers` as literal text or `{{ secrets.NAME }}` tokens. Put credentials in secrets and reference them with `{{ secrets.NAME }}` instead of a bare literal.
Workflow runs also add `x-session-id: <run-id>` to every LLM request so compatible gateways can group requests from the same run. An explicitly configured `x-session-id` in provider `extra_headers` takes precedence.
@ -180,6 +180,23 @@ Fabro ships an [OpenRouter](/integrations/openrouter) provider definition with a
enabled = true
```
### Modal
Fabro ships a [Modal](/integrations/modal) provider definition for Kimi K3, disabled by default. Modal assigns the endpoint URL and authenticates requests with a two-part proxy token:
```toml title="settings.toml"
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
Store both token values in the Fabro server vault:
```bash
fabro secret set MODAL_TOKEN_ID wk-...
fabro secret set MODAL_TOKEN_SECRET ws-...
```
### Amazon Bedrock
Fabro ships an [Amazon Bedrock](/integrations/bedrock) provider definition with a curated multi-vendor catalog over Bedrock's Converse API, disabled by default. Enable it and authenticate with a Bedrock API key or AWS SigV4 credentials:
@ -277,7 +294,7 @@ Then launch with:
fabro run run.toml
```
The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable. In this field, qualified references keep their established provider-pin meaning: `"openai/gpt-5.6-sol"` selects the direct OpenAI offering.
The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model ID or alias (like `"gpt-terra"`), or a qualified `"provider:selector"` reference. A qualified selector may be the provider's canonical model ID, alias, or API ID, including API IDs with slashes such as `"openrouter:moonshotai/kimi-k3"`. Fabro tries entries in order when the primary provider is unavailable, and qualified references remain provider pins. Legacy `provider/model` references remain accepted for compatibility.
<Note>
The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win.

View file

@ -98,6 +98,7 @@
"integrations/bedrock",
"integrations/poolside",
"integrations/openrouter",
"integrations/modal",
"integrations/fireworks",
"integrations/slack",
"integrations/brave-search"

View file

@ -17,7 +17,7 @@ Start → Plan → Implement → Test → Exit
└─ sets response.plan, last_response
```
Context is thread-safe and shared across the entire run. Parallel branches receive an isolated **deep copy** of the context at the point of fan-out, so branches can't interfere with each other. When branches merge, the fan-in handler records the results under `parallel.fan_in.*` keys.
Context is thread-safe and shared across the entire run. Parallel branches receive an isolated **deep copy** of the context at the point of fan-out, so branches can't interfere with each other. The parallel handler gathers their results under `parallel.results`.
## How agents access context
@ -40,6 +40,11 @@ Each handler type writes specific keys into the context after execution:
Agents can also emit arbitrary context updates by including a JSON object with a `context_updates` field in their response. See [Transitions](/workflows/transitions#agent-transitions).
The `review_target` key has an optional typed convention for human review
workflows. A human gate with `review_target=true` reads this exact flat key and
presents its document URL as the primary question link. See
[Review targets](/workflows/human-in-the-loop#review-targets).
### Command nodes
| Key | Value |
@ -61,11 +66,30 @@ Agents can also emit arbitrary context updates by including a JSON object with a
| Key | Value |
|---|---|
| `parallel.results` | Ordered branch results. Each entry contains `id`, `status`, and the branch's isolated `context_updates`. |
| `parallel.branch_count` | Number of outgoing branches dispatched by the parallel node. |
| `parallel.results` | Ordered branch results. Each entry contains `id`, `index`, optional `item_label`, `status`, and the branch's isolated `context_updates`. Legacy results may omit `index`. |
| `parallel.branch_count` | Number of branches dispatched. For `for_each`, this is the runtime array length. |
Branch updates remain nested inside `parallel.results`; they are not merged into top-level context. Prompted fan-in nodes can synthesize the complete result array, while promptless fan-in nodes act as barriers.
### Runtime arrays with `for_each`
A parallel node can read a flat context key and run one agent or prompt target
per array item:
```dot
batch [shape=component, for_each="context.candidates"]
batch -> reviewer
```
`context.candidates` first checks the exact key and then falls back to
`candidates`. The source must be a JSON array, either inline or stored behind a
Fabro-managed `blob://` or `file://` reference. General nested lookup such as
`output.scan.candidates` is not supported; have the producing node write the
array to a flat context key such as `candidates`.
Each item receives a separate context fork, while `(id, index)` identifies its
result. The item itself is not copied into `parallel.results`.
### Engine-managed keys
The engine sets several keys automatically. These are prefixed with `internal.` and are excluded from preambles:

View file

@ -152,16 +152,16 @@ preserve = true
## Environment value interpolation
Environment `env` values can mix literal text with `{{ vars.NAME }}`, `{{ env.NAME }}`, and `{{ secrets.NAME }}` tokens:
Environment `env` values can mix literal text with `{{ vars.NAME }}` and `{{ secrets.NAME }}` tokens:
```toml title="workflow.toml"
[environments.fabro-dev.env]
DEPLOY_ENV = "{{ vars.DEPLOY_ENV }}"
SERVICE_URL = "https://api.{{ env.REGION }}.example.com"
SERVICE_URL = "https://api.{{ vars.REGION }}.example.com"
SERVICE_TOKEN = "{{ secrets.SERVICE_TOKEN }}"
```
Server-managed variables resolve when the run is created. Worker environment variables and token secrets resolve immediately before the sandbox starts, so resolved secret values are not persisted in the run definition. A missing or non-token secret fails closed. For backward compatibility, a value containing only missing `{{ env.* }}` references is passed through in source form.
Server-managed variables resolve when the run is created. Token secrets resolve immediately before the sandbox starts, so resolved secret values are not persisted in the run definition. A missing or non-token secret fails closed, as does any `{{ env.* }}` reference: the process environment is not a configuration source.
## Selecting an environment from the CLI

View file

@ -124,10 +124,10 @@ fallbacks = ["gemini", "openai"]
When Anthropic fails, Fabro tries Gemini first, then OpenAI. Fallback resolution is provider-aware:
- A bare provider token such as `"gemini"` selects that provider's closest compatible model.
- A qualified selector such as `"openrouter/gpt-56-sol"` resolves only within that provider.
- A qualified selector such as `"openrouter:gpt-56-sol"` resolves only within that provider. The selector may be a canonical model ID, alias, or provider API ID such as `"openrouter:moonshotai/kimi-k3"`.
- A bare model slug or alias considers ready providers and uses provider priority.
Qualified fallback references always remain provider pins, including strings that were historical built-in API IDs. For example, `"openai/gpt-5.6-sol"` pins the direct OpenAI offering.
Qualified fallback references always remain provider pins. For example, `"openai:gpt-5.6-sol"` pins the direct OpenAI offering. Legacy `provider/model` fallback references remain accepted for compatibility.
The primary provider and model were already resolved and persisted when the run was created; resuming does not re-run primary selection. Fallbacks are only considered after an eligible runtime failure.

View file

@ -66,6 +66,13 @@ Envelope fields:
Only `id`, `ts`, `run_id`, and `event` are always present. Optional fields are omitted when they do not apply.
For runtime `for_each` branches, `parallel.branch.started` and
`parallel.branch.completed` include the zero-based `index` and an optional
`item_label`. They do not include the raw item. The final prompt is recorded by
the existing `stage.prompt` event, including the fenced item data, so event
streams, run dumps, and retained logs are source-bearing data. Apply the same
access controls and retention policy you use for workflow inputs.
## Reading the event stream
Because event payload lives in `properties`, most shell queries should look there.

View file

@ -26,7 +26,7 @@ Each node type has its own rules for which outcomes it can return:
|---|---|---|
| **Command** | `succeeded`, `failed` | `succeeded` when exit code is 0; `failed` otherwise |
| **Agent / Prompt** | `succeeded`, `failed`, `partially_succeeded`, `skipped` | Defaults to `succeeded`. The LLM can set any outcome via a [routing directive](/agents/outputs#routing-directives) JSON object in its response. Backend errors request retry when retryable or finish as `failed`. |
| **Parallel** | `succeeded`, `partially_succeeded`, `failed` | Waits for every branch. `succeeded` when all branches succeed, `failed` when all branches fail, and `partially_succeeded` for mixed, partial, or zero-branch results. |
| **Parallel** | `succeeded`, `partially_succeeded`, `failed` | Waits for every branch. `succeeded` when all branches succeed, `failed` when all branches fail, and `partially_succeeded` for mixed or partial results. A static fan-out with no branches remains partial; a valid `for_each` source with zero items succeeds. |
| **Human** | `succeeded` | Always succeeds — the user's selection becomes a routing signal via `preferred_label` |
| **Conditional** | `succeeded` | Always succeeds — routing is handled by the engine's edge selection |
| **Start / Exit / Wait** | `succeeded` | Always succeed |

View file

@ -79,7 +79,7 @@ memory = "8GB"
disk = "20GB"
[environments.cloud.env]
API_KEY = "{{ env.MY_API_KEY }}"
API_KEY = "{{ secrets.MY_API_KEY }}"
NODE_ENV = "production"
[run.integrations.github.permissions]
@ -140,10 +140,24 @@ name = "claude-sonnet-4-5"
|---|---|
| `name` | Canonical model slug or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). |
| `provider` | Optional provider pin. When omitted, Fabro selects among ready offerings by provider priority. When present, an unavailable provider is an error rather than permission to switch. |
| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. |
| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model IDs or aliases, or qualified `"provider:selector"` references. |
Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.<id>]`.
For a qualified fallback, the selector may be that provider's canonical model ID, alias, or API ID. Fabro splits on the first `:` when the part before it names a known provider, so provider API IDs may contain `/` or additional colons:
```toml title="run.toml"
[run.model]
fallbacks = [
"openrouter:kimi-k3",
"gpt-terra",
]
```
The first entry could equivalently be written as `"openrouter:moonshotai/kimi-k3"` using OpenRouter's API ID; both forms resolve to its canonical `kimi-k3` offering. The unqualified `gpt-terra` alias uses normal ready-provider priority selection. Legacy `provider/model` fallback references remain accepted but are normalized to `provider:model`.
A colon alone does not make a reference qualified. Many model IDs contain one — ollama `name:tag` values, Bedrock inference-profile IDs and ARNs — so Fabro treats the reference as qualified only when the text before the first `:` names a known provider. `"llama3:8b"` stays a single model ID, while `"ollama:llama3:8b"` pins the `ollama` provider and passes `llama3:8b` as the selector.
At run creation, Fabro resolves the primary selector and every node selector against the ready-provider snapshot. It persists the selected canonical model slug and provider, so resuming the run does not choose a different provider just because credentials or priorities changed. The configured fallback chain remains available for failures that occur while the materialized run is executing.
Historical built-in provider API IDs are accepted for compatibility and normalize before this selection. For example, `name = "openai/gpt-5.6-sol"` is treated as the canonical `gpt-5.6-sol` selector; omit `provider` to use readiness and priority, or set `provider` separately to pin an offering.
@ -192,13 +206,13 @@ env = { NPM_TOKEN = "{{ secrets.NPM_TOKEN }}" }
| Field | Description |
|---|---|
| `script` | Bash source, evaluated by the sandbox's non-login Bash (`bash -c`). Supports `{{ vars.* }}`, `{{ env.* }}`, and `{{ secrets.* }}` interpolation. |
| `script` | Bash source, evaluated by the sandbox's non-login Bash (`bash -c`). Supports `{{ vars.* }}` and `{{ secrets.* }}` interpolation. |
| `command` | Argv-style command, mutually exclusive with `script`. Each resolved element is shell-quoted as one argument. |
| `env` | Additional environment variables for this step. Values support the same interpolation as `script` and `command`. |
Each step must exit with status 0. If any step fails, the run aborts before the workflow starts. Prepare steps replace across layers — the higher-precedence layer wins wholesale.
Fabro substitutes `{{ vars.* }}` when the server creates the run, then resolves `{{ env.* }}` from the worker process and `{{ secrets.* }}` from token entries in the server vault immediately before the worker executes the steps. Worker-time environment and secret expressions remain in the persisted run definition; resolved secret values are not persisted. A missing environment variable, missing secret, or non-token secret aborts startup with the affected step and token named in the error.
Fabro substitutes `{{ vars.* }}` when the server creates the run, then resolves `{{ secrets.* }}` from token entries in the server vault immediately before the worker executes the steps. Secret expressions remain in the persisted run definition; resolved secret values are not persisted. A missing or non-token secret aborts startup with the affected step and token named in the error.
### `[run.clone]`
@ -295,13 +309,13 @@ When `provider = "local"`, Fabro runs directly in the resolved working
directory. If you want local isolation, create or enter a separate clone or Git
worktree yourself.
Environment variable values can combine literal text with server variables, worker environment variables, and token secrets:
Environment variable values can combine literal text with server variables and token secrets:
```toml title="run.toml"
[environments.ci.env]
API_KEY = "{{ secrets.SERVICE_API_KEY }}"
NODE_ENV = "production"
SERVICE_URL = "https://api.{{ env.REGION }}.example.com"
SERVICE_URL = "https://api.{{ vars.REGION }}.example.com"
RELEASE_CHANNEL = "{{ vars.RELEASE_CHANNEL }}"
```
@ -309,11 +323,10 @@ RELEASE_CHANNEL = "{{ vars.RELEASE_CHANNEL }}"
|---|---|
| `"literal"` | Static value passed as-is |
| `"{{ vars.NAME }}"` | Server-managed variable substituted when the run is created |
| `"{{ env.VARNAME }}"` | Worker process environment value resolved when the run starts |
| `"{{ secrets.NAME }}"` | Token secret resolved from the server vault when the run starts |
| `"prefix-{{ env.X }}-suffix"` | Substring interpolation; multiple supported tokens per string are allowed |
| `"prefix-{{ vars.X }}-suffix"` | Substring interpolation; multiple supported tokens per string are allowed |
Missing or non-token secret references fail closed before sandbox startup. For backward compatibility, an environment value that references only a missing `{{ env.* }}` value is passed through in source form; use preflight or prepare-step interpolation when an absent worker variable must be a hard error.
Missing or non-token secret references fail closed before sandbox startup. `{{ env.* }}` is not supported: the process environment is not a configuration source. Use `{{ vars.NAME }}` for a non-sensitive value or `{{ secrets.NAME }}` for a credential.
### `[run.integrations.github.permissions]`
@ -349,13 +362,13 @@ channel = "#deploys"
| `enabled` | Enables this route. Defaults to `false`. |
| `provider` | Notification provider. Use `"slack"` for Slack lifecycle notifications. Other provider names may be parsed but are not delivered by the server yet. |
| `events` | Raw Fabro event names that trigger this route, such as `run.started`, `run.completed`, and `run.failed`. |
| `[run.notifications.<name>.slack].channel` | Required for Slack lifecycle notifications. Literal channel names and `{{ env.VAR }}` interpolation are supported. |
| `[run.notifications.<name>.slack].channel` | Required for Slack lifecycle notifications. Literal channel names and `{{ vars.NAME }}` interpolation are supported. |
Each enabled Slack route posts once for each matching lifecycle event. Messages include the run ID, an Open in Fabro link when available, workflow label, terminal result, duration, and pull request details when those are already present in the run event stream.
`run.failed` is emitted only when the run terminally fails. A failed stage that routes onward to a normal completion path produces `run.completed`, not `run.failed`.
If a Slack route's channel is missing, empty, or references an unresolved environment variable, Fabro logs a warning and skips that route. Delivery failures are logged and never fail or alter the run.
If a Slack route's channel is missing, empty, or contains an unsupported interpolation token, Fabro logs a warning and skips that route. Delivery failures are logged and never fail or alter the run.
### `[run.checkpoint]`
@ -489,7 +502,7 @@ id = "sentry"
| `startup_timeout` | Max duration for server startup + MCP handshake (e.g. `"10s"`, `"1m"`). | `"10s"` |
| `tool_timeout` | Max duration for a single tool call. | `"60s"` |
Inline transport commands, URLs, env values, and headers support `{{ vars.* }}`, `{{ env.* }}`, and `{{ secrets.* }}` interpolation. As with prepare steps, server variables resolve at run creation and worker env/token secrets resolve at launch; missing values fail closed. See [MCP runtime interpolation](/agents/mcp#runtime-interpolation) for the standalone `fabro exec` difference.
Inline transport commands, URLs, env values, and headers support `{{ vars.* }}` and `{{ secrets.* }}` interpolation. As with prepare steps, server variables resolve at run creation and token secrets resolve at launch; missing values fail closed. See [MCP runtime interpolation](/agents/mcp#runtime-interpolation) for the standalone `fabro exec` difference.
The `sandbox` transport runs the MCP server inside the workflow's sandbox. This is useful for tools that need access to the sandbox environment, such as browser automation with Playwright. See [MCP](/agents/mcp#sandbox) for details.

View file

@ -55,6 +55,7 @@ When you choose the GitHub App strategy, the CLI opens GitHub with a pre-filled
| Permission | Level | Purpose |
|---|---|---|
| Contents | Write | Clone repos, push run branches and checkpoints |
| Workflows | Write | Push changes under `.github/workflows/` |
| Metadata | Read | Look up repository installation status |
| Pull requests | Write | Create and update PRs from workflows |
| Checks | Write | Report workflow status on commits |
@ -219,7 +220,7 @@ When a workflow runs in a remote sandbox (Daytona or Docker), Fabro clones the c
2. SSH URLs (e.g. `git@github.com:owner/repo.git`) are converted to HTTPS
3. Fabro signs a short-lived JWT using the App ID and private key (RS256, 10-minute validity)
4. Using the JWT, Fabro looks up the GitHub App installation for the repository (`GET /repos/\{owner\}/\{repo\}/installation`)
5. Fabro requests a scoped Installation Access Token with `contents: write` permission on the specific repository
5. Fabro requests a scoped Installation Access Token with `contents: write` and `workflows: write` permissions on the specific repository
6. The sandbox clones via HTTPS using `x-access-token` as the username and the token as the password
For public repositories, the clone works without credentials. The token is still generated because it's needed for pushing checkpoints.
@ -248,7 +249,9 @@ The upper bound on what Fabro will mint is whatever permissions the GitHub App i
### Checkpoint pushing
After each workflow stage, Fabro [checkpoints](/execution/checkpoints) by pushing the run branch and metadata branch to origin. Inside remote sandboxes, the git remote URL is configured with the Installation Access Token for authenticated pushing.
After each workflow stage, Fabro [checkpoints](/execution/checkpoints) by pushing the run branch and metadata branch to origin. Before a successful run becomes terminal, the publish stage pushes the final commit again and treats failure as a run failure. Inside remote sandboxes, the git remote URL is configured with the Installation Access Token for authenticated pushing.
When pull request creation is enabled, Fabro then checks that GitHub reports the run branch at the exact final commit before opening the PR. A failed final push, branch check, or PR creation marks the run as failed with `publish_failed`; the terminal run event is emitted only after this step finishes.
For long-running workflows, Fabro refreshes the token before each push since Installation Access Tokens are short-lived (typically 1 hour).

View file

@ -0,0 +1,173 @@
---
title: "Modal"
description: "Run Kimi K3 through Modal's OpenAI-compatible inference endpoints"
---
[Modal](https://modal.com/) serves Kimi K3 through an OpenAI-compatible Shared API and through dedicated Auto Endpoints. Fabro ships a disabled `modal` provider entry for Kimi K3. Enable it after Modal gives you an endpoint URL.
## Prerequisites
- A [Modal account](https://modal.com/signup)
- A [Kimi K3 Shared API or Auto Endpoint](https://modal.com/library/moonshot/kimi-k3)
- A Modal proxy-token pair
## Create or select an endpoint
Use the Kimi K3 Shared API from the Modal model library, or create a dedicated Auto Endpoint:
```bash
modal endpoint create --model moonshotai/Kimi-K3
```
Find the endpoint URL in the Modal dashboard or with `modal endpoint list`. Modal serves its OpenAI-compatible API under `/v1`.
## Create a proxy token
Modal endpoints are authenticated with two headers. Create a proxy-token pair:
```bash
modal workspace proxy-tokens create
```
The command prints a token ID that starts with `wk-` and a secret that starts with `ws-`. Modal shows the secret only once, so save both values immediately.
If your Modal workspace uses RBAC, allow the token in the endpoint's environment:
```bash
modal workspace proxy-tokens allow wk-... main
```
## Enable the provider
Add the provider override to the settings file used by the Fabro server. Include `/v1` in the endpoint URL and omit a trailing slash.
```toml title="settings.toml"
_version = 1
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
The endpoint URL is not built into Fabro because Modal assigns it to your Shared API or Auto Endpoint.
## Configure credentials
Store both proxy-token values in the target Fabro server vault:
```bash
fabro secret set MODAL_TOKEN_ID wk-...
fabro secret set MODAL_TOKEN_SECRET ws-...
# For a non-default remote server:
fabro secret --server https://your-fabro.example set MODAL_TOKEN_ID wk-...
fabro secret --server https://your-fabro.example set MODAL_TOKEN_SECRET ws-...
```
<Note>
`fabro provider login --provider modal` is not supported in this release because that command accepts one credential value. Use the two `fabro secret set` commands above.
</Note>
Modal does not use a bearer API key for these endpoints. Fabro sends the vault values as `Modal-Key` and `Modal-Secret` headers and does not send an `Authorization` header.
## Included model
| Fabro model slug | Modal API ID | Context | Input / cached input / output | Estimated speed |
| --- | --- | --- | --- | --- |
| `kimi-k3` | `moonshotai/Kimi-K3` | 1M tokens | $3.00 / $0.30 / $15.00 per MTok | 460 tok/s |
The catalog marks Kimi K3 as supporting tools, vision, reasoning, and prompt caching. Modal's model ID is case-sensitive.
## Use Kimi K3
```bash
fabro model list --provider modal
fabro model test --provider modal --model kimi-k3
fabro run workflow.fabro --provider modal --model kimi-k3
```
When targeting a non-default remote server, pass the same `--server` value:
```bash
fabro model list --server https://your-fabro.example --provider modal
fabro model test --server https://your-fabro.example --provider modal --model kimi-k3
```
In workflow stylesheets:
```dot title="workflow.fabro"
digraph Example {
graph [
model_stylesheet="
* { model: modal/kimi-k3; }
"
]
start [shape=Mdiamond, label="Start"]
work [label="Work", prompt="Use Kimi K3 through Modal."]
exit [shape=Msquare, label="Exit"]
start -> work -> exit
}
```
## Direct SDK environment credentials
The built-in Modal provider reads its two headers from the Fabro vault. `EnvCredentialSource` does not configure Modal automatically because Modal uses two headers instead of one API-key reference.
For direct SDK use, enable Modal and set its endpoint URL in the catalog:
```toml title="settings.toml"
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
Then read both environment variables explicitly and create a typed credential after constructing `catalog` from those settings:
```rust
use fabro_auth::ApiCredential;
use fabro_llm::client::Client;
use std::collections::HashMap;
let credential = ApiCredential::with_extra_headers(
"modal",
HashMap::from([
("Modal-Key".to_string(), std::env::var("MODAL_TOKEN_ID")?),
(
"Modal-Secret".to_string(),
std::env::var("MODAL_TOKEN_SECRET")?,
),
]),
);
let client = Client::from_credentials(vec![credential], catalog).await?;
```
## Costs
Fabro estimates Shared API costs from Modal's published Kimi K3 prices. Completion and reasoning tokens use the output rate. Modal responses do not include an authoritative charge, so Fabro reports `cost_source = "estimated"`.
Dedicated Auto Endpoints use Modal compute billing instead of the Shared API token prices. The Fabro estimate does not represent that compute bill.
## Troubleshooting
**"provider 'modal' uses openai_compatible adapter but does not configure base_url"** — Add the Modal endpoint URL under `[llm.providers.modal]`. Include `/v1`.
**Modal is not configured** — Set both `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` in the target server vault. One value is not sufficient.
**401 or 403** — Confirm that the token pair belongs to the correct Modal workspace and environment. If the workspace uses RBAC, allow the token in that environment.
**404** — Confirm that the base URL is the endpoint URL followed by `/v1`, with no trailing slash.
**Unknown model** — The built-in API ID is exactly `moonshotai/Kimi-K3`. Run `fabro model test --provider modal --model kimi-k3` to test the configured offering.
## Further reading
<Columns cols={2}>
<Card title="Modal Kimi K3" icon="microchip" href="https://modal.com/library/moonshot/kimi-k3">
Shared API prices, model specifications, and Auto Endpoint setup.
</Card>
<Card title="Modal endpoint authentication" icon="key" href="https://modal.com/docs/guide/endpoints#proxy-tokens">
Proxy-token headers and endpoint calling conventions.
</Card>
</Columns>

View file

@ -117,7 +117,7 @@ enabled = true
default_channel = "#fabro-reviews"
```
`default_channel` is a literal channel name used only for human-in-the-loop interview prompts. Fabro does not interpolate `{{ env.* }}` in this server setting. Run lifecycle notifications use per-run or per-workflow `[run.notifications]` routes instead, whose channel values can use environment interpolation.
`default_channel` is a literal channel name used only for human-in-the-loop interview prompts; Fabro does not interpolate it. Run lifecycle notifications use per-run or per-workflow `[run.notifications]` routes instead, whose channel values support `{{ vars.NAME }}` interpolation.
### 8. Invite the bot
@ -182,7 +182,7 @@ Each enabled route posts one message when a matching event is emitted. Lifecycle
`run.failed` is a terminal run event. A stage can fail and still be followed by another graph edge that lets the run complete; in that case a route listening for `run.completed` fires, not `run.failed`.
The route-level Slack channel is required for lifecycle notifications. The channel may be a literal (`"#deploys"`) or an environment interpolation (`"{{ env.DEPLOYS_SLACK_CHANNEL }}"`). If the channel is missing, empty, or cannot be resolved, Fabro logs a warning and skips that route without affecting the run or other notification routes.
The route-level Slack channel is required for lifecycle notifications. The channel may be a literal (`"#deploys"`) or a server variable (`"{{ vars.DEPLOYS_SLACK_CHANNEL }}"`). If the channel is missing, empty, or cannot be resolved, Fabro logs a warning and skips that route without affecting the run or other notification routes.
Lifecycle notifications are one-way and fire-and-forget. They never accept answers, register reply threads, update prior messages, or interact with interview state.

View file

@ -113,7 +113,7 @@ plan [label="Plan", prompt="Create an implementation plan."]
**Node identifiers** must start with a letter or underscore, followed by letters, digits, or underscores (e.g. `run_tests`, `gate_1`, `_private`).
Nodes referenced in edges are auto-created if not explicitly declared.
Every node used by an edge needs its own declaration. Validation fails when an edge names a node the workflow never declares, because that is nearly always a typo or a rename that missed an edge. The declaration can come before or after the edges that use it, and it can live in a subgraph.
### Edge declarations
@ -266,9 +266,31 @@ audit [
| Attribute | Type | Description |
|---|---|---|
| `max_parallel` | Integer | Maximum concurrent branches (default: 4). The node always waits for every branch. |
| `for_each` | String | Flat runtime context key containing a JSON array. Runs the node's single agent or prompt target once per item. `context.items` first checks that exact key, then falls back to `items`. |
For the first node in each branch, `fidelity` resolves from the fork-to-branch edge, then the branch node; without either, the fork preamble is inherited unchanged. Branch-specific preambles are rendered before fan-out from the fork's context snapshot. Concurrent branches cannot share sessions, so explicit branch `full` becomes `summary:high`, and branch-level `thread_id` is inert.
When `for_each` is set, the parallel node must have exactly one outgoing edge,
and its target must be an agent or prompt node. Fabro resolves the source as an
inline array or a managed `blob://` or `file://` JSON artifact, then clones the
target once per item. Nested `for_each` is not supported.
Each clone receives the target's normal prompt followed by the item as pretty
JSON inside a fresh `<untrusted-{16 lowercase hex}>` fence with a matching
closing tag, plus a fixed notice that the content is data, not instructions.
There is no item interpolation syntax. The fence prevents an item from closing
its own data block, but it does not restrict an agent's tools; workflow authors
must give the target only the tool access appropriate for untrusted item data.
A source array above 1000 items fails the parallel stage before any branch
starts. Filter the array in the node that produces it, or split the work across
runs.
Dynamic results retain input order. Each result uses the template target ID and
adds a zero-based `index` plus `item_label`, derived from the item's `name`,
then `label`, then its index. An empty array succeeds and proceeds directly to
the target's fan-in node without executing the unparameterized target.
### Wait nodes
| Attribute | Type | Description |
@ -280,6 +302,7 @@ For the first node in each branch, `fidelity` resolves from the fork-to-branch e
| Attribute | Type | Description |
|---|---|---|
| `question_type` | String | Optional interview question type override: `yes_no`, `confirmation`, `multiple_choice`, `multi_select`, or `freeform`. Defaults to `freeform` when the gate only has a freeform edge; otherwise defaults to `multiple_choice`. |
| `review_target` | Boolean | When `true`, read and validate the typed `review_target` context value, then present it as the primary link in the question. Fabro generates the question text, so the node's `label` is not used. See [Review targets](/workflows/human-in-the-loop#review-targets). |
| `human.default_choice` | String | Target node to use when the question times out. |
### Manager loop (sub-workflow) nodes

View file

@ -375,6 +375,34 @@ For env-backed usage, `EnvCredentialSource` checks for API key environment varia
The first provider registered becomes the default. Provider base URLs come from the model catalog. For vault-backed usage inside Fabro, use `fabro_auth::VaultCredentialSource` instead.
The built-in Modal definition reads two proxy-token headers from the vault, so `EnvCredentialSource` does not configure it automatically. For direct SDK use, enable Modal and set its endpoint URL in the catalog:
```toml
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
Then read the two environment variables explicitly and create a typed credential after constructing `catalog` from those settings:
```rust
use fabro_auth::ApiCredential;
use fabro_llm::client::Client;
use std::collections::HashMap;
let credential = ApiCredential::with_extra_headers(
"modal",
HashMap::from([
("Modal-Key".to_string(), std::env::var("MODAL_TOKEN_ID")?),
(
"Modal-Secret".to_string(),
std::env::var("MODAL_TOKEN_SECRET")?,
),
]),
);
let client = Client::from_credentials(vec![credential], catalog).await?;
```
#### Creating manually
```rust

View file

@ -94,7 +94,7 @@ aliases = ["gateway"]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}"
x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}"
x-portkey-config = "@bedrock-prod"
[llm.providers.proxy.models."team-code-large"]
@ -184,7 +184,7 @@ aliases = ["gateway"]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}"
x-portkey-api-key = "{{ secrets.portkey_api_key }}"
x-portkey-config = "@bedrock-prod"
x-team-secret = "{{ secrets.gateway_team_secret }}"
```
@ -199,7 +199,7 @@ x-team-secret = "{{ secrets.gateway_team_secret }}"
| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>`, `env:<NAME>`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. |
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are interpolation strings: literal text, an `{{ env.NAME }}` token, or a `{{ secrets.NAME }}` token. Put credentials in a secret and reference them with a `{{ secrets.NAME }}` token, not a bare literal. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are literal text or `{{ secrets.NAME }}` interpolation strings. Put credentials in a secret and reference them with a token, not a bare literal. |
| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection; ties use canonical provider ID. |
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |
@ -381,12 +381,12 @@ permissions = "read-write"
[run.model]
provider = "anthropic"
name = "claude-sonnet-4-5"
fallbacks = ["openai", "gpt-5.4"]
fallbacks = ["openrouter:kimi-k3", "gpt-terra"]
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `fallbacks` | array<string> | [] | Ordered list of fallback model references. Supports `...` splice marker<br />at layering time — see [`super::splice_array`]. |
| `fallbacks` | array<string> | [] | Ordered fallback references: bare providers, bare model IDs or aliases,<br />or provider-qualified `provider:selector` values. A qualified selector<br />may be a model ID, alias, or provider API ID. Legacy `provider/model`<br />values remain accepted. Supports the `...` splice marker at layering<br />time — see [`super::splice_array`]. |
| `name` | string | None | Model name for workflow runs. |
| `provider` | string | None | Provider name for workflow model selection. |

View file

@ -91,9 +91,73 @@ fork [shape=component, max_parallel=2]
This is useful when branches are resource-intensive (e.g., each running a full agent session with tool calls) and you want to limit concurrency.
## Review a runtime candidate list
Static branches work when the review perspectives are known while writing the
graph. A security scan often discovers its review candidates at runtime. Write
that array to a flat context key, then use `for_each` to run one reviewer per
candidate:
```dot
discover [
shape=tab,
output_schema="routing",
prompt="Identify security review candidates. Return only JSON with \
context_updates.candidates as an array of objects. Give every object a \
name, path, and reason."
]
review_batch [
shape=component,
for_each="context.candidates",
max_parallel=4
]
reviewer [
label="Candidate Reviewer",
prompt="Inspect this candidate for exploitable security problems. Report \
evidence, severity, and a concrete remediation."
]
aggregate [
shape=tripleoctagon,
prompt="Synthesize all candidate reviews. Call out candidates whose branch failed."
]
discover -> review_batch
review_batch -> reviewer
reviewer -> aggregate
```
The `routing` output schema merges `context_updates.candidates` into the flat
`candidates` context key. `review_batch` accepts that inline array or its
automatically offloaded managed artifact reference. It clones `reviewer` for
each item, appends the item as pretty JSON inside a fresh
`<untrusted-{16 lowercase hex}>` fence with a matching closing tag, and keeps
`parallel.results` in candidate order.
Each result has `id="reviewer"`, a zero-based `index`, and an `item_label`
chosen from the item's `name`, then `label`, then index. An empty candidate
array succeeds and proceeds directly to `aggregate`. Mixed success and failure
also proceeds; only an all-failed batch fails the parallel stage.
Retries and executor-enforced timeouts apply independently to each candidate.
A retry keeps the same result index and branch identity, and releases its
`max_parallel` slot while waiting for backoff. If a run stops partway through
the batch, resuming it reruns every item because per-item checkpoints are not
created.
<Warning>
The randomized fence prevents candidate text from closing its own data block,
but it does not sandbox a tool-enabled reviewer. Candidate data can still try
to influence the model. Limit the target agent's tools and permissions to the
minimum needed for the review.
</Warning>
## What you've learned
- **Fan-out nodes** (`shape=component`) spawn concurrent branches and wait for all of them
- `for_each` runs one agent or prompt template for every item in a runtime array
- Parallel branches share one checkout, so workflows must prevent or tolerate file races
- **Fan-in nodes** (`shape=tripleoctagon`) can synthesize `parallel.results` with a prompt
- Fan-in never selects or restores workspace state, and no results file is created

View file

@ -60,6 +60,68 @@ confirm -> exit [label="[N] No"]
Supported values are `yes_no`, `confirmation`, `multiple_choice`, `multi_select`, and `freeform`.
### Review targets
A human gate can present one external document as the primary review link. Set
`review_target=true` on the gate:
```dot
review [
shape=hexagon,
review_target=true
]
review -> sync [label="[S] Review complete; sync the current Markdown"]
review -> address [label="[A] Ask the agent to address human feedback"]
review -> review [label="[C] Continue reviewing"]
```
Before the workflow reaches the gate, an agent, prompt, or command node must set
the flat `review_target` context key. A routing response can do this directly:
```json
{
"outcome": "succeeded",
"context_updates": {
"review_target": {
"label": "Quarry review exercise",
"url": "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef",
"kind": "document"
}
}
}
```
Fabro then presents this question:
> Review the [Quarry review exercise](https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef) document, then choose the next action.
The link uses the target URL from context. The example uses a placeholder
secret, not a live Quarry document.
Fabro generates this question text from the target. A `label` on the gate is
not used while `review_target=true`.
The target object has three required fields:
| Field | Meaning |
|---|---|
| `label` | The link text. It must contain 1 to 200 characters and no control characters. |
| `url` | An absolute HTTP or HTTPS URL. It must contain a host, must not contain URL credentials, and must be at most 2048 characters. |
| `kind` | The resource type. The supported value is `document`. |
Fabro validates the target before it starts the interview. A missing or invalid
target fails the gate deterministically. A gate without `review_target=true`
ignores this context key and keeps its normal label.
The context value remains available after the human answers. A feedback loop
can return to the same gate without recreating the target. A later stage can
replace the target by writing a new value to the same context key.
Fabro does not fetch the URL. Web and Slack clients open it as an external link.
Treat bearer-capability links as secrets and only provide them to people who
can access the run.
### Default choice on timeout
If a human gate has a timeout configured, you can specify a default choice using the `human.default_choice` attribute:

View file

@ -106,7 +106,7 @@ test [label="Run Tests", script="cargo test 2>&1 || true"]
| Attribute | Description |
|---|---|
| `script` | The shell command to execute (required) |
| `script` | The shell command to execute (required). Substitutes `{{ goal }}`, `{{ inputs.NAME }}`, and `{{ vars.NAME }}` — see [command node scripts](/workflows/variables#command-node-scripts) |
| `language` | `"shell"` (default) or `"python"` |
### Human
@ -170,6 +170,28 @@ fork -> quality
| Attribute | Description |
|---|---|
| `max_parallel` | Maximum concurrent branches (default: 4) |
| `for_each` | Flat context key containing a runtime JSON array. Requires one outgoing agent or prompt template target. |
To run one template node for a runtime array, add `for_each`:
```dot
review_batch [shape=component, for_each="context.candidates", max_parallel=8]
reviewer [prompt="Review this candidate for security issues."]
aggregate [shape=tripleoctagon, prompt="Synthesize every candidate review."]
review_batch -> reviewer -> aggregate
```
Fabro accepts an inline array or a managed JSON artifact reference. It runs
`reviewer` once per item, appends the item to the prompt as fenced data, and
keeps the results in input order. The source lookup is flat:
`context.candidates` checks that exact key and then `candidates`; it does not
traverse nested objects.
The template target must be an agent or prompt node, and nested `for_each` is
rejected. An empty source array succeeds with `parallel.results=[]` and skips
straight to `aggregate`. Missing, invalid, non-array, or over-long sources fail
the parallel stage before any branches start; the limit is 1000 items.
Because the checkout is shared, file changes from one branch are immediately visible to the others. Concurrent writes can race or overwrite each other. Fabro does not isolate branch files, lock paths, detect conflicts, or warn about overlapping writes. Design branches to be read-only or assign each branch disjoint files and directories when deterministic workspace changes matter.

View file

@ -3,7 +3,7 @@ title: "Variables"
description: "Using templates in workflows"
---
Fabro renders `{{ ... }}` templates in exactly two workflow attributes: the graph `goal` and node `prompt`s. Every other attribute is literal text.
Fabro renders `{{ ... }}` templates in exactly two workflow attributes: the graph `goal` and node `prompt`s. A command node's `script` gets narrower treatment — [simple value substitution](#command-node-scripts), not templating. Every other attribute is literal text.
## Template context
@ -15,7 +15,7 @@ Goal templates can reference inputs and server-managed variables. Prompt templat
| `{{ inputs.name }}` | A value from `[run.inputs]`, optionally overridden by CLI input flags |
| `{{ vars.NAME }}` | A server-managed variable snapshotted when the run is created |
Environment variables and secrets are **not** available in goal or prompt templates. Use `{{ env.NAME }}` and `{{ secrets.NAME }}` only in the configuration fields that support run-boundary interpolation.
Secrets are **not** available in goal or prompt templates. Use `{{ secrets.NAME }}` only in the configuration fields that support run-boundary interpolation.
## Run config inputs
@ -51,7 +51,7 @@ digraph Check {
}
```
Other attributes — `script`, `label`, `model`, `provider`, `condition`, and all edge attributes — do not render templates. If one of them contains `{{ … }}` or `{% … %}`, the syntax is treated as literal text and Fabro records a `detemplated_attribute` warning suggesting you move the dynamic value into a `prompt` or `goal`.
Other attributes — `label`, `model`, `provider`, `condition`, and all edge attributes — do not render templates. If one of them contains `{{ … }}` or `{% … %}`, the syntax is treated as literal text and Fabro records a `detemplated_attribute` warning suggesting you move the dynamic value into a `prompt` or `goal`.
Override individual inputs at run time with repeatable `-I` / `--input` flags:
@ -61,6 +61,62 @@ fabro run .fabro/workflows/check/workflow.toml -I repo_name=fabro-2 --input lang
CLI input values use TOML scalar parsing when possible. Quoted strings, booleans, integers, and floats keep their typed values; unquoted bare text falls back to a string. Empty values such as `foo=` are accepted as empty strings. Arrays, inline tables, and datetimes are rejected.
## Command node scripts
A [command node](/workflows/stages-and-nodes#command) `script` substitutes `{{ goal }}`, `{{ inputs.NAME }}`, and `{{ vars.NAME }}`:
```dot title="check.fabro"
digraph Check {
test [shape=parallelogram, script="cargo test -p {{ inputs.crate }} --profile {{ vars.PROFILE }}"]
pr [shape=parallelogram, script="gh pr create --title {{ goal }}"]
}
```
This is value substitution, not templating. Only those three forms are recognized; every other brace sequence reaches the shell untouched, so `jq` filters, `awk` programs, Go templates, and brace expansion all keep working:
```dot
report [shape=parallelogram, script="kubectl get pod -o go-template='{{ .status.phase }}' | jq '{phase: .}'"]
```
There is no `{% if %}`, no filters, and no loops, and `{{ goal }}` has no dotted form — `{{ goal.title }}` stays literal. Put branching in a [conditional node](/workflows/stages-and-nodes#conditional) or in the shell itself.
In a shell script, Fabro quotes each substituted value as one shell argument. Put the token where one shell word is valid. Do not add quotes around the token, and do not use a token to inject multiple flags or shell syntax:
```dot
build [shape=parallelogram, script="docker build -t {{ inputs.image }} ."]
```
For `language="python"`, Fabro inserts each value as a quoted string literal. Put the token where a Python expression is valid:
```dot
report [shape=parallelogram, language="python", script="print({{ goal }})"]
```
Substituted text is never scanned again, so a goal or input containing `{{ ... }}` reaches the command as literal characters rather than being interpolated a second time.
### `env` and `secrets` are not substituted
`{{ env.NAME }}` and `{{ secrets.NAME }}` are rejected in a `script` with a validation error. Read environment variables with `$NAME` in a shell script:
```dot
deploy [shape=parallelogram, script="deploy --token $DEPLOY_TOKEN"]
```
In a Python script, read them with `os.environ["NAME"]`.
To make a secret available that way, put it in the [environment's env map](/execution/run-configuration#run-environment-and-environments-slug), where `{{ secrets.* }}` does resolve — at run start, into the sandbox environment rather than into the script text:
```toml title="run.toml"
[environments.ci.env]
DEPLOY_TOKEN = "{{ secrets.DEPLOY_TOKEN }}"
```
This keeps secret values out of the `command.started` event, which records the script verbatim.
### When values resolve
Inputs and variables are substituted when the run is created, at the same time as goals and prompts — the persisted workflow already contains the final script. An unbound input or variable is a warning from `fabro validate` and an error at run creation, so a run never executes a partially substituted command.
## Server-managed run config variables
Use server-managed variables for non-sensitive values that should be shared across runs, such as deployment environments, default branches, regions, or image tags:
@ -115,8 +171,9 @@ Fabro keeps workflow structure static and renders workflow templates once:
2. Literal `import`, `@file`, graph-goal file, and child-workflow references are resolved.
3. The graph `goal` is rendered with the `{ inputs, vars }` context.
4. Node `prompt` attributes are rendered with the `{ goal, inputs, vars }` context.
5. Node `script` attributes have their `{{ goal }}`, `{{ inputs.* }}`, and `{{ vars.* }}` values substituted.
Templates are not supported in graph syntax, node IDs, edge structure, `import` paths, `@file` paths, child workflow paths, other file references, or any attribute besides `prompt` and `goal`.
Templates are not supported in graph syntax, node IDs, edge structure, `import` paths, `@file` paths, child workflow paths, other file references, or any attribute besides `prompt` and `goal` — and `script`, which takes value substitution rather than templates.
Fabro renders the graph `goal` first and stores the rendered value back onto the graph. Prompts that use `{{ goal }}` receive that rendered value.
@ -124,6 +181,8 @@ Fabro renders the graph `goal` first and stores the rendered value back onto the
Fabro renders undefined workflow variables as empty text and records a `template_undefined_variable` diagnostic. `fabro validate` reports that diagnostic as a warning so you can validate workflow structure before all inputs are known. Offline validation does not read a server's variable store, so `{{ vars.* }}` references also warn there. Run-style commands such as `fabro run`, `fabro create`, and preflight use the server snapshot and promote any still-undefined reference to an error before proceeding.
In a `script`, an undefined value records the same diagnostic but leaves the token in place rather than emptying it, so validation output shows what is unbound.
## Template includes
Prompt and goal templates support static MiniJinja loader dependencies such as `{% include "partial.md" %}`. Includes are resolved relative to the template file being rendered and can be nested.

View file

@ -1036,7 +1036,7 @@ pub(crate) struct RunWorkerArgs {
/// Fabro storage directory for loading worker-visible secrets
#[arg(long, hide = true)]
pub(crate) storage_dir: Option<PathBuf>,
pub(crate) storage_dir: PathBuf,
/// Run scratch directory
#[arg(long)]

View file

@ -282,14 +282,6 @@ impl ProviderAdapter for AuthenticatedFabroServerAdapter {
}
}
#[expect(
clippy::disallowed_methods,
reason = "exec-boundary MCP transport InterpString resolution facade for {{ env.* }} values."
)]
fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn run_mcp_servers_for_exec(
mcps: &HashMap<String, ResolvedMcpEntry>,
) -> AnyResult<Vec<McpServerSettings>> {
@ -341,17 +333,14 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu
.transpose()?
.unwrap_or_default(),
};
// Resolve `{{ env.* }}` in MCP transport config at the exec boundary,
// against the CLI process env — the mirror of the `fabro run` worker
// boundary in `fabro_workflow::operations::start::runtime_mcp_server`.
// Both consumers read the same source-form settings; missing env is a hard
// error. `fabro exec` has no server vault, so secrets/inputs tokens surface
// loudly rather than leaking.
// Fully validate MCP transport config at the exec boundary. `fabro exec`
// has no server vault, so secret and unsupported tokens fail instead of
// reaching the transport.
let mcp_servers = mcp_servers
.into_iter()
.map(|settings| {
settings
.resolve_transport_env(process_env_var, |_| None)
.resolve_transport_secrets(|_| None)
.with_context(|| format!("failed to resolve MCP server {:?}", settings.name))
})
.collect::<AnyResult<Vec<_>>>()?;

View file

@ -438,6 +438,7 @@ fn api_question_to_question(question: &types::ApiQuestion) -> Question {
converted
.context_display
.clone_from(&question.context_display);
converted.review_target.clone_from(&question.review_target);
converted
}
@ -451,6 +452,9 @@ async fn ask_attach_question(question: Question, styles: &'static Styles) -> Ans
let rendered = styles.render_markdown(context_text);
eprint!("{rendered}");
}
if let Some(line) = fabro_interview::review_target_line(&question) {
eprintln!("{line}");
}
eprintln!("{} {}", styles.bold_cyan.apply_to("?"), question.text);
match question.question_type {

View file

@ -61,11 +61,8 @@ pub(crate) async fn create_run(
None
};
let mut validation = manifest_validation::validate_manifest(
&RunLayer::default(),
&built.manifest,
ctx.catalog()?,
)?;
let mut validation =
manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)?;
manifest_validation::promote_template_undefined_variables_to_errors(&mut validation);
let diagnostics = api_diagnostics_to_local(&validation.workflow.diagnostics);
if !quiet {

View file

@ -3,7 +3,7 @@ use std::convert::TryFrom;
use chrono::{DateTime, Utc};
use fabro_agent::Error as AgentError;
use fabro_types::{BilledModelUsage, EventBody, LlmOutputKind, RunEvent};
use fabro_util::error;
use fabro_util::{error, text};
use fabro_workflow::event::RunNoticeLevel;
use serde_json::Value;
@ -330,11 +330,15 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
delay_ms: props.delay_ms,
}),
EventBody::ParallelStarted(_) => Some(ProgressEvent::ParallelStarted),
EventBody::ParallelBranchStarted(_) => {
Some(ProgressEvent::ParallelBranchStarted { branch: node_id })
}
EventBody::ParallelBranchStarted(props) => Some(ProgressEvent::ParallelBranchStarted {
branch: parallel_branch_display(&node_id, props.index, props.item_label.as_deref()),
}),
EventBody::ParallelBranchCompleted(props) => Some(ProgressEvent::ParallelBranchCompleted {
branch: node_id,
branch: parallel_branch_display(
&node_id,
props.index,
props.item_label.as_deref(),
),
duration_ms: props.duration_ms,
status: props.status,
}),
@ -464,6 +468,21 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
}
}
/// Name a parallel branch for the terminal.
///
/// `item_label` is sanitized where it is created, but events replayed from a
/// run recorded before that are not, and this string goes straight to the
/// terminal. Sanitizing again is cheap and keeps the guarantee local.
fn parallel_branch_display(node_id: &str, index: usize, item_label: Option<&str>) -> String {
item_label
.map(text::sanitize_display_label)
.filter(|label| !label.is_empty())
.map_or_else(
|| node_id.to_string(),
|label| format!("{label} ({node_id} #{index})"),
)
}
pub(super) fn from_json_line(line: &str) -> Option<ProgressEvent> {
let stored = RunEvent::from_json_str(line).ok()?;
from_run_event(&stored)
@ -513,6 +532,30 @@ mod tests {
use super::*;
#[test]
fn parallel_branch_display_neutralizes_runtime_labels() {
assert_eq!(
parallel_branch_display("reviewer", 0, Some("auth")),
"auth (reviewer #0)"
);
assert_eq!(parallel_branch_display("reviewer", 0, None), "reviewer");
// A label recorded before sanitizing moved to the source must not
// reach the terminal with escapes or newlines intact.
assert_eq!(
parallel_branch_display("reviewer", 1, Some("\u{1b}[31mauth\u{1b}[0m")),
"auth (reviewer #1)"
);
assert_eq!(
parallel_branch_display("reviewer", 2, Some("auth\nSUCCESS")),
"authSUCCESS (reviewer #2)"
);
// Nothing printable left, so fall back to the node id we control.
assert_eq!(
parallel_branch_display("reviewer", 3, Some("\u{1b}[0m ")),
"reviewer"
);
}
#[test]
fn parse_edge_selected() {
let stored = to_run_event(&fixtures::RUN_1, &Event::EdgeSelected {
@ -614,6 +657,7 @@ mod tests {
repo: "widgets".into(),
base_branch: "main".into(),
head_branch: "fabro/run/42".into(),
head_sha: Some("final-sha".to_string()),
title: "Ship the server-side PR".into(),
draft: true,
};

View file

@ -660,10 +660,11 @@ mod tests {
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
branch: "security".into(),
index: 0,
item_label: Some("auth".into()),
});
let stage = &ui.stage.active_stages["fork1"];
assert_eq!(stage.tool_calls.len(), 1);
assert_eq!(stage.tool_calls[0].tool_call_id, "security");
assert_eq!(stage.tool_calls[0].tool_call_id, "auth (security #0)");
assert!(matches!(
stage.tool_calls[0].status,
ToolCallStatus::Running
@ -674,6 +675,7 @@ mod tests {
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
branch: "security".into(),
index: 0,
item_label: Some("auth".into()),
duration_ms: 2000,
status: fabro_workflow::outcome::StageOutcome::Succeeded,
});
@ -701,6 +703,7 @@ mod tests {
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
branch: "security".into(),
index: 0,
item_label: None,
});
let stage = &ui.stage.active_stages["fork1"];
@ -1383,6 +1386,7 @@ mod tests {
repo: "fabro".into(),
base_branch: "main".into(),
head_branch: "fabro/run/42".into(),
head_sha: Some("final-sha".to_string()),
title: "Ship the change".into(),
draft: true,
});
@ -1477,12 +1481,14 @@ mod tests {
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
branch: "security".into(),
index: 0,
item_label: None,
});
emit(&mut ui, Event::ParallelBranchCompleted {
parallel_group_id: StageId::new("fork1", 1),
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
branch: "security".into(),
index: 0,
item_label: None,
duration_ms: 500,
status: fabro_workflow::outcome::StageOutcome::Succeeded,
});

View file

@ -76,7 +76,7 @@ enum WorkerTitlePhase {
pub(crate) async fn execute(
run_id: RunId,
server: String,
storage_dir: Option<PathBuf>,
storage_dir: PathBuf,
run_dir: PathBuf,
mode: RunWorkerMode,
worker_token: &str,
@ -110,7 +110,6 @@ pub(crate) async fn execute(
run_id,
run_spec.source_directory.as_deref(),
&run_dir,
Arc::clone(&catalog),
)
} else {
None
@ -137,13 +136,10 @@ pub(crate) async fn execute(
if let Some(control_manager) = &mut control_manager {
control_manager.wait_for_first_connection().await?;
}
let vault = load_worker_vault(storage_dir.as_deref()).await?;
let vault = load_worker_vault(&storage_dir).await?;
let github_app = {
let vault_guard = match &vault {
Some(arc) => Some(arc.read().await),
None => None,
};
maybe_build_github_credentials(&run_spec.settings, vault_guard.as_deref())?
let vault_guard = vault.read().await;
maybe_build_github_credentials(&run_spec.settings, &vault_guard)?
};
let services = StartServices {
run_id,
@ -170,7 +166,8 @@ pub(crate) async fn execute(
.run
.integrations
.github
.resolve_permissions(process_env_var),
.resolve_permissions()
.context("failed to resolve github permissions")?,
vault,
catalog,
on_node: None,
@ -237,13 +234,12 @@ fn build_fabro_run_tool_services(
current_run_id: RunId,
source_directory: Option<&str>,
run_dir: &Path,
catalog: Arc<Catalog>,
) -> Option<FabroRunToolServices> {
if worker_token.trim().is_empty() {
return None;
}
let backend = ClientBackend::new(Arc::new(client))
.with_manifest_builder(Arc::new(WorkerRunManifestBuilder { catalog }));
.with_manifest_builder(Arc::new(WorkerRunManifestBuilder));
Some(FabroRunToolServices {
backend: Arc::new(backend),
current_run_id,
@ -252,9 +248,7 @@ fn build_fabro_run_tool_services(
})
}
struct WorkerRunManifestBuilder {
catalog: Arc<Catalog>,
}
struct WorkerRunManifestBuilder;
impl fabro_tool::RunManifestBuilder for WorkerRunManifestBuilder {
fn build_run_manifest(
@ -263,20 +257,15 @@ impl fabro_tool::RunManifestBuilder for WorkerRunManifestBuilder {
cwd: &Path,
user_settings_path: &Path,
) -> fabro_tool::ToolResult<RunManifest> {
run_tool_manifest::build_run_tool_manifest(
spec,
cwd,
user_settings_path,
Arc::clone(&self.catalog),
)
run_tool_manifest::build_run_tool_manifest(spec, cwd, user_settings_path)
}
}
async fn load_worker_vault(storage_dir: Option<&Path>) -> Result<Option<Arc<AsyncRwLock<Vault>>>> {
let Some(storage_dir) = storage_dir else {
return Ok(None);
};
/// Load the worker's secret vault from the run's storage root.
///
/// A worker always receives the server storage root so it can load the same
/// secret vault as the server.
async fn load_worker_vault(storage_dir: &Path) -> Result<Arc<AsyncRwLock<Vault>>> {
let storage = Storage::new(storage_dir);
let vault = SecretStore::open_snapshot(storage.sqlite_path(), storage.secrets_path())
.await
@ -287,7 +276,7 @@ async fn load_worker_vault(storage_dir: Option<&Path>) -> Result<Option<Arc<Asyn
)
})?
.into_vault();
Ok(Some(Arc::new(AsyncRwLock::new(vault))))
Ok(Arc::new(AsyncRwLock::new(vault)))
}
const WORKER_CONTROL_RECONNECT_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
@ -1112,7 +1101,7 @@ fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
fn maybe_build_github_credentials(
settings: &WorkflowSettings,
vault: Option<&fabro_vault::Vault>,
vault: &fabro_vault::Vault,
) -> Result<Option<fabro_github::GitHubCredentials>> {
let resolved_run = &settings.run;
let resolved_server = ServerSettingsBuilder::load_default().ok();
@ -1143,14 +1132,6 @@ fn maybe_build_github_credentials(
Ok(None)
}
#[expect(
clippy::disallowed_methods,
reason = "CLI worker InterpString resolution facade for {{ env.* }} values."
)]
fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
/// Hard-gate for the CLI worker path: a run-level token is requested, or
/// a clone-based sandbox in non-dry-run mode will need credentials to
/// pull the repository. Pull-request-driven credential acquisition is
@ -1361,6 +1342,7 @@ mod tests {
allow_freeform: false,
timeout_seconds: None,
context_display: None,
review_target: None,
})),
Some(WorkerTitlePhase::Waiting)
);
@ -1747,7 +1729,7 @@ mod tests {
.set("ANTHROPIC_API_KEY", "vault-key", SecretType::Token, None)
.unwrap();
let loaded = load_worker_vault(Some(temp.path())).await.unwrap().unwrap();
let loaded = load_worker_vault(temp.path()).await.unwrap();
let guard = loaded.read().await;
let credential = guard.get("ANTHROPIC_API_KEY").unwrap();

View file

@ -23,11 +23,7 @@ pub(crate) fn run(
user_settings_path: Some(active_settings_path(None)),
..Default::default()
})?;
let response = manifest_validation::validate_manifest(
&RunLayer::default(),
&built.manifest,
base_ctx.catalog()?,
)?;
let response = manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)?;
let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics);
if base_ctx.json_output() {

View file

@ -496,7 +496,7 @@ async fn pre_tracing_bootstrap(command: &Commands) -> Result<PreTracingBootstrap
.await
}
Commands::RunCmd(RunCommands::RunWorker(args)) => {
prepare_run_worker_bootstrap(args.storage_dir.as_deref(), &args.run_dir)
prepare_run_worker_bootstrap(&args.storage_dir, &args.run_dir)
}
_ => Ok(PreTracingBootstrap::cli()),
}
@ -541,10 +541,10 @@ async fn prepare_server_bootstrap(
}
fn prepare_run_worker_bootstrap(
storage_dir: Option<&std::path::Path>,
storage_dir: &std::path::Path,
run_dir: &std::path::Path,
) -> Result<PreTracingBootstrap> {
let local_config = local_server::LocalServerConfig::load_with_storage_dir(storage_dir)?;
let local_config = local_server::LocalServerConfig::load_with_storage_dir(Some(storage_dir))?;
let runtime_directory = fabro_config::RuntimeDirectory::new(local_config.storage_dir());
let log_destination = fabro_config::resolve_log_destination(
local_config.config_log_destination().unwrap_or_default(),
@ -1515,6 +1515,8 @@ destination = "{destination}"
"__run-worker",
"--server",
"/tmp/fabro.sock",
"--storage-dir",
"/tmp/storage",
"--run-dir",
"/tmp/run",
"--run-id",
@ -1526,6 +1528,7 @@ destination = "{destination}"
match *cli.command.unwrap() {
Commands::RunCmd(RunCommands::RunWorker(args)) => {
assert_eq!(args.server, "/tmp/fabro.sock");
assert_eq!(args.storage_dir, std::path::PathBuf::from("/tmp/storage"));
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
assert!(matches!(args.mode, args::RunWorkerMode::Start));
@ -1541,6 +1544,8 @@ destination = "{destination}"
"__run-worker",
"--server",
"http://127.0.0.1:3000",
"--storage-dir",
"/tmp/storage",
"--run-dir",
"/tmp/run",
"--run-id",
@ -1552,6 +1557,7 @@ destination = "{destination}"
match *cli.command.unwrap() {
Commands::RunCmd(RunCommands::RunWorker(args)) => {
assert_eq!(args.server, "http://127.0.0.1:3000");
assert_eq!(args.storage_dir, std::path::PathBuf::from("/tmp/storage"));
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
assert!(matches!(args.mode, args::RunWorkerMode::Resume));

View file

@ -8,7 +8,7 @@ pub(crate) fn build_github_credentials(
strategy: GithubIntegrationStrategy,
app_id: Option<&str>,
app_slug: Option<&str>,
vault: Option<&Vault>,
vault: &Vault,
) -> anyhow::Result<Option<GitHubCredentials>> {
match strategy {
GithubIntegrationStrategy::App => {
@ -31,7 +31,7 @@ pub(crate) fn build_github_credentials(
/// Look up GitHub token: GITHUB_TOKEN env -> vault GITHUB_TOKEN -> GH_TOKEN env
/// -> vault GH_TOKEN
fn lookup_github_token(vault: Option<&Vault>) -> Option<String> {
fn lookup_github_token(vault: &Vault) -> Option<String> {
lookup_env_or_vault(EnvVars::GITHUB_TOKEN, vault)
.or_else(|| lookup_env_or_vault(EnvVars::GH_TOKEN, vault))
}
@ -40,10 +40,10 @@ fn lookup_github_token(vault: Option<&Vault>) -> Option<String> {
clippy::disallowed_methods,
reason = "GitHub credential resolution intentionally falls back from vault to documented process-env names."
)]
fn lookup_env_or_vault(name: &str, vault: Option<&Vault>) -> Option<String> {
fn lookup_env_or_vault(name: &str, vault: &Vault) -> Option<String> {
std::env::var(name)
.ok()
.or_else(|| vault.and_then(|v| v.get(name).map(str::to_string)))
.or_else(|| vault.get(name).map(str::to_string))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
}

View file

@ -49,54 +49,49 @@ where
pub(crate) fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles, printer: Printer) {
for d in diagnostics {
let location = match (&d.node_id, &d.edge) {
(Some(node), _) => format!(" [node: {node}]"),
(_, Some((from, to))) => format!(" [edge: {from} -> {to}]"),
_ => String::new(),
};
let source_prefix = source_prefix(d);
match d.severity {
Severity::Error if source_prefix.is_empty() => fabro_util::printerr!(
printer,
"{}{location}: {} ({})",
styles.red.apply_to("error"),
d.message,
styles.dim.apply_to(&d.rule),
),
Severity::Error => fabro_util::printerr!(
printer,
"{}: {source_prefix}{}{location} ({})",
styles.red.apply_to("error"),
d.message,
styles.dim.apply_to(&d.rule),
),
Severity::Warning if source_prefix.is_empty() => fabro_util::printerr!(
printer,
"{}{location}: {} ({})",
styles.yellow.apply_to("warning"),
d.message,
styles.dim.apply_to(&d.rule),
),
Severity::Warning => fabro_util::printerr!(
printer,
"{}: {source_prefix}{}{location} ({})",
styles.yellow.apply_to("warning"),
d.message,
styles.dim.apply_to(&d.rule),
),
Severity::Info => fabro_util::printerr!(
printer,
"{}",
styles.dim.apply_to(if source_prefix.is_empty() {
format!("info{location}: {} ({})", d.message, d.rule)
} else {
format!("info: {source_prefix}{}{location} ({})", d.message, d.rule)
}),
),
print_diagnostic(d, styles, printer);
// The fix is the actionable half of a diagnostic, so it follows every
// severity rather than hiding behind --verbose. Rules that have nothing
// useful to suggest leave it unset.
if let Some(fix) = &d.fix {
fabro_util::printerr!(printer, " {} {fix}", styles.dim.apply_to("fix:"));
}
}
}
fn print_diagnostic(d: &Diagnostic, styles: &Styles, printer: Printer) {
let location = match (&d.node_id, &d.edge) {
(Some(node), _) => format!(" [node: {node}]"),
(_, Some((from, to))) => format!(" [edge: {from} -> {to}]"),
_ => String::new(),
};
let source_prefix = source_prefix(d);
let body = if source_prefix.is_empty() {
format!("{location}: {}", d.message)
} else {
format!(": {source_prefix}{}{location}", d.message)
};
match d.severity {
Severity::Error => fabro_util::printerr!(
printer,
"{}{body} ({})",
styles.red.apply_to("error"),
styles.dim.apply_to(&d.rule),
),
Severity::Warning => fabro_util::printerr!(
printer,
"{}{body} ({})",
styles.yellow.apply_to("warning"),
styles.dim.apply_to(&d.rule),
),
Severity::Info => fabro_util::printerr!(
printer,
"{}",
styles.dim.apply_to(format!("info{body} ({})", d.rule)),
),
}
}
fn source_prefix(diagnostic: &Diagnostic) -> String {
match (
diagnostic.source_path.as_deref(),

View file

@ -101,6 +101,40 @@ fn create_uses_explicit_server_target_and_prints_remote_run_id() {
assert_eq!(output_stdout(&output).trim(), run_id.as_str());
}
#[test]
fn create_defers_provider_validation_to_the_server() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let mock = server.mock(|when, then| {
when.method("POST")
.path("/api/v1/runs")
.body_includes(r#"provider=\"server-only\""#);
then.status(201)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
let output = context
.create_cmd()
.args([
"--server",
&format!("{}/api/v1", server.base_url()),
"--dry-run",
fixture("server-model.fabro").to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"local validation should not reject a server-owned provider\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
mock.assert();
assert_eq!(output_stdout(&output).trim(), run_id.as_str());
}
#[test]
fn create_uses_configured_server_target_without_server_flag() {
let context = test_context!();

View file

@ -95,7 +95,9 @@ fn graph_allow_invalid_renders_after_diagnostics() {
----- stdout -----
----- stderr -----
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
fix: Add a node with shape=Mdiamond or id 'start'
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
fix: Remove outgoing edges from the exit node
");
let svg = read_text(&output_path);
@ -119,7 +121,9 @@ fn graph_invalid_workflow_fails_after_diagnostics() {
----- stdout -----
----- stderr -----
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
fix: Add a node with shape=Mdiamond or id 'start'
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
fix: Remove outgoing edges from the exit node
× Validation failed
");
}

View file

@ -85,6 +85,7 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() {
repo: "fabro".to_string(),
base_branch: "main".to_string(),
head_branch: "fabro/run/demo".to_string(),
head_sha: Some("final-sha".to_string()),
title: "Map the constellations".to_string(),
draft: false,
}),

View file

@ -52,7 +52,9 @@ fn preflight_invalid_workflow_fails_with_validation_output() {
Workflow: Invalid (2 nodes, 1 edges)
Graph: [FIXTURES]/invalid.fabro
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
fix: Add a node with shape=Mdiamond or id 'start'
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
fix: Remove outgoing edges from the exit node
× Validation failed
");
}
@ -74,7 +76,9 @@ fn preflight_rejects_unbound_template_inputs() {
Goal: Demo
error: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
error: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
× Validation failed
");
}

View file

@ -69,19 +69,17 @@ fn spawn_worker_process(
"FABRO_WORKER_TOKEN",
issue_test_worker_jwt(&context.storage_dir, run_id),
);
cmd.args([
"__run-worker",
"--server",
server,
"--run-dir",
run_dir
.to_str()
.expect("run directory path should be valid UTF-8"),
"--run-id",
run_id,
"--mode",
mode,
]);
cmd.arg("__run-worker")
.arg("--storage-dir")
.arg(&context.storage_dir)
.arg("--server")
.arg(server)
.arg("--run-dir")
.arg(run_dir)
.arg("--run-id")
.arg(run_id)
.arg("--mode")
.arg(mode);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
@ -124,8 +122,16 @@ fn child_output(mut child: Child, status: ExitStatus) -> Output {
}
}
fn worker_command(context: &fabro_test::TestContext, run_id: &str) -> assert_cmd::Command {
fn worker_base_command(context: &fabro_test::TestContext) -> assert_cmd::Command {
let mut cmd = context.command();
cmd.arg("__run-worker")
.arg("--storage-dir")
.arg(&context.storage_dir);
cmd
}
fn worker_command(context: &fabro_test::TestContext, run_id: &str) -> assert_cmd::Command {
let mut cmd = worker_base_command(context);
cmd.env(
"FABRO_WORKER_TOKEN",
issue_test_worker_jwt(&context.storage_dir, run_id),
@ -189,7 +195,7 @@ fn help() {
----- stdout -----
Internal: execute a single workflow run locally
Usage: fabro __run-worker [OPTIONS] --server <SERVER> --run-dir <RUN_DIR> --run-id <RUN_ID> --mode <MODE>
Usage: fabro __run-worker [OPTIONS] --server <SERVER> --storage-dir <STORAGE_DIR> --run-dir <RUN_DIR> --run-id <RUN_ID> --mode <MODE>
Options:
--json Output as JSON [env: FABRO_JSON=]
@ -211,10 +217,8 @@ fn worker_requires_fabro_worker_token_env() {
let context = auth_context();
let run_dir = tempfile::tempdir().unwrap();
let run_id = unique_run_id();
let output = context
.command()
let output = worker_base_command(&context)
.args([
"__run-worker",
"--server",
"http://127.0.0.1:32276",
"--run-dir",
@ -272,7 +276,6 @@ digraph CachedGraph {
let output = worker_command(&context, run_id.as_str())
.args([
"__run-worker",
"--server",
server.as_str(),
"--run-dir",
@ -341,7 +344,6 @@ digraph GitHubApp {
let mut cmd = worker_command(&context, run_id.as_str());
cmd.env("GITHUB_APP_PRIVATE_KEY", "%%%not-base64%%%");
cmd.args([
"__run-worker",
"--server",
server.as_str(),
"--run-dir",
@ -390,7 +392,6 @@ digraph DetachedStoreOnly {
let server = server_target(&context.storage_dir);
let output = worker_command(&context, run_id.as_str())
.args([
"__run-worker",
"--server",
server.as_str(),
"--run-dir",
@ -608,7 +609,6 @@ digraph Test {
let mut cmd = worker_command(&context, &run_id);
cmd.args([
"__run-worker",
"--server",
&server,
"--run-dir",
@ -673,7 +673,6 @@ fn runner_reports_malformed_run_state_without_prefetching_events() {
let output = worker_command(&context, &run_id)
.args([
"__run-worker",
"--server",
&format!("{}/api/v1", server.base_url()),
"--run-dir",

View file

@ -69,6 +69,24 @@ fn simple_does_not_connect_to_configured_server() {
);
}
/// Offline validation has no model catalog, so a model or provider the server
/// owns must pass rather than be reported as unknown.
#[test]
fn server_owned_provider_is_not_rejected_by_offline_validation() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("server-model.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: ServerModel (3 nodes, 2 edges)
Graph: [FIXTURES]/server-model.fabro
Validation: OK
");
}
#[test]
fn branching() {
let context = test_context!();
@ -82,6 +100,7 @@ fn branching() {
Workflow: Branch (6 nodes, 6 edges)
Graph: [FIXTURES]/branching.fabro
warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry)
fix: Add retry_target or fallback_retry_target attribute
Validation: OK
");
}
@ -163,7 +182,9 @@ fn bare_fabro_with_unbound_inputs_validates_structurally_with_warning() {
Workflow: TemplatedUnbound (3 nodes, 2 edges)
Graph: [FIXTURES]/templated_unbound.fabro
warning: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
warning: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
Validation: OK
");
}
@ -186,6 +207,7 @@ fn bare_fabro_with_unbound_inputs_in_imported_prompt_validates_structurally_with
Workflow: TemplatedUnboundImported (3 nodes, 2 edges)
Graph: [FIXTURES]/templated_unbound_imported/workflow.fabro
warning: [FIXTURES]/templated_unbound_imported/work.md:1:12: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
Validation: OK
");
}
@ -207,6 +229,7 @@ fn bare_fabro_with_unbound_inputs_in_template_partial_validates_structurally_wit
Workflow: TemplatedUnboundPartial (3 nodes, 2 edges)
Graph: [FIXTURES]/templated_unbound_partial/workflow.fabro
warning: [FIXTURES]/templated_unbound_partial/test-include.partial.md:1:4: undefined template variable `inputs.hello` in node `test_imported_include` attribute `prompt` [node: test_imported_include] (template_undefined_variable)
fix: bind `inputs.hello` via `[run.inputs]` in workflow.toml, or pass `--input inputs.hello=<value>`
Validation: OK
");
}
@ -278,6 +301,26 @@ fn validate_reports_missing_template_dependency() {
");
}
/// A node named only by an edge is almost always a typo, so validation must
/// fail instead of quietly running it as a default agent stage.
#[test]
fn edge_only_node() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("edge_only_node.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Workflow: EdgeOnlyNode (2 nodes, 2 edges)
Graph: [FIXTURES]/edge_only_node.fabro
error [node: misspelled_node]: Node 'misspelled_node' is referenced by edge 'start -> misspelled_node' but has no node declaration (edge_target_exists)
fix: Declare node 'misspelled_node' or correct the edge endpoint
× Validation failed
");
}
#[test]
fn invalid() {
let context = test_context!();
@ -291,7 +334,9 @@ fn invalid() {
Workflow: Invalid (2 nodes, 1 edges)
Graph: [FIXTURES]/invalid.fabro
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
fix: Add a node with shape=Mdiamond or id 'start'
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
fix: Remove outgoing edges from the exit node
× Validation failed
");
}

View file

@ -393,17 +393,17 @@ fn runner_rejects_bogus_worker_token_against_github_only_server() {
cmd.env("FABRO_HOME", &worker_home);
cmd.env("FABRO_AUTH_FILE", &auth_file);
cmd.env("FABRO_WORKER_TOKEN", bogus_token);
cmd.args([
"__run-worker",
"--server",
&target,
"--run-dir",
run_dir.to_str().unwrap(),
"--run-id",
&run_id,
"--mode",
"start",
]);
cmd.arg("__run-worker")
.arg("--server")
.arg(&target)
.arg("--storage-dir")
.arg(&server.storage_dir)
.arg("--run-dir")
.arg(&run_dir)
.arg("--run-id")
.arg(&run_id)
.arg("--mode")
.arg("start");
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());

View file

@ -19,6 +19,7 @@ fn dry_run_branching() {
Goal: Implement and validate a feature
warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry)
fix: Add retry_target or fallback_retry_target attribute
Run: [ULID]
Web UI: http://localhost:3000/runs/[ULID]
Sandbox: local (ready in [TIME])

View file

@ -1,11 +1,8 @@
use std::path::Path;
use std::sync::Arc;
use fabro_api::types;
use fabro_config::load_llm_catalog_settings;
use fabro_model::Catalog;
use fabro_server::run_tool_manifest;
use fabro_tool::{RunManifestBuilder, ToolError, ToolResult, ValidatedCreateRunSpec};
use fabro_tool::{RunManifestBuilder, ToolResult, ValidatedCreateRunSpec};
#[derive(Default)]
pub(crate) struct McpRunManifestBuilder;
@ -17,20 +14,6 @@ impl RunManifestBuilder for McpRunManifestBuilder {
cwd: &Path,
user_settings_path: &Path,
) -> ToolResult<types::RunManifest> {
build_mcp_run_manifest(spec, cwd, user_settings_path)
run_tool_manifest::build_run_tool_manifest(spec, cwd, user_settings_path)
}
}
fn build_mcp_run_manifest(
spec: &ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> ToolResult<types::RunManifest> {
let llm_catalog_settings = load_llm_catalog_settings(Some(user_settings_path))
.map_err(|err| ToolError::message(err.to_string()))?;
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.map_err(|err| ToolError::message(err.to_string()))?,
);
run_tool_manifest::build_run_tool_manifest(spec, cwd, user_settings_path, catalog)
}

View file

@ -41,6 +41,7 @@ fabro-manifest = { path = "../../components/fabro-manifest" }
fabro-mcp-store = { path = "../../components/fabro-mcp-store" }
fabro-model = { path = "../../foundation/fabro-model" }
fabro-proc = { path = "../../foundation/fabro-proc" }
fabro-template = { path = "../../foundation/fabro-template" }
fabro-tool = { path = "../../components/fabro-tool" }
fabro-types = { path = "../../foundation/fabro-types" }
fabro-util = { path = "../../foundation/fabro-util" }
@ -108,6 +109,7 @@ fabro-build-support = { path = "../../foundation/build-support" }
chrono = { workspace = true }
[dev-dependencies]
fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] }
tokio = { workspace = true, features = ["test-util", "macros"] }
tower = "0.5"
http-body-util = "0.1"

View file

@ -1134,6 +1134,7 @@ mod runs {
id: stage_id.clone(),
name: name.to_owned(),
handler,
billing: BilledTokenCounts::default(),
status,
wall_time_ms,
node_id: stage_id.node_id().to_owned(),
@ -1143,6 +1144,8 @@ mod runs {
started_at: None,
graph_visit: None,
resumed_from_stage_id: None,
parallel_group_id: None,
parallel_branch_index: None,
}
}
@ -1280,6 +1283,7 @@ mod runs {
fn parse_failure_reason(reason: &str) -> Option<FailureReason> {
match reason {
"workflow_error" => Some(FailureReason::WorkflowError),
"publish_failed" => Some(FailureReason::PublishFailed),
"cancelled" => Some(FailureReason::Cancelled),
"approval_denied" => Some(FailureReason::ApprovalDenied),
"terminated" => Some(FailureReason::Terminated),
@ -1752,6 +1756,7 @@ mod runs {
allow_freeform: false,
timeout_seconds: None,
context_display: None,
review_target: None,
},
ApiQuestion {
id: "q-002".into(),
@ -1775,6 +1780,7 @@ mod runs {
allow_freeform: true,
timeout_seconds: None,
context_display: None,
review_target: None,
},
]
}

View file

@ -2053,6 +2053,7 @@ fn build_github_app_manifest(
"public": false,
"default_permissions": {
"contents": "write",
"workflows": "write",
"metadata": "read",
"pull_requests": "write",
"checks": "write",
@ -2354,11 +2355,27 @@ mod tests {
InstallObjectStoreCredentialMode, InstallObjectStoreInput, InstallObjectStoreProvider,
InstallObjectStoreState, InstallSandboxProviderState, InstallSandboxState,
InstallTokenQuery, LlmProvidersInput, PendingInstall, ServerConfigInput, ServerSecrets,
classify_object_store_validation_error, detect_canonical_url, install_object_store_lookup,
lock_unpoisoned, post_install_finish, provider_base_url_override,
resolve_install_object_store_state, token_is_valid, write_artifact_store_metadata,
build_github_app_manifest, classify_object_store_validation_error, detect_canonical_url,
install_object_store_lookup, lock_unpoisoned, post_install_finish,
provider_base_url_override, resolve_install_object_store_state, token_is_valid,
write_artifact_store_metadata,
};
#[test]
fn github_app_manifest_allows_workflow_file_writes() {
let manifest = build_github_app_manifest(
"Fabro Test",
"https://fabro.example/setup",
"https://fabro.example/auth/callback/github",
"https://fabro.example/setup",
);
assert_eq!(
manifest["default_permissions"]["workflows"],
serde_json::Value::String("write".to_string())
);
}
#[test]
fn token_validation_accepts_any_matching_source() {
let state = InstallAppState::for_test("expected");

Some files were not shown because too many files have changed in this diff Show more