mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
Merge origin/main into PR branch (resolve start.rs overlap with #545)
This commit is contained in:
commit
c5cf4b7f9f
16 changed files with 736 additions and 129 deletions
189
Cargo.lock
generated
189
Cargo.lock
generated
|
|
@ -153,6 +153,21 @@ version = "0.1.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
|
|
@ -315,6 +330,18 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
|
|
@ -1070,6 +1097,27 @@ version = "0.2.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
|
|
@ -1294,6 +1342,24 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
|
|
@ -2173,7 +2239,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-acp"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-tokio",
|
||||
|
|
@ -2192,7 +2258,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2234,7 +2300,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-automation",
|
||||
|
|
@ -2257,7 +2323,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2281,7 +2347,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-automation"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"croner",
|
||||
"hex",
|
||||
|
|
@ -2296,11 +2362,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-build-support"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -2316,7 +2382,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2418,7 +2484,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2447,7 +2513,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2476,7 +2542,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
|
|
@ -2491,7 +2557,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-db"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"sqlx",
|
||||
|
|
@ -2501,7 +2567,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2520,7 +2586,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2534,7 +2600,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-environment"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2556,7 +2622,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2578,7 +2644,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2592,7 +2658,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -2615,7 +2681,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2625,7 +2691,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2643,7 +2709,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -2658,7 +2724,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2699,7 +2765,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -2710,7 +2776,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
|
|
@ -2728,7 +2794,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2748,7 +2814,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2775,7 +2841,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-store"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-types",
|
||||
"serde",
|
||||
|
|
@ -2787,7 +2853,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2803,7 +2869,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2825,7 +2891,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2833,7 +2899,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2842,7 +2908,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"ref-cast",
|
||||
|
|
@ -2858,7 +2924,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2903,7 +2969,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2994,7 +3060,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -3016,18 +3082,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -3054,7 +3120,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -3080,7 +3146,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -3094,7 +3160,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3119,7 +3185,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3140,7 +3206,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3154,7 +3220,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -3176,7 +3242,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -3197,7 +3263,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-acp",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -3210,7 +3276,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-variable"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3227,7 +3293,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-static",
|
||||
|
|
@ -3240,7 +3306,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3383,6 +3449,16 @@ dependencies = [
|
|||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.10.0"
|
||||
|
|
@ -5105,6 +5181,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7302,6 +7379,12 @@ dependencies = [
|
|||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
|
|
@ -8239,13 +8322,17 @@ version = "0.6.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
|
@ -8382,7 +8469,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -8401,7 +8488,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.281.0-nightly.0"
|
||||
version = "0.283.0-nightly.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ mock.module("../lib/queries", () => ({
|
|||
isLoading: currentGraphLoading,
|
||||
mutate: graphMutateMock,
|
||||
}),
|
||||
useRunGraphSource: () => ({ data: undefined }),
|
||||
useRunStageEvents: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { ApiError } from "../lib/api-client";
|
||||
import { useRun, useRunGraph, useRunStages } from "../lib/queries";
|
||||
import { useRun, useRunGraph, useRunGraphSource, useRunStages } from "../lib/queries";
|
||||
import { FloatingTooltip } from "../components/floating-tooltip";
|
||||
import { RunSummaryPanel } from "../components/run-summary-panel";
|
||||
import { StagePopover } from "../components/stage-popover";
|
||||
|
|
@ -20,13 +20,24 @@ import {
|
|||
type RunGraphNodeHover,
|
||||
} from "../hooks/use-annotated-run-graph-svg";
|
||||
|
||||
export const handle = { wide: true };
|
||||
export const handle = { wide: true, fullHeight: true };
|
||||
|
||||
type Direction = "LR" | "TB";
|
||||
|
||||
// Mirrors fabro-graphviz's RANKDIR_RE (lib/crates/fabro-graphviz/src/render.rs) —
|
||||
// keep the accepted `rankdir=` syntax in sync with that regex.
|
||||
const RANKDIR_RE = /rankdir\s*=\s*(\w+)/;
|
||||
|
||||
function parseSourceDirection(source: string | undefined): Direction | undefined {
|
||||
const value = source?.match(RANKDIR_RE)?.[1];
|
||||
return value === "LR" || value === "TB" ? value : undefined;
|
||||
}
|
||||
|
||||
export default function RunOverview() {
|
||||
const { id } = useParams();
|
||||
const [direction, setDirection] = useState<Direction>("LR");
|
||||
const [direction, setDirection] = useState<Direction | undefined>(undefined);
|
||||
const sourceQuery = useRunGraphSource(id, direction === undefined);
|
||||
const activeDirection = direction ?? parseSourceDirection(sourceQuery.data ?? undefined) ?? "TB";
|
||||
const stagesQuery = useRunStages(id);
|
||||
const graphQuery = useRunGraph(id, direction);
|
||||
const runQuery = useRun(id);
|
||||
|
|
@ -113,17 +124,21 @@ export default function RunOverview() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<StageSidebar stages={stages} runId={id!} />
|
||||
<div className="flex min-h-0 flex-1 gap-6">
|
||||
<div className="min-h-0 shrink-0 overflow-y-auto overflow-x-hidden pb-[var(--fabro-interview-dock-clearance,0px)]">
|
||||
<StageSidebar stages={stages} runId={id!} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
<RunSummaryPanel runId={id!} />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 pb-[var(--fabro-interview-dock-clearance,0px)]">
|
||||
<div className="shrink-0">
|
||||
<RunSummaryPanel runId={id!} />
|
||||
</div>
|
||||
{graphSvg === undefined && graphQuery.isLoading ? (
|
||||
<div className="py-12" />
|
||||
<div className="flex-1" />
|
||||
) : graphSvg ? (
|
||||
<div className="graph-svg relative rounded-md border border-line bg-panel-alt">
|
||||
<div className="graph-svg relative flex min-h-0 flex-1 flex-col rounded-md border border-line bg-panel-alt">
|
||||
<GraphToolbar
|
||||
direction={direction}
|
||||
direction={activeDirection}
|
||||
setDirection={setDirection}
|
||||
fitToWindow={fitToWindow}
|
||||
zoomIndex={zoomIndex}
|
||||
|
|
@ -132,7 +147,7 @@ export default function RunOverview() {
|
|||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-hidden p-6"
|
||||
className="min-h-0 flex-1 overflow-hidden p-6"
|
||||
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
|
|
@ -141,7 +156,7 @@ export default function RunOverview() {
|
|||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className="flex items-center justify-center [&_svg]:mx-auto [&_svg]:block"
|
||||
className="flex h-full items-center justify-center [&_svg]:mx-auto [&_svg]:block"
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, transformOrigin: "center center" }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1920,7 +1920,7 @@ export default function RunStages() {
|
|||
|
||||
return (
|
||||
<div className="-mr-4 -mt-3 flex min-h-0 flex-1 sm:-mr-6 lg:-mr-8">
|
||||
<div className="shrink-0 pb-6 pr-3 pt-3">
|
||||
<div className="min-h-0 shrink-0 overflow-y-auto overflow-x-hidden pr-3 pt-3 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
|
||||
<StageSidebar stages={stages} runId={id} selectedStageId={selectedStage.id} />
|
||||
</div>
|
||||
|
||||
|
|
@ -1933,7 +1933,7 @@ export default function RunStages() {
|
|||
|
||||
{isAgentStage && (
|
||||
<>
|
||||
<div className="shrink-0 px-3 pb-6 pt-3">
|
||||
<div className="min-h-0 shrink-0 overflow-y-auto overflow-x-hidden px-3 pt-3 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
|
||||
<StageInsightsSidebar
|
||||
stage={stageProjection}
|
||||
contextWindow={contextWindowQuery.data}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,10 @@ async function buildOnce() {
|
|||
await copyPierreWorkerAssets(join(buildAssetsDir, "pierre-diffs-worker"));
|
||||
await writeIndexHtml(
|
||||
buildDir,
|
||||
result.outputs.map((output: any) => relative(buildDir, output.path)),
|
||||
result.outputs.map((output: any) => ({
|
||||
kind: output.kind,
|
||||
path: relative(buildDir, output.path),
|
||||
})),
|
||||
);
|
||||
|
||||
await publishBuild(buildDir);
|
||||
|
|
@ -100,15 +103,30 @@ async function copyPierreWorkerAssets(targetDir: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function writeIndexHtml(buildDir: string, outputs: string[]) {
|
||||
// `kind` mirrors Bun's `BuildArtifact.kind`; the union keeps the
|
||||
// "entry-point" comparison below typo-safe.
|
||||
type IndexHtmlOutput = {
|
||||
kind: "entry-point" | "chunk" | "asset" | "sourcemap" | "bytecode";
|
||||
path: string;
|
||||
};
|
||||
|
||||
async function writeIndexHtml(buildDir: string, outputs: IndexHtmlOutput[]) {
|
||||
const template = await readFile(templatePath, "utf8");
|
||||
// Only entry points get <script> tags. Bun's `splitting: true` emits
|
||||
// hundreds of chunks reachable from the entry through static and dynamic
|
||||
// imports; listing every chunk here force-downloads the whole bundle
|
||||
// (13+ MB) before first render, defeating the code splitting. The
|
||||
// browser's module graph pulls static imports itself, and dynamic
|
||||
// import() chunks load on demand.
|
||||
const scripts = outputs
|
||||
.filter((path) => path.endsWith(".js"))
|
||||
.map((path) => `<script type="module" src="/${path.replaceAll("\\\\", "/")}"></script>`)
|
||||
.filter((output) => output.kind === "entry-point" && output.path.endsWith(".js"))
|
||||
.map((output) => `<script type="module" src="/${output.path.replaceAll("\\\\", "/")}"></script>`)
|
||||
.join("\n ");
|
||||
const styles = [
|
||||
"/assets/app.css",
|
||||
...outputs.filter((path) => path.endsWith(".css")).map((path) => `/${path.replaceAll("\\\\", "/")}`),
|
||||
...outputs
|
||||
.filter((output) => output.path.endsWith(".css"))
|
||||
.map((output) => `/${output.path.replaceAll("\\\\", "/")}`),
|
||||
]
|
||||
.filter((value, index, array) => array.indexOf(value) === index)
|
||||
.map((path) => `<link rel="stylesheet" href="${path}" />`)
|
||||
|
|
|
|||
|
|
@ -948,7 +948,9 @@ fn build_github_app_manifest(app_name: &str, port: u16, web_url: &str) -> serde_
|
|||
"pull_requests": "write",
|
||||
"checks": "write",
|
||||
"issues": "write",
|
||||
"emails": "read"
|
||||
"emails": "read",
|
||||
"vulnerability_alerts": "write",
|
||||
"organization_projects": "write"
|
||||
},
|
||||
"default_events": []
|
||||
})
|
||||
|
|
@ -2662,6 +2664,14 @@ client_id = "client-id"
|
|||
manifest["setup_url"],
|
||||
serde_json::json!("https://app.example.com/setup"),
|
||||
);
|
||||
assert_eq!(
|
||||
manifest["default_permissions"]["vulnerability_alerts"],
|
||||
serde_json::json!("write"),
|
||||
);
|
||||
assert_eq!(
|
||||
manifest["default_permissions"]["organization_projects"],
|
||||
serde_json::json!("write"),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ cookie.workspace = true
|
|||
dirs.workspace = true
|
||||
globset.workspace = true
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
tower-http = { version = "0.6", features = ["trace", "compression-br", "compression-gzip"] }
|
||||
tokio-stream = { workspace = true, features = ["sync"] }
|
||||
tokio-util.workspace = true
|
||||
base64.workspace = true
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ use zeroize::Zeroizing;
|
|||
use crate::error::ApiError;
|
||||
use crate::serve::{self, DEFAULT_TCP_PORT};
|
||||
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
|
||||
use crate::{security_headers, static_files};
|
||||
use crate::{security_headers, server, static_files};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InstallAppState {
|
||||
|
|
@ -667,6 +667,10 @@ pub fn build_install_router(state: InstallAppState) -> Router {
|
|||
}
|
||||
}
|
||||
}))
|
||||
// Install mode serves the same multi-megabyte SPA bundle as the main
|
||||
// router; a first-run setup over a slow link needs compression just
|
||||
// as much.
|
||||
.layer(server::compression_layer())
|
||||
.layer(middleware::from_fn(security_headers::layer))
|
||||
}
|
||||
|
||||
|
|
@ -2053,7 +2057,9 @@ fn build_github_app_manifest(
|
|||
"pull_requests": "write",
|
||||
"checks": "write",
|
||||
"issues": "write",
|
||||
"emails": "read"
|
||||
"emails": "read",
|
||||
"vulnerability_alerts": "write",
|
||||
"organization_projects": "write"
|
||||
},
|
||||
"default_events": []
|
||||
})
|
||||
|
|
|
|||
|
|
@ -72,9 +72,17 @@ fn apply_defaults(headers: &mut HeaderMap, is_https: bool) {
|
|||
|
||||
// Conservative cache defaults. Routes that deliberately want to cache
|
||||
// (hashed static assets, public GETs) set their own Cache-Control before
|
||||
// this middleware runs, which prevents the default from being applied.
|
||||
set_default(headers, header::CACHE_CONTROL, "no-store");
|
||||
set_default(headers, header::PRAGMA, "no-cache");
|
||||
// this middleware runs. When they have, we must not also stamp the no-cache
|
||||
// pair: `Pragma: no-cache` next to a long-lived `Cache-Control: immutable`
|
||||
// is contradictory, and browsers resolve it by revalidating on every load.
|
||||
// Since these assets carry no ETag/Last-Modified, that revalidation
|
||||
// degrades into a full re-download each time. Apply the no-store/no-cache
|
||||
// defaults only to responses that haven't opted into caching; a present
|
||||
// Cache-Control is the signal that the handler chose its own policy.
|
||||
if !headers.contains_key(header::CACHE_CONTROL) {
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
|
||||
}
|
||||
set_default(headers, header::VARY, "Accept-Encoding");
|
||||
|
||||
// HSTS is a no-op over plain HTTP per RFC 6797, but only emit it on
|
||||
|
|
@ -202,7 +210,10 @@ mod tests {
|
|||
#[test]
|
||||
fn existing_cache_control_is_not_overridden() {
|
||||
// Static assets set their own cache-control with long immutability.
|
||||
// The middleware default must not clobber it.
|
||||
// The middleware default must not clobber it, and must not stamp a
|
||||
// contradictory `Pragma: no-cache` alongside it — that combination
|
||||
// forces browsers to revalidate (and, absent validators, re-download)
|
||||
// supposedly-immutable assets on every load.
|
||||
let headers = headers_after(&req("/assets/app-abc.js", &[]), &[(
|
||||
"cache-control",
|
||||
"public, max-age=31536000, immutable",
|
||||
|
|
@ -211,6 +222,10 @@ mod tests {
|
|||
headers.get("cache-control").unwrap(),
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
assert!(
|
||||
!headers.contains_key("pragma"),
|
||||
"cacheable responses must not carry Pragma: no-cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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::{CompressionLayer, CompressionLevel};
|
||||
use tracing::{Instrument, debug, error, info, warn};
|
||||
use ulid::Ulid;
|
||||
|
||||
|
|
@ -1852,6 +1853,10 @@ pub fn build_router_with_options(
|
|||
}
|
||||
|
||||
router
|
||||
// Innermost of the outer layers so every response body — static SPA
|
||||
// assets and JSON API alike — is compressed before the header/log
|
||||
// middlewares see it.
|
||||
.layer(compression_layer())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
canonical_host::Config {
|
||||
state: state_for_canonical_host,
|
||||
|
|
@ -1864,6 +1869,17 @@ pub fn build_router_with_options(
|
|||
.layer(middleware::from_fn(request_id::layer))
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Response {
|
||||
let path = req.uri().path();
|
||||
if path.starts_with("/assets/") || path.starts_with("/images/") {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
use std::borrow::Cow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use fabro_static::EnvVars;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::csp;
|
||||
|
|
@ -100,9 +102,8 @@ async fn load_injected_install_shell(
|
|||
asset_root: Option<&Path>,
|
||||
dev_disk_only: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
Some(inject_install_mode(
|
||||
load_asset("index.html", asset_root, dev_disk_only).await?,
|
||||
))
|
||||
let shell = load_asset("index.html", asset_root, dev_disk_only).await?;
|
||||
Some(inject_install_mode(shell.bytes.into()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
|
|
@ -125,7 +126,7 @@ async fn serve_with_mode(
|
|||
}
|
||||
|
||||
if let Some(asset) = load_asset_for_mode(&normalized, mode, asset_root, dev_disk_only).await {
|
||||
return asset_response(&normalized, asset);
|
||||
return asset_response(&normalized, asset, headers);
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html only for browser navigations that
|
||||
|
|
@ -136,7 +137,7 @@ async fn serve_with_mode(
|
|||
if let Some(index) =
|
||||
load_asset_for_mode("index.html", mode, asset_root, dev_disk_only).await
|
||||
{
|
||||
return asset_response("index.html", index);
|
||||
return asset_response("index.html", index, headers);
|
||||
}
|
||||
if dev_disk_only {
|
||||
return build_in_progress_response();
|
||||
|
|
@ -187,7 +188,39 @@ fn normalize(path: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool) -> Option<Vec<u8>> {
|
||||
/// An asset body plus, when the source precomputed it (the embedded SPA
|
||||
/// snapshot), its SHA-256. Carrying the hash lets mutable-asset ETags reuse
|
||||
/// rust-embed's compile-time digest instead of rehashing process-lifetime
|
||||
/// bytes on every revalidation.
|
||||
struct Asset {
|
||||
bytes: Bytes,
|
||||
sha256: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl Asset {
|
||||
fn from_vec(bytes: Vec<u8>) -> Self {
|
||||
Self {
|
||||
bytes: bytes.into(),
|
||||
sha256: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_embedded(asset: fabro_spa::AssetBytes) -> Self {
|
||||
let sha256 = asset.sha256();
|
||||
let bytes = match asset.into_cow() {
|
||||
// Release builds embed assets as statics; serve them without
|
||||
// copying the (potentially multi-megabyte) body per request.
|
||||
Cow::Borrowed(bytes) => Bytes::from_static(bytes),
|
||||
Cow::Owned(bytes) => Bytes::from(bytes),
|
||||
};
|
||||
Self {
|
||||
bytes,
|
||||
sha256: Some(sha256),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool) -> Option<Asset> {
|
||||
if spa_assets_disabled_for_test() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -196,11 +229,11 @@ async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool)
|
|||
// workspace's live `dist/` fallback or test isolation breaks.
|
||||
if let Some(root) = asset_root {
|
||||
if let Some(bytes) = read_disk_asset_from_root(root, path).await {
|
||||
return Some(bytes);
|
||||
return Some(Asset::from_vec(bytes));
|
||||
}
|
||||
} else if cfg!(debug_assertions) {
|
||||
if let Some(bytes) = read_disk_asset(path).await {
|
||||
return Some(bytes);
|
||||
return Some(Asset::from_vec(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -211,7 +244,7 @@ async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool)
|
|||
return None;
|
||||
}
|
||||
|
||||
fabro_spa::get(path).map(fabro_spa::AssetBytes::into_vec)
|
||||
fabro_spa::get(path).map(Asset::from_embedded)
|
||||
}
|
||||
|
||||
async fn load_asset_for_mode(
|
||||
|
|
@ -219,9 +252,11 @@ async fn load_asset_for_mode(
|
|||
mode: SpaMode,
|
||||
asset_root: Option<&Path>,
|
||||
dev_disk_only: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
) -> Option<Asset> {
|
||||
if mode == SpaMode::Install && path == "index.html" {
|
||||
return cached_install_mode_shell(asset_root, dev_disk_only).await;
|
||||
return cached_install_mode_shell(asset_root, dev_disk_only)
|
||||
.await
|
||||
.map(Asset::from_vec);
|
||||
}
|
||||
load_asset(path, asset_root, dev_disk_only).await
|
||||
}
|
||||
|
|
@ -276,43 +311,113 @@ fn disk_asset_root() -> PathBuf {
|
|||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../apps/fabro-web/dist")
|
||||
}
|
||||
|
||||
fn asset_response(path: &str, bytes: Vec<u8>) -> Response {
|
||||
const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
|
||||
const REVALIDATE_CACHE_CONTROL: &str = "no-cache";
|
||||
|
||||
fn asset_response(path: &str, asset: Asset, request_headers: &HeaderMap) -> Response {
|
||||
let content_hashed = is_content_hashed(path);
|
||||
let cache_control = cache_control(content_hashed);
|
||||
// Mutable assets keep stable names across deploys, so their `no-cache`
|
||||
// policy needs a validator to revalidate as a cheap 304 instead of a full
|
||||
// body download on every use. Hashed immutable assets never revalidate,
|
||||
// so an ETag would be dead weight.
|
||||
let etag = (!content_hashed).then(|| asset_etag(&asset));
|
||||
|
||||
if let Some(etag) = &etag {
|
||||
if if_none_match_matches(request_headers, etag) {
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = StatusCode::NOT_MODIFIED;
|
||||
apply_cache_headers(response.headers_mut(), cache_control, Some(etag));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
let mut response = Response::new(Body::from(asset.bytes));
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(mime.as_ref())
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(cache_control(path)),
|
||||
);
|
||||
apply_cache_headers(response.headers_mut(), cache_control, etag.as_deref());
|
||||
response
|
||||
}
|
||||
|
||||
fn cache_control(path: &str) -> &'static str {
|
||||
if path.contains("/assets/") || path.contains('-') && has_hashed_extension(path) {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"no-cache"
|
||||
fn apply_cache_headers(headers: &mut HeaderMap, cache_control: &'static str, etag: Option<&str>) {
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(cache_control),
|
||||
);
|
||||
if let Some(etag) = etag {
|
||||
if let Ok(value) = HeaderValue::from_str(etag) {
|
||||
headers.insert(header::ETAG, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_hashed_extension(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
let mut parts = name.split('.');
|
||||
let Some(stem) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
stem.split('-').count() > 1
|
||||
fn asset_etag(asset: &Asset) -> String {
|
||||
let digest = asset
|
||||
.sha256
|
||||
.unwrap_or_else(|| Sha256::digest(&asset.bytes).into());
|
||||
format!("\"{}\"", hex::encode(digest))
|
||||
}
|
||||
|
||||
fn if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
|
||||
headers
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| {
|
||||
value.split(',').map(str::trim).any(|candidate| {
|
||||
candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn cache_control(content_hashed: bool) -> &'static str {
|
||||
if content_hashed {
|
||||
IMMUTABLE_CACHE_CONTROL
|
||||
} else {
|
||||
REVALIDATE_CACHE_CONTROL
|
||||
}
|
||||
}
|
||||
|
||||
/// True only for the bundler's content-hashed outputs: files directly under
|
||||
/// `assets/` named `<stem>-<hash>.js|css` with an 8-char lowercase base-36
|
||||
/// hash (e.g. `assets/entry-0sv53bs3.js`). Only those names change whenever
|
||||
/// their bytes change, which is what makes a year-long `immutable` policy
|
||||
/// safe.
|
||||
///
|
||||
/// Stable-named files must NOT match — `index.html`, `assets/app.css`, the
|
||||
/// pierre-diffs worker under `assets/pierre-diffs-worker/`, images — because
|
||||
/// caching those immutably pins stale copies in browsers across deploys.
|
||||
/// When in doubt this classifier says "not hashed": the cost of a false
|
||||
/// negative is one 304 revalidation, the cost of a false positive is a
|
||||
/// wrongly-pinned asset for up to a year.
|
||||
fn is_content_hashed(path: &str) -> bool {
|
||||
let Some(file_name) = path.trim_start_matches('/').strip_prefix("assets/") else {
|
||||
return false;
|
||||
};
|
||||
if file_name.contains('/') {
|
||||
// Subdirectories under assets/ (the pierre-diffs worker) hold
|
||||
// stable-named files copied verbatim from their package.
|
||||
return false;
|
||||
}
|
||||
let Some((stem, extension)) = file_name.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(extension, "js" | "css") {
|
||||
return false;
|
||||
}
|
||||
let Some((_, hash)) = stem.rsplit_once('-') else {
|
||||
return false;
|
||||
};
|
||||
hash.len() == 8
|
||||
&& hash
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_source_map(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
|
|
@ -328,8 +433,8 @@ mod tests {
|
|||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
|
||||
use super::{
|
||||
accepts_html, cache_control, inject_install_mode, is_source_map, read_disk_asset_from_root,
|
||||
serve_with_asset_root,
|
||||
accepts_html, cache_control, inject_install_mode, is_content_hashed, is_source_map,
|
||||
read_disk_asset_from_root, serve_with_asset_root,
|
||||
};
|
||||
|
||||
fn headers_with_accept(value: &str) -> HeaderMap {
|
||||
|
|
@ -364,11 +469,126 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn hashed_assets_are_cached_immutably() {
|
||||
for path in [
|
||||
"assets/entry-0sv53bs3.js",
|
||||
"assets/chunk-4tr91ktd.js",
|
||||
"assets/chunk-x912wb67.css",
|
||||
] {
|
||||
assert!(is_content_hashed(path), "{path} should be content-hashed");
|
||||
}
|
||||
assert_eq!(cache_control(true), "public, max-age=31536000, immutable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_named_assets_must_revalidate() {
|
||||
// Files whose names do NOT change when their bytes change would be
|
||||
// pinned stale in browsers for a year if marked immutable.
|
||||
for path in [
|
||||
"index.html",
|
||||
"assets/app.css",
|
||||
"assets/pierre-diffs-worker/worker-portable.js",
|
||||
"images/apple-touch-icon.png",
|
||||
// Dash segment that isn't an 8-char lowercase base-36 hash.
|
||||
"assets/entry-abc123.js",
|
||||
// Right hash shape, but not a bundler output extension.
|
||||
"assets/photo-a1b2c3d4.png",
|
||||
] {
|
||||
assert!(
|
||||
!is_content_hashed(path),
|
||||
"{path} should not be content-hashed"
|
||||
);
|
||||
}
|
||||
assert_eq!(cache_control(false), "no-cache");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutable_assets_serve_etag_and_conditional_304() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let asset_path = temp_dir.path().join("assets/app.css");
|
||||
std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&asset_path, b"body { color: red }").unwrap();
|
||||
|
||||
let first = serve_with_asset_root(
|
||||
"/assets/app.css",
|
||||
&HeaderMap::new(),
|
||||
Some(temp_dir.path()),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
cache_control("assets/entry-abc123.js"),
|
||||
"public, max-age=31536000, immutable"
|
||||
first.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"no-cache"
|
||||
);
|
||||
let etag = first
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.expect("mutable assets should carry an ETag validator")
|
||||
.clone();
|
||||
|
||||
let mut conditional = HeaderMap::new();
|
||||
conditional.insert(header::IF_NONE_MATCH, etag.clone());
|
||||
let second = serve_with_asset_root(
|
||||
"/assets/app.css",
|
||||
&conditional,
|
||||
Some(temp_dir.path()),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
|
||||
assert_eq!(second.headers().get(header::ETAG).unwrap(), &etag);
|
||||
assert_eq!(
|
||||
second.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"no-cache"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(second.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(bytes.is_empty(), "304 must not carry a body");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_if_none_match_gets_full_response() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let asset_path = temp_dir.path().join("assets/app.css");
|
||||
std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&asset_path, b"body { color: red }").unwrap();
|
||||
|
||||
let mut conditional = HeaderMap::new();
|
||||
conditional.insert(
|
||||
header::IF_NONE_MATCH,
|
||||
HeaderValue::from_static("\"0000stale0000\""),
|
||||
);
|
||||
let response = serve_with_asset_root(
|
||||
"/assets/app.css",
|
||||
&conditional,
|
||||
Some(temp_dir.path()),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(response.headers().contains_key(header::ETAG));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn immutable_assets_skip_etag() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let asset_path = temp_dir.path().join("assets/entry-0sv53bs3.js");
|
||||
std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&asset_path, b"console.log(1)").unwrap();
|
||||
|
||||
let response = serve_with_asset_root(
|
||||
"/assets/entry-0sv53bs3.js",
|
||||
&HeaderMap::new(),
|
||||
Some(temp_dir.path()),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
!response.headers().contains_key(header::ETAG),
|
||||
"immutable assets never revalidate, so a validator is dead weight"
|
||||
);
|
||||
assert_eq!(cache_control("index.html"), "no-cache");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
202
lib/crates/fabro-server/tests/it/api/compression.rs
Normal file
202
lib/crates/fabro-server/tests/it/api/compression.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
//! Response compression on the outer router.
|
||||
//!
|
||||
//! The compression layer sits at the outermost edge of `build_router`, so
|
||||
//! these tests exercise it through the full middleware stack rather than in
|
||||
//! isolation. `/api/v1/openapi.json` is used as the probe response: it is a
|
||||
//! multi-hundred-KB JSON body, comfortably above the compression size floor.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
|
||||
)]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use fabro_server::server::RouterOptions;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::helpers::{api, test_app_state};
|
||||
|
||||
/// Router serving an SPA shell comfortably above the compression size floor,
|
||||
/// through the same fallback service production uses for static assets.
|
||||
fn spa_router_with_big_index() -> (Router, TempDir) {
|
||||
let temp_dir = tempfile::tempdir().expect("SPA fixture tempdir should create");
|
||||
std::fs::write(
|
||||
temp_dir.path().join("index.html"),
|
||||
format!("<!doctype html><title>spa</title>{}", "x".repeat(8192)),
|
||||
)
|
||||
.expect("SPA fixture index.html should write");
|
||||
let app = fabro_server::test_support::build_test_router_with_options(
|
||||
test_app_state(),
|
||||
RouterOptions {
|
||||
web_enabled: true,
|
||||
static_asset_root: Some(temp_dir.path().to_path_buf()),
|
||||
..RouterOptions::default()
|
||||
},
|
||||
);
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
async fn serve_on_ephemeral_port(app: Router) -> SocketAddr {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("test TCP listener should bind");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("test TCP listener should have a local address");
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn openapi_request(accept_encoding: Option<&str>) -> Request<Body> {
|
||||
let mut builder = Request::builder().method("GET").uri(api("/openapi.json"));
|
||||
if let Some(encoding) = accept_encoding {
|
||||
builder = builder.header(header::ACCEPT_ENCODING, encoding);
|
||||
}
|
||||
builder
|
||||
.body(Body::empty())
|
||||
.expect("openapi request should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_are_gzip_compressed_when_client_accepts_gzip() {
|
||||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
let response = app.oneshot(openapi_request(Some("gzip"))).await.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CONTENT_ENCODING)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("gzip"),
|
||||
"large JSON responses should be gzip-compressed when the client asks"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_are_brotli_compressed_when_client_prefers_br() {
|
||||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
let response = app
|
||||
.oneshot(openapi_request(Some("gzip, br")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CONTENT_ENCODING)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("br"),
|
||||
"brotli should win encoding negotiation when offered"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spa_assets_are_compressed() {
|
||||
// SPA assets are served by the router's fallback service, not a regular
|
||||
// route — this test pins that compression covers that path too, since the
|
||||
// multi-megabyte JS bundle is the single largest thing the server sends.
|
||||
let (app, _temp_dir) = spa_router_with_big_index();
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/")
|
||||
.header(header::ACCEPT, "text/html")
|
||||
.header(header::ACCEPT_ENCODING, "gzip")
|
||||
.body(Body::empty())
|
||||
.expect("spa request should build");
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CONTENT_ENCODING)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("gzip"),
|
||||
"SPA shell served through the fallback must be compressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compression_applies_over_a_real_tcp_connection() {
|
||||
// `oneshot` exercises the tower stack directly; this pins the same
|
||||
// behavior through hyper's real connection handling, matching how the
|
||||
// production server actually serves (`axum::serve`).
|
||||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
let addr = serve_on_ephemeral_port(app).await;
|
||||
|
||||
let response = fabro_test::test_http_client()
|
||||
.get(format!("http://{addr}/api/v1/openapi.json"))
|
||||
// Setting the header manually also disables reqwest's transparent
|
||||
// decompression, so Content-Encoding stays visible on the response.
|
||||
.header(header::ACCEPT_ENCODING.as_str(), "gzip")
|
||||
.send()
|
||||
.await
|
||||
.expect("openapi request should succeed");
|
||||
|
||||
assert_eq!(response.status(), fabro_http::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CONTENT_ENCODING.as_str())
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("gzip"),
|
||||
"compression must survive real hyper serving, not just oneshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spa_assets_compress_over_a_real_tcp_connection() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let (app, _temp_dir) = spa_router_with_big_index();
|
||||
let addr = serve_on_ephemeral_port(app).await;
|
||||
|
||||
// Raw HTTP/1.1 over the socket: no client-side redirect following or
|
||||
// transparent decompression can distort what the server actually sent.
|
||||
// Host matches the test state's canonical origin so the canonical-host
|
||||
// redirect stays out of the way.
|
||||
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
stream
|
||||
.write_all(
|
||||
b"GET / HTTP/1.1\r\nHost: localhost:3000\r\nAccept: text/html\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut raw = Vec::new();
|
||||
stream.read_to_end(&mut raw).await.unwrap();
|
||||
let head_len = raw
|
||||
.windows(4)
|
||||
.position(|w| w == b"\r\n\r\n")
|
||||
.expect("response should have a header block");
|
||||
let head = String::from_utf8_lossy(&raw[..head_len]).to_lowercase();
|
||||
|
||||
assert!(
|
||||
head.starts_with("http/1.1 200"),
|
||||
"unexpected response: {head}"
|
||||
);
|
||||
assert!(
|
||||
head.contains("content-encoding: gzip"),
|
||||
"fallback-served SPA shell must compress over real TCP; got:\n{head}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stay_identity_encoded_without_accept_encoding() {
|
||||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
let response = app.oneshot(openapi_request(None)).await.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
!response.headers().contains_key(header::CONTENT_ENCODING),
|
||||
"clients that don't advertise Accept-Encoding must get identity bodies"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod auth_sessions;
|
||||
mod automations;
|
||||
mod cli_auth_token;
|
||||
mod compression;
|
||||
mod docs;
|
||||
mod environments;
|
||||
mod events;
|
||||
|
|
|
|||
|
|
@ -8,24 +8,43 @@ use rust_embed::RustEmbed;
|
|||
#[exclude = "**/*.map"]
|
||||
struct EmbeddedAssets;
|
||||
|
||||
pub struct AssetBytes(Cow<'static, [u8]>);
|
||||
pub struct AssetBytes {
|
||||
data: Cow<'static, [u8]>,
|
||||
sha256: [u8; 32],
|
||||
}
|
||||
|
||||
impl AssetBytes {
|
||||
#[must_use]
|
||||
pub fn into_vec(self) -> Vec<u8> {
|
||||
self.0.into_owned()
|
||||
self.data.into_owned()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_cow(self) -> Cow<'static, [u8]> {
|
||||
self.data
|
||||
}
|
||||
|
||||
/// SHA-256 of the asset bytes. rust-embed computes it at compile time in
|
||||
/// release builds, so callers can use it as a validator without rehashing
|
||||
/// the body per request.
|
||||
#[must_use]
|
||||
pub fn sha256(&self) -> [u8; 32] {
|
||||
self.sha256
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for AssetBytes {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.0.as_ref()
|
||||
self.data.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(path: &str) -> Option<AssetBytes> {
|
||||
EmbeddedAssets::get(path).map(|file| AssetBytes(file.data))
|
||||
EmbeddedAssets::get(path).map(|file| AssetBytes {
|
||||
sha256: file.metadata.sha256_hash(),
|
||||
data: file.data,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -397,7 +397,7 @@ impl RunSession {
|
|||
.iter()
|
||||
.map(|(key, entry)| match entry {
|
||||
ResolvedMcpEntry::Resolved(server) => {
|
||||
runtime_mcp_server(server, process_env_var, |name| secret_lookup(name))
|
||||
runtime_mcp_server(server, process_env_var, secret_lookup)
|
||||
}
|
||||
// References must be resolved to concrete servers before the run
|
||||
// spec is persisted (server-side run-preparation pass). Reaching
|
||||
|
|
@ -429,9 +429,7 @@ impl RunSession {
|
|||
SandboxSpec::Local { working_directory }
|
||||
}
|
||||
SandboxProviderKind::Docker => SandboxSpec::Docker {
|
||||
config: Box::new(resolve_docker_config(resolved, |name| {
|
||||
secret_lookup(name)
|
||||
})?),
|
||||
config: Box::new(resolve_docker_config(resolved, secret_lookup)?),
|
||||
github_app: services.github_app.clone(),
|
||||
run_id: Some(record.run_id),
|
||||
clone_origin_url: record.repo_origin_url().map(str::to_string),
|
||||
|
|
@ -455,7 +453,7 @@ impl RunSession {
|
|||
|
||||
let toml_env = resolved
|
||||
.environment
|
||||
.resolve_env(process_env_var, |name| secret_lookup(name))
|
||||
.resolve_env(process_env_var, secret_lookup)
|
||||
.map_err(|err| Error::engine_with_source("failed to resolve run environment", err))?;
|
||||
let github_permissions: Option<HashMap<String, String>> =
|
||||
(!services.github_permissions.is_empty()).then(|| services.github_permissions.clone());
|
||||
|
|
@ -473,9 +471,8 @@ impl RunSession {
|
|||
};
|
||||
|
||||
let pr_config = resolved.pull_request.clone();
|
||||
let setup_commands = runtime_setup_commands(&resolved.prepare, process_env_var, |name| {
|
||||
secret_lookup(name)
|
||||
})?;
|
||||
let setup_commands =
|
||||
runtime_setup_commands(&resolved.prepare, process_env_var, secret_lookup)?;
|
||||
drop(vault_guard);
|
||||
|
||||
Ok(Self {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue