mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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:
commit
cb24f47b59
285 changed files with 18491 additions and 4649 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
105
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
59
apps/fabro-web/app/components/review-target-question.tsx
Normal file
59
apps/fabro-web/app/components/review-target-question.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
apps/fabro-web/app/components/run-dock.test.tsx
Normal file
118
apps/fabro-web/app/components/run-dock.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
317
apps/fabro-web/app/components/run-dock.tsx
Normal file
317
apps/fabro-web/app/components/run-dock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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") && (
|
||||
|
|
|
|||
58
apps/fabro-web/app/components/size-chip.test.tsx
Normal file
58
apps/fabro-web/app/components/size-chip.test.tsx
Normal 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)");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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…");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
28
apps/fabro-web/app/lib/billing.ts
Normal file
28
apps/fabro-web/app/lib/billing.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
5
apps/fabro-web/app/lib/class-names.ts
Normal file
5
apps/fabro-web/app/lib/class-names.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function classNames(
|
||||
...classes: Array<string | false | null | undefined>
|
||||
): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)] : [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }> },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
192
apps/fabro-web/app/routes/run-artifacts/group.test.ts
Normal file
192
apps/fabro-web/app/routes/run-artifacts/group.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
104
apps/fabro-web/app/routes/run-artifacts/group.ts
Normal file
104
apps/fabro-web/app/routes/run-artifacts/group.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
158
apps/fabro-web/app/routes/run-detail/docked-controls.test.tsx
Normal file
158
apps/fabro-web/app/routes/run-detail/docked-controls.test.tsx
Normal 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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
3
apps/fabro-web/public/images/providers/modal.svg
Normal file
3
apps/fabro-web/public/images/providers/modal.svg
Normal 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 |
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@
|
|||
"integrations/bedrock",
|
||||
"integrations/poolside",
|
||||
"integrations/openrouter",
|
||||
"integrations/modal",
|
||||
"integrations/fireworks",
|
||||
"integrations/slack",
|
||||
"integrations/brave-search"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
173
docs/public/integrations/modal.mdx
Normal file
173
docs/public/integrations/modal.mdx
Normal 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>
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<_>>>()?;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue