Merge origin/main into fix/expose-agent-output-schema
One conflict, in StructuredOutputError::repair_message. main (#709) added a `previous_error` parameter and richer validation-error rendering; this branch had replaced the inline expectation match with OutputSchemaKind::expectation(). Resolved by keeping both: main's new signature and section assembly, calling schema.expectation() for the expectation text. The method already supersedes main's inline match and carries this branch's intent of embedding the resolved JSON Schema instead of naming it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
@ -1,10 +1,11 @@
|
|||
ANTHROPIC_API_KEY=
|
||||
BRAVE_SEARCH_API_KEY=
|
||||
DAYTONA_API_KEY=
|
||||
DEEPSEEK_API_KEY=
|
||||
FIREWORKS_API_KEY=
|
||||
GEMINI_API_KEY=
|
||||
INCEPTION_API_KEY=
|
||||
KIMI_API_KEY=
|
||||
MOONSHOT_API_KEY=
|
||||
MINIMAX_API_KEY=
|
||||
MODAL_KIMI_K3_BASE_URL=
|
||||
MODAL_TOKEN_ID=
|
||||
|
|
|
|||
127
Cargo.lock
generated
|
|
@ -338,6 +338,7 @@ checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
|||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"futures-io",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
|
@ -402,6 +403,21 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async_zip"
|
||||
version = "0.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"crc32fast",
|
||||
"futures-lite",
|
||||
"pin-project",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atoi"
|
||||
version = "2.0.0"
|
||||
|
|
@ -1854,7 +1870,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 +1884,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 +1904,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 +2255,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 +2274,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2304,7 +2320,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-automation",
|
||||
|
|
@ -2327,7 +2343,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2352,7 +2368,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-automation"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2371,11 +2387,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 +2407,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2493,7 +2509,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2522,7 +2538,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2552,7 +2568,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 +2583,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-db"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2579,7 +2595,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2598,7 +2614,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2612,7 +2628,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-environment"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2634,7 +2650,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2656,7 +2672,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2671,7 +2687,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 +2710,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 +2720,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2723,7 +2739,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -2738,7 +2754,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2766,6 +2782,7 @@ dependencies = [
|
|||
"rand 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"strum 0.28.0",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
@ -2779,7 +2796,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 +2807,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
|
|
@ -2808,7 +2825,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2828,7 +2845,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2851,11 +2868,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 +2891,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 +2907,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2911,7 +2929,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 +2937,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2928,7 +2946,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 +2962,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2988,10 +3006,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"async_zip",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"base64",
|
||||
|
|
@ -3081,7 +3100,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 +3122,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 +3163,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -3170,7 +3189,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -3184,7 +3203,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3209,7 +3228,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3230,7 +3249,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3244,7 +3263,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -3267,7 +3286,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 +3309,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 +3322,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-variable"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3320,7 +3339,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3339,7 +3358,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -8504,7 +8523,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -8523,7 +8542,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.311.0-nightly.0"
|
||||
version = "0.312.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -36,6 +36,7 @@ dotenvy = "0.15"
|
|||
futures = "0.3"
|
||||
tokio-stream = "0.1"
|
||||
async-trait = "0.1"
|
||||
async_zip = { version = "0.0.18", features = ["tokio", "deflate"] }
|
||||
fs2 = "0.4"
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
|
|
@ -96,8 +97,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"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
import TestRenderer, { act } from "react-test-renderer";
|
||||
|
||||
import { setupReactTestEnv } from "../lib/test-utils";
|
||||
import { DockComposer } from "./run-dock";
|
||||
import { DockComposer, RunDockShell } from "./run-dock";
|
||||
|
||||
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
||||
let teardownReactEnv: (() => void) | undefined;
|
||||
|
|
@ -26,6 +26,69 @@ afterEach(() => {
|
|||
teardownReactEnv = undefined;
|
||||
});
|
||||
|
||||
describe("RunDockShell", () => {
|
||||
function mountShell(
|
||||
collapsed: boolean,
|
||||
onCollapsedChange: (collapsed: boolean) => void,
|
||||
) {
|
||||
let renderer!: TestRenderer.ReactTestRenderer;
|
||||
act(() => {
|
||||
renderer = TestRenderer.create(
|
||||
<RunDockShell
|
||||
label="Steer running agent"
|
||||
tone="idle"
|
||||
status="Steering"
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={onCollapsedChange}
|
||||
actions={<div />}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
mountedRenderers.push(renderer);
|
||||
return renderer;
|
||||
}
|
||||
|
||||
function clickableDivs(renderer: TestRenderer.ReactTestRenderer) {
|
||||
return renderer.root.findAll(
|
||||
(node) => node.type === "div" && node.props.onClick !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
test("the whole collapsed bar is a click target that expands it", () => {
|
||||
const onCollapsedChange = mock((_collapsed: boolean) => undefined);
|
||||
const renderer = mountShell(true, onCollapsedChange);
|
||||
|
||||
const [header] = clickableDivs(renderer);
|
||||
expect(header).toBeDefined();
|
||||
act(() => header.props.onClick({ target: { closest: () => null } }));
|
||||
expect(onCollapsedChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("clicks on header buttons do not also expand the bar", () => {
|
||||
const onCollapsedChange = mock((_collapsed: boolean) => undefined);
|
||||
const renderer = mountShell(true, onCollapsedChange);
|
||||
|
||||
const [header] = clickableDivs(renderer);
|
||||
act(() => header.props.onClick({ target: { closest: () => ({}) } }));
|
||||
expect(onCollapsedChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("clicks with a non-element target still expand the bar", () => {
|
||||
const onCollapsedChange = mock((_collapsed: boolean) => undefined);
|
||||
const renderer = mountShell(true, onCollapsedChange);
|
||||
|
||||
const [header] = clickableDivs(renderer);
|
||||
act(() => header.props.onClick({ target: {} }));
|
||||
expect(onCollapsedChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("the expanded header is not a click target", () => {
|
||||
const onCollapsedChange = mock((_collapsed: boolean) => undefined);
|
||||
const renderer = mountShell(false, onCollapsedChange);
|
||||
expect(clickableDivs(renderer)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DockComposer", () => {
|
||||
test("describes its keyboard behavior to assistive technology", () => {
|
||||
let renderer!: TestRenderer.ReactTestRenderer;
|
||||
|
|
|
|||
|
|
@ -99,7 +99,25 @@ export function RunDockShell({
|
|||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2.5 px-5 py-2 sm:px-6">
|
||||
{/* While collapsed, the whole bar expands on click. Clicks that land on
|
||||
a button (Interrupt, the chevron) keep their own behavior. The
|
||||
chevron button stays the keyboard/assistive-tech toggle. */}
|
||||
<div
|
||||
className={classNames(
|
||||
"flex shrink-0 items-center gap-2.5 px-5 py-2 sm:px-6",
|
||||
collapsed && "cursor-pointer",
|
||||
)}
|
||||
onClick={
|
||||
collapsed
|
||||
? (event) => {
|
||||
if ((event.target as HTMLElement | null)?.closest?.("button")) {
|
||||
return;
|
||||
}
|
||||
onCollapsedChange(false);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<StatusDot tone={tone} />
|
||||
{/* A live region: the dock changing to a state that needs the
|
||||
operator has to reach assistive tech, not only the eye. */}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -98,11 +98,7 @@ describe("SteerBar", () => {
|
|||
});
|
||||
mountedRenderers.push(renderer);
|
||||
|
||||
act(() => {
|
||||
renderer.root
|
||||
.findByProps({ "aria-label": "Collapse Steer running agent" })
|
||||
.props.onClick();
|
||||
});
|
||||
// The dock starts collapsed by default.
|
||||
expect(
|
||||
renderer.root.findByProps({
|
||||
"aria-label": "Expand Steer running agent",
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ export function SteerBar({
|
|||
ref,
|
||||
}: SteerBarProps) {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [collapsePreferred, setCollapsePreferred] = useState(false);
|
||||
// Steering is an occasional control, so the dock starts collapsed and
|
||||
// stays out of the way until the operator opens it.
|
||||
const [collapsePreferred, setCollapsePreferred] = useState(true);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const steer = useSteerRun(runId);
|
||||
const interrupt = useInterruptRun(runId);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
extractRequestId,
|
||||
fetchAllPages,
|
||||
generatedAxios,
|
||||
runArtifactsDownloadUrl,
|
||||
stageArtifactDownloadUrl,
|
||||
} from "./api-client";
|
||||
|
||||
|
|
@ -146,6 +147,14 @@ describe("stageArtifactDownloadUrl", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("runArtifactsDownloadUrl", () => {
|
||||
test("builds the escaped ZIP download href", () => {
|
||||
expect(runArtifactsDownloadUrl("run 1")).toBe(
|
||||
"/api/v1/runs/run%201/artifacts/download",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractRequestId", () => {
|
||||
test("supports top-level, error-level, and detail-embedded request ids", () => {
|
||||
expect(extractRequestId({ request_id: "top" })).toBe("top");
|
||||
|
|
|
|||
|
|
@ -408,6 +408,13 @@ export function requestSignalOptions(request?: Request): RawAxiosRequestConfig {
|
|||
return request?.signal ? { signal: request.signal } : {};
|
||||
}
|
||||
|
||||
/** Absolute href for a path under a run, for links the browser follows itself. */
|
||||
function runApiPath(id: string, suffix: string): string {
|
||||
return `${generatedApiConfiguration.basePath ?? ""}/api/v1/runs/${
|
||||
encodeURIComponent(id)
|
||||
}${suffix}`;
|
||||
}
|
||||
|
||||
export function stageArtifactDownloadUrl(
|
||||
id: string,
|
||||
stageId: string,
|
||||
|
|
@ -418,7 +425,12 @@ export function stageArtifactDownloadUrl(
|
|||
filename,
|
||||
retry: String(retry),
|
||||
});
|
||||
return `${generatedApiConfiguration.basePath ?? ""}/api/v1/runs/${
|
||||
encodeURIComponent(id)
|
||||
}/stages/${encodeURIComponent(stageId)}/artifacts/download?${searchParams}`;
|
||||
return runApiPath(
|
||||
id,
|
||||
`/stages/${encodeURIComponent(stageId)}/artifacts/download?${searchParams}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function runArtifactsDownloadUrl(id: string): string {
|
||||
return runApiPath(id, "/artifacts/download");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import type { RunArtifactEntry } from "@qltysh/fabro-api-client";
|
|||
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/state";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import { stageArtifactDownloadUrl } from "../lib/api-client";
|
||||
import {
|
||||
runArtifactsDownloadUrl,
|
||||
stageArtifactDownloadUrl,
|
||||
} from "../lib/api-client";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { plural } from "../lib/plural";
|
||||
import { useRunArtifacts, useRunStages } from "../lib/queries";
|
||||
|
|
@ -117,7 +120,7 @@ function ArtifactList({ runId, files }: { runId: string; files: readonly Artifac
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-medium text-fg">
|
||||
{files.length} {plural(files.length, "file", "files")}
|
||||
{versioned && (
|
||||
|
|
@ -127,11 +130,21 @@ function ArtifactList({ runId, files }: { runId: string; files: readonly Artifac
|
|||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<span className="text-xs text-fg-muted tabular-nums">
|
||||
{versioned
|
||||
? `${formatBytes(latestBytes)} latest · ${formatBytes(storedBytes)} stored`
|
||||
: `${formatBytes(latestBytes)} total`}
|
||||
</span>
|
||||
<div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3">
|
||||
<span className="text-xs text-fg-muted tabular-nums">
|
||||
{versioned
|
||||
? `${formatBytes(latestBytes)} latest · ${formatBytes(storedBytes)} stored`
|
||||
: `${formatBytes(latestBytes)} total`}
|
||||
</span>
|
||||
<a
|
||||
href={runArtifactsDownloadUrl(runId)}
|
||||
aria-label={`Download all ${files.length} ${plural(files.length, "artifact", "artifacts")} as a ZIP file`}
|
||||
className="inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-md bg-overlay px-2.5 py-1 text-xs font-medium text-fg-2 outline-1 -outline-offset-1 outline-line-strong hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 sm:min-h-8"
|
||||
>
|
||||
<ArrowDownTrayIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
Download all
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="overflow-hidden rounded-md border border-line bg-panel-alt">
|
||||
|
|
|
|||
3
apps/fabro-web/public/images/providers/deepseek.svg
Normal 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 |
|
|
@ -1,3 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M121.683 75.25L149.997 124L91.4816 224.75C90.3128 226.757 88.155 228 85.8174 228H32.9664C31.7976 228 30.6778 227.691 29.697 227.131C28.7161 226.57 27.8906 225.758 27.3021 224.75L0.876625 179.25C-0.292208 177.243 -0.292208 174.765 0.876625 172.75L57.512 75.25C58.0923 74.2425 58.9259 73.43 59.9068 72.8694C60.8876 72.3088 62.0074 72 63.1762 72H116.027C118.365 72 120.523 73.2431 121.692 75.25H121.683ZM299.125 172.75L242.49 75.25C241.91 74.2425 241.076 73.43 240.095 72.8694C239.114 72.3088 237.995 72 236.826 72H183.975C181.637 72 179.479 73.2431 178.311 75.25L149.997 124L208.512 224.75C209.681 226.757 211.839 228 214.177 228H267.027C268.196 228 269.316 227.691 270.297 227.131C271.278 226.57 272.103 225.758 272.692 224.75L299.117 179.25C300.286 177.243 300.286 174.765 299.117 172.75H299.125Z" fill="#62DE61"/>
|
||||
<path d="M121.683 75.25L149.997 124L91.4816 224.75C90.3128 226.757 88.155 228 85.8174 228H32.9664C31.7976 228 30.6778 227.691 29.697 227.131C28.7161 226.57 27.8906 225.758 27.3021 224.75L0.876625 179.25C-0.292208 177.243 -0.292208 174.765 0.876625 172.75L57.512 75.25C58.0923 74.2425 58.9259 73.43 59.9068 72.8694C60.8876 72.3088 62.0074 72 63.1762 72H116.027C118.365 72 120.523 73.2431 121.692 75.25H121.683ZM299.125 172.75L242.49 75.25C241.91 74.2425 241.076 73.43 240.095 72.8694C239.114 72.3088 237.995 72 236.826 72H183.975C181.637 72 179.479 73.2431 178.311 75.25L149.997 124L208.512 224.75C209.681 226.757 211.839 228 214.177 228H267.027C268.196 228 269.316 227.691 270.297 227.131C271.278 226.57 272.103 225.758 272.692 224.75L299.117 179.25C300.286 177.243 300.286 174.765 299.117 172.75H299.125Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 922 B |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
|
@ -1,6 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor">
|
||||
<path d="M3 248.945C18 248.945 76 236 106 219C136 202 136 202 198 158C276.497 102.293 332 120.945 423 120.945" stroke-width="90"/>
|
||||
<path d="M511 121.5L357.25 210.268L357.25 32.7324L511 121.5Z"/>
|
||||
<path d="M0 249C15 249 73 261.945 103 278.945C133 295.945 133 295.945 195 339.945C273.497 395.652 329 377 420 377" stroke-width="90"/>
|
||||
<path d="M508 376.445L354.25 287.678L354.25 465.213L508 376.445Z"/>
|
||||
<svg width="24" height="24" viewBox="0 0 401.4 293.7" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M303.9475,17.19926c42.79734,0,77.48933,34.69327,77.48933,77.48933s-34.69199,77.48933-77.48933,77.48933l76.86166,76.86244c9.76367,9.76313,2.84903,26.45667-10.95697,26.45667h-220.88335c-71.32686,0-129.14889-57.82202-129.14889-129.14889S77.64197,17.19926,148.96884,17.19926h154.97866ZM148.96884,68.85881c-42.79607,0-77.48933,34.69327-77.48933,77.48933s34.69327,77.48933,77.48933,77.48933,77.48933-34.69327,77.48933-77.48933-34.69327-77.48933-77.48933-77.48933Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 533 B After Width: | Height: | Size: 587 B |
|
|
@ -424,7 +424,7 @@ Emitted when a workflow node finishes execution.
|
|||
| `failure_signature` | string? | Dedup key for repeated failures |
|
||||
| `context_updates` | object? | Context delta written by this stage |
|
||||
| `jump_to_node` | string? | Non-edge jump target |
|
||||
| `context_values` | object? | Full context snapshot after the stage |
|
||||
| `context_values` | object? | Context snapshot after the stage, minus runtime-only keys such as `current.preamble`. Artifact pointers are not normalized to blob refs — use `checkpoint.completed` for the durable projection |
|
||||
| `node_visits` | object? | Node visit counts after the stage |
|
||||
| `loop_failure_signatures` | object? | Loop failure signature counts |
|
||||
| `restart_failure_signatures` | object? | Restart failure signature counts |
|
||||
|
|
|
|||
|
|
@ -383,11 +383,12 @@ fabro secret set GEMINI_API_KEY AI...
|
|||
| `ANTHROPIC_API_KEY` | Anthropic (Claude) |
|
||||
| `OPENAI_API_KEY` | OpenAI (GPT) |
|
||||
| `GEMINI_API_KEY` | Google (Gemini) |
|
||||
| `KIMI_API_KEY` | Kimi |
|
||||
| `MOONSHOT_API_KEY` or `KIMI_API_KEY` | Moonshot AI; `MOONSHOT_API_KEY` takes precedence |
|
||||
| `ZAI_API_KEY` | Zai (GLM) |
|
||||
| `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) |
|
||||
|
|
|
|||
|
|
@ -3282,6 +3282,53 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/artifacts/download:
|
||||
get:
|
||||
operationId: downloadRunArtifacts
|
||||
tags: [Run Internals]
|
||||
summary: Download Run Artifacts
|
||||
description: |
|
||||
Streams a ZIP archive with the latest captured version of each artifact path.
|
||||
Stage order, retry number, and then stage ID determine the latest version, matching the artifacts page.
|
||||
Captures from the graph's boundary nodes are excluded, identified by their `start` and `exit` handler type rather than by node name.
|
||||
|
||||
The archive streams, so the response status is sent before the first artifact is read.
|
||||
A failure after that point aborts the transfer rather than returning `500`.
|
||||
The ZIP central directory is written last, so a truncated download does not open as a valid archive.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
responses:
|
||||
"200":
|
||||
description: ZIP archive containing the latest artifact files
|
||||
headers:
|
||||
Content-Disposition:
|
||||
description: Attachment filename for the ZIP archive
|
||||
schema:
|
||||
type: string
|
||||
content:
|
||||
application/zip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"404":
|
||||
description: Run not found
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Artifact archive could not be prepared
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/files:
|
||||
get:
|
||||
operationId: listRunFiles
|
||||
|
|
@ -9184,9 +9231,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:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Previously, you could only see the final output of a run. Now you can trace exac
|
|||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Kimi, Z.AI, and Minimax LLM providers added to model catalog
|
||||
- Moonshot AI, Z.AI, and Minimax LLM providers added to model catalog
|
||||
- Runs use time-sortable ULIDs instead of UUIDs, making it easier to find recent runs in logs and the API
|
||||
- Asymmetric JWT authentication between fabro-web and the API server, with GitHub identity in the token
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ Schema upgrades now create a consistent `fabro.sqlite3.pre-migration.bak` snapsh
|
|||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Added Kimi K3 through the direct Kimi provider and OpenRouter
|
||||
- Added Kimi K3 through the direct Moonshot AI provider and OpenRouter
|
||||
- Added GLM 5.2 through Z.AI and OpenRouter
|
||||
- Left-to-right run graphs now zoom to 400% and preserve separate viewport positions for left-to-right and top-to-bottom layouts
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -57,15 +57,17 @@ Fabro performs this selection once when creating a run and persists the chosen p
|
|||
| `gemini-3.5-flash` | gemini | `gemini-35-flash` | 1M | $1.50 / $9.00 | 150 tok/s |
|
||||
| `gemini-3-flash-preview` | gemini | `gemini-flash` | 1M | $0.50 / $3.00 | 150 tok/s |
|
||||
| `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` | kimi | | 262K | $0.60 / $3.00 | 50 tok/s |
|
||||
| `kimi-k3` | kimi | `kimi` | 1M | $3.00 / $15.00 | n/a |
|
||||
| `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.
|
||||
|
||||
|
|
@ -227,7 +229,7 @@ When no model or provider is specified, Fabro chooses the default offering on th
|
|||
| `anthropic` | `claude-sonnet-4-6` |
|
||||
| `openai` | `gpt-5.6-sol` |
|
||||
| `gemini` | `gemini-3.5-flash` |
|
||||
| `kimi` | `kimi-k3` |
|
||||
| `moonshot` | `kimi-k3` |
|
||||
| `poolside` | `laguna-s-2.1` |
|
||||
| `zai` | `glm-5.2` |
|
||||
| `minimax` | `minimax-m2.5` |
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
"integrations/daytona",
|
||||
"integrations/litellm",
|
||||
"integrations/bedrock",
|
||||
"integrations/deepseek",
|
||||
"integrations/poolside",
|
||||
"integrations/openrouter",
|
||||
"integrations/modal",
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ For a qualified fallback, the selector may be that provider's canonical model ID
|
|||
```toml title="run.toml"
|
||||
[run.model.fallbacks]
|
||||
"kimi-k3" = [
|
||||
"kimi:kimi-k3",
|
||||
"moonshot:kimi-k3",
|
||||
"openrouter:kimi-k3",
|
||||
"claude-opus",
|
||||
]
|
||||
|
|
@ -169,7 +169,7 @@ For example, a server that selects Modal as the primary `kimi-k3` offering can d
|
|||
|
||||
```toml title="settings.toml"
|
||||
[run.model.fallbacks]
|
||||
"kimi-k3" = ["kimi:kimi-k3", "openrouter:kimi-k3", "claude-opus"]
|
||||
"kimi-k3" = ["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"]
|
||||
"glm-5.2" = ["gpt-sol"]
|
||||
"gpt-sol" = ["claude-opus"]
|
||||
"claude-opus" = ["gpt-sol"]
|
||||
|
|
|
|||
146
docs/public/integrations/deepseek.mdx
Normal 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>
|
||||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -366,11 +366,12 @@ For env-backed usage, `EnvCredentialSource` checks for API key environment varia
|
|||
| `ANTHROPIC_API_KEY` | Anthropic |
|
||||
| `OPENAI_API_KEY` | OpenAI |
|
||||
| `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Gemini |
|
||||
| `KIMI_API_KEY` | Kimi |
|
||||
| `MOONSHOT_API_KEY` or `KIMI_API_KEY` | Moonshot AI; `MOONSHOT_API_KEY` takes precedence |
|
||||
| `ZAI_API_KEY` | ZAI |
|
||||
| `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.
|
||||
|
|
|
|||
|
|
@ -3515,12 +3515,13 @@ root = "{}"
|
|||
assert!(ids.contains(&ProviderId::anthropic()));
|
||||
assert!(ids.contains(&ProviderId::openai()));
|
||||
assert!(ids.contains(&ProviderId::gemini()));
|
||||
assert!(ids.contains(&ProviderId::new("kimi")));
|
||||
assert!(ids.contains(&ProviderId::new("moonshot")));
|
||||
assert!(ids.contains(&ProviderId::new("zai")));
|
||||
assert!(ids.contains(&ProviderId::new("minimax")));
|
||||
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")));
|
||||
|
|
|
|||
|
|
@ -723,7 +723,7 @@ mod tests {
|
|||
.body(
|
||||
serde_json::json!({
|
||||
"model_id": "kimi-k2.5",
|
||||
"provider": "kimi",
|
||||
"provider": "moonshot",
|
||||
"status": "skip"
|
||||
})
|
||||
.to_string(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -386,7 +386,7 @@ mod tests {
|
|||
ProviderId::anthropic(),
|
||||
ProviderId::openai(),
|
||||
ProviderId::gemini(),
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("moonshot"),
|
||||
ProviderId::new("zai"),
|
||||
ProviderId::new("minimax"),
|
||||
ProviderId::new("inception"),
|
||||
|
|
|
|||
|
|
@ -1190,7 +1190,6 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"properties": {
|
||||
"attempt": 1,
|
||||
"context_values": {
|
||||
"current.preamble": "Goal: Wait for approval/n",
|
||||
"current_node": "start",
|
||||
"graph.goal": "Wait for approval",
|
||||
"internal.fidelity": "compact",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ fn remove_provider_env(cmd: &mut Command) -> &mut Command {
|
|||
.env_remove("OPENAI_API_KEY")
|
||||
.env_remove("GEMINI_API_KEY")
|
||||
.env_remove("GOOGLE_API_KEY")
|
||||
.env_remove("MOONSHOT_API_KEY")
|
||||
.env_remove("KIMI_API_KEY")
|
||||
.env_remove("ZAI_API_KEY")
|
||||
.env_remove("MINIMAX_API_KEY")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
139
lib/apps/fabro-mcp-server/src/executable_monitor.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ serde_json.workspace = true
|
|||
serde_yaml = "0.9"
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
async_zip.workspace = true
|
||||
clap.workspace = true
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
|
|
@ -121,5 +122,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"] }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
1034
lib/apps/fabro-server/src/run_compiler.rs
Normal 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,
|
||||
|
|
@ -1625,7 +1542,7 @@ enabled = true
|
|||
let mut checks = Vec::new();
|
||||
let configured = std::collections::BTreeMap::from([(
|
||||
"kimi-k3".to_string(),
|
||||
model_refs(&["kimi:kimi-k3", "openrouter:kimi-k3"]),
|
||||
model_refs(&["moonshot:kimi-k3", "openrouter:kimi-k3"]),
|
||||
)]);
|
||||
|
||||
assert!(run_model_fallback_check(
|
||||
|
|
@ -1637,13 +1554,12 @@ enabled = true
|
|||
|
||||
let check = checks.last().expect("fallback check should be present");
|
||||
assert_eq!(check.status, CheckStatus::Warning);
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.iter()
|
||||
.any(|detail| detail.warn
|
||||
&& detail.text.contains("provider `kimi` is not configured"))
|
||||
);
|
||||
assert!(check.details.iter().any(|detail| {
|
||||
detail.warn
|
||||
&& detail
|
||||
.text
|
||||
.contains("provider `moonshot` is not configured")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1686,15 +1602,15 @@ enabled = true
|
|||
})
|
||||
}
|
||||
|
||||
fn ready_kimi_and_openrouter_state(
|
||||
fn ready_moonshot_and_openrouter_state(
|
||||
server: &httpmock::MockServer,
|
||||
) -> Arc<crate::server::AppState> {
|
||||
let kimi_url = server.url("/kimi/v1");
|
||||
let moonshot_url = server.url("/moonshot/v1");
|
||||
let openrouter_url = server.url("/openrouter/v1");
|
||||
let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!(
|
||||
r#"
|
||||
[providers.kimi]
|
||||
base_url = "{kimi_url}"
|
||||
[providers.moonshot]
|
||||
base_url = "{moonshot_url}"
|
||||
|
||||
[providers.openrouter]
|
||||
base_url = "{openrouter_url}"
|
||||
|
|
@ -1706,7 +1622,7 @@ enabled = true
|
|||
crate::test_support::TestAppStateBuilder::new()
|
||||
.llm_catalog_settings(llm_catalog_settings)
|
||||
.vault_entries([
|
||||
(EnvVars::KIMI_API_KEY, "test-kimi-key"),
|
||||
(EnvVars::KIMI_API_KEY, "test-moonshot-key"),
|
||||
(EnvVars::OPENROUTER_API_KEY, "test-openrouter-key"),
|
||||
])
|
||||
.build()
|
||||
|
|
@ -1723,7 +1639,7 @@ enabled = true
|
|||
.unwrap_or_default();
|
||||
ready_providers.sort();
|
||||
assert_eq!(ready_providers, vec![
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("moonshot"),
|
||||
ProviderId::new("openrouter")
|
||||
]);
|
||||
|
||||
|
|
@ -2150,7 +2066,6 @@ root = "/srv/fabro"
|
|||
preserve_sandbox: None,
|
||||
provider: None,
|
||||
environment: None,
|
||||
docker_image: None,
|
||||
input: Vec::new(),
|
||||
verbose: None,
|
||||
});
|
||||
|
|
@ -2183,7 +2098,6 @@ override = "server"
|
|||
preserve_sandbox: None,
|
||||
provider: None,
|
||||
environment: None,
|
||||
docker_image: None,
|
||||
input: vec!["override=cli".to_string()],
|
||||
verbose: None,
|
||||
});
|
||||
|
|
@ -2748,7 +2662,7 @@ digraph Demo {
|
|||
.json_body(openai_compatible_completion("anthropic/claude-fable-5"));
|
||||
})
|
||||
.await;
|
||||
let state = ready_kimi_and_openrouter_state(&server);
|
||||
let state = ready_moonshot_and_openrouter_state(&server);
|
||||
|
||||
let (response, _ok) = preflight_for_model(&state, "claude-fable").await;
|
||||
|
||||
|
|
@ -2772,18 +2686,18 @@ digraph Demo {
|
|||
#[tokio::test]
|
||||
async fn preflight_uses_ready_providers_for_unknown_unqualified_model() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let kimi_probe = server
|
||||
let moonshot_probe = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/kimi/v1/chat/completions")
|
||||
.header("authorization", "Bearer test-kimi-key")
|
||||
.path("/moonshot/v1/chat/completions")
|
||||
.header("authorization", "Bearer test-moonshot-key")
|
||||
.json_body_includes(r#"{"model":"provider-private-preview"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(openai_compatible_completion("provider-private-preview"));
|
||||
})
|
||||
.await;
|
||||
let state = ready_kimi_and_openrouter_state(&server);
|
||||
let state = ready_moonshot_and_openrouter_state(&server);
|
||||
|
||||
let (response, _ok) = preflight_for_model(&state, "provider-private-preview").await;
|
||||
|
||||
|
|
@ -2802,10 +2716,10 @@ digraph Demo {
|
|||
.iter()
|
||||
.map(|detail| detail.text.as_str())
|
||||
.find(|detail| detail.starts_with("Provider: ")),
|
||||
Some("Provider: kimi")
|
||||
Some("Provider: moonshot")
|
||||
);
|
||||
assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Pass);
|
||||
kimi_probe.assert_async().await;
|
||||
moonshot_probe.assert_async().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ use tokio_stream::StreamExt;
|
|||
use tokio_stream::wrappers::{BroadcastStream, UnboundedReceiverStream};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tower::{ServiceExt, service_fn};
|
||||
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
|
||||
use tower_http::compression::{CompressionLayer, CompressionLevel};
|
||||
use tracing::{Instrument, debug, error, info, warn};
|
||||
use ulid::Ulid;
|
||||
|
|
@ -1922,12 +1923,14 @@ pub fn build_router_with_options(
|
|||
/// Response-compression layer shared by the main and install-mode routers.
|
||||
///
|
||||
/// The default predicate skips streaming SSE (`text/event-stream`), gRPC,
|
||||
/// images, and tiny bodies. The quality is pinned because tower-http's
|
||||
/// default defers to each codec's own default, and brotli's is quality 11 —
|
||||
/// seconds of CPU on a multi-megabyte asset. Level 4 keeps both codecs fast
|
||||
/// at a near-optimal ratio.
|
||||
pub(crate) fn compression_layer() -> CompressionLayer {
|
||||
CompressionLayer::new().quality(CompressionLevel::Precise(4))
|
||||
/// images, ZIP archives, and tiny bodies. The quality is pinned because
|
||||
/// tower-http's default defers to each codec's own default, and brotli's is
|
||||
/// quality 11 — seconds of CPU on a multi-megabyte asset. Level 4 keeps both
|
||||
/// codecs fast at a near-optimal ratio.
|
||||
pub(crate) fn compression_layer() -> CompressionLayer<impl Predicate> {
|
||||
CompressionLayer::new()
|
||||
.quality(CompressionLevel::Precise(4))
|
||||
.compress_when(DefaultPredicate::new().and(NotForContentType::const_new("application/zip")))
|
||||
}
|
||||
|
||||
async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Response {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_zip::base::write::ZipFileWriter;
|
||||
use async_zip::error::ZipError;
|
||||
use async_zip::{Compression, ZipEntryBuilder};
|
||||
use axum::http::HeaderValue;
|
||||
use fabro_store::{ArtifactStore, Error as StoreError};
|
||||
use fabro_types::RunProjection;
|
||||
use fabro_util::error::collect_chain;
|
||||
use futures_util::SinkExt as _;
|
||||
use futures_util::io::AsyncWriteExt as _;
|
||||
use tokio::io::{AsyncWrite, BufWriter};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokio_util::io::{CopyToBytes, SinkWriter};
|
||||
use tokio_util::sync::PollSender;
|
||||
use tracing::warn;
|
||||
|
||||
use super::super::{
|
||||
ApiError, AppState, ArtifactEntry, ArtifactKey, ArtifactListResponse, AsyncWriteExt, Body,
|
||||
Bytes, DefaultBodyLimit, Digest, HashMap, HashSet, HeaderMap, IntoResponse, Json, NodeArtifact,
|
||||
|
|
@ -17,6 +34,7 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||
.route("/runs/{id}/blobs", post(write_run_blob))
|
||||
.route("/runs/{id}/blobs/{blobId}", get(read_run_blob))
|
||||
.route("/runs/{id}/artifacts", get(list_run_artifacts))
|
||||
.route("/runs/{id}/artifacts/download", get(download_run_artifacts))
|
||||
.route(
|
||||
"/runs/{id}/stages/{stageId}/artifacts",
|
||||
get(list_stage_artifacts)
|
||||
|
|
@ -147,6 +165,210 @@ async fn list_run_artifacts(
|
|||
}
|
||||
}
|
||||
|
||||
const ARCHIVE_STREAM_CHANNEL_CAPACITY: usize = 8;
|
||||
|
||||
/// Deflate flushes its output in 8 KiB blocks, and every write below becomes
|
||||
/// its own allocation, channel send, and HTTP body frame. Batching to 64 KiB
|
||||
/// cuts all three by eight without meaningfully delaying the stream.
|
||||
const ARCHIVE_WRITE_BUFFER_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ArtifactArchiveError {
|
||||
#[error("archive output failed: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("artifact read failed: {0}")]
|
||||
Store(#[from] StoreError),
|
||||
#[error("ZIP write failed: {0}")]
|
||||
Zip(#[from] ZipError),
|
||||
}
|
||||
|
||||
/// One entry per artifact path, holding the newest capture of that path.
|
||||
///
|
||||
/// Newest means latest stage, then latest retry, then highest stage ID. That
|
||||
/// last tiebreaker only decides between two stages the projection does not know
|
||||
/// about, which both sort oldest (`None` < `Some`); it is here so the winner
|
||||
/// does not depend on the order the store happens to list objects in. All three
|
||||
/// keys mirror the artifacts page, which sorts on the same triple and takes the
|
||||
/// last entry. Boundary stages are dropped: they run no work, so anything they
|
||||
/// captured was already in the workspace.
|
||||
///
|
||||
/// Paths are re-checked here rather than trusted: a path stored before a
|
||||
/// validation rule existed would otherwise be written straight into a ZIP that
|
||||
/// somebody extracts. An unsafe path is skipped, not fatal — one bad path must
|
||||
/// not cost the caller every other artifact.
|
||||
fn latest_run_artifacts(
|
||||
entries: Vec<NodeArtifact>,
|
||||
projection: &RunProjection,
|
||||
) -> Vec<NodeArtifact> {
|
||||
let stage_order = projection
|
||||
.iter_stages()
|
||||
.enumerate()
|
||||
.map(|(order, (stage_id, _))| (stage_id.clone(), order))
|
||||
.collect::<HashMap<_, _>>();
|
||||
// The stage ID compares as its serialized `node@visit` form, matching the
|
||||
// string the artifacts page sorts on rather than `StageId`'s own ordering,
|
||||
// which compares the visit numerically and would disagree.
|
||||
let capture_rank = |artifact: &NodeArtifact| {
|
||||
(
|
||||
stage_order.get(&artifact.node).copied(),
|
||||
artifact.retry,
|
||||
artifact.node.to_string(),
|
||||
)
|
||||
};
|
||||
let mut latest_by_path: HashMap<String, NodeArtifact> = HashMap::new();
|
||||
|
||||
for artifact in entries {
|
||||
if projection.is_boundary_stage(artifact.node.node_id()) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = ArtifactStore::validate_relative_path(&artifact.filename) {
|
||||
warn!(
|
||||
path = %artifact.filename,
|
||||
%error,
|
||||
"skipping artifact with an unsafe path"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
match latest_by_path.get(&artifact.filename) {
|
||||
Some(existing) if capture_rank(existing) >= capture_rank(&artifact) => {}
|
||||
_ => {
|
||||
latest_by_path.insert(artifact.filename.clone(), artifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut latest = latest_by_path.into_values().collect::<Vec<_>>();
|
||||
latest.sort_by(|left, right| left.filename.cmp(&right.filename));
|
||||
latest
|
||||
}
|
||||
|
||||
async fn write_artifact_archive<W>(
|
||||
writer: W,
|
||||
artifact_store: ArtifactStore,
|
||||
run_id: RunId,
|
||||
artifacts: Vec<NodeArtifact>,
|
||||
) -> Result<(), ArtifactArchiveError>
|
||||
where
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut archive = ZipFileWriter::with_tokio(writer);
|
||||
for artifact in artifacts {
|
||||
let key = ArtifactKey::new(artifact.node, artifact.retry, artifact.filename.clone());
|
||||
let Some(mut source) = artifact_store.get_stream(&run_id, &key).await? else {
|
||||
// Deleted between the listing and this read, which in practice means
|
||||
// the run was pruned mid-download. Leave it out and keep going: an
|
||||
// archive missing one file beats a truncated one missing the rest.
|
||||
warn!(%run_id, path = %artifact.filename, "artifact vanished while archiving");
|
||||
continue;
|
||||
};
|
||||
// Deflate, not Stored: artifacts are mostly logs and reports, and the
|
||||
// response is excluded from transfer compression precisely because the
|
||||
// archive already carries its own.
|
||||
let entry = ZipEntryBuilder::new(artifact.filename.into(), Compression::Deflate);
|
||||
// `destination` is a futures-io writer from async_zip, while `writer` at
|
||||
// the end of this function is a tokio one, so both `AsyncWriteExt`
|
||||
// traits are in scope and each call resolves to a different one.
|
||||
let mut destination = archive.write_entry_stream(entry).await?;
|
||||
while let Some(chunk) = source.next().await {
|
||||
destination.write_all(&chunk?).await?;
|
||||
}
|
||||
destination.close().await?;
|
||||
}
|
||||
let mut writer = archive.close().await?.into_inner();
|
||||
writer.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn artifact_archive_body(
|
||||
artifact_store: ArtifactStore,
|
||||
run_id: RunId,
|
||||
artifacts: Vec<NodeArtifact>,
|
||||
) -> Body {
|
||||
// A channel of `Result`, rather than `tokio::io::duplex`, so a failure
|
||||
// partway through can poison the body. Dropping a duplex writer ends the
|
||||
// response with a clean EOF, which would hand the caller a truncated ZIP
|
||||
// that looks like a complete download. Sending an `Err` aborts the chunked
|
||||
// body instead, so the caller sees a transfer error. Do not "simplify" this
|
||||
// to a duplex without replacing that signal.
|
||||
let (sender, receiver) =
|
||||
mpsc::channel::<Result<Bytes, io::Error>>(ARCHIVE_STREAM_CHANNEL_CAPACITY);
|
||||
let error_sender = sender.clone();
|
||||
let sink = PollSender::new(sender)
|
||||
.sink_map_err(|_| io::Error::from(io::ErrorKind::BrokenPipe))
|
||||
.with(|chunk: Bytes| {
|
||||
std::future::ready(Ok::<Result<Bytes, io::Error>, io::Error>(Ok(chunk)))
|
||||
});
|
||||
let writer = BufWriter::with_capacity(
|
||||
ARCHIVE_WRITE_BUFFER_BYTES,
|
||||
SinkWriter::new(CopyToBytes::new(sink)),
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = write_artifact_archive(writer, artifact_store, run_id, artifacts).await
|
||||
{
|
||||
// Log before signalling: the send fails when the caller has already
|
||||
// gone away, and that is exactly when this log is the only record
|
||||
// that the archive failed. The 200 went out long ago.
|
||||
warn!(
|
||||
%run_id,
|
||||
error = %collect_chain(&error).join(": "),
|
||||
"artifact archive stream failed"
|
||||
);
|
||||
let _ = error_sender
|
||||
.send(Err(io::Error::other("artifact archive stream failed")))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
Body::from_stream(ReceiverStream::new(receiver))
|
||||
}
|
||||
|
||||
async fn download_run_artifacts(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let cached = match state.cached_run(&id).await {
|
||||
Ok(cached) => cached,
|
||||
Err(error) => return error.into_response(),
|
||||
};
|
||||
let entries = match state.artifact_store.list_for_run(&id).await {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
warn!(run_id = %id, %error, "failed to list artifacts for ZIP download");
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Artifact archive could not be prepared.",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let artifacts = latest_run_artifacts(entries, &cached.projection);
|
||||
|
||||
let content_disposition = format!("attachment; filename=\"fabro-artifacts-{id}.zip\"");
|
||||
let body = artifact_archive_body(state.artifact_store.clone(), id, artifacts);
|
||||
let mut response = body.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/zip"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_str(&content_disposition)
|
||||
.expect("run IDs produce valid attachment filenames"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn run_artifact_entry_from(entry: NodeArtifact) -> RunArtifactEntry {
|
||||
RunArtifactEntry {
|
||||
stage_id: entry.node.to_string(),
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ fn live_billing_rows(projection: &RunProjection, now: DateTime<Utc>) -> Vec<Live
|
|||
|
||||
for (stage_id, stage) in projection.iter_stages() {
|
||||
let node_id = stage_id.node_id();
|
||||
if is_boundary_stage(projection, node_id) || !stage_has_billing_row(stage) {
|
||||
if projection.is_boundary_stage(node_id) || !stage_has_billing_row(stage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -205,12 +205,3 @@ fn stage_has_billing_row(stage: &StageProjection) -> bool {
|
|||
|| !stage.usage.is_zero()
|
||||
|| stage.started_at.is_some()
|
||||
}
|
||||
|
||||
fn is_boundary_stage(projection: &RunProjection, node_id: &str) -> bool {
|
||||
projection
|
||||
.spec()
|
||||
.graph()
|
||||
.nodes
|
||||
.get(node_id)
|
||||
.is_some_and(|node| matches!(node.handler_type(), Some("start" | "exit")))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
|
|||
.route("/runs/{id}/graph/source", get(demo::get_run_graph_source))
|
||||
.route("/runs/{id}/stages", get(demo::get_run_stages))
|
||||
.route("/runs/{id}/artifacts", get(demo::list_run_artifacts_stub))
|
||||
.route("/runs/{id}/artifacts/download", get(not_implemented))
|
||||
.route("/runs/{id}/files", get(demo::list_run_files_stub))
|
||||
.route("/runs/{id}/commits", get(demo::list_run_commits_stub))
|
||||
.route(
|
||||
|
|
|
|||
|
|
@ -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 { .. },
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use std::process::Stdio;
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc as StdArc, Mutex as StdMutex};
|
||||
|
||||
use async_zip::base::read::mem::ZipFileReader;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, header};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
|
|
@ -3660,6 +3661,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 +6709,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}"
|
||||
|
|
@ -7098,7 +7446,7 @@ async fn list_models_exposes_reasoning_effort_controls() {
|
|||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/models?provider=kimi"))
|
||||
.uri(api("/models?provider=moonshot"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -10589,6 +10937,190 @@ async fn stage_artifacts_keep_same_filename_per_retry() {
|
|||
assert_eq!(&bytes[..], b"second");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_artifacts_download_streams_latest_files_as_zip() {
|
||||
let state = test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let run_id = create_run(&app, MINIMAL_DOT)
|
||||
.await
|
||||
.parse::<RunId>()
|
||||
.unwrap();
|
||||
|
||||
let run_store = state.stores.runs.open_run(&run_id).await.unwrap();
|
||||
for event in [
|
||||
workflow_event::Event::RunRunnable {
|
||||
source: fabro_types::RunRunnableSource::StartRequested,
|
||||
actor: None,
|
||||
},
|
||||
workflow_event::Event::RunStarting,
|
||||
workflow_event::Event::RunRunning,
|
||||
] {
|
||||
workflow_event::append_event(&run_store, &run_id, &event)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
append_scoped_stage_event(
|
||||
&state,
|
||||
run_id,
|
||||
"build",
|
||||
1,
|
||||
&stage_started_event("build", "command"),
|
||||
)
|
||||
.await;
|
||||
append_scoped_stage_event(
|
||||
&state,
|
||||
run_id,
|
||||
"verify",
|
||||
1,
|
||||
&stage_started_event("verify", "command"),
|
||||
)
|
||||
.await;
|
||||
|
||||
for (stage_id, retry, path, contents) in [
|
||||
(
|
||||
StageId::new("unknown", 1),
|
||||
99,
|
||||
"reports/result.txt",
|
||||
&b"unknown stage"[..],
|
||||
),
|
||||
(
|
||||
StageId::new("build", 1),
|
||||
1,
|
||||
"reports/result.txt",
|
||||
&b"build result"[..],
|
||||
),
|
||||
(
|
||||
StageId::new("verify", 1),
|
||||
1,
|
||||
"reports/result.txt",
|
||||
&b"first verify"[..],
|
||||
),
|
||||
(
|
||||
StageId::new("verify", 1),
|
||||
2,
|
||||
"reports/result.txt",
|
||||
&b"latest verify"[..],
|
||||
),
|
||||
(
|
||||
StageId::new("build", 1),
|
||||
1,
|
||||
"logs/run.txt",
|
||||
&b"build log"[..],
|
||||
),
|
||||
(
|
||||
StageId::new("start", 1),
|
||||
1,
|
||||
"control-start.txt",
|
||||
&b"excluded"[..],
|
||||
),
|
||||
(
|
||||
StageId::new("exit", 1),
|
||||
1,
|
||||
"control-exit.txt",
|
||||
&b"excluded"[..],
|
||||
),
|
||||
// Neither stage reached the projection, so both rank equally on stage
|
||||
// order and retry. The serialized stage ID breaks the tie the same way
|
||||
// the artifacts page does: "unknown@2" sorts above "unknown@10".
|
||||
(
|
||||
StageId::new("unknown", 10),
|
||||
1,
|
||||
"orphan.txt",
|
||||
&b"visit ten"[..],
|
||||
),
|
||||
(
|
||||
StageId::new("unknown", 2),
|
||||
1,
|
||||
"orphan.txt",
|
||||
&b"visit two"[..],
|
||||
),
|
||||
] {
|
||||
state
|
||||
.artifact_store
|
||||
.put(&run_id, &ArtifactKey::new(stage_id, retry, path), contents)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!("/runs/{run_id}/artifacts/download")))
|
||||
.header(header::ACCEPT_ENCODING, "gzip")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/zip")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CONTENT_DISPOSITION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(format!("attachment; filename=\"fabro-artifacts-{run_id}.zip\"").as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("private, no-store")
|
||||
);
|
||||
assert!(response.headers().get(header::CONTENT_ENCODING).is_none());
|
||||
|
||||
let bytes = response_bytes!(response, StatusCode::OK).await;
|
||||
let archive = ZipFileReader::new(bytes).await.unwrap();
|
||||
let names = archive
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| entry.filename().as_str().unwrap().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, vec![
|
||||
"logs/run.txt",
|
||||
"orphan.txt",
|
||||
"reports/result.txt"
|
||||
]);
|
||||
|
||||
let mut contents_by_name = HashMap::new();
|
||||
for (index, name) in names.into_iter().enumerate() {
|
||||
let mut entry = archive.reader_with_entry(index).await.unwrap();
|
||||
let mut contents = Vec::new();
|
||||
entry.read_to_end_checked(&mut contents).await.unwrap();
|
||||
contents_by_name.insert(name, contents);
|
||||
}
|
||||
assert_eq!(contents_by_name["logs/run.txt"], b"build log");
|
||||
assert_eq!(contents_by_name["reports/result.txt"], b"latest verify");
|
||||
assert_eq!(contents_by_name["orphan.txt"], b"visit two");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_artifacts_download_returns_not_found_for_unknown_run() {
|
||||
let state = test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!("/runs/{}/artifacts/download", RunId::new())))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::NOT_FOUND).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_run_persists_run_spec() {
|
||||
let state = test_app_state();
|
||||
|
|
@ -11316,6 +11848,7 @@ async fn worker_token_is_rejected_on_user_only_routes() {
|
|||
(Method::GET, format!("/runs/{run_id}/graph/source")),
|
||||
(Method::GET, format!("/runs/{run_id}/stages")),
|
||||
(Method::GET, format!("/runs/{run_id}/artifacts")),
|
||||
(Method::GET, format!("/runs/{run_id}/artifacts/download")),
|
||||
(Method::GET, format!("/runs/{run_id}/files")),
|
||||
(
|
||||
Method::GET,
|
||||
|
|
@ -15910,7 +16443,7 @@ async fn create_completion_unsupported_reasoning_efforts_return_bad_request() {
|
|||
then.status(500);
|
||||
});
|
||||
let state = TestAppStateBuilder::new()
|
||||
.provider_base_url("kimi", upstream.url("/v1"))
|
||||
.provider_base_url("moonshot", upstream.url("/v1"))
|
||||
.vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(state);
|
||||
|
|
@ -15923,7 +16456,7 @@ async fn create_completion_unsupported_reasoning_efforts_return_bad_request() {
|
|||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"provider": "kimi",
|
||||
"provider": "moonshot",
|
||||
"model": "kimi-k3",
|
||||
"reasoning_effort": effort,
|
||||
"stream": stream,
|
||||
|
|
@ -15982,7 +16515,7 @@ async fn create_completion_returns_disjoint_usage_buckets() {
|
|||
}));
|
||||
});
|
||||
let state = TestAppStateBuilder::new()
|
||||
.provider_base_url("kimi", upstream.base_url())
|
||||
.provider_base_url("moonshot", upstream.base_url())
|
||||
.vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(state);
|
||||
|
|
@ -15993,7 +16526,7 @@ async fn create_completion_returns_disjoint_usage_buckets() {
|
|||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"provider": "kimi",
|
||||
"provider": "moonshot",
|
||||
"model": "kimi-k3",
|
||||
"stream": false,
|
||||
"messages": [{
|
||||
|
|
@ -16122,7 +16655,7 @@ async fn create_completion_structured_output_forwards_reasoning_effort() {
|
|||
}));
|
||||
});
|
||||
let state = TestAppStateBuilder::new()
|
||||
.provider_base_url("kimi", upstream.base_url())
|
||||
.provider_base_url("moonshot", upstream.base_url())
|
||||
.vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(state);
|
||||
|
|
@ -16133,7 +16666,7 @@ async fn create_completion_structured_output_forwards_reasoning_effort() {
|
|||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"provider": "kimi",
|
||||
"provider": "moonshot",
|
||||
"model": "kimi-k3",
|
||||
"reasoning_effort": "high",
|
||||
"stream": false,
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ async fn mock_anthropic_install_validation(server: &MockServer) -> httpmock::Moc
|
|||
.await
|
||||
}
|
||||
|
||||
async fn mock_kimi_install_validation(server: &MockServer) -> httpmock::Mock<'_> {
|
||||
async fn mock_moonshot_install_validation(server: &MockServer) -> httpmock::Mock<'_> {
|
||||
server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST")
|
||||
|
|
@ -1355,11 +1355,13 @@ async fn token_install_finish_invokes_shutdown_callback_after_accepting() {
|
|||
#[tokio::test]
|
||||
async fn install_llm_accepts_catalog_openai_compatible_provider() {
|
||||
let llm_mock = MockServer::start_async().await;
|
||||
mock_kimi_install_validation(&llm_mock).await;
|
||||
mock_moonshot_install_validation(&llm_mock).await;
|
||||
|
||||
let app = build_install_router(
|
||||
InstallAppState::for_test("test-install-token")
|
||||
.with_provider_base_url(ProviderId::new("kimi"), format!("{}/v1", llm_mock.url(""))),
|
||||
InstallAppState::for_test("test-install-token").with_provider_base_url(
|
||||
ProviderId::new("moonshot"),
|
||||
format!("{}/v1", llm_mock.url("")),
|
||||
),
|
||||
);
|
||||
|
||||
let test_response = app
|
||||
|
|
@ -1371,7 +1373,7 @@ async fn install_llm_accepts_catalog_openai_compatible_provider() {
|
|||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"provider":"kimi","api_key":"kimi-test-key"}"#,
|
||||
r#"{"provider":"moonshot","api_key":"kimi-test-key"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
|
|
@ -1388,7 +1390,7 @@ async fn install_llm_accepts_catalog_openai_compatible_provider() {
|
|||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"providers":[{"provider":"kimi","api_key":"kimi-test-key"}]}"#,
|
||||
r#"{"providers":[{"provider":"moonshot","api_key":"kimi-test-key"}]}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -443,12 +443,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn summary_budget_for_always_reasoning_route_without_effort_adds_headroom() {
|
||||
let kimi = ProviderId::new("kimi");
|
||||
let model = catalog_model(&kimi, "kimi-k2.5");
|
||||
let moonshot = ProviderId::new("moonshot");
|
||||
let model = catalog_model(&moonshot, "kimi-k2.5");
|
||||
assert!(model.supports_reasoning());
|
||||
assert!(!model.supports_reasoning_effort());
|
||||
assert_eq!(
|
||||
builtin_summary_max_tokens(&kimi, "kimi-k2.5"),
|
||||
builtin_summary_max_tokens(&moonshot, "kimi-k2.5"),
|
||||
SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ impl KimiProfile {
|
|||
Self {
|
||||
base: BaseProfile {
|
||||
profile_kind: AgentProfileKind::Kimi,
|
||||
provider_id: ProviderId::new("kimi"),
|
||||
provider_id: ProviderId::new("moonshot"),
|
||||
model: model.into(),
|
||||
catalog: None,
|
||||
registry,
|
||||
|
|
@ -178,8 +178,8 @@ mod tests {
|
|||
#[test]
|
||||
fn kimi_models_select_the_kimi_profile_on_every_provider() {
|
||||
for (catalog, provider, model) in [
|
||||
(catalog(), "kimi", "kimi-k3"),
|
||||
(catalog(), "kimi", "kimi-k2.5"),
|
||||
(catalog(), "moonshot", "kimi-k3"),
|
||||
(catalog(), "moonshot", "kimi-k2.5"),
|
||||
(catalog_with_openrouter(), "openrouter", "kimi-k3"),
|
||||
(catalog_with_openrouter(), "openrouter", "kimi-k2.6"),
|
||||
] {
|
||||
|
|
|
|||
|
|
@ -548,7 +548,7 @@ mod tests {
|
|||
let options = native_tool_options(AgentProfileKind::OpenAi, has_web_search);
|
||||
let deps = ProfileDeps::standalone(options);
|
||||
OpenAiProfile::with_native_tools("kimi-k2.5", &deps).with_route(
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("moonshot"),
|
||||
Arc::new(Catalog::from_builtin().unwrap()),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,19 +229,19 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_provider_prompt_uses_catalog_display_name() {
|
||||
fn moonshot_provider_prompt_uses_catalog_display_name() {
|
||||
let profile =
|
||||
OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("kimi"), test_catalog());
|
||||
OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("moonshot"), test_catalog());
|
||||
let env = MockSandbox::linux();
|
||||
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
|
||||
assert!(prompt.contains("powered by Kimi"));
|
||||
assert!(prompt.contains("powered by Moonshot AI"));
|
||||
assert!(!prompt.contains("powered by OpenAI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_compatible_profile_uses_json_schema_edit_tool() {
|
||||
let profile =
|
||||
OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("kimi"), test_catalog());
|
||||
OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("moonshot"), test_catalog());
|
||||
|
||||
let names = profile.tool_registry().names();
|
||||
assert!(names.contains(&"edit_file".to_string()));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
source: lib/components/fabro-agent/src/profiles/mod.rs
|
||||
expression: system_prompt(&openai_edit_file_profile(true))
|
||||
---
|
||||
You are a coding agent powered by Kimi, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful.
|
||||
You are a coding agent powered by Moonshot AI, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful.
|
||||
|
||||
You can receive user prompts and context such as files in the workspace, communicate with the user by streaming thinking and responses, and emit function calls to run terminal commands and edit files.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
source: lib/components/fabro-agent/src/profiles/mod.rs
|
||||
expression: system_prompt(&openai_edit_file_profile(false))
|
||||
---
|
||||
You are a coding agent powered by Kimi, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful.
|
||||
You are a coding agent powered by Moonshot AI, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful.
|
||||
|
||||
You can receive user prompts and context such as files in the workspace, communicate with the user by streaming thinking and responses, and emit function calls to run terminal commands and edit files.
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ struct OpenAiTwinOptions {
|
|||
|
||||
fn summarizer_model_id(provider: &Provider) -> ModelHandle {
|
||||
match provider.as_str() {
|
||||
ProviderId::OPENAI | "kimi" | "zai" | "minimax" | "inception" => ModelHandle::ByName {
|
||||
ProviderId::OPENAI | "moonshot" | "zai" | "minimax" | "inception" => ModelHandle::ByName {
|
||||
provider: ProviderId::openai(),
|
||||
model: "gpt-5.4-mini".to_string(),
|
||||
},
|
||||
|
|
@ -279,7 +279,7 @@ macro_rules! provider_tests {
|
|||
);
|
||||
provider_test!(
|
||||
$scenario,
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("moonshot"),
|
||||
"kimi-k2.5",
|
||||
kimi,
|
||||
keys = ["KIMI_API_KEY"]
|
||||
|
|
@ -413,7 +413,7 @@ provider_test!(
|
|||
);
|
||||
provider_test!(
|
||||
web_fetch,
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("moonshot"),
|
||||
"kimi-k2.5",
|
||||
kimi,
|
||||
keys = ["KIMI_API_KEY", "OPENAI_API_KEY"]
|
||||
|
|
@ -461,7 +461,7 @@ web_search_provider_test!(
|
|||
keys = ["GEMINI_API_KEY", "BRAVE_SEARCH_API_KEY"]
|
||||
);
|
||||
web_search_provider_test!(
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("moonshot"),
|
||||
"kimi-k2.5",
|
||||
kimi,
|
||||
keys = ["KIMI_API_KEY", "BRAVE_SEARCH_API_KEY"]
|
||||
|
|
@ -518,7 +518,7 @@ macro_rules! non_openai_provider_tests {
|
|||
);
|
||||
provider_test!(
|
||||
$scenario,
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("moonshot"),
|
||||
"kimi-k2.5",
|
||||
kimi,
|
||||
keys = ["KIMI_API_KEY"]
|
||||
|
|
@ -800,7 +800,7 @@ reasoning_effort_tests!(
|
|||
keys = ["GEMINI_API_KEY"]
|
||||
);
|
||||
reasoning_effort_tests!(
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("moonshot"),
|
||||
"kimi-k2.5",
|
||||
kimi_reasoning_effort,
|
||||
keys = ["KIMI_API_KEY"]
|
||||
|
|
@ -882,7 +882,7 @@ loop_detection_tests!(
|
|||
keys = ["GEMINI_API_KEY"]
|
||||
);
|
||||
loop_detection_tests!(
|
||||
ProviderId::new("kimi"),
|
||||
ProviderId::new("moonshot"),
|
||||
"kimi-k2.5",
|
||||
kimi_loop_detection,
|
||||
keys = ["KIMI_API_KEY"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
@ -425,10 +427,10 @@ mod tests {
|
|||
fn openai_compatible_factory_uses_provider_id_for_name() {
|
||||
let config = AdapterConfig {
|
||||
base_url: Some("https://api.moonshot.ai/v1".to_string()),
|
||||
..AdapterConfig::new("kimi", ApiKeyHeader::Bearer("k".to_string()))
|
||||
..AdapterConfig::new("moonshot", ApiKeyHeader::Bearer("k".to_string()))
|
||||
};
|
||||
let adapter = factory_for(AdapterKind::OpenAiCompatible)(config).unwrap();
|
||||
assert_eq!(adapter.name(), "kimi");
|
||||
assert_eq!(adapter.name(), "moonshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -489,7 +491,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn openai_compatible_factory_errors_without_base_url() {
|
||||
let config = AdapterConfig::new("kimi", ApiKeyHeader::Bearer("k".to_string()));
|
||||
let config = AdapterConfig::new("moonshot", ApiKeyHeader::Bearer("k".to_string()));
|
||||
let Err(err) = factory_for(AdapterKind::OpenAiCompatible)(config) else {
|
||||
panic!("expected missing base_url error");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1529,13 +1529,16 @@ base_url = "{}/v1"
|
|||
let mut client = Client::new(HashMap::new(), None, vec![]);
|
||||
client.catalog = Some(Arc::clone(&catalog));
|
||||
client
|
||||
.register_provider(Arc::new(MockProvider::new("kimi", "should not dispatch")))
|
||||
.register_provider(Arc::new(MockProvider::new(
|
||||
"moonshot",
|
||||
"should not dispatch",
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut request = test_request();
|
||||
request.model = "kimi-k2.5".to_string();
|
||||
request.provider = Some("kimi".to_string());
|
||||
request.provider = Some("moonshot".to_string());
|
||||
request.reasoning_effort = Some(ReasoningEffort::High);
|
||||
|
||||
let err = client.complete(&request).await.unwrap_err();
|
||||
|
|
@ -1554,13 +1557,13 @@ base_url = "{}/v1"
|
|||
let mut client = Client::new(HashMap::new(), None, vec![]);
|
||||
client.catalog = Some(Arc::clone(&catalog));
|
||||
client
|
||||
.register_provider(Arc::new(MockProvider::new("kimi", "accepted")))
|
||||
.register_provider(Arc::new(MockProvider::new("moonshot", "accepted")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut request = test_request();
|
||||
request.model = "kimi-k3".to_string();
|
||||
request.provider = Some("kimi".to_string());
|
||||
request.provider = Some("moonshot".to_string());
|
||||
request.reasoning_effort = Some(ReasoningEffort::High);
|
||||
|
||||
let response = client.complete(&request).await.unwrap();
|
||||
|
|
@ -1724,7 +1727,7 @@ base_url = "{}/v1"
|
|||
let catalog = catalog_with("");
|
||||
let client = Client::from_credentials(
|
||||
vec![ApiCredential {
|
||||
provider: ProviderId::new("kimi"),
|
||||
provider: ProviderId::new("moonshot"),
|
||||
auth_header: Some(ApiKeyHeader::Bearer("kimi-key".to_string())),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
|
|
@ -1737,8 +1740,8 @@ base_url = "{}/v1"
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(client.provider_names(), vec!["kimi"]);
|
||||
assert_eq!(client.default_provider(), Some("kimi"));
|
||||
assert_eq!(client.provider_names(), vec!["moonshot"]);
|
||||
assert_eq!(client.default_provider(), Some("moonshot"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
use super::SYNTHETIC_TOOL_NAME;
|
||||
use super::decode::{convert_synthetic_tool_to_text, map_finish_reason, refusal_error};
|
||||
use crate::codec::{RawEvent, StreamDecoder, parse_tool_arguments_or_empty};
|
||||
use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind};
|
||||
use crate::error::{self, Error, ProviderErrorDetail, ProviderErrorKind};
|
||||
use crate::types::{
|
||||
ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData,
|
||||
TokenCounts, ToolCall,
|
||||
|
|
@ -355,16 +355,11 @@ fn stream_error_event_to_provider_error(data: &serde_json::Value, provider_name:
|
|||
.and_then(serde_json::Value::as_str)
|
||||
.map(String::from);
|
||||
|
||||
let kind = match error_code.as_deref() {
|
||||
Some("rate_limit_error") => ProviderErrorKind::RateLimit,
|
||||
Some("authentication_error") => ProviderErrorKind::Authentication,
|
||||
Some("permission_error") => ProviderErrorKind::AccessDenied,
|
||||
Some("not_found_error") => ProviderErrorKind::NotFound,
|
||||
Some("invalid_request_error") => ProviderErrorKind::InvalidRequest,
|
||||
Some("request_too_large") => ProviderErrorKind::ContextLength,
|
||||
// overloaded_error, api_error, and unknown stream errors are transient.
|
||||
_ => ProviderErrorKind::Server,
|
||||
};
|
||||
// overloaded_error, api_error, and unknown stream errors are transient.
|
||||
let kind = error_code
|
||||
.as_deref()
|
||||
.and_then(error::kind_from_error_code)
|
||||
.unwrap_or(ProviderErrorKind::Server);
|
||||
|
||||
Error::Provider {
|
||||
kind,
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
mod decode;
|
||||
mod encode;
|
||||
mod sanitize;
|
||||
mod stream;
|
||||
|
||||
use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder};
|
||||
|
|
|
|||
122
lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ pub(crate) struct CodecCtx<'a> {
|
|||
/// the stream model fallback uses `request.model`).
|
||||
pub request: &'a Request,
|
||||
/// Identity stamped into `Response.provider`, and the `provider_options`
|
||||
/// namespace key for the openai_compatible codec (kimi/zai/…).
|
||||
/// namespace key for the openai_compatible codec (moonshot/zai/…).
|
||||
pub provider_name: &'a str,
|
||||
/// The model id to send on the wire — catalog `api_id`, resolved by the
|
||||
/// route (today `api_id == id` everywhere).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! The OpenAI Chat Completions (`/chat/completions`) codec.
|
||||
//!
|
||||
//! Serves every "OpenAI-compatible" route (kimi, zai, minimax, venice,
|
||||
//! Serves every "OpenAI-compatible" route (moonshot, zai, minimax, venice,
|
||||
//! inception, ollama, litellm, …). Pure translation: no HTTP, auth, or base
|
||||
//! URL — the adapter shell owns those. Count-tokens and error mapping use the
|
||||
//! `Codec` trait defaults (this dialect has no count route and uses the shared
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ fn apply_cache_breakpoints(messages: &mut [ChatMessage]) {
|
|||
/// Merge `provider_options.<provider_name>` fields into the serialized API
|
||||
/// request body.
|
||||
///
|
||||
/// The provider name is configurable (e.g. "groq", "together", "kimi"),
|
||||
/// The provider name is configurable (e.g. "groq", "together", "moonshot"),
|
||||
/// allowing each instance to have its own namespace in `provider_options`.
|
||||
pub(super) fn merge_provider_options(
|
||||
body: &mut serde_json::Value,
|
||||
|
|
@ -221,7 +221,7 @@ mod tests {
|
|||
let mut request = minimal_request();
|
||||
request.reasoning_effort = Some(ReasoningEffort::High);
|
||||
|
||||
let body = encode_body(&request, "kimi", false);
|
||||
let body = encode_body(&request, "moonshot", false);
|
||||
|
||||
assert_eq!(body["reasoning_effort"], "high");
|
||||
}
|
||||
|
|
@ -229,7 +229,7 @@ mod tests {
|
|||
#[test]
|
||||
fn encode_omits_sampling_params_for_models_that_reject_them() {
|
||||
let model = Catalog::builtin()
|
||||
.get_on_provider(&ProviderId::new("kimi"), "kimi-k3")
|
||||
.get_on_provider(&ProviderId::new("moonshot"), "kimi-k3")
|
||||
.unwrap();
|
||||
let mut request = minimal_request();
|
||||
request.model = model.id.to_string();
|
||||
|
|
@ -238,7 +238,7 @@ mod tests {
|
|||
let params = CodecParams::default();
|
||||
let ctx = CodecCtx {
|
||||
request: &request,
|
||||
provider_name: "kimi",
|
||||
provider_name: "moonshot",
|
||||
deployment_id: model.id.as_str(),
|
||||
model: Some(model),
|
||||
params: ¶ms,
|
||||
|
|
@ -262,7 +262,7 @@ mod tests {
|
|||
let deployment_id = request.model.clone();
|
||||
let ctx = CodecCtx {
|
||||
request: &request,
|
||||
provider_name: "kimi",
|
||||
provider_name: "moonshot",
|
||||
deployment_id: &deployment_id,
|
||||
model: None,
|
||||
params: ¶ms,
|
||||
|
|
|
|||
|
|
@ -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"}));
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use serde::Deserialize;
|
|||
use super::decode::{map_finish_reason, token_counts_from_api_usage, tool_call_from_item};
|
||||
use super::wire::ApiUsage;
|
||||
use crate::codec::{CodecCtx, RawEvent, StreamDecoder};
|
||||
use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind};
|
||||
use crate::error::{self, Error, ProviderErrorDetail, ProviderErrorKind};
|
||||
use crate::types::{
|
||||
ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, TokenCounts,
|
||||
ToolCall,
|
||||
|
|
@ -36,35 +36,10 @@ fn provider_error_from_openai_error_json(error: &serde_json::Value, provider: &s
|
|||
.filter(|message| !message.is_empty())
|
||||
.map_or_else(|| "OpenAI stream error".to_string(), str::to_string);
|
||||
|
||||
let kind = match classifier {
|
||||
Some("insufficient_quota" | "billing_hard_limit_reached") => {
|
||||
ProviderErrorKind::QuotaExceeded
|
||||
}
|
||||
Some("rate_limit_error" | "rate_limit_exceeded" | "too_many_requests") => {
|
||||
ProviderErrorKind::RateLimit
|
||||
}
|
||||
Some("authentication_error" | "invalid_api_key" | "invalid_authentication") => {
|
||||
ProviderErrorKind::Authentication
|
||||
}
|
||||
Some(
|
||||
"access_denied" | "account_deactivated" | "permission_denied" | "permission_error",
|
||||
) => ProviderErrorKind::AccessDenied,
|
||||
Some("content_filter" | "content_policy_violation") => ProviderErrorKind::ContentFilter,
|
||||
Some("context_length_exceeded") => ProviderErrorKind::ContextLength,
|
||||
Some("server_error" | "internal_error" | "service_unavailable" | "engine_overloaded") => {
|
||||
ProviderErrorKind::Server
|
||||
}
|
||||
Some(code) if code.ends_with("_not_found") => ProviderErrorKind::NotFound,
|
||||
Some(code)
|
||||
if code.starts_with("invalid_")
|
||||
|| code.starts_with("unsupported_")
|
||||
|| code.ends_with("_too_large")
|
||||
|| code.ends_with("_too_long") =>
|
||||
{
|
||||
ProviderErrorKind::InvalidRequest
|
||||
}
|
||||
Some(_) | None => ProviderErrorKind::Server,
|
||||
};
|
||||
// Unrecognized and absent codes are treated as transient.
|
||||
let kind = classifier
|
||||
.and_then(error::kind_from_error_code)
|
||||
.unwrap_or(ProviderErrorKind::Server);
|
||||
|
||||
Error::Provider {
|
||||
kind,
|
||||
|
|
|
|||
|
|
@ -284,6 +284,52 @@ impl Error {
|
|||
}
|
||||
}
|
||||
|
||||
/// Provider error code to error kind mapping, for the codes that say more
|
||||
/// than the transport-level status or stream event type does.
|
||||
///
|
||||
/// Returns `None` when the code adds nothing, so each caller keeps its own
|
||||
/// default: the stream decoders treat an unrecognized code as transient,
|
||||
/// while [`error_from_status_code`] falls back to the HTTP status.
|
||||
///
|
||||
/// Every dialect classifies through this one table so a code such as
|
||||
/// `insufficient_quota` means the same thing whether it arrives in an HTTP
|
||||
/// error body or in a mid-stream error event.
|
||||
#[must_use]
|
||||
pub(crate) fn kind_from_error_code(code: &str) -> Option<ProviderErrorKind> {
|
||||
Some(match code {
|
||||
// Out of credit, or over a billing cap. Distinct from RateLimit:
|
||||
// backoff never clears it, but another provider has its own quota.
|
||||
"insufficient_quota" | "billing_hard_limit_reached" | "exceeded_current_quota_error" => {
|
||||
ProviderErrorKind::QuotaExceeded
|
||||
}
|
||||
"rate_limit_error" | "rate_limit_exceeded" | "too_many_requests" => {
|
||||
ProviderErrorKind::RateLimit
|
||||
}
|
||||
"authentication_error" | "invalid_api_key" | "invalid_authentication" => {
|
||||
ProviderErrorKind::Authentication
|
||||
}
|
||||
"access_denied" | "account_deactivated" | "permission_denied" | "permission_error" => {
|
||||
ProviderErrorKind::AccessDenied
|
||||
}
|
||||
"content_filter" | "content_policy_violation" => ProviderErrorKind::ContentFilter,
|
||||
// `request_too_large` is anthropic's oversized-input code, so it has
|
||||
// to precede the `_too_large` suffix rule below.
|
||||
"context_length_exceeded" | "request_too_large" => ProviderErrorKind::ContextLength,
|
||||
"server_error" | "internal_error" | "service_unavailable" | "engine_overloaded" => {
|
||||
ProviderErrorKind::Server
|
||||
}
|
||||
c if c == "not_found_error" || c.ends_with("_not_found") => ProviderErrorKind::NotFound,
|
||||
c if c.starts_with("invalid_")
|
||||
|| c.starts_with("unsupported_")
|
||||
|| c.ends_with("_too_large")
|
||||
|| c.ends_with("_too_long") =>
|
||||
{
|
||||
ProviderErrorKind::InvalidRequest
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// HTTP status code to error type mapping (Section 6.4).
|
||||
#[must_use]
|
||||
pub fn error_from_status_code(
|
||||
|
|
@ -303,6 +349,8 @@ pub fn error_from_status_code(
|
|||
raw,
|
||||
};
|
||||
|
||||
let code_kind = detail.error_code.as_deref().and_then(kind_from_error_code);
|
||||
|
||||
// Check specific status codes first -- these always map to their designated
|
||||
// error types
|
||||
let kind = match status_code {
|
||||
|
|
@ -316,10 +364,16 @@ pub fn error_from_status_code(
|
|||
};
|
||||
}
|
||||
413 => ProviderErrorKind::ContextLength,
|
||||
// A 429 means rate limited unless the body reports a spent quota,
|
||||
// which retrying will never clear.
|
||||
429 if code_kind == Some(ProviderErrorKind::QuotaExceeded) => {
|
||||
ProviderErrorKind::QuotaExceeded
|
||||
}
|
||||
429 => ProviderErrorKind::RateLimit,
|
||||
500..=599 => ProviderErrorKind::Server,
|
||||
// For ambiguous status codes (400, 422, etc.), use message-based classification
|
||||
_ => {
|
||||
// For ambiguous status codes (400, 422, etc.), the provider's error
|
||||
// code is the better signal; fall back to the message only without one
|
||||
_ => code_kind.unwrap_or_else(|| {
|
||||
let lower_msg = detail.message.to_lowercase();
|
||||
if lower_msg.contains("not found") || lower_msg.contains("does not exist") {
|
||||
ProviderErrorKind::NotFound
|
||||
|
|
@ -333,7 +387,7 @@ pub fn error_from_status_code(
|
|||
} else {
|
||||
ProviderErrorKind::InvalidRequest
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
Error::Provider {
|
||||
|
|
@ -603,6 +657,105 @@ mod tests {
|
|||
assert!(err.retryable());
|
||||
}
|
||||
|
||||
/// Every vendor spelling of "you are out of credit" arrives as a 429 and
|
||||
/// has to classify as a spent quota, not as a rate limit.
|
||||
#[test]
|
||||
fn quota_codes_on_429_are_non_retryable_quota_failures() {
|
||||
for (provider, code) in [
|
||||
("kimi", "exceeded_current_quota_error"),
|
||||
("openai", "insufficient_quota"),
|
||||
("openai", "billing_hard_limit_reached"),
|
||||
] {
|
||||
let err = error_from_status_code(
|
||||
429,
|
||||
"Your account has insufficient balance".into(),
|
||||
provider.into(),
|
||||
Some(code.into()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
err.provider_kind(),
|
||||
Some(ProviderErrorKind::QuotaExceeded),
|
||||
"{code}"
|
||||
);
|
||||
assert!(!err.retryable(), "{code}");
|
||||
assert!(err.failover_eligible(), "{code}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 429 that is a genuine rate limit stays retryable, whether the body
|
||||
/// names it, names something unrecognized, or carries no code at all.
|
||||
#[test]
|
||||
fn non_quota_429_stays_a_retryable_rate_limit() {
|
||||
for code in [
|
||||
Some("rate_limit_error"),
|
||||
Some("rate_limit_reached_error"),
|
||||
Some("invalid_request_error"),
|
||||
None,
|
||||
] {
|
||||
let err = error_from_status_code(
|
||||
429,
|
||||
"slow down".into(),
|
||||
"openai".into(),
|
||||
code.map(String::from),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
err.provider_kind(),
|
||||
Some(ProviderErrorKind::RateLimit),
|
||||
"{code:?}"
|
||||
);
|
||||
assert!(err.retryable(), "{code:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// For a status with no fixed meaning, the structured code beats guessing
|
||||
/// from the message text.
|
||||
#[test]
|
||||
fn ambiguous_status_prefers_error_code_over_message() {
|
||||
let err = error_from_status_code(
|
||||
402,
|
||||
"Payment required".into(),
|
||||
"openai".into(),
|
||||
Some("insufficient_quota".into()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_from_error_code_covers_every_dialect() {
|
||||
for (code, expected) in [
|
||||
("insufficient_quota", ProviderErrorKind::QuotaExceeded),
|
||||
("rate_limit_error", ProviderErrorKind::RateLimit),
|
||||
("authentication_error", ProviderErrorKind::Authentication),
|
||||
("permission_error", ProviderErrorKind::AccessDenied),
|
||||
("content_policy_violation", ProviderErrorKind::ContentFilter),
|
||||
("context_length_exceeded", ProviderErrorKind::ContextLength),
|
||||
("engine_overloaded", ProviderErrorKind::Server),
|
||||
// anthropic's oversized-input code beats the `_too_large` rule
|
||||
("request_too_large", ProviderErrorKind::ContextLength),
|
||||
("prompt_too_long", ProviderErrorKind::InvalidRequest),
|
||||
("invalid_request_error", ProviderErrorKind::InvalidRequest),
|
||||
("unsupported_parameter", ProviderErrorKind::InvalidRequest),
|
||||
// both the anthropic and openai not-found spellings
|
||||
("not_found_error", ProviderErrorKind::NotFound),
|
||||
("model_not_found", ProviderErrorKind::NotFound),
|
||||
] {
|
||||
assert_eq!(kind_from_error_code(code), Some(expected), "{code}");
|
||||
}
|
||||
|
||||
// No opinion, so the caller keeps its own default.
|
||||
assert_eq!(kind_from_error_code("overloaded_error"), None);
|
||||
assert_eq!(kind_from_error_code("api_error"), None);
|
||||
assert_eq!(kind_from_error_code(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_message_classification_context_length() {
|
||||
let err = error_from_status_code(
|
||||
|
|
|
|||
|
|
@ -169,7 +169,8 @@ impl Adapter {
|
|||
|
||||
/// Collect a streaming response into a single [`Response`].
|
||||
///
|
||||
/// Used by non-Anthropic providers (e.g. Kimi) that require `stream=true`.
|
||||
/// Used by non-Anthropic providers (e.g. Moonshot) that require
|
||||
/// `stream=true`.
|
||||
async fn complete_via_stream(&self, request: &Request) -> Result<Response, Error> {
|
||||
use futures::StreamExt;
|
||||
|
||||
|
|
@ -266,7 +267,7 @@ impl ProviderAdapter for Adapter {
|
|||
self.validate_request(request)?;
|
||||
|
||||
let route = self.route_config();
|
||||
// Non-Anthropic providers (e.g. Kimi) require stream=true even for
|
||||
// Non-Anthropic providers (e.g. Moonshot) require stream=true even for
|
||||
// blocking calls. Collect the stream into a single Response.
|
||||
if route.force_streaming {
|
||||
return self.complete_via_stream(request).await;
|
||||
|
|
@ -367,8 +368,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn adapter_with_name() {
|
||||
let adapter = Adapter::new("key").with_name("kimi");
|
||||
assert_eq!(adapter.name(), "kimi");
|
||||
let adapter = Adapter::new("key").with_name("moonshot");
|
||||
assert_eq!(adapter.name(), "moonshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -520,6 +520,40 @@ mod tests {
|
|||
assert!(matches!(err, Error::Configuration { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_classifies_insufficient_quota_as_quota_exceeded() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST).path("/responses");
|
||||
then.status(429)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"error": {
|
||||
"message": "You exceeded your current quota.",
|
||||
"type": "insufficient_quota"
|
||||
}
|
||||
}));
|
||||
});
|
||||
let adapter = Adapter::new("sk-test").with_base_url(server.base_url());
|
||||
|
||||
let err = adapter
|
||||
.complete(&minimal_request())
|
||||
.await
|
||||
.expect_err("spent quota should fail the completion");
|
||||
|
||||
mock.assert();
|
||||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded));
|
||||
assert_eq!(err.status_code(), Some(429));
|
||||
assert!(!err.retryable());
|
||||
assert!(err.failover_eligible());
|
||||
match err {
|
||||
Error::Provider { detail, .. } => {
|
||||
assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota"));
|
||||
}
|
||||
other => panic!("expected provider error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_complete_via_stream_propagates_stream_errors() {
|
||||
let server = MockServer::start();
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ async fn openai_gpt_5_5_pro_complete() {
|
|||
async fn kimi_k3_reasoning_tool_round_trip() {
|
||||
let api_key = std::env::var(EnvVars::KIMI_API_KEY).expect("KIMI_API_KEY must be set");
|
||||
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.moonshot.ai/v1")
|
||||
.with_name("kimi")
|
||||
.with_name("moonshot")
|
||||
.with_catalog(Arc::new(Catalog::from_builtin().unwrap()));
|
||||
let tool = ToolDefinition::function(
|
||||
"multiply",
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -835,11 +835,11 @@ async fn stream_without_message_stop_emits_no_finish() {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom-named route (the Kimi-over-anthropic shape)
|
||||
// Custom-named route (the Moonshot Kimi-over-anthropic shape)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared setup for a custom-named anthropic-dialect (Kimi) stream route; the
|
||||
/// request and event halves are pinned by separate tests.
|
||||
/// Shared setup for a custom-named Moonshot Kimi stream route using the
|
||||
/// Anthropic dialect; separate tests pin the request and event halves.
|
||||
async fn custom_named_stream_capture() -> (WireCapture, Vec<serde_json::Value>) {
|
||||
let sse = support::sse_transcript(&[
|
||||
(
|
||||
|
|
@ -865,15 +865,15 @@ async fn custom_named_stream_capture() -> (WireCapture, Vec<serde_json::Value>)
|
|||
("message_stop", r#"{"type":"message_stop"}"#),
|
||||
]);
|
||||
stream_capture(
|
||||
adapter().with_name("kimi"),
|
||||
adapter().with_name("moonshot"),
|
||||
&base_request("kimi-test"),
|
||||
&sse,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// A custom-named (Kimi) route authenticates with a bearer token and sends no
|
||||
/// `anthropic-version` header. This pins that route shape on the wire.
|
||||
/// A custom-named Moonshot route authenticates with a bearer token and sends
|
||||
/// no `anthropic-version` header. This pins that route shape on the wire.
|
||||
#[tokio::test]
|
||||
async fn custom_named_stream_route() {
|
||||
let (capture, _) = custom_named_stream_capture().await;
|
||||
|
|
@ -903,7 +903,7 @@ async fn custom_named_stream_error_identity() {
|
|||
),
|
||||
]);
|
||||
let (_capture, events) = stream_capture(
|
||||
adapter().with_name("kimi"),
|
||||
adapter().with_name("moonshot"),
|
||||
&base_request("kimi-test"),
|
||||
&sse,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -253,7 +253,7 @@ async fn encode_kimi_k3_uses_catalog_reasoning_and_sampling_controls() {
|
|||
..base_request(MODEL)
|
||||
};
|
||||
let capture = encode_capture_with(&request, move |adapter| {
|
||||
adapter.with_name("kimi").with_catalog(catalog)
|
||||
adapter.with_name("moonshot").with_catalog(catalog)
|
||||
})
|
||||
.await;
|
||||
|
||||
|
|
@ -398,9 +398,9 @@ async fn encode_openrouter_claude_auto_cache_opt_out() {
|
|||
async fn encode_provider_options_keyed_by_adapter_name() {
|
||||
let request = corpus_provider_options(
|
||||
MODEL,
|
||||
serde_json::json!({"kimi": {"repetition_penalty": 1.2}}),
|
||||
serde_json::json!({"moonshot": {"repetition_penalty": 1.2}}),
|
||||
);
|
||||
let capture = encode_capture_with(&request, |adapter| adapter.with_name("kimi")).await;
|
||||
let capture = encode_capture_with(&request, |adapter| adapter.with_name("moonshot")).await;
|
||||
fabro_test::fabro_json_snapshot!(capture.body);
|
||||
}
|
||||
|
||||
|
|
@ -411,7 +411,7 @@ async fn encode_provider_options_other_namespace_ignored() {
|
|||
MODEL,
|
||||
serde_json::json!({"openai": {"repetition_penalty": 1.2}}),
|
||||
);
|
||||
let capture = encode_capture_with(&request, |adapter| adapter.with_name("kimi")).await;
|
||||
let capture = encode_capture_with(&request, |adapter| adapter.with_name("moonshot")).await;
|
||||
fabro_test::fabro_json_snapshot!(capture.body);
|
||||
}
|
||||
|
||||
|
|
@ -904,11 +904,11 @@ async fn stream_without_done_or_content_synthesizes_nothing() {
|
|||
async fn custom_named_complete_identity() {
|
||||
let server = MockServer::start();
|
||||
let (mock, _slot) = mount_capture(&server, "/chat/completions", minimal_body());
|
||||
let adapter = adapter(&server).with_name("kimi");
|
||||
let adapter = adapter(&server).with_name("moonshot");
|
||||
let response = adapter
|
||||
.complete(&base_request(MODEL))
|
||||
.await
|
||||
.expect("complete should succeed");
|
||||
mock.assert();
|
||||
assert_eq!(response.provider, "kimi");
|
||||
assert_eq!(response.provider, "moonshot");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ expression: rendered
|
|||
"type": "stream_start"
|
||||
},
|
||||
{
|
||||
"stream_item_error": "Server error from kimi: Overloaded",
|
||||
"stream_item_error": "Server error from moonshot: Overloaded",
|
||||
"retryable": true,
|
||||
"failover_eligible": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ expression: rendered
|
|||
"response": {
|
||||
"id": "msg_kimi",
|
||||
"model": "kimi-test",
|
||||
"provider": "kimi",
|
||||
"provider": "moonshot",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use bytes::Bytes;
|
|||
use chrono::Utc;
|
||||
use fabro_types::RunId;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::BoxStream;
|
||||
use object_store::ObjectStore;
|
||||
use object_store::buffered::BufWriter;
|
||||
use object_store::path::Path as ObjectPath;
|
||||
|
|
@ -122,6 +123,33 @@ impl ArtifactStore {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn get_stream(
|
||||
&self,
|
||||
run_id: &RunId,
|
||||
key: &ArtifactKey,
|
||||
) -> Result<Option<BoxStream<'static, Result<Bytes>>>> {
|
||||
let path = self.artifact_path(run_id, key)?;
|
||||
match self.object_store.get(&path).await {
|
||||
Ok(result) => Ok(Some(
|
||||
result
|
||||
.into_stream()
|
||||
.map(|chunk| chunk.map_err(Error::from))
|
||||
.boxed(),
|
||||
)),
|
||||
Err(object_store::Error::NotFound { .. }) => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a path against the same rules `put` enforces, without writing.
|
||||
///
|
||||
/// Readers that hand artifact paths back to a client — the ZIP download,
|
||||
/// for one — use this to re-check paths that were stored before a rule
|
||||
/// existed.
|
||||
pub fn validate_relative_path(relative_path: &str) -> Result<()> {
|
||||
validate_filename_segments(relative_path).map(drop)
|
||||
}
|
||||
|
||||
pub async fn list_for_run(&self, run_id: &RunId) -> Result<Vec<NodeArtifact>> {
|
||||
let prefix = self.run_prefix(run_id)?;
|
||||
let mut stream = self.object_store.list(Some(&prefix));
|
||||
|
|
@ -220,12 +248,27 @@ impl ArtifactStore {
|
|||
}
|
||||
}
|
||||
|
||||
/// Artifact filenames end up as paths on someone else's disk — extracted from a
|
||||
/// ZIP, written by a worker — so they must stay relative and portable. The
|
||||
/// backslash, NUL, and drive-letter rules are what make the path safe on
|
||||
/// Windows as well as Unix.
|
||||
fn validate_filename_segments(filename: &str) -> Result<Vec<&str>> {
|
||||
if filename.contains('\\') {
|
||||
return Err(Error::Other(
|
||||
"artifact filename must not contain backslashes".to_string(),
|
||||
));
|
||||
}
|
||||
if filename.contains('\0') {
|
||||
return Err(Error::Other(
|
||||
"artifact filename must not contain NUL bytes".to_string(),
|
||||
));
|
||||
}
|
||||
let bytes = filename.as_bytes();
|
||||
if bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':') {
|
||||
return Err(Error::Other(
|
||||
"artifact filename must not start with a drive letter".to_string(),
|
||||
));
|
||||
}
|
||||
let segments = filename.split('/').collect::<Vec<_>>();
|
||||
if segments.is_empty() || segments.iter().any(|segment| segment.is_empty()) {
|
||||
return Err(Error::Other(
|
||||
|
|
@ -460,6 +503,22 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_stream_round_trips_chunked_reads() {
|
||||
let store = test_store();
|
||||
let run_id = fixtures::RUN_1;
|
||||
let key = ArtifactKey::new(StageId::new("build", 2), 1, "logs/output.txt");
|
||||
store.put(&run_id, &key, b"hello world").await.unwrap();
|
||||
|
||||
let mut stream = store.get_stream(&run_id, &key).await.unwrap().unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
bytes.extend_from_slice(&chunk.unwrap());
|
||||
}
|
||||
|
||||
assert_eq!(bytes, b"hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_relative_filenames() {
|
||||
let store = test_store();
|
||||
|
|
@ -468,15 +527,25 @@ mod tests {
|
|||
|
||||
for filename in [
|
||||
"",
|
||||
"/escape.txt",
|
||||
"../escape.txt",
|
||||
"logs//output.txt",
|
||||
"logs/./output.txt",
|
||||
r"logs\output.txt",
|
||||
"C:/escape.txt",
|
||||
"c:escape.txt",
|
||||
"bad\0name.txt",
|
||||
] {
|
||||
let key = ArtifactKey::new(node.clone(), 1, filename);
|
||||
let err = store.put(&run_id, &key, b"boom").await.unwrap_err();
|
||||
assert!(err.to_string().contains("artifact filename"));
|
||||
assert!(
|
||||
err.to_string().contains("artifact filename"),
|
||||
"accepted unsafe artifact filename: {filename:?}"
|
||||
);
|
||||
assert!(ArtifactStore::validate_relative_path(filename).is_err());
|
||||
}
|
||||
|
||||
assert!(ArtifactStore::validate_relative_path("nested/result.txt").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -1363,22 +1363,13 @@ pub(crate) fn projected_billing(state: &RunProjection) -> BilledTokenCounts {
|
|||
|
||||
let mut billing = BilledTokenCounts::default();
|
||||
for (stage_id, stage) in state.iter_stages() {
|
||||
if !is_boundary_stage(state, stage_id.node_id()) {
|
||||
if !state.is_boundary_stage(stage_id.node_id()) {
|
||||
billing.add_counts(&stage.usage);
|
||||
}
|
||||
}
|
||||
billing
|
||||
}
|
||||
|
||||
fn is_boundary_stage(projection: &RunProjection, node_id: &str) -> bool {
|
||||
projection
|
||||
.spec()
|
||||
.graph()
|
||||
.nodes
|
||||
.get(node_id)
|
||||
.is_some_and(|node| matches!(node.handler_type(), Some("start" | "exit")))
|
||||
}
|
||||
|
||||
fn run_models(state: &RunProjection) -> Vec<RunModel> {
|
||||
let mut models = state
|
||||
.iter_stages()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
37
lib/components/fabro-store/src/test_support/mod.rs
Normal 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())
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
|
|
@ -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}")))
|
||||
}
|
||||
}
|
||||
|
|
|
|||