Merge remote-tracking branch 'origin/main' into fix/modal-provider-catalog

Resolve the model catalog table conflict in docs/public/core-concepts/models.mdx
by keeping both changes: this branch's `kimi` -> `moonshot` provider rename for
the Kimi rows, and main's new DeepSeek V4 rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-08-01 09:32:19 -04:00
commit cc590f6f97
No known key found for this signature in database
71 changed files with 4711 additions and 897 deletions

View file

@ -1,6 +1,7 @@
ANTHROPIC_API_KEY=
BRAVE_SEARCH_API_KEY=
DAYTONA_API_KEY=
DEEPSEEK_API_KEY=
FIREWORKS_API_KEY=
GEMINI_API_KEY=
INCEPTION_API_KEY=

110
Cargo.lock generated
View file

@ -1854,7 +1854,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "daytona-api-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
dependencies = [
"reqwest 0.13.2",
"reqwest-middleware",
@ -1868,7 +1868,7 @@ dependencies = [
[[package]]
name = "daytona-sdk"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
dependencies = [
"daytona-api-client",
"daytona-toolbox-client",
@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "daytona-toolbox-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
dependencies = [
"reqwest 0.13.2",
"reqwest-middleware",
@ -2239,7 +2239,7 @@ dependencies = [
[[package]]
name = "fabro-acp"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-tokio",
@ -2258,7 +2258,7 @@ dependencies = [
[[package]]
name = "fabro-agent"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2304,7 +2304,7 @@ dependencies = [
[[package]]
name = "fabro-api"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"chrono",
"fabro-automation",
@ -2327,7 +2327,7 @@ dependencies = [
[[package]]
name = "fabro-auth"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2352,7 +2352,7 @@ dependencies = [
[[package]]
name = "fabro-automation"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2371,11 +2371,11 @@ dependencies = [
[[package]]
name = "fabro-build-support"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
[[package]]
name = "fabro-checkpoint"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -2391,7 +2391,7 @@ dependencies = [
[[package]]
name = "fabro-cli"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2493,7 +2493,7 @@ dependencies = [
[[package]]
name = "fabro-client"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -2522,7 +2522,7 @@ dependencies = [
[[package]]
name = "fabro-config"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2552,7 +2552,7 @@ dependencies = [
[[package]]
name = "fabro-core"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"async-trait",
"fabro-types",
@ -2567,7 +2567,7 @@ dependencies = [
[[package]]
name = "fabro-db"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2579,7 +2579,7 @@ dependencies = [
[[package]]
name = "fabro-dev"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2598,7 +2598,7 @@ dependencies = [
[[package]]
name = "fabro-dump"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -2612,7 +2612,7 @@ dependencies = [
[[package]]
name = "fabro-environment"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2634,7 +2634,7 @@ dependencies = [
[[package]]
name = "fabro-github"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -2656,7 +2656,7 @@ dependencies = [
[[package]]
name = "fabro-graphviz"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -2671,7 +2671,7 @@ dependencies = [
[[package]]
name = "fabro-hooks"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"async-trait",
"fabro-agent",
@ -2694,7 +2694,7 @@ dependencies = [
[[package]]
name = "fabro-http"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"fabro-static",
"http 1.4.0",
@ -2704,7 +2704,7 @@ dependencies = [
[[package]]
name = "fabro-install"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -2723,7 +2723,7 @@ dependencies = [
[[package]]
name = "fabro-interview"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"async-trait",
"dialoguer",
@ -2738,7 +2738,7 @@ dependencies = [
[[package]]
name = "fabro-llm"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2766,6 +2766,7 @@ dependencies = [
"rand 0.9.4",
"serde",
"serde_json",
"sha2 0.10.9",
"strum 0.28.0",
"thiserror 2.0.18",
"tokio",
@ -2779,7 +2780,7 @@ dependencies = [
[[package]]
name = "fabro-macros"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"clap",
"fabro-options-metadata",
@ -2790,7 +2791,7 @@ dependencies = [
[[package]]
name = "fabro-manifest"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"fabro-api",
@ -2808,7 +2809,7 @@ dependencies = [
[[package]]
name = "fabro-mcp"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2828,7 +2829,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-server"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2851,11 +2852,12 @@ dependencies = [
"tempfile",
"tokio",
"toml 0.8.23",
"tracing",
]
[[package]]
name = "fabro-mcp-store"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"chrono",
"fabro-db",
@ -2873,7 +2875,7 @@ dependencies = [
[[package]]
name = "fabro-model"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"fabro-static",
"http 1.4.0",
@ -2889,7 +2891,7 @@ dependencies = [
[[package]]
name = "fabro-oauth"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2911,7 +2913,7 @@ dependencies = [
[[package]]
name = "fabro-options-metadata"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"serde",
"serde_json",
@ -2919,7 +2921,7 @@ dependencies = [
[[package]]
name = "fabro-proc"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"cc",
"libc",
@ -2928,7 +2930,7 @@ dependencies = [
[[package]]
name = "fabro-redact"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"aho-corasick",
"ref-cast",
@ -2944,7 +2946,7 @@ dependencies = [
[[package]]
name = "fabro-sandbox"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2988,7 +2990,7 @@ dependencies = [
[[package]]
name = "fabro-server"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3081,7 +3083,7 @@ dependencies = [
[[package]]
name = "fabro-slack"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-interview",
@ -3103,18 +3105,18 @@ dependencies = [
[[package]]
name = "fabro-spa"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"rust-embed",
]
[[package]]
name = "fabro-static"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
[[package]]
name = "fabro-store"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"async-trait",
"bytes",
@ -3144,7 +3146,7 @@ dependencies = [
[[package]]
name = "fabro-telemetry"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -3170,7 +3172,7 @@ dependencies = [
[[package]]
name = "fabro-template"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -3184,7 +3186,7 @@ dependencies = [
[[package]]
name = "fabro-test"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -3209,7 +3211,7 @@ dependencies = [
[[package]]
name = "fabro-tool"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3230,7 +3232,7 @@ dependencies = [
[[package]]
name = "fabro-tracker"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3244,7 +3246,7 @@ dependencies = [
[[package]]
name = "fabro-types"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"chrono",
"clap",
@ -3267,7 +3269,7 @@ dependencies = [
[[package]]
name = "fabro-util"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"console 0.15.11",
@ -3290,7 +3292,7 @@ dependencies = [
[[package]]
name = "fabro-validate"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"fabro-acp",
"fabro-graphviz",
@ -3303,7 +3305,7 @@ dependencies = [
[[package]]
name = "fabro-variable"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -3320,7 +3322,7 @@ dependencies = [
[[package]]
name = "fabro-vault"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -3339,7 +3341,7 @@ dependencies = [
[[package]]
name = "fabro-workflow"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -8504,7 +8506,7 @@ dependencies = [
[[package]]
name = "twin-github"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"axum",
"base64",
@ -8523,7 +8525,7 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
dependencies = [
"anyhow",
"async-stream",

View file

@ -11,7 +11,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.311.0-nightly.0"
version = "0.312.0-nightly.0"
license = "MIT"
[workspace.dependencies]
@ -96,8 +96,8 @@ twin-openai = { path = "test/twin/openai" }
twin-github = { path = "test/twin/github" }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fc58e22f7f25183df6264276ee186bbc32635738", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fc58e22f7f25183df6264276ee186bbc32635738", package = "daytona-api-client" }
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "73c9c458dd1a1d096afd3521175637af82afd8d8", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "73c9c458dd1a1d096afd3521175637af82afd8d8", package = "daytona-api-client" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

View file

@ -195,15 +195,11 @@ describe("parseParallelOverview", () => {
const overview = parseParallelOverview(events);
expect(overview).toEqual({
branchCount: 3,
successCount: 2,
failureCount: 1,
durationMs: 12000,
results: [
{ 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,
});
});
@ -249,7 +245,6 @@ describe("parseParallelOverview", () => {
}),
];
const overview = parseParallelOverview(events);
expect(overview.isComplete).toBe(false);
expect(overview.branchCount).toBe(4);
expect(overview.results).toEqual([]);
});

View file

@ -172,35 +172,28 @@ export interface ParallelBranchSummary {
export interface ParallelOverview {
branchCount: number | null;
successCount: number | null;
failureCount: number | null;
durationMs: number | null;
results: ParallelBranchSummary[];
isComplete: boolean;
}
/**
* Roll up the `parallel.started` (announces branch count) and
* `parallel.completed` (carries the rolled-up results) events for a parallel
* `parallel.completed` (carries the per-branch results) events for a parallel
* stage. Pre-completion, only the announce data is available.
*
* Only branch identity is parsed. The event's own `success_count`,
* `failure_count` and `duration_ms` rollups are deliberately ignored: the
* renderer counts the branch rows it actually draws, and duration comes from
* the stage record via `StageMetaBar`.
*/
export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview {
let branchCount: number | null = null;
let successCount: number | null = null;
let failureCount: number | null = null;
let durationMs: number | null = null;
let results: ParallelBranchSummary[] = [];
let isComplete = false;
for (const event of events) {
const props: UnknownRecord = event.properties ?? {};
if (event.event === "parallel.started") {
branchCount = getNumber(props, "branch_count") ?? branchCount;
} else if (event.event === "parallel.completed") {
isComplete = true;
successCount = getNumber(props, "success_count") ?? successCount;
failureCount = getNumber(props, "failure_count") ?? failureCount;
durationMs = getNumber(props, "duration_ms") ?? durationMs;
const rawResults = getArray(props, "results") ?? [];
results = rawResults
.map((entry) => {
@ -218,14 +211,7 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview
}
}
return {
branchCount,
successCount,
failureCount,
durationMs,
results,
isComplete,
};
return { branchCount, results };
}
export interface ReducerTranscript {

View file

@ -144,6 +144,25 @@ describe("ParallelChildren", () => {
expect(statValue(renderer, "Failed")).toBe("0");
});
test("shows the recorded stage duration when cancellation interrupts the fan-out", () => {
const renderer = renderParallel(
[startedEvent(2)],
[],
makeStage({
id: "fork@1",
name: "fork",
nodeId: "fork",
handler: "parallel",
status: StageState.CANCELLED,
duration: "53m 29s",
}),
);
// No `parallel.completed` event is emitted for an interrupted fan-out, so
// the stage record is the only duration there is.
expect(textContent(renderer.root)).toContain("53m 29s");
});
test("keeps looped fork links scoped to the selected fork visit", () => {
const renderer = renderParallel(
[startedEvent(1)],

View file

@ -6,7 +6,6 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../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";
@ -184,9 +183,11 @@ export function ParallelChildren({
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
{/* The meta bar owns duration for every stage renderer, including the
live clock while running, so the tiles below stay outcome-only. */}
<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">
<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-3">
<StatItem label="Branches" value={branchCount || "—"} />
<StatItem
label="Succeeded"
@ -198,10 +199,6 @@ export function ParallelChildren({
value={failureCount}
tone={failureCount > 0 ? "danger" : "default"}
/>
<StatItem
label="Duration"
value={overview.durationMs != null ? formatDurationMs(overview.durationMs) : overview.isComplete ? "—" : "running"}
/>
</section>
<section>

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M35.6638 9.91965C35.3251 9.75432 35.1785 10.0703 34.9811 10.2316C34.9131 10.2836 34.8558 10.3516 34.7985 10.413C34.3025 10.9423 33.7238 11.289 32.9678 11.2476C31.8625 11.1863 30.9186 11.533 30.0839 12.3783C29.9066 11.3356 29.3173 10.7143 28.4213 10.3143C27.9519 10.1063 27.4773 9.89965 27.148 9.44766C26.9186 9.12633 26.856 8.76767 26.7413 8.41568C26.668 8.20235 26.5946 7.98502 26.3506 7.94902C26.084 7.90769 25.98 8.13035 25.876 8.31702C25.4587 9.07967 25.2973 9.91965 25.3133 10.7703C25.3493 12.6849 26.1573 14.2102 27.764 15.2942C27.9466 15.4182 27.9933 15.5435 27.9359 15.7249C27.8266 16.0982 27.696 16.4609 27.5813 16.8355C27.508 17.0742 27.3986 17.1248 27.1426 17.0222C26.2777 16.6504 25.4919 16.1164 24.828 15.4489C23.6854 14.3449 22.6534 13.1263 21.3654 12.1716C21.067 11.9511 20.7606 11.7416 20.4468 11.5436C19.1335 10.2676 20.6201 9.21967 20.9641 9.09567C21.3241 8.965 21.0881 8.51968 19.9254 8.52501C18.7628 8.53035 17.6988 8.91834 16.3428 9.43699C16.1413 9.51421 15.934 9.57529 15.7229 9.61966C14.4557 9.38091 13.1598 9.33506 11.8789 9.48366C9.36565 9.76365 7.35902 10.953 5.88305 12.9809C4.10975 15.4182 3.69243 18.1888 4.20308 21.0768C4.74041 24.122 6.29504 26.6433 8.683 28.6139C11.1603 30.6579 14.0122 31.6592 17.2668 31.4672C19.2428 31.3539 21.4441 31.0886 23.9254 28.9873C24.552 29.2993 25.208 29.4233 26.2986 29.5166C27.1386 29.5953 27.9466 29.4766 28.5719 29.3459C29.5519 29.1379 29.4839 28.23 29.1306 28.0646C26.2573 26.726 26.888 27.2713 26.3133 26.83C27.7746 25.102 29.9746 23.3074 30.8359 17.4928C30.9026 17.0302 30.8452 16.7395 30.8359 16.3662C30.8306 16.1395 30.8826 16.0502 31.1426 16.0249C31.8639 15.95 32.5637 15.7349 33.2025 15.3915C35.0638 14.3742 35.8158 12.7049 35.9931 10.7023C36.0198 10.3956 35.9878 10.081 35.6638 9.91965ZM19.4414 27.9433C16.6562 25.754 15.3055 25.0327 14.7482 25.0634C14.2256 25.0954 14.3202 25.6913 14.4349 26.0807C14.5549 26.4647 14.7109 26.7286 14.9295 27.066C15.0815 27.2886 15.1855 27.6206 14.7789 27.87C13.8816 28.4246 12.3229 27.6833 12.2496 27.6473C10.435 26.578 8.91632 25.1673 7.84834 23.2381C6.81637 21.3808 6.21638 19.3888 6.11771 17.2622C6.09105 16.7475 6.24171 16.5662 6.7537 16.4729C7.42583 16.3442 8.11451 16.3267 8.79233 16.4209C11.6349 16.8368 14.0536 18.1075 16.0828 20.1194C17.2402 21.2661 18.1161 22.6354 19.0188 23.974C19.9788 25.3953 21.0108 26.75 22.3254 27.8593C22.7894 28.2486 23.1587 28.5446 23.5134 28.7619C22.4441 28.8819 20.6601 28.9086 19.4414 27.9433ZM20.7748 19.3568C20.7745 19.2906 20.7904 19.2253 20.8211 19.1666C20.8517 19.1078 20.8962 19.0575 20.9507 19.0198C21.0052 18.9821 21.068 18.9583 21.1337 18.9503C21.1995 18.9424 21.2662 18.9505 21.3281 18.9741C21.407 19.0024 21.475 19.0546 21.5228 19.1235C21.5706 19.1923 21.5958 19.2743 21.5947 19.3581C21.5949 19.4123 21.5843 19.4659 21.5636 19.5159C21.5428 19.5659 21.5123 19.6113 21.4738 19.6494C21.4354 19.6875 21.3897 19.7176 21.3395 19.7378C21.2893 19.7581 21.2356 19.7682 21.1814 19.7675C21.1277 19.7676 21.0745 19.7571 21.0248 19.7365C20.9752 19.7158 20.9302 19.6855 20.8925 19.6473C20.8548 19.609 20.825 19.5636 20.805 19.5138C20.785 19.4639 20.7739 19.4105 20.7748 19.3568ZM24.9213 21.4848C24.6547 21.5928 24.3893 21.6861 24.1347 21.6981C23.7516 21.7114 23.3756 21.5918 23.0707 21.3594C22.7054 21.0528 22.4441 20.8821 22.3347 20.3488C22.297 20.0881 22.3042 19.823 22.3561 19.5648C22.4494 19.1288 22.3454 18.8488 22.0374 18.5955C21.7881 18.3875 21.4694 18.3302 21.1201 18.3302C21.0005 18.3232 20.8843 18.2875 20.7814 18.2262C20.6348 18.1542 20.5148 17.9728 20.6294 17.7488C20.6668 17.6768 20.8428 17.5008 20.8854 17.4688C21.3601 17.1995 21.9081 17.2875 22.4134 17.4902C22.8827 17.6822 23.2374 18.0342 23.748 18.5328C24.2694 19.1341 24.364 19.3008 24.6613 19.7515C24.896 20.1048 25.1093 20.4674 25.2547 20.8821C25.344 21.1421 25.2293 21.3541 24.9213 21.4848Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -388,6 +388,7 @@ fabro secret set GEMINI_API_KEY AI...
| `MINIMAX_API_KEY` | Minimax |
| `INCEPTION_API_KEY` | Inception (Mercury) |
| `POOLSIDE_API_KEY` | Poolside (Laguna) |
| `DEEPSEEK_API_KEY` | DeepSeek |
| `OPENROUTER_API_KEY` | OpenRouter (when enabled) |
| `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` | Modal (when enabled) |
| `FIREWORKS_API_KEY` | Fireworks AI (when enabled) |

View file

@ -9184,9 +9184,6 @@ components:
environment:
type: string
description: Named environment slug to select for the run.
docker_image:
type: string
description: Per-run environment image override.
verbose:
type: boolean
dry_run:

View file

@ -59,13 +59,15 @@ Fabro performs this selection once when creating a run and persists the chosen p
| `gemini-3.1-flash-lite` | gemini | `gemini-flash-lite`, `gemini-3.1-flash-lite-preview` | 1M | $0.25 / $1.50 | 200 tok/s |
| `kimi-k2.5` | moonshot | | 262K | $0.60 / $3.00 | 50 tok/s |
| `kimi-k3` | moonshot | `kimi` | 1M | $3.00 / $15.00 | n/a |
| `deepseek-v4-flash` | deepseek | `deepseek`, `deepseek-v4`, `deepseek-flash` | 1,048,576 | $0.14 / $0.28 | n/a |
| `deepseek-v4-pro` | deepseek | | 1,048,576 | $0.435 / $0.87 | n/a |
| `laguna-s-2.1` | poolside | `laguna`, `laguna-s` | 1M | $0.10 / $0.20 | n/a |
| `laguna-xs-2.1` | poolside | `laguna-xs` | 262K | $0.10 / $0.20 | n/a |
| `glm-5.2` | zai | `glm`, `glm5`, `glm52`, `glm5.2` | 1M | $1.40 / $4.40 | n/a |
| `minimax-m2.5` | minimax | `minimax` | 197K | $0.30 / $1.20 | 45 tok/s |
| `mercury-2` | inception | `mercury` | 131K | $0.25 / $0.75 | 1000 tok/s |
Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or `POOLSIDE_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup.
Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, or `POOLSIDE_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup.
Claude Fable 5 is available as an explicit model but is not the default Anthropic model. If Fable refuses a request, Fabro reports the refusal as a content-filter LLM error and applies the configured `run.model.fallbacks` chain when one is present.

View file

@ -96,6 +96,7 @@
"integrations/daytona",
"integrations/litellm",
"integrations/bedrock",
"integrations/deepseek",
"integrations/poolside",
"integrations/openrouter",
"integrations/modal",

View file

@ -0,0 +1,146 @@
---
title: "DeepSeek"
description: "Run DeepSeek V4 Flash and Pro through DeepSeek's direct API or supported gateways"
---
[DeepSeek](https://www.deepseek.com/) provides an OpenAI-compatible API for DeepSeek V4. Fabro includes direct access to V4 Flash and V4 Pro. The same Fabro model IDs also work through the optional Fireworks AI and OpenRouter providers.
## Prerequisites
- A [DeepSeek Platform](https://platform.deepseek.com/) account
- A DeepSeek API key from [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys)
- A running Fabro server
## Configure direct access
The direct `deepseek` provider is enabled in the built-in catalog. Store its key in the target Fabro server vault:
```bash
fabro provider login --provider deepseek
# For a non-default remote server:
fabro provider login --server https://your-fabro.example --provider deepseek
# Or set the vault token directly:
fabro secret set DEEPSEEK_API_KEY
fabro secret --server https://your-fabro.example set DEEPSEEK_API_KEY
```
Standalone SDK usage outside a Fabro server can use an env-backed credential source explicitly:
```bash
export DEEPSEEK_API_KEY=<api-key>
```
Fabro sends bearer-authenticated Chat Completions requests to `https://api.deepseek.com`.
## Included models
| Fabro model ID | DeepSeek API model ID | Context | Max output | Role |
|---|---|---:|---:|---|
| `deepseek-v4-flash` | `deepseek-v4-flash` | 1,048,576 | 384,000 | Provider default, small default, and connectivity probe; aliases `deepseek`, `deepseek-v4`, `deepseek-flash` |
| `deepseek-v4-pro` | `deepseek-v4-pro` | 1,048,576 | 384,000 | Higher-capability V4 model |
Both models support text input, tool calling, native reasoning, streaming, JSON output, and automatic prompt caching. They do not support image input. Thinking mode is enabled by default.
## Agent profile
DeepSeek V4 uses Fabro's `openai` agent profile on every route. This profile is the closest match for DeepSeek's general coding behavior: it supplies project `AGENTS.md` instructions, standard JSON function tools, and a JSON-compatible file editor on Chat Completions routes. The setting is model-specific, so it remains the same through direct DeepSeek, Fireworks AI, and OpenRouter.
Fabro does not use the `gpt56` profile for DeepSeek. That profile has a smaller Codex-specific tool set for GPT-5.6 models.
## Use DeepSeek models
```bash
fabro model list --provider deepseek
fabro model test --provider deepseek --model deepseek-v4-flash --deep
fabro run workflow.fabro --provider deepseek --model deepseek
```
In workflow stylesheets:
```dot title="workflow.fabro"
digraph Example {
graph [
model_stylesheet="
* { model: deepseek-v4-flash; }
.difficult { model: deepseek-v4-pro; }
"
]
start [shape=Mdiamond, label="Start"]
work [label="Implement", prompt="Implement and verify the requested change."]
exit [shape=Msquare, label="Exit"]
start -> work -> exit
}
```
## Thinking and reasoning effort
DeepSeek enables thinking by default at `high` effort. Fabro advertises only effort values that produce a distinct model behavior on each route:
| Route | V4 Flash | V4 Pro |
|---|---|---|
| Direct DeepSeek | `low`, `high`, `max` | `high`, `max` |
| Fireworks AI | `high`, `max` | `high`, `max` |
| OpenRouter | `low`, `high`, `max` | `high`, `xhigh` |
DeepSeek currently maps a V4 Pro request for `low` to `high`; its documentation says this mapping will change in early August 2026. Fireworks maps `low` and `medium` to `high`, and maps `xhigh` to `max`. OpenRouter names the Pro maximum tier `xhigh`.
Fabro omits `temperature` and `top_p` for these models because DeepSeek ignores sampling parameters while thinking is enabled.
To disable thinking on the direct provider, omit typed `reasoning_effort` and pass DeepSeek's native toggle through provider options:
```json
{
"provider_options": {
"deepseek": {
"thinking": { "type": "disabled" }
}
}
}
```
DeepSeek requires `reasoning_content` from an assistant tool call to appear in every later request in that tool-use turn. Fabro captures this content and replays it with the assistant message. This keeps multi-step tool calls valid and prevents DeepSeek's HTTP 400 response for missing reasoning history.
## Prompt caching and pricing
DeepSeek applies prefix caching automatically. Fabro reads DeepSeek's `prompt_cache_hit_tokens` usage field and reports cache-read tokens separately from uncached input tokens.
The built-in catalog uses DeepSeek's published prices per million tokens:
| Model | Uncached input | Cache hit | Output |
|---|---:|---:|---:|
| `deepseek-v4-flash` | $0.14 | $0.0028 | $0.28 |
| `deepseek-v4-pro` | $0.435 | $0.003625 | $0.87 |
DeepSeek does not return an in-band dollar cost. Fabro calculates an estimated cost from these catalog rates and the reported token buckets.
## Use a gateway
The `deepseek-v4-flash`, `deepseek-v4-pro`, `deepseek`, `deepseek-v4`, and `deepseek-flash` selectors are portable across direct DeepSeek, Fireworks AI, and OpenRouter routes. Use `--provider` or a provider-qualified model selector when you need a specific route.
See the [Fireworks AI integration](/integrations/fireworks) and [OpenRouter integration](/integrations/openrouter) for gateway setup and provider-specific pricing.
## Troubleshooting
**"No API key configured"** — Store `DEEPSEEK_API_KEY` on the target server with `fabro provider login --provider deepseek`. The server runtime resolves the key from its vault, not from process env.
**Unknown model** — Use `deepseek-v4-flash` or `deepseek-v4-pro`. The retired `deepseek-chat` and `deepseek-reasoner` API IDs are not in the Fabro catalog.
**A tool continuation returns HTTP 400** — Keep the assistant thinking content in conversation history. Fabro does this automatically when it replays tool-call turns.
## Further reading
<Columns cols={3}>
<Card title="DeepSeek API" icon="code" href="https://api-docs.deepseek.com/">
Official authentication, endpoints, and API reference.
</Card>
<Card title="Models and pricing" icon="money-bill" href="https://api-docs.deepseek.com/quick_start/pricing/">
Official limits, features, and token prices.
</Card>
<Card title="Thinking mode" icon="brain" href="https://api-docs.deepseek.com/guides/thinking_mode/">
Official thinking toggles, effort mappings, and tool-call replay rules.
</Card>
</Columns>

View file

@ -50,7 +50,7 @@ The built-in catalog gives Fireworks offerings the same human-facing model slugs
| --- | --- |
| `kimi-k2.7-code` | `accounts/fireworks/models/kimi-k2p7-code`; provider default |
| `kimi-k2.6` | `accounts/fireworks/models/kimi-k2p6` |
| `deepseek-v4-pro`, `deepseek-v4-flash` | `accounts/fireworks/models/deepseek-v4-...` |
| `deepseek-v4-pro`, `deepseek-v4-flash` (`deepseek`, `deepseek-v4`, `deepseek-flash`) | `accounts/fireworks/models/deepseek-v4-...` |
| `glm-5.2` | `accounts/fireworks/models/glm-5p2` |
| `minimax-m2.7` | `accounts/fireworks/models/minimax-m2p7` |
| `qwen3.7-plus` | `accounts/fireworks/models/qwen3p7-plus` |

View file

@ -53,7 +53,7 @@ The built-in catalog gives OpenRouter offerings the same human-facing model slug
| `claude-haiku-4-5` | `anthropic/claude-haiku-4.5`; provider small default |
| `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4`, `gpt-5.5` | Matching `openai/...` API IDs |
| `gemini-3.1-pro-preview`, `gemini-3.5-flash` | `google/...` API IDs |
| `deepseek-v4-pro` (`deepseek`, `deepseek-v4`), `deepseek-v4-flash` (`deepseek-flash`) | `deepseek/...` API IDs |
| `deepseek-v4-pro`, `deepseek-v4-flash` (`deepseek`, `deepseek-v4`, `deepseek-flash`) | `deepseek/...` API IDs; Flash uses the `deepseek-v4-flash-0731` release |
| `kimi-k3`, `kimi-k2.6`, `qwen3-coder`, `qwen3.6-flash` | Vendor-prefixed API IDs |
| `laguna-s-2.1`, `laguna-xs-2.1` | `poolside/...`; native reasoning and tool use |
| `glm-5.2` (`glm`, `glm5`, `glm52`, `glm5.2`), `glm-4.6` | `z-ai/...` API IDs |

View file

@ -371,6 +371,7 @@ For env-backed usage, `EnvCredentialSource` checks for API key environment varia
| `MINIMAX_API_KEY` | Minimax |
| `INCEPTION_API_KEY` | Inception |
| `POOLSIDE_API_KEY` | Poolside |
| `DEEPSEEK_API_KEY` | DeepSeek |
| `OPENROUTER_API_KEY` | OpenRouter, when enabled in settings |
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.

View file

@ -3521,6 +3521,7 @@ root = "{}"
assert!(ids.contains(&ProviderId::new("inception")));
assert!(ids.contains(&ProviderId::new("venice")));
assert!(ids.contains(&ProviderId::new("poolside")));
assert!(ids.contains(&ProviderId::new("deepseek")));
assert!(!ids.contains(&ProviderId::new("fireworks")));
assert!(!ids.contains(&ProviderId::new("ollama")));
assert!(!ids.contains(&ProviderId::new("litellm")));

View file

@ -75,7 +75,6 @@ pub(crate) fn run_args_overrides(args: &RunArgs) -> Result<ManifestSettingsOverr
model: args.model.as_deref(),
provider: args.provider.as_deref(),
environment: args.environment.as_deref(),
docker_image: None,
preserve_sandbox: sparse_flag(args.preserve_sandbox),
dry_run: sparse_flag(args.dry_run),
auto_approve: sparse_flag(args.auto_approve),
@ -98,7 +97,6 @@ pub(crate) fn preflight_args_overrides(args: &PreflightArgs) -> Result<ManifestS
model: args.model.as_deref(),
provider: args.provider.as_deref(),
environment: args.environment.as_deref(),
docker_image: None,
preserve_sandbox: None,
dry_run: None,
auto_approve: None,

View file

@ -120,8 +120,12 @@ async fn main() {
"{:?}",
miette::Report::new(CliDiagnostic::new(err, !json_mode))
);
std::process::exit(exit_code);
}
// Exit rather than returning. A command can leave a blocked worker thread
// behind — `mcp start` parks Tokio's stdin reader on a read only the MCP
// host can end — and dropping the runtime would wait on it forever.
std::process::exit(exit_code);
}
fn install_miette_hook() {

View file

@ -11,7 +11,6 @@ pub(crate) fn run_manifest_args(args: &RunArgs) -> Option<types::ManifestArgs> {
preserve_sandbox: args.preserve_sandbox.then_some(true),
provider: args.provider.clone(),
environment: args.environment.clone(),
docker_image: None,
input: args.inputs.values.clone(),
verbose: args.verbose.then_some(true),
};
@ -27,7 +26,6 @@ pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option<types::Man
preserve_sandbox: None,
provider: args.provider.clone(),
environment: args.environment.clone(),
docker_image: None,
input: args.inputs.values.clone(),
verbose: args.verbose.then_some(true),
};

View file

@ -8,16 +8,20 @@
)]
use std::collections::HashMap;
#[cfg(unix)]
use std::fs;
use std::io::{BufRead as _, Write as _};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, OAuthEntry, StoredSubject};
use fabro_mcp::client::McpClient;
use fabro_mcp::config::{McpServerSettings, McpTransport};
use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};
use fabro_types::RunId;
use fabro_types::{Graph, RunId, WorkflowSettings, test_support};
use httpmock::Method::{GET, POST};
use httpmock::MockServer;
@ -515,50 +519,69 @@ async fn stdio_server_initializes_and_lists_run_tools() {
fn stdio_start_writes_only_json_rpc_to_stdout() {
let context = test_context!();
let fixture = mcp_stdio_fixture(&context, &[]);
let mut cmd = std::process::Command::new(&fixture.command[0]);
cmd.args(&fixture.command[1..])
.env_clear()
.envs(&fixture.env)
.current_dir(&fixture.current_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18","capabilities":{{}},"clientInfo":{{"name":"fabro-test","version":"0.0.0"}}}}}}"#
)
.unwrap();
let (mut child, _stdin, response) =
spawn_stdio_server(&fixture, Path::new(&fixture.command[0]));
let stdout = child.stdout.take().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut line = String::new();
let result = std::io::BufReader::new(stdout).read_line(&mut line);
let _ = tx.send(result.map(|_| line));
});
let line = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("initialize response should arrive")
.expect("stdout should be readable");
let value: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(value["jsonrpc"], "2.0");
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["result"]["serverInfo"]["name"], "fabro");
assert_eq!(
response["result"]["serverInfo"]["version"],
env!("CARGO_PKG_VERSION")
);
let _ = child.kill();
let _ = child.wait();
}
#[cfg(unix)]
#[test]
fn stdio_server_exits_when_executable_is_replaced() {
let context = test_context!();
let fixture = mcp_stdio_fixture(&context, &[]);
let directory = tempfile::tempdir().expect("replacement directory should exist");
// A symlink stands in for a Homebrew install: the server follows it to the
// real binary, so replacing the link changes the identity it watches without
// copying a multi-hundred-megabyte executable.
let executable = directory.path().join("fabro");
std::os::unix::fs::symlink(&fixture.command[0], &executable)
.expect("Fabro executable should be linked");
// `_stdin` holds the pipe open so replacement, not EOF, stops the server.
let (mut child, _stdin, response) = spawn_stdio_server(&fixture, &executable);
assert_eq!(response["result"]["serverInfo"]["name"], "fabro");
let replacement = directory.path().join("fabro-replacement");
fs::write(&replacement, b"replacement").expect("replacement file should be written");
fs::rename(replacement, &executable).expect("Fabro executable should be replaced");
// The server takes up to 1s to notice the replacement and then bounds its
// own shutdown at 5s, so 6s is the worst case. Allow more so a loaded runner
// cannot fail a healthy server; a passing run exits in about a second.
let deadline = Instant::now() + Duration::from_secs(20);
let status = loop {
if let Some(status) = child.try_wait().expect("MCP server should be polled") {
break status;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("MCP server did not exit after its executable was replaced");
}
thread::sleep(Duration::from_millis(50));
};
assert!(status.success(), "MCP server should exit successfully");
}
#[tokio::test(flavor = "multi_thread")]
async fn stdio_startup_and_list_tools_is_fast() {
let context = test_context!();
let start = std::time::Instant::now();
let start = Instant::now();
let client = spawn_mcp_client(&context, &[]).await;
let tools = client.list_tools().await.unwrap();
assert_eq!(tools.len(), MCP_RUN_TOOL_NAMES.len());
assert!(start.elapsed() < std::time::Duration::from_secs(2));
assert!(start.elapsed() < Duration::from_secs(2));
client
.shutdown()
.await
@ -1602,12 +1625,21 @@ async fn mcp_get_resolves_selector_and_returns_summary_projection_and_questions(
let projection = server.mock(|when, then| {
when.method(GET)
.path(format!("/api/v1/runs/{run_id}/state"));
let mut body = run_projection_json(&run_id, &serde_json::json!({ "kind": "running" }));
body["spec"]["settings"]["run"]["model"] = serde_json::json!({
"provider": "openai",
"name": "gpt-5.6-sol",
"fallbacks": {
"gpt-5.6-sol": ["gpt-5.6-terra"]
},
"controls": {
"reasoning_effort": null,
"speed": null
}
});
then.status(200)
.header("Content-Type", "application/json")
.json_body(run_projection_json(
&run_id,
&serde_json::json!({ "kind": "running" }),
));
.json_body(body);
});
let questions = server.mock(|when, then| {
when.method(GET)
@ -1644,6 +1676,10 @@ async fn mcp_get_resolves_selector_and_returns_summary_projection_and_questions(
assert_eq!(get["summary"]["workflow_name"], "Simple");
assert_eq!(get["summary"]["workflow_slug"], "simple");
assert_eq!(get["projection"]["status"]["kind"], "running");
assert_eq!(
get["projection"]["spec"]["settings"]["run"]["model"]["fallbacks"]["gpt-5.6-sol"][0],
"gpt-5.6-terra"
);
assert_eq!(get["questions"][0]["id"], "q-1");
resolve.assert();
retrieve.assert();
@ -2001,6 +2037,82 @@ async fn mcp_events_filters_find_matches_beyond_first_page() {
.expect("MCP client should shut down");
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_events_decodes_run_created_with_model_keyed_fallbacks() {
let context = test_context!();
let server = MockServer::start();
let target_url = format!("{}/api/v1", server.base_url());
let target: fabro_client::ServerTarget = target_url.parse().unwrap();
seed_dev_token_auth(&context.home_dir, &target, TEST_DEV_TOKEN);
let run_id = unique_run_id();
let resolve = mock_resolved_run(&server, "nightly", &run_id);
let mut settings = serde_json::to_value(WorkflowSettings::default())
.expect("workflow settings should serialize");
settings["run"]["model"] = serde_json::json!({
"provider": "openai",
"name": "gpt-5.6-sol",
"fallbacks": {
"gpt-5.6-sol": ["gpt-5.6-terra"]
},
"controls": {
"reasoning_effort": null,
"speed": null
}
});
let event = serde_json::json!({
"seq": 1,
"id": "evt-created",
"ts": "2026-04-05T12:00:00Z",
"run_id": run_id,
"event": "run.created",
"properties": {
"settings": settings,
"graph": Graph::new("Remote Workflow"),
"labels": {},
"run_dir": "/tmp/run",
"source_directory": "/srv/repo",
"provenance": test_support::test_run_provenance()
},
"actor": null
});
let events = server.mock(|when, then| {
when.method(GET)
.path(format!("/api/v1/runs/{run_id}/events"))
.query_param_missing("limit");
then.status(200)
.header("Content-Type", "application/json")
.json_body(serde_json::json!({
"data": [event],
"meta": { "has_more": false }
}));
});
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
let result = call_tool_json(
&client,
"fabro_run_events",
serde_json::json!({
"run_id": "nightly",
"action": "search",
"query": "gpt-5.6-terra",
"first": 1
}),
)
.await;
assert_eq!(
result["events"][0]["event"]["properties"]["settings"]["run"]["model"]["fallbacks"]["gpt-5.6-sol"]
[0],
"gpt-5.6-terra"
);
resolve.assert();
events.assert();
client
.shutdown()
.await
.expect("MCP client should shut down");
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_events_requires_action_specific_inputs_before_auth() {
let context = test_context!();
@ -2379,6 +2491,45 @@ fn mcp_stdio_fixture(context: &fabro_test::TestContext, extra_args: &[&str]) ->
}
}
const MCP_INITIALIZE_REQUEST: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"fabro-test","version":"0.0.0"}}}"#;
/// Starts `fabro mcp start` as a raw child process, sends `initialize`, and
/// returns the decoded response. Unlike `spawn_mcp_client`, the caller keeps
/// the `Child` and its stdin, so it can observe how and when the server exits.
fn spawn_stdio_server(
fixture: &McpStdioFixture,
program: &Path,
) -> (Child, ChildStdin, serde_json::Value) {
let mut child = Command::new(program)
.args(&fixture.command[1..])
.env_clear()
.envs(&fixture.env)
.current_dir(&fixture.current_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("MCP server should start");
let mut stdin = child.stdin.take().expect("MCP stdin should be available");
writeln!(stdin, "{MCP_INITIALIZE_REQUEST}").expect("initialize request should be written");
let stdout = child.stdout.take().expect("MCP stdout should be available");
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let mut line = String::new();
let result = std::io::BufReader::new(stdout).read_line(&mut line);
let _ = tx.send(result.map(|_| line));
});
let line = rx
.recv_timeout(Duration::from_secs(5))
.expect("initialize response should arrive")
.expect("MCP stdout should be readable");
let response = serde_json::from_str(line.trim()).expect("response should be JSON");
(child, stdin, response)
}
fn write_mcp_server_settings(
context: &mut fabro_test::TestContext,
storage_dir: &Path,

View file

@ -32,7 +32,8 @@ serde_json.workspace = true
strum.workspace = true
tokio.workspace = true
toml.workspace = true
tracing.workspace = true
[dev-dependencies]
httpmock = "0.8"
tempfile = "3"
tempfile = "3"

View file

@ -9,9 +9,7 @@ use anyhow::{Context as _, Result, anyhow};
use serde_json::map::Entry;
use serde_json::{Map, Value, json};
use crate::{McpAgent, McpConfigSettings, McpInitSettings};
const SERVER_NAME: &str = "fabro";
use crate::{McpAgent, McpConfigSettings, McpInitSettings, SERVER_NAME};
pub fn config_json(settings: &McpConfigSettings) -> Result<String> {
serde_json::to_string_pretty(&generic_config(settings))

View file

@ -0,0 +1,139 @@
//! Detects when an upgrade replaces the executable that launched this MCP
//! server.
//!
//! MCP hosts can keep stdio servers alive for days. Without this check, an old
//! process keeps its old API response decoder after the `fabro` file on disk is
//! upgraded.
use std::fs::Metadata;
use std::path::{self, PathBuf};
use std::time::Duration;
use std::{env, fs, io};
use tokio::time::{self, Instant, MissedTickBehavior};
const CHECK_INTERVAL: Duration = Duration::from_secs(1);
pub(crate) struct ExecutableMonitor {
path: PathBuf,
identity: Identity,
}
impl ExecutableMonitor {
pub(crate) fn current() -> io::Result<Self> {
Self::new(invoked_executable_path()?)
}
fn new(path: PathBuf) -> io::Result<Self> {
let identity = identity(&fs::metadata(&path)?);
Ok(Self { path, identity })
}
pub(crate) async fn wait_until_replaced(self) {
let mut interval = time::interval_at(Instant::now() + CHECK_INTERVAL, CHECK_INTERVAL);
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
interval.tick().await;
if self.was_replaced() {
return;
}
}
}
/// Reads the identity synchronously. This is a `stat` of a page-cached
/// inode once per second, so handing it to Tokio's blocking pool would
/// cost more than the call itself and would keep a pool thread resident
/// for the life of the server.
fn was_replaced(&self) -> bool {
!fs::metadata(&self.path).is_ok_and(|metadata| identity(&metadata) == self.identity)
}
}
/// Identifies the file behind an executable path. Upgrades always swap a new
/// file into place — `fabro upgrade` renames over the old one and Homebrew
/// repoints a symlink — so the identity changes even though the path does not.
#[cfg(unix)]
type Identity = (u64, u64);
#[cfg(unix)]
fn identity(metadata: &Metadata) -> Identity {
use std::os::unix::fs::MetadataExt as _;
(metadata.dev(), metadata.ino())
}
#[cfg(not(unix))]
type Identity = (u64, Option<std::time::SystemTime>);
#[cfg(not(unix))]
fn identity(metadata: &Metadata) -> Identity {
(metadata.len(), metadata.modified().ok())
}
/// Resolves the executable path to watch.
///
/// `argv[0]` wins when it carries a directory, because it names the path the
/// host actually launched, symlink included. MCP hosts normally launch a bare
/// `fabro` found on `PATH`, which leaves `current_exe`: it reports the symlink
/// on macOS, and the Homebrew symlink's own target on Linux.
fn invoked_executable_path() -> io::Result<PathBuf> {
match env::args_os().next().map(PathBuf::from) {
Some(invoked) if invoked.components().count() > 1 => path::absolute(invoked),
_ => env::current_exe(),
}
}
#[cfg(test)]
mod tests {
use tokio::fs as async_fs;
use super::*;
#[tokio::test]
async fn unchanged_executable_is_current() {
let directory = tempfile::tempdir().expect("temp directory should exist");
let executable = directory.path().join("fabro");
async_fs::write(&executable, b"current")
.await
.expect("fixture executable should be written");
let monitor = ExecutableMonitor::new(executable).unwrap();
assert!(!monitor.was_replaced());
}
#[tokio::test]
async fn atomic_executable_replacement_is_detected() {
let directory = tempfile::tempdir().expect("temp directory should exist");
let executable = directory.path().join("fabro");
let replacement = directory.path().join("fabro-new");
async_fs::write(&executable, b"old")
.await
.expect("old fixture executable should be written");
async_fs::write(&replacement, b"new executable")
.await
.expect("new fixture executable should be written");
let monitor = ExecutableMonitor::new(executable.clone()).unwrap();
async_fs::rename(&replacement, &executable)
.await
.expect("fixture executable should be replaced");
assert!(monitor.was_replaced());
}
#[tokio::test]
async fn removed_executable_is_detected() {
let directory = tempfile::tempdir().expect("temp directory should exist");
let executable = directory.path().join("fabro");
async_fs::write(&executable, b"current")
.await
.expect("fixture executable should be written");
let monitor = ExecutableMonitor::new(executable.clone()).unwrap();
async_fs::remove_file(executable)
.await
.expect("fixture executable should be removed");
assert!(monitor.was_replaced());
}
}

View file

@ -1,4 +1,5 @@
mod config;
mod executable_monitor;
mod manifest_builder;
mod server;
@ -12,6 +13,10 @@ pub use config::{config_json, init_agent};
use fabro_client::Client;
pub use server::start;
/// The name this MCP server reports over the wire and registers under in agent
/// config files.
pub(crate) const SERVER_NAME: &str = "fabro";
pub type FabroClientFuture = Pin<Box<dyn Future<Output = Result<Client>> + Send>>;
pub type FabroClientFactory = Arc<dyn Fn() -> FabroClientFuture + Send + Sync>;

View file

@ -1,19 +1,24 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use fabro_tool::fabro_client::ClientBackend;
use fabro_tool::{self as run_tools, FabroToolBackend};
use fabro_util::version::FABRO_VERSION;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo};
use rmcp::model::{CallToolResult, Content, Implementation, ServerCapabilities, ServerInfo};
use rmcp::transport::stdio;
use rmcp::{ErrorData, ServerHandler, serve_server, tool, tool_handler, tool_router};
use serde::Serialize;
use tokio::sync::OnceCell;
use tokio::time;
use tracing::warn;
use crate::FabroMcpServerSettings;
use crate::executable_monitor::ExecutableMonitor;
use crate::manifest_builder::McpRunManifestBuilder;
use crate::{FabroMcpServerSettings, SERVER_NAME};
#[derive(Clone)]
pub(crate) struct FabroMcpServer {
@ -23,10 +28,44 @@ pub(crate) struct FabroMcpServer {
tool_router: ToolRouter<Self>,
}
/// How long to wait for the MCP service to stop after an upgrade is detected.
/// The wait is bounded because the transport closes by writing to stdout, which
/// blocks if the host has stopped reading.
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
pub async fn start(settings: FabroMcpServerSettings) -> Result<()> {
let server = FabroMcpServer::new(Arc::new(settings));
let service = serve_server(server, stdio()).await?;
service.waiting().await?;
let monitor = match ExecutableMonitor::current() {
Ok(monitor) => Some(monitor),
Err(error) => {
warn!(
%error,
"Upgrade detection is unavailable; this MCP server will keep running after an \
upgrade replaces it"
);
None
}
};
let service = serve_server(FabroMcpServer::new(Arc::new(settings)), stdio()).await?;
let Some(monitor) = monitor else {
service.waiting().await?;
return Ok(());
};
let cancellation = service.cancellation_token();
let mut service_wait = Box::pin(service.waiting());
tokio::select! {
result = &mut service_wait => {
result?;
}
() = monitor.wait_until_replaced() => {
// An upgrade replaced the executable, so stop serving and let the
// host reconnect to the new one. The CLI exits the process rather
// than returning, because Tokio's stdin worker stays blocked on a
// read that only the host can end.
cancellation.cancel();
let _ = time::timeout(SHUTDOWN_TIMEOUT, service_wait).await;
}
}
Ok(())
}
@ -34,6 +73,7 @@ pub async fn start(settings: FabroMcpServerSettings) -> Result<()> {
impl ServerHandler for FabroMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new(SERVER_NAME, FABRO_VERSION).with_title("Fabro"))
.with_instructions("Use these tools to create, inspect, control, wait for, and read events from Fabro workflow runs.")
}
}
@ -251,6 +291,22 @@ mod tests {
use super::*;
use crate::FabroMcpServerSettings;
#[test]
fn server_info_reports_fabro_version() {
let settings = FabroMcpServerSettings {
cwd: PathBuf::from("."),
config_path: PathBuf::from("fabro.toml"),
client_factory: Arc::new(|| {
Box::pin(async { panic!("client should not be constructed while reading info") })
}),
};
let info = FabroMcpServer::new(Arc::new(settings)).get_info();
assert_eq!(info.server_info.name, "fabro");
assert_eq!(info.server_info.title.as_deref(), Some("Fabro"));
assert_eq!(info.server_info.version, FABRO_VERSION);
}
#[test]
fn fabro_run_pair_tool_is_registered_with_stage_based_schema() {
let settings = FabroMcpServerSettings {

View file

@ -121,5 +121,6 @@ tokio-util.workspace = true
tokio-tungstenite.workspace = true
fabro-macros = { path = "../../foundation/fabro-macros" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] }
fabro-store = { path = "../../components/fabro-store", features = ["test-support"] }
fabro-test = { workspace = true }
fabro-types = { path = "../../foundation/fabro-types", features = ["test-support"] }

View file

@ -34,6 +34,7 @@ pub mod manifest_validation;
mod migrations;
mod principal_middleware;
mod request_id;
mod run_compiler;
mod run_files;
mod run_files_security;
mod run_manifest;

File diff suppressed because it is too large Load diff

View file

@ -7,11 +7,10 @@ use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail};
use fabro_api::types;
use fabro_auth::auth_issue_message;
use fabro_config::parse::{self, SettingsSource};
use fabro_config::parse::SettingsSource;
use fabro_config::{
CliLayer, CliOutputLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
MergeMap, RunLayer, SettingsLayer, WorkflowSettingsBuilder, parse_input_overrides,
parse_labels, project,
CliLayer, CliOutputLayer, EnvironmentLayer, MergeMap, RunLayer, SettingsLayer,
WorkflowSettingsBuilder, parse_input_overrides, parse_labels, project,
};
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_graphviz::render::apply_direction;
@ -30,16 +29,14 @@ use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{EnvironmentProvider, McpServerSettings, RunGoal, RunNamespace};
use fabro_types::{
ManifestPath, RunId, RunNoticeLevel, RunProvenance, SandboxProviderKind, ServerSettings,
WorkflowSettings,
ManifestPath, RunId, RunNoticeLevel, SandboxProviderKind, ServerSettings, WorkflowSettings,
};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::Error as WorkflowError;
use fabro_workflow::model_fallback::resolve_model_fallbacks;
use fabro_workflow::operations::{
CreateRunInput, ValidateInput, WorkflowInput, validate, validate_with_catalog,
validate_with_ready_providers,
ValidateInput, WorkflowInput, validate, validate_with_catalog, validate_with_ready_providers,
};
use fabro_workflow::pipeline::Validated;
use fabro_workflow::run_materialization::materialize_run_with_ready_providers;
@ -48,6 +45,7 @@ use futures_util::stream::{self, StreamExt};
use tokio::process::Command;
use tokio::time;
use crate::run_compiler;
use crate::server::AppState;
use crate::server_secrets::LlmClientResult;
@ -56,21 +54,17 @@ pub(crate) struct PreparedManifest {
pub cwd: PathBuf,
pub git: Option<types::GitContext>,
pub root_source: String,
pub run_id: Option<RunId>,
pub parent_id: Option<RunId>,
pub title: Option<String>,
pub settings: WorkflowSettings,
pub target_path: ManifestPath,
pub workflow_bundle: WorkflowBundle,
pub workflow_input: BundledWorkflow,
pub source_directory: PathBuf,
}
#[derive(Clone, Debug, Default)]
struct ManifestSettingsOverrides {
run: Option<RunLayer>,
cli: Option<CliLayer>,
input_overrides: HashMap<String, toml::Value>,
pub(crate) struct ManifestSettingsOverrides {
pub(crate) run: Option<RunLayer>,
pub(crate) cli: Option<CliLayer>,
pub(crate) input_overrides: HashMap<String, toml::Value>,
}
#[cfg(test)]
@ -157,34 +151,33 @@ pub(crate) fn prepare_manifest_with_environment_defaults(
{
settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal.text)));
}
let title = manifest
manifest
.title
.as_ref()
.map(|title| fabro_types::normalize_explicit_run_title(title.as_str()))
.transpose()?;
manifest
.run_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.context("invalid run ID")?;
manifest
.parent_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.context("invalid parent run ID")?;
let source_directory = project::resolve_working_directory_from_run(&settings.run, &cwd);
Ok(PreparedManifest {
cwd: cwd.clone(),
cwd,
git: manifest.git.clone(),
root_source,
run_id: manifest
.run_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.context("invalid run ID")?,
parent_id: manifest
.parent_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.context("invalid parent run ID")?,
title,
settings: settings.clone(),
settings,
target_path,
workflow_bundle,
workflow_input,
source_directory: project::resolve_working_directory_from_run(&settings.run, &cwd),
source_directory,
})
}
@ -235,34 +228,6 @@ fn manifest_validate_input(
}
}
pub(crate) fn create_run_input(
prepared: PreparedManifest,
configured_providers: Vec<ProviderId>,
provenance: RunProvenance,
web_url: Option<String>,
vars: HashMap<String, String>,
) -> CreateRunInput {
CreateRunInput {
workflow: WorkflowInput::Bundled(prepared.workflow_input),
settings: prepared.settings,
vars,
cwd: prepared.cwd,
workflow_slug: None,
workflow_path: Some(prepared.target_path),
workflow_bundle: Some(prepared.workflow_bundle),
submitted_manifest_bytes: None,
run_id: prepared.run_id,
title: prepared.title,
automation: None,
git: prepared.git,
fork_source_ref: None,
parent_id: prepared.parent_id,
provenance,
configured_providers,
web_url,
}
}
pub(crate) async fn run_preflight(
state: &AppState,
prepared: &PreparedManifest,
@ -375,19 +340,16 @@ fn settings_layer_with_resolved_dockerfiles(
files: &HashMap<ManifestPath, String>,
settings_source: SettingsSource,
) -> Result<SettingsLayer> {
// Parse via `SettingsLayer` so unknown nested keys (like a stale
// `[server.integrations.github.permissions]` after the move to
// `[run.integrations.github.permissions]`) trip `deny_unknown_fields`.
let mut layer = source
.parse::<SettingsLayer>()
.context("Failed to parse run config TOML")?;
parse::validate_settings_source(&layer, settings_source)
.context("Failed to parse run config TOML")?;
resolve_manifest_dockerfiles(&mut layer, config_path, files)?;
Ok(layer)
run_compiler::settings_layer_with_resolved_dockerfiles(
source,
config_path,
files,
settings_source,
)
.map_err(anyhow::Error::new)
}
fn manifest_args_overrides(
pub(crate) fn manifest_args_overrides(
args: Option<&types::ManifestArgs>,
) -> Result<ManifestSettingsOverrides> {
let Some(args) = args else {
@ -399,7 +361,6 @@ fn manifest_args_overrides(
model: args.model.as_deref(),
provider: args.provider.as_deref(),
environment: args.environment.as_deref(),
docker_image: args.docker_image.as_deref(),
preserve_sandbox: args.preserve_sandbox,
dry_run: args.dry_run,
auto_approve: args.auto_approve,
@ -424,50 +385,6 @@ fn manifest_args_overrides(
})
}
fn resolve_manifest_dockerfiles(
layer: &mut SettingsLayer,
config_path: &ManifestPath,
files: &HashMap<ManifestPath, String>,
) -> Result<()> {
for environment in layer.environments.values_mut() {
if let Some(image) = environment.image.as_mut() {
resolve_manifest_dockerfile(image, config_path, files)?;
}
}
if let Some(image) = layer
.run
.as_mut()
.and_then(|run| run.environment.as_mut())
.and_then(|environment| environment.image.as_mut())
{
resolve_manifest_dockerfile(image, config_path, files)?;
}
Ok(())
}
fn resolve_manifest_dockerfile(
image: &mut EnvironmentImageLayer,
config_path: &ManifestPath,
files: &HashMap<ManifestPath, String>,
) -> Result<()> {
let source = image.dockerfile.as_mut();
let Some(source) = source else {
return Ok(());
};
let EnvironmentDockerfileLayer::Path { path } = &*source else {
return Ok(());
};
let path_owned = path.clone();
let manifest_path = ManifestPath::from_reference(config_path.parent_or_dot(), &path_owned)
.ok_or_else(|| anyhow!("unsupported dockerfile reference: {path_owned}"))?;
let content = files
.get(&manifest_path)
.cloned()
.ok_or_else(|| anyhow!("missing bundled dockerfile: {manifest_path}"))?;
*source = EnvironmentDockerfileLayer::Inline(content);
Ok(())
}
fn manifest_project_config_path(
config: &types::ManifestConfig,
cwd: &Path,
@ -2149,7 +2066,6 @@ root = "/srv/fabro"
preserve_sandbox: None,
provider: None,
environment: None,
docker_image: None,
input: Vec::new(),
verbose: None,
});
@ -2182,7 +2098,6 @@ override = "server"
preserve_sandbox: None,
provider: None,
environment: None,
docker_image: None,
input: vec!["override=cli".to_string()],
verbose: None,
});

View file

@ -58,7 +58,6 @@ pub fn run_tool_manifest_args(spec: &ValidatedCreateRunSpec) -> Option<types::Ma
let payload = types::ManifestArgs {
auto_approve: spec.auto_approve.filter(|value| *value),
docker_image: None,
dry_run: spec.dry_run.filter(|value| *value),
input,
label,
@ -77,7 +76,6 @@ pub fn run_tool_run_overrides(spec: &ValidatedCreateRunSpec) -> Option<RunLayer>
model: spec.model.as_deref(),
provider: spec.provider.as_deref(),
environment: spec.environment.as_deref(),
docker_image: None,
preserve_sandbox: spec.preserve_sandbox,
dry_run: spec.dry_run,
auto_approve: spec.auto_approve,

View file

@ -1,7 +1,9 @@
use std::collections::{HashMap, HashSet};
use std::io::ErrorKind;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context as _;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
@ -13,25 +15,25 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use bytes::Bytes;
use chrono::{DateTime, Utc};
use fabro_api::types::{
BoardColumn, RunManifest, SubmitAnswerRequest, UpdateRunParentRequest, UpdateRunRequest,
BoardColumn, ManifestConfigType, ManifestGoalType, RunManifest, SubmitAnswerRequest,
UpdateRunParentRequest, UpdateRunRequest,
};
use fabro_config::Storage;
use fabro_config::{CliLayer, RunLayer, Storage};
use fabro_interview::AnswerSubmission;
use fabro_llm::client::Client as LlmClient;
use fabro_store::{
RunSummaryListQuery, RunSummarySort, RunSummarySortDirection, RunSummaryVisibility,
};
use fabro_types::settings::ResolveError;
use fabro_types::{
AutomationRef, Principal, Run, RunClientProvenance, RunId, RunProvenance, RunServerProvenance,
RunStatusKind, StageContextWindow, StageContextWindowStaleness,
AutomationRef, ManifestPath, Principal, Run, RunClientProvenance, RunId, RunProvenance,
RunServerProvenance, RunStatusKind, StageContextWindow, StageContextWindowStaleness,
StageContextWindowUnavailableReason, StageHandler, StageModelUsage, StageProjection,
SystemActorKind, WorkflowSettings, parse_blob_ref,
SystemActorKind, parse_blob_ref,
};
use fabro_util::version::FABRO_VERSION;
use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError};
use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice};
use fabro_workflow::run_status::RunStatus;
use fabro_workflow::workflow_bundle::WorkflowBundle;
use fabro_workflow::{Error as WorkflowError, operations};
use strum::VariantArray as _;
use tokio::fs;
@ -48,6 +50,9 @@ use crate::principal_middleware::{
RequireCommandLog, RequireRunManagementTarget, RequireRunScoped, RequireRunStageScoped,
RequiredRunManagementActor, RequiredUser,
};
use crate::run_compiler::{
self, ProjectSettingsPathError, ProjectSettingsSource, RawRunCompilerInput, RunCompilerError,
};
use crate::run_files::{list_run_commits, list_run_files};
use crate::run_manifest;
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
@ -552,6 +557,143 @@ pub(crate) struct CreateRunFromManifestRequest {
pub(crate) automation: Option<AutomationRef>,
}
struct ManifestRunCompilerAdapter {
workflow_bundle: WorkflowBundle,
entrypoint: ManifestPath,
cwd: PathBuf,
project_settings: Vec<ProjectSettingsSource>,
user_toml: Vec<String>,
run_overrides: Option<RunLayer>,
cli_overrides: Option<CliLayer>,
input_overrides: HashMap<String, toml::Value>,
inline_goal_override: Option<String>,
}
fn adapt_manifest_source_for_run_compiler(
manifest: &RunManifest,
) -> anyhow::Result<ManifestRunCompilerAdapter> {
if manifest.version != 1 {
anyhow::bail!("unsupported manifest version {}", manifest.version);
}
let cwd = PathBuf::from(&manifest.cwd);
let entrypoint = ManifestPath::from_wire(&manifest.target.path)
.ok_or_else(|| anyhow::anyhow!("invalid manifest target path: {}", manifest.target.path))?;
let workflow_bundle = run_manifest::workflow_bundle_from_manifest(&manifest.workflows)?;
if workflow_bundle.workflow(&entrypoint).is_none() {
anyhow::bail!("manifest target path is missing from workflows map");
}
let overrides = run_manifest::manifest_args_overrides(manifest.args.as_ref())
.context("failed to parse manifest args")?;
let project_settings = manifest
.configs
.iter()
.filter(|config| config.type_ == ManifestConfigType::Project)
.filter_map(|config| config.source.as_ref().map(|source| (config, source)))
.map(|(config, source)| ProjectSettingsSource {
path: normalize_project_settings_path(config.path.as_deref(), &cwd),
toml: source.clone(),
})
.collect();
let user_toml = manifest
.configs
.iter()
.filter(|config| config.type_ == ManifestConfigType::User)
.filter_map(|config| config.source.clone())
.collect();
let inline_goal_override = manifest
.goal
.as_ref()
.filter(|goal| goal.type_ != ManifestGoalType::Graph)
.map(|goal| goal.text.clone());
Ok(ManifestRunCompilerAdapter {
workflow_bundle,
entrypoint,
cwd,
project_settings,
user_toml,
run_overrides: overrides.run,
cli_overrides: overrides.cli,
input_overrides: overrides.input_overrides,
inline_goal_override,
})
}
fn normalize_project_settings_path(
path: Option<&str>,
cwd: &std::path::Path,
) -> Result<ManifestPath, ProjectSettingsPathError> {
let path = path.ok_or(ProjectSettingsPathError::Missing)?;
let path_ref = std::path::Path::new(path);
let manifest_path = if path_ref.is_absolute() {
ManifestPath::from_absolute(path_ref, cwd)
} else {
ManifestPath::from_wire(path)
};
manifest_path.ok_or_else(|| ProjectSettingsPathError::Invalid {
path: path.to_string(),
})
}
struct ManifestRunIdentity {
run_id: Option<RunId>,
parent_id: Option<RunId>,
title: Option<String>,
}
fn manifest_run_identity(
manifest: &RunManifest,
explicit_run_id: Option<RunId>,
) -> anyhow::Result<ManifestRunIdentity> {
let title = manifest
.title
.as_ref()
.map(|title| fabro_types::normalize_explicit_run_title(title.as_str()))
.transpose()?;
let manifest_run_id = manifest
.run_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.context("invalid run ID")?;
let parent_id = manifest
.parent_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.context("invalid parent run ID")?;
Ok(ManifestRunIdentity {
run_id: explicit_run_id.or(manifest_run_id),
parent_id,
title,
})
}
/// Map a [`RunCompilerError`] onto the create endpoint's pre-extraction wire
/// contract. The 400 details for source, settings, and interpolation errors
/// are the error types' own `Display` strings, which are pinned to the
/// legacy messages.
fn run_compiler_error_response(error: RunCompilerError) -> Response {
match error {
RunCompilerError::InvalidSource(_)
| RunCompilerError::InvalidSettings(_)
| RunCompilerError::VariableInterpolation(_) => {
ApiError::bad_request(error.to_string()).into_response()
}
RunCompilerError::Workflow(
WorkflowError::ValidationFailed { .. } | WorkflowError::Parse(_),
) => ApiError::bad_request("Validation failed").into_response(),
RunCompilerError::Workflow(
err @ (WorkflowError::ModelSelection(_) | WorkflowError::ModelReference(_)),
) => ApiError::bad_request(err.to_string()).into_response(),
RunCompilerError::Workflow(err) => ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to persist run state: {err}"),
)
.into_response(),
}
}
pub(crate) async fn create_run_from_manifest(
state: Arc<AppState>,
request: CreateRunFromManifestRequest,
@ -568,15 +710,43 @@ pub(crate) async fn create_run_from_manifest(
let manifest_run_defaults = state.manifest_run_defaults();
let manifest_environment_defaults = state.environment_store().catalog_layer();
let manifest_mcp_server_catalog = state.mcp_server_store().catalog_settings();
let mut prepared = match run_manifest::prepare_manifest_with_environment_defaults(
manifest_run_defaults.as_ref(),
manifest_environment_defaults.as_ref(),
&manifest_mcp_server_catalog,
&manifest,
) {
Ok(prepared) => prepared,
let manifest_adapter = match adapt_manifest_source_for_run_compiler(&manifest) {
Ok(adapter) => adapter,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let title_generation_target = manifest_adapter.entrypoint.clone();
let raw_compiler_input = RawRunCompilerInput {
workflow_bundle: manifest_adapter.workflow_bundle,
entrypoint: manifest_adapter.entrypoint,
cwd: manifest_adapter.cwd,
server_run_defaults: manifest_run_defaults.as_ref().clone(),
server_environment_defaults: manifest_environment_defaults.as_ref().clone(),
server_mcp_catalog: manifest_mcp_server_catalog,
project_settings: manifest_adapter.project_settings,
user_toml: manifest_adapter.user_toml,
run_overrides: manifest_adapter.run_overrides,
cli_overrides: manifest_adapter.cli_overrides,
input_overrides: manifest_adapter.input_overrides,
inline_goal_override: manifest_adapter.inline_goal_override,
run_id: None,
title: None,
parent_id: None,
git: manifest.git.clone(),
storage_root: state.server_storage_dir(),
workflow_slug: None,
provenance: run_provenance(&headers, &actor),
web_url: None,
submitted_manifest_bytes: Some(submitted_manifest_bytes),
automation,
};
let normalized = match run_compiler::normalize_source(raw_compiler_input) {
Ok(normalized) => normalized,
Err(err) => return run_compiler_error_response(err),
};
let layered = match run_compiler::layer_settings(normalized) {
Ok(layered) => layered,
Err(err) => return run_compiler_error_response(err),
};
let vars = match snapshot_run_variables(&state).await {
Ok(vars) => vars,
Err(err) => {
@ -584,20 +754,24 @@ pub(crate) async fn create_run_from_manifest(
.into_response();
}
};
if let Err(err) = substitute_run_variables(&vars, &mut prepared.settings) {
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
.into_response();
}
let run_id = explicit_run_id
.or(prepared.run_id)
.unwrap_or_else(RunId::new);
let provider = run_manifest::effective_sandbox_provider(&prepared.settings.run);
let prepared = match run_compiler::apply_run_variables(layered, vars) {
Ok(prepared) => prepared,
Err(err) => return run_compiler_error_response(err),
};
let identity = match manifest_run_identity(&manifest, explicit_run_id) {
Ok(identity) => identity,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let prepared = prepared.with_identity(identity.run_id, identity.parent_id, identity.title);
let (prepared, run_id) = prepared.resolve_run_id();
let prepared = prepared.with_web_url(state.run_web_url(&run_id));
let provider = run_manifest::effective_sandbox_provider(&prepared.settings().run);
if let Some(error) =
run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)
{
return ApiError::bad_request(error).into_response();
}
if let Some(parent_id) = prepared.parent_id {
if let Some(parent_id) = prepared.parent_id() {
if parent_id == run_id {
return ApiError::bad_request("A run cannot be its own parent.").into_response();
}
@ -607,7 +781,6 @@ pub(crate) async fn create_run_from_manifest(
}
info!(run_id = %run_id, "Run created");
let web_url = state.run_web_url(&run_id);
let catalog = state.catalog();
// Resolve once: we need both the provider IDs (for the run create input
// and ask-fabro-readiness) and the LLM client itself (for the spawned
@ -628,34 +801,21 @@ pub(crate) async fn create_run_from_manifest(
ready_provider_ids.clone()
}
};
let provenance = run_provenance(&headers, &actor);
let mut create_input = run_manifest::create_run_input(
prepared.clone(),
run_materialization_provider_ids,
provenance,
web_url.clone(),
vars,
);
create_input.run_id = Some(run_id);
create_input.submitted_manifest_bytes = Some(submitted_manifest_bytes);
create_input.automation = automation;
let storage_root = state.server_storage_dir();
let created = match Box::pin(operations::create(
let pinned =
match run_compiler::compile_and_pin(prepared, run_materialization_provider_ids, catalog)
.await
{
Ok(pinned) => pinned,
Err(err) => return run_compiler_error_response(err),
};
let persistence_input = run_compiler::assemble_run(pinned);
let created = match Box::pin(operations::persist_create_run(
state.stores.runs.as_ref(),
create_input,
storage_root,
catalog,
persistence_input,
))
.await
{
Ok(created) => created,
Err(WorkflowError::ValidationFailed { .. } | WorkflowError::Parse(_)) => {
return ApiError::bad_request("Validation failed").into_response();
}
Err(err @ (WorkflowError::ModelSelection(_) | WorkflowError::ModelReference(_))) => {
return ApiError::bad_request(err.to_string()).into_response();
}
Err(err) => {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
@ -699,7 +859,7 @@ pub(crate) async fn create_run_from_manifest(
let run_spec = created.persisted.run_spec();
let workflow = run_title_generation::workflow_summary(&run_spec.graph);
let run_inputs = run_spec.settings.run.inputs.clone();
let workflow_target = prepared.target_path.to_string();
let workflow_target = title_generation_target.to_string();
let title_catalog = state.catalog();
let title_model = title_catalog.small_default_for_configured_ids(&ready_provider_ids);
let title_model_id = title_model.id.clone();
@ -844,7 +1004,7 @@ async fn run_preflight(
.into_response();
}
};
if let Err(err) = substitute_run_variables(&vars, &mut prepared.settings) {
if let Err(err) = run_compiler::substitute_run_variables(&vars, &mut prepared.settings) {
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
.into_response();
}
@ -897,7 +1057,7 @@ async fn validate_run_manifest(
.into_response();
}
};
if let Err(err) = substitute_run_variables(&vars, &mut prepared.settings) {
if let Err(err) = run_compiler::substitute_run_variables(&vars, &mut prepared.settings) {
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
.into_response();
}
@ -925,33 +1085,6 @@ async fn snapshot_run_variables(
state.stores.variables.value_map().await
}
#[derive(Debug, thiserror::Error)]
enum RunVariableSubstitutionError {
#[error(transparent)]
Interpolation(#[from] ResolveError),
#[error("run.artifacts.include[{index}]: {source}")]
ArtifactGlob {
index: usize,
#[source]
source: WorkspaceGlobError,
},
}
fn substitute_run_variables(
variables: &HashMap<String, String>,
settings: &mut WorkflowSettings,
) -> Result<(), RunVariableSubstitutionError> {
settings
.run
.substitute_variables(|name| variables.get(name).cloned())?;
for (index, pattern) in settings.run.artifacts.include.iter().enumerate() {
WorkspaceGlob::try_new(pattern)
.map_err(|source| RunVariableSubstitutionError::ArtifactGlob { index, source })?;
}
Ok(())
}
async fn get_run_status(
RequireRunManagementTarget(id, _actor): RequireRunManagementTarget,
State(state): State<Arc<AppState>>,
@ -1254,26 +1387,3 @@ fn build_command_log_response(
})
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_artifact_glob_that_becomes_unsafe_after_interpolation() {
let variables = HashMap::from([("PATTERN".to_string(), "../outside/**".to_string())]);
let mut settings = WorkflowSettings::default();
settings.run.artifacts.include = vec!["{{ vars.PATTERN }}".to_string()];
let error = substitute_run_variables(&variables, &mut settings)
.expect_err("interpolated parent traversal should be rejected");
assert!(matches!(
error,
RunVariableSubstitutionError::ArtifactGlob {
index: 0,
source: WorkspaceGlobError::ParentTraversal { .. },
}
));
}
}

View file

@ -3660,6 +3660,348 @@ async fn create_run_from_manifest_helper_persists_automation_metadata() {
assert_eq!(summary.automation, Some(automation));
}
#[tokio::test]
async fn create_run_from_manifest_pins_compiled_and_persisted_behavior() {
let state = TestAppStateBuilder::new()
.runtime_settings(
default_test_server_settings(),
manifest_run_defaults_from_toml(
r#"
[run.metadata]
server-label = "server"
layer = "server"
"#,
),
)
.env_lookup(|_| None)
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let run_id = RunId::new();
let dot = r#"digraph CompilePin {
graph [goal="Graph goal", target="{{ inputs.target }}"]
start [shape=Mdiamond]
work [prompt="Ship {{ inputs.target }}", model="gpt-5.4"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let mut manifest_json = minimal_manifest_json(dot);
manifest_json["title"] = json!(" Pinned create ");
manifest_json["goal"] = json!({
"type": "value",
"text": "Inline release goal"
});
manifest_json["args"] = json!({
"model": "gpt-5.4",
"input": ["target=payments"]
});
manifest_json["configs"] = json!([{
"type": "project",
"path": "/tmp/project/.fabro/project.toml",
"source": r#"
_version = 1
[project]
name = "payments-project"
[run.metadata]
project-label = "project"
layer = "project"
"#
}]);
manifest_json["cwd"] = json!("/tmp/project");
manifest_json["git"] = json!({
"origin_url": "https://github.com/acme/payments.git",
"branch": "feature/compiler",
"sha": "0123456789abcdef",
"dirty": "clean",
"push_outcome": { "type": "not_attempted" }
});
let manifest: RunManifest = serde_json::from_value(manifest_json).unwrap();
let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap();
let mut headers = HeaderMap::new();
headers.insert(
header::USER_AGENT,
"fabro-cli/9.8.7".parse().expect("user agent should parse"),
);
let response = Box::pin(handler::runs::create_run_from_manifest(
Arc::clone(&state),
handler::runs::CreateRunFromManifestRequest {
manifest,
submitted_manifest_bytes: submitted_manifest_bytes.clone(),
explicit_run_id: Some(run_id),
explicit_title_supplied: true,
actor: Principal::System {
system_kind: SystemActorKind::Engine,
},
headers,
automation: None,
},
))
.await;
let body = response_json!(response, StatusCode::CREATED).await;
assert_eq!(body["id"], run_id.to_string());
assert_eq!(body["title"], "Pinned create");
assert_eq!(body["lifecycle"]["status"]["kind"], "submitted");
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let events = run_store.list_events().await.unwrap();
assert_eq!(
events
.iter()
.map(|envelope| envelope.event.event_name())
.collect::<Vec<_>>(),
vec!["run.created", "run.submitted"]
);
let run_state = run_store.state().await.unwrap();
let spec = &run_state.spec;
assert_eq!(spec.run_id, run_id);
assert_eq!(spec.graph.goal(), "Inline release goal");
assert_eq!(
spec.graph.attrs.get("target").and_then(AttrValue::as_str),
Some("{{ inputs.target }}")
);
assert_eq!(
spec.graph.nodes["work"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str),
Some("Ship payments")
);
assert_eq!(
spec.graph.nodes["work"]
.attrs
.get("model")
.and_then(AttrValue::as_str),
Some("gpt-5.4")
);
assert_eq!(
spec.graph.nodes["work"]
.attrs
.get("provider")
.and_then(AttrValue::as_str),
Some("openai")
);
assert_eq!(spec.settings.run.model.name.as_deref(), Some("gpt-5.4"));
assert_eq!(spec.settings.run.model.provider.as_deref(), Some("openai"));
assert_eq!(
spec.settings.run.inputs.get("target"),
Some(&toml::Value::String("payments".to_string()))
);
assert_eq!(
spec.settings.project.name.as_deref(),
Some("payments-project")
);
assert_eq!(
spec.labels.get("project-label").map(String::as_str),
Some("project")
);
assert_eq!(
spec.labels.get("layer").map(String::as_str),
Some("project")
);
assert_eq!(
spec.git.as_ref().map(|git| git.origin_url.as_str()),
Some("https://github.com/acme/payments.git")
);
let created = events[0].event.to_value().unwrap();
assert_eq!(created["properties"]["title"], "Pinned create");
assert_eq!(created["properties"]["labels"]["project-label"], "project");
assert_eq!(
created["properties"]["provenance"]["client"]["user_agent"],
"fabro-cli/9.8.7"
);
assert_eq!(
created["properties"]["provenance"]["subject"]["kind"],
"system"
);
let manifest_blob = created["properties"]["manifest_blob"]
.as_str()
.expect("run.created should carry the submitted source blob")
.parse::<RunBlobId>()
.unwrap();
let persisted_manifest = run_store
.read_blob(&manifest_blob)
.await
.unwrap()
.expect("submitted source blob should exist");
assert_eq!(persisted_manifest.as_ref(), submitted_manifest_bytes);
}
#[tokio::test]
async fn create_run_from_manifest_pins_compiler_http_error_mappings() {
let cases = [
(
{
let mut manifest = minimal_manifest_json(MINIMAL_DOT);
manifest["version"] = json!(2);
manifest
},
"unsupported manifest version 2",
),
(
minimal_manifest_json(
r#"digraph Test {
graph [goal="Test"]
start [shape=Mdiamond]
work [prompt="Use {{ vars.MISSING }}"]
exit [shape=Msquare]
start -> work -> exit
}"#,
),
"Validation failed",
),
(
{
let mut manifest = minimal_manifest_json(
r#"digraph Test {
graph [goal="Test"]
start [shape=Mdiamond]
work [prompt="Do work", model="gpt-5.4", provider="missing-provider"]
exit [shape=Msquare]
start -> work -> exit
}"#,
);
manifest["args"] = json!({
"model": "gpt-5.4",
"provider": "missing-provider"
});
manifest
},
"Model selection failed: unknown model provider 'missing-provider'",
),
];
for (manifest_json, expected_detail) in cases {
let state = TestAppStateBuilder::new()
.env_lookup(|_| None)
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let manifest: RunManifest = serde_json::from_value(manifest_json).unwrap();
let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap();
let response = Box::pin(handler::runs::create_run_from_manifest(
state,
handler::runs::CreateRunFromManifestRequest {
manifest,
submitted_manifest_bytes,
explicit_run_id: Some(RunId::new()),
explicit_title_supplied: true,
actor: Principal::System {
system_kind: SystemActorKind::Engine,
},
headers: HeaderMap::new(),
automation: None,
},
))
.await;
let body = response_json!(response, StatusCode::BAD_REQUEST).await;
assert_eq!(body["errors"][0]["detail"], expected_detail);
}
}
#[tokio::test]
async fn create_run_from_manifest_preserves_competing_preparation_error_precedence() {
let mut workflow_before_title = minimal_manifest_json(MINIMAL_DOT);
workflow_before_title["workflows"]["workflow.fabro"]["config"] = json!({
"path": "workflow.toml",
"source": "_version = 1\n[run.unknown]\nvalue = true\n",
});
workflow_before_title["title"] = json!(" ");
let mut project_parse_before_later_path = minimal_manifest_json(MINIMAL_DOT);
project_parse_before_later_path["configs"] = json!([
{
"type": "project",
"path": "/tmp/.fabro/project.toml",
"source": "_version = 1\n[run.unknown]\nvalue = true\n",
},
{
"type": "project",
"source": "_version = 1\n",
},
]);
for (manifest_json, expected_detail) in [
(workflow_before_title, "Failed to parse run config TOML"),
(
project_parse_before_later_path,
"Failed to parse run config TOML",
),
] {
let state = TestAppStateBuilder::new().build();
let manifest: RunManifest = serde_json::from_value(manifest_json).unwrap();
let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap();
let response = Box::pin(handler::runs::create_run_from_manifest(
state,
handler::runs::CreateRunFromManifestRequest {
manifest,
submitted_manifest_bytes,
explicit_run_id: None,
explicit_title_supplied: true,
actor: Principal::System {
system_kind: SystemActorKind::Engine,
},
headers: HeaderMap::new(),
automation: None,
},
))
.await;
let body = response_json!(response, StatusCode::BAD_REQUEST).await;
assert_eq!(body["errors"][0]["detail"], expected_detail);
}
}
#[tokio::test]
async fn create_run_from_manifest_resolves_generated_id_after_variable_snapshot() {
let state = TestAppStateBuilder::new()
.env_lookup(|_| None)
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let variable = state
.stores
.variables
.set("OWNER", "payments", None)
.await
.expect("test variable should persist");
let manifest: RunManifest = serde_json::from_value(minimal_manifest_json(
r#"digraph Test {
graph [goal="Test"]
start [shape=Mdiamond]
work [prompt="Ship {{ vars.OWNER }}"]
exit [shape=Msquare]
start -> work -> exit
}"#,
))
.unwrap();
let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap();
let response = Box::pin(handler::runs::create_run_from_manifest(
state,
handler::runs::CreateRunFromManifestRequest {
manifest,
submitted_manifest_bytes,
explicit_run_id: None,
explicit_title_supplied: false,
actor: Principal::System {
system_kind: SystemActorKind::Engine,
},
headers: HeaderMap::new(),
automation: None,
},
))
.await;
let body = response_json!(response, StatusCode::CREATED).await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
assert!(run_id.created_at() >= variable.updated_at);
}
#[tokio::test]
async fn fake_automation_materializer_injection_captures_input_and_returns_manifest() {
let materialized_manifest: RunManifest =
@ -6366,31 +6708,36 @@ async fn create_unreadable_durable_run(state: &Arc<AppState>, run_id: RunId) {
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunRunning)
.await
.unwrap();
let payload = fabro_store::EventPayload::new(
json!({
"id": "evt-unreadable-run-completed",
"ts": "2026-05-05T20:46:33Z",
"run_id": run_id,
"event": "run.completed",
"properties": {
"timing": {
"wall_time_ms": 1,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"artifact_count": 0,
"status": "legacy-status",
"reason": "completed",
},
}),
let seq = run_store.last_event_seq().await.unwrap().unwrap() + 1;
let completed = workflow_event::to_run_event_at(
&run_id,
&workflow_event::Event::WorkflowRunCompleted {
timing: fabro_types::RunTiming::wall_only(1),
artifact_count: 0,
status: "legacy-status".to_string(),
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
},
"2026-05-05T20:46:33Z".parse().unwrap(),
None,
);
let payload = workflow_event::build_redacted_event_payload(&completed, &run_id).unwrap();
fabro_store::test_support::put_unvalidated_run_event(
&state.stores.runs,
&run_id,
seq,
payload.as_value(),
)
.await
.unwrap();
let err = run_store
.append_event(&payload)
.state()
.await
.expect_err("invalid projection event should be persisted but rejected by projection");
.expect_err("poison event should make the run projection unreadable");
assert!(
err.to_string().contains("invalid completed stage status"),
"unexpected projection error: {err}"

View file

@ -21,6 +21,7 @@ anyhow.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
strum.workspace = true
tokio.workspace = true
uuid.workspace = true

View file

@ -303,6 +303,8 @@ mod tests {
("claude-sonnet-4-5", "claude-sonnet-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-sonnet-4-6", "claude-sonnet-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-sonnet-5", "claude-sonnet-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
("deepseek-v4-flash", "deepseek-v4-flash", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("deepseek-v4-pro", "deepseek-v4-pro", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("gemini-3-flash-preview", "gemini-3-flash-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("gemini-3.1-flash-lite", "gemini-3.1-flash-lite", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("gemini-3.1-pro-preview", "gemini-3.1-pro-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),

View file

@ -279,6 +279,21 @@ mod tests {
assert!(decode_content_block(&serde_json::json!({"text": ""})).is_none());
}
#[test]
fn tool_use_names_are_preserved_verbatim() {
let block = serde_json::json!({
"toolUse": {
"toolUseId": "tool-1",
"name": "search???",
"input": {}
}
});
let Some(ContentPart::ToolCall(tool_call)) = decode_content_block(&block) else {
panic!("expected tool call");
};
assert_eq!(tool_call.name, "search???");
}
#[test]
fn reasoning_text_block_round_trips_signature() {
let block = serde_json::json!({

View file

@ -4,6 +4,7 @@ use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde_json::{Map, Value, json};
use super::sanitize;
use crate::codec::{CodecCtx, EncodedRequest, extract_system_prompt, merge_named_provider_options};
use crate::error::Error;
use crate::types::{ContentPart, Message, Request, Role, ToolChoice};
@ -127,12 +128,11 @@ fn encode_message(message: &Message) -> Option<Value> {
if blocks.is_empty() && message.role == Role::Tool {
if let Some(tool_call_id) = &message.tool_call_id {
let text = message.text();
blocks.push(json!({
"toolResult": {
"toolUseId": tool_call_id,
"content": [{ "text": text }],
}
}));
blocks.push(tool_result_block(
tool_call_id,
json!([{ "text": text }]),
false,
));
}
}
@ -183,26 +183,18 @@ fn encode_content_part(part: &ContentPart) -> Option<Value> {
Value::Object(_) => tool_call.arguments.clone(),
_ => json!({}),
};
Some(json!({
"toolUse": {
"toolUseId": tool_call.id,
"name": tool_call.name,
"input": input,
}
}))
Some(tool_use_block(&tool_call.id, &tool_call.name, input))
}
ContentPart::ToolResult(result) => {
let content = match &result.content {
Value::String(text) => json!([{ "text": text }]),
other => json!([{ "json": other }]),
};
let mut block = Map::new();
block.insert("toolUseId".to_string(), json!(result.tool_call_id));
block.insert("content".to_string(), content);
if result.is_error {
block.insert("status".to_string(), json!("error"));
}
Some(json!({ "toolResult": Value::Object(block) }))
Some(tool_result_block(
&result.tool_call_id,
content,
result.is_error,
))
}
ContentPart::Thinking(thinking) => {
if thinking.redacted {
@ -226,6 +218,28 @@ fn encode_content_part(part: &ContentPart) -> Option<Value> {
}
}
/// Build a `toolUse` block. All tool blocks must be constructed through
/// [`tool_use_block`] and [`tool_result_block`] so identifier sanitization
/// keeps `toolUse` and `toolResult` paired on the wire.
fn tool_use_block(id: &str, name: &str, input: Value) -> Value {
let mut block = Map::new();
block.insert("toolUseId".to_string(), json!(sanitize::tool_use_id(id)));
block.insert("name".to_string(), json!(sanitize::tool_name(name)));
block.insert("input".to_string(), input);
json!({ "toolUse": Value::Object(block) })
}
/// Build a `toolResult` block; see [`tool_use_block`] for the pairing contract.
fn tool_result_block(id: &str, content: Value, is_error: bool) -> Value {
let mut block = Map::new();
block.insert("toolUseId".to_string(), json!(sanitize::tool_use_id(id)));
block.insert("content".to_string(), content);
if is_error {
block.insert("status".to_string(), json!("error"));
}
json!({ "toolResult": Value::Object(block) })
}
/// Convert common MIME types into Bedrock's media `format` enum values.
fn media_format<'a>(media_type: Option<&str>, default: &'a str) -> &'a str {
match media_type {
@ -522,6 +536,114 @@ mod tests {
assert_eq!(tool_use["input"], json!({}));
}
#[test]
fn historical_tool_names_are_sanitized_on_the_wire() {
let mut request = base_request("claude");
request.messages = vec![Message {
role: Role::Assistant,
content: vec![ContentPart::ToolCall(ToolCall::new(
"tool-1",
"search???",
json!({}),
))],
name: None,
tool_call_id: None,
}];
let encoded = encode_with(&request);
let tool_use = &encoded.body["messages"][0]["content"][0]["toolUse"];
assert_eq!(tool_use["name"], sanitize::tool_name("search???"));
}
#[test]
fn sanitized_tool_use_ids_remain_paired() {
for id in ["bad id!".to_string(), "x".repeat(100)] {
let mut request = base_request("claude");
request.messages = vec![
Message {
role: Role::Assistant,
content: vec![ContentPart::ToolCall(ToolCall::new(
&id,
"search",
json!({}),
))],
name: None,
tool_call_id: None,
},
Message {
role: Role::Tool,
content: vec![ContentPart::ToolResult(ToolResult::success(
&id,
json!("done"),
))],
name: None,
tool_call_id: Some(id.clone()),
},
];
let encoded = encode_with(&request);
let tool_use_id = &encoded.body["messages"][0]["content"][0]["toolUse"]["toolUseId"];
let tool_result_id =
&encoded.body["messages"][1]["content"][0]["toolResult"]["toolUseId"];
assert_eq!(tool_use_id, tool_result_id);
assert!(tool_use_id.as_str().is_some_and(|value| value.len() <= 64));
}
}
#[test]
fn tool_role_fallback_sanitizes_the_tool_use_id() {
let mut request = base_request("claude");
request.messages = vec![Message {
role: Role::Tool,
content: vec![],
name: None,
tool_call_id: Some("bad id!".to_string()),
}];
let encoded = encode_with(&request);
assert_eq!(
encoded.body["messages"][0]["content"][0]["toolResult"]["toolUseId"],
sanitize::tool_use_id("bad id!")
);
}
#[test]
fn overlength_tool_names_encode_within_the_bedrock_limit() {
let mut request = base_request("claude");
request.messages = vec![Message {
role: Role::Assistant,
content: vec![ContentPart::ToolCall(ToolCall::new(
"tool-1",
"x".repeat(100),
json!({}),
))],
name: None,
tool_call_id: None,
}];
let encoded = encode_with(&request);
let name = encoded.body["messages"][0]["content"][0]["toolUse"]["name"]
.as_str()
.unwrap();
assert_eq!(name.len(), 64);
}
#[test]
fn tool_definition_names_remain_unsanitized() {
let mut request = base_request("claude");
request.tools = Some(vec![ToolDefinition::function(
"weird.name",
"Deliberately invalid for Bedrock",
json!({"type": "object"}),
)]);
let encoded = encode_with(&request);
assert_eq!(
encoded.body["toolConfig"]["tools"][0]["toolSpec"]["name"],
"weird.name"
);
}
#[test]
fn thinking_parts_restructure_into_reasoning_text_blocks() {
let mut request = base_request("claude");

View file

@ -12,6 +12,7 @@
mod decode;
mod encode;
mod sanitize;
mod stream;
use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder};

View file

@ -0,0 +1,122 @@
//! Bedrock Converse tool identifier sanitization.
//!
//! Tool names must match `[a-zA-Z0-9_-]+`; tool-use IDs additionally allow
//! `.` and `:`. Both are limited to 64 characters. These helpers rewrite only
//! the Bedrock wire view: the canonical transcript retains provider output
//! verbatim. The encoder routes every tool block through its
//! `tool_use_block`/`tool_result_block` constructors so `toolUse` and
//! `toolResult` blocks remain paired.
use sha2::{Digest, Sha256};
const MAX_LENGTH: usize = 64;
const HASH_HEX_LENGTH: usize = 16;
const PREFIX_LENGTH: usize = MAX_LENGTH - 1 - HASH_HEX_LENGTH;
pub(super) fn tool_name(name: &str) -> String {
sanitize(name, "unknown_tool", is_tool_name_char)
}
pub(super) fn tool_use_id(id: &str) -> String {
sanitize(id, "unknown_tool_use_id", is_tool_use_id_char)
}
fn sanitize(value: &str, empty_fallback: &'static str, is_allowed: fn(char) -> bool) -> String {
if value.is_empty() {
return empty_fallback.to_string();
}
let sanitized: String = value
.chars()
.map(|character| {
if is_allowed(character) {
character
} else {
'_'
}
})
.collect();
if sanitized.len() <= MAX_LENGTH {
sanitized
} else {
truncate_with_hash(&sanitized, value)
}
}
fn is_tool_name_char(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
}
fn is_tool_use_id_char(character: char) -> bool {
is_tool_name_char(character) || matches!(character, '.' | ':')
}
fn truncate_with_hash(sanitized: &str, original: &str) -> String {
debug_assert!(sanitized.is_ascii());
let digest = Sha256::digest(original.as_bytes());
let digest_hex = format!("{digest:x}");
format!(
"{}-{}",
&sanitized[..PREFIX_LENGTH],
&digest_hex[..HASH_HEX_LENGTH]
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_values_pass_through_unchanged() {
for name in ["search", "TaskList", "a-b_c9"] {
assert_eq!(tool_name(name), name);
}
let max_length = "a".repeat(64);
assert_eq!(tool_name(&max_length), max_length);
let id = "functions.read_file:4";
assert_eq!(tool_use_id(id), id);
assert_eq!(tool_name(id), "functions_read_file_4");
}
#[test]
fn invalid_characters_are_replaced() {
assert_eq!(tool_name("search???"), "search___");
assert_eq!(tool_name("bad name"), "bad_name");
assert_eq!(tool_use_id("bad id!"), "bad_id_");
}
#[test]
fn non_ascii_characters_become_single_underscores() {
let sanitized = tool_name("before🙂after");
assert_eq!(sanitized, "before_after");
assert!(sanitized.is_ascii());
}
#[test]
fn empty_values_use_nonempty_fallbacks() {
assert_eq!(tool_name(""), "unknown_tool");
assert_eq!(tool_use_id(""), "unknown_tool_use_id");
}
#[test]
fn overlength_values_use_deterministic_hash_suffixes() {
let boundary = "a".repeat(65);
let first = tool_name(&boundary);
let second = tool_name(&boundary);
assert_eq!(first, second);
assert_eq!(first.len(), 64);
assert!(
first
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
);
let shared_prefix = "x".repeat(99);
let left = tool_name(&format!("{shared_prefix}a"));
let right = tool_name(&format!("{shared_prefix}b"));
assert_ne!(left, right);
}
}

View file

@ -406,6 +406,22 @@ mod tests {
assert!(!tool_call.arguments.is_null());
}
#[test]
fn streamed_tool_use_names_are_preserved_verbatim() {
let mut d = decoder();
feed(&mut d, "messageStart", r#"{"role":"assistant"}"#);
feed(
&mut d,
"contentBlockStart",
r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"search???"}},"contentBlockIndex":0}"#,
);
let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#);
let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else {
panic!("expected ToolCallEnd");
};
assert_eq!(tool_call.name, "search???");
}
#[test]
fn tool_use_accumulates_string_input_fragments() {
let mut d = decoder();

View file

@ -237,7 +237,9 @@ pub(super) fn translate_response_format(format: &ResponseFormat) -> serde_json::
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{AudioData, ContentPart, DocumentData, Message, Role, ToolCall};
use crate::types::{
AudioData, ContentPart, DocumentData, Message, Role, ThinkingData, ToolCall,
};
#[test]
fn translate_assistant_message_with_tool_calls_only() {
@ -291,6 +293,37 @@ mod tests {
assert_eq!(tool_calls[0].function.name, "get_weather");
}
#[test]
fn translate_assistant_tool_call_replays_reasoning_content() {
let msg = Message {
role: Role::Assistant,
content: vec![
ContentPart::Thinking(ThinkingData {
text: "I need the weather tool.".to_string(),
signature: None,
redacted: false,
}),
ContentPart::ToolCall(ToolCall::new(
"call_2",
"get_weather",
serde_json::json!({"city": "NYC"}),
)),
],
name: None,
tool_call_id: None,
};
let translated = translate_messages(&[msg]);
assert_eq!(
translated[0].reasoning_content.as_deref(),
Some("I need the weather tool.")
);
assert_eq!(translated[0].tool_calls.as_ref().unwrap().len(), 1);
let json = serde_json::to_value(&translated[0]).unwrap();
assert_eq!(json["reasoning_content"], "I need the weather tool.");
}
#[test]
fn translate_assistant_message_with_raw_arguments() {
let mut tc = ToolCall::new("call_3", "search", serde_json::json!({"q": "rust"}));

View file

@ -44,7 +44,7 @@ pub(super) struct ChatMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<ChatContent>,
/// Reasoning/thinking content echoed back for providers that require it
/// (Kimi).
/// during tool-call continuations (including Kimi and DeepSeek).
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -300,6 +300,10 @@ pub(super) struct ApiUsage {
pub cost: Option<f64>,
#[serde(default)]
pub prompt_tokens_details: Option<PromptTokensDetails>,
/// DeepSeek-specific top-level count of prompt tokens served from its
/// automatic context cache.
#[serde(default)]
pub prompt_cache_hit_tokens: Option<i64>,
#[serde(default)]
pub completion_tokens_details: Option<CompletionTokensDetails>,
}
@ -329,6 +333,7 @@ impl ApiUsage {
.prompt_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens)
.or(self.prompt_cache_hit_tokens)
.unwrap_or(0);
let cache_write_detail = self
.prompt_tokens_details
@ -538,6 +543,23 @@ mod tests {
});
}
#[test]
fn token_counts_accept_deepseek_cache_hit_field() {
let usage: ApiUsage = serde_json::from_value(serde_json::json!({
"prompt_tokens": 53,
"completion_tokens": 11,
"prompt_cache_hit_tokens": 41
}))
.unwrap();
assert_eq!(usage.token_counts(), TokenCounts {
input_tokens: 12,
output_tokens: 11,
cache_read_tokens: 41,
..TokenCounts::default()
});
}
#[test]
fn reasoning_accepts_provider_and_openrouter_spellings() {
let provider_response: ApiResponse = serde_json::from_value(serde_json::json!({

View file

@ -316,6 +316,9 @@ pub fn error_from_status_code(
};
}
413 => ProviderErrorKind::ContextLength,
429 if detail.error_code.as_deref() == Some("exceeded_current_quota_error") => {
ProviderErrorKind::QuotaExceeded
}
429 => ProviderErrorKind::RateLimit,
500..=599 => ProviderErrorKind::Server,
// For ambiguous status codes (400, 422, etc.), use message-based classification
@ -603,6 +606,22 @@ mod tests {
assert!(err.retryable());
}
#[test]
fn exceeded_current_quota_error_is_non_retryable_quota_failure() {
let err = error_from_status_code(
429,
"Your account is suspended due to insufficient balance".into(),
"kimi".into(),
Some("exceeded_current_quota_error".into()),
None,
None,
);
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded));
assert!(!err.retryable());
assert!(err.failover_eligible());
}
#[test]
fn error_message_classification_context_length() {
let err = error_from_status_code(

View file

@ -373,7 +373,7 @@ async fn openrouter_complete() {
std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set");
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1")
.with_name("openrouter");
let request = make_request("deepseek/deepseek-v4-flash");
let request = make_request("deepseek/deepseek-v4-flash-0731");
let response = adapter.complete(&request).await.unwrap();
assert!(
@ -599,6 +599,39 @@ async fn fireworks_complete() {
assert_eq!(response.provider, "fireworks");
}
#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))]
async fn deepseek_complete() {
let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set");
let adapter =
OpenAiCompatibleAdapter::new(api_key, "https://api.deepseek.com").with_name("deepseek");
let request = Request {
// Thinking mode is enabled by default and shares this budget with the
// visible answer.
max_tokens: Some(1024),
..make_request("deepseek-v4-flash")
};
let response = adapter.complete(&request).await.unwrap();
assert!(
!response.text().is_empty(),
"response text should not be empty"
);
assert!(response.usage.input_tokens > 0);
assert!(response.usage.output_tokens > 0 || response.usage.reasoning_tokens > 0);
assert_eq!(response.provider, "deepseek");
}
#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))]
async fn deepseek_v4_flash_deep_tool_round_trip() {
let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set");
let provider = ProviderId::new("deepseek");
let catalog = enabled_provider_catalog(&provider, None);
let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog)
.expect("DeepSeek credential should resolve from the catalog");
assert_deep_tool_round_trip(&catalog, &provider, "deepseek-v4-flash", credential).await;
}
#[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))]
async fn fireworks_kimi_k2_7_code_deep_tool_round_trip() {
let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set");

View file

@ -185,8 +185,8 @@ async fn encode_tool_round_trip() {
fabro_test::fabro_json_snapshot!(capture.body);
}
/// Assistant thinking parts echo back as `reasoning_content` (Kimi-motivated,
/// applies to every compat assistant message).
/// Assistant thinking parts echo back as `reasoning_content` (required by
/// Kimi and DeepSeek during tool-call continuations).
#[tokio::test]
async fn encode_thinking_round_trip_as_reasoning_content() {
let capture = encode_capture(&corpus_thinking_round_trip(MODEL)).await;

View file

@ -61,7 +61,6 @@ pub struct RunOverrideInput<'a> {
pub model: Option<&'a str>,
pub provider: Option<&'a str>,
pub environment: Option<&'a str>,
pub docker_image: Option<&'a str>,
pub preserve_sandbox: Option<bool>,
pub dry_run: Option<bool>,
pub auto_approve: Option<bool>,
@ -79,23 +78,19 @@ pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer {
fallbacks: MergeMap::default(),
controls: None,
});
let environment = (input.environment.is_some()
|| input.docker_image.is_some()
|| input.preserve_sandbox.is_some())
.then(|| RunEnvironmentLayer {
id: input.environment.map(ToOwned::to_owned),
image: input.docker_image.map(|image| EnvironmentImageLayer {
docker: Some(image.to_string()),
..EnvironmentImageLayer::default()
}),
lifecycle: input
.preserve_sandbox
.map(|preserve| EnvironmentLifecycleLayer {
preserve: Some(preserve),
..EnvironmentLifecycleLayer::default()
}),
..RunEnvironmentLayer::default()
});
let environment =
(input.environment.is_some() || input.preserve_sandbox.is_some()).then(|| {
RunEnvironmentLayer {
id: input.environment.map(ToOwned::to_owned),
lifecycle: input
.preserve_sandbox
.map(|preserve| EnvironmentLifecycleLayer {
preserve: Some(preserve),
..EnvironmentLifecycleLayer::default()
}),
..RunEnvironmentLayer::default()
}
});
let execution =
(input.dry_run.is_some() || input.auto_approve.is_some()).then(|| RunExecutionLayer {
mode: input.dry_run.map(|dry_run| {
@ -894,7 +889,6 @@ pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
&& args.preserve_sandbox.is_none()
&& args.provider.is_none()
&& args.environment.is_none()
&& args.docker_image.is_none()
&& args.input.is_empty()
&& args.verbose.is_none()
}
@ -966,7 +960,6 @@ mod tests {
model: Some("gpt-5.4-mini"),
provider: Some("openai"),
environment: Some("local"),
docker_image: None,
preserve_sandbox: Some(true),
dry_run: Some(true),
auto_approve: Some(false),
@ -1028,6 +1021,40 @@ mod tests {
);
}
#[test]
fn sparse_run_overrides_preserve_only_has_no_image() {
let overrides = build_sparse_run_overrides(RunOverrideInput {
preserve_sandbox: Some(true),
..RunOverrideInput::default()
})
.expect("preserve override");
let environment = overrides.environment.expect("environment override");
assert!(environment.image.is_none());
assert_eq!(
environment.lifecycle.expect("lifecycle override").preserve,
Some(true)
);
}
#[test]
fn sparse_run_overrides_environment_only_has_no_image() {
let overrides = build_sparse_run_overrides(RunOverrideInput {
environment: Some("local"),
..RunOverrideInput::default()
})
.expect("environment override");
let environment = overrides.environment.expect("environment override");
assert_eq!(environment.id.as_deref(), Some("local"));
assert!(environment.image.is_none());
}
#[test]
fn sparse_run_overrides_default_is_empty() {
assert!(build_sparse_run_overrides(RunOverrideInput::default()).is_none());
}
// Regression coverage for https://github.com/fabro-sh/fabro/issues/476.
#[test]
fn build_manifest_bundles_agent_output_schema_file() {

View file

@ -3305,7 +3305,7 @@ mod tests {
}
#[tokio::test]
async fn check_daytona_api_key_with_accepts_full_scopes() {
async fn check_daytona_api_key_with_accepts_full_scopes_and_new_scopes() {
let server = MockServer::start_async().await;
let auth = mock_auth_probe(&server, 200).await;
let current_key = mock_current_key(&server, vec![
@ -3313,6 +3313,9 @@ mod tests {
"delete:snapshots",
"write:sandboxes",
"delete:sandboxes",
"manage:secrets",
"read:limits",
"manage:sso",
])
.await;

View file

@ -11,6 +11,9 @@ doctest = false
[lints]
workspace = true
[features]
test-support = []
[dependencies]
fabro-types = { path = "../../foundation/fabro-types" }
fabro-util = { path = "../../foundation/fabro-util" }

View file

@ -14,6 +14,11 @@ pub enum Error {
Io(#[from] std::io::Error),
#[error("Invalid event payload: {0}")]
InvalidEvent(String),
#[error("event rejected by run projection: {source}")]
EventRejected {
#[source]
source: Box<Self>,
},
#[error("Run not found: {0}")]
RunNotFound(String),
#[error("Run already exists: {0}")]
@ -35,7 +40,7 @@ pub enum Error {
run_id: String,
field: &'static str,
},
#[error("invalid status transition: {0}")]
#[error(transparent)]
InvalidTransition(#[from] fabro_types::InvalidTransition),
#[error("{0}")]
Other(String),

View file

@ -10,8 +10,8 @@ mod run_state;
mod run_summary_store;
mod serializable_projection;
mod slate;
#[cfg(test)]
mod test_util;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
mod types;
pub use artifact_store::{

View file

@ -154,6 +154,11 @@ impl RunSummaryStore {
Ok(())
}
#[cfg(test)]
pub(crate) async fn close_pool(&self) {
self.pool.close().await;
}
pub(crate) async fn reconcile(&self, entries: &[CachedRunProjection]) -> Result<()> {
let mut transaction = self.pool.begin().await?;
let stored_seqs: HashMap<String, i64> =
@ -571,7 +576,7 @@ mod tests {
RunSummaryVisibility,
};
use crate::slate::CachedRunProjection;
use crate::test_util;
use crate::test_support as store_test_support;
fn dt(value: &str) -> DateTime<Utc> {
value.parse().unwrap()
@ -608,7 +613,7 @@ mod tests {
}
async fn store() -> (tempfile::TempDir, RunSummaryStore) {
test_util::sqlite_summary_store().await
store_test_support::sqlite_summary_store().await
}
fn sample_status(kind: RunStatusKind) -> RunStatus {

View file

@ -322,6 +322,22 @@ impl Database {
Ok(unreadable)
}
#[cfg(any(test, feature = "test-support"))]
pub(crate) async fn put_unvalidated_run_event(
&self,
run_id: &RunId,
seq: u32,
payload: &serde_json::Value,
) -> Result<()> {
let db = self.open_db().await?;
db.put(
keys::run_event_key(run_id, seq, 0),
serde_json::to_vec(payload)?,
)
.await?;
Ok(())
}
pub async fn get_cached_run(&self, run_id: &RunId) -> Result<Option<CachedRunProjection>> {
self.warm_projection_cache().await?;
Ok(self.projection_cache.get(run_id).await)
@ -523,7 +539,7 @@ mod tests {
use object_store::path::Path;
use super::*;
use crate::{EventPayload, keys, test_util};
use crate::{EventPayload, keys, test_support as store_test_support};
fn dt(value: &str) -> DateTime<Utc> {
value.parse().unwrap()
@ -572,7 +588,7 @@ mod tests {
}
async fn make_summary_store() -> (tempfile::TempDir, Arc<RunSummaryStore>) {
let (directory, store) = test_util::sqlite_summary_store().await;
let (directory, store) = store_test_support::sqlite_summary_store().await;
(directory, Arc::new(store))
}
@ -684,6 +700,57 @@ mod tests {
.unwrap();
}
async fn append_runnable(run: &RunDatabase, label: &str, created_at: DateTime<Utc>) {
append_created(run, label, created_at).await;
run.append_event(&event_payload(
label,
"2026-03-27T12:00:01Z",
"run.submitted",
&serde_json::json!({}),
))
.await
.unwrap();
run.append_event(&event_payload(
label,
"2026-03-27T12:00:02Z",
"run.start_requested",
&serde_json::json!({ "resume": false }),
))
.await
.unwrap();
run.append_event(&event_payload(
label,
"2026-03-27T12:00:03Z",
"run.runnable",
&serde_json::json!({ "source": "start_requested" }),
))
.await
.unwrap();
}
fn workflow_failure_payload(label: &str) -> EventPayload {
event_payload(
label,
"2026-03-27T12:00:04Z",
"run.failed",
&serde_json::json!({
"failure": {
"reason": "workflow_error",
"detail": {
"message": "workflow failed",
"category": "deterministic"
}
},
"timing": {
"wall_time_ms": 1,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
}),
)
}
async fn append_completed(run: &RunDatabase, label: &str, created_at: DateTime<Utc>) {
append_running(run, label, created_at).await;
run.append_event(&event_payload(
@ -849,6 +916,160 @@ mod tests {
assert_eq!(run.list_events().await.unwrap().len(), 2);
}
#[tokio::test]
async fn rejected_transition_writes_nothing_and_preserves_projection_cache() {
let (_object_store, store) = make_store();
let run_id = test_run_id("run-1");
let run = store.create_run(&run_id).await.unwrap();
append_runnable(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
let events_before = run.list_events().await.unwrap();
let err = run
.append_event(&workflow_failure_payload("run-1"))
.await
.unwrap_err();
let Error::EventRejected { source } = err else {
panic!("expected event rejection");
};
assert!(matches!(
*source,
Error::InvalidTransition(fabro_types::InvalidTransition {
from: RunStatus::Runnable,
to: RunStatus::Failed {
reason: FailureReason::WorkflowError,
},
})
));
assert_eq!(run.list_events().await.unwrap(), events_before);
assert_eq!(run.state().await.unwrap().status, RunStatus::Runnable);
let cached = store.get_cached_run(&run_id).await.unwrap().unwrap();
assert_eq!(cached.last_seq, 4);
assert_eq!(cached.projection.status, RunStatus::Runnable);
}
#[tokio::test]
async fn rejected_transition_leaves_reconciled_summary_present() {
let (_object_store, store) = make_store();
let (_directory, summaries) = make_summary_store().await;
store.attach_run_summary_store(Arc::clone(&summaries));
let run_id = test_run_id("run-1");
let run = store.create_run(&run_id).await.unwrap();
append_runnable(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
let err = run
.append_event(&workflow_failure_payload("run-1"))
.await
.unwrap_err();
assert!(matches!(err, Error::EventRejected { .. }));
let entries = store
.list_cached_runs(&ListRunsQuery::default(), Utc::now())
.await
.unwrap();
summaries.reconcile(&entries).await.unwrap();
let summary = summaries.get(&run_id, Utc::now()).await.unwrap().unwrap();
assert_eq!(summary.lifecycle.status, RunStatus::Runnable);
}
#[tokio::test]
async fn committed_append_succeeds_when_summary_update_fails_and_is_repairable() {
let (object_store, store) = make_store();
let (directory, summaries) = make_summary_store().await;
store.attach_run_summary_store(Arc::clone(&summaries));
let run_id = test_run_id("run-1");
let run = store.create_run(&run_id).await.unwrap();
append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
summaries.close_pool().await;
let result = run
.append_event_envelope(&event_payload(
"run-1",
"2026-03-27T12:00:01Z",
"run.title.updated",
&serde_json::json!({ "title": "Committed title" }),
))
.await;
assert!(result.is_ok(), "committed append returned {result:?}");
assert_eq!(run.list_events().await.unwrap().len(), 2);
let cached = store.get_cached_run(&run_id).await.unwrap().unwrap();
assert_eq!(cached.last_seq, 2);
assert_eq!(cached.summary.title, "Committed title");
let stored = run.get_event(2).await.unwrap().unwrap();
assert_eq!(stored.event, result.unwrap().event);
let repaired_summaries =
Arc::new(store_test_support::sqlite_summary_store_at(directory.path()).await);
let stale = repaired_summaries
.get(&run_id, Utc::now())
.await
.unwrap()
.unwrap();
assert_ne!(stale.title, "Committed title");
let reopened = Database::new(object_store, "runs/", Duration::from_millis(1), None);
reopened.attach_run_summary_store(Arc::clone(&repaired_summaries));
reopened.warm_projection_cache().await.unwrap();
let repaired = repaired_summaries
.get(&run_id, Utc::now())
.await
.unwrap()
.unwrap();
assert_eq!(repaired.title, "Committed title");
}
#[tokio::test]
async fn first_event_is_validated_before_write() {
let (_object_store, store) = make_store();
let run_id = test_run_id("run-1");
let run = store.create_run(&run_id).await.unwrap();
let invalid_first = event_payload(
"run-1",
"2026-03-27T12:00:00Z",
"run.title.updated",
&serde_json::json!({ "title": "Too early" }),
);
let err = run.append_event(&invalid_first).await.unwrap_err();
assert!(matches!(err, Error::EventRejected { .. }));
assert!(run.list_events().await.unwrap().is_empty());
append_created(&run, "run-1", dt("2026-03-27T12:00:01Z")).await;
assert_eq!(run.list_events().await.unwrap().len(), 1);
assert!(run.state().await.is_ok());
}
#[tokio::test]
async fn malformed_optional_envelope_field_is_rejected_before_write() {
let (_object_store, store) = make_store();
let run_id = test_run_id("run-1");
let run = store.create_run(&run_id).await.unwrap();
let malformed = EventPayload::new(
serde_json::json!({
"id": "evt-created",
"ts": "2026-03-27T12:00:00Z",
"run_id": run_id.to_string(),
"event": "run.created",
"node_id": 42,
"properties": {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"run_dir": "/tmp/test",
"provenance": test_support::test_run_provenance(),
},
}),
&run_id,
)
.unwrap();
let err = run.append_event(&malformed).await.unwrap_err();
assert!(matches!(err, Error::InvalidEvent(_)));
assert!(run.list_events().await.unwrap().is_empty());
}
#[tokio::test]
async fn control_request_events_set_pending_control_without_overwriting_status() {
let (_object_store, store) = make_store();
@ -1321,13 +1542,14 @@ mod tests {
.add(&bad_run_id)
.await
.unwrap();
let db = store.open_db().await.unwrap();
db.put(
keys::run_event_key(&bad_run_id, 1, 0),
br#"{"not":"a valid run event"}"#,
)
.await
.unwrap();
store
.put_unvalidated_run_event(
&bad_run_id,
1,
&serde_json::json!({ "not": "a valid run event" }),
)
.await
.unwrap();
let reopened = Database::new(object_store, "runs", Duration::from_millis(1), None);
reopened.warm_projection_cache().await.unwrap();
@ -1369,28 +1591,28 @@ mod tests {
.and_then(serde_json::Value::as_object_mut)
.unwrap();
run_settings.remove("integrations");
let db = store.open_db().await.unwrap();
db.put(
keys::run_event_key(&bad_run_id, 1, 0),
serde_json::to_vec(&serde_json::json!({
"id": "evt-run-2-run.created",
"ts": "2026-03-27T12:00:10Z",
"run_id": bad_run_id,
"event": "run.created",
"properties": {
"settings": run_spec["settings"],
"graph": run_spec["graph"],
"workflow_slug": run_spec["workflow_slug"],
"source_directory": run_spec["source_directory"],
"run_dir": "/tmp/run-2",
"git": run_spec["git"],
"labels": run_spec["labels"],
},
}))
.unwrap(),
)
.await
.unwrap();
store
.put_unvalidated_run_event(
&bad_run_id,
1,
&serde_json::json!({
"id": "evt-run-2-run.created",
"ts": "2026-03-27T12:00:10Z",
"run_id": bad_run_id,
"event": "run.created",
"properties": {
"settings": run_spec["settings"],
"graph": run_spec["graph"],
"workflow_slug": run_spec["workflow_slug"],
"source_directory": run_spec["source_directory"],
"run_dir": "/tmp/run-2",
"git": run_spec["git"],
"labels": run_spec["labels"],
},
}),
)
.await
.unwrap();
let reopened = Database::new(object_store, "runs", Duration::from_millis(1), None);
let unreadable = reopened.list_unreadable_runs().await.unwrap();
@ -1675,23 +1897,9 @@ mod tests {
))
.await
.unwrap();
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:04Z",
"run.failed",
&serde_json::json!({
"failure": {
"reason": "workflow_error",
"detail": {
"message": "workflow failed",
"category": "deterministic"
}
},
"timing": {"wall_time_ms": 1, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
}),
))
.await
.unwrap();
run.append_event(&workflow_failure_payload("run-1"))
.await
.unwrap();
let reopened = Database::new(
Arc::clone(&object_store),

View file

@ -5,8 +5,8 @@ use chrono::{DateTime, Utc};
use fabro_types::{Run, RunId, RunProjection};
use tokio::sync::Mutex;
use crate::run_state::{RunProjectionReducer, build_summary};
use crate::{Error, EventEnvelope, ListRunsQuery, Result};
use crate::ListRunsQuery;
use crate::run_state::build_summary;
#[derive(Debug, Clone)]
pub struct CachedRunProjection {
@ -85,26 +85,6 @@ impl RunProjectionCacheState {
}
}
fn update_parent_index(
&mut self,
run_id: RunId,
previous_parent_id: Option<RunId>,
parent_id: Option<RunId>,
) {
if previous_parent_id == parent_id {
return;
}
if let Some(previous_parent_id) = previous_parent_id {
self.remove_parent_link(&previous_parent_id, &run_id);
}
if let Some(parent_id) = parent_id {
self.children_by_parent
.entry(parent_id)
.or_default()
.insert(run_id);
}
}
fn count_children(&self, run_id: &RunId) -> u64 {
self.children_by_parent
.get(run_id)
@ -222,51 +202,6 @@ impl RunProjectionCache {
Some(entry.summary)
}
pub(crate) async fn apply_event(
&self,
run_id: &RunId,
event: &EventEnvelope,
) -> Result<CachedRunProjection> {
let mut state = self.state.lock().await;
let Some(entry) = state.entries.get(run_id) else {
if event.seq == 1 {
let projection = RunProjection::apply_events(std::slice::from_ref(event))?;
let entry = CachedRunProjection::from_projection(*run_id, projection, event.seq);
state.insert(entry.clone());
return Ok(entry);
}
return Err(Error::InvalidEvent(format!(
"projection cache cannot initialize run {run_id} from event seq {}",
event.seq
)));
};
let last_seq = entry.last_seq;
if event.seq <= last_seq {
return Ok(entry.clone());
}
if event.seq != last_seq.saturating_add(1) {
return Err(Error::Other(format!(
"projection cache sequence gap for run {run_id}: last_seq={}, event_seq={}",
last_seq, event.seq
)));
}
let (previous_parent_id, parent_id, entry) = {
let entry = state
.entries
.get_mut(run_id)
.expect("entry was read from the same locked map");
let previous_parent_id = entry.summary.parent_id;
Arc::make_mut(&mut entry.projection).apply_event(event)?;
entry.summary = build_summary(&entry.projection, run_id);
entry.last_seq = event.seq;
(previous_parent_id, entry.summary.parent_id, entry.clone())
};
state.update_parent_index(*run_id, previous_parent_id, parent_id);
Ok(entry)
}
pub(crate) async fn remove(&self, run_id: &RunId) {
self.state.lock().await.remove(run_id);
}

View file

@ -9,7 +9,7 @@ use futures::Stream;
use slatedb::{Db, DbIterator, DbRead};
use tokio::sync::{Mutex, broadcast, mpsc};
use tokio_stream::wrappers::UnboundedReceiverStream;
use tracing::{error, warn};
use tracing::warn;
use super::blob_store::BlobStore;
use super::projection_cache::{CachedRunProjection, RunProjectionCache};
@ -192,6 +192,15 @@ impl RunDatabase {
}
async fn projected_state_locked(&self) -> Result<Arc<RunProjection>> {
self.projected_state_option_locked().await?.ok_or_else(|| {
Error::InvalidEvent(format!(
"run {} has no run.created event",
self.inner.run_id
))
})
}
async fn projected_state_option_locked(&self) -> Result<Option<Arc<RunProjection>>> {
let next_seq = {
let cache = self.inner.projection_cache.lock().await;
cache.last_seq.saturating_add(1)
@ -202,55 +211,61 @@ impl RunDatabase {
apply_cached_projection_event(&mut cache.state, event)?;
cache.last_seq = event.seq;
}
cache.state.clone().ok_or_else(|| {
Error::InvalidEvent(format!(
"run {} has no run.created event",
self.inner.run_id
))
})
Ok(cache.state.clone())
}
async fn cache_event(&self, event: &EventEnvelope) -> Result<()> {
/// Current projection for validating an append allocated at `seq`. In the
/// steady state the local cache already sits at `seq - 1` because
/// `state_lock` serializes appends, so this skips the storage scan that
/// `projected_state_option_locked` issues.
async fn projected_state_for_append_locked(
&self,
seq: u32,
) -> Result<Option<Arc<RunProjection>>> {
{
let mut projection_cache = self.inner.projection_cache.lock().await;
if projection_cache.state.is_none() && event.seq > 1 {
drop(projection_cache);
self.rebuild_local_projection_cache_through(event.seq)
.await?;
} else {
apply_cached_projection_event(&mut projection_cache.state, event)?;
projection_cache.last_seq = event.seq;
let cache = self.inner.projection_cache.lock().await;
if cache.last_seq.saturating_add(1) == seq {
return Ok(cache.state.clone());
}
}
self.projected_state_option_locked().await
}
async fn install_in_memory_state_after_append(
&self,
event: &EventEnvelope,
cached: &CachedRunProjection,
) {
{
let mut projection_cache = self.inner.projection_cache.lock().await;
projection_cache.state = Some(Arc::clone(&cached.projection));
projection_cache.last_seq = event.seq;
}
self.inner
.shared_projection_cache
.replace(cached.clone())
.await;
let mut recent_events = self.inner.recent_events.lock().await;
recent_events.push_back(event.clone());
while recent_events.len() > self.inner.recent_event_limit {
recent_events.pop_front();
}
drop(recent_events);
let _ = self.inner.event_tx.send(event.clone());
Ok(())
}
async fn rebuild_local_projection_cache_through(&self, seq: u32) -> Result<()> {
let events = list_events_from(&self.inner.db, &self.inner.run_id, 1).await?;
let Some(last_seq) = events.last().map(|event| event.seq) else {
return Err(Error::InvalidEvent(format!(
"run {} has no events while rebuilding projection cache",
self.inner.run_id
)));
};
if last_seq < seq {
return Err(Error::InvalidEvent(format!(
"run {} projection cache rebuild stopped at seq {last_seq}, before appended seq {seq}",
self.inner.run_id
)));
async fn update_summary_after_committed_append(&self, cached: &CachedRunProjection) {
if let Some(store) = self.inner.run_summary_store.get() {
if let Err(err) = store.upsert_projection(cached).await {
warn!(
run_id = %self.inner.run_id,
source_last_seq = cached.last_seq,
error = ?err,
"failed to update SQLite run summary after committed append"
);
}
}
let state = RunProjection::apply_events(&events)?;
let mut projection_cache = self.inner.projection_cache.lock().await;
projection_cache.state = Some(Arc::new(state));
projection_cache.last_seq = last_seq;
Ok(())
}
async fn cached_events_from(&self, start_seq: u32, limit: usize) -> Option<Vec<EventEnvelope>> {
@ -270,12 +285,26 @@ impl RunDatabase {
}
impl RunDatabase {
/// Appends an event after validating it against the current run projection.
///
/// A rejected event writes nothing. Every returned error means the event
/// was not committed and is safe to retry. Once the SlateDB write succeeds,
/// the append returns success even if a derived cache or SQLite summary
/// update fails; those failures are logged and repaired by later updates or
/// startup reconciliation.
pub async fn append_event(&self, payload: &EventPayload) -> Result<u32> {
Ok(self.append_event_envelope(payload).await?.seq)
}
/// Atomically appends `payload` when `predicate` matches the latest run
/// projection.
///
/// `Ok(None)` means the predicate rejected the append and nothing was
/// written. An invalid transition is also rejected before write, and every
/// returned error means the event was not committed and is safe to retry.
/// After the SlateDB write succeeds, derived cache and SQLite summary
/// updates are best-effort and cannot turn the committed append into an
/// error.
pub async fn append_event_if(
&self,
payload: &EventPayload,
@ -285,95 +314,76 @@ impl RunDatabase {
return Err(Error::ReadOnly);
}
payload.validate(&self.inner.run_id)?;
let _state_guard = self.inner.state_lock.lock().await;
let projection = self.projected_state_locked().await?;
if !predicate(&projection) {
return Ok(None);
}
Ok(Some(self.append_event_envelope_locked(payload).await?.seq))
let (envelope, cached) = {
let _state_guard = self.inner.state_lock.lock().await;
let projection = self.projected_state_locked().await?;
if !predicate(&projection) {
return Ok(None);
}
let event = RunEvent::try_from(payload)?;
let event_bytes = serde_json::to_vec(payload)?;
self.append_event_envelope_locked(event, event_bytes)
.await?
};
self.update_summary_after_committed_append(&cached).await;
Ok(Some(envelope.seq))
}
/// Appends and returns the stored event envelope after pre-write reduction.
///
/// A rejected event writes nothing. Every returned error means the event
/// was not committed and is safe to retry. Once the SlateDB write succeeds,
/// derived cache and SQLite summary updates are best-effort: failures are
/// logged, and this method still returns the committed envelope.
pub async fn append_event_envelope(&self, payload: &EventPayload) -> Result<EventEnvelope> {
if self.read_only {
return Err(Error::ReadOnly);
}
payload.validate(&self.inner.run_id)?;
let _state_guard = self.inner.state_lock.lock().await;
self.append_event_envelope_locked(payload).await
let event = RunEvent::try_from(payload)?;
let event_bytes = serde_json::to_vec(payload)?;
let (envelope, cached) = {
let _state_guard = self.inner.state_lock.lock().await;
self.append_event_envelope_locked(event, event_bytes)
.await?
};
self.update_summary_after_committed_append(&cached).await;
Ok(envelope)
}
async fn append_event_envelope_locked(&self, payload: &EventPayload) -> Result<EventEnvelope> {
async fn append_event_envelope_locked(
&self,
event: RunEvent,
event_bytes: Vec<u8>,
) -> Result<(EventEnvelope, CachedRunProjection)> {
let event_seq = self.inner.event_seq.as_ref().ok_or(Error::ReadOnly)?;
let seq = allocate_event_seq(event_seq)?;
let event = EventEnvelope {
let seq = next_event_seq(event_seq)?;
let envelope = EventEnvelope { seq, event };
// Validation reduces through the exact code replay uses, so an event
// is written iff replay can reduce it. `Arc::make_mut` copy-on-writes,
// leaving the local projection cache untouched on rejection.
let mut next_state = self.projected_state_for_append_locked(seq).await?;
apply_cached_projection_event(&mut next_state, &envelope).map_err(event_rejected)?;
let next_projection =
next_state.expect("apply_cached_projection_event sets the state on success");
let cached = CachedRunProjection::from_projection(
self.inner.run_id,
Arc::unwrap_or_clone(next_projection),
seq,
event: RunEvent::try_from(payload)?,
};
);
reserve_event_seq(event_seq, seq)?;
self.inner
.db
.put(
keys::run_event_key(&self.inner.run_id, seq, Utc::now().timestamp_millis()),
serde_json::to_vec(payload)?,
event_bytes,
)
.await?;
self.cache_event(&event).await?;
// Box::pin keeps append_event_envelope's future small enough for the
// clippy::large_futures budget of its many callers.
Box::pin(self.update_summary_projection_after_append(&event)).await?;
Ok(event)
}
async fn update_summary_projection_after_append(&self, event: &EventEnvelope) -> Result<()> {
let cached = match self
.inner
.shared_projection_cache
.apply_event(&self.inner.run_id, event)
.await
{
Ok(entry) => entry,
Err(err) => {
match Self::build_cached_projection(&self.inner.db, &self.inner.run_id).await {
Ok(Some(entry)) => {
self.inner
.shared_projection_cache
.replace(entry.clone())
.await;
entry
}
rebuild => {
self.inner
.shared_projection_cache
.remove(&self.inner.run_id)
.await;
if let Err(rebuild_err) = rebuild {
warn!(
run_id = %self.inner.run_id,
error = %rebuild_err,
"Failed to rebuild run projection cache after append"
);
}
warn!(
run_id = %self.inner.run_id,
error = %err,
"Failed to update run projection cache after append"
);
return Err(err);
}
}
}
};
if let Some(store) = self.inner.run_summary_store.get() {
if let Err(err) = store.upsert_projection(&cached).await {
error!(
run_id = %self.inner.run_id,
source_last_seq = cached.last_seq,
error = %err,
"Failed to update SQLite run summary after append"
);
return Err(err);
}
}
Ok(())
// Box::pin keeps this future small enough for the
// clippy::large_futures budget of append_event_envelope's many
// callers.
Box::pin(self.install_in_memory_state_after_append(&envelope, &cached)).await;
Ok((envelope, cached))
}
pub async fn list_events(&self) -> Result<Vec<EventEnvelope>> {
@ -589,14 +599,27 @@ impl RunDatabase {
}
}
fn allocate_event_seq(event_seq: &AtomicU32) -> Result<u32> {
event_seq
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |seq| {
(seq <= keys::MAX_EVENT_SEQ).then_some(seq + 1)
})
.map_err(|_| Error::EventSequenceExhausted {
fn event_rejected(error: Error) -> Error {
Error::EventRejected {
source: Box::new(error),
}
}
fn next_event_seq(event_seq: &AtomicU32) -> Result<u32> {
let seq = event_seq.load(Ordering::SeqCst);
if seq > keys::MAX_EVENT_SEQ {
return Err(Error::EventSequenceExhausted {
max_seq: keys::MAX_EVENT_SEQ,
})
});
}
Ok(seq)
}
fn reserve_event_seq(event_seq: &AtomicU32, seq: u32) -> Result<()> {
event_seq
.compare_exchange(seq, seq + 1, Ordering::SeqCst, Ordering::SeqCst)
.map(|_| ())
.map_err(|_| Error::Other("event sequence changed while append lock was held".to_string()))
}
fn apply_cached_projection_event(
@ -1288,6 +1311,29 @@ mod tests {
assert_eq!(seqs, vec![5, 4, 3]);
}
#[tokio::test]
async fn rejected_event_does_not_consume_last_available_sequence() {
let run = fresh_run().await;
let run_id = run.run_id();
run.inner
.event_seq
.as_ref()
.unwrap()
.store(keys::MAX_EVENT_SEQ, Ordering::SeqCst);
let err = run
.append_event(&run_created_payload(&run_id))
.await
.unwrap_err();
assert!(matches!(err, Error::EventRejected { .. }));
let seq = run
.append_event(&stage_prompt_payload(&run_id, 1, Some("alpha")))
.await
.unwrap();
assert_eq!(seq, keys::MAX_EVENT_SEQ);
}
#[tokio::test]
async fn append_event_rejects_sequences_beyond_key_order_limit() {
let run = fresh_run().await;
@ -1304,6 +1350,7 @@ mod tests {
.unwrap();
assert_eq!(seq, keys::MAX_EVENT_SEQ);
let events_before_error = run.list_events().await.unwrap();
let err = run
.append_event(&stage_prompt_payload(&run_id, 2, Some("beta")))
.await
@ -1313,6 +1360,7 @@ mod tests {
Error::EventSequenceExhausted { max_seq }
if max_seq == keys::MAX_EVENT_SEQ
));
assert_eq!(run.list_events().await.unwrap(), events_before_error);
assert!(
run.get_event(keys::MAX_EVENT_SEQ + 1)
.await

View file

@ -0,0 +1,37 @@
#[cfg(test)]
use std::path::Path;
use fabro_types::RunId;
#[cfg(test)]
use crate::RunSummaryStore;
use crate::{Database, Result};
/// Writes an event without append validation to model a log corrupted by an
/// older Fabro version.
pub async fn put_unvalidated_run_event(
database: &Database,
run_id: &RunId,
seq: u32,
payload: &serde_json::Value,
) -> Result<()> {
database
.put_unvalidated_run_event(run_id, seq, payload)
.await
}
#[cfg(test)]
pub(crate) async fn sqlite_summary_store() -> (tempfile::TempDir, RunSummaryStore) {
let directory = tempfile::tempdir().unwrap();
let store = sqlite_summary_store_at(directory.path()).await;
(directory, store)
}
#[cfg(test)]
pub(crate) async fn sqlite_summary_store_at(directory: &Path) -> RunSummaryStore {
let database = fabro_db::Database::connect(directory.join("fabro.sqlite3"))
.await
.unwrap();
database.migrate().await.unwrap();
RunSummaryStore::new(database.clone_pool())
}

View file

@ -1,10 +0,0 @@
use crate::RunSummaryStore;
pub(crate) async fn sqlite_summary_store() -> (tempfile::TempDir, RunSummaryStore) {
let directory = tempfile::tempdir().unwrap();
let database = fabro_db::Database::connect(directory.path().join("fabro.sqlite3"))
.await
.unwrap();
database.migrate().await.unwrap();
(directory, RunSummaryStore::new(database.clone_pool()))
}

View file

@ -57,7 +57,7 @@ impl TryFrom<&EventPayload> for RunEvent {
type Error = Error;
fn try_from(value: &EventPayload) -> Result<Self> {
Self::from_ref(value.as_value())
Self::from_value(value.as_value().clone())
.map_err(|err| Error::InvalidEvent(format!("invalid stored event: {err}")))
}
}

View file

@ -163,14 +163,45 @@ impl RunEventLogger {
let (tx, mut rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
// A dropped run event is unrecoverable history loss, so the first
// one is an ERROR worth investigating. A broken sink fails for
// every event that follows, so report the rest as a count at flush
// instead of one ERROR per event. Flush runs per stage and per
// agent turn, so only losses since the last summary are reported.
let mut write_failures: u64 = 0;
let mut summarized_failures: u64 = 0;
while let Some(command) = rx.recv().await {
match command {
RunEventCommand::Event(event) => {
if let Err(err) = sink.write_run_event(&event).await {
tracing::warn!(error = %err, "Failed to write run event");
write_failures += 1;
if write_failures == 1 {
tracing::error!(
run_id = %event.run_id,
event = %event.body.event_name(),
error = %err,
"Failed to write run event",
);
} else {
tracing::debug!(
run_id = %event.run_id,
event = %event.body.event_name(),
failures = write_failures,
error = %err,
"Failed to write run event",
);
}
}
}
RunEventCommand::Flush(tx) => {
if write_failures > summarized_failures {
tracing::error!(
lost = write_failures - summarized_failures,
total = write_failures,
"Run events were lost to write failures",
);
summarized_failures = write_failures;
}
let _ = tx.send(());
}
}

View file

@ -289,7 +289,7 @@ fn schema_validation_failure_reason(
let mut reason = format!("Script output failed output_schema validation: {script}");
for message in error.messages() {
reason.push_str("\n- ");
reason.push_str(message);
reason.push_str(&message);
}
append_output_tail(&mut reason, output_text);
reason

View file

@ -1480,6 +1480,7 @@ impl CodergenBackend for AgentApiBackend {
.as_ref()
.map(structured_output::prompt_response_format);
let mut repair_attempts = 0_i64;
let mut previous_validation_error = None;
let mut total_usage = TokenCounts::default();
let mut total_cost = None;
let mut inference_duration = Duration::ZERO;
@ -1527,8 +1528,11 @@ impl CodergenBackend for AgentApiBackend {
structured_output::exhausted_failure_reason(node.output_retries()),
));
}
let repair_message =
error.repair_message(schema, previous_validation_error.as_ref());
previous_validation_error = Some(error);
messages.push(Message::assistant(response_text));
messages.push(Message::user(error.repair_message(schema)));
messages.push(Message::user(repair_message));
repair_attempts += 1;
continue;
}
@ -1716,6 +1720,7 @@ impl CodergenBackend for AgentApiBackend {
let mut response = last_assistant_response(&live.session);
if let Some(schema) = &output_schema {
let mut repair_attempts = 0_i64;
let mut previous_validation_error = None;
loop {
let last_file_touched = last_touched_file(&live.file_tracking);
match validate_agent_output_sources(
@ -1734,7 +1739,8 @@ impl CodergenBackend for AgentApiBackend {
structured_output::exhausted_failure_reason(node.output_retries()),
));
}
let repair_message = error.repair_message(schema);
let repair_message =
error.repair_message(schema, previous_validation_error.as_ref());
let repair_result = live
.session
.process_input_with_runtime(
@ -1745,6 +1751,11 @@ impl CodergenBackend for AgentApiBackend {
live.record_input_timing();
match repair_result {
Ok(()) => {
// Only once the model has actually seen the
// repair can a later identical failure mean it
// ignored the correction. Failover rebuilds the
// session from the original prompt instead.
previous_validation_error = Some(error);
live.record_input_usage().await;
repair_attempts += 1;
response = last_assistant_response(&live.session);
@ -2241,6 +2252,13 @@ reasoning = false
)
}
fn nested_output_schema_attr() -> AttrValue {
AttrValue::String(
r#"{"type":"object","required":["findings"],"properties":{"findings":{"type":"array","items":{"type":"object","required":["rationale"],"properties":{"rationale":{"type":"string"}}}}}}"#
.to_string(),
)
}
#[test]
fn agent_backend_stores_config() {
let backend = AgentApiBackend::new(
@ -3733,6 +3751,76 @@ enabled = true
assert_eq!(usage.tokens().output_tokens, 7);
}
#[tokio::test]
async fn agent_run_identifies_a_schema_error_repeated_during_repair() {
let server = MockServer::start();
let first = server.mock(|when, then| {
when.method(POST)
.path("/chat/completions")
.body_includes(r#""stream":true"#)
.body_excludes(r#""role":"assistant""#);
then.status(200)
.header("content-type", "text/event-stream")
.body(chat_completion_stream(r#"{"findings":[{}]}"#, 20, 3));
});
let first_repair = server.mock(|when, then| {
when.method(POST)
.path("/chat/completions")
.body_includes("JSON Pointer `/findings/0/rationale`")
.body_excludes("unchanged from your previous repair");
then.status(200)
.header("content-type", "text/event-stream")
.body(chat_completion_stream(r#"{"findings":[{}]}"#, 21, 4));
});
let second_repair = server.mock(|when, then| {
when.method(POST)
.path("/chat/completions")
.body_includes("JSON Pointer `/findings/0/rationale`")
.body_includes("unchanged from your previous repair");
then.status(200)
.header("content-type", "text/event-stream")
.body(chat_completion_stream(
r#"{"findings":[{"rationale":"done"}]}"#,
22,
5,
));
});
let backend = mock_api_backend(&server);
let mut node = Node::new("audit");
node.attrs
.insert("output_schema".to_string(), nested_output_schema_attr());
node.attrs
.insert("output_retries".to_string(), AttrValue::Integer(2));
let context = Context::new();
let emitter = Arc::new(Emitter::new(fabro_types::RunId::new()));
let workspace = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> =
Arc::new(LocalSandbox::new(workspace.path().to_path_buf()));
let result = backend
.run(CodergenRunRequest {
node: &node,
prompt: "Audit the result",
context: &context,
thread_id: None,
emitter: &emitter,
sandbox: &sandbox,
tool_hooks: None,
cancel_token: CancellationToken::new(),
agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),
})
.await
.unwrap();
first.assert_calls(1);
first_repair.assert_calls(1);
second_repair.assert_calls(1);
let CodergenResult::Text { text, .. } = result else {
panic!("run should return text");
};
assert_eq!(text, r#"{"findings":[{"rationale":"done"}]}"#);
}
#[tokio::test]
async fn agent_output_repair_continues_on_the_original_models_fallback_plan() {
let server = MockServer::start();

View file

@ -1,8 +1,11 @@
use std::fmt::Write as _;
use std::sync::{Arc, LazyLock};
use fabro_graphviz::graph::Node;
use fabro_llm::types::{ResponseFormat, ResponseFormatType};
use jsonschema::Validator;
use jsonschema::error::ValidationErrorKind;
use jsonschema::paths::Location;
use jsonschema::{ValidationError, Validator};
use serde_json::Value;
use crate::error::Error;
@ -45,24 +48,153 @@ pub(crate) enum StructuredOutputErrorKind {
SchemaValidation,
}
const MAX_SCHEMA_FRAGMENT_CHARS: usize = 320;
/// `additionalProperties` errors carry one entry per unexpected key, and the
/// keys come from model output. Cap them so a wide object can't turn the repair
/// prompt into megabytes.
const MAX_UNEXPECTED_PROPERTIES: usize = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
struct SchemaValidationIssue {
instance_path: Location,
schema_path: Location,
detail: SchemaValidationIssueDetail,
}
/// `Required` and `AdditionalProperties` get bespoke rendering because
/// `jsonschema` names the offending property without ever locating it. Every
/// other keyword already renders a message that names both the value and the
/// constraint, so it goes through `Other` with the schema fragment attached.
#[derive(Debug, Clone, PartialEq, Eq)]
enum SchemaValidationIssueDetail {
Required {
property: String,
},
AdditionalProperties {
unexpected: Vec<String>,
total: usize,
},
Other {
message: String,
schema_fragment: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum StructuredOutputErrorDetails {
Message(String),
SchemaValidation(Vec<SchemaValidationIssue>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StructuredOutputError {
kind: StructuredOutputErrorKind,
messages: Vec<String>,
kind: StructuredOutputErrorKind,
details: StructuredOutputErrorDetails,
}
impl SchemaValidationIssue {
fn from_error(error: &ValidationError<'_>, schema: Option<&Value>) -> Self {
let detail = match error.kind() {
ValidationErrorKind::Required { property } => SchemaValidationIssueDetail::Required {
property: property
.as_str()
.map_or_else(|| property.to_string(), str::to_owned),
},
ValidationErrorKind::AdditionalProperties { unexpected } => {
// `unexpected` arrives in the order the model emitted the keys,
// so sort before truncating. That keeps the retained subset and
// the rendered message stable, and lets two attempts that left
// the same keys in place compare equal whatever order they used.
let total = unexpected.len();
let mut sorted = unexpected.clone();
sorted.sort_unstable();
sorted.truncate(MAX_UNEXPECTED_PROPERTIES);
SchemaValidationIssueDetail::AdditionalProperties {
unexpected: sorted,
total,
}
}
_ => SchemaValidationIssueDetail::Other {
message: error.to_string(),
schema_fragment: schema
.and_then(|schema| schema.pointer(error.schema_path().as_str()))
.map(bounded_json),
},
};
Self {
instance_path: error.instance_path().clone(),
schema_path: error.schema_path().clone(),
detail,
}
}
fn render(&self) -> String {
let mut message = match &self.detail {
SchemaValidationIssueDetail::Required { property } => format!(
"Missing required property {} at JSON Pointer `{}`. Add it to the object at {}.",
Value::String(property.clone()),
self.instance_path.join(property),
pointer_phrase(&self.instance_path),
),
SchemaValidationIssueDetail::AdditionalProperties { unexpected, total } => {
let mut properties = unexpected
.iter()
.map(|property| {
format!(
"{} at `{}`",
Value::String(property.clone()),
self.instance_path.join(property),
)
})
.collect::<Vec<_>>()
.join(", ");
let remaining = total - unexpected.len();
if remaining > 0 {
let _ = write!(properties, ", and {remaining} more");
}
format!(
"Unexpected properties in the object at {}: {properties}.",
pointer_phrase(&self.instance_path),
)
}
SchemaValidationIssueDetail::Other { message, .. } => format!(
"At {}: {}.",
pointer_phrase(&self.instance_path),
message.trim_end_matches('.'),
),
};
let _ = write!(
message,
" Schema rule: {}",
pointer_phrase(&self.schema_path)
);
if let SchemaValidationIssueDetail::Other {
schema_fragment: Some(fragment),
..
} = &self.detail
{
message.push_str(": ");
message.push_str(fragment);
}
message.push('.');
message
}
}
impl StructuredOutputError {
fn new(kind: StructuredOutputErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
messages: vec![message.into()],
details: StructuredOutputErrorDetails::Message(message.into()),
}
}
fn validation(messages: Vec<String>) -> Self {
fn validation(issues: Vec<SchemaValidationIssue>) -> Self {
Self {
kind: StructuredOutputErrorKind::SchemaValidation,
messages,
kind: StructuredOutputErrorKind::SchemaValidation,
details: StructuredOutputErrorDetails::SchemaValidation(issues),
}
}
@ -73,8 +205,13 @@ impl StructuredOutputError {
}
#[must_use]
pub(crate) fn messages(&self) -> &[String] {
&self.messages
pub(crate) fn messages(&self) -> Vec<String> {
match &self.details {
StructuredOutputErrorDetails::Message(message) => vec![message.clone()],
StructuredOutputErrorDetails::SchemaValidation(issues) => {
issues.iter().map(SchemaValidationIssue::render).collect()
}
}
}
#[must_use]
@ -87,7 +224,11 @@ impl StructuredOutputError {
}
#[must_use]
pub(crate) fn repair_message(&self, schema: &OutputSchemaKind) -> String {
pub(crate) fn repair_message(
&self,
schema: &OutputSchemaKind,
previous_error: Option<&Self>,
) -> String {
let expectation = match schema {
OutputSchemaKind::Routing => format!(
"Return a single JSON object with at least one routing field: {}.",
@ -98,18 +239,61 @@ impl StructuredOutputError {
}
};
let errors = self
.messages
.messages()
.iter()
.map(|message| format!("- {message}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"Your previous response did not satisfy the node's output_schema.\n\n\
Validation errors:\n{errors}\n\n\
{expectation}\n\
Do not include Markdown fences or explanatory prose; reply only with the corrected JSON object."
)
let mut sections =
vec!["Your previous response did not satisfy the node's output_schema.".to_string()];
if previous_error.is_some_and(|previous| self.shares_schema_issue_with(previous)) {
sections.push(
"At least one validation problem below is unchanged from your previous repair."
.to_string(),
);
}
sections.push(format!("Validation errors:\n{errors}"));
sections.push(expectation);
if self.kind == StructuredOutputErrorKind::SchemaValidation {
sections.push(
"Apply each correction at the exact JSON Pointer shown and return the complete object."
.to_string(),
);
}
sections.push(
"Do not include Markdown fences or explanatory prose; reply only with the corrected JSON object."
.to_string(),
);
sections.join("\n\n")
}
fn shares_schema_issue_with(&self, other: &Self) -> bool {
let (
StructuredOutputErrorDetails::SchemaValidation(current),
StructuredOutputErrorDetails::SchemaValidation(previous),
) = (&self.details, &other.details)
else {
return false;
};
current.iter().any(|issue| previous.contains(issue))
}
}
fn pointer_phrase(path: &Location) -> String {
if path.as_str().is_empty() {
"the document root".to_string()
} else {
format!("JSON Pointer `{path}`")
}
}
fn bounded_json(value: &Value) -> String {
let mut rendered = value.to_string();
if let Some((offset, _)) = rendered.char_indices().nth(MAX_SCHEMA_FRAGMENT_CHARS) {
rendered.truncate(offset);
rendered.push('…');
}
rendered
}
#[derive(Debug, Clone, PartialEq)]
@ -202,8 +386,8 @@ pub(crate) fn validate_response_text(
) -> Result<ValidatedStructuredOutput, StructuredOutputError> {
match schema {
OutputSchemaKind::Routing => validate_routing_response_text(text),
OutputSchemaKind::JsonSchema { validator, .. } => {
validate_custom_response_text(validator, text)
OutputSchemaKind::JsonSchema { schema, validator } => {
validate_custom_response_text(validator, schema, text)
}
}
}
@ -324,7 +508,7 @@ fn validate_routing_response_text(
if !contains_routing_field(obj) {
continue;
}
validate_value_against_validator(routing_validator(), &parsed)?;
validate_value_against_validator(routing_validator(), &parsed, None)?;
return Ok(ValidatedStructuredOutput { value: parsed });
}
@ -339,6 +523,7 @@ fn validate_routing_response_text(
fn validate_custom_response_text(
validator: &Validator,
schema: &Value,
text: &str,
) -> Result<ValidatedStructuredOutput, StructuredOutputError> {
// Prose after the object can contain braces, so the last candidate is not
@ -349,7 +534,7 @@ fn validate_custom_response_text(
for candidate in candidates.iter().rev() {
match serde_json::from_str::<Value>(candidate) {
Ok(parsed) => {
validate_value_against_validator(validator, &parsed)?;
validate_value_against_validator(validator, &parsed, Some(schema))?;
return Ok(ValidatedStructuredOutput { value: parsed });
}
Err(err) if invalid_json.is_none() => invalid_json = Some(err.to_string()),
@ -372,16 +557,17 @@ fn validate_custom_response_text(
fn validate_value_against_validator(
validator: &Validator,
value: &Value,
schema: Option<&Value>,
) -> Result<(), StructuredOutputError> {
let errors = validator
let issues = validator
.iter_errors(value)
.map(|error| error.to_string())
.take(5)
.map(|error| SchemaValidationIssue::from_error(&error, schema))
.collect::<Vec<_>>();
if errors.is_empty() {
if issues.is_empty() {
Ok(())
} else {
Err(StructuredOutputError::validation(errors))
Err(StructuredOutputError::validation(issues))
}
}
@ -682,6 +868,151 @@ mod tests {
);
}
#[test]
fn missing_nested_property_reports_the_required_target_pointer() {
let schema = schema(serde_json::json!({
"type": "object",
"required": ["findings"],
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"required": ["rationale"],
"properties": {
"rationale": { "type": "string" }
}
}
}
}
}));
let error = validate_response_text(&schema, r#"{"findings":[{}]}"#).unwrap_err();
assert_eq!(error.messages(), vec![
"Missing required property \"rationale\" at JSON Pointer `/findings/0/rationale`. \
Add it to the object at JSON Pointer `/findings/0`. Schema rule: JSON Pointer \
`/properties/findings/items/required`."
.to_string(),
],);
}
#[test]
fn type_and_enum_errors_report_instance_and_schema_pointers() {
let schema = schema(serde_json::json!({
"type": "object",
"properties": {
"line": { "type": "integer" },
"severity": { "enum": ["HIGH", "MEDIUM", "LOW"] }
}
}));
let error =
validate_response_text(&schema, r#"{"line":"85","severity":"CRITICAL"}"#).unwrap_err();
assert_eq!(error.messages(), vec![
"At JSON Pointer `/line`: \"85\" is not of type \"integer\". \
Schema rule: JSON Pointer `/properties/line/type`: \"integer\"."
.to_string(),
"At JSON Pointer `/severity`: \"CRITICAL\" is not one of \"HIGH\", \"MEDIUM\" or \
\"LOW\". Schema rule: JSON Pointer `/properties/severity/enum`: \
[\"HIGH\",\"MEDIUM\",\"LOW\"]."
.to_string(),
],);
}
#[test]
fn additional_property_error_reports_each_property_pointer() {
let schema = schema(serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"findings": { "type": "array" }
}
}));
let error = validate_response_text(&schema, r#"{"findings":[],"rationale":"wrong level"}"#)
.unwrap_err();
assert_eq!(error.messages(), vec![
"Unexpected properties in the object at the document root: \"rationale\" at \
`/rationale`. Schema rule: JSON Pointer `/additionalProperties`."
.to_string(),
],);
}
#[test]
fn repeated_schema_error_calls_out_the_unchanged_pointer() {
let schema = schema(serde_json::json!({
"type": "object",
"required": ["findings"],
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"required": ["rationale"]
}
}
}
}));
let previous = validate_response_text(&schema, r#"{"findings":[{}]}"#).unwrap_err();
let current = validate_response_text(&schema, r#"{"findings":[{}]}"#).unwrap_err();
let repair = current.repair_message(&schema, Some(&previous));
assert!(
repair.contains(
"At least one validation problem below is unchanged from your previous repair."
),
"unexpected repair message: {repair}",
);
assert!(
repair.contains("JSON Pointer `/findings/0/rationale`"),
"unexpected repair message: {repair}",
);
}
#[test]
fn the_same_unexpected_properties_in_a_new_order_are_still_unchanged() {
let schema = schema(serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"findings": { "type": "array" }
}
}));
let previous = validate_response_text(&schema, r#"{"beta":1,"alpha":1}"#).unwrap_err();
let current = validate_response_text(&schema, r#"{"alpha":1,"beta":1}"#).unwrap_err();
let repair = current.repair_message(&schema, Some(&previous));
assert!(
repair.contains("unchanged from your previous repair"),
"unexpected repair message: {repair}",
);
}
#[test]
fn a_different_problem_at_the_same_location_is_not_called_unchanged() {
let schema = schema(serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"findings": { "type": "array" }
}
}));
let previous = validate_response_text(&schema, r#"{"stray":1}"#).unwrap_err();
let current = validate_response_text(&schema, r#"{"different":1}"#).unwrap_err();
let repair = current.repair_message(&schema, Some(&previous));
assert!(
!repair.contains("unchanged from your previous repair"),
"unexpected repair message: {repair}",
);
}
#[test]
fn invalid_custom_schema_is_rejected_when_parsing_node_attr() {
let mut node = Node::new("audit");

View file

@ -22,12 +22,10 @@ use tokio::task::spawn_blocking;
use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow};
use crate::error::Error;
use crate::event::{Event, append_event, to_run_event_at};
use crate::file_resolver::FileResolver;
use crate::pipeline::types::PersistOptions;
use crate::pipeline::{self, Persisted, TransformOptions, Validated};
use crate::records::RunSpec;
use crate::run_lookup::default_scratch_base;
use crate::run_materialization::materialize_run;
use crate::run_materialization;
use crate::transforms::{ModelResolutionTransform, RenderMode};
use crate::workflow_bundle::{RunDefinition, WorkflowBundle};
@ -58,6 +56,90 @@ pub struct CreateRunInput {
pub web_url: Option<String>,
}
impl CreateRunInput {
/// Split into the compile-stage input and the persistence metadata for
/// `run_id`, the two halves of the create pipeline.
fn into_stages(
self,
run_id: RunId,
storage_root: PathBuf,
) -> (CreateRunCompileInput, CreateRunPersistenceMetadata) {
let Self {
workflow,
settings,
vars,
cwd,
workflow_slug,
workflow_path,
workflow_bundle,
submitted_manifest_bytes,
run_id: _,
title,
automation,
git,
fork_source_ref,
parent_id,
provenance,
configured_providers,
web_url,
} = self;
(
CreateRunCompileInput {
workflow,
settings,
vars,
cwd,
workflow_path,
workflow_bundle,
configured_providers,
},
CreateRunPersistenceMetadata {
run_id,
storage_root,
workflow_slug,
submitted_manifest_bytes,
title,
automation,
git,
fork_source_ref,
parent_id,
provenance,
web_url,
},
)
}
}
/// Inputs needed to resolve and compile a workflow for run creation.
#[derive(Debug)]
pub struct CreateRunCompileInput {
pub workflow: WorkflowInput,
pub settings: WorkflowSettings,
pub vars: HashMap<String, String>,
pub cwd: PathBuf,
pub workflow_path: Option<ManifestPath>,
pub workflow_bundle: Option<WorkflowBundle>,
pub configured_providers: Vec<ProviderId>,
}
/// Durable metadata joined to a materialized workflow before persistence.
/// `run_id` is already resolved, and `storage_root` is used to derive the
/// run's scratch directory during pure input assembly.
#[derive(Debug)]
pub struct CreateRunPersistenceMetadata {
pub run_id: RunId,
pub storage_root: PathBuf,
pub workflow_slug: Option<String>,
pub submitted_manifest_bytes: Option<Vec<u8>>,
pub title: Option<String>,
pub automation: Option<AutomationRef>,
pub git: Option<GitContext>,
pub fork_source_ref: Option<ForkSourceRef>,
pub parent_id: Option<RunId>,
pub provenance: RunProvenance,
pub web_url: Option<String>,
}
#[derive(Debug)]
pub struct CreatedRun {
pub persisted: Persisted,
@ -66,20 +148,97 @@ pub struct CreatedRun {
pub dot_path: Option<PathBuf>,
}
struct PersistCreateOptions {
/// Result of resolving, preprocessing, validating, and promoting a workflow
/// for run creation. Model selectors in the graph are resolved, while the run
/// settings still reflect the compiled source and have not been materialized.
pub struct CompiledRun {
validated: Validated,
settings: WorkflowSettings,
run_id: Option<RunId>,
run_dir: Option<PathBuf>,
raw_source: String,
workflow_slug: Option<String>,
source_name: Option<String>,
workflow_config: Option<String>,
dot_path: Option<PathBuf>,
definition: Option<RunDefinition>,
source_directory: String,
labels: HashMap<String, String>,
source_directory: Option<String>,
automation: Option<AutomationRef>,
git: Option<GitContext>,
fork_source_ref: Option<ForkSourceRef>,
provenance: RunProvenance,
configured_providers: Vec<ProviderId>,
catalog: Arc<Catalog>,
}
impl CompiledRun {
pub fn validated(&self) -> &Validated {
&self.validated
}
pub fn settings(&self) -> &WorkflowSettings {
&self.settings
}
}
/// Compiled workflow with its run-level model settings materialized against
/// the same provider snapshot used during compilation.
pub struct MaterializedRun {
validated: Validated,
settings: WorkflowSettings,
raw_source: String,
workflow_slug: Option<String>,
workflow_config: Option<String>,
dot_path: Option<PathBuf>,
definition: Option<RunDefinition>,
source_directory: String,
labels: HashMap<String, String>,
}
impl MaterializedRun {
pub fn settings(&self) -> &WorkflowSettings {
&self.settings
}
}
/// Complete input for creating a durable run. The run ID and run directory
/// are resolved during assembly, before persistence begins.
pub struct CreateRunPersistenceInput {
materialized: MaterializedRun,
run_id: RunId,
run_dir: PathBuf,
workflow_slug: Option<String>,
submitted_manifest_bytes: Option<Vec<u8>>,
title: Option<String>,
automation: Option<AutomationRef>,
git: Option<GitContext>,
fork_source_ref: Option<ForkSourceRef>,
parent_id: Option<RunId>,
provenance: RunProvenance,
web_url: Option<String>,
}
impl CreateRunPersistenceInput {
pub fn materialized(&self) -> &MaterializedRun {
&self.materialized
}
pub fn run_id(&self) -> RunId {
self.run_id
}
pub fn run_dir(&self) -> &Path {
&self.run_dir
}
pub fn workflow_slug(&self) -> Option<&str> {
self.workflow_slug.as_deref()
}
pub fn submitted_manifest_bytes(&self) -> Option<&[u8]> {
self.submitted_manifest_bytes.as_deref()
}
pub fn automation(&self) -> Option<&AutomationRef> {
self.automation.as_ref()
}
pub fn definition(&self) -> Option<&RunDefinition> {
self.materialized.definition.as_ref()
}
}
/// Resolve workflow inputs, normalize settings using the caller-provided
@ -90,96 +249,253 @@ pub async fn create(
storage_root: PathBuf,
catalog: Arc<Catalog>,
) -> Result<CreatedRun, Error> {
let resolved = resolve_workflow(ResolveWorkflowInput {
workflow: request.workflow,
settings: request.settings,
cwd: request.cwd,
let run_id = request.run_id.unwrap_or_default();
let persistence_input = spawn_blocking(move || {
let (compile_input, metadata) = request.into_stages(run_id, storage_root);
let compiled = compile_create_run(compile_input, Arc::clone(&catalog))?;
let materialized = materialize_create_run(compiled, catalog.as_ref())?;
Ok::<_, Error>(assemble_create_run_persistence_input(
materialized,
metadata,
))
})
.map_err(|err| Error::Parse(err.to_string()))?;
let labels = resolved.settings.combined_labels();
let settings = resolved.settings.clone();
.await
.map_err(|err| Error::engine_with_source("workflow create task failed", err))??;
let CreateRunInput {
workflow: _,
settings: _,
Box::pin(persist_create_run(store, persistence_input)).await
}
/// Resolve, preprocess, validate, and promote a workflow for run creation.
///
/// This stage is synchronous and may read workflow files. Async callers must
/// run it on a blocking thread.
pub fn compile_create_run(
input: CreateRunCompileInput,
catalog: Arc<Catalog>,
) -> Result<CompiledRun, Error> {
let CreateRunCompileInput {
workflow,
settings,
vars,
cwd: _,
workflow_slug,
cwd,
workflow_path,
workflow_bundle,
submitted_manifest_bytes,
configured_providers,
} = input;
let resolved = resolve_workflow(ResolveWorkflowInput {
workflow,
settings,
cwd,
})
.map_err(|err| Error::Parse(err.to_string()))?;
let settings = resolved.settings;
let labels = settings.combined_labels();
let workflow_config = resolved
.workflow_toml_path
.as_deref()
.and_then(|path| std::fs::read_to_string(path).ok());
let source_name = resolved
.dot_path
.as_ref()
.map(|path| path.display().to_string());
let definition = match (workflow_path, workflow_bundle) {
(Some(workflow_path), Some(workflow_bundle)) => {
let bundled = workflow_bundle.workflow(&workflow_path).ok_or_else(|| {
Error::Parse("workflow path is missing from workflow bundle".to_string())
})?;
if bundled.source != resolved.raw_source {
return Err(Error::Parse(
"resolved workflow does not match workflow bundle entrypoint".to_string(),
));
}
Some(RunDefinition::new(workflow_path, workflow_bundle))
}
(None, None) => None,
_ => {
return Err(Error::Parse(
"workflow path and workflow bundle must be provided together".to_string(),
));
}
};
let mut validated = preprocess_and_validate(
&resolved.raw_source,
resolved.goal_override.as_deref(),
&TransformOptions {
current_dir: resolved.current_dir.clone(),
file_resolver: resolved.file_resolver.clone(),
template_context: template_context(Some(&settings), vars),
source_name,
render_mode: RenderMode::Structural,
custom_transforms: Vec::new(),
model_resolution: Some(
ModelResolutionTransform::for_eligible(
catalog,
configured_providers.iter().cloned().collect(),
)
.with_default_provider(configured_default_provider(&settings)),
),
},
)?;
validated.promote_template_undefined_variables_to_errors();
if validated.has_errors() {
return Err(Error::ValidationFailed {
diagnostics: validated.diagnostics().to_vec(),
});
}
Ok(CompiledRun {
validated,
settings,
raw_source: resolved.raw_source,
workflow_slug: resolved.workflow_slug,
workflow_config,
dot_path: resolved.dot_path,
definition,
source_directory: resolved.working_directory.to_string_lossy().to_string(),
labels,
configured_providers,
})
}
/// Materialize run-level model settings from a compiled workflow.
pub fn materialize_create_run(
compiled: CompiledRun,
catalog: &Catalog,
) -> Result<MaterializedRun, Error> {
let CompiledRun {
validated,
settings,
raw_source,
workflow_slug,
workflow_config,
dot_path,
definition,
source_directory,
labels,
configured_providers,
} = compiled;
let settings = run_materialization::materialize_run(
settings,
validated.graph(),
catalog,
&configured_providers,
)?;
Ok(MaterializedRun {
validated,
settings,
raw_source,
workflow_slug,
workflow_config,
dot_path,
definition,
source_directory,
labels,
})
}
/// Assemble all inputs needed for persistence without I/O or recompilation.
pub fn assemble_create_run_persistence_input(
materialized: MaterializedRun,
metadata: CreateRunPersistenceMetadata,
) -> CreateRunPersistenceInput {
let CreateRunPersistenceMetadata {
run_id,
storage_root,
workflow_slug,
submitted_manifest_bytes,
title,
automation,
git,
fork_source_ref,
parent_id,
provenance,
configured_providers,
web_url,
} = request;
} = metadata;
let run_dir = Storage::new(storage_root)
.run_scratch(&run_id)
.root()
.to_path_buf();
let workflow_slug = workflow_slug.or_else(|| materialized.workflow_slug.clone());
let run_id = run_id.unwrap_or_else(RunId::new);
let storage = Storage::new(storage_root);
let run_dir = storage.run_scratch(&run_id).root().to_path_buf();
let source_directory = Some(resolved.working_directory.to_string_lossy().to_string());
CreateRunPersistenceInput {
materialized,
run_id,
run_dir,
workflow_slug,
submitted_manifest_bytes,
title,
automation,
git,
fork_source_ref,
parent_id,
provenance,
web_url,
}
}
let goal_override = resolved.goal_override.clone();
let current_dir = resolved.current_dir.clone();
let file_resolver = resolved.file_resolver.clone();
let resolved_workflow_slug = resolved.workflow_slug.clone();
/// Persist one already-compiled and materialized run without recompiling it.
pub async fn persist_create_run(
store: &Database,
input: CreateRunPersistenceInput,
) -> Result<CreatedRun, Error> {
let CreateRunPersistenceInput {
materialized,
run_id,
run_dir,
workflow_slug,
submitted_manifest_bytes,
title,
automation,
git,
fork_source_ref,
parent_id,
provenance,
web_url,
} = input;
let MaterializedRun {
validated,
settings,
raw_source,
workflow_slug: _,
workflow_config,
dot_path,
definition,
source_directory,
labels,
} = materialized;
let persisted_run_dir = run_dir.clone();
let accepted_definition = match (&workflow_path, &workflow_bundle) {
(Some(workflow_path), Some(workflow_bundle)) => Some(RunDefinition::new(
workflow_path.clone(),
workflow_bundle.clone(),
)),
_ => None,
};
let raw_source = resolved.raw_source.clone();
let source_name = resolved
.dot_path
.as_ref()
.map(|path| path.display().to_string());
let persisted = spawn_blocking(move || {
create_from_source(
&raw_source,
vars,
PersistCreateOptions {
settings,
run_id: Some(run_id),
run_dir: Some(persisted_run_dir),
workflow_slug: workflow_slug.or(resolved_workflow_slug),
source_name,
labels,
source_directory,
automation,
git,
fork_source_ref,
provenance,
configured_providers,
catalog,
},
current_dir,
file_resolver,
goal_override.as_deref(),
)
let run_spec = RunSpec {
run_id,
settings,
graph: validated.graph().clone(),
graph_source: Some(validated.source().to_string()),
workflow_slug,
automation,
source_directory: Some(source_directory),
labels,
provenance,
manifest_blob: None,
definition_blob: None,
git,
fork_source_ref,
};
pipeline::persist(validated, PersistOptions {
run_dir: persisted_run_dir,
run_spec,
})
})
.await
.map_err(|err| Error::engine_with_source("workflow create task failed", err))??;
let workflow_config = resolved
.workflow_toml_path
.as_deref()
.and_then(|path| std::fs::read_to_string(path).ok());
persist_created_run(
store,
&persisted,
&resolved.raw_source,
&raw_source,
workflow_config,
submitted_manifest_bytes.as_deref(),
accepted_definition.as_ref(),
definition.as_ref(),
title,
parent_id,
web_url,
@ -190,7 +506,7 @@ pub async fn create(
persisted,
run_id,
run_dir,
dot_path: resolved.dot_path,
dot_path,
})
}
@ -285,40 +601,6 @@ fn store_error(err: impl std::fmt::Display) -> Error {
Error::engine(err.to_string())
}
fn create_from_source(
dot_source: &str,
vars: HashMap<String, String>,
options: PersistCreateOptions,
current_dir: Option<PathBuf>,
file_resolver: Option<Arc<dyn FileResolver>>,
goal_override: Option<&str>,
) -> Result<Persisted, Error> {
let mut validated = preprocess_and_validate(dot_source, goal_override, &TransformOptions {
current_dir,
file_resolver,
template_context: template_context(Some(&options.settings), vars),
source_name: options.source_name.clone(),
render_mode: RenderMode::Structural,
custom_transforms: Vec::new(),
model_resolution: Some(
ModelResolutionTransform::for_eligible(
Arc::clone(&options.catalog),
options.configured_providers.iter().cloned().collect(),
)
.with_default_provider(configured_default_provider(&options.settings)),
),
})?;
validated.promote_template_undefined_variables_to_errors();
if validated.has_errors() {
return Err(Error::ValidationFailed {
diagnostics: validated.diagnostics().to_vec(),
});
}
persist_validated(validated, options)
}
/// Parse, transform, and validate `dot_source`.
///
/// `options.model_resolution` drives both halves of catalog awareness: it
@ -376,59 +658,6 @@ fn apply_goal_override(graph: &mut Graph, goal_override: Option<&str>) {
}
}
fn persist_validated(
validated: Validated,
options: PersistCreateOptions,
) -> Result<Persisted, Error> {
let PersistCreateOptions {
settings,
run_id,
run_dir,
workflow_slug,
source_name: _,
labels,
source_directory,
automation,
git,
fork_source_ref,
provenance,
configured_providers,
catalog,
} = options;
let settings = materialize_run(
settings,
validated.graph(),
catalog.as_ref(),
&configured_providers,
)?;
let run_id = run_id.unwrap_or_else(RunId::new);
let run_dir = run_dir.unwrap_or_else(|| default_run_dir(&run_id));
let run_spec = RunSpec {
run_id,
settings,
graph: validated.graph().clone(),
graph_source: Some(validated.source().to_string()),
workflow_slug,
automation,
source_directory,
labels,
provenance,
manifest_blob: None,
definition_blob: None,
git,
fork_source_ref,
};
pipeline::persist(validated, PersistOptions { run_dir, run_spec })
}
pub(crate) fn default_run_dir(run_id: &RunId) -> PathBuf {
make_run_dir(&default_scratch_base(), run_id)
}
pub fn make_run_dir(scratch_base: &Path, run_id: &RunId) -> PathBuf {
fabro_config::RunScratch::for_run(scratch_base, run_id)
.root()
@ -456,6 +685,7 @@ mod tests {
use object_store::memory::InMemory;
use super::*;
use crate::file_resolver::FileResolver;
use crate::operations::{ValidateInput, validate, validate_with_catalog};
use crate::pipeline::types::{GOAL_SELF_REFERENCE_RULE, TEMPLATE_UNDEFINED_VARIABLE_RULE};
use crate::transforms::Transform;
@ -547,6 +777,24 @@ reasoning = false
Catalog::builtin().all_provider_ids().into_iter().collect()
}
fn compile_input(request: &CreateRunInput) -> CreateRunCompileInput {
let (compile_input, _) = request
.clone()
.into_stages(RunId::new(), PathBuf::from("/tmp/storage"));
compile_input
}
fn persistence_metadata(
request: &CreateRunInput,
run_id: RunId,
storage_root: &Path,
) -> CreateRunPersistenceMetadata {
let (_, metadata) = request
.clone()
.into_stages(run_id, storage_root.to_path_buf());
metadata
}
fn validate_dot(dot_source: &str, settings: WorkflowSettings) -> Validated {
validate_with_catalog(
ValidateInput {
@ -1337,6 +1585,242 @@ reasoning = false
);
}
#[test]
fn assemble_create_run_persistence_input_resolves_complete_durable_identity() {
let dir = tempfile::tempdir().unwrap();
let storage_root = dir.path().join("storage");
let automation = AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
};
let request = CreateRunInput {
workflow: WorkflowInput::DotSource {
source: MINIMAL_DOT.to_string(),
base_dir: None,
},
settings: test_default_settings(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
workflow_slug: Some("request-slug".to_string()),
workflow_path: None,
workflow_bundle: None,
submitted_manifest_bytes: Some(b"submitted manifest".to_vec()),
run_id: Some(fixtures::RUN_1),
title: Some("Assembled run".to_string()),
automation: Some(automation.clone()),
git: None,
fork_source_ref: None,
parent_id: Some(fixtures::RUN_2),
provenance: test_support::test_run_provenance(),
configured_providers: test_provider_ids(),
web_url: Some("https://fabro.test/runs/1".to_string()),
};
let catalog = test_catalog();
let resolved_run_id = fixtures::RUN_64;
let compiled = compile_create_run(compile_input(&request), Arc::clone(&catalog)).unwrap();
let materialized = materialize_create_run(compiled, catalog.as_ref()).unwrap();
let metadata = persistence_metadata(&request, resolved_run_id, &storage_root);
let input = assemble_create_run_persistence_input(materialized, metadata);
assert_eq!(input.run_id(), resolved_run_id);
assert_eq!(
input.run_dir(),
Storage::new(&storage_root)
.run_scratch(&resolved_run_id)
.root()
);
assert_eq!(input.workflow_slug(), Some("request-slug"));
assert_eq!(
input.submitted_manifest_bytes(),
Some(b"submitted manifest".as_slice())
);
assert_eq!(input.automation(), Some(&automation));
assert_eq!(
input.materialized().settings().run.model.name.as_deref(),
Some("claude-sonnet-5")
);
}
#[test]
fn compile_create_run_rejects_mismatched_bundle_definition() {
let workflow_path = ManifestPath::from_wire("workflows/main.fabro").unwrap();
let compiled_workflow = BundledWorkflow {
path: workflow_path.clone(),
source: MINIMAL_DOT.to_string(),
config: None,
files: HashMap::new(),
};
let mismatched_bundle =
WorkflowBundle::new(HashMap::from([(workflow_path.clone(), BundledWorkflow {
source: MINIMAL_DOT.replace("Build feature", "Different goal"),
..compiled_workflow.clone()
})]));
let Err(error) = compile_create_run(
CreateRunCompileInput {
workflow: WorkflowInput::Bundled(compiled_workflow),
settings: test_default_settings(),
vars: HashMap::new(),
cwd: PathBuf::from("/tmp/project"),
workflow_path: Some(workflow_path),
workflow_bundle: Some(mismatched_bundle),
configured_providers: test_provider_ids(),
},
test_catalog(),
) else {
panic!("mismatched accepted definition should fail");
};
assert!(matches!(error, Error::Parse(message) if message ==
"resolved workflow does not match workflow bundle entrypoint"));
}
#[test]
fn compile_create_run_exposes_resolved_metadata_and_definition() {
let workflow_path = ManifestPath::from_wire("workflows/main.fabro").unwrap();
let bundled = BundledWorkflow {
path: workflow_path.clone(),
source: MINIMAL_DOT.to_string(),
config: None,
files: HashMap::new(),
};
let bundle = WorkflowBundle::new(HashMap::from([(workflow_path.clone(), bundled.clone())]));
let compiled = compile_create_run(
CreateRunCompileInput {
workflow: WorkflowInput::Bundled(bundled),
settings: test_default_settings(),
vars: HashMap::new(),
cwd: PathBuf::from("/tmp/project"),
workflow_path: Some(workflow_path.clone()),
workflow_bundle: Some(bundle),
configured_providers: test_provider_ids(),
},
test_catalog(),
)
.unwrap();
assert_eq!(compiled.raw_source, MINIMAL_DOT);
assert_eq!(compiled.dot_path.as_deref(), Some(workflow_path.as_path()));
assert_eq!(compiled.labels, compiled.settings().combined_labels());
let materialized = materialize_create_run(compiled, test_catalog().as_ref()).unwrap();
let input =
assemble_create_run_persistence_input(materialized, CreateRunPersistenceMetadata {
run_id: fixtures::RUN_1,
storage_root: PathBuf::from("/tmp/storage"),
workflow_slug: None,
submitted_manifest_bytes: None,
title: None,
automation: None,
git: None,
fork_source_ref: None,
parent_id: None,
provenance: test_support::test_run_provenance(),
web_url: None,
});
let definition = input
.definition()
.expect("bundled create input should retain a run definition");
assert_eq!(definition.workflow_path, workflow_path);
}
#[tokio::test]
async fn persist_create_run_uses_compiled_graph_without_recompiling_source() {
let dir = tempfile::tempdir().unwrap();
let storage_root = dir.path().join("storage");
let dot_path = dir.path().join("workflow.fabro");
let compiled_source = MINIMAL_DOT.replace("Build feature", "Compiled goal");
std::fs::write(&dot_path, &compiled_source).unwrap();
let automation = AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
};
let request = CreateRunInput {
workflow: WorkflowInput::Path(dot_path.clone()),
settings: test_default_settings(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
workflow_slug: Some("compiled-slug".to_string()),
workflow_path: None,
workflow_bundle: None,
submitted_manifest_bytes: Some(b"submitted manifest".to_vec()),
run_id: Some(fixtures::RUN_2),
title: Some("Compiled run".to_string()),
automation: Some(automation.clone()),
git: None,
fork_source_ref: None,
parent_id: None,
provenance: test_support::test_run_provenance(),
configured_providers: test_provider_ids(),
web_url: None,
};
let catalog = test_catalog();
let workflow_config_path = dir.path().join("workflow.toml");
std::fs::write(
&workflow_config_path,
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",
)
.unwrap();
let compiled = compile_create_run(compile_input(&request), Arc::clone(&catalog)).unwrap();
assert_eq!(
compiled.workflow_config.as_deref(),
Some("_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n")
);
std::fs::write(&dot_path, "this is no longer a graph").unwrap();
std::fs::write(&workflow_config_path, "changed after compilation").unwrap();
let materialized = materialize_create_run(compiled, catalog.as_ref()).unwrap();
let metadata = persistence_metadata(&request, fixtures::RUN_2, &storage_root);
let input = assemble_create_run_persistence_input(materialized, metadata);
let store = memory_store();
let created = persist_create_run(store.as_ref(), input).await.unwrap();
assert_eq!(created.run_id, fixtures::RUN_2);
assert_eq!(created.dot_path.as_deref(), Some(dot_path.as_path()));
assert_eq!(created.persisted.graph().goal(), "Compiled goal");
assert_eq!(created.persisted.source(), compiled_source);
let run_store = store.open_run_reader(&fixtures::RUN_2).await.unwrap();
let state = run_store.state().await.unwrap();
assert_eq!(state.spec.graph.goal(), "Compiled goal");
assert_eq!(state.spec.automation, Some(automation));
let events = run_store.list_events().await.unwrap();
assert_eq!(
events
.iter()
.map(|event| event.event.event_name())
.collect::<Vec<_>>(),
vec!["run.created", "run.submitted"]
);
let EventBody::RunCreated(created) = &events[0].event.body else {
panic!("first durable event should be run.created");
};
assert_eq!(
created.workflow_source.as_deref(),
Some(compiled_source.as_str())
);
assert_eq!(
created.workflow_config.as_deref(),
Some("_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n")
);
let manifest_blob = created
.manifest_blob
.as_ref()
.expect("submitted manifest should be persisted");
assert_eq!(
run_store
.read_blob(manifest_blob)
.await
.unwrap()
.expect("submitted manifest blob should exist")
.as_ref(),
b"submitted manifest"
);
}
#[tokio::test]
async fn create_returns_validation_failed_with_diagnostics() {
let dot = r#"digraph Test {

View file

@ -14,7 +14,12 @@ pub use archive::{
ArchiveOutcome, UnarchiveOutcome, archive, archived_rejection_message, ensure_not_archived,
unarchive,
};
pub use create::{CreateRunInput, CreatedRun, create, make_run_dir};
pub use create::{
CompiledRun, CreateRunCompileInput, CreateRunInput, CreateRunPersistenceInput,
CreateRunPersistenceMetadata, CreatedRun, MaterializedRun,
assemble_create_run_persistence_input, compile_create_run, create, make_run_dir,
materialize_create_run, persist_create_run,
};
pub use fork::{ForkOutcome, ForkRunInput, ResolvedForkTarget, fork_run};
pub use resume::resume;
pub use retry::{RetryOutcome, RetryRunInput, retry_run};

View file

@ -0,0 +1,12 @@
use fabro_api::types;
use serde_json::json;
#[test]
fn removed_docker_image_manifest_arg_is_ignored() {
let args: types::ManifestArgs = serde_json::from_value(json!({
"docker_image": "ghcr.io/fabro/custom:latest"
}))
.unwrap();
assert_eq!(serde_json::to_value(args).unwrap(), json!({}));
}

View file

@ -3028,6 +3028,148 @@ enabled = true
);
}
#[test]
fn builtin_deepseek_provider_routes_v4_models() {
let deepseek = ProviderId::new("deepseek");
let catalog = Catalog::builtin();
let provider = catalog
.provider(&deepseek)
.expect("DeepSeek provider should be active");
assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible);
assert_eq!(provider.codec, CodecKind::OpenAiCompatible);
assert_eq!(provider.billing_policy, BillingPolicy::OpenAi);
assert_eq!(
provider.base_url.as_deref(),
Some("https://api.deepseek.com")
);
assert_eq!(provider.priority, 75);
assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![
CredentialRef::Env("DEEPSEEK_API_KEY".to_string()),
CredentialRef::Vault("DEEPSEEK_API_KEY".to_string()),
]);
assert_eq!(
catalog
.default_for_provider(&deepseek)
.map(|model| model.id.as_str()),
Some("deepseek-v4-flash")
);
assert_eq!(
catalog
.small_default_for_provider(&deepseek)
.map(|model| model.id.as_str()),
Some("deepseek-v4-flash")
);
assert_eq!(
catalog
.probe_for_provider(&deepseek)
.map(|model| model.id.as_str()),
Some("deepseek-v4-flash")
);
let expected = [
("deepseek-v4-flash", 0.14, 0.28, 0.0028),
("deepseek-v4-pro", 0.435, 0.87, 0.003_625),
];
for (id, input, output, cache_read) in expected {
let model = catalog
.get_on_provider(&deepseek, id)
.unwrap_or_else(|| panic!("DeepSeek model '{id}' should be present"));
assert_eq!(model.family, "deepseek-v4", "{id}");
assert_eq!(model.limits.context_window, 1_048_576, "{id}");
assert_eq!(model.limits.max_output, Some(384_000), "{id}");
assert!(model.features.tools, "{id}");
assert!(!model.features.vision, "{id}");
assert!(model.features.reasoning, "{id}");
assert!(model.features.prompt_cache, "{id}");
assert!(!model.features.sampling_params, "{id}");
assert_eq!(model.costs.input_cost_per_mtok, Some(input), "{id}");
assert_eq!(model.costs.output_cost_per_mtok, Some(output), "{id}");
assert_eq!(
model.costs.cache_input_cost_per_mtok,
Some(cache_read),
"{id}"
);
let settings = catalog
.model_settings_on_provider(&deepseek, id)
.unwrap_or_else(|| panic!("DeepSeek settings for '{id}' should be present"));
assert_eq!(settings.api_id, id, "{id}");
assert!(settings.reasoning_by_default, "{id}");
}
}
#[test]
fn builtin_deepseek_reasoning_controls_match_provider_dialects() {
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
r"
[providers.fireworks]
enabled = true
[providers.openrouter]
enabled = true
",
))
.expect("DeepSeek gateway providers should build when enabled");
let expected = [
(ProviderId::new("deepseek"), "deepseek-v4-flash", vec![
ReasoningEffort::Low,
ReasoningEffort::High,
ReasoningEffort::Max,
]),
(ProviderId::new("deepseek"), "deepseek-v4-pro", vec![
ReasoningEffort::High,
ReasoningEffort::Max,
]),
(ProviderId::new("fireworks"), "deepseek-v4-flash", vec![
ReasoningEffort::High,
ReasoningEffort::Max,
]),
(ProviderId::new("fireworks"), "deepseek-v4-pro", vec![
ReasoningEffort::High,
ReasoningEffort::Max,
]),
(ProviderId::new("openrouter"), "deepseek-v4-flash", vec![
ReasoningEffort::Low,
ReasoningEffort::High,
ReasoningEffort::Max,
]),
(ProviderId::new("openrouter"), "deepseek-v4-pro", vec![
ReasoningEffort::High,
ReasoningEffort::XHigh,
]),
];
for (provider, id, efforts) in expected {
let model = catalog
.get_on_provider(&provider, id)
.unwrap_or_else(|| panic!("{provider}/{id} should be present"));
assert!(model.features.reasoning, "{provider}/{id}");
assert_eq!(
model.features.reasoning_effort,
ReasoningEffortFeature::Levels,
"{provider}/{id}"
);
assert_eq!(model.controls.reasoning_effort, efforts, "{provider}/{id}");
assert!(!model.features.sampling_params, "{provider}/{id}");
let settings = catalog
.model_settings_on_provider(&provider, id)
.unwrap_or_else(|| panic!("{provider}/{id} settings should be present"));
assert_eq!(
settings.agent_profile,
AgentProfileKind::OpenAi,
"{provider}/{id}"
);
assert!(settings.reasoning_by_default, "{provider}/{id}");
assert_eq!(
settings.controls.reasoning_effort, efforts,
"{provider}/{id}"
);
}
}
#[test]
fn builtin_openrouter_provider_is_opt_in() {
let openrouter = ProviderId::new("openrouter");
@ -3071,6 +3213,27 @@ enabled = true
.billing_policy,
BillingPolicy::OpenAi
);
let deepseek = catalog
.get_on_provider(&openrouter, "deepseek-v4-flash")
.expect("DeepSeek V4 Flash should be present on OpenRouter");
assert_eq!(deepseek.limits.max_output, Some(384_000));
assert!(deepseek.features.prompt_cache);
assert_eq!(deepseek.costs.input_cost_per_mtok, Some(0.14));
assert_eq!(deepseek.costs.output_cost_per_mtok, Some(0.28));
assert_eq!(deepseek.costs.cache_input_cost_per_mtok, Some(0.0028));
assert_eq!(
catalog.settings_for(deepseek).unwrap().api_id,
"deepseek/deepseek-v4-flash-0731"
);
let deepseek_pro = catalog
.get_on_provider(&openrouter, "deepseek-v4-pro")
.expect("DeepSeek V4 Pro should be present on OpenRouter");
assert_eq!(deepseek_pro.limits.max_output, Some(384_000));
assert!(deepseek_pro.features.prompt_cache);
assert_eq!(
deepseek_pro.costs.cache_input_cost_per_mtok,
Some(0.003_625)
);
assert_eq!(
catalog
.default_for_provider(&openrouter)
@ -3316,8 +3479,8 @@ enabled = true
for (selector, canonical_id) in [
("deepseek-v4-pro", "deepseek-v4-pro"),
("deepseek-v4", "deepseek-v4-pro"),
("deepseek", "deepseek-v4-pro"),
("deepseek-v4", "deepseek-v4-flash"),
("deepseek", "deepseek-v4-flash"),
("deepseek-v4-flash", "deepseek-v4-flash"),
("deepseek-flash", "deepseek-v4-flash"),
] {
@ -3906,9 +4069,9 @@ enabled = true
"accounts/fireworks/models/deepseek-v4-flash",
"deepseek-v4",
1_048_576,
16_384,
false,
384_000,
false,
true,
0.14,
0.28,
0.028,
@ -4028,7 +4191,7 @@ enabled = true
}
#[test]
fn builtin_fireworks_shared_slugs_are_portable_with_openrouter() {
fn builtin_deepseek_shared_slugs_are_portable_across_providers() {
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
r"
[providers.fireworks]
@ -4055,6 +4218,43 @@ enabled = true
assert_eq!(model.provider, provider, "{provider}/{id}");
}
}
for provider in [
ProviderId::new("deepseek"),
ProviderId::new("fireworks"),
ProviderId::new("openrouter"),
] {
for id in ["deepseek-v4-pro", "deepseek-v4-flash"] {
let model = catalog
.get_on_provider(&provider, id)
.unwrap_or_else(|| panic!("'{id}' should resolve on provider '{provider}'"));
assert_eq!(model.id, id, "{provider}/{id}");
assert_eq!(model.provider, provider, "{provider}/{id}");
assert_eq!(model.limits.context_window, 1_048_576, "{provider}/{id}");
}
for alias in ["deepseek", "deepseek-v4", "deepseek-flash"] {
let model = catalog
.resolve_on_provider(&provider, alias)
.unwrap_or_else(|error| {
panic!("'{alias}' should resolve on provider '{provider}': {error}")
});
assert_eq!(model.id, "deepseek-v4-flash", "{provider}/{alias}");
assert_eq!(model.provider, provider, "{provider}/{alias}");
}
}
let selected = catalog
.select(
"deepseek",
None,
&HashSet::from([
ProviderId::new("deepseek"),
ProviderId::new("fireworks"),
ProviderId::new("openrouter"),
]),
)
.expect("direct DeepSeek should win portable DeepSeek selection");
assert_eq!(selected.provider, ProviderId::new("deepseek"));
}
#[test]

View file

@ -0,0 +1,74 @@
[providers.deepseek]
display_name = "DeepSeek"
adapter = "openai_compatible"
api_key_url = "https://platform.deepseek.com/api_keys"
base_url = "https://api.deepseek.com"
priority = 75
[providers.deepseek.auth]
credentials = ["env:DEEPSEEK_API_KEY", "vault:DEEPSEEK_API_KEY"]
# DeepSeek V4 uses thinking mode by default. The API accepts sampling
# parameters in that mode but ignores them, so Fabro omits those parameters.
# Prompt caching is automatic and usage reports prompt_cache_hit_tokens.
# Prices are from api-docs.deepseek.com/quick_start/pricing, verified
# 2026-07-31.
[providers.deepseek.models."deepseek-v4-flash"]
display_name = "DeepSeek V4 Flash"
family = "deepseek-v4"
aliases = ["deepseek-v4", "deepseek", "deepseek-flash"]
# DeepSeek uses a general coding prompt and standard JSON function tools.
agent_profile = "openai"
default = true
small_default = true
probe = true
[providers.deepseek.models."deepseek-v4-flash".limits]
context_window = 1048576
max_output = 384000
[providers.deepseek.models."deepseek-v4-flash".features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
reasoning_by_default = true
prompt_cache = true
sampling_params = false
[providers.deepseek.models."deepseek-v4-flash".controls]
reasoning_effort = ["low", "high", "max"]
[providers.deepseek.models."deepseek-v4-flash".costs]
input_cost_per_mtok = 0.14
output_cost_per_mtok = 0.28
cache_input_cost_per_mtok = 0.0028
[providers.deepseek.models."deepseek-v4-pro"]
display_name = "DeepSeek V4 Pro"
family = "deepseek-v4"
agent_profile = "openai"
[providers.deepseek.models."deepseek-v4-pro".limits]
context_window = 1048576
max_output = 384000
[providers.deepseek.models."deepseek-v4-pro".features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
reasoning_by_default = true
prompt_cache = true
sampling_params = false
[providers.deepseek.models."deepseek-v4-pro".controls]
# V4 Pro currently maps low to high. Keep only its distinct effort levels;
# DeepSeek says it plans to change Pro's mapping in early August 2026.
reasoning_effort = ["high", "max"]
[providers.deepseek.models."deepseek-v4-pro".costs]
input_cost_per_mtok = 0.435
output_cost_per_mtok = 0.87
cache_input_cost_per_mtok = 0.003625

View file

@ -25,7 +25,7 @@ credentials = ["env:FIREWORKS_API_KEY", "vault:FIREWORKS_API_KEY"]
# Prompt caching is automatic prefix caching (no cache_control breakpoints);
# serverless responses report prompt_tokens_details.cached_tokens in the
# usage body. Costs below are from docs.fireworks.ai/serverless/pricing
# (standard tier), verified 2026-07-24.
# (standard tier), verified 2026-07-31.
[providers.fireworks.models."kimi-k2.7-code"]
api_id = "accounts/fireworks/models/kimi-k2p7-code"
@ -72,6 +72,7 @@ cache_input_cost_per_mtok = 0.16
api_id = "accounts/fireworks/models/deepseek-v4-pro"
display_name = "DeepSeek V4 Pro"
family = "deepseek-v4"
agent_profile = "openai"
[providers.fireworks.models."deepseek-v4-pro".limits]
context_window = 1048576
@ -81,7 +82,14 @@ max_output = 16384
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
reasoning_by_default = true
prompt_cache = true
sampling_params = false
[providers.fireworks.models."deepseek-v4-pro".controls]
# Fireworks promotes low/medium to high and xhigh to max for DeepSeek V4.
reasoning_effort = ["high", "max"]
[providers.fireworks.models."deepseek-v4-pro".costs]
input_cost_per_mtok = 1.74
@ -92,16 +100,24 @@ cache_input_cost_per_mtok = 0.145
api_id = "accounts/fireworks/models/deepseek-v4-flash"
display_name = "DeepSeek V4 Flash"
family = "deepseek-v4"
aliases = ["deepseek-v4", "deepseek", "deepseek-flash"]
agent_profile = "openai"
[providers.fireworks.models."deepseek-v4-flash".limits]
context_window = 1048576
max_output = 16384
max_output = 384000
[providers.fireworks.models."deepseek-v4-flash".features]
tools = true
vision = false
reasoning = false
reasoning = true
reasoning_effort = "levels"
reasoning_by_default = true
prompt_cache = true
sampling_params = false
[providers.fireworks.models."deepseek-v4-flash".controls]
reasoning_effort = ["high", "max"]
[providers.fireworks.models."deepseek-v4-flash".costs]
input_cost_per_mtok = 0.14

View file

@ -416,39 +416,57 @@ output_cost_per_mtok = 1.20
api_id = "deepseek/deepseek-v4-pro"
display_name = "DeepSeek V4 Pro"
family = "deepseek-v4"
aliases = ["deepseek-v4", "deepseek"]
agent_profile = "openai"
[providers.openrouter.models."deepseek-v4-pro".limits]
context_window = 1050000
max_output = 16384
context_window = 1048576
max_output = 384000
[providers.openrouter.models."deepseek-v4-pro".features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
reasoning_by_default = true
prompt_cache = true
sampling_params = false
[providers.openrouter.models."deepseek-v4-pro".controls]
# OpenRouter names DeepSeek's max tier xhigh on this route.
reasoning_effort = ["high", "xhigh"]
[providers.openrouter.models."deepseek-v4-pro".costs]
input_cost_per_mtok = 0.435
output_cost_per_mtok = 0.87
cache_input_cost_per_mtok = 0.003625
[providers.openrouter.models."deepseek-v4-flash"]
api_id = "deepseek/deepseek-v4-flash"
api_id = "deepseek/deepseek-v4-flash-0731"
display_name = "DeepSeek V4 Flash"
family = "deepseek-v4"
aliases = ["deepseek-flash"]
aliases = ["deepseek-v4", "deepseek", "deepseek-flash"]
agent_profile = "openai"
[providers.openrouter.models."deepseek-v4-flash".limits]
context_window = 1050000
max_output = 16384
context_window = 1048576
max_output = 384000
[providers.openrouter.models."deepseek-v4-flash".features]
tools = true
vision = false
reasoning = false
reasoning = true
reasoning_effort = "levels"
reasoning_by_default = true
prompt_cache = true
sampling_params = false
[providers.openrouter.models."deepseek-v4-flash".controls]
reasoning_effort = ["low", "high", "max"]
[providers.openrouter.models."deepseek-v4-flash".costs]
input_cost_per_mtok = 0.10
output_cost_per_mtok = 0.20
input_cost_per_mtok = 0.14
output_cost_per_mtok = 0.28
cache_input_cost_per_mtok = 0.0028
[providers.openrouter.models."kimi-k2.6"]
api_id = "moonshotai/kimi-k2.6"

View file

@ -48,6 +48,7 @@ impl EnvVars {
pub const BEDROCK_API_KEY: &'static str = "BEDROCK_API_KEY";
pub const BRAVE_SEARCH_API_KEY: &'static str = "BRAVE_SEARCH_API_KEY";
pub const CHATGPT_ACCOUNT_ID: &'static str = "CHATGPT_ACCOUNT_ID";
pub const DEEPSEEK_API_KEY: &'static str = "DEEPSEEK_API_KEY";
pub const FIREWORKS_API_KEY: &'static str = "FIREWORKS_API_KEY";
pub const GEMINI_API_KEY: &'static str = "GEMINI_API_KEY";
pub const GEMINI_BASE_URL: &'static str = "GEMINI_BASE_URL";
@ -199,6 +200,7 @@ mod tests {
EnvVars::BEDROCK_API_KEY,
EnvVars::BRAVE_SEARCH_API_KEY,
EnvVars::CHATGPT_ACCOUNT_ID,
EnvVars::DEEPSEEK_API_KEY,
EnvVars::FIREWORKS_API_KEY,
EnvVars::GEMINI_API_KEY,
EnvVars::GEMINI_BASE_URL,

View file

@ -19,6 +19,7 @@ const OPTIONAL_VAULT_SECRETS: &[&str] = &[
EnvVars::AWS_BEARER_TOKEN_BEDROCK,
EnvVars::BEDROCK_API_KEY,
EnvVars::BRAVE_SEARCH_API_KEY,
EnvVars::DEEPSEEK_API_KEY,
EnvVars::FABRO_SLACK_APP_TOKEN,
EnvVars::FABRO_SLACK_BOT_TOKEN,
EnvVars::FIREWORKS_API_KEY,
@ -93,6 +94,7 @@ mod tests {
EnvVars::ANTHROPIC_API_KEY,
EnvVars::AWS_BEARER_TOKEN_BEDROCK,
EnvVars::BEDROCK_API_KEY,
EnvVars::DEEPSEEK_API_KEY,
EnvVars::FIREWORKS_API_KEY,
EnvVars::GEMINI_API_KEY,
EnvVars::INCEPTION_API_KEY,

View file

@ -24,10 +24,6 @@ export interface ManifestArgs {
* Named environment slug to select for the run.
*/
'environment'?: string;
/**
* Per-run environment image override.
*/
'docker_image'?: string;
'verbose'?: boolean;
'dry_run'?: boolean;
'auto_approve'?: boolean;