diff --git a/Cargo.lock b/Cargo.lock index d6c69461f..ea77ea7fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,12 +81,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aliasable" -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" @@ -188,12 +182,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "asn1-rs" version = "0.6.2" @@ -218,7 +206,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -230,7 +218,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -258,18 +246,6 @@ dependencies = [ "wait-timeout", ] -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-compression" version = "0.4.42" @@ -323,15 +299,9 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.89" @@ -340,7 +310,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -367,33 +337,12 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "auto_enums" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65398a2893f41bce5c9259f6e1a4f03fbae40637c1bdc755b4f387f48c613b03" -dependencies = [ - "derive_utils", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -727,7 +676,7 @@ checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -878,18 +827,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "backon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" -dependencies = [ - "fastrand", - "gloo-timers", - "tokio", + "syn", ] [[package]] @@ -932,15 +870,6 @@ dependencies = [ "vsimd", ] -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -1105,12 +1034,6 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - [[package]] name = "byteorder" version = "1.5.0" @@ -1220,7 +1143,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.1", + "strsim", ] [[package]] @@ -1238,10 +1161,10 @@ version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1275,12 +1198,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "cmsketch" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7ee2cfacbd29706479902b06d75ad8f1362900836aa32799eabc7e004bfd854" - [[package]] name = "colorchoice" version = "1.0.4" @@ -1510,7 +1427,7 @@ dependencies = [ "proc-macro2", "quote", "strict", - "syn 2.0.117", + "syn", ] [[package]] @@ -1574,16 +1491,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-skiplist" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1676,16 +1583,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "darling" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" -dependencies = [ - "darling_core 0.14.4", - "darling_macro 0.14.4", -] - [[package]] name = "darling" version = "0.20.11" @@ -1706,20 +1603,6 @@ dependencies = [ "darling_macro 0.23.0", ] -[[package]] -name = "darling_core" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.10.0", - "syn 1.0.109", -] - [[package]] name = "darling_core" version = "0.20.11" @@ -1730,8 +1613,8 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", - "syn 2.0.117", + "strsim", + "syn", ] [[package]] @@ -1743,19 +1626,8 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" -dependencies = [ - "darling_core 0.14.4", - "quote", - "syn 1.0.109", + "strsim", + "syn", ] [[package]] @@ -1766,7 +1638,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1777,7 +1649,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1907,7 +1779,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1917,7 +1789,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn", ] [[package]] @@ -1939,18 +1811,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "derive_utils" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "362f47930db19fe7735f527e6595e4900316b893ebf6d48ad3d31be928d57dd6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2033,7 +1894,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2057,31 +1918,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" -[[package]] -name = "duration-str" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f88959de2d447fd3eddcf1909d1f19fe084e27a056a6904203dc5d8b9e771c1e" -dependencies = [ - "rust_decimal", - "serde", - "thiserror 2.0.18", - "time", - "winnow 0.6.26", -] - [[package]] name = "dyn-clone" version = "1.0.20" @@ -2221,7 +2063,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "syn 2.0.117", + "syn", "uuid", ] @@ -2432,7 +2274,7 @@ dependencies = [ "ipnet", "serde", "serde_json", - "strsim 0.11.1", + "strsim", "strum 0.28.0", "temp-env", "tempfile", @@ -2632,7 +2474,7 @@ dependencies = [ "fabro-options-metadata", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3000,7 +2842,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "slatedb", "sqlx", "strum 0.28.0", "tempfile", @@ -3281,17 +3122,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "fail-parallel" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5666e8ca4ec174d896fb742789c29b1bea9319dcfd623c41bececc0a60c4939d" -dependencies = [ - "log", - "once_cell", - "rand 0.8.6", -] - [[package]] name = "fancy-regex" version = "0.17.0" @@ -3309,22 +3139,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "figment" -version = "0.10.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" -dependencies = [ - "atomic", - "pear", - "serde", - "serde_json", - "serde_yaml", - "toml 0.8.23", - "uncased", - "version_check", -] - [[package]] name = "filetime" version = "0.2.27" @@ -3342,16 +3156,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flatbuffers" -version = "25.12.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" -dependencies = [ - "bitflags", - "rustc_version", -] - [[package]] name = "flate2" version = "1.1.9" @@ -3382,18 +3186,6 @@ dependencies = [ "serde", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "nanorand", - "spin", -] - [[package]] name = "flume" version = "0.12.0" @@ -3456,112 +3248,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "foyer" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "642093b1a72c4a0ef89862484d669a353e732974781bb9c49a979526d1e30edc" -dependencies = [ - "equivalent", - "foyer-common", - "foyer-memory", - "foyer-storage", - "madsim-tokio", - "mixtrics", - "pin-project", - "serde", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "foyer-common" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9db9c0e4648b13e9216d785b308d43751ca975301aeb83e607ec630b6f956944" -dependencies = [ - "bincode", - "bytes", - "cfg-if", - "itertools", - "madsim-tokio", - "mixtrics", - "parking_lot", - "pin-project", - "serde", - "thiserror 2.0.18", - "tokio", - "twox-hash", -] - -[[package]] -name = "foyer-intrusive-collections" -version = "0.10.0-dev" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b" -dependencies = [ - "memoffset", -] - -[[package]] -name = "foyer-memory" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "040dc38acbfca8f1def26bbbd9e9199090884aabb15de99f7bf4060be66ff608" -dependencies = [ - "arc-swap", - "bitflags", - "cmsketch", - "equivalent", - "foyer-common", - "foyer-intrusive-collections", - "hashbrown 0.15.5", - "itertools", - "madsim-tokio", - "mixtrics", - "parking_lot", - "pin-project", - "serde", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "foyer-storage" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a77ed888da490e997da6d6d62fcbce3f202ccf28be098c4ea595ca046fc4a9" -dependencies = [ - "allocator-api2", - "anyhow", - "auto_enums", - "bytes", - "equivalent", - "flume 0.11.1", - "foyer-common", - "foyer-memory", - "fs4", - "futures-core", - "futures-util", - "itertools", - "libc", - "lz4", - "madsim-tokio", - "ordered_hash_map", - "parking_lot", - "paste", - "pin-project", - "rand 0.9.4", - "serde", - "thiserror 2.0.18", - "tokio", - "tracing", - "twox-hash", - "zstd", -] - [[package]] name = "fraction" version = "0.15.3" @@ -3582,16 +3268,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "fs4" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" -dependencies = [ - "rustix", - "windows-sys 0.59.0", -] - [[package]] name = "fs_extra" version = "1.3.0" @@ -3678,7 +3354,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3817,18 +3493,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "graphviz-sys" version = "0.1.0" @@ -3862,15 +3526,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -3883,8 +3538,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -3941,12 +3594,6 @@ dependencies = [ "http 1.4.0", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -4392,7 +4039,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4441,12 +4088,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "inlinable_string" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" - [[package]] name = "inout" version = "0.1.4" @@ -4572,7 +4213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4659,7 +4300,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.117", + "syn", ] [[package]] @@ -4816,25 +4457,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lz4" -version = "1.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" -dependencies = [ - "lz4-sys", -] - -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "mac_address" version = "1.1.8" @@ -4845,61 +4467,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "madsim" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18351aac4194337d6ea9ffbd25b3d1540ecc0754142af1bff5ba7392d1f6f771" -dependencies = [ - "ahash", - "async-channel", - "async-stream", - "async-task", - "bincode", - "bytes", - "downcast-rs", - "errno 0.3.14", - "futures-util", - "lazy_static", - "libc", - "madsim-macros", - "naive-timer", - "panic-message", - "rand 0.8.6", - "rand_xoshiro 0.6.0", - "rustversion", - "serde", - "spin", - "tokio", - "tokio-util", - "toml 0.9.12+spec-1.1.0", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "madsim-macros" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3d248e97b1a48826a12c3828d921e8548e714394bf17274dd0a93910dc946e1" -dependencies = [ - "darling 0.14.4", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "madsim-tokio" -version = "0.2.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d3eb2acc57c82d21d699119b859e2df70a91dbdb84734885a1e72be83bdecb5" -dependencies = [ - "madsim", - "spin", - "tokio", -] - [[package]] name = "marked-yaml" version = "0.8.0" @@ -5000,7 +4567,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -5066,16 +4633,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mixtrics" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb252c728b9d77c6ef9103f0c81524fa0a3d3b161d0a936295d7fbeff6e04c11" -dependencies = [ - "itertools", - "parking_lot", -] - [[package]] name = "multer" version = "3.1.0" @@ -5093,21 +4650,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "naive-timer" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "034a0ad7deebf0c2abcf2435950a6666c3c15ea9d8fad0c0f48efa8a7f843fed" - -[[package]] -name = "nanorand" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "native-tls" version = "0.2.18" @@ -5566,7 +5108,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -5609,15 +5151,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered_hash_map" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab0e5f22bf6dd04abd854a8874247813a8fa2c8c1260eba6fbb150270ce7c176" -dependencies = [ - "hashbrown 0.13.2", -] - [[package]] name = "os_info" version = "3.14.0" @@ -5634,30 +5167,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ouroboros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" -dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", -] - -[[package]] -name = "ouroboros_macro" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.117", -] - [[package]] name = "outref" version = "0.5.2" @@ -5670,12 +5179,6 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" -[[package]] -name = "panic-message" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384e52fd8fbd4cbe3c317e8216260c21a0f9134de108cea8a4dd4e7e152c472d" - [[package]] name = "parking" version = "2.2.1" @@ -5732,29 +5235,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pear" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.117", -] - [[package]] name = "pebble-agent" version = "0.1.0" @@ -6124,7 +5604,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -6224,7 +5704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -6246,7 +5726,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -6258,19 +5738,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "version_check", - "yansi", -] - [[package]] name = "process-wrap" version = "9.0.3" @@ -6317,7 +5784,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de362a0477182f45accdbad4d43cd89a95a1db0a518a7c1ddf3e525e6896f0f0" dependencies = [ - "heck 0.5.0", + "heck", "http 1.4.0", "indexmap 2.13.0", "openapiv3", @@ -6327,7 +5794,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "syn 2.0.117", + "syn", "thiserror 2.0.18", "typify", "unicode-ident", @@ -6348,7 +5815,7 @@ dependencies = [ "serde_json", "serde_tokenstream", "serde_yaml", - "syn 2.0.117", + "syn", ] [[package]] @@ -6533,24 +6000,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "rand_xoshiro" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rand_xoshiro" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" -dependencies = [ - "rand_core 0.9.5", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -6597,7 +6046,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -6826,7 +6275,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.117", + "syn", ] [[package]] @@ -6850,7 +6299,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.117", + "syn", "walkdir", ] @@ -6865,16 +6314,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "rust_decimal" -version = "1.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" -dependencies = [ - "arrayvec", - "num-traits", -] - [[package]] name = "rustc-demangle" version = "0.1.27" @@ -7203,7 +6642,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn", ] [[package]] @@ -7215,7 +6654,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn", ] [[package]] @@ -7369,7 +6808,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -7380,7 +6819,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -7439,7 +6878,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -7469,7 +6908,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn", ] [[package]] @@ -7513,7 +6952,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -7658,92 +7097,12 @@ dependencies = [ "time", ] -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "slatedb" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d945c6cd6f239873e640c5b7bb204f5638ed5681b02145cdcc2e80b2d3882b8a" -dependencies = [ - "anyhow", - "async-trait", - "atomic", - "backon", - "bitflags", - "bytemuck", - "bytes", - "chrono", - "crc32fast", - "crossbeam-skiplist", - "dotenvy", - "duration-str", - "fail-parallel", - "figment", - "flatbuffers", - "foyer", - "futures", - "log", - "object_store", - "once_cell", - "ouroboros", - "parking_lot", - "rand 0.9.4", - "rand_xoshiro 0.7.0", - "serde", - "serde_json", - "siphasher", - "slatedb-common", - "slatedb-txn-obj", - "sysinfo", - "thiserror 1.0.69", - "thread_local", - "tokio", - "tokio-util", - "tracing", - "ulid", - "url", - "uuid", - "walkdir", - "zstd", -] - -[[package]] -name = "slatedb-common" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2893ff22bb9a1013af0fb5e85380307ccd9d0a4fa8a111e187a48750e14b9a47" -dependencies = [ - "chrono", - "tokio", -] - -[[package]] -name = "slatedb-txn-obj" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c33d5597404e23b9706aa32a1723399f022f0513a289d1059df8810a282dcc4" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures", - "log", - "object_store", - "slatedb-common", - "thiserror 1.0.69", -] - [[package]] name = "smallvec" version = "1.15.1" @@ -7839,7 +7198,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.117", + "syn", ] [[package]] @@ -7851,7 +7210,7 @@ dependencies = [ "cfg-if", "dotenvy", "either", - "heck 0.5.0", + "heck", "hex", "proc-macro2", "quote", @@ -7860,7 +7219,7 @@ dependencies = [ "sha2 0.10.9", "sqlx-core", "sqlx-sqlite", - "syn 2.0.117", + "syn", "thiserror 2.0.18", "tokio", "url", @@ -7934,7 +7293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", - "flume 0.12.0", + "flume", "form_urlencoded", "futures-channel", "futures-core", @@ -7999,12 +7358,6 @@ dependencies = [ "unicode-properties", ] -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "strsim" version = "0.11.1" @@ -8035,10 +7388,10 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -8047,10 +7400,10 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -8080,17 +7433,6 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -8119,7 +7461,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -8276,7 +7618,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -8287,7 +7629,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -8380,7 +7722,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -8454,7 +7796,6 @@ dependencies = [ "futures-io", "futures-sink", "futures-util", - "hashbrown 0.15.5", "libc", "pin-project-lite", "tokio", @@ -8484,7 +7825,7 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.14", + "winnow", ] [[package]] @@ -8516,7 +7857,7 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow 0.7.14", + "winnow", ] [[package]] @@ -8525,7 +7866,7 @@ version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ - "winnow 0.7.14", + "winnow", ] [[package]] @@ -8623,7 +7964,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -8760,15 +8101,6 @@ dependencies = [ "twin-core", ] -[[package]] -name = "twox-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" -dependencies = [ - "rand 0.9.4", -] - [[package]] name = "typenum" version = "1.19.0" @@ -8791,7 +8123,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2fd0d27608a466d063d23b97cf2d26c25d838f01b4f7d5ff406a7446f16b6e3" dependencies = [ - "heck 0.5.0", + "heck", "log", "proc-macro2", "quote", @@ -8800,7 +8132,7 @@ dependencies = [ "semver", "serde", "serde_json", - "syn 2.0.117", + "syn", "thiserror 2.0.18", "unicode-ident", ] @@ -8818,7 +8150,7 @@ dependencies = [ "serde", "serde_json", "serde_tokenstream", - "syn 2.0.117", + "syn", "typify-impl", ] @@ -8829,7 +8161,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ "rand 0.9.4", - "serde", "web-time", ] @@ -8842,15 +8173,6 @@ dependencies = [ "libc", ] -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] - [[package]] name = "unicase" version = "2.9.0" @@ -9150,7 +8472,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -9406,7 +8728,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -9417,7 +8739,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -9748,15 +9070,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.6.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "0.7.14" @@ -9782,7 +9095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -9793,10 +9106,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap 2.13.0", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -9812,7 +9125,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -9904,12 +9217,6 @@ dependencies = [ "hashlink 0.10.0", ] -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yoke" version = "0.8.1" @@ -9929,7 +9236,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -9950,7 +9257,7 @@ checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -9970,7 +9277,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -10010,7 +9317,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index df7a9cd20..4d92ae336 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -13766,54 +13766,24 @@ components: example: Propose Changes RunCheckpoint: - description: Serializable snapshot of execution state for crash recovery and resume. + description: >- + A checkpoint Fabro recorded for the run: when, at which node, and + the commit the workspace was checkpointed at, when it was committed. type: object required: - timestamp - current_node - - completed_nodes - - node_retries - - context_values properties: timestamp: type: string format: date-time - description: ISO 8601 timestamp when the checkpoint was created. + description: When the checkpoint was recorded. current_node: type: string - description: Identifier of the node being executed at checkpoint time. - completed_nodes: - type: array - items: - type: string - description: Identifiers of nodes that have completed execution. - node_retries: - type: object - additionalProperties: - type: integer - description: Map of node identifier to retry count. - context_values: - type: object - additionalProperties: true - description: Key-value context map accumulated during execution. - node_outcomes: - type: object - additionalProperties: true - description: Map of node identifier to outcome data for goal gate checks after resume. - next_node_id: - type: string - description: The node to resume execution at after this checkpoint. + description: The node the checkpoint was recorded for. git_commit_sha: type: string description: SHA of the git commit created at this checkpoint. - loop_failure_signatures: - type: object - additionalProperties: true - description: Failure signature counts within the main loop. - restart_failure_signatures: - type: object - additionalProperties: true - description: Failure signature counts across loop_restart edges. # ── Stage / Turn Schemas ───────────────────────────────────────────── diff --git a/lib/apps/fabro-cli/src/commands/run/output.rs b/lib/apps/fabro-cli/src/commands/run/output.rs index e07a0e6b3..50fa18de1 100644 --- a/lib/apps/fabro-cli/src/commands/run/output.rs +++ b/lib/apps/fabro-cli/src/commands/run/output.rs @@ -156,7 +156,6 @@ pub(crate) async fn print_run_summary_with_client( printer: Printer, ) -> Result<()> { let run_state = client.get_run_state(run_id).await?; - let checkpoint = run_state.current_checkpoint().cloned(); let conclusion = run_state.conclusion.clone(); let pr_url = run_state .pull_request @@ -174,8 +173,7 @@ pub(crate) async fn print_run_summary_with_client( styles, printer, ); - let final_output = - resolve_final_output_with_client(client, run_id, checkpoint.as_ref()).await?; + let final_output = resolve_final_output_with_client(client, run_id, &run_state).await?; print_final_output(final_output.as_deref(), styles, printer); print_assets_with_client(client, run_id, styles, printer).await?; Ok(()) @@ -297,20 +295,19 @@ pub(crate) fn print_final_output(output: Option<&str>, styles: &Styles, printer: } } +/// The run's final output: the response of the last stage that produced +/// one, resolved from the blob table when the projection holds a blob +/// reference in its place. async fn resolve_final_output_with_client( client: &server_client::Client, run_id: &RunId, - checkpoint: Option<&fabro_types::Checkpoint>, + run_state: &server_client::RunProjection, ) -> Result> { - let Some(checkpoint) = checkpoint else { - return Ok(None); - }; - - for node_id in checkpoint.completed_nodes.iter().rev() { - let key = format!("response.{node_id}"); - let Some(serde_json::Value::String(response)) = checkpoint.context_values.get(&key) else { - continue; - }; + let responses = run_state + .iter_stages() + .filter_map(|(_, stage)| stage.response.clone()) + .collect::>(); + for response in responses.iter().rev() { let Some(output) = resolve_response_string(client, run_id, response).await? else { continue; }; diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs index 135808c18..201e0ed5b 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, Utc}; -use fabro_workflow::event::RunNoticeLevel; +use fabro_types::RunNoticeLevel; use pebble_coding_agent::events::{CodingEvent, ErrorKind as AgentErrorKind, LlmOutputKind}; use serde_json::Value; diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs index a19f3440e..420db28a1 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs @@ -1,6 +1,6 @@ use std::path::Path; -use fabro_workflow::event::RunNoticeLevel; +use fabro_types::RunNoticeLevel; use super::renderer::ProgressRenderer; use super::styles; diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/petri.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/petri.rs index 502b5c062..e1f6dbfab 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/petri.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/petri.rs @@ -7,8 +7,7 @@ //! fork's `parallel.branch` delegates are the branches of the parallel //! group, never stages of their own. -use fabro_types::run_event::RunNoticeLevel; -use fabro_types::{CodingAgentEvent, RunStreamItem, StageOutcome, StageTiming}; +use fabro_types::{CodingAgentEvent, RunNoticeLevel, RunStreamItem, StageOutcome, StageTiming}; use serde_json::Value; use super::event::{ProgressEvent, ProgressUsage, coding_progress_event}; diff --git a/lib/apps/fabro-cli/tests/it/workflow/mod.rs b/lib/apps/fabro-cli/tests/it/workflow/mod.rs index 0b716a7ad..b6e3a8a37 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/mod.rs @@ -43,12 +43,16 @@ pub(super) fn read_run_spec(run_dir: &Path) -> Value { serde_json::to_value(run_state(run_dir).spec).expect("run spec should serialize") } +/// The nodes whose stages succeeded, in the order they first ran. pub(super) fn completed_nodes(run_dir: &Path) -> Vec { let state = run_state(run_dir); - let cp = state - .current_checkpoint() - .expect("run store checkpoint should exist"); - cp.completed_nodes.clone() + let mut nodes = state + .iter_stages() + .filter(|(_, stage)| stage.state == fabro_types::StageState::Succeeded) + .map(|(stage_id, _)| stage_id.node_id().to_string()) + .collect::>(); + nodes.dedup(); + nodes } pub(super) fn has_event(run_dir: &Path, event_name: &str) -> bool { diff --git a/lib/apps/fabro-server/src/automation_materializer.rs b/lib/apps/fabro-server/src/automation_materializer.rs index 80d8cb917..c3feace37 100644 --- a/lib/apps/fabro-server/src/automation_materializer.rs +++ b/lib/apps/fabro-server/src/automation_materializer.rs @@ -533,9 +533,7 @@ mod tests { use std::fs; use std::path::Path; use std::sync::Mutex; - use std::time::Duration; - use object_store::memory::InMemory; use tempfile::TempDir; use super::*; @@ -762,12 +760,7 @@ mod tests { } fn test_version_store() -> WorkflowVersionStore { - let database = fabro_store::test_support::test_database( - Arc::new(InMemory::new()), - "", - Duration::from_millis(1), - None, - ); + let database = fabro_store::test_support::test_database(); WorkflowVersionStore::new(database.blobs()) } diff --git a/lib/apps/fabro-server/src/serve.rs b/lib/apps/fabro-server/src/serve.rs index 89ab0f070..67adb2df8 100644 --- a/lib/apps/fabro-server/src/serve.rs +++ b/lib/apps/fabro-server/src/serve.rs @@ -582,32 +582,6 @@ pub fn build_artifact_object_store( build_artifact_object_store_with_server_secrets(settings, &server_secrets) } -fn build_slatedb_store_with_server_secrets( - settings: &ServerNamespace, - server_secrets: &ServerSecrets, -) -> anyhow::Result<(Arc, String, Duration, bool)> { - let prefix = settings.slatedb.prefix.clone(); - let object_store = build_object_store_from_settings_with_lookup( - &settings.slatedb.store, - &|name| server_secrets.get(name), - None, - )?; - Ok(( - object_store, - prefix, - settings.slatedb.flush_interval, - settings.slatedb.disk_cache, - )) -} - -#[cfg(test)] -fn build_slatedb_store( - settings: &ServerNamespace, -) -> anyhow::Result<(Arc, String, Duration, bool)> { - let server_secrets = load_server_secrets_for_settings(settings)?; - build_slatedb_store_with_server_secrets(settings, &server_secrets) -} - /// Start the HTTP API server. /// /// # Errors @@ -741,32 +715,10 @@ where } else { false }; - let (object_store, slatedb_prefix, flush_interval, disk_cache) = - build_slatedb_store_with_server_secrets(&resolved_server_settings, &server_secrets)?; - let cache_path = if disk_cache { - Some(storage.slatedb_cache_dir()) - } else { - None - }; let store = Arc::new(fabro_store::Database::new( - object_store, - slatedb_prefix, - flush_interval, - cache_path, Arc::new(fabro_store::BlobStore::new(database.clone_pool())), Arc::new(fabro_store::RunSummaryStore::new(database.clone_pool())), )); - // Refresh tokens now live in SQLite. Nothing reads the old records and no - // reaper collects them any more, so clear them out once rather than - // leaving them in the object store forever. Pending authorization codes - // also moved to SQLite, but their old records are left in place: at most a - // handful exist at cutover, every binary rejects them within 60 seconds of - // issue, and nothing reads their keyspace again. - match store.retire_refresh_token_keyspace().await { - Ok(0) => {} - Ok(removed) => info!(removed, "Removed retired SlateDB refresh token records"), - Err(err) => warn!(error = %err, "Failed to remove retired SlateDB refresh token records"), - } let (artifact_object_store, artifact_prefix) = build_artifact_object_store_with_server_secrets( &resolved_server_settings, &server_secrets, @@ -1216,9 +1168,9 @@ mod tests { use super::{ SHUTDOWN_GRACE_PERIOD, ServeArgs, ServerTitlePhase, apply_effective_log_destination, bind_tcp_host_with_fallback, build_local_object_store_with_preference, - build_object_store_from_settings_with_lookup, build_slatedb_store, - force_exit_after_shutdown, resolve_bind_request_from_server_settings, serve_overrides, - serve_until_shutdown, server_bind_title, server_title, spawn_shutdown_orchestrator_inner, + build_object_store_from_settings_with_lookup, force_exit_after_shutdown, + resolve_bind_request_from_server_settings, serve_overrides, serve_until_shutdown, + server_bind_title, server_title, spawn_shutdown_orchestrator_inner, }; use crate::server::ResolvedAppStateSettings; @@ -1613,46 +1565,6 @@ strategy = "token" drop(mem_store); } - #[test] - fn build_slatedb_store_uses_configured_local_root() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("custom-slatedb"); - let resolved = server_settings(&format!( - r#" -_version = 1 - -[server.slatedb.local] -root = "{}" -"#, - root.display() - )) - .server; - let (_object_store, prefix, flush_interval, disk_cache) = - build_slatedb_store(&resolved).expect("slatedb store should build"); - - assert!(root.exists(), "configured SlateDB root should be created"); - assert_eq!(prefix, ""); - assert_eq!(flush_interval, Duration::from_millis(1)); - assert!(!disk_cache); - } - - #[test] - fn build_slatedb_store_returns_disk_cache_when_enabled() { - let resolved = server_settings( - r" -_version = 1 - -[server.slatedb] -disk_cache = true -", - ) - .server; - let (_object_store, _prefix, _flush_interval, disk_cache) = - build_slatedb_store(&resolved).expect("slatedb store should build"); - - assert!(disk_cache); - } - #[test] fn build_object_store_from_settings_uses_injected_static_credentials() { let settings = ObjectStoreSettings::S3 { diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index f7bf3c1b6..074f9593b 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -1024,6 +1024,8 @@ pub struct AppState { /// proceed in parallel. See `crate::run_files` for semantics. pub(crate) files_in_flight: FilesInFlight, pull_request_create_locks: KeyedMutex, + /// One lock per run around a control request's check-and-append. + control_request_locks: KeyedMutex, parent_link_lock: AsyncMutex<()>, pub(super) server_secrets: ServerSecrets, @@ -2556,6 +2558,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result RunLifecycleKind::PauseRequested, RunControlAction::Unpause => RunLifecycleKind::UnpauseRequested, }; + // The check and the append are one step under the run's control lock, + // so two concurrent requests for the same control record it once. + let _guard = state.control_request_locks.lock(run_id).await; if action == RunControlAction::Cancel { // A cancel already pending is not asked for twice. let pending = run_records::projection(state, run_id) diff --git a/lib/apps/fabro-server/src/server/handler/sandbox.rs b/lib/apps/fabro-server/src/server/handler/sandbox.rs index c76f8f155..431170f99 100644 --- a/lib/apps/fabro-server/src/server/handler/sandbox.rs +++ b/lib/apps/fabro-server/src/server/handler/sandbox.rs @@ -1009,14 +1009,23 @@ mod tests { #[cfg(test)] mod retrieve_sandbox_tests { + use std::collections::BTreeMap; + + use axum::Router; use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; - use fabro_sandbox::test_support::local_sandbox_id; - use fabro_types::{Graph, RunId, WorkflowSettings, test_support}; + use fabro_types::{RunId, WorkflowPath, WorkflowVersion}; use serde_json::{Value, json}; use tower::ServiceExt; - use crate::test_support::{build_test_router, test_app_state}; + use crate::test_support::{build_test_router, test_app_state, test_register_workflow_version}; + + const MINIMAL_DOT: &str = r#"digraph Test { + graph [goal="Test"] + start [shape=Mdiamond] + exit [shape=Msquare] + start -> exit +}"#; fn req_get(uri: &str) -> Request { Request::builder() @@ -1026,14 +1035,6 @@ mod retrieve_sandbox_tests { .expect("sandbox details GET request should build") } - fn req_post(uri: &str) -> Request { - Request::builder() - .method("POST") - .uri(uri) - .body(Body::empty()) - .expect("sandbox POST request should build") - } - async fn body_json(response: axum::response::Response) -> Value { let bytes = to_bytes(response.into_body(), usize::MAX) .await @@ -1041,86 +1042,38 @@ mod retrieve_sandbox_tests { serde_json::from_slice(&bytes).expect("response body should be valid JSON") } - async fn append_run_created(run_store: &fabro_store::RunDatabase, run_id: &RunId) { - let payload = fabro_store::EventPayload::new( - json!({ - "id": "evt-run-created", - "ts": "2026-05-09T11:59:00Z", - "run_id": run_id, - "event": "run.created", - "properties": { - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "provenance": test_support::test_run_provenance(), - }, - }), - run_id, - ) - .expect("run.created payload should validate"); - run_store.append_event(&payload).await.unwrap(); - } - - async fn append_sandbox_initialized( - run_store: &fabro_store::RunDatabase, - run_id: &RunId, - provider: &str, - ) { - append_sandbox_initialized_in( - run_store, - run_id, - provider, - &format!("{provider}:sandbox-id"), - "/workspace", - ) - .await; - } - - /// A local sandbox reconnects by the id the Host provider derives from - /// its working directory, so a test that reaches one records an - /// existing directory under the id fabro would have written for it. - async fn append_sandbox_initialized_in( - run_store: &fabro_store::RunDatabase, - run_id: &RunId, - provider: &str, - id: &str, - working_directory: &str, - ) { - let payload = fabro_store::EventPayload::new( - json!({ - "id": "evt-sandbox-init", - "ts": "2026-05-09T12:00:00Z", - "run_id": run_id, - "event": "sandbox.initialized", - "properties": { - "provider": provider, - "id": id, - "working_directory": working_directory, - }, - }), - run_id, - ) - .expect("sandbox.initialized payload should validate"); - run_store.append_event(&payload).await.unwrap(); - } - - async fn append_sandbox_failed(run_store: &fabro_store::RunDatabase, run_id: &RunId) { - let payload = fabro_store::EventPayload::new( - json!({ - "id": "evt-sandbox-failed", - "ts": "2026-05-09T12:00:00Z", - "run_id": run_id, - "event": "sandbox.failed", - "properties": { - "provider": "docker", - "error": "Docker daemon unavailable", - "causes": ["connection refused"], - "duration_ms": 42, - }, - }), - run_id, - ) - .expect("sandbox.failed payload should validate"); - run_store.append_event(&payload).await.unwrap(); + /// Create a run through the API. A run that has not started has a + /// planned sandbox and nothing else. + async fn create_run(app: &Router) -> RunId { + let entrypoint = WorkflowPath::new("workflow.fabro").expect("entrypoint should parse"); + let files = BTreeMap::from([(entrypoint.clone(), MINIMAL_DOT.to_string())]); + let version = WorkflowVersion::new(entrypoint, files, BTreeMap::new()) + .expect("workflow version should build"); + let workflow_version_id = test_register_workflow_version(app, &version, None).await; + let intent = json!({ + "workflow_version_id": workflow_version_id, + "target": { "kind": "none" }, + "args": {} + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/runs") + .header("content-type", "application/json") + .body(Body::from(intent.to_string())) + .expect("run creation request should build"), + ) + .await + .expect("run creation should route"); + assert_eq!(response.status(), StatusCode::CREATED); + let body = body_json(response).await; + body["id"] + .as_str() + .expect("run creation response should carry the run id") + .parse() + .expect("run id should parse") } async fn assert_sandbox_not_created_response(response: axum::response::Response) { @@ -1156,15 +1109,8 @@ mod retrieve_sandbox_tests { #[tokio::test] async fn planned_sandbox_returns_404_from_details_endpoint() { - let state = test_app_state(); - let app = build_test_router(state.clone()); - let run_id = RunId::new(); - let run_store = state - .store_ref() - .create_run(&run_id) - .await - .expect("test run should be creatable"); - append_run_created(&run_store, &run_id).await; + let app = build_test_router(test_app_state()); + let run_id = create_run(&app).await; let response = app .oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox"))) .await @@ -1174,15 +1120,8 @@ mod retrieve_sandbox_tests { #[tokio::test] async fn planned_sandbox_rejects_live_operations() { - let state = test_app_state(); - let app = build_test_router(state.clone()); - let run_id = RunId::new(); - let run_store = state - .store_ref() - .create_run(&run_id) - .await - .expect("test run should be creatable"); - append_run_created(&run_store, &run_id).await; + let app = build_test_router(test_app_state()); + let run_id = create_run(&app).await; for uri in [ format!("/api/v1/runs/{run_id}/sandbox/services"), @@ -1193,118 +1132,4 @@ mod retrieve_sandbox_tests { assert_sandbox_not_created_response(response).await; } } - - #[tokio::test] - async fn failed_sandbox_rejects_live_operations() { - let state = test_app_state(); - let app = build_test_router(state.clone()); - let run_id = RunId::new(); - let run_store = state - .store_ref() - .create_run(&run_id) - .await - .expect("test run should be creatable"); - append_run_created(&run_store, &run_id).await; - append_sandbox_failed(&run_store, &run_id).await; - - for uri in [ - format!("/api/v1/runs/{run_id}/sandbox/services"), - format!("/api/v1/runs/{run_id}/sandbox/files?path=/workspace"), - format!("/api/v1/runs/{run_id}/sandbox/file?path=/workspace/README.md"), - ] { - let response = app.clone().oneshot(req_get(&uri)).await.unwrap(); - assert_sandbox_not_created_response(response).await; - } - } - - #[tokio::test] - async fn local_sandbox_returns_provider_neutral_details() { - let state = test_app_state(); - let app = build_test_router(state.clone()); - let run_id = RunId::new(); - let run_store = state - .store_ref() - .create_run(&run_id) - .await - .expect("test run should be creatable"); - append_run_created(&run_store, &run_id).await; - let workspace = tempfile::tempdir().expect("scratch directory"); - let working_directory = workspace.path().to_str().expect("utf-8").to_owned(); - let id = local_sandbox_id(workspace.path()).await; - append_sandbox_initialized_in(&run_store, &run_id, "local", &id, &working_directory).await; - - let response = app - .oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox"))) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response).await; - assert_eq!(body["sandbox"]["provider"], "local"); - assert_eq!(body["sandbox"]["runtime"]["id"], id); - assert_eq!( - body["sandbox"]["runtime"]["working_directory"], - working_directory - ); - assert_eq!(body["status"]["state"], "running"); - assert_eq!(body["status"]["workspace_ownership"], "designated"); - assert!( - body["status"]["id"] - .as_str() - .is_some_and(|id| id.starts_with("host-dir-")), - "{}", - body["status"]["id"] - ); - assert!(body.get("state").is_none(), "the status is not flattened"); - assert!(body.get("identifier").is_none()); - } - - #[tokio::test] - async fn local_sandbox_vnc_returns_501() { - let state = test_app_state(); - let app = build_test_router(state.clone()); - let run_id = RunId::new(); - let run_store = state - .store_ref() - .create_run(&run_id) - .await - .expect("test run should be creatable"); - append_run_created(&run_store, &run_id).await; - let workspace = tempfile::tempdir().expect("scratch directory"); - append_sandbox_initialized_in( - &run_store, - &run_id, - "local", - &local_sandbox_id(workspace.path()).await, - workspace.path().to_str().expect("utf-8"), - ) - .await; - - let response = app - .oneshot(req_post(&format!("/api/v1/runs/{run_id}/sandbox/vnc"))) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - } - - #[tokio::test] - async fn docker_sandbox_vnc_returns_501_without_reconnect() { - let state = test_app_state(); - let app = build_test_router(state.clone()); - let run_id = RunId::new(); - let run_store = state - .store_ref() - .create_run(&run_id) - .await - .expect("test run should be creatable"); - append_run_created(&run_store, &run_id).await; - append_sandbox_initialized(&run_store, &run_id, "docker").await; - - let response = app - .oneshot(req_post(&format!("/api/v1/runs/{run_id}/sandbox/vnc"))) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - } } diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index f5c25f788..57de156fd 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -15,6 +15,9 @@ use fabro_interview::{ AnswerValue, WorkerControlDeliveryFrame, WorkerControlEnvelope, WorkerControlMessage, }; use fabro_llm::lithos_catalog::Catalog; +use fabro_store::platform_records::{ + PlatformRecord, RunLifecycleKind, RunLifecycleRecord, StoredPlatformRecord, +}; use fabro_types::settings::ServerAuthMethod; use fabro_types::settings::run::{ApprovalMode, RunMode}; use fabro_types::{ @@ -3255,15 +3258,7 @@ async fn post_runs_run_intent_derives_workflow_slug_from_immutable_entrypoint() ) .await; let run_id = body["id"].as_str().unwrap().parse::().unwrap(); - let projection = state - .stores - .runs - .open_run_reader(&run_id) - .await - .unwrap() - .state() - .await - .unwrap(); + let projection = state.load_run_projection(&run_id).await.unwrap(); assert_eq!( projection.spec.workflow_slug.as_deref(), @@ -3325,16 +3320,8 @@ file = "goal.md" let run_id = body["id"].as_str().unwrap().parse::().unwrap(); 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(|event| event.event.event_name()) - .collect::>(), - vec!["run.created", "run.submitted"] - ); - let projection = run_store.state().await.unwrap(); + assert_created_then_submitted(&platform_records(&state, run_id).await); + let projection = state.load_run_projection(&run_id).await.unwrap(); assert_eq!( projection.spec.workflow_version_id, Some(workflow_version_id) @@ -3425,16 +3412,8 @@ async fn post_runs_run_intent_creates_submitted_none_target_without_git_projecti let run_id = body["id"].as_str().unwrap().parse::().unwrap(); 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(|event| event.event.event_name()) - .collect::>(), - vec!["run.created", "run.submitted"] - ); - let projection = run_store.state().await.unwrap(); + assert_created_then_submitted(&platform_records(&state, run_id).await); + let projection = state.load_run_projection(&run_id).await.unwrap(); assert_eq!( projection.spec.target, Some(fabro_types::RunTarget::None {}) @@ -3477,8 +3456,7 @@ async fn post_runs_run_intent_args_true_override_resolved_settings_without_start let run_id = body["id"].as_str().unwrap().parse::().unwrap(); assert_eq!(body["lifecycle"]["status"]["kind"], "submitted"); - let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap(); - let projection = run_store.state().await.unwrap(); + let projection = state.load_run_projection(&run_id).await.unwrap(); assert_eq!(projection.spec.settings.run.execution.mode, RunMode::DryRun); assert_eq!( projection.spec.settings.run.execution.approval, @@ -3563,19 +3541,11 @@ async fn post_runs_run_intent_dry_run_uses_configured_target_provider() { ) .await; let run_id = body["id"].as_str().unwrap().parse::().unwrap(); - let projection = state - .stores - .runs - .open_run_reader(&run_id) - .await - .unwrap() - .state() - .await - .unwrap(); + let projection = state.load_run_projection(&run_id).await.unwrap(); assert_eq!(projection.spec.settings.run.execution.mode, RunMode::DryRun); assert_eq!( - serde_json::to_value(projection.spec.target.unwrap()).unwrap(), + serde_json::to_value(projection.spec.target.clone().unwrap()).unwrap(), target ); } @@ -3690,20 +3660,8 @@ preserve = true .parse::() .unwrap(); let omitted_id = omitted["id"].as_str().unwrap().parse::().unwrap(); - let explicit_false_store = state - .stores - .runs - .open_run_reader(&explicit_false_id) - .await - .unwrap(); - let explicit_false = explicit_false_store.state().await.unwrap(); - let omitted_store = state - .stores - .runs - .open_run_reader(&omitted_id) - .await - .unwrap(); - let omitted = omitted_store.state().await.unwrap(); + let explicit_false = state.load_run_projection(&explicit_false_id).await.unwrap(); + let omitted = state.load_run_projection(&omitted_id).await.unwrap(); assert_eq!( explicit_false.spec.settings.run.execution.mode, @@ -3763,16 +3721,8 @@ async fn post_runs_run_intent_canonicalizes_and_persists_a_local_folder_target() let run_id = body["id"].as_str().unwrap().parse::().unwrap(); 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(|event| event.event.event_name()) - .collect::>(), - vec!["run.created", "run.submitted"] - ); - let projection = run_store.state().await.unwrap(); + assert_created_then_submitted(&platform_records(&state, run_id).await); + let projection = state.load_run_projection(&run_id).await.unwrap(); assert_eq!( projection.spec.target, Some(fabro_types::RunTarget::Folder { @@ -3872,15 +3822,7 @@ async fn post_runs_run_intent_accepts_automatic_pull_requests_for_configured_doc ) .await; let run_id = body["id"].as_str().unwrap().parse::().unwrap(); - let projection = state - .stores - .runs - .open_run_reader(&run_id) - .await - .unwrap() - .state() - .await - .unwrap(); + let projection = state.load_run_projection(&run_id).await.unwrap(); assert_eq!( projection.spec.settings.run.environment.provider, @@ -3919,16 +3861,8 @@ async fn post_runs_run_intent_observes_folder_git_metadata_without_a_remote_call let body = post_run_intent(&app, folder_intent(workflow_version_id, canonical)).await; let run_id = body["id"].as_str().unwrap().parse::().unwrap(); - let projection = state - .stores - .runs - .open_run_reader(&run_id) - .await - .unwrap() - .state() - .await - .unwrap(); - let git = projection.spec.git.unwrap(); + let projection = state.load_run_projection(&run_id).await.unwrap(); + let git = projection.spec.git.clone().unwrap(); assert_eq!(git.origin_url, "https://github.com/acme/widgets"); assert!(!git.branch.is_empty()); @@ -4177,15 +4111,7 @@ async fn post_runs_run_intent_accepts_none_target_with_ready_daytona_environment .await; let run_id = body["id"].as_str().unwrap().parse::().unwrap(); - let projection = state - .stores - .runs - .open_run_reader(&run_id) - .await - .unwrap() - .state() - .await - .unwrap(); + let projection = state.load_run_projection(&run_id).await.unwrap(); assert_eq!( projection.spec.target, Some(fabro_types::RunTarget::None {}) @@ -4518,24 +4444,14 @@ async fn create_run_from_intent_helper_persists_automation_version_and_exact_tar .unwrap() .unwrap(); assert_eq!(summary.automation, Some(automation.clone())); - let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap(); - let projection = run_store.state().await.unwrap(); + let projection = state.load_run_projection(&run_id).await.unwrap(); assert_eq!( projection.spec.workflow_version_id, Some(workflow_version_id) ); assert_eq!(projection.spec.target, Some(target)); assert_eq!(projection.spec.automation, Some(automation)); - assert_eq!( - run_store - .list_events() - .await - .unwrap() - .iter() - .map(|event| event.event.event_name()) - .collect::>(), - ["run.created", "run.submitted"] - ); + assert_created_then_submitted(&platform_records(&state, run_id).await); } #[tokio::test] @@ -4635,13 +4551,50 @@ async fn wait_for_mock_hits(mock: &httpmock::Mock<'_>, expected: usize) { } async fn title_update_event_count(state: &AppState, run_id: RunId) -> usize { - let run_store = state.stores.runs.open_run(&run_id).await.unwrap(); - run_store - .list_events() + platform_records(state, run_id) + .await + .iter() + .filter(|stored| matches!(stored.record, PlatformRecord::RunTitle(_))) + .count() +} + +/// Every platform record of the run, in seq order. +async fn platform_records(state: &AppState, run_id: RunId) -> Vec { + state + .stores + .run_summaries + .platform_records() + .read(&run_id) .await .unwrap() - .into_iter() - .filter(|event| event.event.event_name() == "run.title.updated") +} + +/// The records a freshly created run holds: `run.created`, then the +/// `submitted` lifecycle transition. +fn assert_created_then_submitted(records: &[StoredPlatformRecord]) { + assert_eq!( + records + .iter() + .map(|stored| stored.record.kind().to_string()) + .collect::>(), + ["run.created", "run.lifecycle"] + ); + let PlatformRecord::RunLifecycle(lifecycle) = &records[1].record else { + panic!("the second record should be a lifecycle transition"); + }; + assert_eq!(lifecycle.transition, RunLifecycleKind::Submitted); +} + +/// The run's lifecycle transitions of `kind`. +fn lifecycle_transition_count(records: &[StoredPlatformRecord], kind: RunLifecycleKind) -> usize { + records + .iter() + .filter(|stored| { + matches!( + &stored.record, + PlatformRecord::RunLifecycle(lifecycle) if lifecycle.transition == kind + ) + }) .count() } @@ -4864,29 +4817,12 @@ fn test_priced_usage( ) } -async fn append_raw_run_event( - state: &Arc, - run_id: RunId, - seq_hint: &str, - ts: &str, - event: &str, - properties: serde_json::Value, - node_id: Option<&str>, -) { - let run_store = state.stores.runs.open_run(&run_id).await.unwrap(); - let payload = fabro_store::EventPayload::new( - json!({ - "id": format!("evt-{seq_hint}"), - "ts": ts, - "run_id": run_id, - "event": event, - "node_id": node_id, - "properties": properties, - }), - &run_id, - ) - .unwrap(); - run_store.append_event(&payload).await.unwrap(); +/// Seed the lifecycle transitions a worker would have recorded, so the +/// run's projection stands where the test needs it. +async fn seed_lifecycle(state: &AppState, run_id: RunId, records: Vec) { + for record in records { + run_records::lifecycle(state, run_id, record).await.unwrap(); + } } fn github_token_settings() -> ServerSettings { @@ -6473,7 +6409,7 @@ async fn submit_pending_interview_answer_rejects_invalid_answer_shape() { text: "Approve deploy?".to_string(), stage: "gate".to_string(), question_type: QuestionType::MultipleChoice, - options: vec![fabro_types::run_event::InterviewOption { + options: vec![fabro_types::InterviewOption { key: "approve".to_string(), label: "Approve".to_string(), description: None, @@ -6566,7 +6502,7 @@ fn answer_from_typed_selected_request_validates_and_attaches_option() { text: "Choose one.".to_string(), stage: "gate".to_string(), question_type: QuestionType::MultipleChoice, - options: vec![fabro_types::run_event::InterviewOption { + options: vec![fabro_types::InterviewOption { key: "approve".to_string(), label: "Approve".to_string(), description: None, @@ -6600,13 +6536,13 @@ fn answer_from_typed_multi_selected_request_validates_option_keys() { stage: "gate".to_string(), question_type: QuestionType::MultiSelect, options: vec![ - fabro_types::run_event::InterviewOption { + fabro_types::InterviewOption { key: "approve".to_string(), label: "Approve".to_string(), description: None, preview: None, }, - fabro_types::run_event::InterviewOption { + fabro_types::InterviewOption { key: "notify".to_string(), label: "Notify".to_string(), description: None, @@ -6856,12 +6792,13 @@ async fn unlink_run_pull_request_appends_event_and_clears_projected_state() { assert!(state_body["pull_request"].is_null()); let run_id = run_id.parse::().unwrap(); - let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap(); - let events = run_store.list_events().await.unwrap(); - assert!(events.iter().any(|event| { - event.event.event_name() == "pull_request.unlinked" - && event.event.properties().unwrap()["pull_request"]["html_url"] - == "https://github.com/acme/widgets/pull/42" + let records = platform_records(&state, run_id).await; + assert!(records.iter().any(|stored| { + matches!( + &stored.record, + PlatformRecord::PullRequestUnlinked(unlinked) + if unlinked.link().html_url() == "https://github.com/acme/widgets/pull/42" + ) })); } @@ -7068,192 +7005,6 @@ async fn list_run_events_returns_paginated_json() { assert!(body["meta"]["has_more"].is_boolean()); } -#[tokio::test] -async fn list_run_events_rejects_cursor_for_opposite_order() { - let app = crate::test_support::build_test_router(test_app_state()); - let run_id = RunId::new(); - let cases = [ - ( - format!("/runs/{run_id}/events?order=desc&since_seq=2"), - "since_seq cannot be combined with order=desc; use before_seq instead.", - ), - ( - format!("/runs/{run_id}/events?before_seq=2"), - "before_seq requires order=desc.", - ), - ]; - - for (path, expected_detail) in cases { - let response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(api(&path)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let body = response_json!(response, StatusCode::BAD_REQUEST).await; - assert_eq!(body["errors"][0]["detail"], expected_detail); - } -} - -#[tokio::test] -async fn append_run_event_rejects_run_id_mismatch() { - let state = test_app_state(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - - let req = Request::builder() - .method("POST") - .uri(api("/runs")) - .header("content-type", "application/json") - .body(intent_body(&app, MINIMAL_DOT).await) - .unwrap(); - - let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; - let run_id = body["id"].as_str().unwrap(); - - let req = Request::builder() - .method("POST") - .uri(api(&format!("/runs/{run_id}/events"))) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "id": "evt-test", - "ts": "2026-03-27T12:00:00Z", - "run_id": fixtures::RUN_64.to_string(), - "event": "run.submitted", - "properties": {} - }) - .to_string(), - )) - .unwrap(); - - let response = app.oneshot(req).await.unwrap(); - assert_status!(response, StatusCode::BAD_REQUEST).await; -} - -#[tokio::test] -async fn append_run_event_accepts_a_body_larger_than_two_mib() { - 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; - let payload = json!({ - "id": "evt-large-agent-output", - "ts": "2026-08-24T12:00:00Z", - "run_id": run_id, - "event": "agent.tool.completed", - "properties": { - "stage": "code", - "visit": 1, - "session_id": "ses_large", - "timestamp": "2026-08-24T12:00:00.000Z", - "event": { - "ToolCallCompleted": { - "tool_name": "shell", - "tool_call_id": "call-large", - "output": "x".repeat(2 * 1024 * 1024), - "is_error": false, - "output_bytes_observed": 2 * 1024 * 1024, - "output_bytes_retained": 2 * 1024 * 1024, - "output_bytes_omitted": 0 - } - } - } - }) - .to_string(); - assert!(payload.len() > 2 * 1024 * 1024); - assert!(payload.len() < 3 * 1024 * 1024); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!("/runs/{run_id}/events"))) - .header("content-type", "application/json") - .body(Body::from(payload)) - .unwrap(), - ) - .await - .unwrap(); - - assert_status!(response, StatusCode::OK).await; -} - -#[tokio::test] -async fn append_run_event_rejects_a_body_larger_than_three_mib() { - 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; - let payload = json!({ - "id": "evt-oversized-agent-output", - "ts": "2026-08-24T12:00:00Z", - "run_id": run_id, - "event": "agent.tool.completed", - "properties": { - "tool_name": "shell", - "tool_call_id": "call-oversized", - "output": "x".repeat(3 * 1024 * 1024), - "is_error": false, - "visit": 1 - } - }) - .to_string(); - assert!(payload.len() > 3 * 1024 * 1024); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!("/runs/{run_id}/events"))) - .header("content-type", "application/json") - .body(Body::from(payload)) - .unwrap(), - ) - .await - .unwrap(); - - assert_status!(response, StatusCode::PAYLOAD_TOO_LARGE).await; -} - -#[tokio::test] -async fn append_run_event_rejects_reserved_archive_event() { - 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; - - let req = Request::builder() - .method("POST") - .uri(api(&format!("/runs/{run_id}/events"))) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "id": "evt-run-archived", - "ts": "2026-04-19T12:00:00Z", - "run_id": run_id, - "event": "run.archived", - "properties": { - "actor": null - } - }) - .to_string(), - )) - .unwrap(); - - let response = app.oneshot(req).await.unwrap(); - let body = response_json!(response, StatusCode::BAD_REQUEST).await; - assert!( - body["errors"][0]["detail"].as_str().is_some_and(|message| { - message - .contains("run.archived must be performed through its dedicated operation endpoint") - }), - "expected dedicated-operation rejection, got: {body}" - ); -} - #[tokio::test] async fn get_checkpoint_returns_null_initially() { let state = test_app_state(); @@ -7445,15 +7196,7 @@ async fn create_run_persists_run_spec() { .await .parse::() .unwrap(); - let run_state = state - .stores - .runs - .open_run_reader(&run_id) - .await - .unwrap() - .state() - .await - .unwrap(); + let run_state = state.load_run_projection(&run_id).await.unwrap(); assert_eq!(run_state.spec.graph.name, "Test"); } @@ -7487,15 +7230,7 @@ async fn create_run_keeps_missing_project_and_workflow_names_absent() { let body = body_json(response.into_body()).await; let run_id = body["id"].as_str().unwrap().parse::().unwrap(); - let run_state = state - .stores - .runs - .open_run_reader(&run_id) - .await - .unwrap() - .state() - .await - .unwrap(); + let run_state = state.load_run_projection(&run_id).await.unwrap(); assert_eq!(run_state.spec.settings.project.name.as_deref(), None); assert_eq!(run_state.spec.settings.workflow.name.as_deref(), None); @@ -7530,12 +7265,9 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() { let other_run_id = create_run_with_bearer(&app, &user_jwt).await; let other_worker_token = issue_test_worker_token(&other_run_id); let blob_hash = state - .stores - .runs - .open_run(&run_id) - .await - .unwrap() - .write_blob(b"preloaded blob") + .store_ref() + .blobs() + .write(b"preloaded blob") .await .unwrap(); @@ -7568,33 +7300,6 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() { assert_status!(response, StatusCode::OK).await; } - let append_body = serde_json::to_vec(&serde_json::json!({ - "id": "evt-run-notice", - "ts": "2026-04-23T12:00:00Z", - "event": "run.notice", - "run_id": run_id.to_string(), - "properties": { - "level": "info", - "code": "worker", - "message": "hello" - } - })) - .unwrap(); - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri(api(&format!("/runs/{run_id}/events"))) - .header(header::AUTHORIZATION, format!("Bearer {worker_token}")) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(append_body)) - .unwrap(), - ) - .await - .unwrap(); - assert_status!(response, StatusCode::OK).await; - let response = app .clone() .oneshot(bearer_request( @@ -7845,7 +7550,6 @@ async fn run_tools_worker_cannot_call_user_only_non_mcp_routes() { for (method, path) in [ (Method::POST, format!("/runs/{target_run_id}/approve")), (Method::POST, format!("/runs/{target_run_id}/deny")), - (Method::GET, format!("/runs/{target_run_id}/timeline")), ] { let response = app .clone() @@ -8239,12 +7943,7 @@ async fn start_run_transitions_to_runnable() { assert_eq!(body["title"], "Test"); let status = state - .stores - .runs - .open_run_reader(&run_id.parse::().unwrap()) - .await - .unwrap() - .state() + .load_run_projection(&run_id.parse::().unwrap()) .await .unwrap() .status; @@ -8649,7 +8348,7 @@ async fn steer_with_active_non_steerable_session_returns_conflict() { } #[tokio::test] -async fn steer_interrupt_without_active_steerable_session_returns_conflict() { +async fn steer_with_interrupt_returns_unsupported() { let state = test_app_state(); let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = fixtures::RUN_1; @@ -8663,79 +8362,15 @@ async fn steer_interrupt_without_active_steerable_session_returns_conflict() { .body(Body::from(r#"{"text":"try again","interrupt":true}"#)) .unwrap(); + // A steer with an interrupt is not a control a Petri run takes. let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); let body = body_json(response.into_body()).await; - assert_eq!(body["errors"][0]["code"], "no_active_steerable_session"); + assert_eq!(body["errors"][0]["code"], "interrupt_unsupported"); } #[tokio::test] -async fn interrupt_with_active_steerable_session_forwards_interrupt() { - let state = test_app_state(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - let run_id = fixtures::RUN_1; - let stage_id = StageId::new("agent", 1); - let (transport, mut control_rx) = worker_transport_with_receiver(run_id).await; - let _temp_dir = insert_running_control_run(&state, run_id, Some(transport)); - { - let mut runs = state.runs.lock().expect("runs lock poisoned"); - runs.get_mut(&run_id) - .unwrap() - .active_steerable_stages - .insert(stage_id, "session-a".to_string()); - } - - let req = Request::builder() - .method("POST") - .uri(api(&format!("/runs/{run_id}/interrupt"))) - .body(Body::empty()) - .unwrap(); - - let response = app.oneshot(req).await.unwrap(); - assert_status!(response, StatusCode::ACCEPTED).await; - let envelope = recv_worker_control_envelope(&mut control_rx).await; - assert!(matches!( - envelope.message, - WorkerControlMessage::Interrupt { - actor: Principal::User(_), - } - )); -} - -#[tokio::test] -async fn steer_interrupt_with_active_steerable_session_forwards_combined_control_message() { - let state = test_app_state(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - let run_id = fixtures::RUN_1; - let stage_id = StageId::new("agent", 1); - let (transport, mut control_rx) = worker_transport_with_receiver(run_id).await; - let _temp_dir = insert_running_control_run(&state, run_id, Some(transport)); - { - let mut runs = state.runs.lock().expect("runs lock poisoned"); - runs.get_mut(&run_id) - .unwrap() - .active_steerable_stages - .insert(stage_id, "session-a".to_string()); - } - - let req = Request::builder() - .method("POST") - .uri(api(&format!("/runs/{run_id}/steer"))) - .header("content-type", "application/json") - .body(Body::from(r#"{"text":"try again","interrupt":true}"#)) - .unwrap(); - - let response = app.oneshot(req).await.unwrap(); - assert_status!(response, StatusCode::ACCEPTED).await; - let envelope = recv_worker_control_envelope(&mut control_rx).await; - assert!(matches!( - envelope.message, - WorkerControlMessage::InterruptThenSteer { ref text, .. } if text == "try again" - )); -} - -#[tokio::test] -async fn interrupt_terminal_run_returns_run_not_interruptible() { +async fn interrupt_returns_unsupported() { let state = test_app_state(); let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = fixtures::RUN_1; @@ -8762,10 +8397,12 @@ async fn interrupt_terminal_run_returns_run_not_interruptible() { .body(Body::empty()) .unwrap(); + // An interrupt is not a control a Petri run takes: the answer is + // `unsupported`, whatever the run's state. let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); let body = body_json(response.into_body()).await; - assert_eq!(body["errors"][0]["code"], "run_not_interruptible"); + assert_eq!(body["errors"][0]["code"], "interrupt_unsupported"); } #[tokio::test] @@ -9588,16 +9225,8 @@ level = "debug" .and_then(|run| run.run_dir.clone()) .expect("run_dir should be recorded") }; - let run_spec = state - .stores - .runs - .open_run_reader(&run_id) - .await - .unwrap() - .state() - .await - .unwrap() - .spec; + let projection = state.load_run_projection(&run_id).await.unwrap(); + let run_spec = &projection.spec; let resolved_run = &run_spec.settings.run; // Verify a sampling of the persisted v2 settings, including inherited @@ -9685,8 +9314,7 @@ async fn cancel_runnable_run_succeeds() { "cancelled run should preserve the failed lifecycle status" ); - let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap(); - let status = run_store.state().await.unwrap().status; + let status = state.load_run_projection(&run_id).await.unwrap().status; assert_eq!(status, RunStatus::Failed { reason: FailureReason::Cancelled, }); @@ -9951,14 +9579,10 @@ async fn repeated_cancel_request_arms_one_watchdog_and_persists_one_intent() { assert_status!(first_response.unwrap(), StatusCode::ACCEPTED).await; assert_status!(second_response.unwrap(), StatusCode::ACCEPTED).await; - let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap(); - let request_count = run_store - .list_events() - .await - .unwrap() - .iter() - .filter(|event| event.event.event_name() == "run.cancel.requested") - .count(); + let request_count = lifecycle_transition_count( + &platform_records(&state, run_id).await, + RunLifecycleKind::CancelRequested, + ); assert_eq!(request_count, 1); tokio::time::pause(); @@ -10067,35 +9691,13 @@ async fn pause_run_immediately_pauses_blocked_run() { let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); - append_raw_run_event( - &state, - run_id, - "pause-starting", - "2026-04-19T11:59:58Z", - "run.starting", - json!({}), - None, - ) - .await; - append_raw_run_event( - &state, - run_id, - "pause-running", - "2026-04-19T11:59:59Z", - "run.running", - json!({}), - None, - ) - .await; - append_raw_run_event( - &state, - run_id, - "pause-blocked", - "2026-04-19T12:00:00Z", - "run.blocked", - json!({ "blocked_reason": "human_input_required" }), - None, - ) + seed_lifecycle(&state, run_id, vec![ + run_records::transition(RunLifecycleKind::Starting, RunStatus::Starting), + run_records::transition(RunLifecycleKind::Running, RunStatus::Running), + run_records::transition(RunLifecycleKind::Blocked, RunStatus::Blocked { + blocked_reason: BlockedReason::HumanInputRequired, + }), + ]) .await; { @@ -10180,45 +9782,14 @@ async fn unpause_run_returns_blocked_when_human_gate_is_still_unresolved() { let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); - append_raw_run_event( - &state, - run_id, - "paused-blocked-starting", - "2026-04-19T11:59:58Z", - "run.starting", - json!({}), - None, - ) - .await; - append_raw_run_event( - &state, - run_id, - "paused-blocked-running", - "2026-04-19T11:59:59Z", - "run.running", - json!({}), - None, - ) - .await; - append_raw_run_event( - &state, - run_id, - "paused-blocked-paused", - "2026-04-19T12:00:00Z", - "run.paused", - json!({}), - None, - ) - .await; - append_raw_run_event( - &state, - run_id, - "paused-blocked-status", - "2026-04-19T12:00:01Z", - "run.blocked", - json!({ "blocked_reason": "human_input_required" }), - None, - ) + seed_lifecycle(&state, run_id, vec![ + run_records::transition(RunLifecycleKind::Starting, RunStatus::Starting), + run_records::transition(RunLifecycleKind::Running, RunStatus::Running), + RunLifecycleRecord::new(RunLifecycleKind::Paused), + run_records::transition(RunLifecycleKind::Blocked, RunStatus::Blocked { + blocked_reason: BlockedReason::HumanInputRequired, + }), + ]) .await; { diff --git a/lib/apps/fabro-server/src/test_support.rs b/lib/apps/fabro-server/src/test_support.rs index ab146013a..59a85d5e8 100644 --- a/lib/apps/fabro-server/src/test_support.rs +++ b/lib/apps/fabro-server/src/test_support.rs @@ -3,7 +3,6 @@ use std::path::{Path, PathBuf}; #[cfg(test)] use std::sync::Mutex; use std::sync::{Arc, OnceLock}; -use std::time::Duration; use anyhow::Context as _; use axum::body::{self, Body}; @@ -566,12 +565,7 @@ pub fn test_app_state_with_store( pub fn test_store_bundle() -> (Arc, ArtifactStore) { let object_store: Arc = Arc::new(MemoryObjectStore::new()); - let store = Arc::new(store_test_support::test_database( - Arc::clone(&object_store), - "", - Duration::from_millis(1), - None, - )); + let store = Arc::new(store_test_support::test_database()); let artifact_store = ArtifactStore::new(object_store, "artifacts"); (store, artifact_store) } diff --git a/lib/apps/fabro-server/tests/it/api/auth_sessions.rs b/lib/apps/fabro-server/tests/it/api/auth_sessions.rs index d585aa949..fc87a5212 100644 --- a/lib/apps/fabro-server/tests/it/api/auth_sessions.rs +++ b/lib/apps/fabro-server/tests/it/api/auth_sessions.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; use axum::body::Body; use axum::http::{Request, StatusCode, header}; @@ -22,12 +21,7 @@ use crate::helpers::{response_json, response_status, settings_from_toml}; fn test_app(source: &str) -> (axum::Router, Arc) { let settings = settings_from_toml(source); let object_store: Arc = Arc::new(InMemory::new()); - let store = Arc::new(fabro_store::test_support::test_database( - Arc::clone(&object_store), - "", - Duration::from_millis(1), - None, - )); + let store = Arc::new(fabro_store::test_support::test_database()); let artifact_store = ArtifactStore::new(object_store, "artifacts"); let auth_mode = resolve_auth_mode_with_lookup(&settings.server_settings.server, |name| match name { diff --git a/lib/apps/fabro-server/tests/it/api/cli_auth_token.rs b/lib/apps/fabro-server/tests/it/api/cli_auth_token.rs index 4094f7133..09d0b9ce9 100644 --- a/lib/apps/fabro-server/tests/it/api/cli_auth_token.rs +++ b/lib/apps/fabro-server/tests/it/api/cli_auth_token.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use std::time::Duration; use axum::body::Body; use axum::http::{Request, StatusCode, header}; @@ -19,12 +18,7 @@ use crate::helpers::{body_json, settings_from_toml}; fn test_app(source: &str) -> (axum::Router, Arc) { let settings = settings_from_toml(source); let object_store: Arc = Arc::new(InMemory::new()); - let store = Arc::new(fabro_store::test_support::test_database( - Arc::clone(&object_store), - "", - Duration::from_millis(1), - None, - )); + let store = Arc::new(fabro_store::test_support::test_database()); let artifact_store = ArtifactStore::new(object_store, "artifacts"); let auth_mode = resolve_auth_mode_with_lookup(&settings.server_settings.server, |name| match name { diff --git a/lib/apps/fabro-server/tests/it/api/run_files.rs b/lib/apps/fabro-server/tests/it/api/run_files.rs index 162887b1e..28f52757d 100644 --- a/lib/apps/fabro-server/tests/it/api/run_files.rs +++ b/lib/apps/fabro-server/tests/it/api/run_files.rs @@ -7,24 +7,12 @@ //! unit tests on the sandbox-git helpers and by `stitch_file_diff` tests //! in `run_files.rs`. -use std::sync::Arc; -use std::time::Duration; - use axum::body::Body; use axum::http::{Request, StatusCode}; -use fabro_server::test_support::test_app_state_with_store; -use fabro_store::{ArtifactStore, Database}; -use fabro_types::{ - Graph, PetriAdmission, RunId, SandboxProviderKind, WorkflowSettings, test_support, -}; -use fabro_workflow::event as workflow_event; -use fabro_workflow::run_status::SuccessReason; -use object_store::memory::InMemory as MemoryObjectStore; use tower::ServiceExt; use crate::helpers::{ MINIMAL_DOT, api, minimal_intent_json, response_json, response_status, test_app_state, - test_settings, }; fn files_url(run_id: &str) -> String { @@ -39,121 +27,6 @@ fn files_url_with_scope(run_id: &str, scope: &str) -> String { format!("{}?scope={scope}", files_url(run_id)) } -fn store_bundle() -> (Arc, ArtifactStore) { - let object_store: Arc = Arc::new(MemoryObjectStore::new()); - let store = Arc::new(fabro_store::test_support::test_database( - Arc::clone(&object_store), - "", - Duration::from_millis(1), - None, - )); - let artifact_store = ArtifactStore::new(object_store, "artifacts"); - (store, artifact_store) -} - -async fn append_completed_run_with_final_patch( - store: &Database, - run_id: &RunId, - final_patch: &str, -) { - let run_store = store.create_run(run_id).await.expect("create run store"); - workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunCreated { - run_id: *run_id, - title: None, - settings: serde_json::to_value(WorkflowSettings::default()) - .expect("workflow settings should serialize"), - graph: serde_json::to_value(Graph::new("test")) - .expect("graph should serialize"), - workflow_source: None, - labels: std::collections::BTreeMap::default(), - source_directory: None, - workflow_slug: None, - workflow_version_id: None, - target: None, - automation: None, - provenance: test_support::test_run_provenance(), - spec_blob: None, - git: None, - fork_source_ref: None, - retried_from: None, - parent_id: None, - web_url: None, - admission: PetriAdmission::default(), - }) - .await - .expect("append RunCreated"); - workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunRunnable { - source: fabro_types::RunRunnableSource::StartRequested, - actor: None, - }) - .await - .expect("append RunRunnable"); - workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunStarting) - .await - .expect("append RunStarting"); - workflow_event::append_event( - &run_store, - run_id, - &workflow_event::Event::WorkflowRunStarted { - name: "test".to_string(), - run_id: *run_id, - base_branch: Some("main".to_string()), - base_sha: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()), - run_branch: Some("fabro/run/test".to_string()), - worktree_dir: None, - goal: Some("Test degraded files".to_string()), - }, - ) - .await - .expect("append WorkflowRunStarted"); - workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunRunning) - .await - .expect("append RunRunning"); - workflow_event::append_event( - &run_store, - run_id, - &workflow_event::Event::WorkflowRunCompleted { - timing: fabro_types::RunTiming::wall_only(1), - artifact_count: 0, - status: "succeeded".to_string(), - reason: SuccessReason::Completed, - final_git_commit_sha: Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()), - final_patch: Some(final_patch.to_string()), - diff_summary: None, - usage: None, - }, - ) - .await - .expect("append WorkflowRunCompleted"); -} - -async fn append_local_sandbox_initialized(store: &Database, run_id: &RunId) { - let run_store = store.open_run(run_id).await.expect("open run store"); - workflow_event::append_event( - &run_store, - run_id, - &workflow_event::Event::SandboxInitialized { - working_directory: std::env::current_dir() - .expect("test should run inside a source checkout") - .display() - .to_string(), - provider: SandboxProviderKind::LOCAL, - id: "local:test-sandbox".to_string(), - image: None, - snapshot: None, - repo_cloned: None, - clone_origin_url: None, - clone_branch: None, - workspace_root: None, - repos_root: None, - primary_repo_path: None, - primary_repo_link: None, - }, - ) - .await - .expect("append SandboxInitialized"); -} - #[tokio::test] async fn invalid_run_id_returns_400() { let app = fabro_server::test_support::build_test_router(test_app_state()); @@ -313,112 +186,6 @@ async fn submitted_run_without_sandbox_returns_empty_envelope() { assert_eq!(body["meta"]["degraded"].as_bool(), Some(false)); } -#[tokio::test] -async fn degraded_run_returns_file_diff_shape_without_meta_patch() { - let settings = test_settings(); - let (store, artifact_store) = store_bundle(); - let state = test_app_state_with_store( - settings.server_settings, - settings.manifest_run_defaults, - 5, - Arc::clone(&store), - artifact_store, - ); - let app = fabro_server::test_support::build_test_router(state); - let run_id = RunId::new(); - let patch = "\ -diff --git a/src/lib.rs b/src/lib.rs ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -1 +1,2 @@ - old -+new -diff --git a/.env.production b/.env.production ---- a/.env.production -+++ b/.env.production -@@ -1 +1 @@ --SECRET=old -+SECRET=new -"; - append_completed_run_with_final_patch(&store, &run_id, patch).await; - append_local_sandbox_initialized(&store, &run_id).await; - - let req = Request::builder() - .method("GET") - .uri(files_url(&run_id.to_string())) - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - let body = response_json( - resp, - StatusCode::OK, - format!("GET /api/v1/runs/{run_id}/files"), - ) - .await; - - assert_eq!(body["meta"]["degraded"].as_bool(), Some(true)); - assert!(body["meta"]["degraded_reason"].is_string()); - assert_eq!(body["meta"]["source"].as_str(), Some("final_patch")); - assert_eq!(body["meta"]["scope"].as_str(), Some("committed")); - assert!(body["meta"].get("patch").is_none()); - assert_eq!(body["meta"]["total_changed"], 2); - assert_eq!(body["meta"]["truncated"].as_bool(), Some(false)); - - let data = body["data"].as_array().expect("data should be an array"); - assert_eq!(data.len(), 2); - assert_eq!(data[0]["old_file"]["contents"], serde_json::Value::Null); - assert_eq!(data[0]["new_file"]["contents"], serde_json::Value::Null); - assert!(data[0]["unified_patch"].is_string()); - assert_eq!(data[1]["sensitive"].as_bool(), Some(true)); - assert_eq!(data[1]["old_file"]["contents"], serde_json::Value::Null); - assert_eq!(data[1]["new_file"]["contents"], serde_json::Value::Null); - assert!(data[1].get("unified_patch").is_none()); -} - -#[tokio::test] -async fn planned_sandbox_rejects_files_for_every_scope() { - let settings = test_settings(); - let (store, artifact_store) = store_bundle(); - let state = test_app_state_with_store( - settings.server_settings, - settings.manifest_run_defaults, - 5, - Arc::clone(&store), - artifact_store, - ); - let app = fabro_server::test_support::build_test_router(state); - let run_id = RunId::new(); - let patch = "\ -diff --git a/src/lib.rs b/src/lib.rs ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -1 +1,2 @@ - old -+new -"; - append_completed_run_with_final_patch(&store, &run_id, patch).await; - - for scope in ["committed", "uncommitted", "all"] { - let req = Request::builder() - .method("GET") - .uri(files_url_with_scope(&run_id.to_string(), scope)) - .body(Body::empty()) - .unwrap(); - let resp = app.clone().oneshot(req).await.unwrap(); - let body = response_json( - resp, - StatusCode::NOT_FOUND, - format!("GET /api/v1/runs/{run_id}/files?scope={scope}"), - ) - .await; - - assert_eq!( - body["errors"][0]["detail"].as_str(), - Some("Run sandbox was not created.") - ); - } -} - #[tokio::test] async fn demo_mode_returns_fixture_without_touching_store() { // R34: demo handler must return the illustrative fixture with no diff --git a/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs b/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs index b079b4e59..a77050852 100644 --- a/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs +++ b/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs @@ -30,8 +30,7 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_server::server::AppState; use fabro_store::platform_records::{PlatformRecord, PlatformRecordStore, RunNoticeRecord}; -use fabro_types::run_event::RunNoticeLevel; -use fabro_types::{RunId, RunStreamItem, RunStreamItemKind}; +use fabro_types::{RunId, RunNoticeLevel, RunStreamItem, RunStreamItemKind}; use http_body_util::BodyExt; use tokio::time::timeout; use tower::ServiceExt; diff --git a/lib/components/fabro-dump/src/lib.rs b/lib/components/fabro-dump/src/lib.rs index c0b3fa77e..6062d23f0 100644 --- a/lib/components/fabro-dump/src/lib.rs +++ b/lib/components/fabro-dump/src/lib.rs @@ -511,20 +511,12 @@ mod tests { fn sample_checkpoint() -> Checkpoint { Checkpoint { - timestamp: Utc + timestamp: Utc .with_ymd_and_hms(2026, 4, 20, 12, 0, 0) .single() .unwrap(), - current_node: "build".to_string(), - completed_nodes: vec!["build".to_string()], - node_retries: HashMap::new(), - context_values: HashMap::new(), - node_outcomes: HashMap::new(), - next_node_id: Some("ship".to_string()), - git_commit_sha: Some("abc123".to_string()), - loop_failure_signatures: HashMap::new(), - restart_failure_signatures: HashMap::new(), - node_visits: HashMap::from([("build".to_string(), 2usize)]), + current_node: "build".to_string(), + git_commit_sha: Some("abc123".to_string()), } } diff --git a/lib/components/fabro-petri/src/projection.rs b/lib/components/fabro-petri/src/projection.rs index 360dc4d2d..bf910fdd6 100644 --- a/lib/components/fabro-petri/src/projection.rs +++ b/lib/components/fabro-petri/src/projection.rs @@ -25,7 +25,7 @@ //! milliseconds from the run's creation to the stage's `visit.started`, //! plus one, which is the same however the records were delivered. -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet}; use chrono::{DateTime, TimeZone as _, Utc}; use fabro_store::platform_records::{ @@ -237,17 +237,9 @@ impl RunView { .get(&stage_key(record.execution, record.firing)); let current_node = stage.map_or_else(String::new, |stage| stage.node_name.clone()); let checkpoint = fabro_types::Checkpoint { - timestamp: at, - current_node: current_node.clone(), - completed_nodes: Vec::new(), - node_retries: HashMap::default(), - context_values: HashMap::default(), - node_outcomes: HashMap::default(), - next_node_id: None, - git_commit_sha: record.git_commit_sha.clone(), - loop_failure_signatures: HashMap::default(), - restart_failure_signatures: HashMap::default(), - node_visits: HashMap::default(), + timestamp: at, + current_node: current_node.clone(), + git_commit_sha: record.git_commit_sha.clone(), }; projection.checkpoints.push(ViewCheckpoint { seq: u32::try_from(stream_seq).unwrap_or(u32::MAX), @@ -1119,7 +1111,19 @@ fn fold_lifecycle(projection: &mut RunProjection, record: &RunLifecycleRecord, a use RunLifecycleKind as Kind; match record.transition { Kind::Submitted => apply_status(projection, RunStatus::Submitted, at), - Kind::StartRequested | Kind::Unpaused => {} + Kind::StartRequested => {} + Kind::Unpaused => { + let status = match projection.status { + RunStatus::Paused { + prior_block: Some(blocked_reason), + } => RunStatus::Blocked { blocked_reason }, + _ => RunStatus::Running, + }; + apply_status(projection, status, at); + if projection.pending_control == Some(RunControlAction::Unpause) { + projection.pending_control = None; + } + } Kind::Pending => { if let Some(status) = record.status { apply_status(projection, status, at); @@ -1168,12 +1172,31 @@ fn fold_lifecycle(projection: &mut RunProjection, record: &RunLifecycleRecord, a apply_status(projection, status, at); } } - Kind::Starting - | Kind::Running - | Kind::Blocked - | Kind::Unblocked - | Kind::Removing - | Kind::Dead => { + Kind::Blocked => { + // A block that lands while the run is paused waits behind the + // pause: the unpause restores it. + match (projection.status, record.status) { + (RunStatus::Paused { .. }, Some(RunStatus::Blocked { blocked_reason })) => { + apply_status( + projection, + RunStatus::Paused { + prior_block: Some(blocked_reason), + }, + at, + ); + } + (_, Some(status)) => apply_status(projection, status, at), + (_, None) => {} + } + } + Kind::Unblocked => { + let status = match projection.status { + RunStatus::Paused { .. } => RunStatus::Paused { prior_block: None }, + _ => record.status.unwrap_or(RunStatus::Running), + }; + apply_status(projection, status, at); + } + Kind::Starting | Kind::Running | Kind::Removing | Kind::Dead => { if let Some(status) = record.status { apply_status(projection, status, at); } @@ -1181,9 +1204,13 @@ fn fold_lifecycle(projection: &mut RunProjection, record: &RunLifecycleRecord, a Kind::Paused => { let prior_block = match projection.status { RunStatus::Blocked { blocked_reason } => Some(blocked_reason), + RunStatus::Paused { prior_block } => prior_block, _ => None, }; apply_status(projection, RunStatus::Paused { prior_block }, at); + if projection.pending_control == Some(RunControlAction::Pause) { + projection.pending_control = None; + } } Kind::Succeeded | Kind::Failed => { if let Some(status) = record.status { diff --git a/lib/components/fabro-sandbox/src/git_policy.rs b/lib/components/fabro-sandbox/src/git_policy.rs index 51bf6a802..2f1bff0c3 100644 --- a/lib/components/fabro-sandbox/src/git_policy.rs +++ b/lib/components/fabro-sandbox/src/git_policy.rs @@ -17,11 +17,22 @@ use std::sync::{Mutex, PoisonError}; use std::time::{Duration, SystemTime}; use fabro_github::token_source::TokenSnapshot; -pub use fabro_types::run_event::GitPushRetryReason as GitRetryReason; use sandbox_driver::{GitBackoff, GitCredentials, GitFailure, GitFailureKind, GitRetryPolicy}; +use serde::{Deserialize, Serialize}; use crate::credentials::GITHUB_TOKEN_USERNAME; +/// Why a failed git push attempt is safe to retry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum GitRetryReason { + /// A recently minted token may not have reached every GitHub git endpoint. + TokenReplication, + /// The failure came from transient network or service infrastructure. + TransientInfra, +} + /// Backoff between attempts: 3s, then 9s. /// /// GitHub's guidance for token replication is to wait a few seconds and diff --git a/lib/components/fabro-store/Cargo.toml b/lib/components/fabro-store/Cargo.toml index 23e2f5db3..a61446741 100644 --- a/lib/components/fabro-store/Cargo.toml +++ b/lib/components/fabro-store/Cargo.toml @@ -21,7 +21,6 @@ lithos-llm = { workspace = true, features = ["runtime"] } pebble-coding-agent.workspace = true fabro-util = { path = "../../foundation/fabro-util" } hex.workspace = true -slatedb.workspace = true object_store.workspace = true percent-encoding.workspace = true async-trait.workspace = true diff --git a/lib/components/fabro-store/src/blob_store.rs b/lib/components/fabro-store/src/blob_store.rs index f9472a3ee..155a86d52 100644 --- a/lib/components/fabro-store/src/blob_store.rs +++ b/lib/components/fabro-store/src/blob_store.rs @@ -1,14 +1,7 @@ -#[cfg(test)] -use std::sync::Arc; - use bytes::Bytes; use fabro_types::BlobHash; use sqlx::SqlitePool; -#[cfg(test)] -use crate::record::Repository; -#[cfg(test)] -use crate::record::{RawBytesCodec, Record}; use crate::{Error, Result}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -26,46 +19,15 @@ impl From for Blob { } } -#[cfg(test)] -impl Record for Blob { - type Id = BlobHash; - type Codec = RawBytesCodec; - - const PREFIX: &'static str = "blobs/sha256"; - - #[cfg(test)] - fn id(&self) -> Self::Id { - BlobHash::new(&self.0) - } -} - -/// Temporary backend split for compatibility tests. -/// -/// Production compiles only the SQLite arm. The Slate arm remains test-only -/// while the startup import bridge is supported, so tests can exercise the -/// old-source boundary directly. Delete the Slate arm (and this enum) with the -/// separately authorized compatibility cleanup, then inline SQLite into -/// [`BlobStore`]. -enum BlobBackend { - #[cfg(test)] - Slate(Repository), - Sqlite(SqlitePool), -} - +/// The content-addressed `blobs` table: every blob once, under its +/// SHA-256. pub struct BlobStore { - backend: BlobBackend, + pool: SqlitePool, } impl std::fmt::Debug for BlobStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let backend = match &self.backend { - #[cfg(test)] - BlobBackend::Slate(_) => "slate", - BlobBackend::Sqlite(_) => "sqlite", - }; - f.debug_struct("BlobStore") - .field("backend", &backend) - .finish_non_exhaustive() + f.debug_struct("BlobStore").finish_non_exhaustive() } } @@ -73,91 +35,57 @@ impl BlobStore { /// Creates a blob store backed by a SQLite pool whose migrations have run. #[must_use] pub fn new(pool: SqlitePool) -> Self { - Self { - backend: BlobBackend::Sqlite(pool), - } - } - - #[cfg(test)] - pub(crate) fn from_slate(db: Arc) -> Self { - Self { - backend: BlobBackend::Slate(Repository::new(db)), - } + Self { pool } } pub async fn write(&self, bytes: &[u8]) -> Result { - match &self.backend { - #[cfg(test)] - BlobBackend::Slate(repo) => { - let blob = Blob(Bytes::copy_from_slice(bytes)); - let id = blob.id(); - repo.put(&blob).await?; - Ok(id) - } - BlobBackend::Sqlite(pool) => { - let blob_hash = BlobHash::new(bytes); - let result = sqlx::query( - "INSERT INTO blobs (hash, data) VALUES (?, ?) \ - ON CONFLICT(hash) DO NOTHING", - ) - .bind(blob_hash.to_string()) - .bind(bytes) - .execute(pool) - .await?; + let blob_hash = BlobHash::new(bytes); + let result = sqlx::query( + "INSERT INTO blobs (hash, data) VALUES (?, ?) \ + ON CONFLICT(hash) DO NOTHING", + ) + .bind(blob_hash.to_string()) + .bind(bytes) + .execute(&self.pool) + .await?; - if result.rows_affected() == 1 { - return Ok(blob_hash); - } + if result.rows_affected() == 1 { + return Ok(blob_hash); + } - let stored: Vec = sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?") - .bind(blob_hash.to_string()) - .fetch_one(pool) - .await?; - if stored == bytes { - Ok(blob_hash) - } else { - Err(Error::BlobHashConflict { blob_hash }) - } - } + let stored: Vec = sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?") + .bind(blob_hash.to_string()) + .fetch_one(&self.pool) + .await?; + if stored == bytes { + Ok(blob_hash) + } else { + Err(Error::BlobHashConflict { blob_hash }) } } pub async fn read(&self, blob_hash: &BlobHash) -> Result> { - match &self.backend { - #[cfg(test)] - BlobBackend::Slate(repo) => Ok(repo.get(blob_hash).await?.map(|blob| blob.0)), - BlobBackend::Sqlite(pool) => { - let stored: Option> = - sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?") - .bind(blob_hash.to_string()) - .fetch_optional(pool) - .await?; - let Some(stored) = stored else { - return Ok(None); - }; - if BlobHash::new(&stored) != *blob_hash { - return Err(Error::BlobIntegrity { - blob_hash: *blob_hash, - }); - } - Ok(Some(Bytes::from(stored))) - } + let stored: Option> = sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?") + .bind(blob_hash.to_string()) + .fetch_optional(&self.pool) + .await?; + let Some(stored) = stored else { + return Ok(None); + }; + if BlobHash::new(&stored) != *blob_hash { + return Err(Error::BlobIntegrity { + blob_hash: *blob_hash, + }); } + Ok(Some(Bytes::from(stored))) } pub async fn exists(&self, blob_hash: &BlobHash) -> Result { - match &self.backend { - #[cfg(test)] - BlobBackend::Slate(repo) => repo.exists(blob_hash).await, - BlobBackend::Sqlite(pool) => { - let exists: bool = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM blobs WHERE hash = ?)") - .bind(blob_hash.to_string()) - .fetch_one(pool) - .await?; - Ok(exists) - } - } + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM blobs WHERE hash = ?)") + .bind(blob_hash.to_string()) + .fetch_one(&self.pool) + .await?; + Ok(exists) } } @@ -167,33 +95,12 @@ mod tests { use bytes::Bytes; use fabro_types::BlobHash; - use object_store::memory::InMemory; use super::BlobStore; use crate::Error; - use crate::keys::SlateKey; type TestResult = std::result::Result>; - async fn slate_store() -> Arc { - let raw_db = Arc::new( - slatedb::Db::open("blob-store-tests", Arc::new(InMemory::new())) - .await - .unwrap(), - ); - Arc::new(BlobStore::from_slate(raw_db)) - } - - async fn raw_slate_store(name: &str) -> (Arc, BlobStore) { - let raw_db = Arc::new( - slatedb::Db::open(name, Arc::new(InMemory::new())) - .await - .unwrap(), - ); - let store = BlobStore::from_slate(raw_db.clone()); - (raw_db, store) - } - async fn sqlite_store() -> TestResult<(tempfile::TempDir, fabro_db::Database, BlobStore)> { let dir = tempfile::tempdir()?; let database = fabro_db::Database::connect(dir.path().join("fabro.sqlite3")).await?; @@ -202,43 +109,6 @@ mod tests { Ok((dir, database, store)) } - #[tokio::test] - async fn slate_writes_reads_and_checks_existence() { - let store = slate_store().await; - let bytes = b"hello world"; - let id = store.write(bytes).await.unwrap(); - - assert_eq!( - store.read(&id).await.unwrap(), - Some(Bytes::from_static(bytes)) - ); - assert_eq!(store.write(bytes).await.unwrap(), id); - assert!(store.exists(&id).await.unwrap()); - assert!(!store.exists(&BlobHash::new(b"missing")).await.unwrap()); - } - - #[tokio::test] - async fn slate_empty_blobs_round_trip() { - let store = slate_store().await; - let id = store.write(b"").await.unwrap(); - - assert_eq!(store.read(&id).await.unwrap(), Some(Bytes::new())); - } - - #[tokio::test] - async fn raw_slate_db_reads_exact_blob_bytes() { - let (raw_db, store) = raw_slate_store("blob-store-tests").await; - let bytes = b"{\"ok\":true}"; - let id = store.write(bytes).await.unwrap(); - - let saved = raw_db - .get(SlateKey::new("blobs").with("sha256").with(id)) - .await - .unwrap() - .unwrap(); - assert_eq!(saved.as_ref(), bytes); - } - #[tokio::test] async fn sqlite_writes_reads_and_checks_existence() -> TestResult<()> { let (_dir, database, store) = sqlite_store().await?; diff --git a/lib/components/fabro-store/src/database.rs b/lib/components/fabro-store/src/database.rs new file mode 100644 index 000000000..cf5d4ff5e --- /dev/null +++ b/lib/components/fabro-store/src/database.rs @@ -0,0 +1,65 @@ +//! The run store the server holds: the blob table and the run summary +//! store over one SQLite pool, behind one handle. +//! +//! A run's history is Petri's records (`petri_records`) and Fabro's +//! platform records; its view is the projection the projector commits. +//! This handle carries the two stores every reader of a run reaches, and +//! the run-level operations that span them. + +use std::sync::Arc; + +use fabro_types::{RunId, RunProjection}; + +use crate::{BlobStore, PlatformRecordHook, Result, RunSummaryStore}; + +#[derive(Clone)] +pub struct Database { + blobs: Arc, + run_summary_store: Arc, +} + +impl std::fmt::Debug for Database { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Database").finish_non_exhaustive() + } +} + +impl Database { + #[must_use] + pub fn new(blobs: Arc, run_summary_store: Arc) -> Self { + Self { + blobs, + run_summary_store, + } + } + + #[must_use] + pub fn run_summary_store(&self) -> Arc { + Arc::clone(&self.run_summary_store) + } + + #[must_use] + pub fn blobs(&self) -> Arc { + Arc::clone(&self.blobs) + } + + /// The run's projection: the one its projector last committed over + /// Petri's records and the platform records, or `None` before the + /// first view pass commits (or for no such run). + pub async fn load_run_projection(&self, run_id: &RunId) -> Result>> { + self.run_summary_store.load_petri_projection(run_id).await + } + + /// Install the wake-up called after a platform record of a run is + /// committed. + pub fn set_platform_record_hook(&self, hook: PlatformRecordHook) { + self.run_summary_store.set_platform_record_hook(hook); + } + + /// Forget the run's row. Its Petri records, platform records and view + /// are the projector's to delete; its blobs are content-addressed and + /// shared. + pub async fn delete_run(&self, run_id: &RunId) -> Result<()> { + self.run_summary_store.delete_canonical(run_id).await + } +} diff --git a/lib/components/fabro-store/src/error.rs b/lib/components/fabro-store/src/error.rs index 5de630ef9..49db7cf24 100644 --- a/lib/components/fabro-store/src/error.rs +++ b/lib/components/fabro-store/src/error.rs @@ -4,8 +4,6 @@ pub type Result = std::result::Result; #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("SlateDB error: {0}")] - Slate(#[from] slatedb::Error), #[error("Object store error: {0}")] ObjectStore(#[from] object_store::Error), #[error("Serialization error: {0}")] diff --git a/lib/components/fabro-store/src/keys.rs b/lib/components/fabro-store/src/keys.rs deleted file mode 100644 index 146082a88..000000000 --- a/lib/components/fabro-store/src/keys.rs +++ /dev/null @@ -1,186 +0,0 @@ -use std::fmt::{self, Write}; -#[cfg(test)] -use std::ops::Range; - -use fabro_types::RunId; - -pub(crate) const MAX_EVENT_SEQ: u32 = 999_999; - -#[derive(Debug, PartialEq, Eq)] -pub(crate) struct SlateKey(String); - -impl SlateKey { - const SEP: char = '\0'; - - pub(crate) fn new(segment: impl fmt::Display) -> Self { - Self(segment.to_string()) - } - - pub(crate) fn with(mut self, segment: impl fmt::Display) -> Self { - self.0.push(Self::SEP); - write!(&mut self.0, "{segment}").expect("write to String cannot fail"); - self - } - - pub(crate) fn into_prefix(mut self) -> Self { - self.0.push(Self::SEP); - self - } - - /// Exclusive end bound of this key's prefix keyspace: every key under - /// `self.into_prefix()` sorts below it and no other key sorts between. - #[cfg(test)] - fn into_prefix_end(mut self) -> Self { - self.0.push('\u{1}'); - self - } - - #[cfg(test)] - fn as_str(&self) -> &str { - &self.0 - } - - #[cfg(test)] - pub(crate) fn segments(raw: &str) -> impl Iterator { - raw.split(Self::SEP) - } -} - -impl AsRef<[u8]> for SlateKey { - fn as_ref(&self) -> &[u8] { - self.0.as_bytes() - } -} - -// --- Construction --- - -// Sequence keys zero-pad `seq` to six digits so lexicographic key order -// matches numeric seq order through `MAX_EVENT_SEQ`. Seek-based event listing -// (`run_events_range`) depends on this invariant, so event allocation rejects -// larger sequences. -#[cfg(any(test, feature = "test-support"))] -pub(crate) fn run_event_key(run_id: &RunId, seq: u32, epoch_ms: i64) -> SlateKey { - SlateKey::new("runs") - .with(run_id) - .with("events") - .with(format!("{seq:06}-{epoch_ms}")) -} - -#[cfg(test)] -pub(crate) fn run_event_seq_prefix(run_id: &RunId, seq: u32) -> SlateKey { - SlateKey::new("runs") - .with(run_id) - .with("events") - .with(format!("{seq:06}-")) -} - -/// Scan range covering the run's event keys from `start_seq` to the end of -/// the run's event namespace, so seek-based listing never touches keys of -/// other runs or namespaces. -#[cfg(test)] -pub(crate) fn run_events_range(run_id: &RunId, start_seq: u32) -> Range { - let end = SlateKey::new("runs") - .with(run_id) - .with("events") - .into_prefix_end(); - run_event_seq_prefix(run_id, start_seq)..end -} - -#[cfg(test)] -pub(crate) fn session_by_id_key(session_id: &fabro_types::SessionId) -> SlateKey { - SlateKey::new("sessions").with("by-id").with(session_id) -} - -// --- Parsing --- - -#[cfg(test)] -pub(crate) fn parse_event_seq(key: &str) -> Option { - let mut segments = SlateKey::segments(key); - let _ = segments.next()?; // "runs" - let _ = segments.next()?; // run_id - if segments.next()? != "events" { - return None; - } - segments.next()?.split_once('-')?.0.parse().ok() -} - -#[cfg(test)] -mod tests { - use fabro_types::RunId; - - use super::*; - - #[test] - fn builder_joins_segments_with_null_byte() { - let key = SlateKey::new("a").with("b").with("c"); - assert_eq!(key.as_ref(), b"a\0b\0c"); - } - - #[test] - fn into_prefix_appends_trailing_null_byte() { - let key = SlateKey::new("a").with("b").into_prefix(); - assert_eq!(key.as_ref(), b"a\0b\0"); - } - - #[test] - fn event_key_segments() { - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); - let key = run_event_key(&run_id, 7, 123); - let segments: Vec<&str> = SlateKey::segments(key.as_str()).collect(); - assert_eq!(segments, [ - "runs", - "01JT56VE4Z5NZ814GZN2JZD65A", - "events", - "000007-123" - ]); - } - - #[test] - fn sequence_keys_are_zero_padded() { - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); - let key = run_event_key(&run_id, 7, 123); - let leaf = SlateKey::segments(key.as_str()).last().unwrap(); - assert_eq!(leaf, "000007-123"); - } - - #[test] - fn run_events_range_bounds_the_event_namespace() { - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); - let range = run_events_range(&run_id, 2); - let contains = |key: &SlateKey| { - range.start.as_ref() <= key.as_ref() && key.as_ref() < range.end.as_ref() - }; - - assert!(!contains(&run_event_key(&run_id, 1, 123))); - assert!(contains(&run_event_key(&run_id, 2, 123))); - assert!(contains(&run_event_key(&run_id, MAX_EVENT_SEQ, 123))); - // Sibling namespaces of the same run sort outside the range. - assert!(!contains(&SlateKey::new("runs").with(run_id).with("state"))); - assert!(!contains( - &session_by_id_key(&fabro_types::SessionId::new()) - )); - } - - #[test] - fn parse_event_seq_roundtrips() { - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); - assert_eq!( - parse_event_seq(run_event_key(&run_id, 7, 123).as_str()), - Some(7) - ); - } - - #[test] - fn parse_event_seq_rejects_invalid_keys() { - assert_eq!( - parse_event_seq( - SlateKey::new("runs") - .with("not-a-run") - .with("events") - .with("not-a-seq") - .as_str() - ), - None - ); - } -} diff --git a/lib/components/fabro-store/src/lib.rs b/lib/components/fabro-store/src/lib.rs index 0adf5768b..9b4a8e305 100644 --- a/lib/components/fabro-store/src/lib.rs +++ b/lib/components/fabro-store/src/lib.rs @@ -2,23 +2,19 @@ mod artifact_store; mod auth_code_store; pub mod auth_session_store; mod blob_store; +mod database; mod error; mod keyed_mutex; -mod keys; pub mod platform_records; -#[cfg(test)] -mod record; mod run_session_event_store; mod run_session_record_store; mod run_sessions; -mod run_state; +mod run_summary; mod run_summary_store; mod serializable_projection; -mod slate; mod sqlite_row; #[cfg(any(test, feature = "test-support"))] pub mod test_support; -mod types; pub use artifact_store::{ ArtifactKey, ArtifactStore, NodeArtifact, StageArtifactEntry, retry_storage_segment, @@ -29,9 +25,10 @@ pub use auth_session_store::{ ActiveCliSession, AuthSessionRecord, AuthSessionStore, InitialRefreshToken, RotateOutcome, }; pub use blob_store::{Blob, BlobStore}; +pub use database::Database; pub use error::{Error, Result}; pub use fabro_types::{ - BlobHash, EventEnvelope, PendingInterviewRecord, Run, RunProjection, StageId, StageProjection, + BlobHash, PendingInterviewRecord, Run, RunProjection, StageId, StageProjection, }; pub use keyed_mutex::{KeyedMutex, KeyedMutexGuard}; pub use platform_records::{ @@ -41,11 +38,9 @@ pub use platform_records::{ pub use run_session_event_store::RunSessionEventStore; pub use run_session_record_store::{RunSessionRecordStore, StoredSessionRecord}; pub use run_sessions::{ProjectedRunSession, project_run_session, project_run_sessions}; -pub use run_state::{RunProjectionReducer, build_summary, projected_usage}; +pub use run_summary::{build_summary, projected_usage}; pub use run_summary_store::{ RunSummaryIdentity, RunSummaryListQuery, RunSummaryPage, RunSummarySort, RunSummarySortDirection, RunSummaryStore, RunSummaryVisibility, }; pub use serializable_projection::SerializableProjection; -pub use slate::{Database, RunDatabase, UnreadableRun}; -pub use types::EventPayload; diff --git a/lib/components/fabro-store/src/platform_records.rs b/lib/components/fabro-store/src/platform_records.rs index c41faf134..123cfa1a4 100644 --- a/lib/components/fabro-store/src/platform_records.rs +++ b/lib/components/fabro-store/src/platform_records.rs @@ -24,16 +24,11 @@ //! kind), so the record and the effect share one identity and a retry after //! a crash finds the effect already done. -use std::collections::HashMap; use std::sync::Arc; -use fabro_types::run_event::{ - InterviewCompletedProps, PullRequestCreatedProps, RunCreatedProps, RunFailedProps, - RunNoticeLevel, RunPairStartedProps, RunRunnableSource, RunStartedProps, RunSupersededByProps, -}; use fabro_types::{ - BlobHash, DiffSummary, EventBody, GitIdentity, PairId, PairTarget, Principal, - PullRequestCreationId, PullRequestLink, RunControlAction, RunEvent, RunId, RunSpec, RunStatus, + BlobHash, DiffSummary, GitIdentity, PairId, PairTarget, Principal, PullRequestCreationId, + PullRequestLink, RunControlAction, RunId, RunNoticeLevel, RunSpec, RunStatus, }; use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqliteConnection, SqliteRow}; @@ -676,205 +671,9 @@ pub fn now_ms() -> u64 { u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or(0) } -/// The platform record a legacy run event of a Petri run stands for, when -/// it stands for one. The lifecycle paths append legacy events until the -/// old executor is deleted; for a Petri run the store derives the platform -/// record from the event and keeps both, so the projection over Petri's -/// records reads the lifecycle from platform records alone. -#[must_use] -pub fn platform_record_for(event: &RunEvent) -> Option { - use RunLifecycleKind as Kind; - let lifecycle = |kind: Kind| Some(PlatformRecord::RunLifecycle(RunLifecycleRecord::new(kind))); - let status = |kind: Kind, status: RunStatus| { - Some(PlatformRecord::RunLifecycle( - RunLifecycleRecord::new(kind).with_status(status), - )) - }; - let control = |kind: Kind, action: RunControlAction| { - let mut record = RunLifecycleRecord::new(kind); - record.action = Some(action); - Some(PlatformRecord::RunLifecycle(record)) - }; - #[expect( - clippy::wildcard_enum_match_arm, - reason = "stage, agent and sandbox events are Petri's records for a Petri run" - )] - match &event.body { - EventBody::RunCreated(props) => Some(PlatformRecord::RunCreated(run_created_record( - event.run_id, - props, - ))), - EventBody::RunSubmitted(_) => status(Kind::Submitted, RunStatus::Submitted), - EventBody::RunStartRequested(props) => { - let mut record = RunLifecycleRecord::new(Kind::StartRequested); - record.source = Some(if props.resume { "resume" } else { "start" }.to_string()); - Some(PlatformRecord::RunLifecycle(record)) - } - EventBody::RunPending(props) => status(Kind::Pending, RunStatus::Pending { - reason: props.reason, - }), - EventBody::RunApproved(_) => lifecycle(Kind::Approved), - EventBody::RunDenied(props) => { - let mut record = RunLifecycleRecord::new(Kind::Denied); - record.reason.clone_from(&props.reason); - Some(PlatformRecord::RunLifecycle(record)) - } - EventBody::RunRunnable(props) => { - let mut record = - RunLifecycleRecord::new(Kind::Runnable).with_status(RunStatus::Runnable); - record.source = Some(runnable_source(props.source).to_string()); - Some(PlatformRecord::RunLifecycle(record)) - } - EventBody::RunStarting(_) => status(Kind::Starting, RunStatus::Starting), - EventBody::RunRunning(_) => status(Kind::Running, RunStatus::Running), - EventBody::RunBlocked(props) => status(Kind::Blocked, RunStatus::Blocked { - blocked_reason: props.blocked_reason, - }), - EventBody::RunUnblocked(_) => status(Kind::Unblocked, RunStatus::Running), - EventBody::RunRemoving(_) => status(Kind::Removing, RunStatus::Removing), - EventBody::RunCancelRequested(props) => control(Kind::CancelRequested, props.action), - EventBody::RunPauseRequested(props) => control(Kind::PauseRequested, props.action), - EventBody::RunUnpauseRequested(props) => control(Kind::UnpauseRequested, props.action), - EventBody::RunPaused(_) => lifecycle(Kind::Paused), - EventBody::RunUnpaused(_) => lifecycle(Kind::Unpaused), - EventBody::RunCompleted(props) => status(Kind::Succeeded, RunStatus::Succeeded { - reason: props.reason, - }), - EventBody::RunFailed(props) => Some(PlatformRecord::RunLifecycle(failed_record(props))), - EventBody::RunSupersededBy(props) => { - Some(PlatformRecord::RunSuperseded(superseded_record(props))) - } - EventBody::RunArchived(_) => Some(PlatformRecord::RunArchived), - EventBody::RunUnarchived(_) => Some(PlatformRecord::RunUnarchived), - EventBody::RunTitleUpdated(props) => Some(PlatformRecord::RunTitle(RunTitleRecord { - title: props.title.clone(), - })), - EventBody::RunParentLinked(props) => Some(PlatformRecord::RunParent(RunParentRecord { - parent_id: Some(props.parent_id), - previous_parent_id: props.previous_parent_id, - })), - EventBody::RunParentUnlinked(props) => Some(PlatformRecord::RunParent(RunParentRecord { - parent_id: None, - previous_parent_id: Some(props.previous_parent_id), - })), - EventBody::RunNotice(props) => Some(PlatformRecord::RunNotice(RunNoticeRecord { - level: props.level, - code: props.code.clone(), - message: props.message.clone(), - })), - EventBody::RunStarted(props) => Some(PlatformRecord::RunBranch(run_branch_record(props))), - EventBody::GitIdentityResolved(props) => { - Some(PlatformRecord::GitIdentity(GitIdentityRecord { - identity: props.identity.clone(), - })) - } - EventBody::PullRequestCreated(props) => Some(PlatformRecord::PullRequestCreated( - pull_request_created_record(props), - )), - EventBody::RunPairStarted(props) => { - Some(PlatformRecord::RunPaired(run_paired_record(props))) - } - EventBody::InterviewCompleted(props) => Some(PlatformRecord::InterviewAnswered( - interview_answered_record(props, event.actor.clone()), - )), - _ => None, - } -} - -fn runnable_source(source: RunRunnableSource) -> &'static str { - source.into() -} - -fn run_created_record(run_id: RunId, props: &RunCreatedProps) -> RunCreatedRecord { - let labels = props.labels.clone().into_iter().collect::>(); - RunCreatedRecord { - spec: RunSpec { - run_id, - settings: props.settings.clone(), - graph: props.graph.clone(), - graph_source: props.workflow_source.clone(), - workflow_slug: props.workflow_slug.clone(), - workflow_version_id: props.workflow_version_id, - target: props.target.clone(), - automation: props.automation.clone(), - source_directory: props.source_directory.clone(), - labels, - provenance: props.provenance.clone(), - definition_blob: None, - spec_blob: props.spec_blob, - git: props.git.clone(), - fork_source_ref: props.fork_source_ref.clone(), - admission: props.admission.clone(), - }, - title: props.title.clone(), - parent_id: props.parent_id, - retried_from: props.retried_from, - web_url: props.web_url.clone(), - } -} - -fn failed_record(props: &RunFailedProps) -> RunLifecycleRecord { - let mut record = - RunLifecycleRecord::new(RunLifecycleKind::Failed).with_status(RunStatus::Failed { - reason: props.failure.reason, - }); - record.reason = Some(props.failure.detail.message.clone()); - record -} - -fn superseded_record(props: &RunSupersededByProps) -> RunSupersededRecord { - RunSupersededRecord { - new_run_id: props.new_run_id, - target_checkpoint_ordinal: props.target_checkpoint_ordinal, - target_node_id: props.target_node_id.clone(), - target_visit: props.target_visit, - } -} - -fn run_branch_record(props: &RunStartedProps) -> RunBranchRecord { - RunBranchRecord { - run_branch: props.run_branch.clone(), - base_sha: props.base_sha.clone(), - } -} - -fn pull_request_created_record(props: &PullRequestCreatedProps) -> PullRequestCreatedRecord { - PullRequestCreatedRecord { - number: props.pr_number, - owner: props.owner.clone(), - repo: props.repo.clone(), - html_url: props.pr_url.clone(), - head_sha: props.head_sha.clone(), - draft: props.draft, - operation: None, - } -} - -fn run_paired_record(props: &RunPairStartedProps) -> RunPairedRecord { - RunPairedRecord { - pair_id: props.pair_id, - target: props.target.clone(), - } -} - -fn interview_answered_record( - props: &InterviewCompletedProps, - principal: Option, -) -> InterviewAnsweredRecord { - InterviewAnsweredRecord { - question: props.question_id.clone(), - principal, - channel: None, - text: Some(props.question.clone()), - answer: Some(props.answer.clone()), - } -} - #[cfg(test)] mod tests { - use fabro_types::{ - FailureReason, RunStatus, SystemActorKind, fixtures, test_support as types_support, - }; + use fabro_types::{RunStatus, fixtures, test_support as types_support}; use serde_json::json; use super::*; @@ -1098,83 +897,4 @@ mod tests { None ); } - - /// The interview adapter completes a Petri question under Petri's own - /// id with the answering principal as the event's actor; the record - /// keeps both, so who answered is a platform fact keyed on that id. - #[test] - fn a_completed_interview_becomes_an_answered_record_under_petris_id_with_its_actor() { - let actor = Principal::System { - system_kind: SystemActorKind::Engine, - }; - let event = fabro_types::RunEvent { - id: "evt".to_string(), - ts: chrono::Utc::now(), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: Some(actor.clone()), - body: EventBody::InterviewCompleted(InterviewCompletedProps { - question_id: "gate#2".to_string(), - question: "Go?".to_string(), - answer: "N".to_string(), - duration_ms: 1_200, - }), - }; - let Some(PlatformRecord::InterviewAnswered(record)) = platform_record_for(&event) else { - panic!("a completed interview maps to an answered record"); - }; - assert_eq!(record.question, "gate#2"); - assert_eq!(record.principal, Some(actor)); - assert_eq!(record.channel, None); - } - - #[test] - fn a_failed_legacy_event_becomes_a_failed_lifecycle_record_with_its_message() { - let event = fabro_types::RunEvent { - id: "evt".to_string(), - ts: chrono::Utc::now(), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body: EventBody::RunFailed(RunFailedProps { - failure: fabro_types::RunFailure { - reason: FailureReason::Cancelled, - detail: fabro_types::FailureDetail::new( - "stopped", - fabro_types::FailureCategory::Canceled, - ), - }, - timing: fabro_types::RunTiming::default(), - final_git_commit_sha: None, - final_patch: None, - diff_summary: None, - usage: None, - }), - }; - let Some(PlatformRecord::RunLifecycle(record)) = platform_record_for(&event) else { - panic!("a failed run maps to a lifecycle record"); - }; - assert_eq!(record.transition, RunLifecycleKind::Failed); - assert_eq!( - record.status, - Some(RunStatus::Failed { - reason: FailureReason::Cancelled, - }) - ); - assert_eq!(record.reason.as_deref(), Some("stopped")); - } } diff --git a/lib/components/fabro-store/src/record/codec.rs b/lib/components/fabro-store/src/record/codec.rs deleted file mode 100644 index 6c0a8c67a..000000000 --- a/lib/components/fabro-store/src/record/codec.rs +++ /dev/null @@ -1,99 +0,0 @@ -use bytes::Bytes; -#[cfg(test)] -use serde::Serialize; -#[cfg(test)] -use serde::de::DeserializeOwned; - -use crate::{Error, Result}; - -pub(crate) trait Codec: Send + Sync + 'static { - fn encode(value: &R) -> Result>; - - fn decode(bytes: &[u8]) -> Result; -} - -#[cfg(test)] -pub(crate) struct JsonCodec; - -#[cfg(test)] -impl Codec for JsonCodec -where - R: Serialize + DeserializeOwned, -{ - fn encode(value: &R) -> Result> { - serde_json::to_vec(value).map_err(Into::into) - } - - fn decode(bytes: &[u8]) -> Result { - serde_json::from_slice(bytes).map_err(Into::into) - } -} - -pub(crate) struct RawBytesCodec; - -impl Codec for RawBytesCodec -where - R: AsRef<[u8]> + From, -{ - fn encode(value: &R) -> Result> { - Ok(value.as_ref().to_vec()) - } - - fn decode(bytes: &[u8]) -> Result { - Ok(R::from(Bytes::copy_from_slice(bytes))) - } -} - -pub(crate) struct MarkerCodec; - -impl Codec for MarkerCodec -where - R: Default, -{ - fn encode(_: &R) -> Result> { - Ok(Vec::new()) - } - - fn decode(bytes: &[u8]) -> Result { - if bytes.is_empty() { - return Ok(R::default()); - } - Err(Error::Other( - "marker records must decode from an empty byte slice".to_string(), - )) - } -} - -#[cfg(test)] -mod tests { - use chrono::{TimeZone, Utc}; - use serde::{Deserialize, Serialize}; - - use super::{Codec, JsonCodec}; - - #[derive(Debug, Serialize, Deserialize)] - struct SnapshotRecord { - code: String, - issued_at: chrono::DateTime, - expires_at: chrono::DateTime, - attempts: u32, - } - - #[test] - fn json_codec_matches_snapshot() { - let record = SnapshotRecord { - code: "code-123".to_string(), - issued_at: Utc.with_ymd_and_hms(2026, 4, 20, 12, 34, 56).unwrap(), - expires_at: Utc.with_ymd_and_hms(2026, 4, 20, 12, 39, 56).unwrap(), - attempts: 2, - }; - - let encoded = JsonCodec::encode(&record).unwrap(); - let encoded = std::str::from_utf8(&encoded).unwrap(); - - insta::assert_snapshot!( - encoded, - @"{\"code\":\"code-123\",\"issued_at\":\"2026-04-20T12:34:56Z\",\"expires_at\":\"2026-04-20T12:39:56Z\",\"attempts\":2}" - ); - } -} diff --git a/lib/components/fabro-store/src/record/mod.rs b/lib/components/fabro-store/src/record/mod.rs deleted file mode 100644 index 6482eb17c..000000000 --- a/lib/components/fabro-store/src/record/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Internal typed key/value helpers for simple SlateDB-backed records. -//! -//! The split of responsibility is: -//! - [`Record`]: declares the key prefix, id type, and codec for one persisted -//! type. -//! - [`RecordId`]: converts the typed id to and from key segments. -//! - [`Repository`]: performs the generic get/put/delete/scan operations. -//! -//! Production stores no longer read or write SlateDB records; this module is -//! compiled only for tests that model the retired Slate layout and goes away -//! with the remaining compatibility bridges. - -mod codec; -mod record_id; -mod repository; - -#[cfg(test)] -pub(crate) use codec::JsonCodec; -pub(crate) use codec::{Codec, MarkerCodec, RawBytesCodec}; -pub(crate) use repository::Repository; - -use crate::Result; - -pub(crate) trait Record: Sized + Send + Sync + 'static { - type Id: RecordId; - type Codec: Codec; - - const PREFIX: &'static str; - - #[cfg(test)] - fn id(&self) -> Self::Id; -} - -pub(crate) trait RecordId: Sized { - fn key_segments(&self) -> Vec; - - fn from_key_segments(segs: &[&str]) -> Result; -} diff --git a/lib/components/fabro-store/src/record/record_id.rs b/lib/components/fabro-store/src/record/record_id.rs deleted file mode 100644 index b926d9c12..000000000 --- a/lib/components/fabro-store/src/record/record_id.rs +++ /dev/null @@ -1,78 +0,0 @@ -use fabro_types::{BlobHash, RunId}; - -use super::RecordId; -use crate::{Error, Result}; - -impl RecordId for [u8; 32] { - fn key_segments(&self) -> Vec { - vec![hex::encode(self)] - } - - fn from_key_segments(segs: &[&str]) -> Result { - let [segment] = segs else { - return Err(Error::KeyParse(format!( - "expected 1 segment for [u8; 32], got {}", - segs.len() - ))); - }; - let mut bytes = [0_u8; 32]; - hex::decode_to_slice(segment, &mut bytes) - .map_err(|err| Error::KeyParse(format!("invalid hex segment {segment:?}: {err}")))?; - Ok(bytes) - } -} - -impl RecordId for String { - fn key_segments(&self) -> Vec { - vec![self.clone()] - } - - fn from_key_segments(segs: &[&str]) -> Result { - let [segment] = segs else { - return Err(Error::KeyParse(format!( - "expected 1 segment for String, got {}", - segs.len() - ))); - }; - Ok((*segment).to_string()) - } -} - -impl RecordId for BlobHash { - fn key_segments(&self) -> Vec { - vec![self.to_string()] - } - - fn from_key_segments(segs: &[&str]) -> Result { - let [segment] = segs else { - return Err(Error::KeyParse(format!( - "expected 1 segment for BlobHash, got {}", - segs.len() - ))); - }; - segment - .parse() - .map_err(|err| Error::KeyParse(format!("invalid BlobHash segment {segment:?}: {err}"))) - } -} - -impl RecordId for RunId { - fn key_segments(&self) -> Vec { - vec![ - self.created_at().format("%Y-%m-%d").to_string(), - self.to_string(), - ] - } - - fn from_key_segments(segs: &[&str]) -> Result { - if segs.len() != 2 { - return Err(Error::KeyParse(format!( - "expected 2 segments for RunId, got {}", - segs.len() - ))); - } - segs[1] - .parse() - .map_err(|err| Error::KeyParse(format!("invalid RunId segment {:?}: {err}", segs[1]))) - } -} diff --git a/lib/components/fabro-store/src/record/repository.rs b/lib/components/fabro-store/src/record/repository.rs deleted file mode 100644 index f851f6a64..000000000 --- a/lib/components/fabro-store/src/record/repository.rs +++ /dev/null @@ -1,518 +0,0 @@ -//! Thin typed storage wrapper for simple records that live directly in SlateDB. -//! -//! When adding a new persisted record type: -//! 1. Define the data struct. -//! 2. Implement [`Record`] for it with a stable `PREFIX`, `Id`, and `Codec`. -//! 3. Wrap `Repository` in a small domain store that exposes the -//! operations callers should use. -//! -//! Example: -//! -//! ```rust,ignore -//! use std::sync::Arc; -//! -//! use chrono::{DateTime, Utc}; -//! use serde::{Deserialize, Serialize}; -//! -//! use crate::record::{JsonCodec, Record, Repository}; -//! use crate::Result; -//! -//! #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -//! struct Session { -//! id: String, -//! user_id: String, -//! expires_at: DateTime, -//! } -//! -//! impl Record for Session { -//! type Id = String; -//! type Codec = JsonCodec; -//! const PREFIX: &'static str = "auth/session"; -//! -//! fn id(&self) -> Self::Id { -//! self.id.clone() -//! } -//! } -//! -//! struct SessionStore { -//! repo: Repository, -//! } -//! -//! impl SessionStore { -//! fn new(db: Arc) -> Self { -//! Self { -//! repo: Repository::new(db), -//! } -//! } -//! -//! async fn insert(&self, session: Session) -> Result<()> { -//! self.repo.put(&session).await -//! } -//! -//! async fn get(&self, id: &str) -> Result> { -//! self.repo.get(&id.to_string()).await -//! } -//! } -//! ``` -//! -//! `JsonCodec` is currently compiled only for tests; un-gate it when the -//! first production JSON-encoded record type appears. -//! -//! Keep `Repository` internal. Domain-specific invariants such as consume -//! locks, token rotation, or marker-only behavior belong in the named store -//! that wraps it, not in this generic layer. - -use std::marker::PhantomData; -use std::pin::Pin; -use std::sync::Arc; - -use futures::stream::{self}; -use futures::{Stream, StreamExt}; -use slatedb::{Db, KeyValue}; - -use super::{Codec, Record, RecordId}; -use crate::{Error, Result, keys}; - -/// Generic typed key/value operations shared by the simple record-backed -/// stores. -/// -/// Test-only: production stores are SQLite-backed, so this exists solely to -/// exercise the retired Slate layout from tests. -pub(crate) struct Repository { - db: Arc, - _record: PhantomData, -} - -impl Repository { - pub(crate) fn new(db: Arc) -> Self { - validate_prefix::(); - Self { - db, - _record: PhantomData, - } - } - - #[cfg(test)] - pub(crate) async fn get(&self, id: &R::Id) -> Result> { - self.db - .get(key_for_id::(id)?) - .await? - .map(|bytes| R::Codec::decode(&bytes)) - .transpose() - } - - #[cfg(test)] - pub(crate) async fn put(&self, record: &R) -> Result<()> { - let id = record.id(); - self.put_at(&id, record).await - } - - pub(crate) async fn put_at(&self, id: &R::Id, record: &R) -> Result<()> { - self.db - .put(key_for_id::(id)?, R::Codec::encode(record)?) - .await?; - Ok(()) - } - - pub(crate) async fn delete(&self, id: &R::Id) -> Result<()> { - self.db.delete(key_for_id::(id)?).await?; - Ok(()) - } - - #[cfg(test)] - pub(crate) async fn exists(&self, id: &R::Id) -> Result { - Ok(self.db.get(key_for_id::(id)?).await?.is_some()) - } - - #[allow( - dead_code, - reason = "Part of the shared Repository surface; current consumers do not need value scans yet" - )] - pub(crate) fn scan_stream(&self) -> RepositoryStream<'_, (R::Id, R)> { - self.scan_prefix_stream(&[]) - } - - #[allow( - dead_code, - reason = "Part of the shared Repository surface; current consumers do not need value scans by sub-prefix yet" - )] - pub(crate) fn scan_prefix_stream<'a>( - &'a self, - extra_segments: &'a [&'a str], - ) -> RepositoryStream<'a, (R::Id, R)> { - match prefix_key::(extra_segments) { - Ok(prefix) => Box::pin(scan_entries(Arc::clone(&self.db), &prefix).map(|result| { - result - .map_err(Into::into) - .and_then(|entry| decode_entry::(&entry)) - })), - Err(err) => Box::pin(stream::once(async move { Err(err) })), - } - } - - pub(crate) fn scan_ids_stream(&self) -> RepositoryStream<'_, R::Id> { - match prefix_key::(&[]) { - Ok(prefix) => Box::pin(scan_entries(Arc::clone(&self.db), &prefix).map(|result| { - result - .map_err(Into::into) - .and_then(|entry| parse_entry_id::(&entry)) - })), - Err(err) => Box::pin(stream::once(async move { Err(err) })), - } - } -} - -pub(crate) type RepositoryStream<'a, T> = Pin> + Send + 'a>>; - -pub(super) fn key_for_id(id: &R::Id) -> Result { - let id_segments = id.key_segments(); - key_from_segments( - R::PREFIX - .split('/') - .chain(id_segments.iter().map(String::as_str)), - ) -} - -pub(super) fn prefix_key(extra_segments: &[&str]) -> Result { - prefix_from_segments(R::PREFIX.split('/').chain(extra_segments.iter().copied())) -} - -fn decode_entry(entry: &KeyValue) -> Result<(R::Id, R)> { - let id = parse_entry_id::(entry)?; - let value = R::Codec::decode(&entry.value)?; - Ok((id, value)) -} - -fn parse_entry_id(entry: &KeyValue) -> Result { - let raw_key = String::from_utf8(entry.key.to_vec()) - .map_err(|err| Error::Other(format!("stored key is not valid UTF-8: {err}")))?; - let segments: Vec<&str> = keys::SlateKey::segments(&raw_key).collect(); - let prefix_len = R::PREFIX.split('/').count(); - if segments.len() < prefix_len { - return Err(Error::KeyParse(format!( - "key {raw_key:?} had {} segments, expected at least {} for prefix {}", - segments.len(), - prefix_len, - R::PREFIX - ))); - } - if !segments[..prefix_len] - .iter() - .copied() - .eq(R::PREFIX.split('/')) - { - return Err(Error::KeyParse(format!( - "key {raw_key:?} did not match expected prefix {}", - R::PREFIX - ))); - } - R::Id::from_key_segments(&segments[prefix_len..]) -} - -fn scan_entries( - db: Arc, - prefix: &keys::SlateKey, -) -> impl Stream> + Send { - enum ScanState { - Opening { db: Arc, prefix: Vec }, - Iterating(Box), - } - - stream::try_unfold( - ScanState::Opening { - db, - prefix: prefix.as_ref().to_vec(), - }, - |state| async move { - let mut iter = match state { - ScanState::Opening { db, prefix } => db.scan_prefix(prefix).await?, - ScanState::Iterating(iter) => *iter, - }; - - match iter.next().await? { - Some(entry) => Ok(Some((entry, ScanState::Iterating(Box::new(iter))))), - None => Ok(None), - } - }, - ) -} - -fn validate_prefix() { - debug_assert!( - !R::PREFIX.is_empty() - && !R::PREFIX.starts_with('/') - && !R::PREFIX.ends_with('/') - && R::PREFIX.split('/').all(|segment| !segment.is_empty()), - "Record::PREFIX must be a non-empty '/'-separated path with no empty segments: {}", - R::PREFIX - ); -} - -fn key_from_segments<'a>(segments: impl IntoIterator) -> Result { - let mut segments = segments.into_iter(); - let first = segments.next().ok_or_else(|| { - Error::Other("record key assembly requires at least one segment".to_string()) - })?; - validate_key_segment(first)?; - - let mut key = keys::SlateKey::new(first); - for segment in segments { - validate_key_segment(segment)?; - key = key.with(segment); - } - Ok(key) -} - -fn prefix_from_segments<'a>(segments: impl IntoIterator) -> Result { - Ok(key_from_segments(segments)?.into_prefix()) -} - -fn validate_key_segment(segment: &str) -> Result<()> { - if segment.as_bytes().contains(&b'\0') { - return Err(Error::InvalidKeySegment { - segment: segment.to_string(), - }); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use futures::TryStreamExt; - use object_store::memory::InMemory; - use serde::{Deserialize, Serialize}; - - use super::Repository; - use crate::record::{JsonCodec, MarkerCodec, RawBytesCodec, Record, RecordId}; - use crate::{Error, Result}; - - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - struct TestRecord { - id: TestId, - payload: String, - delete_me: bool, - } - - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - struct TestId { - bucket: String, - name: String, - } - - impl RecordId for TestId { - fn key_segments(&self) -> Vec { - vec![self.bucket.clone(), self.name.clone()] - } - - fn from_key_segments(segs: &[&str]) -> Result { - let [bucket, name] = segs else { - return Err(Error::KeyParse(format!( - "expected 2 segments for TestId, got {}", - segs.len() - ))); - }; - Ok(Self { - bucket: (*bucket).to_string(), - name: (*name).to_string(), - }) - } - } - - impl Record for TestRecord { - type Id = TestId; - type Codec = JsonCodec; - - const PREFIX: &'static str = "test/repository"; - - fn id(&self) -> Self::Id { - self.id.clone() - } - } - - #[derive(Debug, Clone, PartialEq, Eq, Default)] - struct TestMarker; - - impl Record for TestMarker { - type Id = String; - type Codec = MarkerCodec; - - const PREFIX: &'static str = "test/marker"; - - fn id(&self) -> Self::Id { - unreachable!("marker records must use put_at") - } - } - - #[derive(Debug, Clone, PartialEq, Eq)] - struct RawBlob(bytes::Bytes); - - impl AsRef<[u8]> for RawBlob { - fn as_ref(&self) -> &[u8] { - self.0.as_ref() - } - } - - impl From for RawBlob { - fn from(value: bytes::Bytes) -> Self { - Self(value) - } - } - - impl Record for RawBlob { - type Id = String; - type Codec = RawBytesCodec; - - const PREFIX: &'static str = "test/raw"; - - fn id(&self) -> Self::Id { - "blob".to_string() - } - } - - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - struct InvalidSegmentRecord { - id: String, - } - - impl Record for InvalidSegmentRecord { - type Id = String; - type Codec = JsonCodec; - - const PREFIX: &'static str = "test/invalid"; - - fn id(&self) -> Self::Id { - self.id.clone() - } - } - - async fn db() -> Arc { - Arc::new( - slatedb::Db::open("repository-tests", Arc::new(InMemory::new())) - .await - .unwrap(), - ) - } - - fn record(bucket: &str, name: &str, delete_me: bool) -> TestRecord { - TestRecord { - id: TestId { - bucket: bucket.to_string(), - name: name.to_string(), - }, - payload: format!("{bucket}/{name}"), - delete_me, - } - } - - #[tokio::test] - async fn put_get_delete_and_scan_round_trip() { - let repo = Repository::::new(db().await); - let saved = record("bucket-a", "alpha", false); - - assert!(repo.get(&saved.id()).await.unwrap().is_none()); - repo.put(&saved).await.unwrap(); - assert_eq!(repo.get(&saved.id()).await.unwrap(), Some(saved.clone())); - - let records = [ - saved.clone(), - record("bucket-a", "beta", false), - record("bucket-b", "alpha", false), - record("bucket-b", "beta", false), - record("bucket-c", "gamma", false), - ]; - for record in &records[1..] { - repo.put(record).await.unwrap(); - } - - let scanned = repo.scan_stream().try_collect::>().await.unwrap(); - assert_eq!(scanned.len(), records.len()); - assert_eq!(scanned[0], (records[0].id(), records[0].clone())); - - let bucket_a = repo - .scan_prefix_stream(&["bucket-a"]) - .try_collect::>() - .await - .unwrap(); - assert_eq!(bucket_a, vec![ - (records[0].id(), records[0].clone()), - (records[1].id(), records[1].clone()), - ]); - - repo.delete(&saved.id()).await.unwrap(); - assert!(repo.get(&saved.id()).await.unwrap().is_none()); - } - - #[tokio::test] - async fn marker_records_use_put_at_exists_and_scan_ids() { - let repo = Repository::::new(db().await); - let marker = TestMarker; - - repo.put_at(&"marker-a".to_string(), &marker).await.unwrap(); - repo.put_at(&"marker-b".to_string(), &marker).await.unwrap(); - - assert!(repo.exists(&"marker-a".to_string()).await.unwrap()); - - let ids = repo - .scan_ids_stream() - .try_collect::>() - .await - .unwrap(); - assert_eq!(ids, vec!["marker-a".to_string(), "marker-b".to_string()]); - - repo.delete(&"marker-a".to_string()).await.unwrap(); - assert!(!repo.exists(&"marker-a".to_string()).await.unwrap()); - } - - #[tokio::test] - async fn raw_bytes_codec_round_trips_bytes() { - let repo = Repository::::new(db().await); - let blob = RawBlob(bytes::Bytes::from_static(b"hello")); - - repo.put(&blob).await.unwrap(); - - assert_eq!(repo.get(&"blob".to_string()).await.unwrap(), Some(blob)); - } - - #[tokio::test] - async fn malformed_bytes_propagate_decode_errors() { - let db = db().await; - let repo = Repository::::new(Arc::clone(&db)); - db.put( - super::key_for_id::(&TestId { - bucket: "bucket-a".to_string(), - name: "broken".to_string(), - }) - .unwrap(), - b"not-json", - ) - .await - .unwrap(); - - let error = repo - .get(&TestId { - bucket: "bucket-a".to_string(), - name: "broken".to_string(), - }) - .await - .unwrap_err(); - assert!(matches!(error, Error::Serde(_))); - } - - #[tokio::test] - async fn invalid_key_segments_return_runtime_error() { - let repo = Repository::::new(db().await); - let error = repo - .put(&InvalidSegmentRecord { - id: "bad\0segment".to_string(), - }) - .await - .unwrap_err(); - - match error { - Error::InvalidKeySegment { segment } => assert_eq!(segment, "bad\0segment"), - other => panic!("expected invalid key segment error, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs deleted file mode 100644 index 081a0f27e..000000000 --- a/lib/components/fabro-store/src/run_state.rs +++ /dev/null @@ -1,6668 +0,0 @@ -use std::collections::{BTreeMap, HashMap}; -use std::str::FromStr; -use std::sync::Arc; - -use chrono::{DateTime, Utc}; -use fabro_types::run_event::{ - AgentEventProps, CheckpointCompletedProps, RunCompletedProps, RunFailedProps, - StageCompletedProps, -}; -use fabro_types::settings::run::RunEnvironmentSettings; -use fabro_types::{ - AskFabro, Checkpoint, CheckpointRecord, CommandTermination, Conclusion, EventBody, - FailureCategory, FailureSignature, InterviewQuestionRecord, ModelRef, ModelUsage, Outcome, - PendingInterviewRecord, PendingReason, PullRequestCreation, PullRequestCreationStatus, - PullRequestLink, RepositoryRef, Run, RunApproval, RunApprovalState, RunControlAction, RunDiff, - RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, RunProjection, RunSandbox, - RunSandboxFailure, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, RunSize, RunSpec, - RunStatus, RunTimestamps, SandboxProviderKind, StageCompletion, StageHandler, StageId, - StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection, StageState, - StartRecord, WorkflowRef, first_event_seq, sum_usage, timing, usage_rollup, -}; -use fabro_util::error::render_compact_with_causes; -use lithos_llm::catalog::{ModelId, ProviderId}; -use lithos_llm::types::Usage; -use pebble_coding_agent::events::CodingEvent; -use pebble_coding_agent::projection::SessionProjection; - -use crate::{Error, EventEnvelope, Result}; - -#[derive(Debug, Clone, Default)] -pub(crate) struct EventProjectionCache { - pub last_seq: u32, - // Arc-shared with readers so a snapshot never deep-copies the projection; - // mutated copy-on-write via `Arc::make_mut`. - pub state: Option>, -} - -/// A run's projection at its committed head. `RunSummaryStore` replays it -/// from SQLite history and derives summary rows from it; `RunDatabase` builds -/// it from a newly committed event and seeds its in-memory cache with it. -#[derive(Debug, Clone)] -pub(crate) struct ProjectedRun { - pub(crate) run_id: RunId, - pub(crate) projection: Arc, - pub(crate) last_seq: u32, -} - -impl ProjectedRun { - pub(crate) fn new(run_id: RunId, projection: Arc, last_seq: u32) -> Self { - Self { - run_id, - projection, - last_seq, - } - } - - /// Replays a run's full history; the last event's `seq` becomes the head. - pub(crate) fn replay(run_id: RunId, events: &[EventEnvelope]) -> Result { - let projection = RunProjection::apply_events(events)?; - let last_seq = events - .last() - .expect("a successfully replayed history contains at least one event") - .seq; - Ok(Self::new(run_id, Arc::new(projection), last_seq)) - } -} - -impl From for EventProjectionCache { - fn from(projected: ProjectedRun) -> Self { - Self { - last_seq: projected.last_seq, - state: Some(projected.projection), - } - } -} - -pub trait RunProjectionReducer { - fn apply_events(events: &[EventEnvelope]) -> Result - where - Self: Sized; - - fn apply_event(&mut self, event: &EventEnvelope) -> Result<()>; -} - -impl RunProjectionReducer for RunProjection { - fn apply_events(events: &[EventEnvelope]) -> Result { - let Some((first, rest)) = events.split_first() else { - return Err(Error::InvalidEvent( - "run projection requires a run.created event".to_string(), - )); - }; - let mut state = projection_from_created(first)?; - for event in rest { - // Runs written before the runnable state was introduced can move - // directly from submitted to starting. Replay that historical - // shape through the equivalent current transition while keeping - // live single-event transitions strict. - if matches!(event.event.body, EventBody::RunStarting(_)) - && matches!(state.status, RunStatus::Submitted) - { - state.try_apply_status(RunStatus::Runnable, event.event.ts)?; - } - if let EventBody::RunFailed(props) = &event.event.body { - let failed = RunStatus::Failed { - reason: props.failure.reason, - }; - if !state.status.can_transition_to(failed) { - if matches!( - state.status, - RunStatus::Submitted | RunStatus::Pending { .. } - ) { - state.try_apply_status(RunStatus::Runnable, event.event.ts)?; - } - if matches!(state.status, RunStatus::Runnable) { - state.try_apply_status(RunStatus::Starting, event.event.ts)?; - } - } - } - state.apply_event(event)?; - } - Ok(state) - } - - fn apply_event(&mut self, event: &EventEnvelope) -> Result<()> { - let stored = &event.event; - let ts = stored.ts; - - self.last_event_at = ts; - - match &stored.body { - EventBody::RunCreated(_) => { - return Err(Error::InvalidEvent( - "run.created cannot be applied to an initialized projection".to_string(), - )); - } - EventBody::RunStarted(props) => { - self.start = Some(StartRecord { - start_time: ts, - run_branch: props.run_branch.clone(), - base_sha: props.base_sha.clone(), - }); - } - EventBody::RunSubmitted(props) => { - self.spec.definition_blob = props.definition_blob; - } - EventBody::RunStartRequested(props) if props.resume => { - self.try_apply_status(RunStatus::Submitted, ts)?; - self.conclusion = None; - } - EventBody::RunPending(props) => { - self.try_apply_status( - RunStatus::Pending { - reason: props.reason, - }, - ts, - )?; - if props.reason == PendingReason::ApprovalRequired { - self.approval = Some(RunApproval { - state: RunApprovalState::Pending, - requested_at: ts, - decided_at: None, - denial_reason: None, - }); - } - } - EventBody::RunApproved(_) => { - if let Some(approval) = &mut self.approval { - approval.state = RunApprovalState::Approved; - approval.decided_at = Some(ts); - approval.denial_reason = None; - } - } - EventBody::RunDenied(props) => { - if let Some(approval) = &mut self.approval { - approval.state = RunApprovalState::Denied; - approval.decided_at = Some(ts); - approval.denial_reason.clone_from(&props.reason); - } - } - EventBody::RunRunnable(_) => { - self.try_apply_status(RunStatus::Runnable, ts)?; - } - EventBody::RunStarting(_) => { - self.try_apply_status(RunStatus::Starting, ts)?; - } - EventBody::RunRunning(_) => { - self.try_apply_status(RunStatus::Running, ts)?; - } - EventBody::RunBlocked(props) => { - let next = if matches!(self.status, RunStatus::Paused { .. }) { - RunStatus::Paused { - prior_block: Some(props.blocked_reason), - } - } else { - RunStatus::Blocked { - blocked_reason: props.blocked_reason, - } - }; - self.try_apply_status(next, ts)?; - } - EventBody::RunUnblocked(_) => { - let next = match self.status { - RunStatus::Paused { - prior_block: Some(_), - } => RunStatus::Paused { prior_block: None }, - RunStatus::Paused { prior_block: None } => { - RunStatus::Paused { prior_block: None } - } - _ => RunStatus::Running, - }; - self.try_apply_status(next, ts)?; - } - EventBody::RunRemoving(_) => { - self.try_apply_status(RunStatus::Removing, ts)?; - } - EventBody::RunCancelRequested(_) => { - self.pending_control = Some(RunControlAction::Cancel); - } - EventBody::RunPauseRequested(_) => { - self.pending_control = Some(RunControlAction::Pause); - } - EventBody::RunUnpauseRequested(_) => { - self.pending_control = Some(RunControlAction::Unpause); - } - EventBody::RunPaused(_) => { - self.try_apply_status( - RunStatus::Paused { - prior_block: self.status.blocked_reason(), - }, - ts, - )?; - self.pending_control = None; - } - EventBody::RunUnpaused(_) => { - let next = match self.status { - RunStatus::Paused { - prior_block: Some(blocked_reason), - } => RunStatus::Blocked { blocked_reason }, - _ => RunStatus::Running, - }; - self.try_apply_status(next, ts)?; - self.pending_control = None; - } - EventBody::RunCompleted(props) => { - self.try_apply_status( - RunStatus::Succeeded { - reason: props.reason, - }, - ts, - )?; - self.pending_control = None; - self.conclusion = Some(conclusion_from_completed(self, props, ts)?); - self.pending_interviews.clear(); - } - EventBody::RunFailed(props) => { - self.try_apply_status( - RunStatus::Failed { - reason: props.failure.reason, - }, - ts, - )?; - self.pending_control = None; - self.conclusion = Some(conclusion_from_failed(self, props, ts)); - self.pending_interviews.clear(); - finalize_unfinished_stages_after_run_failed(self, props, ts); - } - EventBody::RunSupersededBy(props) => { - self.superseded_by = Some(props.new_run_id); - } - EventBody::RunParentLinked(props) => { - self.parent_id = Some(props.parent_id); - } - EventBody::RunParentUnlinked(_props) => { - self.parent_id = None; - } - EventBody::RunArchived(_props) => { - if self.archived_at.is_some() { - return Ok(()); - } - if !self.status.is_terminal() { - return Err(fabro_types::InvalidTransition { - from: self.status, - to: self.status, - } - .into()); - } - self.archived_at = Some(ts); - } - EventBody::RunUnarchived(_props) => { - self.archived_at = None; - } - EventBody::RunTitleUpdated(props) => { - self.title.clone_from(&props.title); - } - EventBody::CheckpointCompleted(props) => { - let checkpoint = checkpoint_from_props(props, ts); - if let Some(stage_id) = stored.stage_id.as_ref() { - // Envelope-first: the diff and any skipped-stage synthesis - // attach to the exact execution recorded on the event. - // Historical `node_outcomes` must not create or collide - // with a newer execution ordinal. - apply_checkpoint_to_stage(self, stage_id, props, &checkpoint, event.seq, ts); - } else { - // Legacy fallback for events without a stored stage id: - // resolve the visit from the checkpointed `node_visits` - // and synthesize skipped stages from historical outcomes. - if let Some(node_id) = stored.node_id.as_deref() { - let visit = checkpoint - .node_visits - .get(node_id) - .and_then(|visit| u32::try_from(*visit).ok()) - .unwrap_or(1); - if let Some(diff) = props.diff.clone() { - self.stage_entry(node_id, visit, first_event_seq(event.seq)) - .diff = Some(diff); - } - } - for (node_id, outcome) in &checkpoint.node_outcomes { - if outcome.status != StageOutcome::Skipped { - continue; - } - let visit = checkpoint - .node_visits - .get(node_id) - .and_then(|visit| u32::try_from(*visit).ok()) - .unwrap_or(1); - if self - .stage(&fabro_types::StageId::new(node_id, visit)) - .is_some() - { - continue; - } - let stage = self.stage_entry(node_id, visit, first_event_seq(event.seq)); - stage.completion = Some(stage_completion_from_outcome(outcome, ts)); - stage.state = StageState::Skipped; - } - } - self.checkpoints.push(CheckpointRecord { - seq: event.seq, - checkpoint, - diff: diff_from_checkpoint_props(props), - }); - } - EventBody::SandboxInitializing(_) => { - let plan = sandbox_plan_from_projection_or_settings(self); - self.sandbox = Some(RunSandbox::initializing(plan)); - } - EventBody::SandboxFailed(props) => { - let plan = sandbox_plan_from_projection_or_settings(self); - self.sandbox = Some(RunSandbox::failed(plan, RunSandboxFailure { - provider: props.provider.clone(), - error: props.error.clone(), - causes: props.causes.clone(), - duration_ms: props.duration_ms, - })); - } - EventBody::GitIdentityResolved(props) => { - self.git_identity = Some(props.identity.clone()); - } - EventBody::SandboxInitialized(props) => { - let plan = sandbox_plan_from_projection_or_settings(self); - self.sandbox = Some(RunSandbox::ready(plan, RunSandboxInstance { - provider: props.provider.clone(), - image: props.image.clone(), - snapshot: props.snapshot.clone(), - runtime: RunSandboxRuntime { - id: props.id.clone(), - working_directory: props.working_directory.clone(), - repo_cloned: props.repo_cloned, - clone_origin_url: props.clone_origin_url.clone(), - clone_branch: props.clone_branch.clone(), - workspace_root: props.workspace_root.clone(), - repos_root: props.repos_root.clone(), - primary_repo_path: props.primary_repo_path.clone(), - primary_repo_link: props.primary_repo_link.clone(), - }, - })); - } - EventBody::PullRequestCreationRequested(props) => { - self.pull_request_creation = Some(PullRequestCreation { - id: props.creation_id, - status: PullRequestCreationStatus::Pending, - model: props.model.clone(), - force: props.force, - requested_at: ts, - updated_at: ts, - pull_request: None, - error: None, - }); - } - EventBody::PullRequestCreated(props) => { - let pull_request = PullRequestLink { - owner: props.owner.clone(), - repo: props.repo.clone(), - number: props.pr_number, - }; - self.pull_request = Some(pull_request.clone()); - if let Some(creation) = self - .pull_request_creation - .as_mut() - .filter(|creation| creation.is_pending()) - { - creation.succeed(pull_request, ts); - } - } - EventBody::PullRequestLinked(props) => { - self.pull_request = Some(props.pull_request.clone()); - if let Some(creation) = self - .pull_request_creation - .as_mut() - .filter(|creation| creation.is_pending()) - { - creation.succeed(props.pull_request.clone(), ts); - } - } - EventBody::PullRequestUnlinked(_) => { - self.pull_request = None; - // Clear the creation record too: a lingering `Succeeded` - // record would point at a pull request that is no longer - // linked, and it would block a later explicit creation. - self.pull_request_creation = None; - } - EventBody::PullRequestFailed(props) => { - // Only a failure that names the pending creation resolves it; - // publish-stage failures carry no creation id and must not - // fail an unrelated explicit creation. - if let Some(creation) = self.pull_request_creation.as_mut().filter(|creation| { - Some(creation.id) == props.creation_id && creation.is_pending() - }) { - creation.fail(props.error.clone(), ts); - } - } - EventBody::InterviewStarted(props) => { - if props.question_id.is_empty() { - return Ok(()); - } - self.pending_interviews - .insert(props.question_id.clone(), PendingInterviewRecord { - question: InterviewQuestionRecord { - id: props.question_id.clone(), - text: props.question.clone(), - stage: props.stage.clone(), - question_type: props.question_type.parse().unwrap_or_default(), - options: props.options.clone(), - allow_freeform: props.allow_freeform, - timeout_seconds: props.timeout_seconds, - context_display: props.context_display.clone(), - review_target: props.review_target.clone(), - }, - started_at: ts, - }); - } - EventBody::InterviewCompleted(props) if !props.question_id.is_empty() => { - self.pending_interviews.remove(&props.question_id); - } - EventBody::InterviewTimeout(props) if !props.question_id.is_empty() => { - self.pending_interviews.remove(&props.question_id); - } - EventBody::InterviewInterrupted(props) if !props.question_id.is_empty() => { - self.pending_interviews.remove(&props.question_id); - } - EventBody::StageStarted(props) => { - let Some(stage_id) = stored.stage_id.as_ref() else { - return Ok(()); - }; - // A `stage.started` for a new `StageId` creates a new - // projection, so an older execution's terminal projection - // stays immutable. `begin_attempt` on an existing entry - // remains the compatibility path for automatic retries and - // legacy histories that repeat one `StageId`. - let is_new = self.stage(stage_id).is_none(); - let stage = stage_at_stored_stage_id(self, stage_id, event.seq); - stage.begin_attempt( - ts, - StageHandler::from_handler_type(Some(&props.handler_type)), - ); - if is_new { - stage.graph_visit = props.graph_visit; - stage - .resumed_from_stage_id - .clone_from(&props.resumed_from_stage_id); - } - } - EventBody::StageRetrying(_) => { - let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - stage.state = StageState::Retrying; - } - EventBody::StagePrompt(props) => { - let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) - else { - return Ok(()); - }; - stage.prompt = Some(props.text.clone()); - stage.provider_used = StageModelUsage::from_prompt_props(props); - } - EventBody::PromptCompleted(props) => { - let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - stage.response = Some(props.response.clone()); - if let Some(usage) = &props.usage { - stage.usage = usage.usage; - stage.model = Some(usage.model().clone()); - } - } - EventBody::StageCompleted(props) => { - let response = props.response.clone(); - let outcome = stage_outcome_from_props(props); - let completion = stage_completion_from_outcome(&outcome, ts); - let Some(stage) = - stage_at_completed_visit(self, stored, props.node_visits.as_ref(), event.seq) - else { - return Ok(()); - }; - stage.response = response; - stage.completion = Some(completion); - stage.set_authoritative_timing(props.timing); - if let Some(usage) = &props.usage { - stage.usage = usage.usage; - stage.model = Some(usage.model().clone()); - } - stage.usage_by_model.clone_from(&props.usage_by_model); - stage.state = StageState::from(outcome.status); - } - EventBody::StageFailed(props) => { - let failure_reason = props.failure.as_ref().map(|detail| detail.message.clone()); - let failure_category = props.failure.as_ref().map(|detail| detail.category); - let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - let outcome = StageOutcome::Failed { - retry_requested: props.will_retry, - }; - stage.completion = Some(StageCompletion { - outcome, - notes: None, - failure_reason, - timestamp: ts, - }); - stage.set_authoritative_timing(props.timing); - if let Some(usage) = &props.usage { - stage.usage = usage.usage; - stage.model = Some(usage.model().clone()); - } - stage.usage_by_model.clone_from(&props.usage_by_model); - stage.state = - stage_state_from_failure(props.will_retry, failure_category, stage.termination); - } - EventBody::Agent(props) => { - apply_agent_event(self, stored, props, event.seq, ts); - } - EventBody::AgentSessionActivated(props) => { - let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) - else { - return Ok(()); - }; - stage.provider_used = Some(StageModelUsage::from_agent_session_activated(props)); - stage.permission_level = props.permission_level; - } - EventBody::AgentToolsAvailable(props) => { - let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) - else { - return Ok(()); - }; - stage.agent_tools.clone_from(&props.tools); - } - EventBody::AgentAcpStarted(props) => { - let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) - else { - return Ok(()); - }; - stage.open_acp_inference(ts); - // `provider_used` is intentionally sourced from the subsequent - // `AgentSessionActivated` event, which carries the canonical - // provider/model. ACP runs without a steering hub never emit - // activation and legitimately leave it unset. - } - EventBody::CommandStarted(props) => { - let script_invocation = serde_json::to_value(props).map_err(|err| { - Error::InvalidEvent(format!("invalid command.started payload: {err}")) - })?; - let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - stage.script_invocation = Some(script_invocation); - } - EventBody::CommandCompleted(props) => { - let script_timing = serde_json::to_value(props).map_err(|err| { - Error::InvalidEvent(format!("invalid command.completed payload: {err}")) - })?; - let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - stage.output = Some(props.output.clone()); - stage.output_bytes = Some(props.output_bytes); - stage.live_streaming = Some(props.live_streaming); - stage.termination = Some(props.termination); - stage.script_timing = Some(script_timing); - } - EventBody::AgentAcpCompleted(props) => { - let Some(stage) = stage_at_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - stage.close_acp_inference(props.duration_ms); - apply_agent_terminal( - "agent.acp", - stage, - props, - merge_agent_process_output(&props.stdout, &props.stderr), - CommandTermination::Exited, - )?; - } - EventBody::AgentAcpCancelled(props) => { - let Some(stage) = stage_at_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - stage.close_acp_inference(props.duration_ms); - apply_agent_terminal( - "agent.acp", - stage, - props, - merge_agent_process_output(&props.stdout, &props.stderr), - CommandTermination::Cancelled, - )?; - } - EventBody::AgentAcpTimedOut(props) => { - let Some(stage) = stage_at_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - stage.close_acp_inference(props.duration_ms); - apply_agent_terminal( - "agent.acp", - stage, - props, - merge_agent_process_output(&props.stdout, &props.stderr), - CommandTermination::TimedOut, - )?; - } - EventBody::ParallelCompleted(props) => { - let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - stage.parallel_results = Some(props.results.clone()); - } - EventBody::ParallelBranchStarted(props) => { - // Branches bypass the engine's StageStarted/StageCompleted - // lifecycle. Seed started_at so the branch stage drives a live - // wall-clock timer while it runs (the entry is created Running). - let handler = stored - .node_id - .as_deref() - .and_then(|node_id| self.spec().graph.nodes.get(node_id)) - .map(|node| StageHandler::from_handler_type(node.handler_type())); - let is_new = stored - .stage_id - .as_ref() - .is_none_or(|stage_id| self.stage(stage_id).is_none()); - let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - if stage.started_at.is_none() { - stage.started_at = Some(ts); - } - if stage.handler.is_none() { - stage.handler = handler; - } - if is_new { - stage.graph_visit = props.graph_visit; - stage - .resumed_from_stage_id - .clone_from(&props.resumed_from_stage_id); - stage - .parallel_branch_id - .clone_from(&stored.parallel_branch_id); - } - stage.state = StageState::Running; - } - EventBody::ParallelBranchCompleted(props) => { - // A branch never emits its own StageCompleted, so finalize it - // here; otherwise the stage spins Running forever after the run - // (and the fan-in) is done. - let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - stage.completion = Some(StageCompletion { - outcome: props.status, - notes: None, - failure_reason: None, - timestamp: ts, - }); - stage.timing = Some(fabro_types::StageTiming::wall_only(props.duration_ms)); - stage.state = StageState::from(props.status); - } - _ => {} - } - - Ok(()) - } -} - -/// Fold one pebble coding-agent event into the stage that produced it. -/// -/// The stage comes from the stored envelope (`stage_id`) or, for events -/// written before stage identity existed, from the node and visit carried in -/// the properties. Streaming deltas never reach the store. -fn apply_agent_event( - state: &mut RunProjection, - stored: &RunEvent, - props: &AgentEventProps, - seq: u32, - ts: DateTime, -) { - let visit = props.visit; - // Pebble's own fold sees every agent event the stage stored, before the - // fabro-only arms below read the same event. While the stage runs, its - // usage is that fold's: the tree's tokens, the root's and every - // subagent's, with the cost lithos-llm attached to each answer. The - // terminal usage is the same sum, split by model. - if let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) { - let agent = stage.agent.get_or_insert_default(); - agent.apply(&props.event); - if stage.completion.is_none() { - stage.usage = live_usage(agent); - } - } - #[expect( - clippy::wildcard_enum_match_arm, - reason = "pebble's event vocabulary is non-exhaustive and only some events project" - )] - match props.coding_event() { - CodingEvent::AssistantMessage { model, .. } => { - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - if let Some(model) = stage_model_ref(stage, model) { - stage.model = Some(model); - } - close_inference_bracket(state, stored, visit, seq, ts); - } - CodingEvent::LlmRequestStarted { requested_model } => { - open_inference_bracket(state, stored, requested_model, visit, seq, ts); - } - CodingEvent::LlmFirstOutput { kind } => { - let Some(inference) = matching_inference_bracket(state, stored, visit, seq) else { - return; - }; - inference.first_output_at = Some(ts); - inference.first_output_kind = Some(*kind); - } - CodingEvent::LlmRetry { .. } => { - let Some(inference) = matching_inference_bracket(state, stored, visit, seq) else { - return; - }; - inference.retries = inference.retries.saturating_add(1); - // A retry discards whatever the failed attempt produced. - // Replay is driven purely by events, so resetting the - // in-process latch is not enough: without this the projection - // keeps asserting output the agent already threw away. - inference.first_output_at = None; - inference.first_output_kind = None; - } - // An error ends the open request; an interrupt does too, and the - // interrupt itself is the fold's (`agent.activity`). - CodingEvent::Error { .. } | CodingEvent::RoundInterrupted { .. } => { - close_inference_bracket(state, stored, visit, seq, ts); - } - CodingEvent::SessionEnded => { - close_active_brackets_for_session(state, stored, ts); - } - CodingEvent::ToolCallStarted { - tool_name, - tool_call_id, - .. - } => { - let root_session_id = if stored.parent_session_id.is_none() { - stored.session_id.clone() - } else { - None - }; - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - if let Some(tool) = stage - .agent_tools - .iter_mut() - .find(|tool| tool.name == *tool_name) - { - tool.invoked = true; - } - // A subagent's tools run inside the root session's tool call, - // so the root batch already covers them. Timing them again - // would double-count that span. - if let Some(session_id) = root_session_id { - stage.open_tool_call(session_id, tool_call_id.clone(), ts); - } - } - CodingEvent::ToolCallCompleted { tool_call_id, .. } => { - if stored.parent_session_id.is_some() { - return; - } - let Some(session_id) = stored.session_id.as_deref() else { - return; - }; - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - stage.close_tool_call(session_id, tool_call_id, ts); - } - _ => {} - } -} - -/// A running stage's usage, from its agent's fold: the tree's tokens and the -/// cost the provider reported for them, `None` once any of them went -/// unpriced. -fn live_usage(agent: &SessionProjection) -> Usage { - agent.usage.saturating_add(agent.descendant_usage()) -} - -/// The model reference for a message the stage's session produced. -/// -/// Pebble reports the model id alone; the provider comes from the session -/// activation (or session open) recorded on the stage. Without either there -/// is no honest provider to bill against, so the stage's model is left as is. -fn stage_model_ref(stage: &StageProjection, model: &str) -> Option { - let provider = stage_provider(stage)?; - Some(ModelRef::new(provider, ModelId::new(model))) -} - -fn stage_provider(stage: &StageProjection) -> Option { - stage - .provider_used - .as_ref() - .and_then(|usage| usage.provider.as_deref()) - .map(ProviderId::new) - .or_else(|| stage.model.as_ref().map(|model| model.provider.clone())) -} - -fn projection_from_created(event: &EventEnvelope) -> Result { - let stored = &event.event; - let EventBody::RunCreated(props) = &stored.body else { - return Err(Error::InvalidEvent(format!( - "run projection must start with run.created, got {}", - stored.body.event_name() - ))); - }; - - let labels = props.labels.clone().into_iter().collect::>(); - let title = props - .title - .clone() - .unwrap_or_else(|| fabro_types::infer_run_title(props.graph.goal())); - let spec = RunSpec { - run_id: stored.run_id, - settings: props.settings.clone(), - graph: props.graph.clone(), - graph_source: props.workflow_source.clone(), - workflow_slug: props.workflow_slug.clone(), - workflow_version_id: props.workflow_version_id, - target: props.target.clone(), - automation: props.automation.clone(), - source_directory: props.source_directory.clone(), - labels, - provenance: props.provenance.clone(), - definition_blob: None, - spec_blob: props.spec_blob, - git: props.git.clone(), - fork_source_ref: props.fork_source_ref.clone(), - admission: props.admission.clone(), - }; - - let mut projection = RunProjection::new(title, spec, stored.ts); - projection.parent_id = props.parent_id; - projection.retried_from = props.retried_from; - projection.web_url.clone_from(&props.web_url); - projection.sandbox = Some(RunSandbox::planned(sandbox_plan( - &projection.spec.settings.run.environment, - ))); - Ok(projection) -} - -fn sandbox_plan_from_projection_or_settings(state: &RunProjection) -> RunSandboxPlan { - state.sandbox.as_ref().map_or_else( - || sandbox_plan(&state.spec.settings.run.environment), - |sandbox| sandbox.plan().clone(), - ) -} - -fn sandbox_plan(settings: &RunEnvironmentSettings) -> RunSandboxPlan { - RunSandboxPlan { - provider: settings.provider.clone(), - image: (settings.provider == SandboxProviderKind::DOCKER) - .then(|| settings.image.docker.clone()) - .flatten() - .filter(|image| !image.is_empty()), - snapshot: None, - } -} - -fn stage_at_visit<'a>( - state: &'a mut RunProjection, - stored: &RunEvent, - visit: u32, - seq: u32, -) -> Option<&'a mut StageProjection> { - if visit == 0 { - return None; - } - let node_id = stored.node_id.as_deref()?; - Some(state.stage_entry(node_id, visit, first_event_seq(seq))) -} - -fn stage_at_current_visit<'a>( - state: &'a mut RunProjection, - stored: &RunEvent, - seq: u32, -) -> Option<&'a mut StageProjection> { - let node_id = stored.node_id.as_deref()?; - let visit = state.current_visit_for(node_id).unwrap_or(1); - Some(state.stage_entry(node_id, visit, first_event_seq(seq))) -} - -fn stage_at_stored_stage_id<'a>( - state: &'a mut RunProjection, - stage_id: &StageId, - seq: u32, -) -> &'a mut StageProjection { - state.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(seq)) -} - -fn stage_at_stored_or_visit<'a>( - state: &'a mut RunProjection, - stored: &RunEvent, - visit: u32, - seq: u32, -) -> Option<&'a mut StageProjection> { - if let Some(stage_id) = stored.stage_id.as_ref() { - return Some(stage_at_stored_stage_id(state, stage_id, seq)); - } - stage_at_visit(state, stored, visit, seq) -} - -/// Open the inference bracket for the stage this event names. -/// -/// Only root-session events open a bracket: forwarded child events carry a -/// parent session and must not overwrite the parent's bracket. The session id -/// is copied from the envelope so every later transition can be gated on it. -fn open_inference_bracket( - state: &mut RunProjection, - stored: &RunEvent, - requested_model: &str, - visit: u32, - seq: u32, - ts: DateTime, -) { - if stored.parent_session_id.is_some() { - return; - } - let Some(session_id) = stored.session_id.clone() else { - return; - }; - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - stage.inference = Some(StageInferenceProjection { - session_id, - started_at: ts, - requested_model: requested_model.to_string(), - first_output_at: None, - first_output_kind: None, - retries: 0, - }); -} - -/// Resolve the stage owning a bracket this event is allowed to mutate, -/// borrowing the whole projection so the caller can also fold elapsed time -/// into the stage's live accumulators. -/// -/// Returns `None` for child-session events, for a stage with no open bracket, -/// and for a bracket belonging to a different session (which is what -/// post-failover events look like). -fn matching_inference_stage<'a>( - state: &'a mut RunProjection, - stored: &RunEvent, - visit: u32, - seq: u32, -) -> Option<&'a mut StageProjection> { - if stored.parent_session_id.is_some() { - return None; - } - let session_id = stored.session_id.as_deref()?; - let stage = stage_at_stored_or_visit(state, stored, visit, seq)?; - let opened_here = stage - .inference - .as_ref() - .is_some_and(|inference| inference.session_id == session_id); - opened_here.then_some(stage) -} - -/// Resolve the open inference bracket this event is allowed to mutate. -fn matching_inference_bracket<'a>( - state: &'a mut RunProjection, - stored: &RunEvent, - visit: u32, - seq: u32, -) -> Option<&'a mut StageInferenceProjection> { - matching_inference_stage(state, stored, visit, seq)? - .inference - .as_mut() -} - -/// Close the bracket on a stage-addressed terminal event, folding its elapsed -/// time into the stage's live inference accumulator. -fn close_inference_bracket( - state: &mut RunProjection, - stored: &RunEvent, - visit: u32, - seq: u32, - ts: DateTime, -) { - let Some(stage) = matching_inference_stage(state, stored, visit, seq) else { - return; - }; - close_bracket_on_stage(stage, ts); -} - -/// Take the open bracket and add its span to `live_inference_ms`. -/// -/// Retries inside the bracket are deliberately included: the in-process -/// stopwatch counts a retried attempt's elapsed time as inference, and -/// `agent.llm.retry` keeps the bracket open rather than reopening it. -fn close_bracket_on_stage(stage: &mut StageProjection, ts: DateTime) { - let Some(inference) = stage.inference.take() else { - return; - }; - stage.accumulate_inference_ms(timing::elapsed_ms(inference.started_at, ts)); -} - -/// Close every active bracket opened by the session that just ended. -/// -/// `agent.session.ended` is the only ordering-safe backstop for terminal -/// cancel and wall-clock timeout, which tear the session down through -/// `discard_session` without emitting a message, error, or interrupt. It is -/// emitted after the forwarder drains queued agent events, unlike -/// `agent.session.deactivated`, which is emitted before the drain and so can -/// be followed by a queued `agent.llm.started` that would re-open the bracket. -/// -/// The tradeoff is that it carries no stage identity — its props are empty and -/// its envelope has only session ids. So the close takes ordering from the -/// event and identity from the projection, scanning for brackets this session -/// opened. Implemented as a normal stage lookup it would find no target and -/// silently no-op, leaving the bracket open forever on exactly the path it -/// exists to cover. -fn close_active_brackets_for_session( - state: &mut RunProjection, - stored: &RunEvent, - ts: DateTime, -) { - if stored.parent_session_id.is_some() { - return; - } - let Some(session_id) = stored.session_id.as_deref() else { - return; - }; - for (_, stage) in state.iter_stages_unordered_mut() { - let opened_here = stage - .inference - .as_ref() - .is_some_and(|inference| inference.session_id == session_id); - if opened_here { - close_bracket_on_stage(stage, ts); - } - stage.close_tool_batch_for_session(session_id, ts); - } -} - -fn stage_at_stored_or_current_visit<'a>( - state: &'a mut RunProjection, - stored: &RunEvent, - seq: u32, -) -> Option<&'a mut StageProjection> { - if let Some(stage_id) = stored.stage_id.as_ref() { - return Some(stage_at_stored_stage_id(state, stage_id, seq)); - } - stage_at_current_visit(state, stored, seq) -} - -/// Apply a `checkpoint.completed` event to the exact execution named by the -/// envelope `StageId`: attach the checkpoint diff, and for a skipped -/// checkpoint finalize that execution as `Skipped`. A synthetic skipped -/// projection is created only for a first-attempt skip that never -/// materialized a stage; an existing non-terminal (e.g. `Retrying`) -/// projection keeps its identity and becomes terminal, while an older -/// terminal execution stays immutable. -fn apply_checkpoint_to_stage( - state: &mut RunProjection, - stage_id: &StageId, - props: &CheckpointCompletedProps, - checkpoint: &Checkpoint, - seq: u32, - ts: DateTime, -) { - let node_id = stage_id.node_id(); - let skipped_completion = checkpoint - .node_outcomes - .get(node_id) - .filter(|outcome| outcome.status == StageOutcome::Skipped) - .map(|outcome| stage_completion_from_outcome(outcome, ts)); - if props.diff.is_none() && skipped_completion.is_none() { - return; - } - - let is_new = state.stage(stage_id).is_none(); - let stage = stage_at_stored_stage_id(state, stage_id, seq); - if is_new { - stage.graph_visit = props.graph_visit; - stage - .resumed_from_stage_id - .clone_from(&props.resumed_from_stage_id); - } - if let Some(diff) = props.diff.clone() { - stage.diff = Some(diff); - } - if let Some(completion) = skipped_completion { - if !stage.state.is_terminal() { - stage.completion = Some(completion); - stage.state = StageState::Skipped; - } - } -} - -fn stage_at_completed_visit<'a>( - state: &'a mut RunProjection, - stored: &RunEvent, - node_visits: Option<&BTreeMap>, - seq: u32, -) -> Option<&'a mut StageProjection> { - if let Some(stage_id) = stored.stage_id.as_ref() { - return Some(stage_at_stored_stage_id(state, stage_id, seq)); - } - let node_id = stored.node_id.as_deref()?; - let visit = stage_visit(node_id, node_visits, state).unwrap_or(1); - Some(state.stage_entry(node_id, visit, first_event_seq(seq))) -} - -/// The run summary (`Run`) a projection stands for: what the run list, the -/// board and the scheduler read. -#[must_use] -pub fn build_summary(state: &RunProjection, run_id: &RunId) -> Run { - let goal = state.spec.graph.goal().to_string(); - let diff_summary = state - .conclusion - .as_ref() - .and_then(|conclusion| conclusion.diff.summary) - .or_else(|| { - state - .checkpoints - .iter() - .rev() - .find_map(|checkpoint| checkpoint.diff.summary) - }); - - let current_question = state - .pending_interviews - .iter() - .min_by(|(left_id, left), (right_id, right)| { - left.started_at - .cmp(&right.started_at) - .then_with(|| left_id.cmp(right_id)) - }) - .map(|(_, record)| record.question.clone()); - let models = run_models(state); - let created_by = state.spec.provenance.subject.clone(); - let source_directory = state.spec.source_directory.clone(); - let repo_origin_url = state.spec.git.as_ref().map(|git| git.origin_url.clone()); - let start_time = state.start.as_ref().map(|start| start.start_time); - let completed_at = state - .conclusion - .as_ref() - .map(|conclusion| conclusion.timestamp); - let run_timing = state - .conclusion - .as_ref() - .map(|conclusion| conclusion.timing); - let usage = projected_usage(state); - - Run { - id: *run_id, - parent_id: state.parent_id, - children_count: 0, - title: state.title().into_owned(), - goal, - workflow: WorkflowRef { - slug: state.spec.workflow_slug.clone(), - name: state.spec.workflow_name().map(ToOwned::to_owned), - graph_name: state.spec.graph_name().map(ToOwned::to_owned), - node_count: i64::try_from(state.spec.graph.nodes.len()) - .expect("graph node count should fit in i64"), - edge_count: i64::try_from(state.spec.graph.edges.len()) - .expect("graph edge count should fit in i64"), - }, - automation: state.spec.automation.clone(), - repository: Some(RepositoryRef::from_origin_and_source( - repo_origin_url, - source_directory.as_deref(), - )), - created_by, - origin: RunOrigin::default(), - labels: state.spec.labels.clone(), - lifecycle: RunLifecycle { - status: state.status, - approval: state.approval.clone(), - pending_control: state.pending_control, - queue_position: None, - error: None, - archived: state.archived_at.is_some(), - archived_at: state.archived_at, - }, - sandbox: state.sandbox.clone(), - models, - source_directory, - timestamps: RunTimestamps { - created_at: run_id.created_at(), - started_at: start_time, - last_event_at: Some(state.last_event_at), - completed_at, - }, - timing: run_timing, - usage, - size: RunSize::from_cost(usage.cost), - ask_fabro: AskFabro::default(), - diff: diff_summary, - pull_request: state.pull_request.clone(), - current_question, - superseded_by: state.superseded_by, - retried_from: state.retried_from, - links: RunLinks { - web: state.web_url.clone(), - }, - } -} - -/// The run's usage: the conclusion's total once the run ended, else the sum -/// of every non-boundary stage's usage so far. -#[must_use] -pub fn projected_usage(state: &RunProjection) -> Usage { - if let Some(usage) = state - .conclusion - .as_ref() - .and_then(|conclusion| conclusion.usage) - { - return usage; - } - - sum_usage( - state - .iter_stages() - .filter(|(stage_id, _)| !state.is_boundary_stage(stage_id.node_id())) - .map(|(_, stage)| stage.usage), - ) -} - -fn run_models(state: &RunProjection) -> Vec { - let mut models = state - .iter_stages() - .filter_map(|(_, stage)| stage.model.as_ref()) - .map(|model| RunModel { - provider: Some(model.provider.to_string()), - name: model.model_id.to_string(), - }) - .collect::>(); - models.sort_by(|left, right| { - left.provider - .cmp(&right.provider) - .then_with(|| left.name.cmp(&right.name)) - }); - models.dedup_by(|left, right| left.provider == right.provider && left.name == right.name); - models -} - -fn checkpoint_from_props(props: &CheckpointCompletedProps, timestamp: DateTime) -> Checkpoint { - let loop_failure_signatures = props - .loop_failure_signatures - .clone() - .into_iter() - .map(|(key, value)| (FailureSignature(key), value)) - .collect(); - let restart_failure_signatures = props - .restart_failure_signatures - .clone() - .into_iter() - .map(|(key, value)| (FailureSignature(key), value)) - .collect(); - - Checkpoint { - timestamp, - current_node: props.current_node.clone(), - completed_nodes: props.completed_nodes.clone(), - node_retries: props.node_retries.clone().into_iter().collect(), - context_values: props.context_values.clone().into_iter().collect(), - node_outcomes: props.node_outcomes.clone().into_iter().collect(), - next_node_id: props.next_node_id.clone(), - git_commit_sha: props.git_commit_sha.clone(), - loop_failure_signatures, - restart_failure_signatures, - node_visits: props.node_visits.clone().into_iter().collect(), - } -} - -fn diff_from_checkpoint_props(props: &CheckpointCompletedProps) -> RunDiff { - RunDiff { - patch: props.diff.clone(), - summary: props.diff_summary, - } -} - -fn conclusion_from_completed( - projection: &RunProjection, - props: &RunCompletedProps, - timestamp: DateTime, -) -> Result { - let (stages, total_retries) = - usage_rollup::usage_rollup_from_projection(projection).conclusion_stages(projection); - Ok(Conclusion { - timestamp, - status: StageOutcome::from_str(&props.status) - .map_err(|err| Error::InvalidEvent(format!("invalid completed stage status: {err}")))?, - timing: props.timing, - failure: None, - final_git_commit_sha: props.final_git_commit_sha.clone(), - stages, - usage: props.usage, - total_retries, - diff: RunDiff { - patch: props.final_patch.clone(), - summary: props.diff_summary, - }, - }) -} - -fn conclusion_from_failed( - projection: &RunProjection, - props: &RunFailedProps, - timestamp: DateTime, -) -> Conclusion { - let (stages, total_retries) = - usage_rollup::usage_rollup_from_projection(projection).conclusion_stages(projection); - Conclusion { - timestamp, - status: StageOutcome::Failed { - retry_requested: false, - }, - timing: props.timing, - failure: Some(props.failure.clone()), - final_git_commit_sha: props.final_git_commit_sha.clone(), - stages, - usage: props.usage, - total_retries, - diff: RunDiff { - patch: props.final_patch.clone(), - summary: props.diff_summary, - }, - } -} - -fn finalize_unfinished_stages_after_run_failed( - state: &mut RunProjection, - props: &RunFailedProps, - timestamp: DateTime, -) { - let terminal_state = if props.failure.reason == fabro_types::FailureReason::Cancelled { - StageState::Cancelled - } else { - StageState::Failed - }; - - for (_, stage) in state.iter_stages_unordered_mut() { - if stage.state.is_terminal() { - continue; - } - - // Close any brackets still open so their spans are not dropped on the - // floor when the live estimate is frozen into `timing` below. - close_bracket_on_stage(stage, timestamp); - stage.close_open_acp_inference(timestamp); - stage.close_open_tool_batch(timestamp); - - // Freeze the live estimate before flipping to a terminal state: - // `live_timing` reads `effective_state` and would return wall-only - // once the stage no longer looks in-flight. - let frozen = stage.live_timing(timestamp); - stage.state = terminal_state; - if stage.timing.is_none() && stage.started_at.is_some() { - stage.set_authoritative_timing(frozen); - } else { - stage.clear_live_timing(); - } - } -} - -fn stage_state_from_failure( - will_retry: bool, - failure_category: Option, - command_termination: Option, -) -> StageState { - if will_retry { - StageState::Retrying - } else if failure_category == Some(FailureCategory::Canceled) - && command_termination != Some(CommandTermination::Exited) - { - StageState::Cancelled - } else { - StageState::Failed - } -} - -fn stage_visit( - node_id: &str, - node_visits: Option<&BTreeMap>, - state: &RunProjection, -) -> Option { - node_visits - .and_then(|visits| visits.get(node_id).copied()) - .and_then(|visit| u32::try_from(visit).ok()) - .filter(|visit| *visit > 0) - .or_else(|| state.current_visit_for(node_id)) -} - -fn stage_outcome_from_props(props: &StageCompletedProps) -> Outcome> { - Outcome { - status: props.status, - preferred_label: props.preferred_label.clone(), - suggested_next_ids: props.suggested_next_ids.clone(), - context_updates: props - .context_updates - .clone() - .unwrap_or_default() - .into_iter() - .collect(), - jump_to_node: props.jump_to_node.clone(), - notes: props.notes.clone(), - failure: props.failure.clone(), - usage: props.usage.clone(), - usage_by_model: props.usage_by_model.clone(), - files_touched: props.files_touched.clone(), - timing: Some(props.timing), - } -} - -fn stage_completion_from_outcome( - outcome: &Outcome>, - timestamp: DateTime, -) -> StageCompletion { - StageCompletion { - outcome: outcome.status, - notes: outcome.notes.clone(), - failure_reason: outcome - .failure - .as_ref() - .map(|failure| render_compact_with_causes(&failure.message, &failure.causes)), - timestamp, - } -} - -fn apply_agent_terminal( - event_prefix: &str, - stage: &mut StageProjection, - props: &impl serde::Serialize, - output: String, - termination: CommandTermination, -) -> Result<()> { - let script_timing = serde_json::to_value(props).map_err(|err| { - Error::InvalidEvent(format!("invalid {event_prefix} terminal payload: {err}")) - })?; - stage.output = Some(output); - stage.termination = Some(termination); - stage.script_timing = Some(script_timing); - Ok(()) -} - -fn merge_agent_process_output(stdout: &str, stderr: &str) -> String { - match (stdout.is_empty(), stderr.is_empty()) { - (true, true) => String::new(), - (false, true) => stdout.to_string(), - (true, false) => stderr.to_string(), - (false, false) => format!("{stdout}\n{stderr}"), - } -} - -#[cfg(test)] -mod tests { - use std::collections::{BTreeMap, HashMap}; - use std::time::SystemTime; - - use chrono::{DateTime, Utc}; - use fabro_types::run_event::misc::CommandCompletedProps; - use fabro_types::run_event::run::RunFailedProps; - use fabro_types::run_event::{ - AgentAcpCancelledProps, AgentAcpCompletedProps, AgentAcpStartedProps, - AgentAcpTimedOutProps, AgentEventProps, AgentSessionActivatedProps, - AgentSessionDeactivatedProps, CheckpointCompletedProps, InterviewCompletedProps, - InterviewOption, InterviewStartedProps, ParallelBranchCompletedProps, - ParallelBranchStartedProps, RunCompletedProps, RunControlEffectProps, StageCompletedProps, - StageFailedProps, StagePromptProps, StageRetryingProps, StageStartedProps, - }; - use fabro_types::settings::run::DockerfileSource; - use fabro_types::{ - AgentBackend, AttrValue, AutomationRef, BlobHash, BlockedReason, Checkpoint, - CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail, - FailureReason, Graph, ModelUsage, Node, Outcome, ParallelBranchId, PendingReason, - PullRequestCreationStatus, PullRequestLink, QuestionType, RunApprovalState, - RunControlAction, RunDiff, RunEvent, RunSize, RunSpec, RunStatus, SandboxProviderKind, - StageHandler, StageModelUsage, StageOutcome, StageState, StageTiming, SuccessReason, - WorkflowSettings, first_event_seq, fixtures, test_support, - }; - use lithos_llm::types::{Cost, CostSource, ReasoningEffort, Speed, TokenCounts, Usage}; - use pebble_coding_agent::events::{ - CodingAgentEvent, CodingEvent, CompactionReason, ErrorData, ErrorKind, - }; - use pebble_coding_agent::tools::ToolOutputMetadata; - use serde_json::json; - - use super::{RunProjection, RunProjectionReducer, build_summary}; - use crate::{Error, EventEnvelope, StageId}; - - /// Live accumulation of inference and tool time while a stage is in - /// flight. The finalized breakdown still arrives with the terminal event - /// and replaces these; these exist so a long-running stage is not reported - /// as doing no work. - mod live_active_accumulation { - use fabro_types::{LlmOutputKind, LlmRetryPhase, StageOutcome, StageProjection}; - - use super::*; - - fn stage_id() -> StageId { - StageId::new("plan", 1) - } - - fn session_event(seq: u32, ts: &str, session_id: &str, body: EventBody) -> EventEnvelope { - let mut event = test_stage_event_at(seq, ts, body, stage_id()); - event.event.session_id = Some(session_id.to_string()); - event - } - - fn agent_event(seq: u32, ts: &str, body: EventBody) -> EventEnvelope { - session_event(seq, ts, "session-1", body) - } - - /// An event from a sub-agent session nested under the root session. - fn child_event(seq: u32, ts: &str, body: EventBody) -> EventEnvelope { - let mut event = agent_event(seq, ts, body); - event.event.session_id = Some("session-child".to_string()); - event.event.parent_session_id = Some("session-1".to_string()); - event - } - - fn llm_started() -> EventBody { - agent_body(CodingEvent::LlmRequestStarted { - requested_model: "claude-fable-5".to_string(), - }) - } - - fn tool_started(tool_call_id: &str) -> EventBody { - agent_body(CodingEvent::ToolCallStarted { - tool_name: "Bash".to_string(), - tool_call_id: tool_call_id.to_string(), - arguments: json!({}), - }) - } - - fn tool_completed(tool_call_id: &str) -> EventBody { - agent_body(CodingEvent::ToolCallCompleted { - tool_name: "Bash".to_string(), - tool_call_id: tool_call_id.to_string(), - output: json!("ok"), - metadata: ToolOutputMetadata::default(), - is_error: false, - error_kind: None, - output_bytes_observed: 0, - output_bytes_retained: 0, - output_bytes_omitted: 0, - }) - } - - fn agent_message() -> EventBody { - agent_message_body(10, 5) - } - - fn started_state() -> RunProjection { - let mut state = initialized_projection(); - state - .apply_event(&test_stage_event_at( - 1, - "2026-04-07T12:00:00Z", - EventBody::StageStarted(started_props()), - stage_id(), - )) - .unwrap(); - state - } - - fn stage(state: &RunProjection) -> &StageProjection { - state.stage(&stage_id()).unwrap() - } - - #[test] - fn closing_an_inference_bracket_accumulates_its_span() { - let mut state = started_state(); - state - .apply_event(&agent_event(2, "2026-04-07T12:00:05Z", llm_started())) - .unwrap(); - state - .apply_event(&agent_event( - 3, - "2026-04-07T12:00:06Z", - agent_body(CodingEvent::LlmFirstOutput { - kind: LlmOutputKind::Text, - }), - )) - .unwrap(); - state - .apply_event(&agent_event(4, "2026-04-07T12:00:12Z", agent_message())) - .unwrap(); - - // 12:00:05 -> 12:00:12; first_output is a marker, not the close. - assert_eq!(stage(&state).live_inference_ms, 7_000); - assert!(stage(&state).inference.is_none()); - } - - #[test] - fn concurrent_tool_calls_count_once_not_per_call() { - let mut state = started_state(); - for (seq, id) in [(2, "call-a"), (3, "call-b"), (4, "call-c")] { - state - .apply_event(&agent_event(seq, "2026-04-07T12:00:00Z", tool_started(id))) - .unwrap(); - } - // All three finish 10s later. Summing per-call spans would report - // 30s; the batch actually occupied 10s of wall time. - for (seq, id) in [(5, "call-a"), (6, "call-b"), (7, "call-c")] { - state - .apply_event(&agent_event( - seq, - "2026-04-07T12:00:10Z", - tool_completed(id), - )) - .unwrap(); - } - - assert_eq!(stage(&state).live_tool_ms, 10_000); - assert!(stage(&state).tool_batch.is_none()); - } - - #[test] - fn a_batch_stays_open_until_its_last_call_reports() { - let mut state = started_state(); - state - .apply_event(&agent_event( - 2, - "2026-04-07T12:00:00Z", - tool_started("call-a"), - )) - .unwrap(); - state - .apply_event(&agent_event( - 3, - "2026-04-07T12:00:02Z", - tool_started("call-b"), - )) - .unwrap(); - state - .apply_event(&agent_event( - 4, - "2026-04-07T12:00:05Z", - tool_completed("call-a"), - )) - .unwrap(); - - assert_eq!( - stage(&state).live_tool_ms, - 0, - "batch must not close while call-b is outstanding" - ); - - state - .apply_event(&agent_event( - 5, - "2026-04-07T12:00:09Z", - tool_completed("call-b"), - )) - .unwrap(); - - // Measured from the batch open, not from the last call's start. - assert_eq!(stage(&state).live_tool_ms, 9_000); - } - - #[test] - fn successive_batches_accumulate() { - let mut state = started_state(); - for (seq, ts, body) in [ - (2, "2026-04-07T12:00:00Z", tool_started("call-a")), - (3, "2026-04-07T12:00:04Z", tool_completed("call-a")), - (4, "2026-04-07T12:00:10Z", tool_started("call-b")), - (5, "2026-04-07T12:00:16Z", tool_completed("call-b")), - ] { - state.apply_event(&agent_event(seq, ts, body)).unwrap(); - } - - assert_eq!(stage(&state).live_tool_ms, 10_000); - } - - #[test] - fn a_duplicate_completion_does_not_drain_the_batch_early() { - let mut state = started_state(); - state - .apply_event(&agent_event( - 2, - "2026-04-07T12:00:00Z", - tool_started("call-a"), - )) - .unwrap(); - state - .apply_event(&agent_event( - 3, - "2026-04-07T12:00:00Z", - tool_started("call-b"), - )) - .unwrap(); - // call-a reports twice, as a replayed or duplicated log can. - state - .apply_event(&agent_event( - 4, - "2026-04-07T12:00:03Z", - tool_completed("call-a"), - )) - .unwrap(); - state - .apply_event(&agent_event( - 5, - "2026-04-07T12:00:04Z", - tool_completed("call-a"), - )) - .unwrap(); - - assert_eq!(stage(&state).live_tool_ms, 0); - assert!(stage(&state).tool_batch.is_some()); - } - - #[test] - fn a_foreign_session_completion_does_not_mutate_the_open_batch() { - let mut state = started_state(); - state - .apply_event(&agent_event( - 2, - "2026-04-07T12:00:00Z", - tool_started("call-a"), - )) - .unwrap(); - state - .apply_event(&session_event( - 3, - "2026-04-07T12:00:05Z", - "session-2", - tool_completed("call-a"), - )) - .unwrap(); - - let batch = stage(&state).tool_batch.as_ref().unwrap(); - assert_eq!(batch.session_id, "session-1"); - assert!(batch.open_call_ids.contains("call-a")); - assert_eq!(stage(&state).live_tool_ms, 0); - } - - #[test] - fn a_replacement_session_starts_a_separate_tool_batch() { - let mut state = started_state(); - state - .apply_event(&agent_event( - 2, - "2026-04-07T12:00:00Z", - tool_started("old-call"), - )) - .unwrap(); - state - .apply_event(&session_event( - 3, - "2026-04-07T12:00:05Z", - "session-2", - tool_started("new-call"), - )) - .unwrap(); - - assert_eq!(stage(&state).live_tool_ms, 5_000); - let batch = stage(&state).tool_batch.as_ref().unwrap(); - assert_eq!(batch.session_id, "session-2"); - assert_eq!( - batch.open_call_ids, - ["new-call".to_string()].into_iter().collect() - ); - - // A delayed completion from the old session cannot close the new - // session's batch even when call ids happen to collide. - state - .apply_event(&agent_event( - 4, - "2026-04-07T12:00:07Z", - tool_completed("new-call"), - )) - .unwrap(); - assert!(stage(&state).tool_batch.is_some()); - - state - .apply_event(&session_event( - 5, - "2026-04-07T12:00:09Z", - "session-2", - tool_completed("new-call"), - )) - .unwrap(); - assert_eq!(stage(&state).live_tool_ms, 9_000); - assert!(stage(&state).tool_batch.is_none()); - } - - #[test] - fn subagent_tool_calls_do_not_double_count_against_the_root_batch() { - let mut state = started_state(); - state - .apply_event(&agent_event( - 2, - "2026-04-07T12:00:00Z", - tool_started("root-call"), - )) - .unwrap(); - // The sub-agent's own tools run inside the root call's span. - state - .apply_event(&child_event( - 3, - "2026-04-07T12:00:01Z", - tool_started("child-call"), - )) - .unwrap(); - state - .apply_event(&child_event( - 4, - "2026-04-07T12:00:02Z", - tool_completed("child-call"), - )) - .unwrap(); - state - .apply_event(&agent_event( - 5, - "2026-04-07T12:00:08Z", - tool_completed("root-call"), - )) - .unwrap(); - - assert_eq!(stage(&state).live_tool_ms, 8_000); - } - - #[test] - fn session_end_accumulates_every_open_active_bracket() { - let mut state = started_state(); - state - .apply_event(&agent_event(2, "2026-04-07T12:00:05Z", llm_started())) - .unwrap(); - state - .apply_event(&agent_event( - 3, - "2026-04-07T12:00:07Z", - tool_started("call-a"), - )) - .unwrap(); - - let mut ended = test_stage_event_at( - 4, - "2026-04-07T12:00:20Z", - agent_body(CodingEvent::SessionEnded), - stage_id(), - ); - ended.event.session_id = Some("session-1".to_string()); - state.apply_event(&ended).unwrap(); - - assert_eq!(stage(&state).live_inference_ms, 15_000); - assert_eq!(stage(&state).live_tool_ms, 13_000); - assert!(stage(&state).inference.is_none()); - assert!(stage(&state).tool_batch.is_none()); - } - - #[test] - fn a_foreign_session_close_leaves_the_bracket_and_accumulator_alone() { - let mut state = started_state(); - state - .apply_event(&agent_event(2, "2026-04-07T12:00:05Z", llm_started())) - .unwrap(); - - // Post-failover: a new session emits the message, so the old - // bracket is not this event's to close or bill. - let mut foreign = - test_stage_event_at(3, "2026-04-07T12:00:20Z", agent_message(), stage_id()); - foreign.event.session_id = Some("session-2".to_string()); - state.apply_event(&foreign).unwrap(); - - assert_eq!(stage(&state).live_inference_ms, 0); - assert!(stage(&state).inference.is_some()); - } - - #[test] - fn a_retry_keeps_accumulating_within_one_bracket() { - let mut state = started_state(); - state - .apply_event(&agent_event(2, "2026-04-07T12:00:00Z", llm_started())) - .unwrap(); - state - .apply_event(&agent_event( - 3, - "2026-04-07T12:00:04Z", - agent_body(CodingEvent::LlmRetry { - provider: "anthropic".to_string(), - model: "claude-fable-5".to_string(), - attempt: 0, - delay_secs: 0.0, - error: ErrorData::new(ErrorKind::Llm, "stream"), - phase: LlmRetryPhase::Consume, - }), - )) - .unwrap(); - state - .apply_event(&agent_event(4, "2026-04-07T12:00:11Z", agent_message())) - .unwrap(); - - // The whole bracket counts, retry included, matching the - // in-process stopwatch. - assert_eq!(stage(&state).live_inference_ms, 11_000); - assert_eq!(stage(&state).inference, None); - } - - #[test] - fn stage_completion_replaces_the_live_estimate_with_finalized_timing() { - let mut state = started_state(); - state - .apply_event(&agent_event(2, "2026-04-07T12:00:00Z", llm_started())) - .unwrap(); - state - .apply_event(&agent_event(3, "2026-04-07T12:00:09Z", agent_message())) - .unwrap(); - assert_eq!(stage(&state).live_inference_ms, 9_000); - - state - .apply_event(&test_stage_event_at( - 4, - "2026-04-07T12:00:10Z", - EventBody::StageCompleted(completed_props(10_000, StageOutcome::Succeeded)), - stage_id(), - )) - .unwrap(); - - let stage = stage(&state); - assert_eq!( - stage.live_timing(test_dt("2026-04-07T12:30:00Z")), - stage.timing.unwrap(), - "a terminal stage reports its finalized breakdown, not a live estimate" - ); - assert_eq!(stage.live_inference_ms, 0); - assert_eq!(stage.live_tool_ms, 0); - assert!(stage.inference.is_none()); - assert!(stage.tool_batch.is_none()); - } - - #[test] - fn run_failure_freezes_open_work_and_clears_live_bookkeeping() { - let mut state = started_state(); - state.status = RunStatus::Running; - state - .apply_event(&agent_event(2, "2026-04-07T12:00:01Z", llm_started())) - .unwrap(); - state - .apply_event(&agent_event(3, "2026-04-07T12:00:04Z", agent_message())) - .unwrap(); - state - .apply_event(&agent_event( - 4, - "2026-04-07T12:00:05Z", - tool_started("call-a"), - )) - .unwrap(); - - let mut failed = test_event( - 5, - EventBody::RunFailed(run_failed_props(FailureReason::WorkflowError)), - None, - ); - failed.event.ts = test_dt("2026-04-07T12:00:10Z"); - state.apply_event(&failed).unwrap(); - - let stage = stage(&state); - assert_eq!( - stage.timing, - Some(fabro_types::StageTiming::new(10_000, 3_000, 5_000)) - ); - assert_eq!(stage.state, StageState::Failed); - assert_eq!(stage.live_inference_ms, 0); - assert_eq!(stage.live_tool_ms, 0); - assert!(stage.inference.is_none()); - assert!(stage.tool_batch.is_none()); - } - } - - fn test_event(seq: u32, body: EventBody, node_id: Option<&str>) -> EventEnvelope { - let event = RunEvent { - id: format!("evt-{seq}"), - ts: Utc::now(), - run_id: fixtures::RUN_1, - node_id: node_id.map(ToOwned::to_owned), - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body, - }; - - EventEnvelope { seq, event } - } - - fn test_stage_event(seq: u32, body: EventBody, stage_id: StageId) -> EventEnvelope { - let mut event = test_event(seq, body, Some(stage_id.node_id())); - event.event.stage_id = Some(stage_id); - event - } - - fn test_branch_event( - seq: u32, - body: EventBody, - stage_id: StageId, - branch_id: ParallelBranchId, - ) -> EventEnvelope { - let mut event = test_stage_event(seq, body, stage_id); - event.event.parallel_group_id = Some(branch_id.group().clone()); - event.event.parallel_branch_id = Some(branch_id); - event - } - - fn test_stage_event_at( - seq: u32, - ts: &str, - body: EventBody, - stage_id: StageId, - ) -> EventEnvelope { - let mut event = test_stage_event(seq, body, stage_id); - event.event.ts = test_dt(ts); - event - } - - fn test_usage(model_id: &str, input_tokens: u64, output_tokens: u64) -> ModelUsage { - serde_json::from_value(json!({ - "model": { "provider": "openai", "model_id": model_id }, - "usage": { - "tokens": { - "input": input_tokens, - "output": output_tokens - }, - "cost": { "usd_micros": input_tokens + output_tokens, "source": "catalog" } - } - })) - .unwrap() - } - - fn usage_json(usage: &ModelUsage) -> serde_json::Value { - serde_json::to_value(usage).unwrap() - } - - fn test_run_spec() -> RunSpec { - RunSpec { - graph_source: Some("digraph test {}".to_string()), - ..test_support::test_run_spec() - } - } - - fn initialized_projection() -> RunProjection { - RunProjection::new("Test run".to_string(), test_run_spec(), Utc::now()) - } - - fn test_dt(value: &str) -> DateTime { - value.parse().unwrap() - } - - fn running_projection() -> RunProjection { - let mut state = initialized_projection(); - state - .apply_event(&test_raw_event( - 1, - "run.runnable", - &json!({ "source": "start_requested" }), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event(2, "run.starting", &json!({}), None)) - .unwrap(); - state - .apply_event(&test_raw_event(3, "run.running", &json!({}), None)) - .unwrap(); - state - } - - #[test] - fn planned_sandbox_uses_docker_image_and_hides_daytona_snapshot_until_init() { - let mut docker = WorkflowSettings::default().run.environment; - docker.provider = SandboxProviderKind::DOCKER; - docker.image.docker = Some("ubuntu:24.04".to_string()); - - let planned_docker = super::sandbox_plan(&docker); - assert_eq!(planned_docker.image.as_deref(), Some("ubuntu:24.04")); - assert_eq!(planned_docker.snapshot, None); - - let mut daytona = WorkflowSettings::default().run.environment; - daytona.provider = SandboxProviderKind::DAYTONA; - daytona.image.dockerfile = Some(DockerfileSource::Inline("FROM ubuntu:24.04".to_string())); - - let planned_daytona = super::sandbox_plan(&daytona); - assert_eq!(planned_daytona.image, None); - assert_eq!(planned_daytona.snapshot, None); - } - - #[test] - fn sandbox_initialized_updates_image_and_snapshot_projection_fields() { - let mut state = initialized_projection(); - state - .apply_event(&test_raw_event( - 1, - "sandbox.initialized", - &json!({ - "provider": "daytona", - "id": "fabro-run-sandbox", - "working_directory": "/home/daytona/workspace", - "snapshot": "fabro-11111111-2222-8333-8444-555555555555" - }), - None, - )) - .unwrap(); - - let sandbox = state.sandbox.expect("sandbox should be projected"); - let instance = sandbox.instance().expect("sandbox should be ready"); - assert_eq!(instance.image, None); - assert_eq!( - instance.snapshot.as_deref(), - Some("fabro-11111111-2222-8333-8444-555555555555") - ); - } - - #[test] - fn run_created_without_retried_from_projects_retried_from_none() { - let event = test_raw_event( - 1, - "run.created", - &json!({ - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - ); - - let projection = RunProjection::apply_events(&[event]).unwrap(); - assert_eq!(projection.retried_from, None); - assert_eq!(projection.spec.workflow_version_id, None); - assert_eq!( - build_summary(&projection, &fixtures::RUN_1).retried_from, - None - ); - } - - #[test] - fn run_created_projects_workflow_version_id_into_spec() { - let workflow_version_id = test_support::test_workflow_version_id(); - let event = test_raw_event( - 1, - "run.created", - &json!({ - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "workflow_version_id": workflow_version_id, - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - ); - - let projection = RunProjection::apply_events(&[event]).unwrap(); - - assert_eq!( - projection.spec.workflow_version_id, - Some(workflow_version_id) - ); - } - - #[test] - fn run_created_replay_ignores_unknown_properties() { - let provenance = test_support::test_run_provenance(); - let event = test_raw_event( - 1, - "run.created", - &json!({ - "title": "Historical run", - "settings": WorkflowSettings::default(), - "graph": Graph::new("historical"), - "workflow_source": "digraph historical { start -> exit }", - "labels": {"team": "platform"}, - "source_directory": "/workspace/project", - "workflow_slug": "historical", - "unknown_future_property": { - "nested": ["value", 42, true] - }, - "provenance": provenance - }), - None, - ); - - let projection = RunProjection::apply_events(&[event]).unwrap(); - - assert_eq!(projection.title(), "Historical run"); - assert_eq!(projection.spec.graph.name, "historical"); - assert_eq!( - projection.spec.graph_source.as_deref(), - Some("digraph historical { start -> exit }") - ); - assert_eq!( - projection.spec.labels.get("team").map(String::as_str), - Some("platform") - ); - assert_eq!( - projection.spec.source_directory.as_deref(), - Some("/workspace/project") - ); - assert_eq!(projection.spec.workflow_slug.as_deref(), Some("historical")); - assert_eq!(projection.spec.provenance, provenance); - } - - #[test] - fn run_created_projects_automation_into_spec_and_summary() { - let automation = AutomationRef { - id: "nightly".to_string(), - name: Some("Nightly".to_string()), - trigger_id: Some("schedule_1".to_string()), - workflow_source: None, - }; - let event = test_raw_event( - 1, - "run.created", - &json!({ - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "automation": automation, - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - ); - - let projection = RunProjection::apply_events(&[event]).unwrap(); - - assert_eq!(projection.spec.automation, Some(automation.clone())); - assert_eq!( - build_summary(&projection, &fixtures::RUN_1).automation, - Some(automation) - ); - } - - #[test] - fn run_created_projects_planned_sandbox_lifecycle() { - let state = RunProjection::apply_events(&[test_raw_event( - 1, - "run.created", - &json!({ - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - )]) - .unwrap(); - - let sandbox = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); - assert_eq!(sandbox["kind"], "planned"); - assert_eq!(sandbox["plan"]["provider"], "local"); - assert!(sandbox.get("instance").is_none()); - assert!(sandbox.get("failure").is_none()); - } - - #[test] - fn sandbox_lifecycle_events_update_projected_sandbox_state() { - let mut state = RunProjection::apply_events(&[test_raw_event( - 1, - "run.created", - &json!({ - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - )]) - .unwrap(); - - state - .apply_event(&test_raw_event( - 2, - "sandbox.initializing", - &json!({ "provider": "docker" }), - None, - )) - .unwrap(); - let initializing = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); - assert_eq!(initializing["kind"], "initializing"); - assert!(initializing.get("instance").is_none()); - - state - .apply_event(&test_raw_event( - 3, - "sandbox.initialized", - &json!({ - "provider": "docker", - "id": "container-abc123", - "working_directory": "/workspace", - "image": "ubuntu:24.04", - "repo_cloned": true, - "clone_origin_url": "https://github.com/fabro-sh/fabro.git", - "clone_branch": "main" - }), - None, - )) - .unwrap(); - let ready = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); - assert_eq!(ready["kind"], "ready"); - assert_eq!(ready["plan"]["provider"], "local"); - assert_eq!(ready["instance"]["provider"], "docker"); - assert_eq!(ready["instance"]["image"], "ubuntu:24.04"); - assert_eq!(ready["instance"]["runtime"]["id"], "container-abc123"); - assert_eq!( - ready["instance"]["runtime"]["working_directory"], - "/workspace" - ); - - state - .apply_event(&test_raw_event( - 4, - "sandbox.failed", - &json!({ - "provider": "docker", - "error": "Docker daemon unavailable", - "causes": ["connection refused"], - "duration_ms": 42 - }), - None, - )) - .unwrap(); - let failed = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); - assert_eq!(failed["kind"], "failed"); - assert_eq!(failed["failure"]["provider"], "docker"); - assert_eq!(failed["failure"]["error"], "Docker daemon unavailable"); - assert_eq!(failed["failure"]["causes"], json!(["connection refused"])); - assert_eq!(failed["failure"]["duration_ms"], 42); - assert!(failed.get("instance").is_none()); - } - - #[test] - fn run_failed_before_sandbox_events_leaves_sandbox_planned() { - let state = RunProjection::apply_events(&[ - test_raw_event( - 1, - "run.created", - &json!({ - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - ), - test_raw_event( - 2, - "run.runnable", - &json!({ "source": "start_requested" }), - None, - ), - test_raw_event(3, "run.starting", &json!({}), None), - test_raw_event(4, "run.running", &json!({}), None), - test_raw_event( - 5, - "run.failed", - &json!({ - "failure": { - "reason": "sandbox_init_failed", - "detail": { - "message": "Failed before sandbox initialized", - "category": "transient_infra" - } - }, - "timing": { - "wall_time_ms": 1, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - } - }), - None, - ), - ]) - .unwrap(); - - let sandbox = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); - assert_eq!(sandbox["kind"], "planned"); - assert!(sandbox.get("instance").is_none()); - assert!(sandbox.get("failure").is_none()); - } - - fn test_raw_event( - seq: u32, - event: &str, - properties: &serde_json::Value, - node_id: Option<&str>, - ) -> EventEnvelope { - EventEnvelope { - seq, - event: RunEvent::from_value(json!({ - "id": format!("evt-{seq}"), - "ts": Utc::now().to_rfc3339(), - "run_id": fixtures::RUN_1, - "event": event, - "node_id": node_id, - "properties": properties, - })) - .unwrap(), - } - } - - fn test_raw_event_at( - seq: u32, - ts: &str, - event: &str, - properties: &serde_json::Value, - node_id: Option<&str>, - ) -> EventEnvelope { - EventEnvelope { - seq, - event: RunEvent::from_value(json!({ - "id": format!("evt-{seq}"), - "ts": ts, - "run_id": fixtures::RUN_1, - "event": event, - "node_id": node_id, - "properties": properties, - })) - .unwrap(), - } - } - - fn historical_created_event() -> EventEnvelope { - test_raw_event( - 1, - "run.created", - &json!({ - "settings": WorkflowSettings::default(), - "graph": Graph::new("historical"), - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - ) - } - - #[test] - fn historical_submitted_to_starting_transition_replays() { - let state = RunProjection::apply_events(&[ - historical_created_event(), - test_raw_event(2, "run.submitted", &json!({}), None), - test_raw_event(3, "run.starting", &json!({}), None), - ]) - .unwrap(); - - assert_eq!(state.status, RunStatus::Starting); - } - - #[test] - fn historical_runnable_to_terminated_transition_replays() { - let state = RunProjection::apply_events(&[ - historical_created_event(), - test_raw_event(2, "run.submitted", &json!({}), None), - test_raw_event( - 3, - "run.runnable", - &json!({ "source": "start_requested" }), - None, - ), - test_raw_event( - 4, - "run.failed", - &json!({ - "failure": { - "reason": "terminated", - "detail": { - "message": "worker stopped before startup", - "category": "deterministic" - } - }, - "timing": { - "wall_time_ms": 1, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - } - }), - None, - ), - ]) - .unwrap(); - - assert_eq!(state.status, RunStatus::Failed { - reason: FailureReason::Terminated, - }); - } - - #[test] - fn live_run_timing_returns_none_before_run_starts() { - let state = initialized_projection(); - - assert_eq!(state.live_run_timing(Utc::now()), None); - } - - #[test] - fn live_run_timing_derives_wall_and_completed_stage_active_for_in_flight_run() { - let mut state = initialized_projection(); - state - .apply_event(&test_raw_event_at( - 1, - "2026-04-07T12:00:00Z", - "run.started", - &json!({ "name": "Test run" }), - None, - )) - .unwrap(); - state.stage_entry("plan", 1, first_event_seq(2)).timing = - Some(fabro_types::StageTiming::new(2_000, 700, 300)); - state.stage_entry("code", 1, first_event_seq(3)).timing = - Some(fabro_types::StageTiming::new(3_000, 20, 80)); - state.stage_entry("running", 1, first_event_seq(4)).timing = None; - - assert_eq!( - state.live_run_timing(test_dt("2026-04-07T12:00:12.345Z")), - Some(fabro_types::RunTiming::new(12_345, 720, 380)) - ); - } - - #[test] - fn live_run_timing_matches_conclusion_timing_at_conclusion_moment() { - let mut state = initialized_projection(); - let started_at = test_dt("2026-04-07T12:00:00Z"); - let completed_at = test_dt("2026-04-07T12:00:10Z"); - state - .apply_event(&test_raw_event_at( - 1, - "2026-04-07T12:00:00Z", - "run.started", - &json!({ "name": "Test run" }), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 2, - "2026-04-07T12:00:00Z", - "run.runnable", - &json!({ "source": "start_requested" }), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 3, - "2026-04-07T12:00:00Z", - "run.starting", - &json!({}), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 4, - "2026-04-07T12:00:01Z", - "run.running", - &json!({}), - None, - )) - .unwrap(); - state.stage_entry("plan", 1, first_event_seq(5)).timing = - Some(fabro_types::StageTiming::new(2_000, 700, 300)); - state.stage_entry("code", 1, first_event_seq(6)).timing = - Some(fabro_types::StageTiming::new(3_000, 50, 200)); - - let conclusion_timing = fabro_types::RunTiming::new( - u64::try_from( - completed_at - .signed_duration_since(started_at) - .num_milliseconds(), - ) - .unwrap(), - 750, - 500, - ); - let mut completed = test_event( - 7, - EventBody::RunCompleted(RunCompletedProps { - timing: conclusion_timing, - artifact_count: 0, - status: "succeeded".to_string(), - reason: SuccessReason::Completed, - final_git_commit_sha: None, - final_patch: None, - diff_summary: None, - usage: None, - }), - None, - ); - completed.event.ts = completed_at; - state.apply_event(&completed).unwrap(); - - assert_eq!( - state - .conclusion - .as_ref() - .map(|conclusion| conclusion.timing), - Some(conclusion_timing) - ); - assert_eq!(state.live_run_timing(completed_at), Some(conclusion_timing)); - } - - #[test] - fn last_event_at_tracks_most_recent_event_timestamp() { - let mut state = initialized_projection(); - let later = test_raw_event_at( - 2, - "2026-04-20T12:05:30Z", - "run.start_requested", - &json!({ "resume": false }), - None, - ); - - state.apply_event(&later).unwrap(); - - assert_eq!(state.last_event_at, later.event.ts); - } - - #[test] - fn deserialize_and_round_trip_projection_preserves_stages_and_pending_control() { - let state: RunProjection = serde_json::from_value(serde_json::json!({ - "spec": { - "run_id": "01JW6A7VNFZSFF0SKXJG29Z2M3", - "settings": WorkflowSettings::default(), - "graph": { "name": "ship", "nodes": {}, "edges": [], "attrs": {} }, - "workflow_slug": "demo", - "source_directory": "/tmp/project", - "repo_origin_url": null, - "base_branch": null, - "labels": {}, - "provenance": test_support::test_run_provenance(), - "definition_blob": null, - "git": null, - "fork_source_ref": null - }, - "status": { "kind": "submitted" }, - "status_updated_at": "2026-04-07T12:00:00Z", - "last_event_at": "2026-04-07T12:00:00Z", - "pending_control": "cancel", - "checkpoints": [ - { - "seq": 0, - "diff": {}, - "checkpoint": { - "timestamp": "2026-04-07T12:00:00Z", - "current_node": "build", - "completed_nodes": ["build"], - "node_retries": {}, - "context_values": {}, - "node_outcomes": {}, - "loop_failure_signatures": {}, - "restart_failure_signatures": {}, - "node_visits": { "build": 2 } - } - } - ], - "pending_interviews": {}, - "stages": { - "build@2": { - "first_event_seq": 1, - "diff": "diff --git a/file b/file", - "output": "done", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "total_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "state": "running" - } - } - })) - .unwrap(); - - let stage_id = StageId::new("build", 2); - let node = state.stage(&stage_id).unwrap(); - assert_eq!(node.first_event_seq, first_event_seq(1)); - assert_eq!(node.diff.as_deref(), Some("diff --git a/file b/file")); - assert_eq!(state.list_node_visits("build"), vec![2]); - assert_eq!(state.pending_control, Some(RunControlAction::Cancel)); - - let round_tripped: RunProjection = - serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap(); - let serialized = serde_json::to_value(&state).unwrap(); - let round_tripped_node = round_tripped.stage(&stage_id).unwrap(); - assert_eq!(round_tripped_node.output.as_deref(), Some("done")); - assert_eq!(round_tripped.list_node_visits("build"), vec![2]); - assert_eq!( - round_tripped.pending_control, - Some(RunControlAction::Cancel) - ); - assert!(serialized.get("spec").is_some()); - assert!(serialized.get("run").is_none()); - } - - #[test] - fn stage_entry_round_trips_through_json() { - let mut state = running_projection(); - state.pending_control = Some(RunControlAction::Unpause); - state.checkpoints = vec![CheckpointRecord { - seq: 7, - checkpoint: Checkpoint { - timestamp: "2026-04-07T12:00:00Z".parse().unwrap(), - current_node: "build".to_string(), - completed_nodes: vec!["build".to_string()], - node_retries: HashMap::new(), - context_values: HashMap::new(), - node_outcomes: HashMap::new(), - next_node_id: None, - git_commit_sha: None, - loop_failure_signatures: HashMap::new(), - restart_failure_signatures: HashMap::new(), - node_visits: HashMap::from([("build".to_string(), 2usize)]), - }, - diff: RunDiff::default(), - }]; - state.stage_entry("build", 2, first_event_seq(7)).output = Some("done".to_string()); - - let round_tripped: RunProjection = - serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap(); - - assert_eq!( - round_tripped - .stage(&StageId::new("build", 2)) - .unwrap() - .output - .as_deref(), - Some("done") - ); - assert_eq!(round_tripped.list_node_visits("build"), vec![2]); - assert_eq!( - round_tripped.pending_control, - Some(RunControlAction::Unpause) - ); - } - - #[test] - fn stage_started_sets_first_event_seq() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event( - 3, - EventBody::StageStarted(StageStartedProps { - graph_visit: None, - resumed_from_stage_id: None, - index: 0, - handler_type: "agent".to_string(), - attempt: 1, - max_attempts: 1, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.first_event_seq, first_event_seq(3)); - } - - #[test] - fn later_stage_events_do_not_overwrite_first_event_seq() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event( - 3, - EventBody::StageStarted(StageStartedProps { - graph_visit: None, - resumed_from_stage_id: None, - index: 0, - handler_type: "agent".to_string(), - attempt: 1, - max_attempts: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_event( - 4, - EventBody::StagePrompt(StagePromptProps { - visit: 1, - text: "prompt".to_string(), - mode: None, - provider: None, - model: None, - reasoning_effort: None, - speed: None, - }), - Some("build"), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.first_event_seq, first_event_seq(3)); - assert_eq!(stage.prompt.as_deref(), Some("prompt")); - } - - #[test] - fn parallel_branch_started_projects_identity_without_churning_it() { - let mut state = initialized_projection(); - let group_id = StageId::new("review_fork", 1); - let branch_stage_id = StageId::new("review_glm", 1); - let branch_id = ParallelBranchId::new(group_id.clone(), 0); - let started = test_branch_event( - 3, - EventBody::ParallelBranchStarted(ParallelBranchStartedProps { - index: 0, - item_label: None, - graph_visit: Some(1), - resumed_from_stage_id: None, - }), - branch_stage_id.clone(), - branch_id.clone(), - ); - - state.apply_event(&started).unwrap(); - - assert_eq!( - state - .stage(&branch_stage_id) - .unwrap() - .parallel_branch_id - .as_ref(), - Some(&branch_id) - ); - - let reobserved = test_branch_event( - 4, - EventBody::ParallelBranchStarted(ParallelBranchStartedProps { - index: 1, - item_label: None, - graph_visit: Some(2), - resumed_from_stage_id: Some(StageId::new("review_glm", 2)), - }), - branch_stage_id.clone(), - ParallelBranchId::new(group_id, 1), - ); - - state.apply_event(&reobserved).unwrap(); - - let stage = state.stage(&branch_stage_id).unwrap(); - assert_eq!(stage.parallel_branch_id.as_ref(), Some(&branch_id)); - assert_eq!(stage.graph_visit, Some(1)); - assert!(stage.resumed_from_stage_id.is_none()); - } - - #[test] - fn parallel_branch_completed_finalizes_branch_stage() { - // A parallel branch never runs through the engine's StageStarted/ - // StageCompleted lifecycle: its stage entry is created Running by the - // first branch-scoped event, and only ParallelBranchCompleted marks it - // terminal. Guards against branches spinning Running forever. - let mut state = initialized_projection(); - let branch = StageId::new("review_ux", 1); - let branch_started_at = test_dt("2026-04-07T12:00:00Z"); - - state - .apply_event(&test_stage_event_at( - 3, - "2026-04-07T12:00:00Z", - EventBody::ParallelBranchStarted(ParallelBranchStartedProps { - index: 0, - item_label: None, - graph_visit: None, - resumed_from_stage_id: None, - }), - branch.clone(), - )) - .unwrap(); - let stage = state.stage(&branch).unwrap(); - assert_eq!(stage.state, StageState::Running); - assert_eq!(stage.started_at, Some(branch_started_at)); - - state - .apply_event(&test_stage_event( - 4, - EventBody::ParallelBranchCompleted(ParallelBranchCompletedProps { - index: 0, - item_label: None, - duration_ms: 1234, - status: StageOutcome::Succeeded, - }), - branch.clone(), - )) - .unwrap(); - - let stage = state.stage(&branch).unwrap(); - assert_eq!(stage.state, StageState::Succeeded); - assert_eq!(stage.timing.unwrap().wall_time_ms, 1234); - assert_eq!( - stage.completion.as_ref().unwrap().outcome, - StageOutcome::Succeeded - ); - } - - #[test] - fn parallel_branch_completed_folds_failed_status_as_failed() { - let mut state = initialized_projection(); - let branch = StageId::new("review_ux", 1); - - state - .apply_event(&test_stage_event( - 3, - EventBody::ParallelBranchStarted(ParallelBranchStartedProps { - index: 0, - item_label: None, - graph_visit: None, - resumed_from_stage_id: None, - }), - branch.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 4, - EventBody::ParallelBranchCompleted(ParallelBranchCompletedProps { - index: 0, - item_label: None, - duration_ms: 500, - status: StageOutcome::Failed { - retry_requested: false, - }, - }), - branch.clone(), - )) - .unwrap(); - - let stage = state.stage(&branch).unwrap(); - assert_eq!(stage.state, StageState::Failed); - assert_eq!(stage.timing.unwrap().wall_time_ms, 500); - assert_eq!( - stage.completion.as_ref().unwrap().outcome, - StageOutcome::Failed { - retry_requested: false, - } - ); - } - - #[test] - fn parallel_branch_started_uses_the_graph_handler_for_live_timing() { - for (handler_type, handler, expected) in [ - ( - "prompt", - StageHandler::Prompt, - StageTiming::new(5_000, 5_000, 0), - ), - ( - "command", - StageHandler::Command, - StageTiming::new(5_000, 0, 5_000), - ), - ] { - let mut spec = test_run_spec(); - let mut node = Node::new("review"); - node.attrs.insert( - "type".to_string(), - AttrValue::String(handler_type.to_string()), - ); - spec.graph.nodes.insert(node.id.clone(), node); - let mut state = RunProjection::new("Test run".to_string(), spec, Utc::now()); - let branch = StageId::new("review", 1); - - state - .apply_event(&test_stage_event_at( - 3, - "2026-04-07T12:00:00Z", - EventBody::ParallelBranchStarted(ParallelBranchStartedProps { - index: 0, - item_label: None, - graph_visit: None, - resumed_from_stage_id: None, - }), - branch.clone(), - )) - .unwrap(); - - let stage = state.stage(&branch).unwrap(); - assert_eq!(stage.handler, Some(handler)); - assert_eq!(stage.live_timing(test_dt("2026-04-07T12:00:05Z")), expected); - } - } - - fn start_stage(state: &mut RunProjection, stage_id: &StageId) { - state - .apply_event(&test_stage_event( - 3, - EventBody::StageStarted(StageStartedProps { - graph_visit: None, - resumed_from_stage_id: None, - index: 0, - handler_type: "agent".to_string(), - attempt: 1, - max_attempts: 1, - }), - stage_id.clone(), - )) - .unwrap(); - } - - #[test] - fn agent_session_activated_updates_stage_provider_used() { - let mut state = initialized_projection(); - let stage_id = StageId::new("code", 1); - start_stage(&mut state, &stage_id); - - state - .apply_event(&test_stage_event( - 4, - EventBody::AgentSessionActivated(AgentSessionActivatedProps { - thread_id: Some("thread-1".to_string()), - provider: Some("openai".to_string()), - model: Some("gpt-5.4".to_string()), - reasoning_effort: Some(ReasoningEffort::High), - speed: Some(Speed::Fast), - permission_level: None, - capabilities: vec![fabro_types::SessionCapability::Steer], - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - let provider_used = stage.provider_used.as_ref().unwrap(); - assert_eq!(provider_used.mode, StageModelUsage::MODE_AGENT); - assert_eq!(provider_used.provider.as_deref(), Some("openai")); - assert_eq!(provider_used.model.as_deref(), Some("gpt-5.4")); - assert_eq!(provider_used.reasoning_effort, Some(ReasoningEffort::High)); - assert_eq!(provider_used.speed, Some(Speed::Fast)); - } - - #[test] - fn object_lifecycle_session_events_do_not_update_stage_provider_used() { - let mut state = initialized_projection(); - let stage_id = StageId::new("code", 1); - start_stage(&mut state, &stage_id); - - state - .apply_event(&test_event( - 4, - agent_body(CodingEvent::SessionStarted { - provider: Some("openai".to_string()), - model: Some("gpt-5.4".to_string()), - }), - None, - )) - .unwrap(); - state - .apply_event(&test_event(5, agent_body(CodingEvent::SessionEnded), None)) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert!(stage.provider_used.is_none()); - } - - #[test] - fn agent_acp_started_alone_leaves_stage_provider_used_unset() { - // `agent.acp.started` no longer writes `provider_used`; the canonical - // source is the subsequent `agent.session.activated` event. ACP runs - // without a steering hub never activate and so legitimately leave - // `provider_used` unset. - let mut state = initialized_projection(); - let stage_id = StageId::new("code", 1); - start_stage(&mut state, &stage_id); - - state - .apply_event(&test_stage_event( - 4, - EventBody::AgentAcpStarted(AgentAcpStartedProps { - visit: 1, - command: "python fake_agent.py".to_string(), - config_name: Some("fake".to_string()), - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert!(stage.provider_used.is_none()); - } - - #[test] - fn acp_session_activation_records_provider_used_with_acp_mode() { - let mut state = initialized_projection(); - let stage_id = StageId::new("code", 1); - start_stage(&mut state, &stage_id); - - state - .apply_event(&test_stage_event( - 4, - EventBody::AgentAcpStarted(AgentAcpStartedProps { - visit: 1, - command: "python fake_agent.py".to_string(), - config_name: Some("fake".to_string()), - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 5, - EventBody::AgentSessionActivated(AgentSessionActivatedProps { - thread_id: None, - provider: Some(AgentBackend::Acp.to_string()), - model: Some("fake".to_string()), - reasoning_effort: None, - speed: None, - permission_level: None, - capabilities: vec![fabro_types::SessionCapability::Steer], - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - let provider_used = stage.provider_used.as_ref().unwrap(); - assert_eq!(provider_used.mode, StageModelUsage::MODE_ACP); - assert_eq!(provider_used.provider.as_deref(), Some("acp")); - assert_eq!(provider_used.model.as_deref(), Some("fake")); - } - - #[test] - fn acp_events_accumulate_live_inference_time() { - let mut state = initialized_projection(); - let stage_id = StageId::new("code", 1); - - state - .apply_event(&test_stage_event_at( - 3, - "2026-04-07T12:00:00Z", - EventBody::StageStarted(StageStartedProps { - graph_visit: None, - resumed_from_stage_id: None, - index: 0, - handler_type: "agent".to_string(), - attempt: 1, - max_attempts: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event_at( - 4, - "2026-04-07T12:00:05Z", - EventBody::AgentAcpStarted(AgentAcpStartedProps { - visit: 1, - command: "python fake_agent.py".to_string(), - config_name: Some("fake".to_string()), - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!( - stage.live_timing(test_dt("2026-04-07T12:00:15Z")), - StageTiming::new(15_000, 10_000, 0) - ); - - state - .apply_event(&test_stage_event_at( - 5, - "2026-04-07T12:00:17Z", - EventBody::AgentAcpCompleted(AgentAcpCompletedProps { - stdout: "done".to_string(), - stderr: String::new(), - stop_reason: "end_turn".to_string(), - duration_ms: 12_000, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!( - stage.live_timing(test_dt("2026-04-07T12:00:20Z")), - StageTiming::new(20_000, 12_000, 0) - ); - } - - #[test] - fn agent_acp_completed_updates_stage_output_projection() { - let mut state = initialized_projection(); - let stage_id = StageId::new("code", 1); - start_stage(&mut state, &stage_id); - - state - .apply_event(&test_stage_event( - 4, - EventBody::AgentAcpCompleted(AgentAcpCompletedProps { - stdout: "done".to_string(), - stderr: "warn".to_string(), - stop_reason: "end_turn".to_string(), - duration_ms: 42, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.output.as_deref(), Some("done\nwarn")); - assert_eq!(stage.termination, Some(CommandTermination::Exited)); - assert_eq!( - stage.script_timing.as_ref().unwrap()["stop_reason"], - serde_json::json!("end_turn") - ); - } - - #[test] - fn agent_acp_cancelled_and_timed_out_update_terminal_projection() { - let mut cancelled = initialized_projection(); - let cancelled_stage_id = StageId::new("cancelled", 1); - start_stage(&mut cancelled, &cancelled_stage_id); - - cancelled - .apply_event(&test_stage_event( - 4, - EventBody::AgentAcpCancelled(AgentAcpCancelledProps { - stdout: "partial".to_string(), - stderr: "cancelled".to_string(), - duration_ms: 7, - }), - cancelled_stage_id.clone(), - )) - .unwrap(); - - let stage = cancelled.stage(&cancelled_stage_id).unwrap(); - assert_eq!(stage.output.as_deref(), Some("partial\ncancelled")); - assert_eq!(stage.termination, Some(CommandTermination::Cancelled)); - - let mut timed_out = initialized_projection(); - let timed_out_stage_id = StageId::new("timed_out", 1); - start_stage(&mut timed_out, &timed_out_stage_id); - - timed_out - .apply_event(&test_stage_event( - 4, - EventBody::AgentAcpTimedOut(AgentAcpTimedOutProps { - stdout: "partial".to_string(), - stderr: "timeout".to_string(), - duration_ms: 99, - }), - timed_out_stage_id.clone(), - )) - .unwrap(); - - let stage = timed_out.stage(&timed_out_stage_id).unwrap(); - assert_eq!(stage.output.as_deref(), Some("partial\ntimeout")); - assert_eq!(stage.termination, Some(CommandTermination::TimedOut)); - } - - #[test] - fn stage_completed_event_captures_duration_and_usage_per_visit() { - let mut state = initialized_projection(); - let usage = test_usage("gpt-5.2", 123, 45); - - state - .apply_event(&test_event( - 3, - EventBody::StageCompleted(StageCompletedProps { - index: 0, - timing: fabro_types::StageTiming::wall_only(789), - status: StageOutcome::Succeeded, - preferred_label: None, - suggested_next_ids: Vec::new(), - usage_by_model: Vec::new(), - usage: Some(usage.clone()), - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: None, - restart_failure_signatures: None, - response: Some("done".to_string()), - attempt: 1, - max_attempts: 1, - }), - Some("build"), - )) - .unwrap(); - - let stage = state.stage(&StageId::new("build", 1)).unwrap(); - assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(789)); - assert_eq!(stage.usage, usage.usage); - assert_eq!(stage.model.as_ref(), Some(usage.model())); - } - - #[test] - fn stage_failed_event_captures_duration_and_usage_per_visit() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - let usage = test_usage("gpt-5.2", 321, 54); - - state - .apply_event(&test_stage_event( - 2, - EventBody::StageStarted(StageStartedProps { - graph_visit: None, - resumed_from_stage_id: None, - index: 0, - handler_type: "agent".to_string(), - attempt: 1, - max_attempts: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_raw_event( - 3, - "stage.failed", - &json!({ - "index": 0, - "failure": { - "message": "provider failed", - "category": "transient_infra" - }, - "will_retry": false, - "timing": {"wall_time_ms": 654, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0}, - "usage": usage_json(&usage) - }), - Some("build"), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(654)); - assert_eq!(stage.usage, usage.usage); - assert_eq!(stage.model.as_ref(), Some(usage.model())); - } - - #[test] - fn two_visits_of_one_node_retain_distinct_usage() { - let mut state = initialized_projection(); - let first_usage = test_usage("gpt-5.2", 100, 10); - let second_usage = test_usage("gpt-5.2", 200, 20); - - for (seq, visit, duration_ms, usage) in [ - (3, 1usize, 111, first_usage.clone()), - (4, 2usize, 222, second_usage.clone()), - ] { - state - .apply_event(&test_event( - seq, - EventBody::StageCompleted(StageCompletedProps { - index: 0, - timing: fabro_types::StageTiming::wall_only(duration_ms), - status: StageOutcome::Succeeded, - preferred_label: None, - suggested_next_ids: Vec::new(), - usage_by_model: Vec::new(), - usage: Some(usage), - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: Some(BTreeMap::from([("build".to_string(), visit)])), - loop_failure_signatures: None, - restart_failure_signatures: None, - response: None, - attempt: 1, - max_attempts: 1, - }), - Some("build"), - )) - .unwrap(); - } - - let first_stage = state.stage(&StageId::new("build", 1)).unwrap(); - let second_stage = state.stage(&StageId::new("build", 2)).unwrap(); - assert_eq!(first_stage.timing.map(|t| t.wall_time_ms), Some(111)); - assert_eq!(first_stage.usage, first_usage.usage); - assert_eq!(first_stage.model.as_ref(), Some(first_usage.model())); - assert_eq!(second_stage.timing.map(|t| t.wall_time_ms), Some(222)); - assert_eq!(second_stage.usage, second_usage.usage); - assert_eq!(second_stage.model.as_ref(), Some(second_usage.model())); - } - - #[test] - fn stage_completed_prefers_stored_stage_id_over_legacy_node_visits() { - let mut state = initialized_projection(); - let usage = test_usage("gpt-5.2", 300, 30); - let scoped_stage_id = StageId::new("build", 2); - - state - .apply_event(&test_stage_event( - 3, - EventBody::StageCompleted(StageCompletedProps { - index: 0, - timing: fabro_types::StageTiming::wall_only(333), - status: StageOutcome::Succeeded, - preferred_label: None, - suggested_next_ids: Vec::new(), - usage_by_model: Vec::new(), - usage: Some(usage.clone()), - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: Some(BTreeMap::from([("build".to_string(), 1usize)])), - loop_failure_signatures: None, - restart_failure_signatures: None, - response: Some("done".to_string()), - attempt: 1, - max_attempts: 1, - }), - scoped_stage_id.clone(), - )) - .unwrap(); - - assert!( - state.stage(&StageId::new("build", 1)).is_none(), - "legacy node_visits must not override stored stage_id" - ); - let stage = state.stage(&scoped_stage_id).unwrap(); - assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(333)); - assert_eq!(stage.usage, usage.usage); - assert_eq!(stage.model.as_ref(), Some(usage.model())); - assert_eq!(stage.response.as_deref(), Some("done")); - } - - #[test] - fn stage_failed_prefers_stored_stage_id_and_preserves_retry_request() { - let mut state = initialized_projection(); - let usage = test_usage("gpt-5.2", 400, 40); - let scoped_stage_id = StageId::new("build", 2); - - state - .apply_event(&test_stage_event( - 3, - EventBody::StageFailed(StageFailedProps { - index: 0, - failure: Some(fabro_types::FailureDetail::new( - "try again", - fabro_types::FailureCategory::TransientInfra, - )), - will_retry: true, - timing: fabro_types::StageTiming::wall_only(444), - usage_by_model: Vec::new(), - usage: Some(usage.clone()), - }), - scoped_stage_id.clone(), - )) - .unwrap(); - - assert!( - state.stage(&StageId::new("build", 1)).is_none(), - "current-visit fallback must not override stored stage_id" - ); - let stage = state.stage(&scoped_stage_id).unwrap(); - assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(444)); - assert_eq!(stage.usage, usage.usage); - assert_eq!(stage.model.as_ref(), Some(usage.model())); - let completion = stage.completion.as_ref().unwrap(); - assert_eq!(completion.outcome, StageOutcome::Failed { - retry_requested: true, - }); - assert_eq!(completion.failure_reason.as_deref(), Some("try again")); - } - - #[test] - fn checkpoint_completed_creates_projection_entry_for_skipped_stage() { - let mut state = initialized_projection(); - let stage_id = StageId::new("skip_me", 1); - - state - .apply_event(&test_event( - 5, - EventBody::CheckpointCompleted(CheckpointCompletedProps { - graph_visit: None, - resumed_from_stage_id: None, - status: "running".to_string(), - current_node: "next".to_string(), - completed_nodes: vec!["skip_me".to_string()], - node_retries: BTreeMap::new(), - context_values: BTreeMap::new(), - node_outcomes: BTreeMap::from([( - "skip_me".to_string(), - Outcome::skipped("condition was false"), - )]), - next_node_id: Some("next".to_string()), - git_commit_sha: None, - loop_failure_signatures: BTreeMap::new(), - restart_failure_signatures: BTreeMap::new(), - node_visits: BTreeMap::from([("skip_me".to_string(), 1usize)]), - diff: None, - diff_summary: None, - }), - None, - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.first_event_seq, first_event_seq(5)); - let completion = stage.completion.as_ref().unwrap(); - assert_eq!(completion.outcome, StageOutcome::Skipped); - assert_eq!(completion.notes.as_deref(), Some("condition was false")); - } - - #[test] - fn interview_events_populate_and_clear_pending_interviews() { - let mut state = initialized_projection(); - state - .apply_event(&test_event( - 1, - EventBody::InterviewStarted(InterviewStartedProps { - question_id: "q-1".to_string(), - question: "Approve deploy?".to_string(), - stage: "gate".to_string(), - question_type: "multiple_choice".to_string(), - options: vec![ - InterviewOption { - key: "approve".to_string(), - label: "Approve".to_string(), - description: Some("Ship it".to_string()), - preview: Some("deploy --prod".to_string()), - }, - InterviewOption { - key: "revise".to_string(), - label: "Revise".to_string(), - description: None, - preview: None, - }, - ], - allow_freeform: true, - timeout_seconds: Some(30.0), - context_display: Some("Latest draft".to_string()), - review_target: Some( - fabro_types::ReviewTarget::new( - "Quarry review exercise", - "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef", - fabro_types::ReviewTargetKind::Document, - ) - .unwrap(), - ), - }), - Some("gate"), - )) - .unwrap(); - - let pending = state - .pending_interviews - .get("q-1") - .expect("pending interview should be present"); - assert_eq!(pending.question.id, "q-1"); - assert_eq!(pending.question.stage, "gate"); - assert_eq!(pending.question.question_type, QuestionType::MultipleChoice); - assert_eq!(pending.question.options.len(), 2); - assert_eq!( - pending.question.options[0].description.as_deref(), - Some("Ship it") - ); - assert_eq!( - pending.question.options[0].preview.as_deref(), - Some("deploy --prod") - ); - assert!(pending.question.allow_freeform); - assert_eq!(pending.question.timeout_seconds, Some(30.0)); - assert_eq!( - pending.question.context_display.as_deref(), - Some("Latest draft") - ); - assert_eq!( - pending - .question - .review_target - .as_ref() - .map(fabro_types::ReviewTarget::url), - Some("https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef") - ); - - state - .apply_event(&test_event( - 2, - EventBody::InterviewCompleted(InterviewCompletedProps { - question_id: "q-1".to_string(), - question: "Approve deploy?".to_string(), - answer: "approve".to_string(), - duration_ms: 42, - }), - Some("gate"), - )) - .unwrap(); - - assert!( - state.pending_interviews.is_empty(), - "completed interview should clear pending state" - ); - } - - #[test] - fn pending_runnable_and_blocked_events_drive_projection_and_summary_fields() { - let mut state = initialized_projection(); - - state - .apply_event(&test_raw_event( - 1, - "run.pending", - &json!({ "reason": "approval_required" }), - None, - )) - .unwrap(); - assert_eq!(state.status(), RunStatus::Pending { - reason: PendingReason::ApprovalRequired, - }); - assert_eq!( - state.approval.as_ref().map(|approval| approval.state), - Some(RunApprovalState::Pending) - ); - - state - .apply_event(&test_raw_event(2, "run.approved", &json!({}), None)) - .unwrap(); - state - .apply_event(&test_raw_event( - 3, - "run.runnable", - &json!({ "source": "approved" }), - None, - )) - .unwrap(); - assert_eq!(state.status(), RunStatus::Runnable); - assert_eq!( - state.approval.as_ref().map(|approval| approval.state), - Some(RunApprovalState::Approved) - ); - - state - .apply_event(&test_raw_event(4, "run.starting", &json!({}), None)) - .unwrap(); - state - .apply_event(&test_raw_event(5, "run.running", &json!({}), None)) - .unwrap(); - state - .apply_event(&test_event( - 6, - EventBody::RunPaused(RunControlEffectProps::default()), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event( - 7, - "run.blocked", - &json!({ "blocked_reason": "human_input_required" }), - None, - )) - .unwrap(); - - let status_json = serde_json::to_value(state.status()).unwrap(); - assert_eq!(state.status(), RunStatus::Paused { - prior_block: Some(BlockedReason::HumanInputRequired), - }); - assert_eq!( - status_json, - json!({ - "kind": "paused", - "prior_block": "human_input_required" - }) - ); - - let summary = build_summary(&state, &fixtures::RUN_1); - let summary_json = serde_json::to_value(summary).unwrap(); - assert_eq!( - summary_json["lifecycle"]["status"], - json!({ - "kind": "paused", - "prior_block": "human_input_required" - }) - ); - assert_eq!( - summary_json["lifecycle"]["approval"]["state"], - json!("approved") - ); - } - - #[test] - fn approval_denial_projection_records_decision_then_failure() { - let mut state = initialized_projection(); - - state - .apply_event(&test_raw_event_at( - 1, - "2026-05-23T12:00:00Z", - "run.start_requested", - &json!({ "resume": false }), - None, - )) - .unwrap(); - assert_eq!(state.status(), RunStatus::Submitted); - assert!(state.approval.is_none()); - - state - .apply_event(&test_raw_event_at( - 2, - "2026-05-23T12:00:01Z", - "run.pending", - &json!({ "reason": "approval_required" }), - None, - )) - .unwrap(); - let approval = state.approval.as_ref().expect("approval should be pending"); - assert_eq!(state.status(), RunStatus::Pending { - reason: PendingReason::ApprovalRequired, - }); - assert_eq!(approval.state, RunApprovalState::Pending); - assert_eq!( - approval.requested_at.to_rfc3339(), - "2026-05-23T12:00:01+00:00" - ); - assert_eq!(approval.decided_at, None); - - state - .apply_event(&test_raw_event_at( - 3, - "2026-05-23T12:00:02Z", - "run.denied", - &json!({ "reason": "Not approved for execution" }), - None, - )) - .unwrap(); - let approval = state.approval.as_ref().expect("approval should be denied"); - assert_eq!(state.status(), RunStatus::Pending { - reason: PendingReason::ApprovalRequired, - }); - assert_eq!(approval.state, RunApprovalState::Denied); - assert_eq!( - approval.denial_reason.as_deref(), - Some("Not approved for execution") - ); - assert_eq!( - approval.decided_at.map(|ts| ts.to_rfc3339()).as_deref(), - Some("2026-05-23T12:00:02+00:00") - ); - - state - .apply_event(&test_raw_event( - 4, - "run.failed", - &json!({ - "failure": { - "reason": "approval_denied", - "detail": { - "message": "Not approved for execution", - "category": "deterministic" - } - }, - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - } - }), - None, - )) - .unwrap(); - - assert_eq!(state.status(), RunStatus::Failed { - reason: FailureReason::ApprovalDenied, - }); - let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap(); - assert_eq!( - summary_json["lifecycle"]["approval"], - json!({ - "state": "denied", - "requested_at": "2026-05-23T12:00:01Z", - "decided_at": "2026-05-23T12:00:02Z", - "denial_reason": "Not approved for execution" - }) - ); - assert_eq!( - summary_json["lifecycle"]["status"], - json!({ "kind": "failed", "reason": "approval_denied" }) - ); - } - - #[test] - fn runnable_projection_without_approval_has_null_summary_approval() { - let mut state = initialized_projection(); - state - .apply_event(&test_raw_event( - 1, - "run.runnable", - &json!({ "source": "start_requested" }), - None, - )) - .unwrap(); - - assert_eq!(state.status(), RunStatus::Runnable); - assert!(state.approval.is_none()); - let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap(); - assert_eq!( - summary_json["lifecycle"]["status"], - json!({ "kind": "runnable" }) - ); - assert!(summary_json["lifecycle"]["approval"].is_null()); - } - - #[test] - fn run_unblocked_clears_blocked_reason_and_restores_running() { - let mut state = running_projection(); - - state - .apply_event(&test_raw_event( - 1, - "run.blocked", - &json!({ "blocked_reason": "human_input_required" }), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event(2, "run.unblocked", &json!({}), None)) - .unwrap(); - - assert_eq!(state.status(), RunStatus::Running); - let status_json = serde_json::to_value(state.status()).unwrap(); - assert_eq!(status_json, json!({ "kind": "running" })); - } - - #[test] - fn run_unblocked_while_paused_clears_blocked_reason_without_changing_paused_status() { - let mut state = running_projection(); - - state - .apply_event(&test_raw_event( - 1, - "run.blocked", - &json!({ "blocked_reason": "human_input_required" }), - None, - )) - .unwrap(); - state - .apply_event(&test_event( - 2, - EventBody::RunPaused(RunControlEffectProps::default()), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event(3, "run.unblocked", &json!({}), None)) - .unwrap(); - - assert_eq!(state.status(), RunStatus::Paused { prior_block: None }); - let status_json = serde_json::to_value(state.status()).unwrap(); - assert_eq!( - status_json, - json!({ - "kind": "paused", - "prior_block": null - }) - ); - } - - #[test] - fn unpause_to_still_blocked_yields_visible_blocked_after_event_sequence() { - let mut state = running_projection(); - - state - .apply_event(&test_raw_event( - 1, - "run.blocked", - &json!({ "blocked_reason": "human_input_required" }), - None, - )) - .unwrap(); - state - .apply_event(&test_event( - 2, - EventBody::RunPaused(RunControlEffectProps::default()), - None, - )) - .unwrap(); - state - .apply_event(&test_event( - 3, - EventBody::RunUnpaused(RunControlEffectProps::default()), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event( - 4, - "run.blocked", - &json!({ "blocked_reason": "human_input_required" }), - None, - )) - .unwrap(); - - assert_eq!(state.status(), RunStatus::Blocked { - blocked_reason: BlockedReason::HumanInputRequired, - }); - let status_json = serde_json::to_value(state.status()).unwrap(); - assert_eq!( - status_json, - json!({ - "kind": "blocked", - "blocked_reason": "human_input_required" - }) - ); - } - - #[test] - fn summary_synthesizes_submitted_when_run_exists_without_status() { - let mut state = initialized_projection(); - state.spec = fabro_types::RunSpec { - workflow_slug: Some("test".to_string()), - source_directory: Some("/tmp/repo".to_string()), - ..test_support::test_run_spec() - }; - - let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap(); - assert_eq!( - summary_json["lifecycle"]["status"], - json!({ "kind": "submitted" }) - ); - } - - #[test] - fn summary_preserves_absent_workflow_name_and_reports_graph_name() { - let mut state = initialized_projection(); - state.spec = fabro_types::RunSpec { - graph: fabro_types::Graph::new("GraphName"), - workflow_slug: Some("release-flow".to_string()), - source_directory: Some("/tmp/repo".to_string()), - ..test_support::test_run_spec() - }; - - let summary = build_summary(&state, &fixtures::RUN_1); - - assert_eq!(summary.workflow.name, None); - assert_eq!(summary.workflow.graph_name.as_deref(), Some("GraphName")); - assert_eq!(summary.workflow.slug.as_deref(), Some("release-flow")); - } - - #[test] - fn summary_uses_explicit_workflow_name() { - let mut state = initialized_projection(); - state.spec.settings.workflow.name = Some("Ship workflow".to_string()); - - let summary = build_summary(&state, &fixtures::RUN_1); - - assert_eq!(summary.workflow.name.as_deref(), Some("Ship workflow")); - assert_eq!(summary.workflow.graph_name.as_deref(), Some("test")); - } - - #[test] - fn run_created_title_populates_projection_and_summary() { - let event = test_raw_event( - 1, - "run.created", - &json!({ - "title": "Explicit title", - "settings": WorkflowSettings::default(), - "graph": { - "name": "test", - "nodes": {}, - "edges": [], - "attrs": { "goal": { "String": "Goal title" } } - }, - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - ); - - let state = RunProjection::apply_events(&[event]).unwrap(); - assert_eq!(state.title, "Explicit title"); - assert_eq!( - build_summary(&state, &fixtures::RUN_1).title, - "Explicit title" - ); - } - - #[test] - fn run_created_without_title_infers_projection_title() { - let event = test_raw_event( - 1, - "run.created", - &json!({ - "settings": WorkflowSettings::default(), - "graph": { - "name": "test", - "nodes": {}, - "edges": [], - "attrs": { "goal": { "String": "## Plan: Legacy title\n\nDetails" } } - }, - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - ); - - let state = RunProjection::apply_events(&[event]).unwrap(); - assert_eq!(state.title, "Legacy title"); - assert_eq!( - build_summary(&state, &fixtures::RUN_1).title, - "Legacy title" - ); - } - - #[test] - fn run_title_updated_changes_projection_and_summary() { - let events = vec![ - test_raw_event( - 1, - "run.created", - &json!({ - "title": "Original title", - "settings": WorkflowSettings::default(), - "graph": { - "name": "test", - "nodes": {}, - "edges": [], - "attrs": { "goal": { "String": "Goal title" } } - }, - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - ), - test_raw_event( - 2, - "run.title.updated", - &json!({ "title": "Renamed title" }), - None, - ), - ]; - - let state = RunProjection::apply_events(&events).unwrap(); - assert_eq!(state.title, "Renamed title"); - assert_eq!( - build_summary(&state, &fixtures::RUN_1).title, - "Renamed title" - ); - } - - #[test] - fn historical_metadata_events_and_settings_remain_replayable() { - let mut settings = serde_json::to_value(WorkflowSettings::default()).unwrap(); - settings["run"]["meta_branch"] = json!({"enabled": true, "push": true}); - let mut events = vec![test_raw_event( - 1, - "run.created", - &json!({ - "title": "Historical run", - "settings": settings, - "graph": { "name": "test", "nodes": {}, "edges": [], "attrs": {} }, - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - )]; - for (name, properties) in [ - ( - "metadata.snapshot.started", - json!({ - "phase": "init", "branch": "fabro/meta/historical" - }), - ), - ( - "metadata.snapshot.completed", - json!({ - "phase": "init", "branch": "fabro/meta/historical", - "duration_ms": 10, "entry_count": 2, "bytes": 42, "commit_sha": "abc123" - }), - ), - ( - "metadata.snapshot.failed", - json!({ - "phase": "checkpoint", "branch": "fabro/meta/historical", - "duration_ms": 20, "failure_kind": "push", "error": "remote unavailable" - }), - ), - ] { - let event = test_raw_event( - u32::try_from(events.len()).unwrap() + 1, - name, - &properties, - None, - ); - assert!(matches!( - event.event.body, - EventBody::MetadataSnapshotStarted(_) - | EventBody::MetadataSnapshotCompleted(_) - | EventBody::MetadataSnapshotFailed(_) - )); - assert_eq!(event.event.event_name(), name); - assert_eq!(event.event.properties().unwrap(), properties); - events.push(event); - } - events.push(test_raw_event( - 5, - "run.title.updated", - &json!({ "title": "Replayed historical run" }), - None, - )); - - let state = RunProjection::apply_events(&events).unwrap(); - assert_eq!(state.title, "Replayed historical run"); - assert!( - serde_json::to_value(&state.spec.settings).unwrap()["run"] - .get("meta_branch") - .is_none() - ); - } - - #[test] - fn projection_serialization_includes_definition_blob() { - let definition_blob = - BlobHash::new(br#"{"version":1,"workflow_path":"workflow.fabro"}"#).to_string(); - let events = vec![ - EventEnvelope { - seq: 1, - event: RunEvent::from_value(json!({ - "id": "evt-run-created", - "ts": "2026-04-07T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "run.created", - "properties": { - "settings": WorkflowSettings::default(), - "graph": { - "name": "test", - "nodes": {}, - "edges": [], - "attrs": {} - }, - "labels": {}, - "source_directory": "/tmp/run", - "provenance": test_support::test_run_provenance() - } - })) - .unwrap(), - }, - EventEnvelope { - seq: 2, - event: RunEvent::from_value(json!({ - "id": "evt-run-submitted", - "ts": "2026-04-07T12:00:01Z", - "run_id": fixtures::RUN_1, - "event": "run.submitted", - "properties": { - "definition_blob": definition_blob - } - })) - .unwrap(), - }, - ]; - - let state = RunProjection::apply_events(&events).unwrap(); - let value = serde_json::to_value(&state).unwrap(); - - assert_eq!( - value["spec"]["definition_blob"], - events[1].event.properties().unwrap()["definition_blob"] - ); - } - - #[test] - fn terminal_conclusion_replays_stage_summaries_without_metadata() { - for terminal_name in ["run.completed", "run.failed"] { - let settings = WorkflowSettings::default(); - let mut events = vec![ - test_raw_event( - 1, - "run.created", - &json!({ - "settings": settings, - "graph": { "name": "test", "nodes": {}, "edges": [], "attrs": {} }, - "labels": {}, - "provenance": test_support::test_run_provenance() - }), - None, - ), - test_raw_event( - 2, - "run.runnable", - &json!({ "source": "start_requested" }), - None, - ), - test_raw_event(3, "run.starting", &json!({}), None), - test_raw_event(4, "run.running", &json!({}), None), - ]; - // Two executions of zebra share one conclusion row. First-event - // order differs from checkpoint order, and skipped has no completion. - for (seq, node, visit, millis, tokens) in [ - (5, "zebra", 1, 1200, 100), - (6, "apple", 1, 300, 20), - (7, "zebra", 2, 800, 200), - ] { - let mut props = completed_props(millis, StageOutcome::Succeeded); - props.usage = Some(test_usage("test-model", tokens, 10)); - events.push(test_stage_event( - seq, - EventBody::StageCompleted(props), - StageId::new(node, visit), - )); - } - events.push(test_raw_event( - 8, - "checkpoint.completed", - &json!({ - "status": "succeeded", - "current_node": "zebra", - "completed_nodes": ["apple", "zebra", "zebra"], - "node_retries": { "zebra": 3, "apple": 1 }, - "node_outcomes": { - "apple": Outcome::>::success(), - "zebra": Outcome::>::success(), - "skipped": Outcome::>::skipped("condition was false") - }, - "context_values": {}, - "node_visits": { "zebra": 2, "apple": 1, "skipped": 1 }, - "git_commit_sha": "checkpoint-sha" - }), - Some("zebra"), - )); - let terminal_usage = test_usage("test-model", 320, 30).usage; - let terminal_props = if terminal_name == "run.completed" { - json!({ - "status": "succeeded", "reason": "completed", - "timing": fabro_types::RunTiming::wall_only(9000), - "artifact_count": 0, "usage": terminal_usage, - "final_git_commit_sha": "final-sha", "final_patch": "final patch" - }) - } else { - let mut props = run_failed_props(FailureReason::WorkflowError); - props.timing = fabro_types::RunTiming::wall_only(9000); - props.usage = Some(terminal_usage); - props.final_git_commit_sha = Some("final-sha".to_string()); - props.final_patch = Some("final patch".to_string()); - serde_json::to_value(props).unwrap() - }; - events.push(test_raw_event(9, terminal_name, &terminal_props, None)); - for event in &mut events { - event.event.ts = test_dt("2026-04-07T12:00:00Z") - + chrono::Duration::seconds(i64::from(event.seq)); - } - - // Cross the persisted wire boundary before both incremental and full replay. - let events: Vec = - serde_json::from_slice(&serde_json::to_vec(&events).unwrap()).unwrap(); - let mut live = RunProjection::apply_events(&events[..1]).unwrap(); - for event in &events[1..] { - live.apply_event(event).unwrap(); - } - let replayed = RunProjection::apply_events(&events).unwrap(); - let conclusion = replayed.conclusion.as_ref().unwrap(); - assert_eq!( - serde_json::to_value(&live.conclusion).unwrap(), - serde_json::to_value(conclusion).unwrap(), - ); - assert_eq!(conclusion.timestamp, events.last().unwrap().event.ts); - assert_eq!(conclusion.timing.wall_time_ms, 9000); - assert_eq!(conclusion.usage, Some(terminal_usage)); - assert_eq!( - conclusion.final_git_commit_sha.as_deref(), - Some("final-sha") - ); - assert_eq!(conclusion.diff.patch.as_deref(), Some("final patch")); - assert_eq!(conclusion.failure.is_some(), terminal_name == "run.failed"); - insta::allow_duplicates! { - insta::assert_snapshot!(serde_json::to_string_pretty(&json!({ - "stages": conclusion.stages, - "total_retries": conclusion.total_retries, - })).unwrap(), @r###" - { - "stages": [ - { - "stage_id": "zebra", - "stage_label": "zebra", - "timing": { - "wall_time_ms": 2000, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - }, - "usage": { - "tokens": { - "input": 300, - "output": 20, - "reasoning": 0, - "cache_read": 0, - "cache_write": 0 - }, - "cost": { - "usd_micros": 320, - "source": "catalog" - } - }, - "retries": 2 - }, - { - "stage_id": "apple", - "stage_label": "apple", - "timing": { - "wall_time_ms": 300, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - }, - "usage": { - "tokens": { - "input": 20, - "output": 10, - "reasoning": 0, - "cache_read": 0, - "cache_write": 0 - }, - "cost": { - "usd_micros": 30, - "source": "catalog" - } - }, - "retries": 0 - }, - { - "stage_id": "skipped", - "stage_label": "skipped", - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - }, - "usage": { - "tokens": { - "input": 0, - "output": 0, - "reasoning": 0, - "cache_read": 0, - "cache_write": 0 - } - }, - "retries": 0 - } - ], - "total_retries": 2 - } - "###); - } - } - } - - #[test] - fn terminal_conclusion_without_checkpoint_has_no_stage_summaries() { - let mut state = running_projection(); - let terminal = test_event( - 4, - EventBody::RunFailed(run_failed_props(FailureReason::WorkflowError)), - None, - ); - state.apply_event(&terminal).unwrap(); - let conclusion = state.conclusion.unwrap(); - assert!(conclusion.stages.is_empty()); - assert_eq!(conclusion.total_retries, 0); - assert_eq!(conclusion.timestamp, terminal.event.ts); - } - - #[test] - fn run_failed_with_final_patch_populates_projection() { - let mut state = running_projection(); - let patch = "diff --git a/foo.rs b/foo.rs\n@@ -1 +1 @@\n-a\n+b\n"; - state - .apply_event(&test_event( - 1, - EventBody::RunFailed(RunFailedProps { - failure: fabro_types::RunFailure { - reason: FailureReason::WorkflowError, - detail: FailureDetail::new("boom", FailureCategory::Deterministic), - }, - timing: fabro_types::RunTiming::wall_only(42), - final_git_commit_sha: Some("abc123".to_string()), - final_patch: Some(patch.to_string()), - diff_summary: None, - usage: None, - }), - None, - )) - .unwrap(); - - assert_eq!( - state - .conclusion - .as_ref() - .and_then(|conclusion| conclusion.diff.patch.as_deref()), - Some(patch) - ); - } - - #[test] - fn patch_bearing_events_roll_up_diff_summary_without_blanking_prior_value() { - let mut state = running_projection(); - state - .apply_event(&test_raw_event( - 3, - "checkpoint.completed", - &json!({ - "status": "running", - "current_node": "build", - "completed_nodes": ["build"], - "diff_summary": { - "files_changed": 2, - "additions": 10, - "deletions": 3 - } - }), - Some("build"), - )) - .unwrap(); - assert_eq!( - serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff"], - json!({ - "files_changed": 2, - "additions": 10, - "deletions": 3 - }) - ); - - state - .apply_event(&test_raw_event( - 4, - "checkpoint.completed", - &json!({ - "status": "running", - "current_node": "review", - "completed_nodes": ["build", "review"] - }), - Some("review"), - )) - .unwrap(); - assert_eq!( - serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff"]["files_changed"], - 2 - ); - - state - .apply_event(&test_raw_event( - 5, - "run.completed", - &json!({ - "timing": {"wall_time_ms": 42, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0}, - "artifact_count": 0, - "status": "succeeded", - "reason": "completed", - "diff_summary": { - "files_changed": 4, - "additions": 18, - "deletions": 7 - } - }), - None, - )) - .unwrap(); - assert_eq!( - serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff"], - json!({ - "files_changed": 4, - "additions": 18, - "deletions": 7 - }) - ); - - let mut failed_state = running_projection(); - failed_state - .apply_event(&test_raw_event( - 3, - "run.failed", - &json!({ - "failure": { - "reason": "workflow_error", - "detail": { - "message": "boom", - "category": "deterministic" - } - }, - "timing": {"wall_time_ms": 42, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0}, - "diff_summary": { - "files_changed": 5, - "additions": 20, - "deletions": 8 - } - }), - None, - )) - .unwrap(); - assert_eq!( - serde_json::to_value(build_summary(&failed_state, &fixtures::RUN_1)).unwrap()["diff"], - json!({ - "files_changed": 5, - "additions": 20, - "deletions": 8 - }) - ); - } - - #[test] - fn run_failed_projection_renders_causes() { - let mut state = running_projection(); - state - .apply_event(&test_event( - 1, - EventBody::RunFailed(RunFailedProps { - failure: fabro_types::RunFailure { - reason: FailureReason::WorkflowError, - detail: { - let mut detail = FailureDetail::new( - "Failed to initialize sandbox", - FailureCategory::TransientInfra, - ); - detail.causes = vec![ - "Failed to pull Docker image buildpack-deps:noble".to_string(), - "connection refused".to_string(), - ]; - detail - }, - }, - timing: fabro_types::RunTiming::wall_only(42), - final_git_commit_sha: None, - final_patch: None, - diff_summary: None, - usage: None, - }), - None, - )) - .unwrap(); - - let failure = state.conclusion.unwrap().failure.unwrap(); - assert_eq!(failure.detail.message, "Failed to initialize sandbox"); - assert_eq!(failure.detail.causes, vec![ - "Failed to pull Docker image buildpack-deps:noble".to_string(), - "connection refused".to_string(), - ]); - } - - #[test] - fn run_failed_projection_uses_nested_failure_reason_and_conclusion() { - let mut state = running_projection(); - let failure = fabro_types::RunFailure { - reason: FailureReason::SandboxInitFailed, - detail: { - let mut detail = FailureDetail::new( - "Failed to initialize sandbox", - FailureCategory::TransientInfra, - ); - detail.causes = vec!["connection refused".to_string()]; - detail.system_actor = Some(fabro_types::SystemActorKind::Engine); - detail.signature = Some(fabro_types::FailureSignature( - "init|transient_infra|docker".to_string(), - )); - detail - }, - }; - state - .apply_event(&test_event( - 1, - EventBody::RunFailed(RunFailedProps { - failure: failure.clone(), - timing: fabro_types::RunTiming::wall_only(42), - final_git_commit_sha: Some("abc123".to_string()), - final_patch: None, - diff_summary: None, - usage: None, - }), - None, - )) - .unwrap(); - - assert_eq!(state.status, RunStatus::Failed { - reason: FailureReason::SandboxInitFailed, - }); - let conclusion = state.conclusion.unwrap(); - assert_eq!(conclusion.failure, Some(failure)); - assert_eq!(conclusion.final_git_commit_sha.as_deref(), Some("abc123")); - } - - #[test] - fn resume_start_request_clears_prior_terminal_conclusion() { - let mut state = running_projection(); - let failed_at = "2026-05-24T22:01:19Z"; - - state - .apply_event(&test_raw_event_at( - 4, - failed_at, - "run.failed", - &serde_json::to_value(run_failed_props(FailureReason::Cancelled)).unwrap(), - None, - )) - .unwrap(); - assert!(state.conclusion.is_some()); - assert_eq!( - build_summary(&state, &fixtures::RUN_1) - .timestamps - .completed_at, - Some(test_dt(failed_at)) - ); - - state - .apply_event(&test_raw_event_at( - 5, - "2026-05-24T22:36:50Z", - "run.start_requested", - &json!({ "resume": true }), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 6, - "2026-05-24T22:36:51Z", - "run.runnable", - &json!({ "source": "start_requested" }), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 7, - "2026-05-24T22:36:52Z", - "run.starting", - &json!({}), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 8, - "2026-05-24T22:36:53Z", - "run.running", - &json!({}), - None, - )) - .unwrap(); - - let summary = build_summary(&state, &fixtures::RUN_1); - assert_eq!(state.status, RunStatus::Running); - assert!(state.conclusion.is_none()); - assert_eq!(summary.timestamps.completed_at, None); - assert_eq!(summary.timing, None); - } - - #[test] - fn run_archived_captures_prior_status_and_preserves_reason() { - use fabro_types::run_event::{RunArchivedProps, RunCompletedProps}; - - let mut state = running_projection(); - state - .apply_event(&test_event( - 1, - EventBody::RunCompleted(RunCompletedProps { - timing: fabro_types::RunTiming::wall_only(10), - artifact_count: 0, - status: "succeeded".to_string(), - reason: SuccessReason::Completed, - final_git_commit_sha: None, - final_patch: None, - diff_summary: None, - usage: None, - }), - None, - )) - .unwrap(); - state - .apply_event(&test_event( - 2, - EventBody::RunArchived(RunArchivedProps::default()), - None, - )) - .unwrap(); - - assert_eq!(state.status(), RunStatus::Succeeded { - reason: SuccessReason::Completed, - }); - assert!(state.archived_at.is_some()); - } - - #[test] - fn run_superseded_by_populates_projection_and_summary() { - use fabro_types::run_event::RunSupersededByProps; - - let mut state = running_projection(); - state - .apply_event(&test_event( - 1, - EventBody::RunSupersededBy(RunSupersededByProps { - new_run_id: fixtures::RUN_2, - target_checkpoint_ordinal: 2, - target_node_id: "build".to_string(), - target_visit: 1, - }), - None, - )) - .unwrap(); - - assert_eq!(state.superseded_by, Some(fixtures::RUN_2)); - - let summary = build_summary(&state, &fixtures::RUN_1); - assert_eq!(summary.superseded_by, Some(fixtures::RUN_2)); - } - - #[test] - fn pull_request_created_populates_projection_and_summary() { - use fabro_types::run_event::PullRequestCreatedProps; - - let mut state = running_projection(); - state - .apply_event(&test_event( - 1, - EventBody::PullRequestCreated(PullRequestCreatedProps { - pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(), - pr_number: 123, - owner: "fabro-sh".to_string(), - repo: "fabro".to_string(), - base_branch: "main".to_string(), - head_branch: "fabro/run/demo".to_string(), - head_sha: Some("final-sha".to_string()), - title: "Add run PR chip".to_string(), - draft: false, - }), - None, - )) - .unwrap(); - - let pull_request = state - .pull_request - .as_ref() - .expect("projection should store pull request"); - assert_eq!( - pull_request.html_url(), - "https://github.com/fabro-sh/fabro/pull/123" - ); - assert_eq!(pull_request.number, 123); - - let summary = build_summary(&state, &fixtures::RUN_1); - assert_eq!(summary.pull_request, state.pull_request); - } - - #[test] - fn pull_request_creation_projects_failure_retry_and_success() { - use fabro_types::run_event::{ - PullRequestCreatedProps, PullRequestCreationRequestedProps, PullRequestFailedProps, - }; - - let mut state = running_projection(); - let first_id = "01KYYK70WTZT2E551P3H5P0059".parse().unwrap(); - state - .apply_event(&test_event( - 1, - EventBody::PullRequestCreationRequested(PullRequestCreationRequestedProps { - creation_id: first_id, - model: "kimi-k3".to_string(), - force: false, - }), - None, - )) - .unwrap(); - assert!(state.pull_request_creation.as_ref().unwrap().is_pending()); - - state - .apply_event(&test_event( - 2, - EventBody::PullRequestFailed(PullRequestFailedProps { - creation_id: None, - error: "publish stage failure".to_string(), - }), - None, - )) - .unwrap(); - assert!( - state.pull_request_creation.as_ref().unwrap().is_pending(), - "a failure without a creation id must not resolve the creation" - ); - - state - .apply_event(&test_event( - 3, - EventBody::PullRequestFailed(PullRequestFailedProps { - creation_id: Some(first_id), - error: "provider unavailable".to_string(), - }), - None, - )) - .unwrap(); - let failed = state.pull_request_creation.as_ref().unwrap(); - assert_eq!(failed.status, PullRequestCreationStatus::Failed); - assert_eq!(failed.error.as_deref(), Some("provider unavailable")); - - let retry_id = "01KYYK70WTZT2E551P3H5P0060".parse().unwrap(); - state - .apply_event(&test_event( - 4, - EventBody::PullRequestCreationRequested(PullRequestCreationRequestedProps { - creation_id: retry_id, - model: "claude-sonnet-4-6".to_string(), - force: true, - }), - None, - )) - .unwrap(); - assert_eq!(state.pull_request_creation.as_ref().unwrap().id, retry_id); - - state - .apply_event(&test_event( - 5, - EventBody::PullRequestCreated(PullRequestCreatedProps { - pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(), - pr_number: 123, - owner: "fabro-sh".to_string(), - repo: "fabro".to_string(), - base_branch: "main".to_string(), - head_branch: "fabro/run/demo".to_string(), - head_sha: Some("final-sha".to_string()), - title: "Create asynchronously".to_string(), - draft: true, - }), - None, - )) - .unwrap(); - let succeeded = state.pull_request_creation.as_ref().unwrap(); - assert_eq!(succeeded.status, PullRequestCreationStatus::Succeeded); - assert_eq!(succeeded.pull_request.as_ref().unwrap().number, 123); - assert_eq!(succeeded.pull_request, state.pull_request); - assert!(succeeded.error.is_none()); - } - - #[test] - fn pull_request_linked_replaces_and_unlinked_clears_projection() { - use fabro_types::run_event::{ - PullRequestCreatedProps, PullRequestLinkedProps, PullRequestUnlinkedProps, - }; - - let mut state = running_projection(); - let github_pull_request = PullRequestLink { - owner: "fabro-sh".to_string(), - repo: "fabro".to_string(), - number: 123, - }; - let replacement_pull_request = PullRequestLink { - owner: "acme".to_string(), - repo: "widgets".to_string(), - number: 42, - }; - - state - .apply_event(&test_event( - 1, - EventBody::PullRequestCreated(PullRequestCreatedProps { - pr_url: github_pull_request.html_url(), - pr_number: github_pull_request.number, - owner: github_pull_request.owner.clone(), - repo: github_pull_request.repo.clone(), - base_branch: "main".to_string(), - head_branch: "fabro/run/demo".to_string(), - head_sha: Some("final-sha".to_string()), - title: "Add run PR chip".to_string(), - draft: false, - }), - None, - )) - .unwrap(); - assert_eq!(state.pull_request, Some(github_pull_request.clone())); - - state - .apply_event(&test_event( - 2, - EventBody::PullRequestLinked(PullRequestLinkedProps { - pull_request: replacement_pull_request.clone(), - }), - None, - )) - .unwrap(); - assert_eq!(state.pull_request, Some(replacement_pull_request.clone())); - - state - .apply_event(&test_event( - 3, - EventBody::PullRequestUnlinked(PullRequestUnlinkedProps { - pull_request: replacement_pull_request, - }), - None, - )) - .unwrap(); - assert_eq!(state.pull_request, None); - - state - .apply_event(&test_event( - 4, - EventBody::PullRequestLinked(PullRequestLinkedProps { - pull_request: github_pull_request.clone(), - }), - None, - )) - .unwrap(); - assert_eq!(state.pull_request, Some(github_pull_request)); - } - - #[test] - fn run_unarchived_restores_prior_status() { - use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps}; - - let mut state = running_projection(); - state - .apply_event(&test_event( - 1, - EventBody::RunCompleted(RunCompletedProps { - timing: fabro_types::RunTiming::wall_only(10), - artifact_count: 0, - status: "succeeded".to_string(), - reason: SuccessReason::PartialSuccess, - final_git_commit_sha: None, - final_patch: None, - diff_summary: None, - usage: None, - }), - None, - )) - .unwrap(); - state - .apply_event(&test_event( - 2, - EventBody::RunArchived(RunArchivedProps::default()), - None, - )) - .unwrap(); - state - .apply_event(&test_event( - 3, - EventBody::RunUnarchived(RunUnarchivedProps::default()), - None, - )) - .unwrap(); - - assert_eq!(state.status(), RunStatus::Succeeded { - reason: SuccessReason::PartialSuccess, - }); - } - - #[test] - fn duplicate_event_noops_without_bumping_status_updated_at() { - let mut state = initialized_projection(); - state - .apply_event(&test_raw_event_at( - 1, - "2026-04-07T12:00:00Z", - "run.runnable", - &json!({ "source": "start_requested" }), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 2, - "2026-04-07T12:00:30Z", - "run.starting", - &json!({}), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 3, - "2026-04-07T12:01:00Z", - "run.running", - &json!({}), - None, - )) - .unwrap(); - let first_updated_at = state.status_updated_at; - - state - .apply_event(&test_raw_event_at( - 4, - "2026-04-07T12:02:00Z", - "run.running", - &json!({}), - None, - )) - .unwrap(); - - assert_eq!(state.status(), RunStatus::Running); - assert_eq!(state.status_updated_at, first_updated_at); - } - - #[test] - fn paused_over_blocked_round_trips_back_to_blocked() { - let mut state = running_projection(); - state - .apply_event(&test_raw_event( - 3, - "run.blocked", - &json!({ "blocked_reason": "human_input_required" }), - None, - )) - .unwrap(); - state - .apply_event(&test_event( - 4, - EventBody::RunPaused(RunControlEffectProps::default()), - None, - )) - .unwrap(); - state - .apply_event(&test_event( - 5, - EventBody::RunUnpaused(RunControlEffectProps::default()), - None, - )) - .unwrap(); - - assert_eq!(state.status(), RunStatus::Blocked { - blocked_reason: BlockedReason::HumanInputRequired, - }); - } - - #[test] - fn run_archived_on_non_terminal_projection_is_rejected() { - use fabro_types::run_event::RunArchivedProps; - - let mut state = running_projection(); - - let err = state - .apply_event(&test_event( - 3, - EventBody::RunArchived(RunArchivedProps::default()), - None, - )) - .unwrap_err(); - - assert!(matches!(err, Error::InvalidTransition(_))); - assert_eq!(state.status(), RunStatus::Running); - } - - #[test] - fn run_unarchived_replayed_on_non_archived_projection_is_ignored() { - use fabro_types::run_event::{RunCompletedProps, RunUnarchivedProps}; - - let mut state = running_projection(); - state - .apply_event(&test_event( - 1, - EventBody::RunCompleted(RunCompletedProps { - timing: fabro_types::RunTiming::wall_only(10), - artifact_count: 0, - status: "succeeded".to_string(), - reason: SuccessReason::Completed, - final_git_commit_sha: None, - final_patch: None, - diff_summary: None, - usage: None, - }), - None, - )) - .unwrap(); - let updated_at = state.status_updated_at; - - state - .apply_event(&test_event( - 2, - EventBody::RunUnarchived(RunUnarchivedProps::default()), - None, - )) - .unwrap(); - - assert_eq!(state.status(), RunStatus::Succeeded { - reason: SuccessReason::Completed, - }); - assert_eq!(state.status_updated_at, updated_at); - } - - fn started_props() -> StageStartedProps { - StageStartedProps { - graph_visit: None, - resumed_from_stage_id: None, - index: 0, - handler_type: "agent".to_string(), - attempt: 1, - max_attempts: 3, - } - } - - fn failed_props(duration_ms: u64, will_retry: bool) -> StageFailedProps { - StageFailedProps { - index: 0, - failure: Some(FailureDetail::new("boom", FailureCategory::TransientInfra)), - will_retry, - timing: fabro_types::StageTiming::wall_only(duration_ms), - usage_by_model: Vec::new(), - usage: None, - } - } - - fn canceled_failed_props(duration_ms: u64, will_retry: bool) -> StageFailedProps { - StageFailedProps { - index: 0, - failure: Some(FailureDetail::new("cancelled", FailureCategory::Canceled)), - will_retry, - timing: fabro_types::StageTiming::wall_only(duration_ms), - usage_by_model: Vec::new(), - usage: None, - } - } - - fn run_failed_props(reason: FailureReason) -> RunFailedProps { - let category = if reason == FailureReason::Cancelled { - FailureCategory::Canceled - } else { - FailureCategory::Deterministic - }; - - RunFailedProps { - failure: fabro_types::RunFailure { - reason, - detail: FailureDetail::new("run failed", category), - }, - timing: fabro_types::RunTiming::wall_only(42), - final_git_commit_sha: None, - final_patch: None, - diff_summary: None, - usage: None, - } - } - - fn retrying_props() -> StageRetryingProps { - StageRetryingProps { - index: 0, - attempt: 2, - max_attempts: 3, - delay_ms: 0, - } - } - - fn completed_props(duration_ms: u64, status: StageOutcome) -> StageCompletedProps { - StageCompletedProps { - index: 0, - timing: fabro_types::StageTiming::wall_only(duration_ms), - status, - preferred_label: None, - suggested_next_ids: Vec::new(), - usage_by_model: Vec::new(), - usage: None, - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: None, - restart_failure_signatures: None, - response: None, - attempt: 1, - max_attempts: 3, - } - } - - fn priced_usage() -> ModelUsage { - serde_json::from_value(json!({ - "model": { "provider": "openai", "model_id": "gpt-test" }, - "usage": { - "tokens": { - "input": 10, - "output": 5, - "reasoning": 2, - "cache_read": 3, - "cache_write": 4 - }, - "cost": { "usd_micros": 123, "source": "catalog" } - } - })) - .expect("usage fixture should deserialize") - } - - fn agent_body(event: CodingEvent) -> EventBody { - EventBody::Agent(AgentEventProps::new( - "code", - 1, - CodingAgentEvent::new("ses_test", event, SystemTime::UNIX_EPOCH), - )) - } - - fn assistant_message(input: u64, output: u64) -> CodingEvent { - CodingEvent::AssistantMessage { - text: "assistant text".to_string(), - model: priced_usage().model().model_id.to_string(), - usage: Usage::from(TokenCounts { - input, - output, - ..TokenCounts::default() - }), - tool_call_count: 0, - context_window: None, - reasoning: None, - } - } - - fn agent_message_body(input: u64, output: u64) -> EventBody { - agent_body(assistant_message(input, output)) - } - - fn activated(provider: &str, model: &str) -> EventBody { - EventBody::AgentSessionActivated(AgentSessionActivatedProps { - thread_id: None, - provider: Some(provider.to_string()), - model: Some(model.to_string()), - reasoning_effort: None, - speed: None, - permission_level: None, - capabilities: vec![fabro_types::SessionCapability::Steer], - visit: 1, - }) - } - - fn live_counts(input: u64, output: u64) -> Usage { - Usage::from(TokenCounts { - input, - output, - ..TokenCounts::default() - }) - } - - #[test] - fn stage_started_records_started_at_and_running_state() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event( - 3, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.state, StageState::Running); - assert!(stage.started_at.is_some()); - assert_eq!(stage.effective_state(), StageState::Running); - } - - #[test] - fn agent_message_accumulates_live_usage_on_stage_projection() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - let model = priced_usage().model().clone(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - activated(model.provider.as_str(), model.model_id.as_str()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - agent_message_body(10, 5), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 4, - agent_message_body(20, 7), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.usage, live_counts(30, 12)); - assert_eq!(stage.model, Some(model)); - } - - fn child_message_body(input: u64, output: u64) -> EventBody { - EventBody::Agent(AgentEventProps::new( - "code", - 1, - CodingAgentEvent::new( - "ses_child", - assistant_message(input, output), - SystemTime::UNIX_EPOCH, - ) - .with_parent_session_id("ses_test"), - )) - } - - /// One usage rule: a stage's usage is its session tree's, live and at - /// completion, cost included. lithos-llm prices each answer once, pebble - /// sums them, and the terminal usage is the same sum, so completion - /// changes neither the tokens nor the cost; it adds the split by model. - #[test] - fn stage_completed_keeps_the_trees_live_usage_and_its_cost() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - let model = priced_usage().model().clone(); - let catalog_priced = |input: u64, output: u64, usd_micros: u64| Usage { - tokens: TokenCounts { - input, - output, - ..TokenCounts::default() - }, - cost: Some(Cost { - usd_micros, - source: CostSource::Catalog, - }), - }; - let message = |session: &str, usage: Usage| { - let mut event = CodingAgentEvent::new( - session, - CodingEvent::AssistantMessage { - text: "assistant text".to_string(), - model: model.model_id.to_string(), - usage, - tool_call_count: 0, - context_window: None, - reasoning: None, - }, - SystemTime::UNIX_EPOCH, - ); - if session != "ses_test" { - event = event.with_parent_session_id("ses_test"); - } - EventBody::Agent(AgentEventProps::new("code", 1, event)) - }; - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - activated(model.provider.as_str(), model.model_id.as_str()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - message("ses_test", catalog_priced(100, 50, 300)), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 4, - message("ses_child", catalog_priced(7, 1, 21)), - stage_id.clone(), - )) - .unwrap(); - let live = state.stage(&stage_id).unwrap().usage; - assert_eq!( - live, - catalog_priced(107, 51, 321), - "the subagent's tokens and cost are the stage's too" - ); - - // The terminal usage is the same sum, under the root's route. - let tree = ModelUsage::new(model.clone(), live); - let mut props = completed_props(42, StageOutcome::Succeeded); - props.usage = Some(tree.clone()); - props.usage_by_model = vec![tree.clone()]; - state - .apply_event(&test_stage_event( - 5, - EventBody::StageCompleted(props), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!( - stage.usage, live, - "completion keeps the usage the fold showed, cost included" - ); - assert_eq!(stage.model.as_ref(), Some(&model)); - assert_eq!(stage.usage_by_model, vec![tree]); - } - - /// An answer nobody priced leaves the tree's cost unknown, live and at - /// completion alike; the tokens are still counted. - #[test] - fn an_unpriced_answer_leaves_the_stage_cost_unknown_live_and_at_completion() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - let model = priced_usage().model().clone(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - activated(model.provider.as_str(), model.model_id.as_str()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - agent_message_body(100, 50), - stage_id.clone(), - )) - .unwrap(); - let live = state.stage(&stage_id).unwrap().usage; - assert_eq!(live, live_counts(100, 50)); - assert_eq!(live.cost, None); - - let tree = ModelUsage::new(model.clone(), live); - let mut props = completed_props(42, StageOutcome::Succeeded); - props.usage = Some(tree.clone()); - props.usage_by_model = vec![tree]; - state - .apply_event(&test_stage_event( - 4, - EventBody::StageCompleted(props), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.usage, live); - assert_eq!( - stage.usage.cost, None, - "nothing priced it, so nothing invents a cost" - ); - } - - #[test] - fn live_usage_is_the_trees_with_compactions_and_the_reported_cost() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - let priced_message = |input: u64, output: u64, cost: u64| { - let CodingEvent::AssistantMessage { - text, - model, - usage, - tool_call_count, - context_window, - reasoning, - .. - } = assistant_message(input, output) - else { - unreachable!("assistant_message builds an assistant message") - }; - agent_body(CodingEvent::AssistantMessage { - text, - model, - usage: Usage { - tokens: usage.tokens, - cost: Some(Cost { - usd_micros: cost, - source: CostSource::Provider, - }), - }, - tool_call_count, - context_window, - reasoning, - }) - }; - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - priced_message(10, 5, 5), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - child_message_body(7, 1), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 4, - agent_body(CodingEvent::CompactionCompleted { - original_turn_count: 20, - preserved_turn_count: 6, - summary_token_estimate: 500, - tracked_file_count: 1, - reason: CompactionReason::Threshold, - usage: Usage { - tokens: TokenCounts { - input: 30, - ..TokenCounts::default() - }, - cost: Some(Cost { - usd_micros: 2, - source: CostSource::Provider, - }), - }, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!( - stage.usage.tokens, - live_counts(47, 6).tokens, - "the root's messages and compaction, and the child's message" - ); - assert_eq!( - stage.usage.cost, None, - "the child's unpriced message leaves the tree's cost unknown" - ); - } - - #[test] - fn stage_completed_without_usage_preserves_live_usage() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - let model = priced_usage().model().clone(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - activated(model.provider.as_str(), model.model_id.as_str()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - agent_message_body(10, 5), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 4, - EventBody::StageCompleted(completed_props(42, StageOutcome::Succeeded)), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.usage, live_counts(10, 5)); - assert_eq!(stage.model, Some(model)); - } - - #[test] - fn summary_size_tracks_current_projected_usage_before_terminal_conclusion() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - let usage = test_usage("gpt-5.2", 10_000_001, 10_000_000); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - let mut props = completed_props(42, StageOutcome::Succeeded); - props.usage = Some(usage); - state - .apply_event(&test_stage_event( - 2, - EventBody::StageCompleted(props), - stage_id, - )) - .unwrap(); - - let summary = build_summary(&state, &fixtures::RUN_1); - assert_eq!(summary.size, RunSize::S); - assert_eq!( - summary.usage.cost.map(|cost| cost.usd_micros), - Some(20_000_001) - ); - } - - #[test] - fn stage_failed_replaces_live_usage_with_terminal_usage() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - let usage = priced_usage(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - agent_message_body(100, 50), - stage_id.clone(), - )) - .unwrap(); - let mut props = failed_props(42, false); - props.usage = Some(usage.clone()); - state - .apply_event(&test_stage_event( - 3, - EventBody::StageFailed(props), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.usage, usage.usage); - assert_eq!(stage.model.as_ref(), Some(usage.model())); - } - - #[test] - fn stage_started_resets_live_usage_for_new_attempt() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - agent_message_body(10, 5), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.usage, Usage::default()); - assert_eq!(stage.model, None); - assert_eq!(stage.state, StageState::Running); - } - - #[test] - fn stage_completed_records_duration_usage_and_terminal_state() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - let usage = priced_usage(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - let mut props = completed_props(42, StageOutcome::Succeeded); - props.usage = Some(usage.clone()); - state - .apply_event(&test_event( - 2, - EventBody::StageCompleted(props), - Some("build"), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(42)); - assert_eq!(stage.usage, usage.usage); - assert_eq!(stage.model.as_ref(), Some(usage.model())); - assert_eq!(stage.state, StageState::Succeeded); - assert_eq!(stage.effective_state(), StageState::Succeeded); - } - - #[test] - fn stage_failed_records_duration_and_failed_state() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_event( - 2, - EventBody::StageFailed(failed_props(10, false)), - Some("build"), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(10)); - assert_eq!(stage.state, StageState::Failed); - } - - #[test] - fn stage_failed_canceled_without_retry_records_cancelled_state() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_event( - 2, - EventBody::StageFailed(canceled_failed_props(10, false)), - Some("build"), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(10)); - assert_eq!(stage.state, StageState::Cancelled); - } - - #[test] - fn exited_command_with_canceled_failure_category_records_failed_state() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - EventBody::CommandCompleted(CommandCompletedProps { - output: "blob://sha256/test".to_string(), - exit_code: Some(100), - duration_ms: 10, - termination: CommandTermination::Exited, - output_bytes: 42, - live_streaming: true, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_event( - 3, - EventBody::StageFailed(StageFailedProps { - index: 0, - failure: Some(FailureDetail::new( - "Script failed with exit code: 100\n\nCancelling due to test failure", - FailureCategory::Canceled, - )), - will_retry: false, - timing: fabro_types::StageTiming::wall_only(10), - usage_by_model: Vec::new(), - usage: None, - }), - Some("build"), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.termination, Some(CommandTermination::Exited)); - assert_eq!(stage.state, StageState::Failed); - } - - #[test] - fn run_failed_cancelled_finalizes_running_stage_as_cancelled() { - let mut state = running_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event_at( - 4, - "2026-04-07T12:00:00Z", - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 5, - "2026-04-07T12:00:05Z", - "run.failed", - &serde_json::to_value(run_failed_props(FailureReason::Cancelled)).unwrap(), - None, - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.state, StageState::Cancelled); - assert_eq!( - stage.timing, - Some(fabro_types::StageTiming::wall_only(5_000)) - ); - } - - #[test] - fn stage_started_records_execution_identity_metadata() { - let mut state = running_projection(); - let stage_id = StageId::new("work", 2); - - state - .apply_event(&test_stage_event( - 4, - EventBody::StageStarted(StageStartedProps { - graph_visit: Some(1), - resumed_from_stage_id: Some(StageId::new("work", 1)), - ..started_props() - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.graph_visit, Some(1)); - assert_eq!(stage.resumed_from_stage_id, Some(StageId::new("work", 1))); - } - - #[test] - fn retry_stage_started_preserves_execution_identity_metadata() { - let mut state = running_projection(); - let stage_id = StageId::new("work", 2); - - state - .apply_event(&test_stage_event( - 4, - EventBody::StageStarted(StageStartedProps { - graph_visit: Some(1), - resumed_from_stage_id: Some(StageId::new("work", 1)), - ..started_props() - }), - stage_id.clone(), - )) - .unwrap(); - // A malformed retry event for the same StageId cannot rewrite the - // first attempt's immutable execution identity. - state - .apply_event(&test_stage_event( - 5, - EventBody::StageStarted(StageStartedProps { - attempt: 2, - graph_visit: Some(99), - resumed_from_stage_id: Some(StageId::new("other", 7)), - ..started_props() - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.graph_visit, Some(1)); - assert_eq!(stage.resumed_from_stage_id, Some(StageId::new("work", 1))); - } - - #[test] - fn cancelled_execution_stays_immutable_when_resumed_execution_starts() { - let mut state = running_projection(); - let first = StageId::new("work", 1); - let second = StageId::new("work", 2); - - state - .apply_event(&test_stage_event_at( - 4, - "2026-04-07T12:00:00Z", - EventBody::StageStarted(started_props()), - first.clone(), - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 5, - "2026-04-07T12:00:05Z", - "run.failed", - &serde_json::to_value(run_failed_props(FailureReason::Cancelled)).unwrap(), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event( - 6, - "run.start_requested", - &json!({ "resume": true }), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event( - 7, - "run.runnable", - &json!({ "source": "start_requested" }), - None, - )) - .unwrap(); - state - .apply_event(&test_raw_event(8, "run.starting", &json!({}), None)) - .unwrap(); - state - .apply_event(&test_raw_event(9, "run.running", &json!({}), None)) - .unwrap(); - state - .apply_event(&test_stage_event( - 10, - EventBody::StageStarted(StageStartedProps { - graph_visit: Some(1), - resumed_from_stage_id: Some(first.clone()), - ..started_props() - }), - second.clone(), - )) - .unwrap(); - - // The cancelled execution keeps its terminal projection untouched... - let cancelled = state.stage(&first).unwrap(); - assert_eq!(cancelled.state, StageState::Cancelled); - assert_eq!( - cancelled.timing, - Some(fabro_types::StageTiming::wall_only(5_000)) - ); - // ...while the reexecution runs as a distinct stage linked back to it. - let resumed = state.stage(&second).unwrap(); - assert_eq!(resumed.state, StageState::Running); - assert_eq!(resumed.graph_visit, Some(1)); - assert_eq!(resumed.resumed_from_stage_id, Some(first)); - } - - #[test] - fn run_failed_after_resume_preserves_earlier_terminal_executions() { - let mut state = running_projection(); - let done = StageId::new("verify", 1); - let active = StageId::new("work", 2); - - state - .apply_event(&test_stage_event_at( - 4, - "2026-04-07T12:00:00Z", - EventBody::StageStarted(started_props()), - done.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event_at( - 5, - "2026-04-07T12:00:03Z", - EventBody::StageCompleted(completed_props(3_000, StageOutcome::Succeeded)), - done.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event_at( - 6, - "2026-04-07T12:00:04Z", - EventBody::StageStarted(started_props()), - active.clone(), - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 7, - "2026-04-07T12:00:09Z", - "run.failed", - &serde_json::to_value(run_failed_props(FailureReason::Cancelled)).unwrap(), - None, - )) - .unwrap(); - - let terminal = state.stage(&done).unwrap(); - assert_eq!(terminal.state, StageState::Succeeded); - assert_eq!( - terminal.timing, - Some(fabro_types::StageTiming::wall_only(3_000)) - ); - assert_eq!(state.stage(&active).unwrap().state, StageState::Cancelled); - } - - #[test] - fn checkpoint_completed_targets_envelope_stage_id_after_ordinal_divergence() { - let mut state = running_projection(); - let execution = StageId::new("work", 2); - - state - .apply_event(&test_stage_event( - 4, - EventBody::StageStarted(StageStartedProps { - graph_visit: Some(1), - ..started_props() - }), - execution.clone(), - )) - .unwrap(); - // Checkpointed graph state still says visit 1 for `work`, and carries - // a historical skipped outcome for another node. Neither may create - // or mutate a projection at a stale ordinal. - state - .apply_event(&test_stage_event( - 5, - EventBody::CheckpointCompleted(CheckpointCompletedProps { - graph_visit: Some(1), - resumed_from_stage_id: None, - status: "success".to_string(), - current_node: "work".to_string(), - completed_nodes: vec!["old_skip".to_string(), "work".to_string()], - node_retries: BTreeMap::new(), - context_values: BTreeMap::new(), - node_outcomes: BTreeMap::from([( - "old_skip".to_string(), - Outcome::skipped("historical skip"), - )]), - next_node_id: None, - git_commit_sha: None, - loop_failure_signatures: BTreeMap::new(), - restart_failure_signatures: BTreeMap::new(), - node_visits: BTreeMap::from([ - ("work".to_string(), 1usize), - ("old_skip".to_string(), 1usize), - ]), - diff: Some("diff for work@2".to_string()), - diff_summary: None, - }), - execution.clone(), - )) - .unwrap(); - - assert_eq!( - state.stage(&execution).unwrap().diff.as_deref(), - Some("diff for work@2") - ); - assert!(state.stage(&StageId::new("work", 1)).is_none()); - assert!(state.stage(&StageId::new("old_skip", 1)).is_none()); - } - - #[test] - fn skipped_checkpoint_with_stage_id_creates_synthetic_execution() { - let mut state = running_projection(); - let execution = StageId::new("gate", 3); - - // First-attempt StageStart-hook skip: no stage.started was emitted, - // the checkpoint is the first stage-scoped event for this execution. - state - .apply_event(&test_stage_event( - 4, - EventBody::CheckpointCompleted(CheckpointCompletedProps { - graph_visit: Some(2), - resumed_from_stage_id: Some(StageId::new("gate", 2)), - status: "skipped".to_string(), - current_node: "gate".to_string(), - completed_nodes: vec!["gate".to_string()], - node_retries: BTreeMap::new(), - context_values: BTreeMap::new(), - node_outcomes: BTreeMap::from([( - "gate".to_string(), - Outcome::skipped("skipped by StageStart hook"), - )]), - next_node_id: None, - git_commit_sha: None, - loop_failure_signatures: BTreeMap::new(), - restart_failure_signatures: BTreeMap::new(), - node_visits: BTreeMap::from([("gate".to_string(), 2usize)]), - diff: None, - diff_summary: None, - }), - execution.clone(), - )) - .unwrap(); - - let stage = state.stage(&execution).unwrap(); - assert_eq!(stage.state, StageState::Skipped); - assert_eq!(stage.graph_visit, Some(2)); - assert_eq!(stage.resumed_from_stage_id, Some(StageId::new("gate", 2))); - assert_eq!( - stage.completion.as_ref().unwrap().notes.as_deref(), - Some("skipped by StageStart hook") - ); - } - - #[test] - fn skipped_checkpoint_finalizes_existing_retrying_execution() { - let mut state = running_projection(); - let execution = StageId::new("gate", 1); - - state - .apply_event(&test_stage_event( - 4, - EventBody::StageStarted(started_props()), - execution.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 5, - EventBody::StageRetrying(StageRetryingProps { - index: 0, - attempt: 2, - max_attempts: 3, - delay_ms: 0, - }), - execution.clone(), - )) - .unwrap(); - assert_eq!(state.stage(&execution).unwrap().state, StageState::Retrying); - - // StageStart hook skipped the retry; the checkpoint finalizes the - // existing execution instead of allocating a new projection. - state - .apply_event(&test_stage_event( - 6, - EventBody::CheckpointCompleted(CheckpointCompletedProps { - graph_visit: Some(1), - resumed_from_stage_id: None, - status: "skipped".to_string(), - current_node: "gate".to_string(), - completed_nodes: vec!["gate".to_string()], - node_retries: BTreeMap::new(), - context_values: BTreeMap::new(), - node_outcomes: BTreeMap::from([( - "gate".to_string(), - Outcome::skipped("skipped on retry"), - )]), - next_node_id: None, - git_commit_sha: None, - loop_failure_signatures: BTreeMap::new(), - restart_failure_signatures: BTreeMap::new(), - node_visits: BTreeMap::from([("gate".to_string(), 1usize)]), - diff: None, - diff_summary: None, - }), - execution.clone(), - )) - .unwrap(); - - let stage = state.stage(&execution).unwrap(); - assert_eq!(stage.state, StageState::Skipped); - assert_eq!(stage.first_event_seq, first_event_seq(4)); - assert!(state.stage(&StageId::new("gate", 2)).is_none()); - } - - #[test] - fn skipped_checkpoint_never_reopens_an_older_terminal_execution() { - let mut state = running_projection(); - let execution = StageId::new("gate", 1); - - state - .apply_event(&test_stage_event( - 4, - EventBody::StageStarted(started_props()), - execution.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 5, - EventBody::StageCompleted(completed_props(2_000, StageOutcome::Succeeded)), - execution.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 6, - EventBody::CheckpointCompleted(CheckpointCompletedProps { - graph_visit: Some(1), - resumed_from_stage_id: None, - status: "skipped".to_string(), - current_node: "gate".to_string(), - completed_nodes: vec!["gate".to_string()], - node_retries: BTreeMap::new(), - context_values: BTreeMap::new(), - node_outcomes: BTreeMap::from([( - "gate".to_string(), - Outcome::skipped("late skip"), - )]), - next_node_id: None, - git_commit_sha: None, - loop_failure_signatures: BTreeMap::new(), - restart_failure_signatures: BTreeMap::new(), - node_visits: BTreeMap::from([("gate".to_string(), 1usize)]), - diff: None, - diff_summary: None, - }), - execution.clone(), - )) - .unwrap(); - - let stage = state.stage(&execution).unwrap(); - assert_eq!(stage.state, StageState::Succeeded); - assert_eq!( - stage.completion.as_ref().unwrap().outcome, - StageOutcome::Succeeded - ); - } - - #[test] - fn legacy_stage_started_payload_without_identity_fields_deserializes() { - let event = test_raw_event( - 4, - "stage.started", - &json!({ - "index": 0, - "handler_type": "agent", - "attempt": 1, - "max_attempts": 3 - }), - Some("work"), - ); - - let EventBody::StageStarted(props) = &event.event.body else { - panic!("expected stage.started body"); - }; - assert_eq!(props.graph_visit, None); - assert_eq!(props.resumed_from_stage_id, None); - } - - #[test] - fn run_failed_non_cancelled_finalizes_running_stage_as_failed() { - let mut state = running_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event_at( - 4, - "2026-04-07T12:00:00Z", - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 5, - "2026-04-07T12:00:05Z", - "run.failed", - &serde_json::to_value(run_failed_props(FailureReason::WorkflowError)).unwrap(), - None, - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.state, StageState::Failed); - assert_eq!( - stage.timing, - Some(fabro_types::StageTiming::wall_only(5_000)) - ); - } - - #[test] - fn run_failed_preserves_already_terminal_stage_projection() { - let mut state = running_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event_at( - 4, - "2026-04-07T12:00:00Z", - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 5, - EventBody::StageCompleted(completed_props(42, StageOutcome::Succeeded)), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_raw_event_at( - 6, - "2026-04-07T12:00:05Z", - "run.failed", - &serde_json::to_value(run_failed_props(FailureReason::Cancelled)).unwrap(), - None, - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.state, StageState::Succeeded); - assert_eq!(stage.timing, Some(fabro_types::StageTiming::wall_only(42))); - } - - #[test] - fn stage_retrying_sets_retrying_state() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_event( - 2, - EventBody::StageFailed(failed_props(10, true)), - Some("build"), - )) - .unwrap(); - state - .apply_event(&test_event( - 3, - EventBody::StageRetrying(retrying_props()), - Some("build"), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.state, StageState::Retrying); - } - - #[test] - fn stage_started_after_retrying_returns_to_running_and_resets_attempt_data() { - let mut state = initialized_projection(); - let stage_id = StageId::new("build", 1); - - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_event( - 2, - EventBody::StageFailed(failed_props(10, true)), - Some("build"), - )) - .unwrap(); - state - .apply_event(&test_event( - 3, - EventBody::StageRetrying(retrying_props()), - Some("build"), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 4, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.state, StageState::Running); - // Prior attempt's terminal data must not leak into the new attempt. - assert!(stage.completion.is_none()); - assert_eq!(stage.timing.map(|t| t.wall_time_ms), None); - } - - mod inference_bracket_reducer { - use fabro_types::{LlmOutputKind, LlmRetryPhase, StageInferenceProjection}; - - use super::*; - - const ROOT: &str = "ses_root"; - - fn stage_id() -> StageId { - StageId::new("code", 1) - } - - /// Stage-addressed event attributed to a root session. - fn root_event(seq: u32, body: EventBody) -> EventEnvelope { - let mut event = test_stage_event(seq, body, stage_id()); - event.event.session_id = Some(ROOT.to_string()); - event - } - - /// Forwarded child-session event: same stage, but with a parent link. - fn child_event(seq: u32, body: EventBody) -> EventEnvelope { - let mut event = test_stage_event(seq, body, stage_id()); - event.event.session_id = Some("ses_child".to_string()); - event.event.parent_session_id = Some(ROOT.to_string()); - event - } - - /// `agent.session.ended` as it is actually stored: session ids only, - /// no `node_id` and no `stage_id`. - fn session_ended_event(seq: u32, session_id: &str) -> EventEnvelope { - let mut event = test_event(seq, agent_body(CodingEvent::SessionEnded), None); - event.event.session_id = Some(session_id.to_string()); - event - } - - fn started() -> EventBody { - agent_body(CodingEvent::LlmRequestStarted { - requested_model: "claude-fable-5".to_string(), - }) - } - - fn first_output(kind: LlmOutputKind) -> EventBody { - agent_body(CodingEvent::LlmFirstOutput { kind }) - } - - fn retry(phase: LlmRetryPhase) -> EventBody { - agent_body(CodingEvent::LlmRetry { - provider: "anthropic".to_string(), - model: "claude-fable-5".to_string(), - attempt: 0, - delay_secs: 0.0, - error: ErrorData::new(ErrorKind::Llm, "stream"), - phase, - }) - } - - fn open_bracket(state: &RunProjection) -> Option<&StageInferenceProjection> { - state.stage(&stage_id()).unwrap().inference.as_ref() - } - - #[test] - fn started_opens_a_bracket_carrying_the_requested_model() { - let mut state = initialized_projection(); - state.apply_event(&root_event(1, started())).unwrap(); - - let inference = open_bracket(&state).expect("bracket should be open"); - assert_eq!(inference.session_id, ROOT); - assert_eq!(inference.requested_model, "claude-fable-5"); - assert_eq!(inference.first_output_at, None); - assert_eq!(inference.first_output_kind, None); - assert_eq!(inference.retries, 0); - } - - #[test] - fn first_output_records_the_observed_kind() { - let mut state = initialized_projection(); - state.apply_event(&root_event(1, started())).unwrap(); - state - .apply_event(&root_event(2, first_output(LlmOutputKind::ToolCall))) - .unwrap(); - - let inference = open_bracket(&state).unwrap(); - assert!(inference.first_output_at.is_some()); - assert_eq!(inference.first_output_kind, Some(LlmOutputKind::ToolCall)); - } - - #[test] - fn message_closes_the_bracket() { - let mut state = initialized_projection(); - state.apply_event(&root_event(1, started())).unwrap(); - state - .apply_event(&root_event(2, first_output(LlmOutputKind::Text))) - .unwrap(); - state - .apply_event(&root_event(3, agent_message_body(10, 5))) - .unwrap(); - - assert!(open_bracket(&state).is_none()); - // The close must not undo the rest of the message's work. - assert_eq!(state.stage(&stage_id()).unwrap().usage.tokens.input, 10); - } - - #[test] - fn error_and_round_interrupt_close_the_bracket() { - for close in [ - agent_body(CodingEvent::Error { - error: ErrorData::new(ErrorKind::Agent, "boom"), - }), - agent_body(CodingEvent::RoundInterrupted { generation: 1 }), - ] { - let mut state = initialized_projection(); - state.apply_event(&root_event(1, started())).unwrap(); - state.apply_event(&root_event(2, close)).unwrap(); - assert!(open_bracket(&state).is_none()); - } - } - - #[test] - fn retry_counts_the_attempt_and_clears_observed_output() { - let mut state = initialized_projection(); - state.apply_event(&root_event(1, started())).unwrap(); - state - .apply_event(&root_event(2, first_output(LlmOutputKind::Text))) - .unwrap(); - state - .apply_event(&root_event(3, retry(LlmRetryPhase::Consume))) - .unwrap(); - - // Replay is event-driven, so the projection must forget output the - // agent discarded rather than keep asserting it. - let inference = open_bracket(&state).unwrap(); - assert_eq!(inference.retries, 1); - assert_eq!(inference.first_output_at, None); - assert_eq!(inference.first_output_kind, None); - - state - .apply_event(&root_event(4, first_output(LlmOutputKind::Reasoning))) - .unwrap(); - state - .apply_event(&root_event(5, retry(LlmRetryPhase::Open))) - .unwrap(); - let inference = open_bracket(&state).unwrap(); - assert_eq!(inference.retries, 2); - assert_eq!(inference.first_output_kind, None); - } - - #[test] - fn session_ended_closes_a_bracket_it_cannot_address_by_stage() { - let mut state = initialized_projection(); - state.apply_event(&root_event(1, started())).unwrap(); - assert!(open_bracket(&state).is_some()); - - // Terminal cancel path: no message, error, or interrupt is ever - // emitted. The envelope carries no node_id or stage_id, so a - // normal stage lookup would silently no-op and leave the bracket - // open forever. - state.apply_event(&session_ended_event(2, ROOT)).unwrap(); - assert!(open_bracket(&state).is_none()); - } - - #[test] - fn session_ended_from_another_session_leaves_the_bracket_open() { - let mut state = initialized_projection(); - state.apply_event(&root_event(1, started())).unwrap(); - state - .apply_event(&session_ended_event(2, "ses_other")) - .unwrap(); - assert!(open_bracket(&state).is_some()); - } - - #[test] - fn a_start_queued_behind_deactivation_does_not_leave_a_phantom_bracket() { - let mut state = initialized_projection(); - - // `lease.release()` emits deactivation *before* the forwarder - // drains queued agent events, so a queued start legitimately - // arrives after it. Keying the close on deactivation would clear - // the bracket and then immediately re-open it. - state - .apply_event(&root_event( - 1, - EventBody::AgentSessionDeactivated(AgentSessionDeactivatedProps { visit: 1 }), - )) - .unwrap(); - state.apply_event(&root_event(2, started())).unwrap(); - state.apply_event(&session_ended_event(3, ROOT)).unwrap(); - - assert!(open_bracket(&state).is_none()); - } - - #[test] - fn child_session_events_do_not_touch_the_root_bracket() { - let mut state = initialized_projection(); - state.apply_event(&root_event(1, started())).unwrap(); - state - .apply_event(&root_event(2, first_output(LlmOutputKind::Text))) - .unwrap(); - - // A sub-agent runs its own rounds on the same stage. None of them - // may open, advance, or close the root session's bracket. - state.apply_event(&child_event(3, started())).unwrap(); - state - .apply_event(&child_event(4, first_output(LlmOutputKind::ToolCall))) - .unwrap(); - state - .apply_event(&child_event(5, retry(LlmRetryPhase::Open))) - .unwrap(); - state - .apply_event(&session_ended_event(6, "ses_child")) - .unwrap(); - - let inference = open_bracket(&state).expect("root bracket should survive"); - assert_eq!(inference.session_id, ROOT); - assert_eq!(inference.first_output_kind, Some(LlmOutputKind::Text)); - assert_eq!(inference.retries, 0); - } - - #[test] - fn transitions_without_an_open_bracket_are_ignored() { - let mut state = initialized_projection(); - state - .apply_event(&root_event(1, first_output(LlmOutputKind::Text))) - .unwrap(); - state - .apply_event(&root_event(2, retry(LlmRetryPhase::Open))) - .unwrap(); - assert!(open_bracket(&state).is_none()); - } - } -} diff --git a/lib/components/fabro-store/src/run_summary.rs b/lib/components/fabro-store/src/run_summary.rs new file mode 100644 index 000000000..7fbcd389e --- /dev/null +++ b/lib/components/fabro-store/src/run_summary.rs @@ -0,0 +1,143 @@ +//! The run summary (`Run`) a projection stands for: the row the run list, +//! the board and the scheduler read, derived from the projection. + +use fabro_types::{ + AskFabro, RepositoryRef, Run, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, + RunProjection, RunSize, RunTimestamps, WorkflowRef, sum_usage, +}; +use lithos_llm::types::Usage; + +/// The run summary (`Run`) a projection stands for: what the run list, the +/// board and the scheduler read. +#[must_use] +pub fn build_summary(state: &RunProjection, run_id: &RunId) -> Run { + let goal = state.spec.graph.goal().to_string(); + let diff_summary = state + .conclusion + .as_ref() + .and_then(|conclusion| conclusion.diff.summary) + .or_else(|| { + state + .checkpoints + .iter() + .rev() + .find_map(|checkpoint| checkpoint.diff.summary) + }); + + let current_question = state + .pending_interviews + .iter() + .min_by(|(left_id, left), (right_id, right)| { + left.started_at + .cmp(&right.started_at) + .then_with(|| left_id.cmp(right_id)) + }) + .map(|(_, record)| record.question.clone()); + let models = run_models(state); + let created_by = state.spec.provenance.subject.clone(); + let source_directory = state.spec.source_directory.clone(); + let repo_origin_url = state.spec.git.as_ref().map(|git| git.origin_url.clone()); + let start_time = state.start.as_ref().map(|start| start.start_time); + let completed_at = state + .conclusion + .as_ref() + .map(|conclusion| conclusion.timestamp); + let run_timing = state + .conclusion + .as_ref() + .map(|conclusion| conclusion.timing); + let usage = projected_usage(state); + + Run { + id: *run_id, + parent_id: state.parent_id, + children_count: 0, + title: state.title().into_owned(), + goal, + workflow: WorkflowRef { + slug: state.spec.workflow_slug.clone(), + name: state.spec.workflow_name().map(ToOwned::to_owned), + graph_name: state.spec.graph_name().map(ToOwned::to_owned), + node_count: i64::try_from(state.spec.graph.nodes.len()) + .expect("graph node count should fit in i64"), + edge_count: i64::try_from(state.spec.graph.edges.len()) + .expect("graph edge count should fit in i64"), + }, + automation: state.spec.automation.clone(), + repository: Some(RepositoryRef::from_origin_and_source( + repo_origin_url, + source_directory.as_deref(), + )), + created_by, + origin: RunOrigin::default(), + labels: state.spec.labels.clone(), + lifecycle: RunLifecycle { + status: state.status, + approval: state.approval.clone(), + pending_control: state.pending_control, + queue_position: None, + error: None, + archived: state.archived_at.is_some(), + archived_at: state.archived_at, + }, + sandbox: state.sandbox.clone(), + models, + source_directory, + timestamps: RunTimestamps { + created_at: run_id.created_at(), + started_at: start_time, + last_event_at: Some(state.last_event_at), + completed_at, + }, + timing: run_timing, + usage, + size: RunSize::from_cost(usage.cost), + ask_fabro: AskFabro::default(), + diff: diff_summary, + pull_request: state.pull_request.clone(), + current_question, + superseded_by: state.superseded_by, + retried_from: state.retried_from, + links: RunLinks { + web: state.web_url.clone(), + }, + } +} + +/// The run's usage: the conclusion's total once the run ended, else the sum +/// of every non-boundary stage's usage so far. +#[must_use] +pub fn projected_usage(state: &RunProjection) -> Usage { + if let Some(usage) = state + .conclusion + .as_ref() + .and_then(|conclusion| conclusion.usage) + { + return usage; + } + + sum_usage( + state + .iter_stages() + .filter(|(stage_id, _)| !state.is_boundary_stage(stage_id.node_id())) + .map(|(_, stage)| stage.usage), + ) +} + +fn run_models(state: &RunProjection) -> Vec { + let mut models = state + .iter_stages() + .filter_map(|(_, stage)| stage.model.as_ref()) + .map(|model| RunModel { + provider: Some(model.provider.to_string()), + name: model.model_id.to_string(), + }) + .collect::>(); + models.sort_by(|left, right| { + left.provider + .cmp(&right.provider) + .then_with(|| left.name.cmp(&right.name)) + }); + models.dedup_by(|left, right| left.provider == right.provider && left.name == right.name); + models +} diff --git a/lib/components/fabro-store/src/run_summary_store.rs b/lib/components/fabro-store/src/run_summary_store.rs index 193644871..0a55e2d9c 100644 --- a/lib/components/fabro-store/src/run_summary_store.rs +++ b/lib/components/fabro-store/src/run_summary_store.rs @@ -1,70 +1,20 @@ +//! The `runs` row: the summary the run list, the board and the scheduler +//! read, written by a Petri run's projector from its projection, beside +//! the platform records and the projection tables over the same pool. + use std::fmt::Write as _; use std::sync::{Arc, LazyLock, PoisonError, RwLock}; use chrono::{DateTime, Utc}; -use fabro_types::{ - EventEnvelope, Run, RunEvent, RunId, RunSize, RunStatusKind, RunTiming, SessionId, StageId, - timing, -}; -use sqlx::pool::PoolConnection; +use fabro_types::{Run, RunId, RunSize, RunStatusKind, RunTiming, timing}; use sqlx::query::Query; use sqlx::sqlite::{SqliteArguments, SqliteConnection, SqliteRow}; -use sqlx::{Connection as _, QueryBuilder, Row as _, Sqlite, SqlitePool, Transaction}; +use sqlx::{QueryBuilder, Row as _, Sqlite, SqlitePool}; use strum::VariantArray as _; -use crate::platform_records::{self, PlatformRecordHook, PlatformRecordStore}; -use crate::run_state::{ProjectedRun, build_summary, projected_usage}; -use crate::{Error, EventPayload, Result, RunProjection, keys}; - -const INSERT_RUN_SQL: &str = r" -INSERT INTO runs ( - id, source_last_seq, created_at_ms, started_at_ms, last_event_at_ms, completed_at_ms, - status, archived_at_ms, parent_id, title, workflow_slug, workflow_name, - repository_name, automation_id, diff_files_changed, diff_additions, diff_deletions, - input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cache_write_tokens, - total_usd_micros, summary_json -) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? -) -"; - -#[cfg(test)] -const UPSERT_RUN_SQL: &str = r" -INSERT INTO runs ( - id, source_last_seq, created_at_ms, started_at_ms, last_event_at_ms, completed_at_ms, - status, archived_at_ms, parent_id, title, workflow_slug, workflow_name, - repository_name, automation_id, diff_files_changed, diff_additions, diff_deletions, - input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cache_write_tokens, - total_usd_micros, summary_json -) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? -) -ON CONFLICT(id) DO UPDATE SET - source_last_seq = excluded.source_last_seq, - created_at_ms = excluded.created_at_ms, - started_at_ms = excluded.started_at_ms, - last_event_at_ms = excluded.last_event_at_ms, - completed_at_ms = excluded.completed_at_ms, - status = excluded.status, - archived_at_ms = excluded.archived_at_ms, - parent_id = excluded.parent_id, - title = excluded.title, - workflow_slug = excluded.workflow_slug, - workflow_name = excluded.workflow_name, - repository_name = excluded.repository_name, - automation_id = excluded.automation_id, - diff_files_changed = excluded.diff_files_changed, - diff_additions = excluded.diff_additions, - diff_deletions = excluded.diff_deletions, - input_tokens = excluded.input_tokens, - output_tokens = excluded.output_tokens, - reasoning_tokens = excluded.reasoning_tokens, - cache_read_tokens = excluded.cache_read_tokens, - cache_write_tokens = excluded.cache_write_tokens, - total_usd_micros = excluded.total_usd_micros, - summary_json = excluded.summary_json -WHERE excluded.source_last_seq > runs.source_last_seq -"; +use crate::platform_records::{PlatformRecordHook, PlatformRecordStore}; +use crate::run_summary::{build_summary, projected_usage}; +use crate::{Error, Result, RunProjection}; /// The `runs` row of a Petri run, written by its projector: every column the /// list views and the scheduler read, and never `source_last_seq`, which the @@ -98,42 +48,6 @@ ON CONFLICT(id) DO UPDATE SET summary_json = excluded.summary_json "; -const UPDATE_RUN_SQL: &str = r" -UPDATE runs SET - source_last_seq = ?, - created_at_ms = ?, - started_at_ms = ?, - last_event_at_ms = ?, - completed_at_ms = ?, - status = ?, - archived_at_ms = ?, - parent_id = ?, - title = ?, - workflow_slug = ?, - workflow_name = ?, - repository_name = ?, - automation_id = ?, - diff_files_changed = ?, - diff_additions = ?, - diff_deletions = ?, - input_tokens = ?, - output_tokens = ?, - reasoning_tokens = ?, - cache_read_tokens = ?, - cache_write_tokens = ?, - total_usd_micros = ?, - summary_json = ? -WHERE id = ? AND source_last_seq = ? -"; - -const SELECT_EVENT_COLUMNS: &str = - "SELECT run_id, seq, event_name, node_id, stage_id, session_id, event_json FROM run_events"; - -const INSERT_EVENT_SQL: &str = r" -INSERT INTO run_events (run_id, seq, event_name, node_id, stage_id, session_id, event_json) -VALUES (?, ?, ?, ?, ?, ?, ?) -"; - const SELECT_RUN_SUMMARIES_SQL: &str = r" SELECT runs.id, runs.summary_json, (SELECT COUNT(*) FROM runs AS child WHERE child.parent_id = runs.id) AS children_count @@ -218,8 +132,8 @@ pub struct RunSummaryPage { #[derive(Clone)] pub struct RunSummaryStore { pool: SqlitePool, - /// Called after a platform record for a Petri run is committed beside - /// its legacy event: the projector's wake-up. + /// Called after a platform record of a run is committed: the + /// projector's wake-up. platform_hook: Arc>>, } @@ -238,9 +152,9 @@ impl RunSummaryStore { } } - /// The pool this store's tables live in: the `runs` row, the run events, - /// the platform records and the Petri projection tables. The server's - /// one database; a test fixture's own. + /// The pool this store's tables live in: the `runs` row, the platform + /// records and the Petri projection tables. The server's one database; + /// a test fixture's own. #[must_use] pub fn pool(&self) -> SqlitePool { self.pool.clone() @@ -252,8 +166,8 @@ impl RunSummaryStore { PlatformRecordStore::new(self.pool.clone()) } - /// Install the wake-up called after a platform record of a Petri run is - /// committed beside its legacy event. + /// Install the wake-up called after a platform record of a run is + /// committed. pub fn set_platform_record_hook(&self, hook: PlatformRecordHook) { *self .platform_hook @@ -273,7 +187,7 @@ impl RunSummaryStore { } } - /// The stored projection of a Petri run, as the run's projector last + /// The stored projection of a run, as the run's projector last /// committed it, or `None` when no view pass has run for it yet. pub async fn load_petri_projection( &self, @@ -288,19 +202,15 @@ impl RunSummaryStore { .transpose() } - /// Write the `runs` row of a Petri run from its projection, on a - /// connection the caller holds a transaction on: the columns the list - /// views and the scheduler read, and the summary JSON. The legacy - /// concurrency guard `source_last_seq` is left as the legacy path set it - /// (or `1` when this write creates the row), so both writers keep - /// working until the legacy events go. + /// Write the `runs` row of a run from its projection, on a connection + /// the caller holds a transaction on: the columns the list views and + /// the scheduler read, and the summary JSON. pub async fn write_petri_run_row_on_connection( connection: &mut SqliteConnection, run_id: &RunId, projection: &RunProjection, ) -> Result<()> { - let entry = ProjectedRun::new(*run_id, Arc::new(projection.clone()), 1); - let record = PreparedRunSummary::from_entry(&entry); + let record = PreparedRunSummary::from_projection(run_id, projection); bind_run_columns( sqlx::query(UPSERT_PETRI_RUN_SQL).bind(run_id.to_string()), &record, @@ -310,59 +220,6 @@ impl RunSummaryStore { Ok(()) } - #[cfg(test)] - pub(crate) async fn close_pool(&self) { - self.pool.close().await; - } - - /// Corrupts one fixture history while preserving its current row so - /// cross-crate repair-endpoint tests can exercise unreadable SQL runs. - #[cfg(any(test, feature = "test-support"))] - pub async fn test_delete_run_events(&self, run_id: &RunId) -> Result<()> { - sqlx::query("DELETE FROM run_events WHERE run_id = ?") - .bind(run_id.to_string()) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Inserts a fixture event without reducing it into the current row. - /// - /// This deliberately creates an unreadable history for cross-crate repair - /// endpoint tests. It is never linked into production builds. - #[cfg(any(test, feature = "test-support"))] - pub async fn test_insert_unvalidated_event( - &self, - run_id: &RunId, - seq: u32, - payload: &serde_json::Value, - ) -> Result<()> { - let payload = EventPayload::new(payload.clone(), run_id)?; - let event = RunEvent::try_from(&payload)?; - let envelope = EventEnvelope { seq, event }; - let event_json = serde_json::to_string(&payload)?; - let mut transaction = self.pool.begin().await?; - insert_event_json_on_connection(&mut transaction, run_id, &envelope, &event_json).await?; - let updated = sqlx::query("UPDATE runs SET source_last_seq = ? WHERE id = ?") - .bind(i64::from(seq)) - .bind(run_id.to_string()) - .execute(&mut *transaction) - .await?; - if updated.rows_affected() != 1 { - return Err(Error::RunNotFound(run_id.to_string())); - } - transaction.commit().await?; - Ok(()) - } - - pub(crate) async fn acquire(&self) -> Result> { - Ok(self.pool.acquire().await?) - } - - pub(crate) async fn begin(&self) -> Result> { - Ok(self.pool.begin().await?) - } - /// Whether a run with `run_id` is stored. pub async fn contains(&self, run_id: &RunId) -> Result { Ok( @@ -373,154 +230,6 @@ impl RunSummaryStore { ) } - #[cfg(test)] - pub(crate) async fn upsert_projection(&self, entry: &ProjectedRun) -> Result<()> { - let record = PreparedRunSummary::from_entry(entry); - let mut connection = self.pool.acquire().await?; - upsert_run_on_connection(&mut connection, &record).await - } - - #[cfg(test)] - pub(crate) async fn reconcile(&self, entries: &[ProjectedRun]) -> Result<()> { - use std::collections::{HashMap, HashSet}; - - let mut transaction = self.pool.begin().await?; - let stored_seqs: HashMap = - sqlx::query_as::<_, (String, i64)>("SELECT id, source_last_seq FROM runs") - .fetch_all(&mut *transaction) - .await? - .into_iter() - .collect(); - - let mut authoritative_ids = HashSet::new(); - for entry in entries { - let run_id = entry.run_id.to_string(); - let up_to_date = stored_seqs - .get(&run_id) - .is_some_and(|stored_seq| *stored_seq >= i64::from(entry.last_seq)); - authoritative_ids.insert(run_id); - if up_to_date { - continue; - } - upsert_run_on_connection(&mut transaction, &PreparedRunSummary::from_entry(entry)) - .await?; - } - - let stale_ids = stored_seqs - .keys() - .filter(|stored_id| !authoritative_ids.contains(stored_id.as_str())) - .collect::>(); - for chunk in stale_ids.chunks(500) { - let mut delete = QueryBuilder::::new("DELETE FROM runs WHERE id IN ("); - let mut separated = delete.separated(", "); - for stale_id in chunk { - separated.push_bind(stale_id.as_str()); - } - delete.push(")"); - delete.build().execute(&mut *transaction).await?; - } - transaction.commit().await?; - Ok(()) - } - - pub(crate) async fn list_run_ids(&self) -> Result> { - sqlx::query_scalar::<_, String>("SELECT id FROM runs ORDER BY id ASC") - .fetch_all(&self.pool) - .await? - .into_iter() - .map(parse_stored_run_id) - .collect() - } - - pub(crate) async fn head(&self, run_id: &RunId) -> Result> { - let mut connection = self.acquire().await?; - select_run_head(&mut connection, run_id).await - } - - /// Replays one run's canonical history from one validated SQLite snapshot. - /// Fails with `RunNotFound` when the run does not exist. - pub(crate) async fn load_projection(&self, run_id: &RunId) -> Result { - let events = self.list_events_for_run(run_id).await?; - ProjectedRun::replay(*run_id, &events) - } - - pub(crate) async fn list_events_for_run(&self, run_id: &RunId) -> Result> { - let mut connection = self.acquire().await?; - Self::list_events_on_connection(&mut connection, run_id).await - } - - pub(crate) async fn list_events_from_with_limit( - &self, - run_id: &RunId, - start_seq: u32, - limit: usize, - ) -> Result> { - let mut connection = self.acquire().await?; - Self::list_events_from_with_limit_on_connection(&mut connection, run_id, start_seq, limit) - .await - } - - pub(crate) async fn list_events_before_with_limit( - &self, - run_id: &RunId, - before_seq: Option, - limit: usize, - ) -> Result> { - let mut connection = self.acquire().await?; - Self::list_events_before_with_limit_on_connection( - &mut connection, - run_id, - before_seq, - limit, - ) - .await - } - - pub(crate) async fn get_event_for_run( - &self, - run_id: &RunId, - seq: u32, - ) -> Result> { - let mut connection = self.acquire().await?; - Self::get_event_on_connection(&mut connection, run_id, seq).await - } - - pub(crate) async fn list_events_for_stage_from_with_limit( - &self, - run_id: &RunId, - stage_id: &StageId, - start_seq: u32, - limit: usize, - ) -> Result> { - let mut connection = self.acquire().await?; - Self::list_events_for_stage_from_with_limit_on_connection( - &mut connection, - run_id, - stage_id, - start_seq, - limit, - ) - .await - } - - pub(crate) async fn list_events_for_session_from_with_limit( - &self, - run_id: &RunId, - session_id: &SessionId, - start_seq: u32, - limit: usize, - ) -> Result> { - let mut connection = self.acquire().await?; - Self::list_events_for_session_from_with_limit_on_connection( - &mut connection, - run_id, - session_id, - start_seq, - limit, - ) - .await - } - pub(crate) async fn delete_canonical(&self, run_id: &RunId) -> Result<()> { // Reserve the write lock up front: a deferred transaction upgraded // while another writer is active can fail at once, bypassing the @@ -583,33 +292,33 @@ impl RunSummaryStore { decode_run_rows(&rows, now) } - /// Run ids whose latest explicit pull request creation request has no - /// later event that would resolve it. This mirrors the projection reducer: - /// a newer request supersedes the old one; `created`, `linked`, and - /// `unlinked` resolve any pending request; `failed` resolves only the - /// request whose creation id it names. Callers still replay each candidate - /// to confirm, so this must never omit a genuinely pending run, but it - /// keeps the replayed set bounded by in-flight requests rather than by - /// every run that ever asked for a pull request. + /// Run ids whose latest pull request creation request has no later + /// record that resolves it. A newer request supersedes the old one; + /// `created`, `linked` and `unlinked` resolve any pending request; + /// `failed` resolves only the request whose creation id it names. + /// Callers still read each candidate's projection to confirm, so this + /// must never omit a genuinely pending run, but it keeps the set + /// bounded by in-flight requests rather than by every run that ever + /// asked for a pull request. pub async fn list_pull_request_creation_candidate_run_ids(&self) -> Result> { sqlx::query_scalar::<_, String>( - "SELECT DISTINCT requested.run_id FROM run_events AS requested \ - WHERE requested.event_name = 'pull_request.creation_requested' \ + "SELECT DISTINCT requested.run_id FROM platform_records AS requested \ + WHERE requested.kind = 'pull_request.requested' \ AND NOT EXISTS ( \ - SELECT 1 FROM run_events AS later \ + SELECT 1 FROM platform_records AS later \ WHERE later.run_id = requested.run_id \ AND later.seq > requested.seq \ AND ( \ - later.event_name IN ( \ - 'pull_request.creation_requested', \ + later.kind IN ( \ + 'pull_request.requested', \ 'pull_request.created', \ 'pull_request.linked', \ 'pull_request.unlinked' \ ) \ OR ( \ - later.event_name = 'pull_request.failed' \ - AND json_extract(later.event_json, '$.properties.creation_id') \ - = json_extract(requested.event_json, '$.properties.creation_id') \ + later.kind = 'pull_request.failed' \ + AND json_extract(later.record_json, '$.creation_id') \ + = json_extract(requested.record_json, '$.creation_id') \ ) \ ) \ )", @@ -682,216 +391,6 @@ FROM runs", } } -impl RunSummaryStore { - pub(crate) async fn insert_first_event_on_connection( - connection: &mut SqliteConnection, - entry: &ProjectedRun, - payload: &EventPayload, - ) -> Result { - let record = PreparedRunSummary::from_entry(entry); - ensure_entry_identity(entry, &record, 1)?; - ensure_prepared_head(&record, 1)?; - let envelope = validate_event_for_record(&record, payload, 1)?; - if envelope.event.event_name() != "run.created" { - return Err(run_event_mismatch(&record.run.id, 1, "event_name")); - } - - insert_run_on_connection(connection, &record).await?; - insert_event_on_connection(connection, &record, payload, &envelope).await?; - insert_platform_record_on_connection(connection, entry, &envelope).await?; - Ok(envelope) - } - - pub(crate) async fn append_event_on_connection( - connection: &mut SqliteConnection, - expected_last_seq: u32, - entry: &ProjectedRun, - payload: &EventPayload, - ) -> Result { - let next_seq = next_event_seq_after(expected_last_seq)?; - let record = PreparedRunSummary::from_entry(entry); - ensure_entry_identity(entry, &record, next_seq)?; - ensure_prepared_head(&record, next_seq)?; - let envelope = validate_event_for_record(&record, payload, next_seq)?; - - update_run_on_connection(connection, &record, expected_last_seq).await?; - insert_event_on_connection(connection, &record, payload, &envelope).await?; - insert_platform_record_on_connection(connection, entry, &envelope).await?; - Ok(envelope) - } - - pub(crate) async fn list_events_on_connection( - connection: &mut SqliteConnection, - run_id: &RunId, - ) -> Result> { - Ok( - Self::list_events_with_json_on_connection(connection, run_id) - .await? - .into_iter() - .map(|(envelope, _event_json)| envelope) - .collect(), - ) - } - - pub(crate) async fn list_events_with_json_on_connection( - connection: &mut SqliteConnection, - run_id: &RunId, - ) -> Result> { - // The current-row head and event rows must come from one snapshot. - // Otherwise a concurrent append between the two SELECTs looks like - // durable corruption even though both versions are individually valid. - let mut transaction = connection.begin().await?; - let events = Self::list_events_with_json_in_transaction(&mut transaction, run_id).await?; - transaction.commit().await?; - Ok(events) - } - - pub(crate) async fn list_events_with_json_in_transaction( - transaction: &mut Transaction<'_, Sqlite>, - run_id: &RunId, - ) -> Result> { - Self::list_events_with_json_in_snapshot(&mut *transaction, run_id).await - } - - async fn list_events_with_json_in_snapshot( - connection: &mut SqliteConnection, - run_id: &RunId, - ) -> Result> { - let mut query = QueryBuilder::::new(SELECT_EVENT_COLUMNS); - query - .push(" WHERE run_id = ") - .push_bind(run_id.to_string()) - .push(" ORDER BY seq ASC"); - let expected_last_seq = select_run_head(&mut *connection, run_id) - .await? - .ok_or_else(|| Error::RunNotFound(run_id.to_string()))?; - let rows = query.build().fetch_all(&mut *connection).await?; - let actual_last_seq = rows - .last() - .map(|row| row.try_get::("seq")) - .transpose()? - .and_then(stored_seq); - if actual_last_seq != Some(expected_last_seq) { - return Err(Error::RunHeadMismatch { - run_id: run_id.to_string(), - expected_last_seq, - actual_last_seq, - }); - } - decode_event_rows_with_json(&rows, run_id) - } - - pub(crate) async fn list_events_from_with_limit_on_connection( - connection: &mut SqliteConnection, - run_id: &RunId, - start_seq: u32, - limit: usize, - ) -> Result> { - let mut query = QueryBuilder::::new(SELECT_EVENT_COLUMNS); - query - .push(" WHERE run_id = ") - .push_bind(run_id.to_string()) - .push(" AND seq >= ") - .push_bind(i64::from(start_seq)) - .push(" ORDER BY seq ASC LIMIT ") - .push_bind(sql_limit(limit)); - let rows = query.build().fetch_all(&mut *connection).await?; - decode_event_rows(&rows, run_id) - } - - pub(crate) async fn list_events_before_with_limit_on_connection( - connection: &mut SqliteConnection, - run_id: &RunId, - before_seq: Option, - limit: usize, - ) -> Result> { - let mut query = QueryBuilder::::new(SELECT_EVENT_COLUMNS); - query.push(" WHERE run_id = ").push_bind(run_id.to_string()); - if let Some(before_seq) = before_seq { - query.push(" AND seq < ").push_bind(i64::from(before_seq)); - } - query - .push(" ORDER BY seq DESC LIMIT ") - .push_bind(sql_limit(limit)); - let rows = query.build().fetch_all(&mut *connection).await?; - decode_event_rows(&rows, run_id) - } - - pub(crate) async fn get_event_on_connection( - connection: &mut SqliteConnection, - run_id: &RunId, - seq: u32, - ) -> Result> { - let mut query = QueryBuilder::::new(SELECT_EVENT_COLUMNS); - query - .push(" WHERE run_id = ") - .push_bind(run_id.to_string()) - .push(" AND seq = ") - .push_bind(i64::from(seq)); - let row = query.build().fetch_optional(&mut *connection).await?; - row.as_ref() - .map(|row| decode_event_row(row, run_id, &run_id.to_string())) - .transpose() - } - - pub(crate) async fn list_events_for_stage_from_with_limit_on_connection( - connection: &mut SqliteConnection, - run_id: &RunId, - stage_id: &StageId, - start_seq: u32, - limit: usize, - ) -> Result> { - // Legacy rows for a first visit carry only `node_id`. Query them as a - // second `UNION ALL` arm instead of an `OR` so each arm can use its - // own partial index rather than scanning the run's primary key range. - let mut query = QueryBuilder::::new(SELECT_EVENT_COLUMNS); - query - .push(" WHERE run_id = ") - .push_bind(run_id.to_string()) - .push(" AND seq >= ") - .push_bind(i64::from(start_seq)) - .push(" AND stage_id = ") - .push_bind(stage_id.to_string()); - if stage_id.visit() == 1 { - query - .push(" UNION ALL ") - .push(SELECT_EVENT_COLUMNS) - .push(" WHERE run_id = ") - .push_bind(run_id.to_string()) - .push(" AND seq >= ") - .push_bind(i64::from(start_seq)) - .push(" AND stage_id IS NULL AND node_id = ") - .push_bind(stage_id.node_id().to_string()); - } - query - .push(" ORDER BY seq ASC LIMIT ") - .push_bind(sql_limit(limit)); - let rows = query.build().fetch_all(&mut *connection).await?; - decode_event_rows(&rows, run_id) - } - - pub(crate) async fn list_events_for_session_from_with_limit_on_connection( - connection: &mut SqliteConnection, - run_id: &RunId, - session_id: &SessionId, - start_seq: u32, - limit: usize, - ) -> Result> { - let mut query = QueryBuilder::::new(SELECT_EVENT_COLUMNS); - query - .push(" WHERE run_id = ") - .push_bind(run_id.to_string()) - .push(" AND seq >= ") - .push_bind(i64::from(start_seq)) - .push(" AND session_id = ") - .push_bind(session_id.to_string()) - .push(" AND event_name GLOB 'run.session.*' ORDER BY seq ASC LIMIT ") - .push_bind(sql_limit(limit)); - let rows = query.build().fetch_all(&mut *connection).await?; - decode_event_rows(&rows, run_id) - } -} - /// Identity fields of a stored run summary, cheap to list for selector /// resolution. #[derive(Debug, Clone)] @@ -905,7 +404,6 @@ pub struct RunSummaryIdentity { #[derive(Debug)] struct PreparedRunSummary { run: Run, - last_seq: u32, workflow_name: Option, repository_name: Option, input_tokens: i64, @@ -917,16 +415,16 @@ struct PreparedRunSummary { } impl PreparedRunSummary { - fn from_entry(entry: &ProjectedRun) -> Self { - let mut run = build_summary(&entry.projection, &entry.run_id); + fn from_projection(run_id: &RunId, projection: &RunProjection) -> Self { + let mut run = build_summary(projection, run_id); if run.timing.is_none() { let at = run .timestamps .last_event_at .unwrap_or(run.timestamps.created_at); - run.timing = entry.projection.live_run_timing(at); + run.timing = projection.live_run_timing(at); } - let usage = projected_usage(&entry.projection); + let usage = projected_usage(projection); let workflow_name = run.workflow.display_name().map(str::to_string); let repository_name = run .repository @@ -935,7 +433,6 @@ impl PreparedRunSummary { Self { run, - last_seq: entry.last_seq, workflow_name, repository_name, input_tokens: column_count(usage.tokens.input), @@ -948,255 +445,12 @@ impl PreparedRunSummary { } } -fn ensure_entry_identity( - entry: &ProjectedRun, - record: &PreparedRunSummary, - seq: u32, -) -> Result<()> { - if entry.run_id != record.run.id || entry.projection.spec.run_id != entry.run_id { - return Err(run_event_mismatch(&entry.run_id, seq, "run_id")); - } - Ok(()) -} - -fn ensure_prepared_head(record: &PreparedRunSummary, expected_last_seq: u32) -> Result<()> { - if record.last_seq != expected_last_seq { - return Err(Error::RunHeadMismatch { - run_id: record.run.id.to_string(), - expected_last_seq, - actual_last_seq: Some(record.last_seq), - }); - } - Ok(()) -} - -fn validate_event_for_record( - record: &PreparedRunSummary, - payload: &EventPayload, - seq: u32, -) -> Result { - payload.validate(&record.run.id)?; - let event = RunEvent::try_from(payload)?; - if event.run_id != record.run.id { - return Err(run_event_mismatch(&record.run.id, seq, "run_id")); - } - Ok(EventEnvelope { seq, event }) -} - -fn run_event_mismatch(run_id: &RunId, seq: u32, field: &'static str) -> Error { - Error::RunEventMismatch { - run_id: run_id.to_string(), - seq, - field, - } -} - -async fn insert_event_on_connection( - connection: &mut SqliteConnection, - record: &PreparedRunSummary, - payload: &EventPayload, - envelope: &EventEnvelope, -) -> Result<()> { - let event_json = serde_json::to_string(payload)?; - insert_event_json_on_connection(connection, &record.run.id, envelope, &event_json).await -} - -async fn insert_event_json_on_connection( - connection: &mut SqliteConnection, - run_id: &RunId, - envelope: &EventEnvelope, - event_json: &str, -) -> Result<()> { - sqlx::query(INSERT_EVENT_SQL) - .bind(run_id.to_string()) - .bind(i64::from(envelope.seq)) - .bind(envelope.event.event_name()) - .bind(envelope.event.node_id.as_deref()) - .bind(envelope.event.stage_id.as_ref().map(ToString::to_string)) - .bind(envelope.event.session_id.as_deref()) - .bind(event_json) - .execute(connection) - .await?; - Ok(()) -} - -/// For a Petri run, the platform record the legacy event stands for, stored -/// in the event's transaction so the projection over Petri's records reads -/// the lifecycle from platform records alone. Whether one was written is -/// what [`platform_record_written`] answers after the commit. -async fn insert_platform_record_on_connection( - connection: &mut SqliteConnection, - entry: &ProjectedRun, - envelope: &EventEnvelope, -) -> Result<()> { - let Some(record) = platform_record_written(entry, envelope) else { - return Ok(()); - }; - let recorded_at = u64::try_from(envelope.event.ts.timestamp_millis()).unwrap_or(0); - PlatformRecordStore::append_on_connection( - connection, - &entry.run_id, - recorded_at, - &record, - None, - ) - .await?; - Ok(()) -} - -/// The platform record a committed legacy event of a Petri run produced, -/// if any: the same derivation the insert makes, for the caller that -/// notifies after the commit. -pub(crate) fn platform_record_written( - entry: &ProjectedRun, - envelope: &EventEnvelope, -) -> Option { - let _ = entry; - platform_records::platform_record_for(&envelope.event) -} - -fn sql_limit(limit: usize) -> i64 { - i64::try_from(limit.saturating_add(1)).unwrap_or(i64::MAX) -} - -pub(crate) fn next_event_seq_after(last_seq: u32) -> Result { - last_seq - .checked_add(1) - .filter(|seq| *seq <= keys::MAX_EVENT_SEQ) - .ok_or(Error::EventSequenceExhausted { - max_seq: keys::MAX_EVENT_SEQ, - }) -} - -/// Decodes a stored sequence column, rejecting anything outside the valid -/// `1..=MAX_EVENT_SEQ` range. -fn stored_seq(value: i64) -> Option { - u32::try_from(value) - .ok() - .filter(|seq| (1..=keys::MAX_EVENT_SEQ).contains(seq)) -} - -fn decode_event_rows(rows: &[SqliteRow], run_id: &RunId) -> Result> { - let run_id_text = run_id.to_string(); - rows.iter() - .map(|row| decode_event_row(row, run_id, &run_id_text)) - .collect() -} - -fn decode_event_rows_with_json( - rows: &[SqliteRow], - run_id: &RunId, -) -> Result> { - let run_id_text = run_id.to_string(); - rows.iter() - .map(|row| decode_event_row_with_json(row, run_id, &run_id_text)) - .collect() -} - -fn decode_event_row( - row: &SqliteRow, - expected_run_id: &RunId, - expected_run_id_text: &str, -) -> Result { - decode_event_row_with_json(row, expected_run_id, expected_run_id_text) - .map(|(envelope, _event_json)| envelope) -} - -/// Decodes one event row and returns the raw `event_json` alongside it so -/// callers that need both do not fetch the column twice. -fn decode_event_row_with_json( - row: &SqliteRow, - expected_run_id: &RunId, - expected_run_id_text: &str, -) -> Result<(EventEnvelope, String)> { - let stored_run_id: String = row.try_get("run_id")?; - let raw_seq: i64 = row.try_get("seq")?; - let seq = stored_seq(raw_seq).ok_or_else(|| run_event_mismatch(expected_run_id, 0, "seq"))?; - if stored_run_id != expected_run_id_text { - return Err(run_event_mismatch(expected_run_id, seq, "run_id")); - } - - let event_json: String = row.try_get("event_json")?; - let payload: EventPayload = serde_json::from_str(&event_json)?; - if payload - .as_value() - .get("run_id") - .and_then(serde_json::Value::as_str) - != Some(expected_run_id_text) - { - return Err(run_event_mismatch(expected_run_id, seq, "run_id")); - } - payload.validate(expected_run_id)?; - let event = RunEvent::try_from(&payload)?; - if event.run_id != *expected_run_id { - return Err(run_event_mismatch(expected_run_id, seq, "run_id")); - } - - let stored_event_name: String = row.try_get("event_name")?; - if stored_event_name != event.event_name() { - return Err(run_event_mismatch(expected_run_id, seq, "event_name")); - } - let stored_node_id: Option = row.try_get("node_id")?; - if stored_node_id.as_deref() != event.node_id.as_deref() { - return Err(run_event_mismatch(expected_run_id, seq, "node_id")); - } - let stored_stage_id: Option = row.try_get("stage_id")?; - let decoded_stage_id = event.stage_id.as_ref().map(ToString::to_string); - if stored_stage_id != decoded_stage_id { - return Err(run_event_mismatch(expected_run_id, seq, "stage_id")); - } - let stored_session_id: Option = row.try_get("session_id")?; - if stored_session_id.as_deref() != event.session_id.as_deref() { - return Err(run_event_mismatch(expected_run_id, seq, "session_id")); - } - - Ok((EventEnvelope { seq, event }, event_json)) -} - -async fn select_run_head(connection: &mut SqliteConnection, run_id: &RunId) -> Result> { - let stored: Option = sqlx::query_scalar("SELECT source_last_seq FROM runs WHERE id = ?") - .bind(run_id.to_string()) - .fetch_optional(connection) - .await?; - stored - .map(|value| { - stored_seq(value).ok_or_else(|| run_event_mismatch(run_id, 0, "source_last_seq")) - }) - .transpose() -} - /// A usage count as the SQLite read model stores it: the columns are signed, /// so a count past `i64::MAX` saturates rather than wrapping negative. fn column_count(count: u64) -> i64 { i64::try_from(count).unwrap_or(i64::MAX) } -#[cfg(test)] -async fn upsert_run_on_connection( - connection: &mut SqliteConnection, - record: &PreparedRunSummary, -) -> Result<()> { - write_insert_shaped_run(connection, record, UPSERT_RUN_SQL).await -} - -async fn insert_run_on_connection( - connection: &mut SqliteConnection, - record: &PreparedRunSummary, -) -> Result<()> { - write_insert_shaped_run(connection, record, INSERT_RUN_SQL).await -} - -async fn write_insert_shaped_run( - connection: &mut SqliteConnection, - record: &PreparedRunSummary, - sql: &'static str, -) -> Result<()> { - bind_run_columns(sqlx::query(sql).bind(record.run.id.to_string()), record)? - .execute(connection) - .await?; - Ok(()) -} - /// Binds the `runs` columns shared by the insert, upsert, and update /// statements, in the positional order those statements declare them /// (`source_last_seq` through `summary_json`). @@ -1208,7 +462,9 @@ fn bind_run_columns<'q>( let diff = run.diff.unwrap_or_default(); let summary_json = serde_json::to_string(run)?; Ok(query - .bind(i64::from(record.last_seq)) + // `source_last_seq`: a column the legacy event log owned; `1` until + // the migration that drops it. + .bind(1_i64) .bind(run.timestamps.created_at.timestamp_millis()) .bind( run.timestamps @@ -1250,27 +506,6 @@ fn bind_run_columns<'q>( .bind(summary_json)) } -async fn update_run_on_connection( - connection: &mut SqliteConnection, - record: &PreparedRunSummary, - expected_last_seq: u32, -) -> Result<()> { - let run = &record.run; - let result = bind_run_columns(sqlx::query(UPDATE_RUN_SQL), record)? - .bind(run.id.to_string()) - .bind(i64::from(expected_last_seq)) - .execute(&mut *connection) - .await?; - if result.rows_affected() == 0 { - return Err(Error::RunHeadMismatch { - run_id: run.id.to_string(), - expected_last_seq, - actual_last_seq: select_run_head(connection, &run.id).await?, - }); - } - Ok(()) -} - fn push_filters(builder: &mut QueryBuilder, query: &RunSummaryListQuery) { builder.push(" WHERE 1 = 1"); if let Some(parent_id) = query.parent_id { @@ -1427,15 +662,14 @@ fn overlay_live_wall_time(run: &mut Run, now: DateTime) { #[cfg(test)] mod tests { use std::collections::HashMap; - use std::sync::Arc; use std::time::Duration; use chrono::{DateTime, Utc}; use fabro_types::{ - AutomationRef, BlockedReason, Conclusion, DiffSummary, EventEnvelope, FailureReason, Graph, - PendingReason, PetriAdmission, PullRequestCreationId, RunDiff, RunId, RunProjection, - RunSize, RunSpec, RunStatus, RunStatusKind, RunTiming, SessionId, StageId, StageOutcome, - SuccessReason, WorkflowSettings, test_support, + AutomationRef, BlockedReason, Conclusion, DiffSummary, FailureReason, Graph, PendingReason, + PetriAdmission, PullRequestCreationId, RunDiff, RunId, RunProjection, RunSize, RunSpec, + RunStatus, RunStatusKind, RunTiming, StageOutcome, SuccessReason, WorkflowSettings, + test_support, }; use lithos_llm::types::{Cost, CostSource, TokenCounts, Usage}; use strum::VariantArray as _; @@ -1443,11 +677,14 @@ mod tests { use ulid::Ulid; use super::{ - INSERT_EVENT_SQL, RunSummaryListQuery, RunSummarySort, RunSummarySortDirection, - RunSummaryStore, RunSummaryVisibility, decode_event_row, + RunSummaryListQuery, RunSummarySort, RunSummarySortDirection, RunSummaryStore, + RunSummaryVisibility, }; - use crate::run_state::ProjectedRun; - use crate::{Error, EventPayload, RunProjectionReducer, test_support as store_test_support}; + use crate::platform_records::{ + PlatformRecord, PullRequestCreatedRecord, PullRequestFailedRecord, + PullRequestRequestedRecord, + }; + use crate::test_support as store_test_support; fn dt(value: &str) -> DateTime { value.parse().unwrap() @@ -1482,82 +719,21 @@ mod tests { ) } - fn entry(projection: RunProjection, last_seq: u32) -> ProjectedRun { - ProjectedRun::new(projection.spec.run_id, Arc::new(projection), last_seq) - } - async fn store() -> (tempfile::TempDir, RunSummaryStore) { store_test_support::sqlite_run_summary_store().await } - fn sql_event_payload( - run_id: &RunId, - event: &str, - node_id: Option<&str>, - stage_id: Option<&StageId>, - session_id: Option<&SessionId>, - properties: serde_json::Value, - ) -> EventPayload { - let mut value = serde_json::json!({ - "id": format!("evt-{event}"), - "ts": "2026-08-27T12:00:00Z", - "run_id": run_id.to_string(), - "event": event, - }); - let object = value.as_object_mut().unwrap(); - object.insert("properties".to_string(), properties); - if let Some(node_id) = node_id { - object.insert("node_id".to_string(), node_id.into()); - } - if let Some(stage_id) = stage_id { - object.insert("stage_id".to_string(), stage_id.to_string().into()); - } - if let Some(session_id) = session_id { - object.insert("session_id".to_string(), session_id.to_string().into()); - } - EventPayload::new(value, run_id).unwrap() - } - - fn seqs(events: &[EventEnvelope]) -> Vec { - events.iter().map(|event| event.seq).collect() - } - - fn created_payload(run_id: &RunId) -> EventPayload { - sql_event_payload( - run_id, - "run.created", - None, - None, - None, - serde_json::json!({ - "title": "created", - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "workflow_slug": "test-workflow", - "labels": {}, - "provenance": test_support::test_run_provenance(), - }), + /// Write the run's row as its projector does. + async fn write(store: &RunSummaryStore, projection: &RunProjection) { + let mut transaction = store.pool.begin().await.unwrap(); + RunSummaryStore::write_petri_run_row_on_connection( + &mut transaction, + &projection.spec.run_id, + projection, ) - } - - async fn seed_sql_event( - store: &RunSummaryStore, - run_id: &RunId, - seq: u32, - payload: &EventPayload, - ) { - let event = fabro_types::RunEvent::try_from(payload).unwrap(); - sqlx::query(INSERT_EVENT_SQL) - .bind(run_id.to_string()) - .bind(i64::from(seq)) - .bind(event.event_name()) - .bind(event.node_id) - .bind(event.stage_id.map(|stage_id| stage_id.to_string())) - .bind(event.session_id) - .bind(serde_json::to_string(payload).unwrap()) - .execute(&store.pool) - .await - .unwrap(); + .await + .unwrap(); + transaction.commit().await.unwrap(); } fn sample_status(kind: RunStatusKind) -> RunStatus { @@ -1585,615 +761,7 @@ mod tests { } #[tokio::test] - async fn sql_run_transitions_are_atomic_and_guard_the_current_head() { - let (_directory, store) = store().await; - let created_at = dt("2026-08-27T12:00:00Z"); - let id = run_id(created_at.timestamp_millis().cast_unsigned(), 1); - let first = entry(projection(id, "created", created_at), 1); - let first_payload = created_payload(&id); - - let mut transaction = store.pool.begin().await.unwrap(); - let first_envelope = RunSummaryStore::insert_first_event_on_connection( - &mut transaction, - &first, - &first_payload, - ) - .await - .unwrap(); - transaction.commit().await.unwrap(); - assert_eq!(first_envelope.seq, 1); - - let stored_first: (i64, String) = - sqlx::query_as("SELECT source_last_seq, event_json FROM runs JOIN run_events ON run_events.run_id = runs.id WHERE runs.id = ? AND run_events.seq = 1") - .bind(id.to_string()) - .fetch_one(&store.pool) - .await - .unwrap(); - assert_eq!(stored_first.0, 1); - assert_eq!( - stored_first.1, - serde_json::to_string(&first_payload).unwrap() - ); - - let mut duplicate_first = store.pool.begin().await.unwrap(); - assert!( - RunSummaryStore::insert_first_event_on_connection( - &mut duplicate_first, - &first, - &first_payload, - ) - .await - .is_err() - ); - duplicate_first.rollback().await.unwrap(); - - let rolled_back_id = run_id(created_at.timestamp_millis().cast_unsigned() + 1, 2); - let rolled_back = entry(projection(rolled_back_id, "rollback", created_at), 1); - let mut transaction = store.pool.begin().await.unwrap(); - RunSummaryStore::insert_first_event_on_connection( - &mut transaction, - &rolled_back, - &created_payload(&rolled_back_id), - ) - .await - .unwrap(); - transaction.rollback().await.unwrap(); - let rolled_back_rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM runs WHERE id = ?") - .bind(rolled_back_id.to_string()) - .fetch_one(&store.pool) - .await - .unwrap(); - assert_eq!(rolled_back_rows, 0); - - let invalid_id = run_id(created_at.timestamp_millis().cast_unsigned() + 2, 3); - let invalid = entry(projection(invalid_id, "invalid", created_at), 1); - let invalid_payload = sql_event_payload( - &invalid_id, - "run.title.updated", - None, - None, - None, - serde_json::json!({ "title": "too early" }), - ); - let mut transaction = store.pool.begin().await.unwrap(); - let error = RunSummaryStore::insert_first_event_on_connection( - &mut transaction, - &invalid, - &invalid_payload, - ) - .await - .unwrap_err(); - assert!(matches!(error, Error::RunEventMismatch { - field: "event_name", - .. - })); - transaction.rollback().await.unwrap(); - - let rejected_id = run_id(created_at.timestamp_millis().cast_unsigned() + 3, 4); - sqlx::query( - "CREATE TRIGGER reject_test_event BEFORE INSERT ON run_events BEGIN SELECT RAISE(ABORT, 'rejected'); END", - ) - .execute(&store.pool) - .await - .unwrap(); - let rejected = entry(projection(rejected_id, "rejected", created_at), 1); - let mut transaction = store.pool.begin().await.unwrap(); - assert!( - RunSummaryStore::insert_first_event_on_connection( - &mut transaction, - &rejected, - &created_payload(&rejected_id), - ) - .await - .is_err() - ); - transaction.rollback().await.unwrap(); - let rejected_rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM runs WHERE id = ?") - .bind(rejected_id.to_string()) - .fetch_one(&store.pool) - .await - .unwrap(); - assert_eq!(rejected_rows, 0); - sqlx::query("DROP TRIGGER reject_test_event") - .execute(&store.pool) - .await - .unwrap(); - - let mut updated_projection = projection(id, "updated", created_at); - updated_projection.last_event_at = created_at + chrono::Duration::seconds(1); - updated_projection.status = RunStatus::Running; - let second = entry(updated_projection, 2); - let second_payload = sql_event_payload( - &id, - "run.title.updated", - None, - None, - None, - serde_json::json!({ "title": "updated" }), - ); - let mut transaction = store.pool.begin().await.unwrap(); - RunSummaryStore::append_event_on_connection(&mut transaction, 1, &second, &second_payload) - .await - .unwrap(); - transaction.commit().await.unwrap(); - let updated_row: (i64, String, String, i64) = sqlx::query_as( - "SELECT source_last_seq, status, title, last_event_at_ms FROM runs WHERE id = ?", - ) - .bind(id.to_string()) - .fetch_one(&store.pool) - .await - .unwrap(); - assert_eq!( - updated_row, - ( - 2, - "running".to_string(), - "updated".to_string(), - (created_at + chrono::Duration::seconds(1)).timestamp_millis(), - ) - ); - - let mut stale = store.pool.begin().await.unwrap(); - let stale_error = - RunSummaryStore::append_event_on_connection(&mut stale, 1, &second, &second_payload) - .await - .unwrap_err(); - assert!(matches!(stale_error, Error::RunHeadMismatch { - expected_last_seq: 1, - actual_last_seq: Some(2), - .. - })); - stale.rollback().await.unwrap(); - - let third = entry(projection(id, "third", created_at), 3); - let third_payload = sql_event_payload( - &id, - "future.event", - None, - None, - None, - serde_json::json!({ "preserved": true }), - ); - let mut mismatched_value = third_payload.as_value().clone(); - mismatched_value["run_id"] = rolled_back_id.to_string().into(); - let mismatched_payload: EventPayload = serde_json::from_value(mismatched_value).unwrap(); - let mut invalid = store.pool.begin().await.unwrap(); - assert!( - RunSummaryStore::append_event_on_connection( - &mut invalid, - 2, - &third, - &mismatched_payload, - ) - .await - .is_err() - ); - invalid.rollback().await.unwrap(); - let mut rollback = store.pool.begin().await.unwrap(); - RunSummaryStore::append_event_on_connection(&mut rollback, 2, &third, &third_payload) - .await - .unwrap(); - rollback.rollback().await.unwrap(); - - seed_sql_event(&store, &id, 3, &third_payload).await; - let mut duplicate = store.pool.begin().await.unwrap(); - assert!( - RunSummaryStore::append_event_on_connection(&mut duplicate, 2, &third, &third_payload,) - .await - .is_err() - ); - duplicate.rollback().await.unwrap(); - let head_after_failures: i64 = - sqlx::query_scalar("SELECT source_last_seq FROM runs WHERE id = ?") - .bind(id.to_string()) - .fetch_one(&store.pool) - .await - .unwrap(); - assert_eq!(head_after_failures, 2); - - sqlx::query("DELETE FROM run_events WHERE run_id = ? AND seq = 3") - .bind(id.to_string()) - .execute(&store.pool) - .await - .unwrap(); - let mut transaction = store.pool.begin().await.unwrap(); - RunSummaryStore::append_event_on_connection(&mut transaction, 2, &third, &third_payload) - .await - .unwrap(); - transaction.commit().await.unwrap(); - let sequences = sqlx::query_scalar::<_, i64>( - "SELECT seq FROM run_events WHERE run_id = ? ORDER BY seq", - ) - .bind(id.to_string()) - .fetch_all(&store.pool) - .await - .unwrap(); - assert_eq!(sequences, vec![1, 2, 3]); - } - - #[tokio::test] - async fn load_projection_replays_committed_events_and_reports_missing_run() { - let (_directory, store) = store().await; - let created_at = dt("2026-08-27T12:00:00Z"); - let id = run_id(created_at.timestamp_millis().cast_unsigned(), 31); - let first = entry(projection(id, "created", created_at), 1); - let first_payload = created_payload(&id); - - let mut transaction = store.pool.begin().await.unwrap(); - let first_envelope = RunSummaryStore::insert_first_event_on_connection( - &mut transaction, - &first, - &first_payload, - ) - .await - .unwrap(); - transaction.commit().await.unwrap(); - - let second = entry(projection(id, "updated", created_at), 2); - let second_payload = sql_event_payload( - &id, - "run.title.updated", - None, - None, - None, - serde_json::json!({ "title": "updated" }), - ); - let mut transaction = store.pool.begin().await.unwrap(); - let second_envelope = RunSummaryStore::append_event_on_connection( - &mut transaction, - 1, - &second, - &second_payload, - ) - .await - .unwrap(); - transaction.commit().await.unwrap(); - - let expected = RunProjection::apply_events(&[first_envelope, second_envelope]).unwrap(); - - let loaded = store.load_projection(&id).await.unwrap(); - assert_eq!(loaded.run_id, id); - assert_eq!(loaded.last_seq, 2); - assert_eq!( - serde_json::to_value(loaded.projection.as_ref()).unwrap(), - serde_json::to_value(expected).unwrap() - ); - - let missing = run_id(created_at.timestamp_millis().cast_unsigned() + 1, 32); - assert!(matches!( - store.load_projection(&missing).await, - Err(Error::RunNotFound(text)) if text == missing.to_string() - )); - } - - #[tokio::test] - async fn load_projection_reports_removed_events() { - let (_directory, store) = store().await; - let created_at = dt("2026-08-27T12:00:00Z"); - let id = run_id(created_at.timestamp_millis().cast_unsigned(), 33); - let current = entry(projection(id, "created", created_at), 1); - let mut transaction = store.pool.begin().await.unwrap(); - RunSummaryStore::insert_first_event_on_connection( - &mut transaction, - ¤t, - &created_payload(&id), - ) - .await - .unwrap(); - transaction.commit().await.unwrap(); - store.test_delete_run_events(&id).await.unwrap(); - - assert!(matches!( - store.load_projection(&id).await, - Err(Error::RunHeadMismatch { - expected_last_seq: 1, - actual_last_seq: None, - .. - }) - )); - } - - #[tokio::test] - async fn canonical_delete_waits_for_a_concurrent_writer() { - let (_directory, store) = store().await; - let created_at = dt("2026-08-27T12:00:00Z"); - let id = run_id(created_at.timestamp_millis().cast_unsigned(), 5); - let first = entry(projection(id, "created", created_at), 1); - let mut transaction = store.pool.begin().await.unwrap(); - RunSummaryStore::insert_first_event_on_connection( - &mut transaction, - &first, - &created_payload(&id), - ) - .await - .unwrap(); - transaction.commit().await.unwrap(); - let blocker = store.pool.begin_with("BEGIN IMMEDIATE").await.unwrap(); - let contender = store.clone(); - let delete = tokio::spawn(async move { contender.delete_canonical(&id).await }); - time::sleep(Duration::from_millis(25)).await; - assert!( - !delete.is_finished(), - "delete should wait for the existing writer" - ); - - blocker.commit().await.unwrap(); - delete.await.unwrap().unwrap(); - assert!(!store.contains(&id).await.unwrap()); - } - - #[tokio::test] - async fn sql_run_reads_preserve_paging_filters_json_and_legacy_gaps() { - let (_directory, store) = store().await; - let created_at = dt("2026-08-27T12:00:00Z"); - let id = run_id(created_at.timestamp_millis().cast_unsigned(), 11); - let first = entry(projection(id, "created", created_at), 1); - let mut transaction = store.pool.begin().await.unwrap(); - RunSummaryStore::insert_first_event_on_connection( - &mut transaction, - &first, - &created_payload(&id), - ) - .await - .unwrap(); - transaction.commit().await.unwrap(); - - let visit_one = StageId::new("work", 1); - let visit_two = StageId::new("work", 2); - let session_id = SessionId::new(); - let payloads = [ - sql_event_payload( - &id, - "future.stage", - Some("work"), - Some(&visit_one), - None, - serde_json::json!({ "kind": "visit-one" }), - ), - sql_event_payload( - &id, - "future.stage", - Some("work"), - Some(&visit_two), - None, - serde_json::json!({ "kind": "visit-two" }), - ), - sql_event_payload( - &id, - "future.legacy", - Some("work"), - None, - None, - serde_json::json!({ "kind": "legacy" }), - ), - sql_event_payload( - &id, - "run.session.future", - None, - None, - Some(&session_id), - serde_json::json!({ "redacted": "[REDACTED]" }), - ), - sql_event_payload( - &id, - "future.non_session", - None, - None, - Some(&session_id), - serde_json::json!({ "same_session": true }), - ), - ]; - for (index, payload) in payloads.iter().enumerate() { - seed_sql_event(&store, &id, u32::try_from(index).unwrap() + 2, payload).await; - } - sqlx::query("UPDATE runs SET source_last_seq = 6 WHERE id = ?") - .bind(id.to_string()) - .execute(&store.pool) - .await - .unwrap(); - - let mut connection = store.pool.acquire().await.unwrap(); - let all = RunSummaryStore::list_events_on_connection(&mut connection, &id) - .await - .unwrap(); - assert_eq!(seqs(&all), vec![1, 2, 3, 4, 5, 6]); - let forward = - RunSummaryStore::list_events_from_with_limit_on_connection(&mut connection, &id, 2, 2) - .await - .unwrap(); - assert_eq!(seqs(&forward), vec![2, 3, 4]); - let reverse = RunSummaryStore::list_events_before_with_limit_on_connection( - &mut connection, - &id, - Some(5), - 2, - ) - .await - .unwrap(); - assert_eq!(seqs(&reverse), vec![4, 3, 2]); - let exact = RunSummaryStore::get_event_on_connection(&mut connection, &id, 5) - .await - .unwrap() - .unwrap(); - assert_eq!(exact.event.event_name(), "run.session.future"); - assert_eq!( - serde_json::to_value(&exact.event).unwrap(), - payloads[3].as_value().clone() - ); - - let visit_one_events = - RunSummaryStore::list_events_for_stage_from_with_limit_on_connection( - &mut connection, - &id, - &visit_one, - 1, - 10, - ) - .await - .unwrap(); - assert_eq!(seqs(&visit_one_events), vec![2, 4]); - let visit_two_events = - RunSummaryStore::list_events_for_stage_from_with_limit_on_connection( - &mut connection, - &id, - &visit_two, - 1, - 10, - ) - .await - .unwrap(); - assert_eq!(seqs(&visit_two_events), vec![3]); - let session_events = - RunSummaryStore::list_events_for_session_from_with_limit_on_connection( - &mut connection, - &id, - &session_id, - 1, - 10, - ) - .await - .unwrap(); - assert_eq!(seqs(&session_events), vec![5]); - drop(connection); - - sqlx::query("DELETE FROM run_events WHERE run_id = ? AND seq = 3") - .bind(id.to_string()) - .execute(&store.pool) - .await - .unwrap(); - let mut connection = store.pool.acquire().await.unwrap(); - let gapped = RunSummaryStore::list_events_on_connection(&mut connection, &id) - .await - .unwrap(); - assert_eq!(gapped.last().unwrap().seq, 6); - assert_eq!(gapped.len(), 5); - } - - #[tokio::test] - async fn sql_run_reads_reject_extracted_column_and_identity_corruption() { - let (_directory, store) = store().await; - let created_at = dt("2026-08-27T12:00:00Z"); - let id = run_id(created_at.timestamp_millis().cast_unsigned(), 21); - let other_id = run_id(created_at.timestamp_millis().cast_unsigned() + 1, 22); - store - .upsert_projection(&entry(projection(id, "one", created_at), 2)) - .await - .unwrap(); - store - .upsert_projection(&entry(projection(other_id, "two", created_at), 1)) - .await - .unwrap(); - let stage_id = StageId::new("work", 1); - let session_id = SessionId::new(); - let payload = sql_event_payload( - &id, - "run.session.future", - Some("work"), - Some(&stage_id), - Some(&session_id), - serde_json::json!({ "safe": true }), - ); - seed_sql_event(&store, &id, 2, &payload).await; - - for (sql, field) in [ - ( - "UPDATE run_events SET event_name = 'wrong' WHERE run_id = ? AND seq = 2", - "event_name", - ), - ( - "UPDATE run_events SET node_id = 'wrong' WHERE run_id = ? AND seq = 2", - "node_id", - ), - ( - "UPDATE run_events SET stage_id = 'wrong@1' WHERE run_id = ? AND seq = 2", - "stage_id", - ), - ( - "UPDATE run_events SET session_id = 'wrong' WHERE run_id = ? AND seq = 2", - "session_id", - ), - ] { - sqlx::query(sql) - .bind(id.to_string()) - .execute(&store.pool) - .await - .unwrap(); - let mut connection = store.pool.acquire().await.unwrap(); - let error = RunSummaryStore::get_event_on_connection(&mut connection, &id, 2) - .await - .unwrap_err(); - assert!(matches!( - &error, - Error::RunEventMismatch { - field: mismatch_field, - .. - } if *mismatch_field == field - )); - assert!(!error.to_string().contains(&session_id.to_string())); - drop(connection); - seed_sql_event_restore(&store, &id, 2, &payload).await; - } - - let mut wrong_json = payload.as_value().clone(); - wrong_json["run_id"] = other_id.to_string().into(); - sqlx::query("UPDATE run_events SET event_json = ? WHERE run_id = ? AND seq = 2") - .bind(serde_json::to_string(&wrong_json).unwrap()) - .bind(id.to_string()) - .execute(&store.pool) - .await - .unwrap(); - let mut connection = store.pool.acquire().await.unwrap(); - assert!(matches!( - RunSummaryStore::get_event_on_connection(&mut connection, &id, 2) - .await - .unwrap_err(), - Error::RunEventMismatch { - field: "run_id", - .. - } - )); - drop(connection); - - seed_sql_event_restore(&store, &id, 2, &payload).await; - sqlx::query("UPDATE run_events SET run_id = ? WHERE run_id = ? AND seq = 2") - .bind(other_id.to_string()) - .bind(id.to_string()) - .execute(&store.pool) - .await - .unwrap(); - let row = sqlx::query(super::SELECT_EVENT_COLUMNS) - .fetch_one(&store.pool) - .await - .unwrap(); - assert!(matches!( - decode_event_row(&row, &id, &id.to_string()).unwrap_err(), - Error::RunEventMismatch { - field: "run_id", - .. - } - )); - } - - async fn seed_sql_event_restore( - store: &RunSummaryStore, - run_id: &RunId, - seq: u32, - payload: &EventPayload, - ) { - sqlx::query("DELETE FROM run_events WHERE run_id = ? AND seq = ?") - .bind(run_id.to_string()) - .bind(i64::from(seq)) - .execute(&store.pool) - .await - .unwrap(); - seed_sql_event(store, run_id, seq, payload).await; - } - - /// The migration's `CHECK (status IN (...))` freezes the status strings; - /// prove every `RunStatusKind` variant passes it so an enum change that - /// forgets a follow-up migration fails in CI instead of at runtime. - #[tokio::test] - async fn every_status_kind_upserts_within_schema_check() { + async fn every_status_kind_writes_within_schema_check() { let (_directory, store) = store().await; let created_at = dt("2026-07-11T12:00:00Z"); for (index, kind) in RunStatusKind::VARIANTS.iter().enumerate() { @@ -2203,36 +771,31 @@ mod tests { ); let mut projected = projection(id, "status", created_at); projected.status = sample_status(*kind); - store.upsert_projection(&entry(projected, 1)).await.unwrap(); + write(&store, &projected).await; } } #[tokio::test] - async fn upsert_is_monotonic_and_get_applies_children_count() { + async fn a_rewrite_replaces_the_row_and_get_applies_children_count() { let (_directory, store) = store().await; let created_at = dt("2026-07-11T12:00:00Z"); let parent_id = run_id(created_at.timestamp_millis().cast_unsigned(), 1); let child_id = run_id(created_at.timestamp_millis().cast_unsigned() + 1, 2); - let parent = entry(projection(parent_id, "parent", created_at), 1); - store.upsert_projection(&parent).await.unwrap(); - - let mut child_projection = projection(child_id, "new title", created_at); - child_projection.parent_id = Some(parent_id); - child_projection.last_event_at = created_at + chrono::Duration::seconds(2); - store - .upsert_projection(&entry(child_projection, 2)) - .await - .unwrap(); - - let mut stale = projection(child_id, "stale title", created_at); - stale.parent_id = Some(parent_id); - store.upsert_projection(&entry(stale, 1)).await.unwrap(); + write(&store, &projection(parent_id, "parent", created_at)).await; + let mut child = projection(child_id, "first title", created_at); + child.parent_id = Some(parent_id); + write(&store, &child).await; + child.title = "new title".to_string(); + child.last_event_at = created_at + chrono::Duration::seconds(2); + write(&store, &child).await; let parent = store.get(&parent_id, created_at).await.unwrap().unwrap(); let child = store.get(&child_id, created_at).await.unwrap().unwrap(); assert_eq!(parent.children_count, 1); assert_eq!(child.title, "new title"); + assert!(store.contains(&child_id).await.unwrap()); + assert!(!store.contains(&run_id(1, 99)).await.unwrap()); } #[tokio::test] @@ -2260,7 +823,7 @@ mod tests { let mut archived = projection(archived_id, "charlie", created_at); archived.archived_at = Some(created_at); for projected in [first, second, archived] { - store.upsert_projection(&entry(projected, 1)).await.unwrap(); + write(&store, &projected).await; } let page = store @@ -2352,7 +915,7 @@ mod tests { let mut expected_ids = Vec::new(); for projected in projections { expected_ids.push(projected.spec.run_id); - store.upsert_projection(&entry(projected, 1)).await.unwrap(); + write(&store, &projected).await; } expected_ids.sort_by(|left, right| { right @@ -2396,7 +959,7 @@ mod tests { ); let mut projected = projection(id, &kind.to_string(), id.created_at()); projected.status = sample_status(*kind); - store.upsert_projection(&entry(projected, 1)).await.unwrap(); + write(&store, &projected).await; } let startup_statuses = [ @@ -2428,248 +991,83 @@ mod tests { } #[tokio::test] - async fn pull_request_creation_candidates_are_distinct_and_indexed_by_event_name() { + async fn pull_request_creation_candidates_are_the_runs_with_an_unresolved_request() { let (_directory, store) = store().await; let created_at = dt("2026-08-27T12:00:00Z"); - let first_id = run_id(created_at.timestamp_millis().cast_unsigned(), 1); - let second_id = run_id(created_at.timestamp_millis().cast_unsigned() + 1, 2); - let unrelated_id = run_id(created_at.timestamp_millis().cast_unsigned() + 2, 3); - for id in [first_id, second_id, unrelated_id] { - store - .upsert_projection(&entry(projection(id, "candidate", id.created_at()), 3)) - .await - .unwrap(); - } - for (run_id, seq) in [(first_id, 2), (first_id, 3), (second_id, 2)] { - let request = sql_event_payload( - &run_id, - "pull_request.creation_requested", - None, - None, - None, - serde_json::json!({ - "creation_id": PullRequestCreationId::new(), - "model": "test-model", - "force": false, - }), - ); - seed_sql_event(&store, &run_id, seq, &request).await; - } - - let mut candidates = store - .list_pull_request_creation_candidate_run_ids() - .await - .unwrap(); - candidates.sort_unstable(); - let mut expected = vec![first_id, second_id]; - expected.sort_unstable(); - assert_eq!(candidates, expected); - } - - /// The candidate query must agree with the projection reducer about which - /// later events resolve a creation request, so recovery replays only runs - /// that are still plausibly pending and never skips one that is. - #[tokio::test] - async fn pull_request_creation_candidates_exclude_requests_resolved_by_later_events() { - let (_directory, store) = store().await; - let created_at = dt("2026-08-27T12:00:00Z"); - let base = created_at.timestamp_millis().cast_unsigned(); - let link = serde_json::json!({ "owner": "acme", "repo": "widgets", "number": 7 }); - let created = serde_json::json!({ - "pr_url": "https://github.com/acme/widgets/pull/7", - "pr_number": 7, - "owner": "acme", - "repo": "widgets", - "base_branch": "main", - "head_branch": "fabro/run/7", - "title": "Widgets", - "draft": false, - }); - let request = |creation_id: PullRequestCreationId| { - serde_json::json!({ - "creation_id": creation_id, - "model": "test-model", - "force": false, + let pending_id = run_id(created_at.timestamp_millis().cast_unsigned(), 1); + let created_id = run_id(created_at.timestamp_millis().cast_unsigned() + 1, 2); + let failed_id = run_id(created_at.timestamp_millis().cast_unsigned() + 2, 3); + let renewed_id = run_id(created_at.timestamp_millis().cast_unsigned() + 3, 4); + let records = store.platform_records(); + let requested = |creation_id: PullRequestCreationId| { + PlatformRecord::PullRequestRequested(PullRequestRequestedRecord { + creation_id, + model: "test-model".to_string(), + force: false, }) }; + let first = PullRequestCreationId::new(); + let second = PullRequestCreationId::new(); - let resolved_by_created = run_id(base, 1); - let resolved_by_linked = run_id(base + 1, 2); - let resolved_by_unlinked = run_id(base + 2, 3); - let resolved_by_matching_failure = run_id(base + 3, 4); - let failed_for_other_request = run_id(base + 4, 5); - let failed_without_creation_id = run_id(base + 5, 6); - let retried_after_failure = run_id(base + 6, 7); - let superseded_then_resolved = run_id(base + 7, 8); - let all_ids = [ - resolved_by_created, - resolved_by_linked, - resolved_by_unlinked, - resolved_by_matching_failure, - failed_for_other_request, - failed_without_creation_id, - retried_after_failure, - superseded_then_resolved, - ]; - for id in all_ids { - store - .upsert_projection(&entry(projection(id, "candidate", id.created_at()), 9)) - .await - .unwrap(); - } - - let seed = async |id: RunId, seq: u32, event: &str, properties: serde_json::Value| { - let payload = sql_event_payload(&id, event, None, None, None, properties); - seed_sql_event(&store, &id, seq, &payload).await; - }; - - let id_a = PullRequestCreationId::new(); - seed( - resolved_by_created, - 2, - "pull_request.creation_requested", - request(id_a), - ) - .await; - seed( - resolved_by_created, - 3, - "pull_request.created", - created.clone(), - ) - .await; - - let id_b = PullRequestCreationId::new(); - seed( - resolved_by_linked, - 2, - "pull_request.creation_requested", - request(id_b), - ) - .await; - seed( - resolved_by_linked, - 3, - "pull_request.linked", - serde_json::json!({ "pull_request": link }), - ) - .await; - - let id_c = PullRequestCreationId::new(); - seed( - resolved_by_unlinked, - 2, - "pull_request.creation_requested", - request(id_c), - ) - .await; - seed( - resolved_by_unlinked, - 3, - "pull_request.unlinked", - serde_json::json!({ "pull_request": link }), - ) - .await; - - let id_d = PullRequestCreationId::new(); - seed( - resolved_by_matching_failure, - 2, - "pull_request.creation_requested", - request(id_d), - ) - .await; - seed( - resolved_by_matching_failure, - 3, - "pull_request.failed", - serde_json::json!({ "creation_id": id_d, "error": "boom" }), - ) - .await; - - let id_e = PullRequestCreationId::new(); - seed( - failed_for_other_request, - 2, - "pull_request.creation_requested", - request(id_e), - ) - .await; - seed( - failed_for_other_request, - 3, - "pull_request.failed", - serde_json::json!({ "creation_id": PullRequestCreationId::new(), "error": "other" }), - ) - .await; - - let id_f = PullRequestCreationId::new(); - seed( - failed_without_creation_id, - 2, - "pull_request.creation_requested", - request(id_f), - ) - .await; - seed( - failed_without_creation_id, - 3, - "pull_request.failed", - serde_json::json!({ "error": "publish stage failure" }), - ) - .await; - - let id_g = PullRequestCreationId::new(); - seed( - retried_after_failure, - 2, - "pull_request.creation_requested", - request(id_g), - ) - .await; - seed( - retried_after_failure, - 3, - "pull_request.failed", - serde_json::json!({ "creation_id": id_g, "error": "boom" }), - ) - .await; - seed( - retried_after_failure, - 4, - "pull_request.creation_requested", - request(PullRequestCreationId::new()), - ) - .await; - - let id_h = PullRequestCreationId::new(); - seed( - superseded_then_resolved, - 2, - "pull_request.creation_requested", - request(id_h), - ) - .await; - seed( - superseded_then_resolved, - 3, - "pull_request.creation_requested", - request(PullRequestCreationId::new()), - ) - .await; - seed(superseded_then_resolved, 4, "pull_request.created", created).await; + // A request with nothing after it is pending. + records + .append(&pending_id, &requested(first), None) + .await + .unwrap(); + // A created pull request resolves the request before it. + records + .append(&created_id, &requested(first), None) + .await + .unwrap(); + records + .append( + &created_id, + &PlatformRecord::PullRequestCreated(PullRequestCreatedRecord { + number: 7, + owner: "acme".to_string(), + repo: "widgets".to_string(), + html_url: "https://github.com/acme/widgets/pull/7".to_string(), + head_sha: None, + draft: false, + operation: None, + }), + None, + ) + .await + .unwrap(); + // A failure resolves only the request it names. + records + .append(&failed_id, &requested(first), None) + .await + .unwrap(); + records + .append( + &failed_id, + &PlatformRecord::PullRequestFailed(PullRequestFailedRecord { + creation_id: Some(second), + error: "boom".to_string(), + }), + None, + ) + .await + .unwrap(); + // A newer request supersedes the old one and is itself pending. + records + .append(&renewed_id, &requested(first), None) + .await + .unwrap(); + records + .append(&renewed_id, &requested(second), None) + .await + .unwrap(); let mut candidates = store .list_pull_request_creation_candidate_run_ids() .await .unwrap(); - candidates.sort_unstable(); - let mut expected = vec![ - failed_for_other_request, - failed_without_creation_id, - retried_after_failure, - ]; - expected.sort_unstable(); + candidates.sort(); + let mut expected = vec![pending_id, failed_id, renewed_id]; + expected.sort(); assert_eq!(candidates, expected); } @@ -2719,13 +1117,10 @@ mod tests { }), }, }); - store - .upsert_projection(&entry(projection, 4)) - .await - .unwrap(); + write(&store, &projection).await; let row = sqlx::query( - "SELECT source_last_seq, created_at_ms, last_event_at_ms, status, title, workflow_slug, \ + "SELECT created_at_ms, last_event_at_ms, status, title, workflow_slug, \ automation_id, input_tokens, reasoning_tokens, cache_read_tokens, total_usd_micros, \ diff_files_changed, diff_additions, diff_deletions FROM runs WHERE id = ?", ) @@ -2733,7 +1128,6 @@ mod tests { .fetch_one(&store.pool) .await .unwrap(); - assert_eq!(sqlx::Row::get::(&row, "source_last_seq"), 4); assert_eq!( sqlx::Row::get::(&row, "created_at_ms"), created_at.timestamp_millis() @@ -2768,53 +1162,23 @@ mod tests { } #[tokio::test] - async fn reconcile_removes_rows_absent_from_authoritative_entries() { + async fn canonical_delete_waits_for_a_concurrent_writer() { let (_directory, store) = store().await; - let created_at = dt("2026-07-11T12:00:00Z"); - let kept_id = run_id(created_at.timestamp_millis().cast_unsigned(), 1); - let removed_id = run_id(created_at.timestamp_millis().cast_unsigned() + 1, 2); - let kept = entry(projection(kept_id, "kept", created_at), 1); - let removed = entry(projection(removed_id, "removed", created_at), 1); - store.upsert_projection(&kept).await.unwrap(); - store.upsert_projection(&removed).await.unwrap(); + let created_at = dt("2026-08-27T12:00:00Z"); + let id = run_id(created_at.timestamp_millis().cast_unsigned(), 5); + write(&store, &projection(id, "created", created_at)).await; - store.reconcile(std::slice::from_ref(&kept)).await.unwrap(); - - assert!(store.get(&kept_id, created_at).await.unwrap().is_some()); - assert!(store.get(&removed_id, created_at).await.unwrap().is_none()); - } - - #[tokio::test] - async fn failed_reconcile_rolls_back_and_can_be_retried() { - let (_directory, store) = store().await; - let created_at = dt("2026-07-11T12:00:00Z"); - let stale_id = run_id(created_at.timestamp_millis().cast_unsigned(), 1); - let good_id = run_id(created_at.timestamp_millis().cast_unsigned() + 1, 2); - let recovered_id = run_id(created_at.timestamp_millis().cast_unsigned() + 2, 3); - store - .upsert_projection(&entry(projection(stale_id, "stale", created_at), 1)) - .await - .unwrap(); - - let good = entry(projection(good_id, "good", created_at), 1); - let recovered_projection = projection(recovered_id, "recovered", created_at); - let invalid = entry(recovered_projection.clone(), 0); - - assert!(store.reconcile(&[good.clone(), invalid]).await.is_err()); - assert!(store.get(&stale_id, created_at).await.unwrap().is_some()); - assert!(store.get(&good_id, created_at).await.unwrap().is_none()); - - let recovered = entry(recovered_projection, 1); - store.reconcile(&[good, recovered]).await.unwrap(); - - assert!(store.get(&stale_id, created_at).await.unwrap().is_none()); - assert!(store.get(&good_id, created_at).await.unwrap().is_some()); + let blocker = store.pool.begin_with("BEGIN IMMEDIATE").await.unwrap(); + let contender = store.clone(); + let delete = tokio::spawn(async move { contender.delete_canonical(&id).await }); + time::sleep(Duration::from_millis(25)).await; assert!( - store - .get(&recovered_id, created_at) - .await - .unwrap() - .is_some() + !delete.is_finished(), + "delete should wait for the existing writer" ); + + blocker.commit().await.unwrap(); + delete.await.unwrap().unwrap(); + assert!(!store.contains(&id).await.unwrap()); } } diff --git a/lib/components/fabro-store/src/slate/mod.rs b/lib/components/fabro-store/src/slate/mod.rs deleted file mode 100644 index c2ca329d4..000000000 --- a/lib/components/fabro-store/src/slate/mod.rs +++ /dev/null @@ -1,1631 +0,0 @@ -mod run_store; - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use chrono::{DateTime, Utc}; -use fabro_types::RunId; -use object_store::ObjectStore; -pub use run_store::RunDatabase; -use run_store::RunDatabaseInner; -use slatedb::config::{CompressionCodec, Settings}; -use tokio::sync::{Mutex, MutexGuard, OnceCell}; - -use crate::{ - BlobStore, Error, EventPayload, Result, RunProjection, RunSummaryStore, keys, run_summary_store, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnreadableRun { - pub run_id: RunId, - pub created_at: DateTime, - pub error: String, -} - -#[derive(Clone)] -pub struct Database { - object_store: Arc, - base_prefix: String, - flush_interval: Duration, - cache_path: Option, - db: Arc>, - active_runs: Arc>>>, - blobs: Arc, - run_summary_store: Arc, -} - -impl std::fmt::Debug for Database { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Database") - .field("base_prefix", &self.base_prefix) - .field("flush_interval", &self.flush_interval) - .field("cache_path", &self.cache_path) - .finish_non_exhaustive() - } -} - -impl Database { - pub fn new( - object_store: Arc, - base_prefix: impl Into, - flush_interval: Duration, - cache_path: Option, - blobs: Arc, - run_summary_store: Arc, - ) -> Self { - Self { - object_store, - base_prefix: normalize_base_prefix(base_prefix.into()), - flush_interval, - cache_path, - db: Arc::new(OnceCell::new()), - active_runs: Arc::new(Mutex::new(HashMap::new())), - blobs, - run_summary_store, - } - } - - #[must_use] - pub fn run_summary_store(&self) -> Arc { - Arc::clone(&self.run_summary_store) - } - - fn shared_db_prefix(&self) -> String { - self.base_prefix.clone() - } - - pub(crate) async fn open_db(&self) -> Result { - let db = self - .db - .get_or_try_init(|| async { - let mut settings = Settings { - flush_interval: Some(self.flush_interval), - compression_codec: Some(CompressionCodec::Zstd), - ..Settings::default() - }; - if let Some(ref cache_path) = self.cache_path { - settings.object_store_cache_options.root_folder = Some(cache_path.clone()); - } - slatedb::Db::builder(self.shared_db_prefix(), self.object_store.clone()) - .with_settings(settings) - .build() - .await - }) - .await?; - Ok(db.clone()) - } - - async fn get_active_run(&self, run_id: &RunId) -> Option { - let active_runs = self.active_runs.lock().await; - active_run_from(&active_runs, run_id) - } - - #[cfg(test)] - async fn remove_active_run(&self, run_id: &RunId) -> Option { - self.active_runs - .lock() - .await - .remove(run_id) - .map(RunDatabase::from_inner) - } - - fn cache_active_run( - active_runs: &mut HashMap>, - run_store: &RunDatabase, - ) { - active_runs.insert(run_store.run_id(), run_store.inner_arc()); - } - - /// Builds a run handle wired to the Database-owned shared stores. - async fn open_run_database(&self, run_id: &RunId, read_only: bool) -> Result { - RunDatabase::build(*run_id, read_only, self.blobs(), self.run_summary_store()).await - } - - pub async fn create_run_with_first_event( - &self, - run_id: &RunId, - payload: &EventPayload, - ) -> Result { - let (mut active_runs, run_store) = self.reserve_new_run(run_id).await?; - let (envelope, projected) = run_store.commit_first_event(payload).await?; - let platform_record = run_summary_store::platform_record_written(&projected, &envelope); - run_store.install_in_memory_state(projected); - Self::cache_active_run(&mut active_runs, &run_store); - run_store.publish(&envelope); - if platform_record.is_some() { - self.run_summary_store.notify_platform_record(*run_id); - } - Ok(run_store) - } - - /// Creates an empty run handle for fixture setup. Production code must - /// create sequence-1 `run.created` atomically with the canonical row. - #[cfg(any(test, feature = "test-support"))] - pub async fn create_run(&self, run_id: &RunId) -> Result { - let (mut active_runs, run_store) = self.reserve_new_run(run_id).await?; - Self::cache_active_run(&mut active_runs, &run_store); - Ok(run_store) - } - - /// Builds an empty handle for a run that exists neither in memory nor in - /// SQLite, returning the held `active_runs` guard so the caller can - /// register the handle before any concurrent creator observes the gap. - async fn reserve_new_run( - &self, - run_id: &RunId, - ) -> Result<( - MutexGuard<'_, HashMap>>, - RunDatabase, - )> { - let active_runs = self.active_runs.lock().await; - if active_runs.contains_key(run_id) || self.run_summary_store.contains(run_id).await? { - return Err(Error::RunAlreadyExists(run_id.to_string())); - } - let run_store = RunDatabase::build_empty(*run_id, self.blobs(), self.run_summary_store()); - Ok((active_runs, run_store)) - } - - pub async fn open_run(&self, run_id: &RunId) -> Result { - // Keep the active-writer miss and insert atomic. Otherwise concurrent - // callers can create independent writers with the same recovered seq. - let mut active_runs = self.active_runs.lock().await; - - if let Some(active) = active_run_from(&active_runs, run_id) { - return Ok(active); - } - let run_store = self.open_run_database(run_id, false).await?; - Self::cache_active_run(&mut active_runs, &run_store); - Ok(run_store) - } - - pub async fn open_run_reader(&self, run_id: &RunId) -> Result { - if let Some(active) = self.get_active_run(run_id).await { - return Ok(active.read_only_clone()); - } - self.open_run_database(run_id, true).await - } - - pub async fn list_unreadable_runs(&self) -> Result> { - let run_ids = self.run_summary_store.list_run_ids().await?; - let mut unreadable = Vec::new(); - for run_id in run_ids { - if let Err(err) = self.run_summary_store.load_projection(&run_id).await { - unreadable.push(UnreadableRun { - run_id, - created_at: run_id.created_at(), - error: err.to_string(), - }); - } - } - unreadable.sort_by(|left, right| { - right - .created_at - .cmp(&left.created_at) - .then_with(|| right.run_id.cmp(&left.run_id)) - }); - 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<()> { - self.run_summary_store - .test_insert_unvalidated_event(run_id, seq, payload) - .await?; - self.active_runs.lock().await.remove(run_id); - Ok(()) - } - - #[cfg(any(test, feature = "test-support"))] - pub(crate) async fn put_unvalidated_legacy_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(()) - } - - /// The run's projection: the one its projector last committed over - /// Petri's records and the platform records, or `None` before the - /// first view pass commits (or for no such run). - pub async fn load_run_projection(&self, run_id: &RunId) -> Result>> { - self.run_summary_store.load_petri_projection(run_id).await - } - - /// Install the wake-up called after a platform record of a Petri run is - /// committed beside its legacy event. - pub fn set_platform_record_hook(&self, hook: crate::PlatformRecordHook) { - self.run_summary_store.set_platform_record_hook(hook); - } - - pub async fn delete_run(&self, run_id: &RunId) -> Result<()> { - let mut active_runs = self.active_runs.lock().await; - let active = active_runs.get(run_id).cloned(); - let _state_guard = match &active { - Some(active) => Some(active.state_lock.lock().await), - None => None, - }; - self.run_summary_store.delete_canonical(run_id).await?; - active_runs.remove(run_id); - Ok(()) - } - - #[must_use] - pub fn blobs(&self) -> Arc { - Arc::clone(&self.blobs) - } - - /// Delete every record under the retired `auth/refresh` prefix. - /// - /// Refresh tokens moved to SQLite without an import, so these records are - /// unreadable -- and the reaper that used to collect them is gone, so - /// nothing else would ever remove them. Returns the number of records - /// deleted; a later boot finds the prefix empty and does nothing. - pub async fn retire_refresh_token_keyspace(&self) -> Result { - let db = self.open_db().await?; - let mut iter = db - .scan_prefix(keys::SlateKey::new("auth").with("refresh").into_prefix()) - .await?; - let mut batch = slatedb::WriteBatch::new(); - let mut deletes = 0_u64; - while let Some(entry) = iter.next().await? { - batch.delete(entry.key); - deletes += 1; - } - if deletes > 0 { - db.write(batch).await?; - } - Ok(deletes) - } -} - -pub(crate) fn normalize_base_prefix(prefix: String) -> String { - if prefix.is_empty() { - return String::new(); - } - if prefix.ends_with('/') { - prefix - } else { - format!("{prefix}/") - } -} - -fn active_run_from( - active_runs: &HashMap>, - run_id: &RunId, -) -> Option { - active_runs - .get(run_id) - .cloned() - .map(RunDatabase::from_inner) -} - -#[cfg(test)] -mod tests { - use chrono::{DateTime, Utc}; - use fabro_types::{ - AttrValue, FailureReason, Graph, PetriAdmission, RunControlAction, RunSpec, RunStatus, - StageId, SuccessReason, WorkflowSettings, test_support, - }; - use futures::TryStreamExt; - use object_store::memory::InMemory; - use object_store::path::Path; - - use super::*; - use crate::run_state::ProjectedRun; - use crate::{EventPayload, keys, test_support as store_test_support}; - - fn dt(value: &str) -> DateTime { - value.parse().unwrap() - } - - fn test_run_id(label: &str) -> RunId { - let (timestamp_ms, random) = match label { - "run-1" => ( - dt("2026-03-27T12:00:00Z") - .timestamp_millis() - .cast_unsigned(), - 1, - ), - "run-2" => ( - dt("2026-03-27T12:00:10Z") - .timestamp_millis() - .cast_unsigned(), - 2, - ), - "run-3" => ( - dt("2026-03-27T12:00:20Z") - .timestamp_millis() - .cast_unsigned(), - 3, - ), - "run-4" => ( - dt("2026-03-27T12:00:30Z") - .timestamp_millis() - .cast_unsigned(), - 4, - ), - _ => panic!("unknown test run id: {label}"), - }; - RunId::from(ulid::Ulid::from_parts(timestamp_ms, random)) - } - - fn make_store() -> (Arc, Database) { - let object_store: Arc = Arc::new(InMemory::new()); - let store = store_test_support::test_database( - object_store.clone(), - "runs/", - Duration::from_millis(1), - None, - ); - (object_store, store) - } - - fn make_store_with_run_summaries( - run_summaries: Arc, - ) -> (Arc, Database) { - let object_store: Arc = Arc::new(InMemory::new()); - let store = store_test_support::test_database_with_stores( - object_store.clone(), - "runs/", - Duration::from_millis(1), - None, - store_test_support::test_blob_store(), - run_summaries, - ); - (object_store, store) - } - - /// Reopens a `Database` over an existing object store and SQLite summary - /// store, simulating a process restart with no active run handles. - fn reopen_store( - object_store: Arc, - run_summaries: Arc, - ) -> Database { - store_test_support::test_database_with_stores( - object_store, - "runs", - Duration::from_millis(1), - None, - store_test_support::test_blob_store(), - run_summaries, - ) - } - - #[tokio::test] - async fn retire_refresh_token_keyspace_clears_the_prefix_and_is_idempotent() { - let (_object_store, store) = make_store(); - let db = store.open_db().await.unwrap(); - - let refresh_keys = ["aaa", "bbb"].map(|id| { - keys::SlateKey::new("auth") - .with("refresh") - .with(id) - .as_ref() - .to_vec() - }); - // "auth/code" sorts adjacent to "auth/refresh", so it is the - // neighbour a too-wide prefix delete would take with it. - let auth_code_key = keys::SlateKey::new("auth") - .with("code") - .with("keep") - .as_ref() - .to_vec(); - - let mut batch = slatedb::WriteBatch::new(); - for key in &refresh_keys { - batch.put(key.as_slice(), b"{}".as_slice()); - } - batch.put(auth_code_key.as_slice(), b"{}".as_slice()); - db.write(batch).await.unwrap(); - - assert_eq!(store.retire_refresh_token_keyspace().await.unwrap(), 2); - assert_eq!(store.retire_refresh_token_keyspace().await.unwrap(), 0); - for key in &refresh_keys { - assert!(db.get(key.as_slice()).await.unwrap().is_none()); - } - assert!( - db.get(auth_code_key.as_slice()).await.unwrap().is_some(), - "retiring refresh tokens must not touch the auth code prefix" - ); - } - - async fn make_run_summary_store() -> (tempfile::TempDir, Arc) { - let (directory, store) = store_test_support::sqlite_run_summary_store().await; - (directory, Arc::new(store)) - } - - fn sample_run_spec(label: &str) -> RunSpec { - let mut graph = Graph::new("night-sky"); - graph.attrs.insert( - "goal".to_string(), - AttrValue::String("map the constellations".to_string()), - ); - RunSpec { - run_id: test_run_id(label), - settings: WorkflowSettings::default(), - graph, - graph_source: None, - workflow_slug: Some("night-sky".to_string()), - workflow_version_id: None, - target: None, - automation: None, - source_directory: Some(format!("/tmp/{label}")), - labels: std::collections::HashMap::from([("team".to_string(), "infra".to_string())]), - provenance: test_support::test_run_provenance(), - definition_blob: None, - spec_blob: None, - git: Some(fabro_types::GitContext { - origin_url: "https://github.com/fabro-sh/fabro".to_string(), - branch: "main".to_string(), - sha: None, - dirty: fabro_types::DirtyStatus::Clean, - }), - fork_source_ref: None, - admission: PetriAdmission::default(), - } - } - - fn event_payload( - run_id: &str, - ts: &str, - event: &str, - properties: &serde_json::Value, - ) -> EventPayload { - event_payload_with_node(run_id, ts, event, properties, None) - } - - fn event_payload_with_node( - run_id: &str, - ts: &str, - event: &str, - properties: &serde_json::Value, - node_id: Option<&str>, - ) -> EventPayload { - EventPayload::new( - serde_json::json!({ - "id": format!("evt-{run_id}-{event}"), - "ts": ts, - "run_id": test_run_id(run_id).to_string(), - "event": event, - "node_id": node_id, - "stage_id": node_id.map(|node| format!("{node}@1")), - "properties": properties, - }), - &test_run_id(run_id), - ) - .unwrap() - } - - async fn append_created(run: &RunDatabase, label: &str, created_at: DateTime) { - let run_spec = sample_run_spec(label); - run.append_event(&event_payload( - label, - &created_at.to_rfc3339(), - "run.created", - &serde_json::json!({ - "settings": run_spec.settings, - "graph": run_spec.graph, - "workflow_slug": run_spec.workflow_slug, - "source_directory": run_spec.source_directory, - "git": run_spec.git, - "labels": run_spec.labels, - "provenance": run_spec.provenance, - }), - )) - .await - .unwrap(); - } - - async fn append_created_with_parent( - run: &RunDatabase, - label: &str, - created_at: DateTime, - parent_id: RunId, - ) { - let run_spec = sample_run_spec(label); - run.append_event(&event_payload( - label, - &created_at.to_rfc3339(), - "run.created", - &serde_json::json!({ - "settings": run_spec.settings, - "graph": run_spec.graph, - "workflow_slug": run_spec.workflow_slug, - "source_directory": run_spec.source_directory, - "git": run_spec.git, - "labels": run_spec.labels, - "parent_id": parent_id, - "provenance": run_spec.provenance, - }), - )) - .await - .unwrap(); - } - - async fn append_runnable(run: &RunDatabase, label: &str, created_at: DateTime) { - 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 failure_payload(label: &str, reason: FailureReason, message: &str) -> EventPayload { - event_payload( - label, - "2026-03-27T12:00:04Z", - "run.failed", - &serde_json::json!({ - "failure": { - "reason": reason.to_string(), - "detail": { - "message": message, - "category": "deterministic" - } - }, - "timing": { - "wall_time_ms": 1, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - }, - }), - ) - } - - fn workflow_failure_payload(label: &str) -> EventPayload { - failure_payload(label, FailureReason::WorkflowError, "workflow failed") - } - - /// A failure that can only occur after `Starting`, so a `Runnable` run must - /// reject it. - fn sandbox_init_failure_payload(label: &str) -> EventPayload { - assert!(!FailureReason::SandboxInitFailed.can_occur_before_start()); - failure_payload( - label, - FailureReason::SandboxInitFailed, - "sandbox initialization failed", - ) - } - - async fn append_completed(run: &RunDatabase, label: &str, created_at: DateTime) { - append_running(run, label, created_at).await; - run.append_event(&event_payload( - label, - "2026-03-27T12:00:03Z", - "run.completed", - &serde_json::json!({ - "timing": {"wall_time_ms": 3210, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0}, - "artifact_count": 1, - "status": "succeeded", - "reason": "completed", - "total_cost": 1.25, - }), - )) - .await - .unwrap(); - } - - async fn append_running(run: &RunDatabase, label: &str, created_at: DateTime) { - append_created(run, label, created_at).await; - run.append_event(&event_payload( - label, - "2026-03-27T12:00:01Z", - "run.runnable", - &serde_json::json!({ "source": "start_requested" }), - )) - .await - .unwrap(); - run.append_event(&event_payload( - label, - "2026-03-27T12:00:02Z", - "run.starting", - &serde_json::json!({}), - )) - .await - .unwrap(); - run.append_event(&event_payload( - label, - "2026-03-27T12:00:03Z", - "run.running", - &serde_json::json!({}), - )) - .await - .unwrap(); - } - - async fn list_paths(store: Arc, prefix: &str) -> Vec { - let mut items = store - .list(Some(&Path::from(prefix.to_string()))) - .map_ok(|meta| meta.location.to_string()) - .try_collect::>() - .await - .unwrap(); - items.sort(); - items - } - - #[tokio::test] - async fn create_open_list_and_delete_full_lifecycle_without_legacy_slate_writes() { - let (object_store, store) = make_store(); - let run_1 = store.create_run(&test_run_id("run-1")).await.unwrap(); - let run_2 = store.create_run(&test_run_id("run-2")).await.unwrap(); - append_completed(&run_1, "run-1", dt("2026-03-27T12:00:00Z")).await; - append_created(&run_2, "run-2", dt("2026-03-27T12:00:10Z")).await; - - let summary = store - .run_summary_store() - .list_all(Utc::now()) - .await - .unwrap(); - assert_eq!(summary.len(), 2); - assert_eq!(summary[0].id, test_run_id("run-2")); - assert_eq!(summary[1].id, test_run_id("run-1")); - assert_eq!(summary[1].workflow.name, None); - assert_eq!(summary[1].workflow.graph_name.as_deref(), Some("night-sky")); - assert_eq!(summary[1].goal, "map the constellations"); - assert_eq!(summary[1].lifecycle.status, RunStatus::Succeeded { - reason: SuccessReason::Completed, - }); - - let reopened = store.open_run(&test_run_id("run-1")).await.unwrap(); - let stored = reopened.state().await.unwrap().spec; - assert_eq!(stored.run_id, test_run_id("run-1")); - - store.delete_run(&test_run_id("run-1")).await.unwrap(); - assert!(store.open_run(&test_run_id("run-1")).await.is_err()); - let remaining = store - .run_summary_store() - .list_all(Utc::now()) - .await - .unwrap(); - assert_eq!(remaining.len(), 1); - assert_eq!(remaining[0].id, test_run_id("run-2")); - assert!( - list_paths(object_store, "runs/").await.is_empty(), - "canonical run lifecycle must not open SlateDB solely for retired session indexes" - ); - } - - #[tokio::test] - async fn delete_run_keeps_global_cas_blobs() { - let (_object_store, store) = make_store(); - let run_1 = store.create_run(&test_run_id("run-1")).await.unwrap(); - let run_2 = store.create_run(&test_run_id("run-2")).await.unwrap(); - append_created(&run_1, "run-1", dt("2026-03-27T12:00:00Z")).await; - append_created(&run_2, "run-2", dt("2026-03-27T12:00:10Z")).await; - - let shared_blob = br#"{"summary":"shared"}"#; - let shared_blob_hash = run_1.write_blob(shared_blob).await.unwrap(); - - store.delete_run(&test_run_id("run-1")).await.unwrap(); - - let reopened = store.open_run(&test_run_id("run-2")).await.unwrap(); - let read = reopened.read_blob(&shared_blob_hash).await.unwrap(); - assert_eq!(read.as_deref(), Some(shared_blob.as_slice())); - } - - #[tokio::test] - async fn missing_run_open_paths_return_run_not_found() { - let (_object_store, store) = make_store(); - let run_id = test_run_id("run-4"); - - assert!(matches!( - store.open_run(&run_id).await, - Err(Error::RunNotFound(id)) if id == run_id.to_string() - )); - assert!(matches!( - store.open_run_reader(&run_id).await, - Err(Error::RunNotFound(id)) if id == run_id.to_string() - )); - } - - #[tokio::test] - async fn open_run_reader_is_read_only() { - let (_object_store, store) = make_store(); - let run = store.create_run(&test_run_id("run-1")).await.unwrap(); - append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; - let blob = br#"{"summary":"readable"}"#; - let blob_hash = run.write_blob(blob).await.unwrap(); - - // Evict the cached writer so the reader is built through the real - // `open_run_reader` construction path, not a clone of the writer. - let _ = store.remove_active_run(&test_run_id("run-1")).await; - - let reader = store.open_run_reader(&test_run_id("run-1")).await.unwrap(); - assert_eq!( - reader.read_blob(&blob_hash).await.unwrap().as_deref(), - Some(blob.as_slice()) - ); - let err = reader.write_blob(b"blocked").await.unwrap_err(); - assert!(matches!(err, Error::ReadOnly)); - - let err = reader - .append_event(&event_payload( - "run-1", - "2026-03-27T12:00:01Z", - "run.completed", - &serde_json::json!({ "reason": "completed" }), - )) - .await - .unwrap_err(); - assert!(matches!(err, Error::ReadOnly)); - } - - #[tokio::test] - async fn append_event_if_evaluates_latest_projection_before_appending() { - let (_object_store, store) = make_store(); - let run = store.create_run(&test_run_id("run-1")).await.unwrap(); - append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; - let initial_title = run.state().await.unwrap().title().into_owned(); - - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:01Z", - "run.title.updated", - &serde_json::json!({ "title": "User title" }), - )) - .await - .unwrap(); - - let generated_update = event_payload( - "run-1", - "2026-03-27T12:00:02Z", - "run.title.updated", - &serde_json::json!({ "title": "Generated title" }), - ); - let appended = run - .append_event_if(&generated_update, |projection| { - projection.title() == initial_title - }) - .await - .unwrap(); - - assert_eq!(appended, None); - assert_eq!(run.state().await.unwrap().title(), "User title"); - assert_eq!(run.list_events().await.unwrap().len(), 2); - } - - #[tokio::test] - async fn rejected_transition_writes_nothing_and_preserves_projection_state() { - 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(&sandbox_init_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::SandboxInitFailed, - }, - }) - )); - assert_eq!(run.list_events().await.unwrap(), events_before); - assert_eq!(run.state().await.unwrap().status, RunStatus::Runnable); - let projection = store.load_run_projection(&run_id).await.unwrap().unwrap(); - assert_eq!(run.last_event_seq().await.unwrap(), Some(4)); - assert_eq!(projection.status, RunStatus::Runnable); - } - - #[tokio::test] - async fn rejected_transition_leaves_reconciled_summary_present() { - let (_directory, summaries) = make_run_summary_store().await; - let (_object_store, store) = make_store_with_run_summaries(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(&sandbox_init_failure_payload("run-1")) - .await - .unwrap_err(); - assert!(matches!(err, Error::EventRejected { .. })); - - let projection = store.load_run_projection(&run_id).await.unwrap().unwrap(); - let last_seq = run.last_event_seq().await.unwrap().unwrap(); - let entries = [ProjectedRun::new(run_id, projection, last_seq)]; - 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 sql_failure_leaves_event_and_projection_unpublished() { - let (_directory, summaries) = make_run_summary_store().await; - let (_object_store, store) = make_store_with_run_summaries(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; - let projection_before = store.load_run_projection(&run_id).await.unwrap().unwrap(); - 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": "Uncommitted title" }), - )) - .await; - - assert!(matches!( - result, - Err(Error::Sqlite(sqlx::Error::PoolClosed)) - )); - let projection_after = store.load_run_projection(&run_id).await.unwrap().unwrap(); - assert!(Arc::ptr_eq(&projection_before, &projection_after)); - } - - #[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_eq!(run.last_event_seq().await.unwrap(), None); - - 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"), - "provenance": test_support::test_run_provenance(), - }, - }), - &run_id, - ) - .unwrap(); - - let err = run.append_event(&malformed).await.unwrap_err(); - - assert!(matches!(err, Error::InvalidEvent(_))); - assert_eq!(run.last_event_seq().await.unwrap(), None); - } - - #[tokio::test] - async fn control_request_events_set_pending_control_without_overwriting_status() { - let (_object_store, store) = make_store(); - let run = store.create_run(&test_run_id("run-1")).await.unwrap(); - append_running(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; - - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:02Z", - "run.pause.requested", - &serde_json::json!({ "action": "pause" }), - )) - .await - .unwrap(); - - let summary = store - .run_summary_store() - .list_all(Utc::now()) - .await - .unwrap(); - assert_eq!(summary.len(), 1); - assert_eq!(summary[0].lifecycle.status, RunStatus::Running); - assert_eq!( - summary[0].lifecycle.pending_control, - Some(RunControlAction::Pause) - ); - } - - #[tokio::test] - async fn parent_id_is_projected_from_created_and_parent_events() { - let (_object_store, store) = make_store(); - let parent_1 = store.create_run(&test_run_id("run-1")).await.unwrap(); - let parent_2 = store.create_run(&test_run_id("run-2")).await.unwrap(); - let child = store.create_run(&test_run_id("run-3")).await.unwrap(); - append_created(&parent_1, "run-1", dt("2026-03-27T12:00:00Z")).await; - append_created(&parent_2, "run-2", dt("2026-03-27T12:00:10Z")).await; - append_created_with_parent( - &child, - "run-3", - dt("2026-03-27T12:00:20Z"), - test_run_id("run-1"), - ) - .await; - - let initial = store.open_run(&test_run_id("run-3")).await.unwrap(); - assert_eq!( - initial.state().await.unwrap().parent_id, - Some(test_run_id("run-1")) - ); - assert_eq!( - store - .run_summary_store() - .get(&test_run_id("run-3"), Utc::now()) - .await - .unwrap() - .unwrap() - .parent_id, - Some(test_run_id("run-1")) - ); - - child - .append_event(&event_payload( - "run-3", - "2026-03-27T12:00:21Z", - "run.parent.linked", - &serde_json::json!({ - "previous_parent_id": test_run_id("run-1"), - "parent_id": test_run_id("run-2"), - }), - )) - .await - .unwrap(); - assert_eq!( - store - .run_summary_store() - .get(&test_run_id("run-3"), Utc::now()) - .await - .unwrap() - .unwrap() - .parent_id, - Some(test_run_id("run-2")) - ); - child - .append_event(&event_payload( - "run-3", - "2026-03-27T12:00:22Z", - "run.parent.unlinked", - &serde_json::json!({ - "previous_parent_id": test_run_id("run-2"), - }), - )) - .await - .unwrap(); - assert_eq!( - store - .run_summary_store() - .get(&test_run_id("run-3"), Utc::now()) - .await - .unwrap() - .unwrap() - .parent_id, - None - ); - } - - #[tokio::test] - async fn run_summary_includes_children_count() { - let (_object_store, store) = make_store(); - let parent = store.create_run(&test_run_id("run-1")).await.unwrap(); - let child_a = store.create_run(&test_run_id("run-2")).await.unwrap(); - let child_b = store.create_run(&test_run_id("run-3")).await.unwrap(); - let unrelated = store.create_run(&test_run_id("run-4")).await.unwrap(); - append_created(&parent, "run-1", dt("2026-03-27T12:00:00Z")).await; - append_created_with_parent( - &child_a, - "run-2", - dt("2026-03-27T12:00:10Z"), - test_run_id("run-1"), - ) - .await; - append_created_with_parent( - &child_b, - "run-3", - dt("2026-03-27T12:00:20Z"), - test_run_id("run-1"), - ) - .await; - append_created(&unrelated, "run-4", dt("2026-03-27T12:00:30Z")).await; - - let summaries = store - .run_summary_store() - .list_all(Utc::now()) - .await - .unwrap(); - - let parent_summary = summaries - .iter() - .find(|r| r.id == test_run_id("run-1")) - .expect("parent summary should be present"); - assert_eq!(parent_summary.children_count, 2); - - let child_summary = summaries - .iter() - .find(|r| r.id == test_run_id("run-2")) - .expect("child summary should be present"); - assert_eq!(child_summary.children_count, 0); - - let unrelated_summary = summaries - .iter() - .find(|r| r.id == test_run_id("run-4")) - .expect("unrelated summary should be present"); - assert_eq!(unrelated_summary.children_count, 0); - } - - #[tokio::test] - async fn control_effect_events_clear_pending_control_and_update_status() { - let (_object_store, store) = make_store(); - let run = store.create_run(&test_run_id("run-1")).await.unwrap(); - append_running(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; - - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:02Z", - "run.pause.requested", - &serde_json::json!({ "action": "pause" }), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:03Z", - "run.paused", - &serde_json::json!({}), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:04Z", - "run.unpause.requested", - &serde_json::json!({ "action": "unpause" }), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:05Z", - "run.unpaused", - &serde_json::json!({}), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:06Z", - "run.cancel.requested", - &serde_json::json!({ "action": "cancel" }), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:07Z", - "run.failed", - &serde_json::json!({ - "failure": { - "reason": "cancelled", - "detail": { - "message": "cancelled", - "category": "canceled" - } - }, - "timing": {"wall_time_ms": 1, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0}, - }), - )) - .await - .unwrap(); - - let summary = store - .run_summary_store() - .list_all(Utc::now()) - .await - .unwrap(); - assert_eq!(summary.len(), 1); - assert_eq!(summary[0].lifecycle.status, RunStatus::Failed { - reason: FailureReason::Cancelled, - }); - assert_eq!(summary[0].lifecycle.pending_control, None); - } - - #[tokio::test] - async fn reader_sees_cached_projection_and_recent_events_for_active_run() { - let (_object_store, store) = make_store(); - let run = store.create_run(&test_run_id("run-1")).await.unwrap(); - append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; - - let reader = store.open_run_reader(&test_run_id("run-1")).await.unwrap(); - let state = reader.state().await.unwrap(); - assert_eq!(state.spec.run_id, test_run_id("run-1")); - - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:01Z", - "run.runnable", - &serde_json::json!({ "source": "start_requested" }), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:02Z", - "run.starting", - &serde_json::json!({}), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:03Z", - "run.running", - &serde_json::json!({}), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:04Z", - "run.completed", - &serde_json::json!({ - "timing": {"wall_time_ms": 3210, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0}, - "artifact_count": 1, - "status": "succeeded", - "reason": "completed", - "total_cost": 1.25, - }), - )) - .await - .unwrap(); - - let recent = reader.list_events_from_with_limit(4, 10).await.unwrap(); - assert_eq!(recent.len(), 2); - assert_eq!(recent[0].seq, 4); - } - - #[tokio::test] - async fn reopening_store_rebuilds_from_shared_sqlite() { - let (_directory, summaries) = make_run_summary_store().await; - let (object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries)); - let run = store.create_run(&test_run_id("run-1")).await.unwrap(); - append_completed(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; - - let reopened = reopen_store(object_store, summaries); - let summary = reopened - .run_summary_store() - .list_all(Utc::now()) - .await - .unwrap(); - assert_eq!(summary.len(), 1); - assert_eq!(summary[0].id, test_run_id("run-1")); - assert_eq!(summary[0].lifecycle.status, RunStatus::Succeeded { - reason: SuccessReason::Completed, - }); - } - - #[tokio::test] - async fn inactive_projection_loads_on_demand_without_registering_run() { - let (_directory, summaries) = make_run_summary_store().await; - let (object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries)); - let run_1 = store.create_run(&test_run_id("run-1")).await.unwrap(); - let run_2 = store.create_run(&test_run_id("run-2")).await.unwrap(); - let run_3 = store.create_run(&test_run_id("run-3")).await.unwrap(); - append_completed(&run_1, "run-1", dt("2026-03-27T12:00:00Z")).await; - append_completed(&run_2, "run-2", dt("2026-03-27T12:00:10Z")).await; - append_completed(&run_3, "run-3", dt("2026-03-27T12:00:20Z")).await; - - let reopened = reopen_store(object_store, summaries); - assert!(reopened.active_runs.lock().await.is_empty()); - - let projection = reopened - .load_run_projection(&test_run_id("run-2")) - .await - .unwrap() - .unwrap(); - - assert_eq!(projection.spec.run_id, test_run_id("run-2")); - assert_eq!(projection.status, RunStatus::Succeeded { - reason: SuccessReason::Completed, - }); - assert!( - reopened.active_runs.lock().await.is_empty(), - "inactive detail reads must stay request-local" - ); - } - - #[tokio::test] - async fn active_projection_reads_are_coherent_immutable_snapshots() { - let (_object_store, store) = make_store(); - 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; - - let first = store.load_run_projection(&run_id).await.unwrap().unwrap(); - let second = store.load_run_projection(&run_id).await.unwrap().unwrap(); - assert!(Arc::ptr_eq(&first, &second)); - let original_title = first.title.clone(); - - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:01Z", - "run.title.updated", - &serde_json::json!({ "title": "New title" }), - )) - .await - .unwrap(); - - let updated = store.load_run_projection(&run_id).await.unwrap().unwrap(); - assert!(!Arc::ptr_eq(&first, &updated)); - assert_eq!(first.title, original_title); - assert_eq!(updated.title, "New title"); - } - - #[tokio::test] - async fn inactive_projection_replay_accepts_sequence_gaps_and_failures_do_not_poison_reads() { - let (_directory, summaries) = make_run_summary_store().await; - let (object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries)); - let healthy_id = test_run_id("run-1"); - let broken_id = test_run_id("run-2"); - let healthy = store.create_run(&healthy_id).await.unwrap(); - let broken = store.create_run(&broken_id).await.unwrap(); - append_created(&healthy, "run-1", dt("2026-03-27T12:00:00Z")).await; - append_created(&broken, "run-2", dt("2026-03-27T12:00:10Z")).await; - store.remove_active_run(&healthy_id).await; - store.remove_active_run(&broken_id).await; - store - .put_unvalidated_run_event( - &healthy_id, - 3, - event_payload( - "run-1", - "2026-03-27T12:00:02Z", - "run.title.updated", - &serde_json::json!({ "title": "Imported gap" }), - ) - .as_value(), - ) - .await - .unwrap(); - summaries.test_delete_run_events(&broken_id).await.unwrap(); - - let reopened = reopen_store(object_store, summaries); - assert!(matches!( - reopened.load_run_projection(&broken_id).await, - Err(Error::RunHeadMismatch { .. }) - )); - - let projection = reopened - .load_run_projection(&healthy_id) - .await - .unwrap() - .unwrap(); - assert_eq!(projection.title, "Imported gap"); - assert!(reopened.active_runs.lock().await.is_empty()); - } - - #[tokio::test] - async fn inactive_projection_read_concurrent_with_append_is_one_coherent_snapshot() { - let (_directory, summaries) = make_run_summary_store().await; - let (object_store, writer_store) = make_store_with_run_summaries(Arc::clone(&summaries)); - let run_id = test_run_id("run-1"); - let writer = writer_store.create_run(&run_id).await.unwrap(); - append_created(&writer, "run-1", dt("2026-03-27T12:00:00Z")).await; - let reader_store = reopen_store(object_store, summaries); - let original_title = writer.state().await.unwrap().title; - let update = event_payload( - "run-1", - "2026-03-27T12:00:01Z", - "run.title.updated", - &serde_json::json!({ "title": "Concurrent title" }), - ); - - let (loaded, appended) = tokio::join!( - reader_store.load_run_projection(&run_id), - writer.append_event(&update) - ); - - appended.unwrap(); - let loaded = loaded.unwrap().unwrap(); - assert!(loaded.title == original_title || loaded.title == "Concurrent title"); - assert!(reader_store.active_runs.lock().await.is_empty()); - } - - #[tokio::test] - async fn required_run_summary_append_refreshes_active_projection_and_delete_removes_rows() { - let (_directory, summaries) = make_run_summary_store().await; - let (_object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries)); - let run = store.create_run(&test_run_id("run-1")).await.unwrap(); - append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:01Z", - "run.runnable", - &serde_json::json!({ "source": "start_requested" }), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:01Z", - "run.starting", - &serde_json::json!({}), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:02Z", - "run.running", - &serde_json::json!({}), - )) - .await - .unwrap(); - run.append_event(&event_payload_with_node( - "run-1", - "2026-03-27T12:00:03Z", - "stage.started", - &serde_json::json!({ - "index": 0, - "handler_type": "prompt", - "attempt": 1, - "max_attempts": 1, - }), - Some("review"), - )) - .await - .unwrap(); - run.append_event(&event_payload_with_node( - "run-1", - "2026-03-27T12:00:04Z", - "interview.started", - &serde_json::json!({ - "question_id": "q-1", - "question": "Approve deploy?", - "stage": "review", - "question_type": "yes_no", - "options": [], - "allow_freeform": false, - "context_display": null, - "timeout_seconds": null, - }), - Some("review"), - )) - .await - .unwrap(); - run.append_event(&event_payload_with_node( - "run-1", - "2026-03-27T12:00:05Z", - "checkpoint.completed", - &serde_json::json!({ - "status": "running", - "current_node": "review", - "completed_nodes": [], - "node_retries": {}, - "context_values": {}, - "node_outcomes": {}, - "next_node_id": "review", - "git_commit_sha": "abc123", - "loop_failure_signatures": {}, - "restart_failure_signatures": {}, - "node_visits": { "review": 1 }, - }), - Some("review"), - )) - .await - .unwrap(); - - let projection = store - .load_run_projection(&test_run_id("run-1")) - .await - .unwrap() - .unwrap(); - assert_eq!(projection.status, RunStatus::Running); - assert_eq!(run.last_event_seq().await.unwrap(), Some(7)); - assert_eq!( - projection - .stage(&StageId::new("review", 1)) - .unwrap() - .effective_state(), - fabro_types::StageState::Running - ); - assert_eq!( - projection.pending_interviews["q-1"].question.text, - "Approve deploy?" - ); - assert_eq!( - projection - .current_checkpoint() - .unwrap() - .git_commit_sha - .as_deref(), - Some("abc123") - ); - - let comparison_time = dt("2026-03-27T12:00:10Z"); - let sql_summary = summaries - .get(&test_run_id("run-1"), comparison_time) - .await - .unwrap() - .unwrap(); - assert_eq!(sql_summary.lifecycle.status, RunStatus::Running); - - store.delete_run(&test_run_id("run-1")).await.unwrap(); - assert!( - store - .load_run_projection(&test_run_id("run-1")) - .await - .unwrap() - .is_none() - ); - assert!( - summaries - .get(&test_run_id("run-1"), Utc::now()) - .await - .unwrap() - .is_none() - ); - } - - #[tokio::test] - async fn opening_sql_backed_run_does_not_read_legacy_event_history() { - let (_directory, summaries) = make_run_summary_store().await; - let (object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries)); - let run_id = test_run_id("run-1"); - let run = store.create_run(&run_id).await.unwrap(); - append_completed(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; - - let reopened = reopen_store(object_store, summaries); - // Opening and projecting from canonical SQLite must not inspect an - // unreadable key in the retained legacy event history. - let mut unreadable_old_key = keys::run_event_seq_prefix(&run_id, 2).as_ref().to_vec(); - unreadable_old_key.push(0xff); - reopened - .open_db() - .await - .unwrap() - .put(unreadable_old_key, b"invalid json") - .await - .unwrap(); - - let fresh_writer = reopened.open_run(&run_id).await.unwrap(); - assert_eq!(fresh_writer.last_event_seq().await.unwrap(), Some(5)); - let state = fresh_writer.state().await.unwrap(); - assert_eq!(state.status, RunStatus::Succeeded { - reason: SuccessReason::Completed, - }); - - let seq = fresh_writer - .append_event(&event_payload( - "run-1", - "2026-03-27T12:00:05Z", - "run.title.updated", - &serde_json::json!({ "title": "Renamed completed run" }), - )) - .await - .unwrap(); - assert_eq!(seq, 6); - } - - #[tokio::test] - async fn append_event_hydrates_local_projection_cache_for_fresh_writer() { - let (_directory, summaries) = make_run_summary_store().await; - let (object_store, store) = make_store_with_run_summaries(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; - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:01Z", - "run.runnable", - &serde_json::json!({ "source": "start_requested" }), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:02Z", - "run.starting", - &serde_json::json!({}), - )) - .await - .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:03Z", - "run.running", - &serde_json::json!({}), - )) - .await - .unwrap(); - run.append_event(&workflow_failure_payload("run-1")) - .await - .unwrap(); - - let reopened = store_test_support::test_database_with_stores( - object_store, - "runs/", - Duration::from_millis(1), - None, - store_test_support::test_blob_store(), - summaries, - ); - let fresh_writer = reopened.open_run(&run_id).await.unwrap(); - fresh_writer - .append_event(&event_payload( - "run-1", - "2026-03-27T12:00:05Z", - "run.title.updated", - &serde_json::json!({ "title": "Renamed failed run" }), - )) - .await - .unwrap(); - - let state = fresh_writer.state().await.unwrap(); - assert_eq!(state.title, "Renamed failed run"); - assert_eq!(state.status, RunStatus::Failed { - reason: FailureReason::WorkflowError, - }); - - let projection = reopened - .load_run_projection(&run_id) - .await - .unwrap() - .unwrap(); - assert_eq!(projection.title, "Renamed failed run"); - assert_eq!(projection.status, RunStatus::Failed { - reason: FailureReason::WorkflowError, - }); - } -} diff --git a/lib/components/fabro-store/src/slate/run_store.rs b/lib/components/fabro-store/src/slate/run_store.rs deleted file mode 100644 index ca70766aa..000000000 --- a/lib/components/fabro-store/src/slate/run_store.rs +++ /dev/null @@ -1,692 +0,0 @@ -use std::sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}; - -use bytes::Bytes; -use fabro_types::{BlobHash, RunEvent, RunId, SessionId}; -use futures::Stream; -use tokio::sync::{Mutex as AsyncMutex, broadcast, mpsc}; -use tokio_stream::wrappers::UnboundedReceiverStream; - -use crate::run_state::{EventProjectionCache, ProjectedRun, RunProjectionReducer}; -use crate::{ - BlobStore, Error, EventEnvelope, EventPayload, Result, RunProjection, RunSummaryStore, StageId, - run_summary_store, -}; - -/// Broadcast capacity for live event subscribers; a lagging subscriber refills -/// from SQLite. -const EVENT_BROADCAST_CAPACITY: usize = 1024; - -#[derive(Clone)] -pub struct RunDatabase { - inner: Arc, - read_only: bool, -} - -impl std::fmt::Debug for RunDatabase { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("RunDatabase") - .field("run_id", &self.inner.run_id) - .field("read_only", &self.read_only) - .finish_non_exhaustive() - } -} - -pub(crate) struct RunDatabaseInner { - pub(crate) run_id: RunId, - blob_store: Arc, - pub(crate) state_lock: AsyncMutex<()>, - projection_cache: StdMutex, - run_summary_store: Arc, - event_tx: broadcast::Sender, -} - -impl RunDatabaseInner { - fn lock_projection_cache(&self) -> StdMutexGuard<'_, EventProjectionCache> { - self.projection_cache.lock().expect( - "event projection cache mutex is never poisoned: no code panics while holding this lock", - ) - } -} - -impl RunDatabase { - pub(crate) async fn build( - run_id: RunId, - read_only: bool, - blob_store: Arc, - run_summary_store: Arc, - ) -> Result { - let projected = run_summary_store.load_projection(&run_id).await?; - Ok(Self::from_event_projection_cache( - run_id, - read_only, - blob_store, - run_summary_store, - projected.into(), - )) - } - - pub(crate) fn build_empty( - run_id: RunId, - blob_store: Arc, - run_summary_store: Arc, - ) -> Self { - Self::from_event_projection_cache( - run_id, - false, - blob_store, - run_summary_store, - EventProjectionCache::default(), - ) - } - - fn from_event_projection_cache( - run_id: RunId, - read_only: bool, - blob_store: Arc, - run_summary_store: Arc, - projection_cache: EventProjectionCache, - ) -> Self { - let (event_tx, _) = broadcast::channel(EVENT_BROADCAST_CAPACITY); - Self { - inner: Arc::new(RunDatabaseInner { - run_id, - blob_store, - state_lock: AsyncMutex::new(()), - projection_cache: StdMutex::new(projection_cache), - run_summary_store, - event_tx, - }), - read_only, - } - } - - pub(crate) fn from_inner(inner: Arc) -> Self { - Self { - inner, - read_only: false, - } - } - - pub(crate) fn read_only_clone(&self) -> Self { - Self { - inner: Arc::clone(&self.inner), - read_only: true, - } - } - - pub(crate) fn inner_arc(&self) -> Arc { - Arc::clone(&self.inner) - } - - pub(crate) fn run_id(&self) -> RunId { - self.inner.run_id - } - - pub fn subscribe(&self) -> broadcast::Receiver { - self.inner.event_tx.subscribe() - } - - pub(super) async fn projection_snapshot(&self) -> Result> { - let _state_guard = self.inner.state_lock.lock().await; - self.projection_snapshot_locked() - } - - fn projection_snapshot_locked(&self) -> Result> { - self.inner - .lock_projection_cache() - .state - .clone() - .ok_or_else(|| { - Error::InvalidEvent(format!( - "run {} has no run.created event", - self.inner.run_id - )) - }) - } - - pub(crate) fn install_in_memory_state(&self, projected: ProjectedRun) { - *self.inner.lock_projection_cache() = projected.into(); - } - - pub(crate) fn publish(&self, event: &EventEnvelope) { - let _ = self.inner.event_tx.send(event.clone()); - } - - pub(crate) async fn commit_first_event( - &self, - payload: &EventPayload, - ) -> Result<(EventEnvelope, ProjectedRun)> { - payload.validate(&self.inner.run_id)?; - let event = RunEvent::try_from(payload)?; - let _state_guard = self.inner.state_lock.lock().await; - if self.inner.lock_projection_cache().last_seq != 0 { - return Err(Error::RunAlreadyExists(self.inner.run_id.to_string())); - } - self.commit_event_locked(payload, event).await - } -} - -impl RunDatabase { - /// Appends an event after validating it against the current run projection. - /// - /// Every returned error means the event/current-row transaction did not - /// commit and is safe to retry. Memory and broadcasts advance only after - /// the SQLite commit succeeds. - pub async fn append_event(&self, payload: &EventPayload) -> Result { - Ok(Box::pin(self.append_event_envelope(payload)).await?.seq) - } - - /// Atomically appends `payload` when `predicate` matches the latest run - /// projection. - pub async fn append_event_if( - &self, - payload: &EventPayload, - predicate: impl FnOnce(&RunProjection) -> bool, - ) -> Result> { - if self.read_only { - return Err(Error::ReadOnly); - } - payload.validate(&self.inner.run_id)?; - let event = RunEvent::try_from(payload)?; - let _state_guard = self.inner.state_lock.lock().await; - let projection = self.projection_snapshot_locked()?; - if !predicate(&projection) { - return Ok(None); - } - Ok(Some( - Box::pin(self.append_event_envelope_locked(payload, event)) - .await? - .seq, - )) - } - - /// Appends and returns the stored event envelope after pre-write reduction. - pub async fn append_event_envelope(&self, payload: &EventPayload) -> Result { - if self.read_only { - return Err(Error::ReadOnly); - } - payload.validate(&self.inner.run_id)?; - let event = RunEvent::try_from(payload)?; - let _state_guard = self.inner.state_lock.lock().await; - Box::pin(self.append_event_envelope_locked(payload, event)).await - } - - async fn append_event_envelope_locked( - &self, - payload: &EventPayload, - event: RunEvent, - ) -> Result { - let (envelope, projected) = self.commit_event_locked(payload, event).await?; - // Keep post-commit propagation await-free: cancellation after SQLite - // commits must not leave in-memory state stale or omit the broadcast. - let platform_record = run_summary_store::platform_record_written(&projected, &envelope); - self.install_in_memory_state(projected); - self.publish(&envelope); - if platform_record.is_some() { - self.inner - .run_summary_store - .notify_platform_record(self.inner.run_id); - } - Ok(envelope) - } - - async fn commit_event_locked( - &self, - payload: &EventPayload, - event: RunEvent, - ) -> Result<(EventEnvelope, ProjectedRun)> { - let (expected_last_seq, mut next_state) = { - let cache = self.inner.lock_projection_cache(); - (cache.last_seq, cache.state.clone()) - }; - let seq = run_summary_store::next_event_seq_after(expected_last_seq)?; - let prospective = EventEnvelope { seq, event }; - apply_cached_projection_event(&mut next_state, &prospective).map_err(event_rejected)?; - let next_projection = - next_state.expect("applying a valid event should always produce a projection"); - let projected = ProjectedRun::new(self.inner.run_id, next_projection, seq); - - let mut transaction = self.inner.run_summary_store.begin().await?; - let envelope = if expected_last_seq == 0 { - RunSummaryStore::insert_first_event_on_connection(&mut transaction, &projected, payload) - .await? - } else { - RunSummaryStore::append_event_on_connection( - &mut transaction, - expected_last_seq, - &projected, - payload, - ) - .await? - }; - transaction.commit().await?; - Ok((envelope, projected)) - } - - pub async fn list_events(&self) -> Result> { - self.inner - .run_summary_store - .list_events_for_run(&self.inner.run_id) - .await - } - - pub async fn last_event_seq(&self) -> Result> { - self.inner.run_summary_store.head(&self.inner.run_id).await - } - - /// Returns up to `limit + 1` events starting at `start_seq`. - pub async fn list_events_from_with_limit( - &self, - start_seq: u32, - limit: usize, - ) -> Result> { - self.inner - .run_summary_store - .list_events_from_with_limit(&self.inner.run_id, start_seq, limit) - .await - } - - /// Returns up to `limit + 1` events before `before_seq`, newest first. - pub async fn list_events_before_with_limit( - &self, - before_seq: Option, - limit: usize, - ) -> Result> { - self.inner - .run_summary_store - .list_events_before_with_limit(&self.inner.run_id, before_seq, limit) - .await - } - - pub async fn get_event(&self, seq: u32) -> Result> { - self.inner - .run_summary_store - .get_event_for_run(&self.inner.run_id, seq) - .await - } - - pub async fn list_events_for_stage_from_with_limit( - &self, - stage_id: &StageId, - start_seq: u32, - limit: usize, - ) -> Result> { - self.inner - .run_summary_store - .list_events_for_stage_from_with_limit(&self.inner.run_id, stage_id, start_seq, limit) - .await - } - - pub async fn list_events_for_session_from_with_limit( - &self, - session_id: SessionId, - start_seq: u32, - limit: usize, - ) -> Result> { - self.inner - .run_summary_store - .list_events_for_session_from_with_limit( - &self.inner.run_id, - &session_id, - start_seq, - limit, - ) - .await - } - - pub fn watch_events_from( - &self, - seq: u32, - ) -> Result> + Send>>> { - let inner = Arc::clone(&self.inner); - // Subscribe before the durable catch-up query to close the read/subscribe race. - let mut broadcasts = inner.event_tx.subscribe(); - let (sender, receiver) = mpsc::unbounded_channel(); - tokio::spawn(async move { - let mut next_seq = seq; - if !refill_from_sql(&inner, &sender, &mut next_seq).await { - return; - } - loop { - match broadcasts.recv().await { - Ok(event) if event.seq < next_seq => {} - Ok(event) if event.seq == next_seq => { - next_seq = event.seq.saturating_add(1); - if sender.send(Ok(event)).is_err() { - return; - } - } - Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => { - if !refill_from_sql(&inner, &sender, &mut next_seq).await { - return; - } - } - Err(broadcast::error::RecvError::Closed) => return, - } - } - }); - Ok(Box::pin(UnboundedReceiverStream::new(receiver))) - } - - pub async fn write_blob(&self, data: &[u8]) -> Result { - if self.read_only { - return Err(Error::ReadOnly); - } - self.inner.blob_store.write(data).await - } - - pub async fn read_blob(&self, blob_hash: &BlobHash) -> Result> { - self.inner.blob_store.read(blob_hash).await - } - - pub async fn state(&self) -> Result { - Ok(Arc::unwrap_or_clone(self.projection_snapshot().await?)) - } -} - -async fn refill_from_sql( - inner: &RunDatabaseInner, - sender: &mpsc::UnboundedSender>, - next_seq: &mut u32, -) -> bool { - let events = match inner - .run_summary_store - .list_events_from_with_limit(&inner.run_id, *next_seq, usize::MAX) - .await - { - Ok(events) => events, - Err(error) => { - let _ = sender.send(Err(error)); - return false; - } - }; - for event in events { - *next_seq = event.seq.saturating_add(1); - if sender.send(Ok(event)).is_err() { - return false; - } - } - true -} - -fn event_rejected(error: Error) -> Error { - Error::EventRejected { - source: Box::new(error), - } -} - -fn apply_cached_projection_event( - state: &mut Option>, - event: &EventEnvelope, -) -> Result<()> { - if let Some(projection) = state { - Arc::make_mut(projection).apply_event(event)?; - } else { - *state = Some(Arc::new(RunProjection::apply_events( - std::slice::from_ref(event), - )?)); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use std::time::Duration; - - use fabro_types::{Graph, RunId, WorkflowSettings, test_support}; - use futures::StreamExt as _; - use object_store::memory::InMemory; - use serde_json::json; - use tokio::task; - - use crate::{EventPayload, test_support as store_test_support}; - - fn run_created_payload(run_id: &RunId) -> EventPayload { - EventPayload::new( - json!({ - "id": "evt-created", - "ts": "2026-04-09T11:59:00Z", - "run_id": run_id.to_string(), - "event": "run.created", - "properties": { - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "provenance": test_support::test_run_provenance(), - }, - }), - run_id, - ) - .unwrap() - } - - fn stage_payload(run_id: &RunId, index: u32) -> EventPayload { - EventPayload::new( - json!({ - "id": format!("evt-{index}"), - "ts": "2026-04-09T12:00:00Z", - "run_id": run_id.to_string(), - "event": "stage.prompt", - "node_id": "build", - "stage_id": "build@1", - "properties": { "visit": 1, "text": format!("prompt {index}") }, - }), - run_id, - ) - .unwrap() - } - - fn store() -> crate::Database { - store_test_support::test_database( - Arc::new(InMemory::new()), - "run-store-sql-tests", - Duration::from_millis(1), - None, - ) - } - - #[tokio::test] - async fn first_and_later_events_commit_to_sql_before_publication() { - let store = store(); - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); - - let run = store - .create_run_with_first_event(&run_id, &run_created_payload(&run_id)) - .await - .unwrap(); - assert_eq!( - run.append_event(&stage_payload(&run_id, 2)).await.unwrap(), - 2 - ); - assert_eq!( - run.list_events() - .await - .unwrap() - .iter() - .map(|event| event.seq) - .collect::>(), - vec![1, 2] - ); - } - - /// A Petri run's legacy lifecycle events leave platform records beside - /// them, in the same commit, and the hook fires after each; a legacy - /// run's events leave none. - #[tokio::test] - async fn a_petri_runs_lifecycle_events_become_platform_records_and_wake_the_hook() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use crate::platform_records::{PlatformRecord, PlatformRecordKind, RunLifecycleKind}; - - let store = store(); - let woken = Arc::new(AtomicUsize::new(0)); - let counter = Arc::clone(&woken); - store.set_platform_record_hook(Arc::new(move |_| { - counter.fetch_add(1, Ordering::SeqCst); - })); - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65E".parse().unwrap(); - let mut created = run_created_payload(&run_id); - let mut petri = serde_json::to_value(&created).unwrap(); - petri["properties"]["engine"] = json!({ - "kind": "petri", - "graph": { "blob": fabro_types::BlobHash::new(b"graph").to_string(), "digest": "d" }, - }); - created = EventPayload::new(petri, &run_id).unwrap(); - let run = store - .create_run_with_first_event(&run_id, &created) - .await - .unwrap(); - run.append_event( - &EventPayload::new( - json!({ - "id": "evt-starting", - "ts": "2026-04-09T12:00:00Z", - "run_id": run_id.to_string(), - "event": "run.start_requested", - "properties": { "resume": false }, - }), - &run_id, - ) - .unwrap(), - ) - .await - .unwrap(); - run.append_event(&stage_payload(&run_id, 3)).await.unwrap(); - - let records = store - .run_summary_store() - .platform_records() - .read(&run_id) - .await - .unwrap(); - let kinds: Vec = records.iter().map(|r| r.record.kind()).collect(); - assert_eq!(kinds, vec![ - PlatformRecordKind::RunCreated, - PlatformRecordKind::RunLifecycle - ]); - let PlatformRecord::RunLifecycle(lifecycle) = &records[1].record else { - panic!("the second record is the lifecycle"); - }; - assert_eq!(lifecycle.transition, RunLifecycleKind::StartRequested); - assert_eq!(lifecycle.source.as_deref(), Some("start")); - assert_eq!(woken.load(Ordering::SeqCst), 2, "one wake-up per record"); - - let legacy_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65F".parse().unwrap(); - store - .create_run_with_first_event(&legacy_id, &run_created_payload(&legacy_id)) - .await - .unwrap(); - assert!( - store - .run_summary_store() - .platform_records() - .read(&legacy_id) - .await - .unwrap() - .is_empty(), - "a legacy run leaves no platform records" - ); - assert_eq!(woken.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn watcher_catches_up_from_sql_without_duplicates() { - let store = store(); - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65B".parse().unwrap(); - let run = store - .create_run_with_first_event(&run_id, &run_created_payload(&run_id)) - .await - .unwrap(); - run.append_event(&stage_payload(&run_id, 2)).await.unwrap(); - - let mut stream = run.watch_events_from(1).unwrap(); - assert_eq!(stream.next().await.unwrap().unwrap().seq, 1); - assert_eq!(stream.next().await.unwrap().unwrap().seq, 2); - run.append_event(&stage_payload(&run_id, 3)).await.unwrap(); - assert_eq!(stream.next().await.unwrap().unwrap().seq, 3); - } - - #[tokio::test] - async fn simultaneous_appends_allocate_one_contiguous_sql_sequence() { - let store = store(); - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65C".parse().unwrap(); - let run = store - .create_run_with_first_event(&run_id, &run_created_payload(&run_id)) - .await - .unwrap(); - - let mut tasks = Vec::new(); - for index in 2..=33 { - let writer = run.clone(); - tasks.push(tokio::spawn(async move { - writer.append_event(&stage_payload(&run_id, index)).await - })); - } - let mut sequences = Vec::new(); - for task in tasks { - sequences.push(task.await.unwrap().unwrap()); - } - sequences.sort_unstable(); - assert_eq!(sequences, (2..=33).collect::>()); - assert_eq!(run.last_event_seq().await.unwrap(), Some(33)); - assert_eq!(run.list_events().await.unwrap().len(), 33); - } - - #[tokio::test] - async fn simultaneous_creation_has_exactly_one_winner() { - let store = store(); - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65D".parse().unwrap(); - let left_payload = run_created_payload(&run_id); - let right_payload = run_created_payload(&run_id); - let left = store.create_run_with_first_event(&run_id, &left_payload); - let right = store.create_run_with_first_event(&run_id, &right_payload); - - let (left, right) = tokio::join!(left, right); - assert_eq!(usize::from(left.is_ok()) + usize::from(right.is_ok()), 1); - let error = left.err().or_else(|| right.err()).unwrap(); - assert!(matches!(error, crate::Error::RunAlreadyExists(_))); - assert_eq!( - store - .open_run(&run_id) - .await - .unwrap() - .list_events() - .await - .unwrap() - .len(), - 1 - ); - } - - #[tokio::test] - async fn readers_observe_only_complete_committed_prefixes_during_appends() { - let store = store(); - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65E".parse().unwrap(); - let run = store - .create_run_with_first_event(&run_id, &run_created_payload(&run_id)) - .await - .unwrap(); - let writer = run.clone(); - let write_task = tokio::spawn(async move { - for index in 2..=25 { - writer.append_event(&stage_payload(&run_id, index)).await?; - task::yield_now().await; - } - crate::Result::Ok(()) - }); - - while !write_task.is_finished() { - let events = run.list_events().await.unwrap(); - assert!( - events - .iter() - .enumerate() - .all(|(index, event)| event.seq as usize == index + 1) - ); - task::yield_now().await; - } - write_task.await.unwrap().unwrap(); - assert_eq!(run.list_events().await.unwrap().len(), 25); - } -} diff --git a/lib/components/fabro-store/src/test_support/mod.rs b/lib/components/fabro-store/src/test_support/mod.rs index 79ead3f4e..5689e1605 100644 --- a/lib/components/fabro-store/src/test_support/mod.rs +++ b/lib/components/fabro-store/src/test_support/mod.rs @@ -1,15 +1,11 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; -use fabro_types::{BlobHash, RunId}; -use object_store::ObjectStore; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; -use crate::keys::SlateKey; #[cfg(test)] use crate::{AuthCodeStore, AuthSessionStore}; -use crate::{BlobStore, Database, Result, RunSummaryStore}; +use crate::{BlobStore, Database, RunSummaryStore}; /// Returns an isolated SQLite blob authority backed by its own in-memory /// database. @@ -25,17 +21,21 @@ pub fn test_blob_store() -> Arc { ]))) } +/// The migrations a run summary fixture installs: the `runs` row, the +/// platform records and the projection tables. +const RUN_SUMMARY_MIGRATIONS: &[&str] = &[ + fabro_db::RUNS_MIGRATION_SQL, + fabro_db::PETRI_PROJECTION_MIGRATION_SQL, +]; + /// Returns an isolated SQLite run-summary store backed by its own in-memory -/// database and the production `runs` and `run_events` schemas. +/// database and the production `runs`, platform record and projection +/// schemas. #[must_use] pub fn test_run_summary_store() -> Arc { - Arc::new(RunSummaryStore::new(lazy_in_memory_pool(&[ - fabro_db::RUNS_MIGRATION_SQL, - fabro_db::RUN_EVENTS_MIGRATION_SQL, - fabro_db::RUN_HISTORY_ACTIVATION_MIGRATION_SQL, - fabro_db::RUN_EVENT_SESSION_OWNER_MIGRATION_SQL, - fabro_db::PETRI_PROJECTION_MIGRATION_SQL, - ]))) + Arc::new(RunSummaryStore::new(lazy_in_memory_pool( + RUN_SUMMARY_MIGRATIONS, + ))) } /// An isolated in-memory SQLite pool with `migrations` installed on first @@ -111,13 +111,7 @@ pub fn test_run_summary_store_at(store_dir: &Path) -> Arc { Arc::new(RunSummaryStore::new(lazy_file_pool( test_run_summary_store_path(store_dir), "runs", - &[ - fabro_db::RUNS_MIGRATION_SQL, - fabro_db::RUN_EVENTS_MIGRATION_SQL, - fabro_db::RUN_HISTORY_ACTIVATION_MIGRATION_SQL, - fabro_db::RUN_EVENT_SESSION_OWNER_MIGRATION_SQL, - fabro_db::PETRI_PROJECTION_MIGRATION_SQL, - ], + RUN_SUMMARY_MIGRATIONS, ))) } @@ -160,123 +154,40 @@ fn lazy_file_pool( .connect_lazy_with(options) } -/// Builds a test database whose SQLite blob and run-history authorities are -/// durable beside `store_dir` and shared by reopen-style handles. +/// Builds a test database whose blob and run summary stores are durable +/// beside `store_dir` and shared by reopen-style handles. #[must_use] -pub fn test_database_at( - object_store: Arc, - base_prefix: impl Into, - flush_interval: Duration, - cache_path: Option, - store_dir: &Path, -) -> Database { - test_database_with_stores( - object_store, - base_prefix, - flush_interval, - cache_path, +pub fn test_database_at(store_dir: &Path) -> Database { + Database::new( test_blob_store_at(store_dir), test_run_summary_store_at(store_dir), ) } -/// Builds a Slate-backed run database with its own isolated blob authority. +/// Builds a run database with its own isolated blob and run summary +/// stores. #[must_use] -pub fn test_database( - object_store: Arc, - base_prefix: impl Into, - flush_interval: Duration, - cache_path: Option, -) -> Database { - test_database_with_blobs( - object_store, - base_prefix, - flush_interval, - cache_path, - test_blob_store(), - ) +pub fn test_database() -> Database { + Database::new(test_blob_store(), test_run_summary_store()) } -/// Builds a Slate-backed run database sharing an explicit blob authority. +/// Builds a run database sharing an explicit blob store. /// /// Use this for reopen-style tests where two store handles must observe the -/// same signed SQLite blob table, mirroring the one blob authority a -/// production process shares across every run handle. +/// same blob table, mirroring the one blob authority a production process +/// shares across every run handle. #[must_use] -pub fn test_database_with_blobs( - object_store: Arc, - base_prefix: impl Into, - flush_interval: Duration, - cache_path: Option, - blobs: Arc, -) -> Database { - test_database_with_stores( - object_store, - base_prefix, - flush_interval, - cache_path, - blobs, - test_run_summary_store(), - ) +pub fn test_database_with_blobs(blobs: Arc) -> Database { + Database::new(blobs, test_run_summary_store()) } -/// Builds a Slate-backed run database with explicit shared SQLite stores. -/// -/// Use this only when a test needs a failing, persistent, or shared store; -/// ordinary fixtures should use [`test_database`]. +/// Builds a run database with explicit shared stores. #[must_use] pub fn test_database_with_stores( - object_store: Arc, - base_prefix: impl Into, - flush_interval: Duration, - cache_path: Option, blobs: Arc, run_summaries: Arc, ) -> Database { - Database::new( - object_store, - base_prefix, - flush_interval, - cache_path, - blobs, - run_summaries, - ) -} - -/// Seeds one canonical row in the legacy SlateDB blob keyspace. -pub async fn put_legacy_blob(database: &Database, bytes: &[u8]) -> Result { - let hash = BlobHash::new(bytes); - let source = database.open_db().await?; - source - .put(SlateKey::new("blobs").with("sha256").with(hash), bytes) - .await?; - source.flush().await?; - Ok(hash) -} - -/// 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 -} - -/// Seeds one event in the retired Slate run-history keyspace. -pub async fn put_legacy_run_event( - database: &Database, - run_id: &RunId, - seq: u32, - payload: &serde_json::Value, -) -> Result<()> { - database - .put_unvalidated_legacy_run_event(run_id, seq, payload) - .await + Database::new(blobs, run_summaries) } /// Connects to a migrated `fabro.sqlite3` in `directory` and returns its pool. diff --git a/lib/components/fabro-store/src/types.rs b/lib/components/fabro-store/src/types.rs deleted file mode 100644 index c91bbde56..000000000 --- a/lib/components/fabro-store/src/types.rs +++ /dev/null @@ -1,63 +0,0 @@ -use fabro_types::{RunEvent, RunId}; -use serde::{Deserialize, Serialize}; - -use crate::{Error, Result}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct EventPayload(serde_json::Value); - -impl EventPayload { - pub fn new(value: serde_json::Value, expected_run_id: &RunId) -> Result { - let payload = Self(value); - payload.validate(expected_run_id)?; - Ok(payload) - } - - pub(crate) fn validate(&self, expected_run_id: &RunId) -> Result<()> { - let obj = self - .0 - .as_object() - .ok_or_else(|| Error::InvalidEvent("event payload must be a JSON object".into()))?; - - for field in ["id", "ts", "run_id", "event"] { - match obj.get(field) { - Some(serde_json::Value::String(_)) => {} - _ => { - return Err(Error::InvalidEvent(format!( - "missing or non-string required field: {field}" - ))); - } - } - } - - match obj.get("run_id") { - Some(serde_json::Value::String(run_id)) if run_id == &expected_run_id.to_string() => { - Ok(()) - } - Some(serde_json::Value::String(run_id)) => Err(Error::InvalidEvent(format!( - "payload run_id {run_id:?} does not match store run_id {expected_run_id:?}" - ))), - _ => Err(Error::InvalidEvent( - "missing or non-string required field: run_id".into(), - )), - } - } - - pub fn into_inner(self) -> serde_json::Value { - self.0 - } - - pub fn as_value(&self) -> &serde_json::Value { - &self.0 - } -} - -impl TryFrom<&EventPayload> for RunEvent { - type Error = Error; - - fn try_from(value: &EventPayload) -> Result { - Self::from_value(value.as_value().clone()) - .map_err(|err| Error::InvalidEvent(format!("invalid stored event: {err}"))) - } -} diff --git a/lib/components/fabro-store/tests/serializable_projection.rs b/lib/components/fabro-store/tests/serializable_projection.rs index a559681c6..980d07657 100644 --- a/lib/components/fabro-store/tests/serializable_projection.rs +++ b/lib/components/fabro-store/tests/serializable_projection.rs @@ -30,20 +30,12 @@ fn sample_run_spec() -> RunSpec { fn sample_checkpoint() -> Checkpoint { Checkpoint { - timestamp: Utc + timestamp: Utc .with_ymd_and_hms(2026, 4, 20, 12, 0, 0) .single() .expect("timestamp should be representable"), - current_node: "build".to_string(), - completed_nodes: vec!["build".to_string()], - node_retries: HashMap::new(), - context_values: HashMap::new(), - node_outcomes: HashMap::new(), - next_node_id: Some("ship".to_string()), - git_commit_sha: Some("abc123".to_string()), - loop_failure_signatures: HashMap::new(), - restart_failure_signatures: HashMap::new(), - node_visits: HashMap::from([("build".to_string(), 2usize)]), + current_node: "build".to_string(), + git_commit_sha: Some("abc123".to_string()), } } diff --git a/lib/components/fabro-workflow-version/src/store.rs b/lib/components/fabro-workflow-version/src/store.rs index 0c40dfedf..fb03be91f 100644 --- a/lib/components/fabro-workflow-version/src/store.rs +++ b/lib/components/fabro-workflow-version/src/store.rs @@ -225,11 +225,9 @@ impl WorkflowVersionStore { mod tests { use std::collections::BTreeMap; use std::sync::Arc; - use std::time::Duration; use fabro_store::{BlobStore, test_support}; use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId}; - use object_store::memory::InMemory; use super::{WorkflowVersionStore, WorkflowVersionStoreError}; use crate::ValidatedWorkflowVersion; @@ -260,12 +258,7 @@ mod tests { } fn stores() -> (Arc, WorkflowVersionStore) { - let database = test_support::test_database( - Arc::new(InMemory::new()), - "", - Duration::from_millis(1), - None, - ); + let database = test_support::test_database(); let blobs = database.blobs(); let versions = WorkflowVersionStore::new(Arc::clone(&blobs)); (blobs, versions) diff --git a/lib/components/fabro-workflow/src/error.rs b/lib/components/fabro-workflow/src/error.rs index 2ceb0b0e6..657c94b23 100644 --- a/lib/components/fabro-workflow/src/error.rs +++ b/lib/components/fabro-workflow/src/error.rs @@ -13,7 +13,6 @@ use fabro_util::error::{SharedError, collect_causes, collect_chain, render_with_ use regex::Regex; use thiserror::Error as ThisError; -use crate::event::RunEventPersistenceError; use crate::outcome::{FailureDetail, Outcome, StageOutcome}; /// Classify an LLM error into a `FailureCategory` based on its structure. @@ -712,12 +711,6 @@ impl From for Error { } } -impl From for Error { - fn from(err: RunEventPersistenceError) -> Self { - Self::engine_with_source("run event persistence failed", err) - } -} - pub type Result = std::result::Result; #[cfg(test)] diff --git a/lib/components/fabro-workflow/src/event.rs b/lib/components/fabro-workflow/src/event.rs deleted file mode 100644 index b972bf6a9..000000000 --- a/lib/components/fabro-workflow/src/event.rs +++ /dev/null @@ -1,27 +0,0 @@ -mod convert; -mod driver_events; -mod emitter; -mod events; -mod names; -mod redaction; -mod sink; -mod stored_fields; -#[cfg(test)] -mod test_support; - -pub use fabro_types::{EventBody, RunNoticeCode, RunNoticeLevel}; - -pub use self::convert::{to_run_event, to_run_event_at}; -pub use self::driver_events::DriverEventRecorder; -pub use self::emitter::Emitter; -pub use self::events::{Event, SandboxLifecycle}; -pub use self::names::event_name; -pub use self::redaction::{ - build_redacted_event_payload, event_payload_from_redacted_json, redacted_event_json, -}; -pub use self::sink::{ - RunEventLogger, RunEventPersistenceError, RunEventSink, StoreProgressLogger, append_event, - append_event_if, append_event_to_sink, create_run, -}; -pub use self::stored_fields::{actor_from_principal, principal_from_actor}; -pub use crate::stage_scope::StageScope; diff --git a/lib/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs deleted file mode 100644 index bd48b3241..000000000 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ /dev/null @@ -1,2324 +0,0 @@ -use ::fabro_types::{ - EventBody, RunControlAction, RunEvent, RunId, StageOutcome, run_event as fabro_types, -}; -use chrono::Utc; -use uuid::Uuid; - -use super::stored_fields::stored_event_fields; -use super::{Event, SandboxLifecycle}; -use crate::stage_scope::StageScope; - -fn stage_status_from_string(status: &str) -> StageOutcome { - status.parse().unwrap_or_else(|_| { - tracing::warn!( - status, - "unknown stage status in StageCompleted event; using Fail" - ); - StageOutcome::Failed { - retry_requested: false, - } - }) -} - -/// Project the sandbox layer's runtime push attempts into the durable -/// `git.push` attempt shape. -/// -/// This is the only place the runtime attempt record crosses into stored -/// events: the token snapshot flattens into the three flat `token_*` fields -/// (a nested provenance enum never appears in stored events), and the retry -/// classifier's verdict becomes `classified_reason`. -fn git_push_attempt_props( - attempts: &[fabro_sandbox::PushAttempt], -) -> Vec { - attempts - .iter() - .map(|attempt| fabro_types::GitPushAttemptProps { - attempt: attempt.attempt, - started_at: attempt.started_at, - success: attempt.success, - classified_reason: attempt.retry_reason, - exec_output_tail: attempt.exec_output_tail.clone(), - token_generation: attempt.token.map(|token| token.generation), - token_provenance: attempt.token.map(|token| match token.provenance { - fabro_sandbox::TokenProvenance::Minted { .. } => { - fabro_types::GitTokenProvenance::Minted - } - fabro_sandbox::TokenProvenance::Reused { .. } => { - fabro_types::GitTokenProvenance::Reused - } - fabro_sandbox::TokenProvenance::Static => fabro_types::GitTokenProvenance::Static, - }), - token_age_ms: attempt - .token - .and_then(|token| token.age_at(attempt.started_at)) - .map(|age| u64::try_from(age.as_millis()).unwrap_or(u64::MAX)), - }) - .collect() -} - -fn event_body_from_event(event: &Event) -> EventBody { - match event { - Event::RunCreated { - title, - settings, - graph, - workflow_source, - labels, - source_directory, - workflow_slug, - workflow_version_id, - target, - automation, - provenance, - spec_blob, - git, - fork_source_ref, - retried_from, - parent_id, - web_url, - admission, - .. - } => EventBody::RunCreated(fabro_types::RunCreatedProps { - title: title.clone(), - settings: serde_json::from_value(settings.clone()) - .expect("run.created settings should deserialize: value was serialized from a typed struct in this session"), - graph: serde_json::from_value(graph.clone()).expect("run.created graph should deserialize: value was serialized from a typed struct in this session"), - workflow_source: workflow_source.clone(), - labels: labels.clone(), - source_directory: source_directory.clone(), - workflow_slug: workflow_slug.clone(), - workflow_version_id: *workflow_version_id, - target: target.clone(), - automation: automation.clone(), - provenance: provenance.clone(), - spec_blob: *spec_blob, - git: git.clone(), - fork_source_ref: fork_source_ref.clone(), - retried_from: *retried_from, - parent_id: *parent_id, - web_url: web_url.clone(), - admission: admission.clone(), - }), - Event::WorkflowRunStarted { - name, - base_branch, - base_sha, - run_branch, - worktree_dir, - goal, - .. - } => EventBody::RunStarted(fabro_types::RunStartedProps { - name: name.clone(), - base_branch: base_branch.clone(), - base_sha: base_sha.clone(), - run_branch: run_branch.clone(), - worktree_dir: worktree_dir.clone(), - goal: goal.clone(), - }), - Event::RunSubmitted { definition_blob } => { - EventBody::RunSubmitted(fabro_types::RunSubmittedProps { - definition_blob: *definition_blob, - }) - } - Event::RunStartRequested { resume, .. } => { - EventBody::RunStartRequested(fabro_types::RunStartRequestedProps { resume: *resume }) - } - Event::RunPending { reason, .. } => { - EventBody::RunPending(fabro_types::RunPendingProps { reason: *reason }) - } - Event::RunApproved { .. } => { - EventBody::RunApproved(fabro_types::RunApprovedProps::default()) - } - Event::RunDenied { reason, .. } => EventBody::RunDenied(fabro_types::RunDeniedProps { - reason: reason.clone(), - }), - Event::RunRunnable { source, .. } => { - EventBody::RunRunnable(fabro_types::RunRunnableProps { source: *source }) - } - Event::RunStarting => { - EventBody::RunStarting(fabro_types::RunStatusTransitionProps::default()) - } - Event::RunRunning => { - EventBody::RunRunning(fabro_types::RunStatusTransitionProps::default()) - } - Event::RunInterrupt { .. } => { - EventBody::RunInterrupt(fabro_types::RunInterruptProps::default()) - } - Event::RunSteer { text, .. } => { - EventBody::RunSteer(fabro_types::RunSteerProps { text: text.clone() }) - } - Event::RunPairStarted { - pair_id, target, .. - } => EventBody::RunPairStarted(fabro_types::RunPairStartedProps { - pair_id: *pair_id, - target: target.clone(), - }), - Event::RunPairEnded { - pair_id, reason, .. - } => EventBody::RunPairEnded(fabro_types::RunPairEndedProps { - pair_id: *pair_id, - reason: *reason, - }), - Event::RunPairFailed { - pair_id, - reason, - message, - .. - } => EventBody::RunPairFailed(fabro_types::RunPairFailedProps { - pair_id: *pair_id, - reason: *reason, - message: message.clone(), - }), - Event::RunBlocked { blocked_reason } => { - EventBody::RunBlocked(fabro_types::RunBlockedProps { - blocked_reason: *blocked_reason, - }) - } - Event::RunUnblocked => { - EventBody::RunUnblocked(fabro_types::RunStatusEffectProps::default()) - } - Event::RunRemoving => { - EventBody::RunRemoving(fabro_types::RunStatusTransitionProps::default()) - } - Event::RunCancelRequested { .. } => { - EventBody::RunCancelRequested(fabro_types::RunControlRequestedProps { - action: RunControlAction::Cancel, - }) - } - Event::RunPauseRequested { .. } => { - EventBody::RunPauseRequested(fabro_types::RunControlRequestedProps { - action: RunControlAction::Pause, - }) - } - Event::RunUnpauseRequested { .. } => { - EventBody::RunUnpauseRequested(fabro_types::RunControlRequestedProps { - action: RunControlAction::Unpause, - }) - } - Event::RunPaused => EventBody::RunPaused(fabro_types::RunControlEffectProps::default()), - Event::RunUnpaused => EventBody::RunUnpaused(fabro_types::RunControlEffectProps::default()), - Event::RunSupersededBy { - new_run_id, - target_checkpoint_ordinal, - target_node_id, - target_visit, - } => EventBody::RunSupersededBy(fabro_types::RunSupersededByProps { - new_run_id: *new_run_id, - target_checkpoint_ordinal: *target_checkpoint_ordinal, - target_node_id: target_node_id.clone(), - target_visit: *target_visit, - }), - Event::RunArchived { .. } => { - EventBody::RunArchived(fabro_types::RunArchivedProps::default()) - } - Event::RunUnarchived { .. } => { - EventBody::RunUnarchived(fabro_types::RunUnarchivedProps::default()) - } - Event::RunTitleUpdated { title, .. } => { - EventBody::RunTitleUpdated(fabro_types::RunTitleUpdatedProps { - title: title.clone(), - }) - } - Event::RunParentLinked { - previous_parent_id, - parent_id, - .. - } => EventBody::RunParentLinked(fabro_types::RunParentLinkedProps { - previous_parent_id: *previous_parent_id, - parent_id: *parent_id, - }), - Event::RunParentUnlinked { - previous_parent_id, .. - } => EventBody::RunParentUnlinked(fabro_types::RunParentUnlinkedProps { - previous_parent_id: *previous_parent_id, - }), - Event::WorkflowRunCompleted { - timing, - artifact_count, - status, - reason, - final_git_commit_sha, - final_patch, - diff_summary, - usage, - } => EventBody::RunCompleted(fabro_types::RunCompletedProps { - timing: *timing, - artifact_count: *artifact_count, - status: status.clone(), - reason: *reason, - final_git_commit_sha: final_git_commit_sha.clone(), - final_patch: final_patch.clone(), - diff_summary: *diff_summary, - usage: *usage, - }), - Event::WorkflowRunFailed { - failure, - timing, - final_git_commit_sha, - final_patch, - diff_summary, - usage, - } => EventBody::RunFailed(fabro_types::RunFailedProps { - failure: failure.clone(), - timing: *timing, - final_git_commit_sha: final_git_commit_sha.clone(), - final_patch: final_patch.clone(), - diff_summary: *diff_summary, - usage: *usage, - }), - Event::RunNotice { - level, - code, - message, - exec_output_tail, - } => EventBody::RunNotice(fabro_types::RunNoticeProps { - level: *level, - code: code.clone(), - message: message.clone(), - exec_output_tail: exec_output_tail.clone(), - }), - Event::MetadataSnapshotStarted { phase, branch } => { - EventBody::MetadataSnapshotStarted(fabro_types::MetadataSnapshotStartedProps { - phase: *phase, - branch: branch.clone(), - }) - } - Event::MetadataSnapshotCompleted { - phase, - branch, - duration_ms, - entry_count, - bytes, - commit_sha, - } => EventBody::MetadataSnapshotCompleted(fabro_types::MetadataSnapshotCompletedProps { - phase: *phase, - branch: branch.clone(), - duration_ms: *duration_ms, - entry_count: *entry_count, - bytes: *bytes, - commit_sha: commit_sha.clone(), - }), - Event::MetadataSnapshotFailed { - phase, - branch, - duration_ms, - failure_kind, - error, - causes, - commit_sha, - entry_count, - bytes, - exec_output_tail, - } => EventBody::MetadataSnapshotFailed(fabro_types::MetadataSnapshotFailedProps { - phase: *phase, - branch: branch.clone(), - duration_ms: *duration_ms, - failure_kind: *failure_kind, - error: error.clone(), - causes: causes.clone(), - commit_sha: commit_sha.clone(), - entry_count: *entry_count, - bytes: *bytes, - exec_output_tail: exec_output_tail.clone(), - }), - Event::StageStarted { - index, - handler_type, - attempt, - max_attempts, - graph_visit, - resumed_from_stage_id, - .. - } => EventBody::StageStarted(fabro_types::StageStartedProps { - index: *index, - handler_type: handler_type.clone(), - attempt: *attempt, - max_attempts: *max_attempts, - graph_visit: *graph_visit, - resumed_from_stage_id: resumed_from_stage_id.clone(), - }), - Event::StageCompleted { - index, - timing, - status, - preferred_label, - suggested_next_ids, - usage, - usage_by_model, - failure, - notes, - files_touched, - context_updates, - jump_to_node, - context_values, - node_visits, - loop_failure_signatures, - restart_failure_signatures, - response, - attempt, - max_attempts, - .. - } => EventBody::StageCompleted(fabro_types::StageCompletedProps { - index: *index, - timing: *timing, - status: stage_status_from_string(status), - preferred_label: preferred_label.clone(), - suggested_next_ids: suggested_next_ids.clone(), - usage: usage.clone(), - usage_by_model: usage_by_model.clone(), - failure: failure.clone(), - notes: notes.clone(), - files_touched: files_touched.clone(), - context_updates: context_updates.clone(), - jump_to_node: jump_to_node.clone(), - context_values: context_values.clone(), - node_visits: node_visits.clone(), - loop_failure_signatures: loop_failure_signatures.clone(), - restart_failure_signatures: restart_failure_signatures.clone(), - response: response.clone(), - attempt: *attempt, - max_attempts: *max_attempts, - }), - Event::StageFailed { - index, - failure, - will_retry, - timing, - usage, - usage_by_model, - .. - } => EventBody::StageFailed(fabro_types::StageFailedProps { - index: *index, - failure: Some(failure.clone()), - will_retry: *will_retry, - timing: *timing, - usage: usage.clone(), - usage_by_model: usage_by_model.clone(), - }), - Event::StageRetrying { - index, - attempt, - max_attempts, - delay_ms, - .. - } => EventBody::StageRetrying(fabro_types::StageRetryingProps { - index: *index, - attempt: *attempt, - max_attempts: *max_attempts, - delay_ms: *delay_ms, - }), - Event::ParallelStarted { - visit, - branch_count, - .. - } => EventBody::ParallelStarted(fabro_types::ParallelStartedProps { - visit: *visit, - branch_count: *branch_count, - }), - Event::ParallelBranchStarted { - index, - item_label, - graph_visit, - resumed_from_stage_id, - .. - } => EventBody::ParallelBranchStarted(fabro_types::ParallelBranchStartedProps { - index: *index, - item_label: item_label.clone(), - graph_visit: *graph_visit, - resumed_from_stage_id: resumed_from_stage_id.clone(), - }), - Event::ParallelBranchCompleted { - index, - item_label, - duration_ms, - status, - .. - } => EventBody::ParallelBranchCompleted(fabro_types::ParallelBranchCompletedProps { - index: *index, - item_label: item_label.clone(), - duration_ms: *duration_ms, - status: *status, - }), - Event::ParallelCompleted { - visit, - duration_ms, - success_count, - failure_count, - results, - .. - } => EventBody::ParallelCompleted(fabro_types::ParallelCompletedProps { - visit: *visit, - duration_ms: *duration_ms, - success_count: *success_count, - failure_count: *failure_count, - results: results.clone(), - }), - Event::InterviewStarted { - question_id, - question, - stage, - question_type, - options, - allow_freeform, - timeout_seconds, - context_display, - review_target, - } => EventBody::InterviewStarted(fabro_types::InterviewStartedProps { - question_id: question_id.clone(), - question: question.clone(), - stage: stage.clone(), - question_type: question_type.clone(), - options: options.clone(), - allow_freeform: *allow_freeform, - timeout_seconds: *timeout_seconds, - context_display: context_display.clone(), - review_target: review_target.clone(), - }), - Event::InterviewCompleted { - actor: _, - question_id, - question, - answer, - duration_ms, - } => EventBody::InterviewCompleted(fabro_types::InterviewCompletedProps { - question_id: question_id.clone(), - question: question.clone(), - answer: answer.clone(), - duration_ms: *duration_ms, - }), - Event::InterviewTimeout { - actor: _, - question_id, - question, - stage, - duration_ms, - } => EventBody::InterviewTimeout(fabro_types::InterviewTimeoutProps { - question_id: question_id.clone(), - question: question.clone(), - stage: stage.clone(), - duration_ms: *duration_ms, - }), - Event::InterviewInterrupted { - actor: _, - question_id, - question, - stage, - reason, - duration_ms, - } => EventBody::InterviewInterrupted(fabro_types::InterviewInterruptedProps { - question_id: question_id.clone(), - question: question.clone(), - stage: stage.clone(), - reason: reason.clone(), - duration_ms: *duration_ms, - }), - Event::CheckpointCompleted { - status, - current_node, - completed_nodes, - node_retries, - context_values, - node_outcomes, - next_node_id, - git_commit_sha, - loop_failure_signatures, - restart_failure_signatures, - node_visits, - diff, - diff_summary, - graph_visit, - resumed_from_stage_id, - .. - } => EventBody::CheckpointCompleted(fabro_types::CheckpointCompletedProps { - status: status.clone(), - current_node: current_node.clone(), - completed_nodes: completed_nodes.clone(), - node_retries: node_retries.clone(), - context_values: context_values.clone(), - node_outcomes: node_outcomes.clone(), - next_node_id: next_node_id.clone(), - git_commit_sha: git_commit_sha.clone(), - loop_failure_signatures: loop_failure_signatures.clone(), - restart_failure_signatures: restart_failure_signatures.clone(), - node_visits: node_visits.clone(), - diff: diff.clone(), - diff_summary: *diff_summary, - graph_visit: *graph_visit, - resumed_from_stage_id: resumed_from_stage_id.clone(), - }), - Event::CheckpointFailed { - error, - exec_output_tail, - .. - } => EventBody::CheckpointFailed(fabro_types::CheckpointFailedProps { - error: error.clone(), - exec_output_tail: exec_output_tail.clone(), - }), - Event::GitCommit { sha, .. } => { - EventBody::GitCommit(fabro_types::GitCommitProps { sha: sha.clone() }) - } - Event::GitPush { - branch, - success, - exec_output_tail, - attempts, - } => EventBody::GitPush(fabro_types::GitPushProps { - branch: branch.clone(), - success: *success, - exec_output_tail: exec_output_tail.clone(), - attempts: git_push_attempt_props(attempts), - }), - Event::GitFetch { branch, success } => EventBody::GitFetch(fabro_types::GitFetchProps { - branch: branch.clone(), - success: *success, - }), - Event::GitReset { sha } => { - EventBody::GitReset(fabro_types::GitResetProps { sha: sha.clone() }) - } - Event::EdgeSelected { - from_node, - to_node, - label, - condition, - reason, - preferred_label, - suggested_next_ids, - stage_status, - is_jump, - } => EventBody::EdgeSelected(fabro_types::EdgeSelectedProps { - from_node: from_node.clone(), - to_node: to_node.clone(), - label: label.clone(), - condition: condition.clone(), - reason: reason.clone(), - preferred_label: preferred_label.clone(), - suggested_next_ids: suggested_next_ids.clone(), - stage_status: stage_status.clone(), - is_jump: *is_jump, - }), - Event::LoopRestart { from_node, to_node } => { - EventBody::LoopRestart(fabro_types::LoopRestartProps { - from_node: from_node.clone(), - to_node: to_node.clone(), - }) - } - Event::Prompt { - visit, - text, - mode, - provider, - model, - reasoning_effort, - speed, - .. - } => EventBody::StagePrompt(fabro_types::StagePromptProps { - visit: *visit, - text: text.clone(), - mode: mode.clone(), - provider: provider.clone(), - model: model.clone(), - reasoning_effort: *reasoning_effort, - speed: *speed, - }), - Event::PromptCompleted { - response, - model, - provider, - usage, - .. - } => EventBody::PromptCompleted(fabro_types::PromptCompletedProps { - response: response.clone(), - model: model.clone(), - provider: provider.clone(), - usage: usage.clone(), - }), - Event::Agent { - stage, - visit, - event, - } => EventBody::Agent(fabro_types::AgentEventProps::new( - stage.clone(), - *visit, - event.clone(), - )), - Event::SubgraphStarted { start_node, .. } => { - EventBody::SubgraphStarted(fabro_types::SubgraphStartedProps { - start_node: start_node.clone(), - }) - } - Event::SubgraphCompleted { - steps_executed, - status, - duration_ms, - .. - } => EventBody::SubgraphCompleted(fabro_types::SubgraphCompletedProps { - steps_executed: *steps_executed, - status: status.clone(), - duration_ms: *duration_ms, - }), - Event::Sandbox { event } => match event { - SandboxLifecycle::Initializing { provider } => { - EventBody::SandboxInitializing(fabro_types::SandboxInitializingProps { - provider: provider.clone(), - }) - } - SandboxLifecycle::Ready { - provider, - duration_ms, - name, - url, - } => EventBody::SandboxReady(fabro_types::SandboxReadyProps { - provider: provider.clone(), - duration_ms: *duration_ms, - name: name.clone(), - url: url.clone(), - }), - SandboxLifecycle::InitializeFailed { - provider, - error, - causes, - duration_ms, - } => EventBody::SandboxFailed(fabro_types::SandboxFailedProps { - provider: provider.clone(), - error: error.clone(), - causes: causes.clone(), - duration_ms: *duration_ms, - }), - }, - Event::SandboxDriver { event } => EventBody::sandbox_driver(event.clone()), - Event::SandboxInitialized { - working_directory, - provider, - id, - image, - snapshot, - repo_cloned, - clone_origin_url, - clone_branch, - workspace_root, - repos_root, - primary_repo_path, - primary_repo_link, - } => EventBody::SandboxInitialized(fabro_types::SandboxInitializedProps { - working_directory: working_directory.clone(), - provider: provider.clone(), - id: id.clone(), - image: image.clone(), - snapshot: snapshot.clone(), - repo_cloned: *repo_cloned, - clone_origin_url: clone_origin_url.clone(), - clone_branch: clone_branch.clone(), - workspace_root: workspace_root.clone(), - repos_root: repos_root.clone(), - primary_repo_path: primary_repo_path.clone(), - primary_repo_link: primary_repo_link.clone(), - }), - Event::SetupStarted { command_count } => { - EventBody::SetupStarted(fabro_types::SetupStartedProps { - command_count: *command_count, - }) - } - Event::SetupCommandStarted { command, index } => { - EventBody::SetupCommandStarted(fabro_types::SetupCommandStartedProps { - command: command.clone(), - index: *index, - }) - } - Event::SetupCommandCompleted { - command, - index, - exit_code, - duration_ms, - } => EventBody::SetupCommandCompleted(fabro_types::SetupCommandCompletedProps { - command: command.clone(), - index: *index, - exit_code: *exit_code, - duration_ms: *duration_ms, - }), - Event::SetupCompleted { duration_ms } => { - EventBody::SetupCompleted(fabro_types::SetupCompletedProps { - duration_ms: *duration_ms, - }) - } - Event::GitIdentityResolved { identity } => { - EventBody::GitIdentityResolved(fabro_types::GitIdentityResolvedProps { - identity: identity.clone(), - }) - } - Event::SetupFailed { - command, - index, - exit_code, - stderr, - exec_output_tail, - } => EventBody::SetupFailed(fabro_types::SetupFailedProps { - command: command.clone(), - index: *index, - exit_code: *exit_code, - stderr: stderr.clone(), - exec_output_tail: exec_output_tail.clone(), - }), - Event::StallWatchdogTimeout { idle_seconds, .. } => { - EventBody::StallWatchdogTimeout(fabro_types::StallWatchdogTimeoutProps { - idle_seconds: *idle_seconds, - }) - } - Event::ArtifactCaptured { - attempt, - node_slug, - path, - mime, - content_md5, - content_sha256, - bytes, - .. - } => EventBody::ArtifactCaptured(fabro_types::ArtifactCapturedProps { - attempt: *attempt, - node_slug: node_slug.clone(), - path: path.clone(), - mime: mime.clone(), - content_md5: content_md5.clone(), - content_sha256: content_sha256.clone(), - bytes: *bytes, - }), - Event::SshAccessReady { ssh_command } => { - EventBody::SshAccessReady(fabro_types::SshAccessReadyProps { - ssh_command: ssh_command.clone(), - }) - } - Event::Failover { props, .. } => EventBody::Failover(props.clone()), - Event::CommandStarted { - script, - command, - language, - timeout_ms, - .. - } => EventBody::CommandStarted(fabro_types::CommandStartedProps { - script: script.clone(), - command: command.clone(), - language: language.clone(), - timeout_ms: *timeout_ms, - }), - Event::CommandCompleted { - output, - exit_code, - duration_ms, - termination, - output_bytes, - live_streaming, - .. - } => EventBody::CommandCompleted(fabro_types::CommandCompletedProps { - output: output.clone(), - exit_code: *exit_code, - duration_ms: *duration_ms, - termination: *termination, - output_bytes: *output_bytes, - live_streaming: *live_streaming, - }), - Event::AgentSessionActivated { - thread_id, - provider, - model, - reasoning_effort, - speed, - permission_level, - capabilities, - visit, - .. - } => EventBody::AgentSessionActivated(fabro_types::AgentSessionActivatedProps { - thread_id: thread_id.clone(), - provider: provider.clone(), - model: model.clone(), - reasoning_effort: *reasoning_effort, - speed: *speed, - permission_level: *permission_level, - capabilities: capabilities.clone(), - visit: *visit, - }), - Event::AgentToolsAvailable { tools, visit, .. } => { - EventBody::AgentToolsAvailable(fabro_types::AgentToolsAvailableProps { - tools: tools.clone(), - visit: *visit, - }) - } - Event::AgentSessionDeactivated { visit, .. } => { - EventBody::AgentSessionDeactivated(fabro_types::AgentSessionDeactivatedProps { - visit: *visit, - }) - } - Event::AgentInterruptInjected { visit, .. } => { - EventBody::AgentInterruptInjected(fabro_types::AgentInterruptInjectedProps { - visit: *visit, - }) - } - Event::AgentPairUserMessage { - visit, - pair_id, - message_id, - client_message_id, - text, - .. - } => EventBody::AgentPairUserMessage(fabro_types::AgentPairUserMessageProps { - pair_id: *pair_id, - message_id: *message_id, - client_message_id: client_message_id.clone(), - text: text.clone(), - visit: *visit, - }), - Event::AgentPairSystemMessage { - visit, - pair_id, - kind, - text, - .. - } => EventBody::AgentPairSystemMessage(fabro_types::AgentPairSystemMessageProps { - pair_id: *pair_id, - kind: *kind, - text: text.clone(), - visit: *visit, - }), - Event::AgentSteerBuffered { .. } => { - EventBody::AgentSteerBuffered(fabro_types::AgentSteerBufferedProps::default()) - } - Event::AgentSteerDropped { reason, count, .. } => { - EventBody::AgentSteerDropped(fabro_types::AgentSteerDroppedProps { - reason: *reason, - count: *count, - }) - } - Event::AgentAcpStarted { - visit, - command, - config_name, - .. - } => EventBody::AgentAcpStarted(fabro_types::AgentAcpStartedProps { - visit: *visit, - command: command.clone(), - config_name: config_name.clone(), - }), - Event::AgentAcpCompleted { - stdout, - stderr, - stop_reason, - duration_ms, - .. - } => EventBody::AgentAcpCompleted(fabro_types::AgentAcpCompletedProps { - stdout: stdout.clone(), - stderr: stderr.clone(), - stop_reason: stop_reason.clone(), - duration_ms: *duration_ms, - }), - Event::AgentAcpCancelled { - stdout, - stderr, - duration_ms, - .. - } => EventBody::AgentAcpCancelled(fabro_types::AgentAcpCancelledProps { - stdout: stdout.clone(), - stderr: stderr.clone(), - duration_ms: *duration_ms, - }), - Event::AgentAcpTimedOut { - stdout, - stderr, - duration_ms, - .. - } => EventBody::AgentAcpTimedOut(fabro_types::AgentAcpTimedOutProps { - stdout: stdout.clone(), - stderr: stderr.clone(), - duration_ms: *duration_ms, - }), - Event::PullRequestCreationRequested { - creation_id, - model, - force, - } => EventBody::PullRequestCreationRequested( - fabro_types::PullRequestCreationRequestedProps { - creation_id: *creation_id, - model: model.clone(), - force: *force, - }, - ), - Event::PullRequestCreated { - pr_url, - pr_number, - owner, - repo, - base_branch, - head_branch, - head_sha, - title, - draft, - } => EventBody::PullRequestCreated(fabro_types::PullRequestCreatedProps { - pr_url: pr_url.clone(), - pr_number: *pr_number, - owner: owner.clone(), - repo: repo.clone(), - base_branch: base_branch.clone(), - head_branch: head_branch.clone(), - head_sha: head_sha.clone(), - title: title.clone(), - draft: *draft, - }), - Event::PullRequestLinked { pull_request } => { - EventBody::PullRequestLinked(fabro_types::PullRequestLinkedProps { - pull_request: pull_request.clone(), - }) - } - Event::PullRequestUnlinked { pull_request } => { - EventBody::PullRequestUnlinked(fabro_types::PullRequestUnlinkedProps { - pull_request: pull_request.clone(), - }) - } - Event::PullRequestFailed { creation_id, error } => { - EventBody::PullRequestFailed(fabro_types::PullRequestFailedProps { - creation_id: *creation_id, - error: error.clone(), - }) - } - } -} - -#[must_use] -pub fn to_run_event(run_id: &RunId, event: &Event) -> RunEvent { - to_run_event_at(run_id, event, Utc::now(), None) -} - -#[must_use] -pub fn to_run_event_at( - run_id: &RunId, - event: &Event, - ts: chrono::DateTime, - scope: Option<&StageScope>, -) -> RunEvent { - let fields = stored_event_fields(event, scope); - let body = event_body_from_event(event); - RunEvent { - id: Uuid::now_v7().to_string(), - ts, - run_id: *run_id, - node_id: fields.node_id, - node_label: fields.node_label, - stage_id: fields.stage_id, - parallel_group_id: fields.parallel_group_id, - parallel_branch_id: fields.parallel_branch_id, - session_id: fields.session_id, - parent_session_id: fields.parent_session_id, - tool_call_id: fields.tool_call_id, - actor: fields.actor, - body, - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use ::fabro_types::{ - AutomationRef, EventBody, FailureReason, ParallelBranchId, Principal, RunNoticeCode, - RunNoticeLevel, RunProvenance, StageId, SystemActorKind, fixtures, - run_event as fabro_types, test_support, - }; - use chrono::Utc; - use lithos_llm::types::ReasoningOutput; - use pebble_coding_agent::events::{ - CodingAgentEvent, CodingEvent, Cost, CostSource, TokenCounts, Usage, - }; - - use super::*; - use crate::error::Error; - use crate::event::test_support::user_principal; - use crate::event::{Event, StageScope}; - use crate::outcome::FailureDetail; - - #[derive(Debug)] - struct EventTestCause; - - impl std::fmt::Display for EventTestCause { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("connection refused") - } - } - - impl std::error::Error for EventTestCause {} - - fn exec_tail() -> fabro_types::ExecOutputTail { - fabro_types::ExecOutputTail { - stdout: Some("last stdout line".to_string()), - stderr: Some("last stderr line".to_string()), - stdout_truncated: false, - stderr_truncated: true, - } - } - - use crate::test_support::test_usage; - - #[test] - fn run_event_stage_completed_places_node_fields_in_header() { - let stored = to_run_event_at( - &fixtures::RUN_2, - &Event::StageCompleted { - node_id: "plan".to_string(), - name: "Plan".to_string(), - index: 0, - timing: ::fabro_types::StageTiming::wall_only(5000), - status: "succeeded".to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - usage_by_model: Vec::new(), - usage: None, - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: None, - restart_failure_signatures: None, - response: None, - attempt: 1, - max_attempts: 1, - }, - Utc::now(), - Some(&StageScope { - node_id: "plan".to_string(), - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, - }), - ); - - assert_eq!(stored.event_name(), "stage.completed"); - assert_eq!(stored.run_id, fixtures::RUN_2); - assert_eq!(stored.node_id.as_deref(), Some("plan")); - assert_eq!(stored.node_label.as_deref(), Some("Plan")); - assert_eq!(stored.stage_id, Some(StageId::new("plan", 1))); - let properties = stored.properties().unwrap(); - assert_eq!(properties["timing"]["wall_time_ms"], 5000); - assert_eq!(properties["timing"]["active_time_ms"], 0); - assert_eq!(properties["status"], "succeeded"); - assert!(stored.session_id.is_none()); - } - - #[test] - fn run_event_stage_completed_keeps_response_and_signature_snapshots() { - let stored = to_run_event(&fixtures::RUN_2, &Event::StageCompleted { - node_id: "plan".to_string(), - name: "Plan".to_string(), - index: 0, - timing: ::fabro_types::StageTiming::wall_only(5000), - status: "succeeded".to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - usage_by_model: Vec::new(), - usage: None, - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: Some(BTreeMap::from([("sig-a".to_string(), 2usize)])), - restart_failure_signatures: Some(BTreeMap::from([("sig-b".to_string(), 1usize)])), - response: Some("done".to_string()), - attempt: 1, - max_attempts: 1, - }); - - let properties = stored.properties().unwrap(); - assert_eq!(properties["response"], "done"); - assert_eq!(properties["loop_failure_signatures"]["sig-a"], 2); - assert_eq!(properties["restart_failure_signatures"]["sig-b"], 1); - } - - #[test] - fn run_event_stage_failure_keeps_failure_detail() { - let usage = test_usage("gpt-5.2", 321, 54); - let stored = to_run_event(&fixtures::RUN_3, &Event::StageFailed { - node_id: "code".to_string(), - name: "Code".to_string(), - index: 1, - failure: FailureDetail::new( - "lint failed", - crate::outcome::FailureCategory::Deterministic, - ), - will_retry: true, - timing: ::fabro_types::StageTiming::wall_only(5000), - usage_by_model: Vec::new(), - usage: Some(usage.clone()), - actor: None, - }); - - assert_eq!(stored.event_name(), "stage.failed"); - let properties = stored.properties().unwrap(); - assert_eq!(properties["failure"]["message"], "lint failed"); - assert_eq!(properties["failure"]["category"], "deterministic"); - assert_eq!(properties["will_retry"], true); - assert_eq!(properties["usage"], serde_json::to_value(&usage).unwrap()); - } - - #[test] - fn run_event_agent_tools_available_moves_session_and_stage_metadata_to_header() { - let stored = to_run_event(&fixtures::RUN_4, &Event::AgentToolsAvailable { - node_id: "code".to_string(), - visit: 2, - session_id: "ses_root".to_string(), - tools: vec![::fabro_types::ToolSummary { - name: "apply_patch".to_string(), - description: "Apply a unified diff patch".to_string(), - source: ::fabro_types::ToolSource::Native, - category: ::fabro_types::ToolCategory::Write, - invoked: false, - }], - }); - - assert_eq!(stored.event_name(), "agent.tools.available"); - assert_eq!(stored.node_id.as_deref(), Some("code")); - assert_eq!(stored.stage_id, Some(StageId::new("code", 2))); - assert_eq!(stored.session_id.as_deref(), Some("ses_root")); - let properties = stored.properties().unwrap(); - assert_eq!(properties["visit"], 2); - assert_eq!(properties["tools"][0]["name"], "apply_patch"); - assert_eq!(properties["tools"][0]["category"], "write"); - } - - #[test] - fn run_event_sandbox_event_keeps_properties_nested() { - let stored = to_run_event(&fixtures::RUN_5, &Event::Sandbox { - event: SandboxLifecycle::Ready { - provider: "daytona".to_string(), - duration_ms: 2500, - name: Some("sandbox-1".to_string()), - url: Some("https://example.test".to_string()), - }, - }); - - assert_eq!(stored.event_name(), "sandbox.ready"); - assert!(stored.node_id.is_none()); - let properties = stored.properties().unwrap(); - assert_eq!(properties["provider"], "daytona"); - assert_eq!(properties["duration_ms"], 2500); - } - - #[test] - fn run_event_driver_events_are_named_from_the_subject_action_and_phase() { - let stopped = to_run_event(&fixtures::RUN_5, &Event::SandboxDriver { - event: driver_event(serde_json::json!({ - "id": {"source_id": "test", "sequence": 1}, - "occurred_at": "2026-05-09T12:00:00Z", - "provider": "docker", - "subject": {"type": "sandbox", "id": "container-1"}, - "type": "operation_completed", - "action": "stop", - "duration": {"secs": 0, "nanos": 10_000_000} - })), - }); - let building = to_run_event(&fixtures::RUN_5, &Event::SandboxDriver { - event: driver_event(serde_json::json!({ - "id": {"source_id": "test", "sequence": 2}, - "occurred_at": "2026-05-09T12:00:01Z", - "provider": "daytona", - "subject": {"type": "snapshot", "name": "sandbox-driver-abc"}, - "type": "operation_started", - "action": "create" - })), - }); - - assert_eq!(stopped.event_name(), "sandbox.stop.completed"); - assert_eq!(building.event_name(), "snapshot.create.started"); - let properties = stopped.properties().unwrap(); - assert_eq!(properties["action"], "stop"); - assert_eq!(properties["subject"]["id"], "container-1"); - assert_eq!(properties["duration"]["nanos"], 10_000_000); - - // The stored form reads back as the driver's event. - let round_trip: RunEvent = serde_json::from_value(serde_json::to_value(&stopped).unwrap()) - .expect("a stored driver event decodes"); - assert!(matches!( - &round_trip.body, - EventBody::SandboxDriver { name, event } - if name == "sandbox.stop.completed" - && matches!(event.body, sandbox_driver::EventBody::OperationCompleted { .. }) - )); - } - - fn driver_event(value: serde_json::Value) -> sandbox_driver::Event { - serde_json::from_value(value).expect("a driver event") - } - - #[test] - fn run_event_sandbox_failure_serializes_causes() { - let stored = to_run_event(&fixtures::RUN_5, &Event::Sandbox { - event: SandboxLifecycle::InitializeFailed { - provider: "docker".to_string(), - error: "Failed to pull Docker image buildpack-deps:noble".to_string(), - causes: vec!["connection refused".to_string()], - duration_ms: 42, - }, - }); - - assert_eq!(stored.event_name(), "sandbox.failed"); - let properties = stored.properties().unwrap(); - assert_eq!(properties["provider"], "docker"); - assert_eq!( - properties["error"], - "Failed to pull Docker image buildpack-deps:noble" - ); - assert_eq!( - properties["causes"], - serde_json::json!(["connection refused"]) - ); - } - - #[test] - fn run_event_workflow_failure_uses_display_error() { - let event = Event::workflow_run_failed_from_error( - &Error::handler("boom"), - ::fabro_types::RunTiming::wall_only(900), - FailureReason::WorkflowError, - Some("abc123".to_string()), - None, - None, - None, - ); - let stored = to_run_event(&fixtures::RUN_6, &event); - - assert_eq!(stored.event_name(), "run.failed"); - let properties = stored.properties().unwrap(); - assert_eq!(properties["failure"]["detail"]["message"], "boom"); - assert_eq!(properties["timing"]["wall_time_ms"], 900); - } - - #[test] - fn run_event_workflow_failure_serializes_causes() { - let source = EventTestCause; - let event = Event::workflow_run_failed_from_error( - &Error::engine_with_source("Failed to initialize sandbox", source), - ::fabro_types::RunTiming::wall_only(900), - FailureReason::WorkflowError, - None, - None, - None, - None, - ); - let stored = to_run_event(&fixtures::RUN_6, &event); - - let properties = stored.properties().unwrap(); - assert_eq!( - properties["failure"]["detail"]["message"], - "Failed to initialize sandbox" - ); - assert_eq!( - properties["failure"]["detail"]["causes"], - serde_json::json!(["connection refused"]) - ); - } - - #[test] - fn run_event_workflow_failure_projects_nested_failure_contract() { - let source = EventTestCause; - let event = Event::workflow_run_failed_from_error( - &Error::engine_with_source("Failed to initialize sandbox", source), - ::fabro_types::RunTiming::wall_only(900), - FailureReason::SandboxInitFailed, - Some("abc123".to_string()), - None, - None, - None, - ); - let stored = to_run_event(&fixtures::RUN_6, &event); - - assert_eq!(stored.event_name(), "run.failed"); - let properties = stored.properties().unwrap(); - assert_eq!( - properties["failure"]["detail"]["message"], - "Failed to initialize sandbox" - ); - assert_eq!( - properties["failure"]["detail"]["causes"], - serde_json::json!(["connection refused"]) - ); - assert_eq!(properties["failure"]["reason"], "sandbox_init_failed"); - assert_eq!( - properties["failure"]["detail"]["category"], - "transient_infra" - ); - assert_eq!(properties["timing"]["wall_time_ms"], 900); - assert_eq!(properties["final_git_commit_sha"], "abc123"); - assert!(properties.get("error").is_none()); - assert!(properties.get("causes").is_none()); - assert!(properties.get("reason").is_none()); - assert!(properties.get("git_commit_sha").is_none()); - } - - #[test] - fn stage_started_populates_parallel_ids_when_present() { - let stored = to_run_event_at( - &fixtures::RUN_1, - &Event::StageStarted { - graph_visit: None, - resumed_from_stage_id: None, - node_id: "review".to_string(), - name: "review".to_string(), - index: 1, - handler_type: "agent".to_string(), - attempt: 1, - max_attempts: 1, - }, - Utc::now(), - Some(&StageScope { - node_id: "review".to_string(), - visit: 1, - parallel_group_id: Some(StageId::new("fanout", 2)), - parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)), - }), - ); - assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); - assert_eq!( - stored.parallel_branch_id, - Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)) - ); - } - - #[test] - fn parallel_started_populates_group_id_and_public_properties() { - let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelStarted { - node_id: "fanout".to_string(), - visit: 2, - branch_count: 3, - }); - assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); - assert!(stored.parallel_branch_id.is_none()); - assert_eq!( - stored.properties().unwrap(), - serde_json::json!({ - "visit": 2, - "branch_count": 3, - }) - ); - } - - #[test] - fn parallel_branch_completed_public_properties() { - let group_id = StageId::new("fanout", 2); - let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelBranchCompleted { - parallel_group_id: group_id.clone(), - parallel_branch_id: ParallelBranchId::new(group_id, 1), - branch: "review".to_string(), - index: 1, - item_label: Some("api".to_string()), - duration_ms: 42, - status: StageOutcome::Succeeded, - }); - - assert_eq!( - stored.properties().unwrap(), - serde_json::json!({ - "index": 1, - "item_label": "api", - "duration_ms": 42, - "status": "succeeded", - }) - ); - } - - #[test] - fn parallel_completed_exposes_typed_results_in_input_order() { - let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelCompleted { - node_id: "fanout".to_string(), - visit: 2, - duration_ms: 84, - success_count: 1, - failure_count: 1, - results: vec![ - ::fabro_types::ParallelBranchResult { - id: "review_api".to_string(), - index: Some(0), - item_label: Some("api".to_string()), - status: StageOutcome::Succeeded, - context_updates: BTreeMap::from([( - "response.review_api".to_string(), - serde_json::json!("looks good"), - )]), - }, - ::fabro_types::ParallelBranchResult { - id: "review_ux".to_string(), - index: Some(1), - item_label: Some("ux".to_string()), - status: StageOutcome::Failed { - retry_requested: false, - }, - context_updates: BTreeMap::from([( - "response.review_ux".to_string(), - serde_json::json!("needs work"), - )]), - }, - ], - }); - - assert_eq!( - stored.properties().unwrap(), - serde_json::json!({ - "visit": 2, - "duration_ms": 84, - "success_count": 1, - "failure_count": 1, - "results": [ - { - "id": "review_api", - "index": 0, - "item_label": "api", - "status": "succeeded", - "context_updates": {"response.review_api": "looks good"}, - }, - { - "id": "review_ux", - "index": 1, - "item_label": "ux", - "status": "failed", - "context_updates": {"response.review_ux": "needs work"}, - }, - ], - }) - ); - } - - #[test] - fn parallel_branch_started_populates_group_and_branch_ids() { - let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelBranchStarted { - graph_visit: None, - resumed_from_stage_id: None, - parallel_group_id: StageId::new("fanout", 2), - parallel_branch_id: ParallelBranchId::new(StageId::new("fanout", 2), 1), - branch: "review".to_string(), - index: 1, - item_label: Some("api".to_string()), - }); - assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); - assert_eq!( - stored.parallel_branch_id, - Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)) - ); - } - - #[test] - fn agent_interrupt_injected_populates_stage_session_and_actor() { - let actor = Principal::System { - system_kind: SystemActorKind::Engine, - }; - let stored = to_run_event(&fixtures::RUN_1, &Event::AgentInterruptInjected { - node_id: "code".to_string(), - visit: 3, - session_id: "ses_1".to_string(), - actor: Some(actor.clone()), - }); - - assert_eq!(stored.event_name(), "agent.interrupt.injected"); - assert_eq!(stored.node_id.as_deref(), Some("code")); - assert_eq!(stored.node_label.as_deref(), Some("code")); - assert_eq!(stored.stage_id, Some(StageId::new("code", 3))); - assert_eq!(stored.session_id.as_deref(), Some("ses_1")); - assert_eq!(stored.actor, Some(actor)); - match stored.body { - EventBody::AgentInterruptInjected(props) => assert_eq!(props.visit, 3), - other => panic!("unexpected body: {other:?}"), - } - } - - #[test] - fn stage_scope_populates_stage_id_on_non_stage_events() { - // Events tied to a concrete stage execution but lacking scope in their - // own variant fields (CheckpointCompleted, CommandStarted, PromptCompleted, - // Prompt, InterviewStarted, Failover, GitCommit) should pick up stage_id - // / parallel_group_id / parallel_branch_id from the scope argument. - let scope = StageScope { - node_id: "build".to_string(), - visit: 2, - parallel_group_id: Some(StageId::new("fanout", 1)), - parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 1), 0)), - }; - - let command_started = to_run_event_at( - &fixtures::RUN_1, - &Event::CommandStarted { - node_id: "build".to_string(), - script: "echo".to_string(), - command: "echo".to_string(), - language: "shell".to_string(), - timeout_ms: None, - }, - Utc::now(), - Some(&scope), - ); - assert_eq!(command_started.stage_id, Some(StageId::new("build", 2))); - assert_eq!(command_started.parallel_group_id, scope.parallel_group_id); - assert_eq!(command_started.parallel_branch_id, scope.parallel_branch_id); - - let prompt = to_run_event_at( - &fixtures::RUN_1, - &Event::Prompt { - stage: "build".to_string(), - visit: 2, - text: "do it".to_string(), - mode: None, - provider: None, - model: None, - reasoning_effort: None, - speed: None, - }, - Utc::now(), - Some(&scope), - ); - assert_eq!(prompt.stage_id, Some(StageId::new("build", 2))); - - let git_commit = to_run_event_at( - &fixtures::RUN_1, - &Event::GitCommit { - node_id: Some("build".to_string()), - sha: "deadbeef".to_string(), - }, - Utc::now(), - Some(&scope), - ); - assert_eq!(git_commit.stage_id, Some(StageId::new("build", 2))); - } - - #[test] - fn run_level_events_without_scope_leave_stage_id_absent() { - let stored = to_run_event(&fixtures::RUN_1, &Event::RunRunning); - assert!(stored.stage_id.is_none()); - assert!(stored.parallel_group_id.is_none()); - assert!(stored.parallel_branch_id.is_none()); - } - - #[test] - fn control_action_events_carry_actor_in_envelope() { - let actor = user_principal("alice"); - - let cancel = to_run_event(&fixtures::RUN_1, &Event::RunCancelRequested { - actor: Some(actor.clone()), - }); - assert_eq!(cancel.event_name(), "run.cancel.requested"); - assert_eq!(cancel.actor.as_ref().expect("actor set"), &actor); - - let pause = to_run_event(&fixtures::RUN_1, &Event::RunPauseRequested { - actor: Some(actor.clone()), - }); - assert_eq!(pause.actor.as_ref().expect("actor set"), &actor); - - let unpause = to_run_event(&fixtures::RUN_1, &Event::RunUnpauseRequested { - actor: None, - }); - assert!(unpause.actor.is_none()); - } - - #[test] - fn run_archived_round_trips_actor_in_envelope() { - let actor = user_principal("alice"); - - let archived = to_run_event(&fixtures::RUN_1, &Event::RunArchived { - actor: Some(actor.clone()), - }); - assert_eq!(archived.event_name(), "run.archived"); - assert_eq!(archived.actor.as_ref().expect("actor set"), &actor); - assert!(matches!(archived.body, EventBody::RunArchived(_))); - } - - #[test] - fn run_unarchived_round_trips_actor_in_envelope() { - let actor = user_principal("bob"); - - let unarchived = to_run_event(&fixtures::RUN_1, &Event::RunUnarchived { - actor: Some(actor.clone()), - }); - assert_eq!(unarchived.event_name(), "run.unarchived"); - assert_eq!(unarchived.actor.as_ref().expect("actor set"), &actor); - match &unarchived.body { - EventBody::RunUnarchived(_) => {} - other => panic!("expected RunUnarchived body, got {other:?}"), - } - } - - #[test] - fn run_notice_maps_exec_output_tail_to_props() { - let stored = to_run_event(&fixtures::RUN_1, &Event::RunNotice { - level: RunNoticeLevel::Warn, - code: RunNoticeCode::GitDiffFailed.to_string(), - message: "git diff failed".to_string(), - exec_output_tail: Some(exec_tail()), - }); - - match stored.body { - EventBody::RunNotice(props) => { - let tail = props.exec_output_tail.expect("exec output tail"); - assert_eq!(tail.stderr.as_deref(), Some("last stderr line")); - assert!(tail.stderr_truncated); - } - other => panic!("expected RunNotice body, got {other:?}"), - } - } - - #[test] - fn checkpoint_failed_maps_exec_output_tail_to_props() { - let stored = to_run_event(&fixtures::RUN_1, &Event::CheckpointFailed { - node_id: "build".to_string(), - error: "git commit failed".to_string(), - exec_output_tail: Some(exec_tail()), - }); - - match stored.body { - EventBody::CheckpointFailed(props) => { - let tail = props.exec_output_tail.expect("exec output tail"); - assert_eq!(tail.stdout.as_deref(), Some("last stdout line")); - assert!(!tail.stdout_truncated); - } - other => panic!("expected CheckpointFailed body, got {other:?}"), - } - } - - /// The `git.push` attempts contract: every runtime attempt fact - /// round-trips through `GitPushAttemptProps`, the token snapshot is - /// flattened to the three flat token fields (a nested provenance enum - /// never appears in stored events), and optional failure fields are - /// omitted when absent. - #[test] - fn git_push_attempts_round_trip_through_the_durable_shape() { - let started_at = Utc::now(); - let minted_at = started_at - chrono::Duration::milliseconds(180); - let expires_at = started_at + chrono::Duration::minutes(60); - let runtime_attempts = vec![ - fabro_sandbox::PushAttempt { - attempt: 1, - started_at, - success: false, - retry_reason: Some(fabro_sandbox::GitRetryReason::TokenReplication), - exec_output_tail: Some(exec_tail()), - token: Some(fabro_sandbox::TokenSnapshot { - generation: 14, - provenance: fabro_sandbox::TokenProvenance::Minted { - minted_at, - expires_at, - }, - }), - }, - // Terminal classified failure with a refresh error: the last - // attempt carries its classification too. - fabro_sandbox::PushAttempt { - attempt: 2, - started_at: started_at + chrono::Duration::seconds(3), - success: false, - retry_reason: Some(fabro_sandbox::GitRetryReason::TransientInfra), - exec_output_tail: Some(exec_tail()), - token: Some(fabro_sandbox::TokenSnapshot { - generation: 14, - provenance: fabro_sandbox::TokenProvenance::Reused { - minted_at, - expires_at, - }, - }), - }, - ]; - let expected_attempts = git_push_attempt_props(&runtime_attempts); - - let stored = to_run_event(&fixtures::RUN_1, &Event::GitPush { - branch: "fabro/run/01M0DH033P2XSTHAGVBHG6922F".to_string(), - success: false, - exec_output_tail: Some(exec_tail()), - attempts: runtime_attempts, - }); - - let json = serde_json::to_value(&stored).unwrap(); - let serialized = &json["properties"]["attempts"]; - assert_eq!(serialized[0]["attempt"], 1); - assert_eq!(serialized[0]["classified_reason"], "token_replication"); - assert_eq!(serialized[0]["token_generation"], 14); - assert_eq!(serialized[0]["token_provenance"], "minted"); - assert_eq!(serialized[0]["token_age_ms"], 180); - assert_eq!(serialized[1]["classified_reason"], "transient_infra"); - assert_eq!(serialized[1]["token_provenance"], "reused"); - // The provenance enum never nests in stored events. - assert!(serialized[0].get("token").is_none()); - - let round_tripped: ::fabro_types::RunEvent = serde_json::from_value(json).unwrap(); - match round_tripped.body { - EventBody::GitPush(props) => { - assert!(!props.success); - assert_eq!(props.attempts, expected_attempts); - } - other => panic!("expected GitPush body, got {other:?}"), - } - } - - /// Attempts stored by earlier releases carried `credential_action` and - /// `refresh_error` from the origin-URL credential design. The fields are - /// gone; the stored events still read. - #[test] - fn stored_attempts_with_retired_credential_fields_still_deserialize() { - let json = serde_json::json!({ - "attempt": 1, - "started_at": "2026-03-30T12:00:01.000Z", - "success": true, - "token_generation": 3, - "token_provenance": "reused", - "token_age_ms": 120, - "credential_action": "embedded", - "refresh_error": "set_url" - }); - let props: ::fabro_types::run_event::GitPushAttemptProps = - serde_json::from_value(json).unwrap(); - assert_eq!(props.attempt, 1); - assert_eq!(props.token_generation, Some(3)); - assert_eq!( - props.token_provenance, - Some(::fabro_types::run_event::GitTokenProvenance::Reused) - ); - } - - #[test] - fn successful_single_attempt_push_omits_failure_fields() { - let attempts = vec![fabro_sandbox::PushAttempt { - attempt: 1, - started_at: Utc::now(), - success: true, - retry_reason: None, - exec_output_tail: None, - token: Some(fabro_sandbox::TokenSnapshot { - generation: 0, - provenance: fabro_sandbox::TokenProvenance::Static, - }), - }]; - let stored = to_run_event(&fixtures::RUN_1, &Event::GitPush { - branch: "fabro/run/run-1".to_string(), - success: true, - exec_output_tail: None, - attempts, - }); - - let json = serde_json::to_value(&stored).unwrap(); - let attempt = &json["properties"]["attempts"][0]; - assert_eq!(attempt["success"], true); - assert_eq!(attempt["token_provenance"], "static"); - for absent in ["classified_reason", "exec_output_tail", "token_age_ms"] { - assert!(attempt.get(absent).is_none(), "{absent} should be omitted"); - } - } - - /// Events stored before attempts were recorded deserialize with the field - /// absent; the pre-existing three fields are untouched. - #[test] - fn stored_git_push_without_attempts_still_deserializes() { - let json = serde_json::json!({ - "branch": "fabro/run/old", - "success": true - }); - let props: fabro_types::GitPushProps = serde_json::from_value(json).unwrap(); - assert!(props.attempts.is_empty()); - assert!(props.exec_output_tail.is_none()); - } - - #[test] - fn git_push_maps_exec_output_tail_to_props() { - let stored = to_run_event(&fixtures::RUN_1, &Event::GitPush { - branch: "refs/heads/run:refs/heads/run".to_string(), - success: false, - exec_output_tail: Some(exec_tail()), - attempts: Vec::new(), - }); - - match stored.body { - EventBody::GitPush(props) => { - assert!(!props.success); - let tail = props.exec_output_tail.expect("exec output tail"); - assert_eq!(tail.stderr.as_deref(), Some("last stderr line")); - } - other => panic!("expected GitPush body, got {other:?}"), - } - } - - #[test] - fn metadata_snapshot_events_map_to_typed_bodies() { - let started = to_run_event(&fixtures::RUN_1, &Event::MetadataSnapshotStarted { - phase: fabro_types::MetadataSnapshotPhase::Init, - branch: "fabro/metadata/run".to_string(), - }); - - assert_eq!(started.event_name(), "metadata.snapshot.started"); - assert!(started.node_id.is_none()); - assert!(started.stage_id.is_none()); - match started.body { - EventBody::MetadataSnapshotStarted(props) => { - assert_eq!(props.phase, fabro_types::MetadataSnapshotPhase::Init); - assert_eq!(props.branch, "fabro/metadata/run"); - } - other => panic!("expected MetadataSnapshotStarted body, got {other:?}"), - } - - let completed = to_run_event(&fixtures::RUN_1, &Event::MetadataSnapshotCompleted { - phase: fabro_types::MetadataSnapshotPhase::Finalize, - branch: "fabro/metadata/run".to_string(), - duration_ms: 2400, - entry_count: 4, - bytes: 512, - commit_sha: "abc123".to_string(), - }); - - assert_eq!(completed.event_name(), "metadata.snapshot.completed"); - match completed.body { - EventBody::MetadataSnapshotCompleted(props) => { - assert_eq!(props.phase, fabro_types::MetadataSnapshotPhase::Finalize); - assert_eq!(props.duration_ms, 2400); - assert_eq!(props.entry_count, 4); - assert_eq!(props.bytes, 512); - assert_eq!(props.commit_sha, "abc123"); - } - other => panic!("expected MetadataSnapshotCompleted body, got {other:?}"), - } - - let failed = to_run_event(&fixtures::RUN_1, &Event::MetadataSnapshotFailed { - phase: fabro_types::MetadataSnapshotPhase::Checkpoint, - branch: "fabro/metadata/run".to_string(), - duration_ms: 120, - failure_kind: fabro_types::MetadataSnapshotFailureKind::Push, - error: "push rejected".to_string(), - causes: vec!["permission denied".to_string()], - commit_sha: Some("def456".to_string()), - entry_count: Some(4), - bytes: Some(512), - exec_output_tail: Some(fabro_types::ExecOutputTail { - stdout: Some("last stdout line".to_string()), - stderr: Some("last stderr line".to_string()), - stdout_truncated: false, - stderr_truncated: true, - }), - }); - - assert_eq!(failed.event_name(), "metadata.snapshot.failed"); - match failed.body { - EventBody::MetadataSnapshotFailed(props) => { - assert_eq!( - props.failure_kind, - fabro_types::MetadataSnapshotFailureKind::Push - ); - assert_eq!(props.commit_sha.as_deref(), Some("def456")); - assert_eq!(props.entry_count, Some(4)); - assert_eq!(props.bytes, Some(512)); - let tail = props.exec_output_tail.expect("exec output tail"); - assert_eq!(tail.stdout.as_deref(), Some("last stdout line")); - assert_eq!(tail.stderr.as_deref(), Some("last stderr line")); - assert!(tail.stderr_truncated); - assert!(!tail.stdout_truncated); - } - other => panic!("expected MetadataSnapshotFailed body, got {other:?}"), - } - } - - #[test] - fn checkpoint_metadata_snapshot_events_can_be_stage_scoped() { - let scope = StageScope { - node_id: "build".to_string(), - visit: 2, - parallel_group_id: Some(StageId::new("fanout", 1)), - parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 1), 0)), - }; - let stored = to_run_event_at( - &fixtures::RUN_1, - &Event::MetadataSnapshotStarted { - phase: fabro_types::MetadataSnapshotPhase::Checkpoint, - branch: "fabro/metadata/run".to_string(), - }, - Utc::now(), - Some(&scope), - ); - - assert_eq!(stored.node_id.as_deref(), Some("build")); - assert_eq!(stored.node_label.as_deref(), Some("build")); - assert_eq!(stored.stage_id, Some(StageId::new("build", 2))); - assert_eq!(stored.parallel_group_id, scope.parallel_group_id); - assert_eq!(stored.parallel_branch_id, scope.parallel_branch_id); - } - - #[test] - fn agent_acp_events_map_to_event_bodies_with_stage_scope() { - let scope = StageScope { - node_id: "code".to_string(), - visit: 2, - parallel_group_id: Some(StageId::new("fanout", 1)), - parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 1), 0)), - }; - - let started = to_run_event_at( - &fixtures::RUN_1, - &Event::AgentAcpStarted { - node_id: "code".to_string(), - visit: 2, - command: "python fake_agent.py".to_string(), - config_name: Some("fake".to_string()), - }, - Utc::now(), - Some(&scope), - ); - assert_eq!(started.event_name(), "agent.acp.started"); - assert_eq!(started.node_id.as_deref(), Some("code")); - assert_eq!(started.stage_id, Some(StageId::new("code", 2))); - assert_eq!(started.parallel_group_id, scope.parallel_group_id); - assert_eq!(started.parallel_branch_id, scope.parallel_branch_id); - match &started.body { - EventBody::AgentAcpStarted(props) => { - assert_eq!(props.visit, 2); - assert_eq!(props.command, "python fake_agent.py"); - assert_eq!(props.config_name.as_deref(), Some("fake")); - } - other => panic!("expected AgentAcpStarted, got {other:?}"), - } - - let completed = to_run_event_at( - &fixtures::RUN_1, - &Event::AgentAcpCompleted { - node_id: "code".to_string(), - stdout: "done".to_string(), - stderr: "warn".to_string(), - stop_reason: "end_turn".to_string(), - duration_ms: 42, - }, - Utc::now(), - Some(&scope), - ); - assert_eq!(completed.event_name(), "agent.acp.completed"); - match &completed.body { - EventBody::AgentAcpCompleted(props) => { - assert_eq!(props.stdout, "done"); - assert_eq!(props.stderr, "warn"); - assert_eq!(props.stop_reason, "end_turn"); - assert_eq!(props.duration_ms, 42); - } - other => panic!("expected AgentAcpCompleted, got {other:?}"), - } - - let cancelled = to_run_event_at( - &fixtures::RUN_1, - &Event::AgentAcpCancelled { - node_id: "code".to_string(), - stdout: "partial".to_string(), - stderr: "cancelled".to_string(), - duration_ms: 7, - }, - Utc::now(), - Some(&scope), - ); - assert_eq!(cancelled.event_name(), "agent.acp.cancelled"); - assert_eq!(cancelled.stage_id, Some(StageId::new("code", 2))); - assert!(matches!( - cancelled.body, - EventBody::AgentAcpCancelled(fabro_types::AgentAcpCancelledProps { - duration_ms: 7, - .. - }) - )); - - let timed_out = to_run_event_at( - &fixtures::RUN_1, - &Event::AgentAcpTimedOut { - node_id: "code".to_string(), - stdout: "partial".to_string(), - stderr: "timeout".to_string(), - duration_ms: 99, - }, - Utc::now(), - Some(&scope), - ); - assert_eq!(timed_out.event_name(), "agent.acp.timed_out"); - assert_eq!(timed_out.stage_id, Some(StageId::new("code", 2))); - assert!(matches!( - timed_out.body, - EventBody::AgentAcpTimedOut(fabro_types::AgentAcpTimedOutProps { - duration_ms: 99, - .. - }) - )); - } - - #[test] - fn stall_watchdog_timeout_populates_watchdog_actor() { - let stored = to_run_event(&fixtures::RUN_1, &Event::StallWatchdogTimeout { - node: "code".to_string(), - idle_seconds: 60, - }); - - assert_eq!(stored.event_name(), "watchdog.timeout"); - assert_eq!(stored.node_id.as_deref(), Some("code")); - assert_eq!( - stored.actor, - Some(Principal::System { - system_kind: SystemActorKind::Watchdog, - }) - ); - } - - #[test] - fn run_created_populates_user_actor_from_provenance() { - use ::fabro_types::{Graph, WorkflowSettings, fixtures}; - - let provenance = RunProvenance { - server: None, - client: None, - subject: user_principal("alice"), - }; - let automation = AutomationRef { - id: "nightly".to_string(), - name: Some("Nightly".to_string()), - trigger_id: Some("schedule_1".to_string()), - workflow_source: None, - }; - let workflow_version_id = test_support::test_workflow_version_id(); - - let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated { - run_id: fixtures::RUN_1, - title: None, - settings: serde_json::to_value(WorkflowSettings::default()).unwrap(), - graph: serde_json::to_value(Graph::new("test")).unwrap(), - workflow_source: None, - labels: BTreeMap::default(), - source_directory: Some("/tmp/run".to_string()), - workflow_slug: None, - workflow_version_id: Some(workflow_version_id), - target: None, - automation: Some(automation.clone()), - provenance, - spec_blob: None, - git: None, - fork_source_ref: None, - retried_from: None, - parent_id: None, - web_url: None, - admission: ::fabro_types::PetriAdmission::default(), - }); - let actor = stored.actor.as_ref().expect("actor set"); - assert_eq!(actor, &user_principal("alice")); - let EventBody::RunCreated(props) = stored.body else { - panic!("expected run.created body"); - }; - assert_eq!(props.automation, Some(automation)); - assert_eq!(props.workflow_version_id, Some(workflow_version_id)); - } - - fn agent_event(session_id: &str, event: CodingEvent) -> CodingAgentEvent { - CodingAgentEvent::new(session_id, event, std::time::SystemTime::UNIX_EPOCH) - } - - #[test] - fn run_event_agent_tool_started_moves_session_metadata_to_header() { - let stored = to_run_event(&fixtures::RUN_4, &Event::Agent { - stage: "code".to_string(), - visit: 2, - event: agent_event("ses_child", CodingEvent::ToolCallStarted { - tool_name: "read_file".to_string(), - tool_call_id: "call_1".to_string(), - arguments: serde_json::json!({"path": "src/main.rs"}), - }) - .with_parent_session_id("ses_parent"), - }); - - assert_eq!(stored.event_name(), "agent.tool.started"); - assert_eq!(stored.node_id.as_deref(), Some("code")); - assert_eq!(stored.node_label.as_deref(), Some("code")); - assert_eq!(stored.stage_id, Some(StageId::new("code", 2))); - assert_eq!(stored.session_id.as_deref(), Some("ses_child")); - assert_eq!(stored.parent_session_id.as_deref(), Some("ses_parent")); - assert_eq!(stored.tool_call_id.as_deref(), Some("call_1")); - let properties = stored.properties().unwrap(); - assert_eq!(properties["visit"], 2); - assert_eq!(properties["stage"], "code"); - assert_eq!( - properties["event"]["ToolCallStarted"]["tool_name"], - "read_file" - ); - assert_eq!( - properties["event"]["ToolCallStarted"]["tool_call_id"], - "call_1" - ); - } - - #[test] - fn run_event_agent_tool_process_completed_carries_stage_session_and_actor() { - let stored = to_run_event_at( - &fixtures::RUN_4, - &Event::Agent { - stage: "code".to_string(), - visit: 2, - event: agent_event("ses_child", CodingEvent::ToolProcessCompleted { - exit_code: Some(7), - termination: ::fabro_types::CommandTermination::Exited, - duration_ms: 12, - streams_separated: true, - output_bytes_observed: 120, - output_bytes_retained: 100, - output_bytes_omitted: 20, - exec_output_tail: Some(exec_tail()), - }) - .with_parent_session_id("ses_parent") - .with_tool_call_id("call_1"), - }, - Utc::now(), - Some(&StageScope { - node_id: "code".to_string(), - visit: 2, - parallel_group_id: Some(StageId::new("fanout", 2)), - parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)), - }), - ); - - assert_eq!(stored.event_name(), "agent.tool.process.completed"); - assert_eq!(stored.stage_id, Some(StageId::new("code", 2))); - assert_eq!(stored.session_id.as_deref(), Some("ses_child")); - assert_eq!(stored.parent_session_id.as_deref(), Some("ses_parent")); - assert_eq!(stored.tool_call_id.as_deref(), Some("call_1")); - assert_eq!( - stored.actor, - Some(::fabro_types::Principal::Agent { - session_id: Some("ses_child".to_string()), - parent_session_id: Some("ses_parent".to_string()), - model: None, - }) - ); - let properties = stored.properties().unwrap(); - let event = &properties["event"]["ToolProcessCompleted"]; - assert_eq!(event["exit_code"], 7); - assert_eq!(event["termination"], "exited"); - assert_eq!(event["exec_output_tail"]["stdout"], "last stdout line"); - assert_eq!( - stored.parallel_branch_id, - Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)) - ); - } - - #[test] - fn agent_round_interrupted_populates_stage_and_session() { - let stored = to_run_event(&fixtures::RUN_1, &Event::Agent { - stage: "code".to_string(), - visit: 3, - event: agent_event("ses_1", CodingEvent::RoundInterrupted { generation: 2 }), - }); - - assert_eq!(stored.event_name(), "agent.round.interrupted"); - assert_eq!(stored.stage_id, Some(StageId::new("code", 3))); - assert_eq!(stored.session_id.as_deref(), Some("ses_1")); - match stored.body { - EventBody::Agent(props) => { - assert_eq!(props.visit, 3); - assert!(matches!( - props.coding_event(), - CodingEvent::RoundInterrupted { generation: 2 } - )); - } - other => panic!("unexpected body: {other:?}"), - } - } - - #[test] - fn agent_todo_event_uses_the_todo_event_name() { - let stored = to_run_event(&fixtures::RUN_1, &Event::Agent { - stage: "code".to_string(), - visit: 1, - event: agent_event( - "ses_1", - CodingEvent::TodoCreated(::fabro_types::TodoCreatedProps { - list_id: "openai_plan:ses_1".to_string(), - list_kind: ::fabro_types::TodoListKind::OpenAiPlan, - todo_id: "todo_1".to_string(), - status: ::fabro_types::TodoStatus::Pending, - order: 0, - subject: "step".to_string(), - description: String::new(), - active_form: None, - owner: None, - blocks: Vec::new(), - blocked_by: Vec::new(), - metadata: BTreeMap::new(), - }), - ) - .with_tool_call_id("call_todo"), - }); - - assert_eq!(stored.event_name(), "todo.created"); - assert_eq!(stored.session_id.as_deref(), Some("ses_1")); - assert_eq!(stored.tool_call_id.as_deref(), Some("call_todo")); - assert!(matches!(stored.body, EventBody::Agent(_))); - } - - #[test] - fn agent_assistant_message_populates_agent_actor_with_model() { - let stored = to_run_event(&fixtures::RUN_1, &Event::Agent { - stage: "code".to_string(), - visit: 1, - event: agent_event("ses_agent", CodingEvent::AssistantMessage { - text: "ok".to_string(), - model: "claude-sonnet".to_string(), - usage: Usage::default(), - tool_call_count: 0, - context_window: None, - reasoning: None, - }), - }); - - assert_eq!(stored.event_name(), "agent.message"); - let actor = stored.actor.as_ref().expect("actor set"); - assert_eq!(actor, &Principal::Agent { - session_id: Some("ses_agent".to_string()), - parent_session_id: None, - model: Some("claude-sonnet".to_string()), - }); - } - - #[test] - fn agent_assistant_message_round_trips_reasoning_through_the_stored_payload() { - let stored = to_run_event(&fixtures::RUN_1, &Event::Agent { - stage: "code".to_string(), - visit: 1, - event: agent_event("ses_agent", CodingEvent::AssistantMessage { - text: String::new(), - model: "gpt-5.4".to_string(), - usage: Usage { - tokens: TokenCounts::default(), - cost: Some(Cost { - usd_micros: 125_000, - source: CostSource::Provider, - }), - }, - tool_call_count: 1, - context_window: None, - reasoning: Some(ReasoningOutput::new( - "inspect the conversion first", - "read convert.rs, then the sink", - )), - }), - }); - - let value = stored.to_value().unwrap(); - assert_eq!(value["event"], "agent.message"); - let message = &value["properties"]["event"]["AssistantMessage"]; - assert_eq!(message["usage"]["cost"]["usd_micros"], 125_000); - assert_eq!(message["usage"]["cost"]["source"], "provider"); - assert_eq!( - message["reasoning"]["summary"], - "inspect the conversion first" - ); - assert_eq!( - message["reasoning"]["trace"], - "read convert.rs, then the sink" - ); - - let decoded = RunEvent::from_value(value).unwrap(); - let EventBody::Agent(props) = decoded.body else { - panic!("expected agent body"); - }; - assert!(matches!( - props.coding_event(), - CodingEvent::AssistantMessage { - tool_call_count: 1, - .. - } - )); - } -} diff --git a/lib/components/fabro-workflow/src/event/driver_events.rs b/lib/components/fabro-workflow/src/event/driver_events.rs deleted file mode 100644 index a14637685..000000000 --- a/lib/components/fabro-workflow/src/event/driver_events.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! The sandbox driver's events for a run's sandbox, kept as run events. -//! -//! A run's sandbox is created or attached with a driver [`EventContext`] -//! whose observer is a [`DriverEventRecorder`]. Everything the driver -//! reports about the sandbox — the operations it performs and their -//! outcome, progress inside a create such as an image pull, snapshot -//! builds, state observations, notices — is stored whole as an -//! [`Event::SandboxDriver`], named from the event (see -//! `fabro_types::sandbox_driver_event_name`). -//! -//! [`EventContext`]: sandbox_driver::EventContext - -use std::sync::Arc; - -use async_trait::async_trait; -use sandbox_driver::{Event as DriverEvent, EventObserver}; - -use super::{Emitter, Event}; - -/// Records every event the driver reports as a run event. -pub struct DriverEventRecorder { - emitter: Arc, -} - -impl DriverEventRecorder { - pub fn new(emitter: Arc) -> Self { - Self { emitter } - } -} - -#[async_trait] -impl EventObserver for DriverEventRecorder { - async fn observe(&self, event: DriverEvent) { - self.emitter.emit(&Event::SandboxDriver { event }); - } -} diff --git a/lib/components/fabro-workflow/src/event/emitter.rs b/lib/components/fabro-workflow/src/event/emitter.rs deleted file mode 100644 index a99928ed5..000000000 --- a/lib/components/fabro-workflow/src/event/emitter.rs +++ /dev/null @@ -1,261 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; - -use ::fabro_types::{ExecOutputTail, RunEvent, RunId, RunNoticeCode, RunNoticeLevel}; -use chrono::Utc; -use tokio::time::Instant; - -use super::Event; -use super::convert::to_run_event_at; -use super::sink::{RunEventLogger, RunEventPersistenceError}; -use crate::millis_u64; -use crate::stage_scope::StageScope; - -/// Listener callback type for workflow run events. -type EventListener = Arc; - -/// Callback-based event emitter for workflow run events. -pub struct Emitter { - run_id: RunId, - listeners: std::sync::Mutex>, - /// The persistence path. Events reach it before any listener, and - /// [`Emitter::emit_durable`] waits for it. - persisters: std::sync::Mutex>, - /// Monotonic origin that `last_activity_ms` is measured from. - activity_origin: Instant, - /// Milliseconds after `activity_origin` of the last `emit()` or `touch()`. - /// 0 until the first event. - last_activity_ms: AtomicU64, -} - -impl std::fmt::Debug for Emitter { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let count = self.listeners.lock().map_or(0, |l| l.len()); - f.debug_struct("Emitter") - .field("run_id", &self.run_id) - .field("listener_count", &count) - .field( - "last_activity_ms", - &self.last_activity_ms.load(Ordering::Relaxed), - ) - .finish_non_exhaustive() - } -} - -impl Default for Emitter { - fn default() -> Self { - Self::new(RunId::new()) - } -} - -impl Emitter { - #[must_use] - pub fn new(run_id: RunId) -> Self { - Self { - run_id, - listeners: std::sync::Mutex::new(Vec::new()), - persisters: std::sync::Mutex::new(Vec::new()), - activity_origin: Instant::now(), - last_activity_ms: AtomicU64::new(0), - } - } - - #[must_use] - pub fn run_id(&self) -> RunId { - self.run_id - } - - pub fn on_event(&self, listener: impl Fn(&RunEvent) + Send + Sync + 'static) { - self.listeners - .lock() - .expect("listeners lock poisoned") - .push(Arc::new(listener)); - } - - pub(super) fn attach_persistence(&self, logger: RunEventLogger) { - self.persisters - .lock() - .expect("persisters lock poisoned") - .push(logger); - } - - pub fn emit(&self, event: &Event) { - self.emit_with_scope(event, None); - } - - /// Emits `event` and returns once every attached persistence path has - /// accepted it. - /// - /// Listeners see the event only after it is durable. With no persistence - /// attached the event is dispatched to listeners and the call succeeds. - /// - /// # Errors - /// - /// Returns the persistence failure that stopped the run event log. - pub async fn emit_durable( - &self, - event: &Event, - scope: Option<&StageScope>, - ) -> Result<(), RunEventPersistenceError> { - event.trace(); - let stored = to_run_event_at(&self.run_id, event, Utc::now(), scope); - self.record_activity(); - let persisters: Vec = self - .persisters - .lock() - .expect("persisters lock poisoned") - .clone(); - for persister in &persisters { - persister.write_acknowledged(&stored).await?; - } - self.dispatch_to_listeners(&stored); - Ok(()) - } - - pub fn emit_scoped(&self, event: &Event, scope: &StageScope) { - self.emit_with_scope(event, Some(scope)); - } - - pub fn notice(&self, level: RunNoticeLevel, code: RunNoticeCode, message: impl Into) { - self.emit(&Event::RunNotice { - level, - code: code.to_string(), - message: message.into(), - exec_output_tail: None, - }); - } - - pub fn notice_scoped( - &self, - level: RunNoticeLevel, - code: RunNoticeCode, - message: impl Into, - scope: &StageScope, - ) { - self.emit_scoped( - &Event::RunNotice { - level, - code: code.to_string(), - message: message.into(), - exec_output_tail: None, - }, - scope, - ); - } - - pub fn notice_with_tail( - &self, - level: RunNoticeLevel, - code: RunNoticeCode, - message: impl Into, - exec_output_tail: Option, - ) { - self.emit(&Event::RunNotice { - level, - code: code.to_string(), - message: message.into(), - exec_output_tail, - }); - } - - fn emit_with_scope(&self, event: &Event, scope: Option<&StageScope>) { - event.trace(); - if let Event::WorkflowRunStarted { run_id, .. } = event { - debug_assert_eq!( - *run_id, self.run_id, - "workflow run started event must match emitter run_id" - ); - } - let stored = to_run_event_at(&self.run_id, event, Utc::now(), scope); - self.dispatch_run_event(&stored); - } - - pub(crate) fn dispatch_run_event(&self, event: &RunEvent) { - self.record_activity(); - let persisters: Vec = self - .persisters - .lock() - .expect("persisters lock poisoned") - .clone(); - for persister in &persisters { - persister.enqueue(event); - } - self.dispatch_to_listeners(event); - } - - fn dispatch_to_listeners(&self, event: &RunEvent) { - // Clone the listener list so we don't hold the lock during dispatch. - // This prevents deadlocks if a listener calls emit() reentrantly. - // Note: listeners added during this emit() won't receive the current event. - let snapshot: Vec = self - .listeners - .lock() - .expect("listeners lock poisoned") - .clone(); - for listener in &snapshot { - listener(event); - } - } - - /// Manually record activity (e.g. to seed the watchdog at workflow run - /// start, or for agent stream deltas that are not emitted as run events). - pub fn touch(&self) { - self.record_activity(); - } - - /// Called for every event, including agent streaming deltas. Keep this to a - /// single clock read and a relaxed store — the stall watchdog samples it at - /// its own deadline rather than being woken here. - fn record_activity(&self) { - self.last_activity_ms.store( - millis_u64(self.activity_origin.elapsed()), - Ordering::Relaxed, - ); - } -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - - use ::fabro_types::fixtures; - - use super::*; - use crate::event::Event; - - #[test] - fn event_emitter_new_has_no_listeners() { - let emitter = Emitter::new(fixtures::RUN_1); - assert_eq!(emitter.listeners.lock().unwrap().len(), 0); - } - - #[test] - fn event_emitter_calls_listener_with_envelope() { - let emitter = Emitter::new(fixtures::RUN_1); - let received = Arc::new(Mutex::new(Vec::new())); - let received_clone = Arc::clone(&received); - emitter.on_event(move |event| { - received_clone.lock().unwrap().push(event.clone()); - }); - emitter.emit(&Event::WorkflowRunStarted { - name: "test".to_string(), - run_id: fixtures::RUN_1, - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: None, - }); - let events = received.lock().unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_name(), "run.started"); - assert_eq!(events[0].run_id, fixtures::RUN_1); - assert!(events[0].id.len() >= 32); - } - - #[test] - fn event_emitter_default() { - let emitter = Emitter::default(); - assert_eq!(emitter.listeners.lock().unwrap().len(), 0); - } -} diff --git a/lib/components/fabro-workflow/src/event/events.rs b/lib/components/fabro-workflow/src/event/events.rs deleted file mode 100644 index 710e4081a..000000000 --- a/lib/components/fabro-workflow/src/event/events.rs +++ /dev/null @@ -1,1647 +0,0 @@ -use std::collections::BTreeMap; - -use ::fabro_types::{ - AutomationRef, BlobHash, BlockedReason, CommandTermination, DiffSummary, FailureReason, - ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget, - ParallelBranchId, ParallelBranchResult, PendingReason, PermissionLevel, PetriAdmission, - Principal, PullRequestCreationId, PullRequestLink, ReviewTarget, RunFailure, RunId, - RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource, - RunTarget, RunTiming, SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason, - WorkflowVersionId, run_event as fabro_types, -}; -use lithos_llm::types::{ReasoningEffort, Speed, Usage}; -use pebble_coding_agent::events::CodingAgentEvent; -use serde::{Deserialize, Serialize}; - -use crate::error::{Error, run_failure_from_error}; -use crate::outcome::{FailureDetail, ModelUsage, Outcome}; - -/// Events emitted during workflow run execution for observability. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[allow( - clippy::large_enum_variant, - reason = "Workflow events stay inline to match the serialized event stream." -)] -pub enum Event { - RunCreated { - run_id: RunId, - title: Option, - settings: serde_json::Value, - graph: serde_json::Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - workflow_source: Option, - labels: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - source_directory: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - workflow_slug: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - workflow_version_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - target: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - automation: Option, - provenance: RunProvenance, - #[serde(default, skip_serializing_if = "Option::is_none")] - spec_blob: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - git: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - fork_source_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - retried_from: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - parent_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - web_url: Option, - /// The engine the run was created for, with what it admitted. - admission: PetriAdmission, - }, - WorkflowRunStarted { - name: String, - run_id: RunId, - #[serde(default, skip_serializing_if = "Option::is_none")] - base_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - base_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - run_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - worktree_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - goal: Option, - }, - RunSubmitted { - #[serde(default, skip_serializing_if = "Option::is_none")] - definition_blob: Option, - }, - RunStartRequested { - resume: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunPending { - reason: PendingReason, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunApproved { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunDenied { - #[serde(default, skip_serializing_if = "Option::is_none")] - reason: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunRunnable { - source: RunRunnableSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunStarting, - RunRunning, - RunInterrupt { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunSteer { - text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunPairStarted { - pair_id: PairId, - target: PairTarget, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunPairEnded { - pair_id: PairId, - reason: RunPairEndedReason, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunPairFailed { - pair_id: PairId, - reason: RunPairFailedReason, - message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunBlocked { - blocked_reason: BlockedReason, - }, - RunUnblocked, - RunRemoving, - RunCancelRequested { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunPauseRequested { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunUnpauseRequested { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunPaused, - RunUnpaused, - RunSupersededBy { - new_run_id: RunId, - target_checkpoint_ordinal: usize, - target_node_id: String, - target_visit: usize, - }, - RunArchived { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunUnarchived { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunTitleUpdated { - title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunParentLinked { - #[serde(default, skip_serializing_if = "Option::is_none")] - previous_parent_id: Option, - parent_id: RunId, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunParentUnlinked { - previous_parent_id: RunId, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - WorkflowRunCompleted { - timing: RunTiming, - artifact_count: usize, - #[serde(default)] - status: String, - reason: SuccessReason, - #[serde(default, skip_serializing_if = "Option::is_none")] - final_git_commit_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - final_patch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - diff_summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - usage: Option, - }, - WorkflowRunFailed { - failure: RunFailure, - timing: RunTiming, - #[serde(default, skip_serializing_if = "Option::is_none")] - final_git_commit_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - final_patch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - diff_summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - usage: Option, - }, - RunNotice { - level: RunNoticeLevel, - code: String, - message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - exec_output_tail: Option, - }, - MetadataSnapshotStarted { - phase: fabro_types::MetadataSnapshotPhase, - branch: String, - }, - MetadataSnapshotCompleted { - phase: fabro_types::MetadataSnapshotPhase, - branch: String, - duration_ms: u64, - entry_count: usize, - bytes: u64, - commit_sha: String, - }, - MetadataSnapshotFailed { - phase: fabro_types::MetadataSnapshotPhase, - branch: String, - duration_ms: u64, - failure_kind: fabro_types::MetadataSnapshotFailureKind, - error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - causes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - commit_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - entry_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bytes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - exec_output_tail: Option, - }, - StageStarted { - node_id: String, - name: String, - index: usize, - handler_type: String, - attempt: usize, - max_attempts: usize, - /// Graph visit that produced this stage execution. Diverges from the - /// envelope `StageId` ordinal when post-checkpoint work is replayed - /// after resume. - #[serde(default, skip_serializing_if = "Option::is_none")] - graph_visit: Option, - /// Prior execution superseded by this resumed replay, for the first - /// execution reserved after a resume when the node had an - /// observable post-checkpoint execution. - #[serde(default, skip_serializing_if = "Option::is_none")] - resumed_from_stage_id: Option, - }, - StageCompleted { - node_id: String, - name: String, - index: usize, - timing: StageTiming, - status: String, - preferred_label: Option, - suggested_next_ids: Vec, - usage: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - usage_by_model: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - failure: Option, - notes: Option, - files_touched: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - context_updates: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - jump_to_node: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - context_values: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - node_visits: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - loop_failure_signatures: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - restart_failure_signatures: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - response: Option, - attempt: usize, - max_attempts: usize, - }, - StageFailed { - node_id: String, - name: String, - index: usize, - failure: FailureDetail, - will_retry: bool, - timing: StageTiming, - usage: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - usage_by_model: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - StageRetrying { - node_id: String, - name: String, - index: usize, - attempt: usize, - max_attempts: usize, - delay_ms: u64, - }, - ParallelStarted { - node_id: String, - visit: u32, - branch_count: usize, - }, - ParallelBranchStarted { - parallel_group_id: StageId, - parallel_branch_id: ParallelBranchId, - branch: String, - index: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - item_label: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - graph_visit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - resumed_from_stage_id: Option, - }, - ParallelBranchCompleted { - parallel_group_id: StageId, - parallel_branch_id: ParallelBranchId, - branch: String, - index: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - item_label: Option, - duration_ms: u64, - status: StageOutcome, - }, - ParallelCompleted { - node_id: String, - visit: u32, - duration_ms: u64, - success_count: usize, - failure_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - results: Vec, - }, - InterviewStarted { - question_id: String, - question: String, - stage: String, - question_type: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - options: Vec, - #[serde(default)] - allow_freeform: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - timeout_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - context_display: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - review_target: Option, - }, - InterviewCompleted { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - question_id: String, - question: String, - answer: String, - duration_ms: u64, - }, - InterviewTimeout { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - question_id: String, - question: String, - stage: String, - duration_ms: u64, - }, - InterviewInterrupted { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - question_id: String, - question: String, - stage: String, - reason: String, - duration_ms: u64, - }, - CheckpointCompleted { - node_id: String, - status: String, - current_node: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - completed_nodes: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - node_retries: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - context_values: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - node_outcomes: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - next_node_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - git_commit_sha: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - loop_failure_signatures: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - restart_failure_signatures: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - node_visits: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - diff: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - diff_summary: Option, - /// Graph visit of the checkpointed stage execution; used when this - /// checkpoint is the event that first materializes a skipped stage. - #[serde(default, skip_serializing_if = "Option::is_none")] - graph_visit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - resumed_from_stage_id: Option, - }, - CheckpointFailed { - node_id: String, - error: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - exec_output_tail: Option, - }, - GitCommit { - #[serde(default, skip_serializing_if = "Option::is_none")] - node_id: Option, - sha: String, - }, - GitPush { - branch: String, - success: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - exec_output_tail: Option, - /// Per-attempt history of the push operation. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - attempts: Vec, - }, - GitFetch { - branch: String, - success: bool, - }, - GitReset { - sha: String, - }, - EdgeSelected { - from_node: String, - to_node: String, - label: Option, - condition: Option, - /// Which selection step chose this edge (e.g. "condition", - /// "preferred_label", "jump"). - reason: String, - /// The stage's preferred label hint, if any. - #[serde(default, skip_serializing_if = "Option::is_none")] - preferred_label: Option, - /// The stage's suggested next node IDs, if any. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - suggested_next_ids: Vec, - /// The stage outcome status that influenced routing. - stage_status: String, - /// Whether this was a direct jump (bypassing normal edge selection). - is_jump: bool, - }, - LoopRestart { - from_node: String, - to_node: String, - }, - Prompt { - stage: String, - visit: u32, - text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - reasoning_effort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - speed: Option, - }, - PromptCompleted { - node_id: String, - response: String, - model: String, - provider: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - usage: Option, - }, - /// One coding-agent event, tagged with the workflow stage that produced - /// it. Pebble's envelope is kept whole: `seq`, `stream_id`, session ids, - /// `tool_call_id`, and `timestamp`. - Agent { - stage: String, - visit: u32, - event: CodingAgentEvent, - }, - SubgraphStarted { - node_id: String, - start_node: String, - }, - SubgraphCompleted { - node_id: String, - steps_executed: usize, - status: String, - duration_ms: u64, - }, - /// A fact about the run's sandbox from the pipeline bringing it up. - Sandbox { - event: SandboxLifecycle, - }, - /// An event the sandbox driver reported about the run's sandbox (an - /// operation and its outcome, progress inside a create, a state - /// observation, a notice), kept whole. Named from the event; see - /// `fabro_types::sandbox_driver_event_name`. - SandboxDriver { - event: sandbox_driver::Event, - }, - /// Emitted after the sandbox has been initialized (by engine lifecycle). - SandboxInitialized { - working_directory: String, - provider: SandboxProviderKind, - id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - image: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - snapshot: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - repo_cloned: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - clone_origin_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - clone_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - workspace_root: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - repos_root: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - primary_repo_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - primary_repo_link: Option, - }, - SetupStarted { - command_count: usize, - }, - SetupCommandStarted { - command: String, - index: usize, - }, - SetupCommandCompleted { - command: String, - index: usize, - exit_code: i32, - duration_ms: u64, - }, - SetupCompleted { - duration_ms: u64, - }, - /// The run resolved the Git author/committer identity it uses for every - /// commit: engine checkpoints, metadata commits, and workflow commands. - GitIdentityResolved { - identity: ::fabro_types::GitIdentity, - }, - SetupFailed { - command: String, - index: usize, - exit_code: i32, - stderr: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - exec_output_tail: Option, - }, - StallWatchdogTimeout { - node: String, - idle_seconds: u64, - }, - ArtifactCaptured { - node_id: String, - attempt: u32, - node_slug: String, - path: String, - mime: String, - content_md5: String, - content_sha256: String, - bytes: u64, - }, - SshAccessReady { - ssh_command: String, - }, - Failover { - stage: String, - props: fabro_types::FailoverProps, - }, - CommandStarted { - node_id: String, - script: String, - command: String, - language: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - timeout_ms: Option, - }, - CommandCompleted { - node_id: String, - output: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - exit_code: Option, - duration_ms: u64, - termination: CommandTermination, - output_bytes: u64, - live_streaming: bool, - }, - /// A stage has a currently steerable live session binding. - AgentSessionActivated { - node_id: String, - visit: u32, - session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - thread_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - reasoning_effort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - speed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - permission_level: Option, - capabilities: Vec, - }, - /// Effective model-callable tools for a stage session after profile setup, - /// optional registrations, MCP integration, and access-policy filtering. - AgentToolsAvailable { - node_id: String, - visit: u32, - session_id: String, - tools: Vec<::fabro_types::ToolSummary>, - }, - /// A stage's steerable live session binding ended. - AgentSessionDeactivated { - node_id: String, - visit: u32, - session_id: String, - }, - /// A run-level interrupt was delivered to a concrete steerable agent - /// session/stage. - AgentInterruptInjected { - node_id: String, - visit: u32, - session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - AgentPairUserMessage { - node_id: String, - visit: u32, - session_id: String, - pair_id: PairId, - message_id: PairMessageId, - #[serde(default, skip_serializing_if = "Option::is_none")] - client_message_id: Option, - text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - AgentPairSystemMessage { - node_id: String, - visit: u32, - session_id: String, - pair_id: PairId, - kind: PairSystemMessageKind, - text: String, - }, - /// A steer arrived with no active session and was parked in the run-wide - /// pending buffer. The actor (steer author) is lifted to top-level. - AgentSteerBuffered { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - /// One or more buffered/queued steers were dropped because a cap was - /// reached or the run ended before they could be delivered. - AgentSteerDropped { - reason: fabro_types::AgentSteerDroppedReason, - count: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - node_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - visit: Option, - }, - AgentAcpStarted { - node_id: String, - visit: u32, - command: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - config_name: Option, - }, - AgentAcpCompleted { - node_id: String, - stdout: String, - stderr: String, - stop_reason: String, - duration_ms: u64, - }, - AgentAcpCancelled { - node_id: String, - stdout: String, - stderr: String, - duration_ms: u64, - }, - AgentAcpTimedOut { - node_id: String, - stdout: String, - stderr: String, - duration_ms: u64, - }, - PullRequestCreationRequested { - creation_id: PullRequestCreationId, - model: String, - force: bool, - }, - PullRequestCreated { - pr_url: String, - pr_number: u64, - owner: String, - repo: String, - base_branch: String, - head_branch: String, - /// Absent on events written before the head SHA was recorded. - #[serde(default, skip_serializing_if = "Option::is_none")] - head_sha: Option, - title: String, - draft: bool, - }, - PullRequestLinked { - pull_request: PullRequestLink, - }, - PullRequestUnlinked { - pull_request: PullRequestLink, - }, - PullRequestFailed { - /// Set when the failure resolves an explicitly requested creation; - /// `None` for pull request failures in the workflow publish stage. - creation_id: Option, - error: String, - }, -} - -/// The lifecycle of a run's sandbox as workflow events. -/// -/// Initializing, ready, and failed are the pipeline's view of bringing the -/// sandbox up — create, activate, and prepare the workspace as one step. -/// The rest are the sandbox driver's own operations and snapshot work, -/// the driver's own events are kept whole as [`Event::SandboxDriver`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum SandboxLifecycle { - Initializing { - provider: String, - }, - Ready { - provider: String, - duration_ms: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - url: Option, - }, - InitializeFailed { - provider: String, - error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - causes: Vec, - duration_ms: u64, - }, -} - -impl SandboxLifecycle { - pub fn trace(&self) { - use tracing::{debug, error, info}; - match self { - Self::Initializing { provider } => { - debug!(provider, "Sandbox initializing"); - } - Self::Ready { - provider, - duration_ms, - .. - } => { - info!(provider, duration_ms, "Sandbox ready"); - } - Self::InitializeFailed { - provider, - error, - causes, - duration_ms, - } => { - error!(provider, error, causes = ?causes, duration_ms, "Sandbox init failed"); - } - } - } -} - -impl Event { - #[must_use] - pub fn workflow_run_failed_from_error( - error: &Error, - timing: RunTiming, - reason: FailureReason, - final_git_commit_sha: Option, - final_patch: Option, - diff_summary: Option, - usage: Option, - ) -> Self { - Self::WorkflowRunFailed { - failure: run_failure_from_error(error, reason), - timing, - final_git_commit_sha, - final_patch, - diff_summary, - usage, - } - } - - pub fn pull_request_created( - record: &PullRequestLink, - base_branch: &str, - head_branch: &str, - head_sha: &str, - title: &str, - draft: bool, - ) -> Self { - Self::PullRequestCreated { - pr_url: record.html_url(), - pr_number: record.number, - owner: record.owner.clone(), - repo: record.repo.clone(), - base_branch: base_branch.to_string(), - head_branch: head_branch.to_string(), - head_sha: Some(head_sha.to_string()), - title: title.to_string(), - draft, - } - } - - pub fn trace(&self) { - use tracing::{debug, error, info, warn}; - match self { - Self::RunCreated { run_id, .. } => { - info!(run_id = %run_id, "Run created"); - } - Self::WorkflowRunStarted { name, run_id, .. } => { - info!(workflow = name.as_str(), run_id = %run_id, "Workflow run started"); - } - Self::RunSubmitted { definition_blob } => { - info!(?definition_blob, "Run submitted"); - } - Self::RunStartRequested { resume, .. } => { - info!(resume, "Run start requested"); - } - Self::RunPending { reason, .. } => { - info!(?reason, "Run pending"); - } - Self::RunApproved { .. } => { - info!("Run approved"); - } - Self::RunDenied { reason, .. } => { - info!(?reason, "Run denied"); - } - Self::RunRunnable { source, .. } => { - info!(?source, "Run runnable"); - } - Self::RunStarting => { - info!("Run starting"); - } - Self::RunRunning => { - info!("Run running"); - } - Self::RunInterrupt { .. } => { - info!("Run interrupt accepted"); - } - Self::RunSteer { text, .. } => { - info!(text_len = text.len(), "Run steer accepted"); - } - Self::RunPairStarted { - pair_id, target, .. - } => { - info!( - %pair_id, - stage_id = %target.stage_id, - node_label = %target.node_label, - "Run pairing started", - ); - } - Self::RunPairEnded { - pair_id, reason, .. - } => { - info!(%pair_id, ?reason, "Run pairing ended"); - } - Self::RunPairFailed { - pair_id, - reason, - message, - .. - } => { - warn!(%pair_id, ?reason, message, "Run pairing failed"); - } - Self::RunBlocked { blocked_reason } => { - info!(?blocked_reason, "Run blocked"); - } - Self::RunUnblocked => { - info!("Run unblocked"); - } - Self::RunRemoving => { - info!("Run removing"); - } - Self::RunCancelRequested { .. } => { - info!("Run cancel requested"); - } - Self::RunPauseRequested { .. } => { - info!("Run pause requested"); - } - Self::RunUnpauseRequested { .. } => { - info!("Run unpause requested"); - } - Self::RunPaused => { - info!("Run paused"); - } - Self::RunUnpaused => { - info!("Run unpaused"); - } - Self::RunSupersededBy { - new_run_id, - target_checkpoint_ordinal, - target_node_id, - target_visit, - } => { - info!( - %new_run_id, - target_checkpoint_ordinal, - target_node_id, - target_visit, - "Run superseded by new run" - ); - } - Self::RunArchived { actor } => { - info!(?actor, "Run archived"); - } - Self::RunUnarchived { actor } => { - info!(?actor, "Run unarchived"); - } - Self::RunTitleUpdated { title, actor } => { - info!(title, ?actor, "Run title updated"); - } - Self::RunParentLinked { - previous_parent_id, - parent_id, - actor, - } => { - info!(?previous_parent_id, %parent_id, ?actor, "Run parent linked"); - } - Self::RunParentUnlinked { - previous_parent_id, - actor, - } => { - info!(%previous_parent_id, ?actor, "Run parent unlinked"); - } - Self::WorkflowRunCompleted { - timing, - artifact_count, - status, - .. - } => { - info!( - wall_time_ms = timing.wall_time_ms, - active_time_ms = timing.active_time_ms, - inference_time_ms = timing.inference_time_ms, - tool_time_ms = timing.tool_time_ms, - artifact_count, - status, - "Workflow run completed" - ); - } - Self::WorkflowRunFailed { - failure, timing, .. - } => { - let detail = &failure.detail; - let tail = - fabro_types::ExecOutputTail::trace_summary(detail.exec_output_tail.as_ref()); - error!( - message = %detail.message, - reason = %failure.reason, - category = %detail.category, - system_actor = ?detail.system_actor, - signature = ?detail.signature, - cause_count = detail.causes.len(), - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - wall_time_ms = timing.wall_time_ms, - active_time_ms = timing.active_time_ms, - "Workflow run failed" - ); - } - Self::RunNotice { - level, - code, - message, - exec_output_tail, - } => match level { - RunNoticeLevel::Info => { - let tail = - fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - info!( - code, - message, - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "Run notice" - ); - } - RunNoticeLevel::Warn => { - let tail = - fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - warn!( - code, - message, - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "Run notice" - ); - } - RunNoticeLevel::Error => { - let tail = - fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - error!( - code, - message, - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "Run notice" - ); - } - }, - Self::MetadataSnapshotStarted { phase, branch } => { - debug!(%phase, branch, "Metadata snapshot started"); - } - Self::MetadataSnapshotCompleted { - phase, - branch, - duration_ms, - .. - } => { - info!(%phase, branch, duration_ms, "Metadata snapshot completed"); - } - Self::MetadataSnapshotFailed { - phase, - branch, - duration_ms, - failure_kind, - error, - exec_output_tail, - .. - } => { - let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - warn!( - %phase, - branch, - duration_ms, - %failure_kind, - error, - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "Metadata snapshot failed" - ); - } - Self::StageStarted { - node_id, - name, - index, - handler_type, - attempt, - max_attempts, - .. - } => { - info!( - node_id, - stage = name.as_str(), - index, - handler_type, - attempt, - max_attempts, - "Stage started" - ); - } - Self::StageCompleted { - node_id, - name, - index, - timing, - status, - attempt, - max_attempts, - .. - } => { - info!( - node_id, - stage = name.as_str(), - index, - wall_time_ms = timing.wall_time_ms, - active_time_ms = timing.active_time_ms, - inference_time_ms = timing.inference_time_ms, - tool_time_ms = timing.tool_time_ms, - status, - attempt, - max_attempts, - "Stage completed" - ); - } - Self::StageFailed { - node_id, - name, - index, - failure, - will_retry, - .. - } => { - let error_msg = &failure.message; - if *will_retry { - warn!( - node_id, - stage = name.as_str(), - index, - error = error_msg.as_str(), - will_retry, - "Stage failed" - ); - } else { - error!( - node_id, - stage = name.as_str(), - index, - error = error_msg.as_str(), - will_retry, - "Stage failed" - ); - } - } - Self::StageRetrying { - node_id, - name, - index, - attempt, - max_attempts, - delay_ms, - .. - } => { - warn!( - node_id, - stage = name.as_str(), - index, - attempt, - max_attempts, - delay_ms, - "Stage retrying" - ); - } - Self::ParallelStarted { branch_count, .. } => { - debug!(branch_count, "Parallel execution started"); - } - Self::ParallelBranchStarted { branch, index, .. } => { - debug!(branch, index, "Parallel branch started"); - } - Self::ParallelBranchCompleted { - branch, - index, - duration_ms, - status, - .. - } => { - debug!( - branch, - index, - duration_ms, - status = %status, - "Parallel branch completed" - ); - } - Self::ParallelCompleted { - duration_ms, - success_count, - failure_count, - results, - .. - } => { - debug!( - duration_ms, - success_count, - failure_count, - result_count = results.len(), - "Parallel execution completed" - ); - } - Self::InterviewStarted { - stage, - question_type, - .. - } => { - debug!(stage, question_type, "Interview started"); - } - Self::InterviewCompleted { duration_ms, .. } => { - debug!(duration_ms, "Interview completed"); - } - Self::InterviewTimeout { - stage, duration_ms, .. - } => { - warn!(stage, duration_ms, "Interview timeout"); - } - Self::InterviewInterrupted { - stage, - reason, - duration_ms, - .. - } => { - warn!(stage, reason, duration_ms, "Interview interrupted"); - } - Self::CheckpointCompleted { - node_id, - status, - completed_nodes, - .. - } => { - info!( - node_id, - status, - completed_count = completed_nodes.len(), - "Checkpoint completed" - ); - } - Self::CheckpointFailed { - node_id, - error, - exec_output_tail, - } => { - let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - error!( - node_id, - error, - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "Checkpoint failed" - ); - } - Self::GitCommit { node_id, sha } => { - debug!( - node_id = node_id.as_deref().unwrap_or(""), - sha, "Git commit" - ); - } - Self::GitPush { - branch, - success, - exec_output_tail, - attempts, - } => { - if *success { - debug!(branch, attempts = attempts.len(), "Git push succeeded"); - } else { - let tail = - fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - warn!( - branch, - attempts = attempts.len(), - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "Git push failed" - ); - } - } - Self::GitFetch { branch, success } => { - if *success { - debug!(branch, "Git fetch succeeded"); - } else { - warn!(branch, "Git fetch failed"); - } - } - Self::GitReset { sha } => { - debug!(sha, "Git reset"); - } - Self::EdgeSelected { - from_node, - to_node, - label, - reason, - .. - } => { - info!( - from_node, - to_node, - label = label.as_deref().unwrap_or(""), - reason, - "Edge selected" - ); - } - Self::LoopRestart { from_node, to_node } => { - debug!(from_node, to_node, "Loop restart"); - } - Self::Prompt { - stage, - text, - mode, - provider, - model, - .. - } => { - debug!( - stage, - text_len = text.len(), - mode = mode.as_deref().unwrap_or(""), - provider = provider.as_deref().unwrap_or(""), - model = model.as_deref().unwrap_or(""), - "Prompt sent" - ); - } - Self::PromptCompleted { - node_id, - model, - provider, - .. - } => { - debug!(node_id, model, provider, "Prompt completed"); - } - Self::Agent { event, .. } => event.event.trace(&event.session_id), - Self::Sandbox { event } => event.trace(), - Self::SandboxDriver { event } => trace_driver_event(event), - Self::SandboxInitialized { - working_directory, - provider, - id, - .. - } => { - info!( - working_directory, - provider = %provider, - id, - "Sandbox initialized" - ); - } - Self::SubgraphStarted { - node_id, - start_node, - } => { - debug!(node_id, start_node, "Subgraph started"); - } - Self::SubgraphCompleted { - node_id, - steps_executed, - status, - duration_ms, - } => { - debug!( - node_id, - steps_executed, status, duration_ms, "Subgraph completed" - ); - } - Self::SetupStarted { command_count } => { - info!(command_count, "Setup started"); - } - Self::SetupCommandStarted { command, index } => { - debug!(command, index, "Setup command started"); - } - Self::SetupCommandCompleted { - command, - index, - exit_code, - duration_ms, - } => { - debug!( - command, - index, exit_code, duration_ms, "Setup command completed" - ); - } - Self::SetupCompleted { duration_ms } => { - info!(duration_ms, "Setup completed"); - } - Self::GitIdentityResolved { identity } => { - info!( - name = %identity.name, - email = %identity.email, - source = %identity.source, - "Git identity resolved" - ); - } - Self::SetupFailed { - command, - index, - exit_code, - exec_output_tail, - .. - } => { - let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - error!( - command, - index, - exit_code, - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "Setup command failed" - ); - } - Self::StallWatchdogTimeout { node, idle_seconds } => { - warn!(node, idle_seconds, "Stall watchdog timeout"); - } - Self::ArtifactCaptured { - node_id, - node_slug, - attempt, - path, - bytes, - .. - } => { - debug!( - node_id, - node_slug, attempt, path, bytes, "Artifact captured" - ); - } - Self::SshAccessReady { ssh_command } => { - info!(ssh_command, "SSH access ready"); - } - Self::Failover { stage, props } => { - warn!( - stage, - attempt = ?props.attempt, - from_provider = %props.from_provider, - from_model = %props.from_model, - to_provider = %props.to_provider, - to_model = %props.to_model, - error = %props.error, - "Prompt stage moved to a fallback route" - ); - } - Self::CommandStarted { - node_id, - language, - timeout_ms, - .. - } => { - debug!(node_id, language, timeout_ms, "Command started"); - } - Self::CommandCompleted { - node_id, - exit_code, - duration_ms, - termination, - output_bytes, - .. - } => { - debug!( - node_id, - exit_code, - duration_ms, - termination = %termination, - output_bytes, - "Command completed" - ); - } - Self::AgentSessionActivated { - node_id, - visit, - session_id, - .. - } => { - debug!(node_id, visit, session_id, "Agent session activated"); - } - Self::AgentToolsAvailable { - node_id, - visit, - session_id, - tools, - } => { - debug!( - node_id, - visit, - session_id, - tool_count = tools.len(), - "Agent tools available" - ); - } - Self::AgentSessionDeactivated { - node_id, - visit, - session_id, - } => { - debug!(node_id, visit, session_id, "Agent session deactivated"); - } - Self::AgentInterruptInjected { - node_id, - visit, - session_id, - .. - } => { - debug!(node_id, visit, session_id, "Agent interrupt injected"); - } - Self::AgentPairUserMessage { - node_id, - visit, - session_id, - pair_id, - text, - .. - } => { - debug!(node_id, visit, session_id, %pair_id, text_len = text.len(), "Agent pair user message accepted"); - } - Self::AgentPairSystemMessage { - node_id, - visit, - session_id, - pair_id, - kind, - .. - } => { - debug!(node_id, visit, session_id, %pair_id, ?kind, "Agent pair system message queued"); - } - Self::AgentSteerBuffered { .. } => { - debug!("Steer buffered (no active session)"); - } - Self::AgentSteerDropped { reason, count, .. } => { - warn!(?reason, count, "Steer dropped"); - } - Self::AgentAcpStarted { - node_id, - command, - config_name, - .. - } => { - debug!(node_id, command, ?config_name, "Agent ACP started"); - } - Self::AgentAcpCompleted { - node_id, - stop_reason, - duration_ms, - .. - } => { - debug!(node_id, stop_reason, duration_ms, "Agent ACP completed"); - } - Self::AgentAcpCancelled { - node_id, - duration_ms, - .. - } => { - debug!(node_id, duration_ms, "Agent ACP cancelled"); - } - Self::AgentAcpTimedOut { - node_id, - duration_ms, - .. - } => { - debug!(node_id, duration_ms, "Agent ACP timed out"); - } - Self::PullRequestCreationRequested { - creation_id, - model, - force, - } => { - info!(creation_id = %creation_id, model, force, "Pull request creation requested"); - } - Self::PullRequestCreated { - pr_url, - pr_number, - draft, - owner, - repo, - .. - } => { - info!(pr_url = %pr_url, pr_number, draft, owner, repo, "Pull request created"); - } - Self::PullRequestLinked { pull_request } => { - info!( - pr_url = %pull_request.html_url(), - pr_number = pull_request.number, - "Pull request linked" - ); - } - Self::PullRequestUnlinked { pull_request } => { - info!( - pr_url = %pull_request.html_url(), - pr_number = pull_request.number, - "Pull request unlinked" - ); - } - Self::PullRequestFailed { error, .. } => { - error!(error = %error, "Pull request creation failed"); - } - } - } -} - -/// Traces a sandbox driver event under its run event name. -fn trace_driver_event(event: &sandbox_driver::Event) { - use sandbox_driver::EventBody as Body; - use tracing::{debug, info, warn}; - - let name = fabro_types::sandbox_driver_event_name(event); - match &event.body { - Body::OperationFailed { error, .. } => { - warn!(event = %name, error = %error.message, "Sandbox driver operation failed"); - } - Body::OperationCompleted { duration, .. } => { - let duration_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX); - info!(event = %name, duration_ms, "Sandbox driver operation completed"); - } - Body::OperationStarted { .. } => info!(event = %name, "Sandbox driver operation started"), - _ => debug!(event = %name, "Sandbox driver event"), - } -} diff --git a/lib/components/fabro-workflow/src/event/names.rs b/lib/components/fabro-workflow/src/event/names.rs deleted file mode 100644 index c3acf4c18..000000000 --- a/lib/components/fabro-workflow/src/event/names.rs +++ /dev/null @@ -1,214 +0,0 @@ -use std::borrow::Cow; - -use super::{Event, SandboxLifecycle}; - -#[must_use] -pub fn event_name(event: &Event) -> Cow<'static, str> { - let name: &'static str = match event { - Event::SandboxDriver { event } => { - return Cow::Owned(fabro_types::sandbox_driver_event_name(event)); - } - Event::RunCreated { .. } => "run.created", - Event::WorkflowRunStarted { .. } => "run.started", - Event::RunSubmitted { .. } => "run.submitted", - Event::RunStartRequested { .. } => "run.start_requested", - Event::RunPending { .. } => "run.pending", - Event::RunApproved { .. } => "run.approved", - Event::RunDenied { .. } => "run.denied", - Event::RunRunnable { .. } => "run.runnable", - Event::RunStarting => "run.starting", - Event::RunRunning => "run.running", - Event::RunInterrupt { .. } => "run.interrupt", - Event::RunSteer { .. } => "run.steer", - Event::RunPairStarted { .. } => "run.pair.started", - Event::RunPairEnded { .. } => "run.pair.ended", - Event::RunPairFailed { .. } => "run.pair.failed", - Event::RunBlocked { .. } => "run.blocked", - Event::RunUnblocked => "run.unblocked", - Event::RunRemoving => "run.removing", - Event::RunCancelRequested { .. } => "run.cancel.requested", - Event::RunPauseRequested { .. } => "run.pause.requested", - Event::RunUnpauseRequested { .. } => "run.unpause.requested", - Event::RunPaused => "run.paused", - Event::RunUnpaused => "run.unpaused", - Event::RunSupersededBy { .. } => "run.superseded_by", - Event::RunArchived { .. } => "run.archived", - Event::RunUnarchived { .. } => "run.unarchived", - Event::RunTitleUpdated { .. } => "run.title.updated", - Event::RunParentLinked { .. } => "run.parent.linked", - Event::RunParentUnlinked { .. } => "run.parent.unlinked", - Event::WorkflowRunCompleted { .. } => "run.completed", - Event::WorkflowRunFailed { .. } => "run.failed", - Event::RunNotice { .. } => "run.notice", - Event::MetadataSnapshotStarted { .. } => "metadata.snapshot.started", - Event::MetadataSnapshotCompleted { .. } => "metadata.snapshot.completed", - Event::MetadataSnapshotFailed { .. } => "metadata.snapshot.failed", - Event::StageStarted { .. } => "stage.started", - Event::StageCompleted { .. } => "stage.completed", - Event::StageFailed { .. } => "stage.failed", - Event::StageRetrying { .. } => "stage.retrying", - Event::ParallelStarted { .. } => "parallel.started", - Event::ParallelBranchStarted { .. } => "parallel.branch.started", - Event::ParallelBranchCompleted { .. } => "parallel.branch.completed", - Event::ParallelCompleted { .. } => "parallel.completed", - Event::InterviewStarted { .. } => "interview.started", - Event::InterviewCompleted { .. } => "interview.completed", - Event::InterviewTimeout { .. } => "interview.timeout", - Event::InterviewInterrupted { .. } => "interview.interrupted", - Event::CheckpointCompleted { .. } => "checkpoint.completed", - Event::CheckpointFailed { .. } => "checkpoint.failed", - Event::GitCommit { .. } => "git.commit", - Event::GitPush { .. } => "git.push", - Event::GitFetch { .. } => "git.fetch", - Event::GitReset { .. } => "git.reset", - Event::EdgeSelected { .. } => "edge.selected", - Event::LoopRestart { .. } => "loop.restart", - Event::Prompt { .. } => "stage.prompt", - Event::PromptCompleted { .. } => "prompt.completed", - Event::Agent { event, .. } => fabro_types::coding_event_name(&event.event), - Event::SubgraphStarted { .. } => "subgraph.started", - Event::SubgraphCompleted { .. } => "subgraph.completed", - Event::Sandbox { event } => match event { - SandboxLifecycle::Initializing { .. } => "sandbox.initializing", - SandboxLifecycle::Ready { .. } => "sandbox.ready", - SandboxLifecycle::InitializeFailed { .. } => "sandbox.failed", - }, - Event::SandboxInitialized { .. } => "sandbox.initialized", - Event::SetupStarted { .. } => "setup.started", - Event::SetupCommandStarted { .. } => "setup.command.started", - Event::SetupCommandCompleted { .. } => "setup.command.completed", - Event::SetupCompleted { .. } => "setup.completed", - Event::GitIdentityResolved { .. } => "git.identity.resolved", - Event::SetupFailed { .. } => "setup.failed", - Event::StallWatchdogTimeout { .. } => "watchdog.timeout", - Event::ArtifactCaptured { .. } => "artifact.captured", - Event::SshAccessReady { .. } => "ssh.ready", - Event::Failover { .. } => "prompt.failover", - Event::CommandStarted { .. } => "command.started", - Event::CommandCompleted { .. } => "command.completed", - Event::AgentSessionActivated { .. } => "agent.session.activated", - Event::AgentToolsAvailable { .. } => "agent.tools.available", - Event::AgentSessionDeactivated { .. } => "agent.session.deactivated", - Event::AgentInterruptInjected { .. } => "agent.interrupt.injected", - Event::AgentPairUserMessage { .. } => "agent.pair.user_message", - Event::AgentPairSystemMessage { .. } => "agent.pair.system_message", - Event::AgentSteerBuffered { .. } => "agent.steer.buffered", - Event::AgentSteerDropped { .. } => "agent.steer.dropped", - Event::AgentAcpStarted { .. } => "agent.acp.started", - Event::AgentAcpCompleted { .. } => "agent.acp.completed", - Event::AgentAcpCancelled { .. } => "agent.acp.cancelled", - Event::AgentAcpTimedOut { .. } => "agent.acp.timed_out", - Event::PullRequestCreationRequested { .. } => "pull_request.creation_requested", - Event::PullRequestCreated { .. } => "pull_request.created", - Event::PullRequestLinked { .. } => "pull_request.linked", - Event::PullRequestUnlinked { .. } => "pull_request.unlinked", - Event::PullRequestFailed { .. } => "pull_request.failed", - }; - Cow::Borrowed(name) -} - -#[cfg(test)] -mod tests { - use ::fabro_types::{ParallelBranchId, StageId}; - use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent}; - - use super::*; - use crate::event::Event; - - #[test] - fn event_name_matches_new_dot_notation() { - assert_eq!( - event_name(&Event::ParallelBranchStarted { - graph_visit: None, - resumed_from_stage_id: None, - parallel_group_id: StageId::new("plan", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("plan", 1), 0), - branch: "fork".to_string(), - index: 0, - item_label: None, - }), - "parallel.branch.started" - ); - assert_eq!( - event_name(&Event::Failover { - stage: "code".to_string(), - props: fabro_types::FailoverProps { - from_provider: "anthropic".to_string(), - from_model: "claude-fable-5".to_string(), - to_provider: "openai".to_string(), - to_model: "gpt-5.6-sol".to_string(), - attempt: Some(1), - error: "overloaded".to_string(), - }, - }), - "prompt.failover" - ); - assert_eq!( - event_name(&Event::Agent { - stage: "code".to_string(), - visit: 1, - event: CodingAgentEvent::new( - "ses_test".to_string(), - CodingEvent::SubAgentSpawned { - agent_id: "a1".to_string(), - depth: 1, - task: "do it".to_string(), - generation: 1, - }, - std::time::SystemTime::UNIX_EPOCH, - ), - }), - "agent.sub.spawned" - ); - assert_eq!( - event_name(&Event::Agent { - stage: "code".to_string(), - visit: 1, - event: CodingAgentEvent::new( - "ses_test".to_string(), - CodingEvent::SubAgentTurnStarted { - agent_id: "a1".to_string(), - depth: 1, - task: "fix it".to_string(), - generation: 2, - }, - std::time::SystemTime::UNIX_EPOCH, - ), - }), - "agent.sub.turn.started" - ); - assert_eq!( - event_name(&Event::Agent { - stage: "code".to_string(), - visit: 1, - event: CodingAgentEvent::new( - "session-1".to_string(), - CodingEvent::RoundInterrupted { generation: 1 }, - std::time::SystemTime::UNIX_EPOCH, - ), - }), - "agent.round.interrupted" - ); - assert_eq!( - event_name(&Event::AgentToolsAvailable { - node_id: "code".to_string(), - visit: 1, - session_id: "session-1".to_string(), - tools: Vec::new(), - }), - "agent.tools.available" - ); - } - - #[test] - fn run_archived_event_name_matches_dot_notation() { - assert_eq!( - event_name(&Event::RunArchived { actor: None }), - "run.archived" - ); - assert_eq!( - event_name(&Event::RunUnarchived { actor: None }), - "run.unarchived" - ); - } -} diff --git a/lib/components/fabro-workflow/src/event/redaction.rs b/lib/components/fabro-workflow/src/event/redaction.rs deleted file mode 100644 index f5de7b104..000000000 --- a/lib/components/fabro-workflow/src/event/redaction.rs +++ /dev/null @@ -1,153 +0,0 @@ -use ::fabro_types::{RunEvent, RunId}; -use anyhow::{Context, Result}; -use fabro_redact::redact_json_value; -use fabro_store::EventPayload; -use fabro_util::json::normalize_json_value; -use serde_json::Value; - -pub fn build_redacted_event_payload(event: &RunEvent, run_id: &RunId) -> Result { - let value = redacted_event_value(event)?; - EventPayload::new(value, run_id).map_err(anyhow::Error::from) -} - -pub fn redacted_event_json(event: &RunEvent) -> Result { - serde_json::to_string(&redacted_event_value(event)?).map_err(anyhow::Error::from) -} - -fn normalized_event_value(event: &RunEvent) -> Result { - let value = event.to_value()?; - Ok(normalize_json_value(value)) -} - -fn redacted_event_value(event: &RunEvent) -> Result { - Ok(redact_json_value(normalized_event_value(event)?)) -} - -pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result { - let value = serde_json::from_str(line).context("Failed to parse redacted event payload")?; - EventPayload::new(value, run_id).map_err(anyhow::Error::from) -} - -#[cfg(test)] -mod tests { - use ::fabro_types::{fixtures, run_event as fabro_types}; - use lithos_llm::types::ReasoningOutput; - use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent, Usage}; - - use super::*; - use crate::event::{Event, to_run_event}; - - #[test] - fn build_redacted_event_payload_requires_id() { - let stored = to_run_event(&fixtures::RUN_8, &Event::RunSubmitted { - definition_blob: None, - }); - let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap(); - assert_eq!(payload.as_value()["id"], stored.id); - assert_eq!(payload.as_value()["event"], "run.submitted"); - } - - #[test] - fn build_redacted_event_payload_redacts_exec_output_tail_values() { - let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; - let stored = to_run_event(&fixtures::RUN_8, &Event::SetupFailed { - command: "setup".to_string(), - index: 0, - exit_code: 1, - stderr: "compat stderr".to_string(), - exec_output_tail: Some(fabro_types::ExecOutputTail { - stdout: Some(format!("stdout {secret}")), - stderr: Some("plain stderr".to_string()), - stdout_truncated: false, - stderr_truncated: false, - }), - }); - - let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap(); - let payload_text = serde_json::to_string(payload.as_value()).unwrap(); - - assert!(!payload_text.contains(secret)); - assert!(payload_text.contains("REDACTED")); - assert_eq!(payload.as_value()["event"], "setup.failed"); - assert_eq!( - payload.as_value()["properties"]["exec_output_tail"]["stderr"], - "plain stderr" - ); - } - - #[test] - fn build_redacted_event_payload_redacts_tool_process_output_tails() { - let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; - let stored = to_run_event(&fixtures::RUN_8, &Event::Agent { - stage: "code".to_string(), - visit: 1, - event: CodingAgentEvent::new( - "ses_child".to_string(), - CodingEvent::ToolProcessCompleted { - exit_code: Some(7), - termination: ::fabro_types::CommandTermination::Exited, - duration_ms: 12, - streams_separated: true, - output_bytes_observed: 100, - output_bytes_retained: 100, - output_bytes_omitted: 0, - exec_output_tail: Some(fabro_types::ExecOutputTail { - stdout: Some(format!("stdout {secret}")), - stderr: Some("plain stderr".to_string()), - stdout_truncated: false, - stderr_truncated: false, - }), - }, - std::time::SystemTime::UNIX_EPOCH, - ) - .with_tool_call_id("call_1".to_string()), - }); - - let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap(); - let payload_text = serde_json::to_string(payload.as_value()).unwrap(); - - assert!(!payload_text.contains(secret)); - assert!(payload_text.contains("REDACTED")); - assert_eq!(payload.as_value()["event"], "agent.tool.process.completed"); - assert_eq!( - payload.as_value()["properties"]["event"]["ToolProcessCompleted"]["exec_output_tail"]["stderr"], - "plain stderr" - ); - } - - /// Reasoning is model-authored text like any other, so it goes through - /// the same canonical redaction pass as assistant output. - #[test] - fn build_redacted_event_payload_redacts_secrets_inside_reasoning() { - let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; - let stored = to_run_event(&fixtures::RUN_8, &Event::Agent { - stage: "code".to_string(), - visit: 1, - event: CodingAgentEvent::new( - "ses_agent".to_string(), - CodingEvent::AssistantMessage { - text: "done".to_string(), - model: "gpt-5.4".to_string(), - usage: Usage::default(), - tool_call_count: 0, - context_window: None, - reasoning: Some(ReasoningOutput::new( - format!("the key is {secret}"), - format!("reading {secret} from the env"), - )), - }, - std::time::SystemTime::UNIX_EPOCH, - ), - }); - - let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap(); - let reasoning = &payload.as_value()["properties"]["event"]["AssistantMessage"]["reasoning"]; - let summary = reasoning["summary"].as_str().unwrap(); - let trace = reasoning["trace"].as_str().unwrap(); - - assert!(!summary.contains(secret)); - assert!(!trace.contains(secret)); - assert!(summary.contains("REDACTED")); - assert!(trace.contains("REDACTED")); - } -} diff --git a/lib/components/fabro-workflow/src/event/sink.rs b/lib/components/fabro-workflow/src/event/sink.rs deleted file mode 100644 index 165b5b5b2..000000000 --- a/lib/components/fabro-workflow/src/event/sink.rs +++ /dev/null @@ -1,575 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -use ::fabro_types::{RunEvent, RunId, RunProjection}; -use anyhow::Result; -use chrono::{DateTime, Utc}; -use fabro_store::{Database, RunDatabase}; -use fabro_util::error::{SharedError, collect_chain}; -use tokio::io::{AsyncWrite, AsyncWriteExt}; -use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot, watch}; - -use super::emitter::Emitter; -use super::redaction::{build_redacted_event_payload, redacted_event_json}; -use super::{Event, to_run_event, to_run_event_at}; -use crate::runtime_store::RunStoreHandle; - -pub async fn append_event(run_store: &RunDatabase, run_id: &RunId, event: &Event) -> Result<()> { - let stored = to_run_event(run_id, event); - let payload = build_redacted_event_payload(&stored, run_id)?; - run_store - .append_event(&payload) - .await - .map(|_| ()) - .map_err(anyhow::Error::from) -} - -/// Creates a run by committing its redacted `run.created` event and canonical -/// current row in one SQLite transaction. -pub async fn create_run( - store: &Database, - run_id: &RunId, - event: &Event, - timestamp: DateTime, -) -> Result { - let stored = to_run_event_at(run_id, event, timestamp, None); - let payload = build_redacted_event_payload(&stored, run_id)?; - Box::pin(store.create_run_with_first_event(run_id, &payload)) - .await - .map_err(anyhow::Error::from) -} - -pub async fn append_event_if( - run_store: &RunDatabase, - run_id: &RunId, - event: &Event, - predicate: impl FnOnce(&RunProjection) -> bool, -) -> Result { - let stored = to_run_event(run_id, event); - let payload = build_redacted_event_payload(&stored, run_id)?; - run_store - .append_event_if(&payload, predicate) - .await - .map(|seq| seq.is_some()) - .map_err(anyhow::Error::from) -} - -pub async fn append_event_to_sink( - sink: &RunEventSink, - run_id: &RunId, - event: &Event, -) -> Result<(), RunEventPersistenceError> { - let stored = to_run_event(run_id, event); - sink.write_run_event(&stored) - .await - .map_err(|err| RunEventPersistenceError::Write { - run_id: *run_id, - event: stored.body.event_name().to_string(), - source: SharedError::new(err), - }) -} - -#[derive(Clone)] -pub enum RunEventSink { - Store(RunStoreHandle), - JsonLines(Arc>>>), - Callback(Arc), - Map { - transform: Arc, - inner: Box, - }, - Composite(Vec), -} - -type RunEventSinkFuture = Pin> + Send + 'static>>; -type RunEventSinkCallback = dyn Fn(RunEvent) -> RunEventSinkFuture + Send + Sync + 'static; -type RunEventTransform = dyn Fn(RunEvent) -> RunEvent + Send + Sync + 'static; - -impl RunEventSink { - #[must_use] - pub fn store(run_store: RunDatabase) -> Self { - Self::Store(RunStoreHandle::local(run_store)) - } - - #[must_use] - pub fn backend(run_store: RunStoreHandle) -> Self { - Self::Store(run_store) - } - - #[must_use] - pub fn json_lines(writer: W) -> Self - where - W: AsyncWrite + Send + 'static, - { - Self::JsonLines(Arc::new(AsyncMutex::new(Box::pin(writer)))) - } - - #[must_use] - pub fn callback(callback: F) -> Self - where - F: Fn(RunEvent) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - { - Self::Callback(Arc::new(move |event| Box::pin(callback(event)))) - } - - #[must_use] - pub fn fanout(sinks: Vec) -> Self { - let mut flattened = Vec::new(); - for sink in sinks { - match sink { - Self::Composite(inner) => flattened.extend(inner), - other => flattened.push(other), - } - } - Self::Composite(flattened) - } - - #[must_use] - pub fn map(transform: F, inner: Self) -> Self - where - F: Fn(RunEvent) -> RunEvent + Send + Sync + 'static, - { - Self::Map { - transform: Arc::new(transform), - inner: Box::new(inner), - } - } - - pub async fn write_run_event(&self, event: &RunEvent) -> Result<()> { - let mut pending = vec![(self, event.clone())]; - while let Some((sink, event)) = pending.pop() { - match sink { - Self::Store(run_store) => { - run_store.append_run_event(&event).await?; - } - Self::JsonLines(writer) => { - let line = redacted_event_json(&event)?; - let mut writer = writer.lock().await; - writer.write_all(line.as_bytes()).await?; - writer.write_all(b"\n").await?; - writer.flush().await?; - } - Self::Callback(callback) => callback(event).await?, - Self::Map { transform, inner } => { - pending.push((inner.as_ref(), transform(event))); - } - Self::Composite(sinks) => { - for sink in sinks.iter().rev() { - pending.push((sink, event.clone())); - } - } - } - } - Ok(()) - } -} - -#[allow( - clippy::large_enum_variant, - reason = "Logger queue messages stay inline to avoid boxing hot-path payloads." -)] -enum RunEventCommand { - Event(RunEvent), - /// An event whose writer waits for the store to accept it. - Acknowledged( - RunEvent, - oneshot::Sender>, - ), - Flush(oneshot::Sender>), -} - -#[derive(Clone, Debug, thiserror::Error)] -pub enum RunEventPersistenceError { - #[error("failed to persist run event {event} for run {run_id}")] - Write { - run_id: RunId, - event: String, - #[source] - source: SharedError, - }, - #[error("run event persistence task stopped")] - TaskStopped, -} - -async fn write_event( - sink: &RunEventSink, - event: &RunEvent, -) -> Result<(), RunEventPersistenceError> { - match sink.write_run_event(event).await { - Ok(()) => Ok(()), - Err(err) => { - let rendered_error = collect_chain(err.as_ref()).join(": "); - tracing::error!( - run_id = %event.run_id, - event = %event.body.event_name(), - error = %rendered_error, - "Failed to persist run event; stopping workflow", - ); - Err(RunEventPersistenceError::Write { - run_id: event.run_id, - event: event.body.event_name().to_string(), - source: SharedError::new(err), - }) - } - } -} - -#[derive(Clone)] -pub struct RunEventLogger { - tx: mpsc::UnboundedSender, - failure_rx: watch::Receiver>, -} - -impl RunEventLogger { - #[must_use] - pub fn new(sink: RunEventSink) -> Self { - let (tx, mut rx) = mpsc::unbounded_channel(); - let (failure_tx, failure_rx) = watch::channel(None); - - tokio::spawn(async move { - // The watch channel is the single record of the latched failure: - // the worker is its only writer, so borrowing it here cannot race. - while let Some(command) = rx.recv().await { - match command { - RunEventCommand::Event(event) => { - if failure_tx.borrow().is_some() { - continue; - } - if let Err(failure) = write_event(&sink, &event).await { - failure_tx.send_replace(Some(failure)); - } - } - RunEventCommand::Acknowledged(event, tx) => { - let latched = failure_tx.borrow().clone(); - let result = match latched { - Some(failure) => Err(failure), - None => match write_event(&sink, &event).await { - Ok(()) => Ok(()), - Err(failure) => { - failure_tx.send_replace(Some(failure.clone())); - Err(failure) - } - }, - }; - let _ = tx.send(result); - } - RunEventCommand::Flush(tx) => { - let result = failure_tx.borrow().clone().map_or(Ok(()), Err); - let _ = tx.send(result); - } - } - } - }); - - Self { tx, failure_rx } - } - - /// Makes this logger the emitter's persistence path: every emitted event - /// is queued here, and [`Emitter::emit_durable`] waits for this logger's - /// acknowledgement. - pub fn register(&self, emitter: &Emitter) { - emitter.attach_persistence(self.clone()); - } - - pub(super) fn enqueue(&self, event: &RunEvent) { - if self.tx.send(RunEventCommand::Event(event.clone())).is_err() { - tracing::error!( - run_id = %event.run_id, - event = %event.body.event_name(), - "Run event persistence task stopped while forwarding event", - ); - } - } - - /// Writes `event` and returns once the sink has accepted it, or with the - /// failure that stopped persistence. - /// - /// Ordering with events queued through the emitter is preserved: the - /// write goes through the same queue. - pub async fn write_acknowledged( - &self, - event: &RunEvent, - ) -> Result<(), RunEventPersistenceError> { - let (tx, rx) = oneshot::channel(); - if self - .tx - .send(RunEventCommand::Acknowledged(event.clone(), tx)) - .is_err() - { - return Err(RunEventPersistenceError::TaskStopped); - } - rx.await - .unwrap_or(Err(RunEventPersistenceError::TaskStopped)) - } - - pub async fn wait_for_failure(&self) -> RunEventPersistenceError { - let mut failure_rx = self.failure_rx.clone(); - let failure = failure_rx.wait_for(Option::is_some).await; - match failure { - Ok(failure) => failure - .clone() - .expect("wait_for only returns values matching the predicate"), - Err(_) => RunEventPersistenceError::TaskStopped, - } - } - - pub async fn flush(&self) -> Result<(), RunEventPersistenceError> { - let (tx, rx) = oneshot::channel(); - if self.tx.send(RunEventCommand::Flush(tx)).is_err() { - return Err(RunEventPersistenceError::TaskStopped); - } - rx.await - .unwrap_or(Err(RunEventPersistenceError::TaskStopped)) - } -} - -#[derive(Clone)] -pub struct StoreProgressLogger { - inner: RunEventLogger, -} - -impl StoreProgressLogger { - #[must_use] - pub fn new(run_store: impl Into) -> Self { - Self { - inner: RunEventLogger::new(RunEventSink::backend(run_store.into())), - } - } - - pub fn register(&self, emitter: &Emitter) { - self.inner.register(emitter); - } - - pub async fn flush(&self) -> Result<(), RunEventPersistenceError> { - self.inner.flush().await - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - use ::fabro_types::{Graph, RunNoticeLevel, WorkflowSettings, fixtures}; - use fabro_types::{PetriAdmission, test_support}; - use lithos_llm::types::ReasoningOutput; - use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent, Usage}; - use tokio::sync::Mutex as AsyncMutex; - - use super::*; - use crate::event::test_support::user_principal; - use crate::event::{ - Emitter, Event, append_event, build_redacted_event_payload, - event_payload_from_redacted_json, to_run_event, - }; - - #[tokio::test] - async fn append_event_writes_store_event_shape() { - let store = fabro_store::test_support::test_database( - std::sync::Arc::new(object_store::memory::InMemory::new()), - "", - std::time::Duration::from_millis(1), - None, - ); - let run_store = store.create_run(&fixtures::RUN_7).await.unwrap(); - append_event(&run_store, &fixtures::RUN_7, &Event::RunCreated { - run_id: fixtures::RUN_7, - title: None, - settings: serde_json::to_value(WorkflowSettings::default()).unwrap(), - graph: serde_json::to_value(Graph::new("test")).unwrap(), - workflow_source: None, - labels: std::collections::BTreeMap::new(), - source_directory: None, - workflow_slug: None, - workflow_version_id: None, - target: None, - automation: None, - provenance: test_support::test_run_provenance(), - spec_blob: None, - git: None, - fork_source_ref: None, - retried_from: None, - parent_id: None, - web_url: None, - admission: PetriAdmission::default(), - }) - .await - .unwrap(); - let stored = to_run_event(&fixtures::RUN_7, &Event::RunNotice { - level: RunNoticeLevel::Warn, - code: "example".to_string(), - message: "notice".to_string(), - exec_output_tail: None, - }); - let payload = build_redacted_event_payload(&stored, &fixtures::RUN_7).unwrap(); - run_store.append_event(&payload).await.unwrap(); - - let events = run_store.list_events().await.unwrap(); - let line = events - .into_iter() - .find(|event| event.event.event_name() == "run.notice") - .map(|event| event.event.to_value().unwrap()) - .unwrap(); - assert!(line.get("id").is_some()); - assert_eq!(line["event"], "run.notice"); - assert_eq!(line["properties"]["code"], "example"); - } - - #[tokio::test] - async fn run_event_sink_json_lines_writes_canonical_event_lines() { - use tokio::io::{AsyncBufReadExt, BufReader}; - - let (writer, reader) = tokio::io::duplex(4096); - let sink = RunEventSink::json_lines(writer); - let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None }); - - sink.write_run_event(&event).await.unwrap(); - - let mut reader = BufReader::new(reader); - let mut line = String::new(); - reader.read_line(&mut line).await.unwrap(); - - let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_7).unwrap(); - assert_eq!(payload.as_value()["event"], "run.pause.requested"); - assert_eq!(payload.as_value()["properties"]["action"], "pause"); - } - - #[tokio::test] - async fn run_event_sink_json_lines_carries_agent_message_reasoning() { - use tokio::io::{AsyncBufReadExt, BufReader}; - - let (writer, reader) = tokio::io::duplex(4096); - let sink = RunEventSink::json_lines(writer); - let event = to_run_event(&fixtures::RUN_7, &Event::Agent { - stage: "code".to_string(), - visit: 1, - event: CodingAgentEvent::new( - "ses_agent".to_string(), - CodingEvent::AssistantMessage { - text: String::new(), - model: "gpt-5.4".to_string(), - usage: Usage::default(), - tool_call_count: 1, - context_window: None, - reasoning: Some(ReasoningOutput::new( - "inspect the sink first", - "write the line, then read it back", - )), - }, - std::time::SystemTime::UNIX_EPOCH, - ), - }); - - sink.write_run_event(&event).await.unwrap(); - - let mut reader = BufReader::new(reader); - let mut line = String::new(); - reader.read_line(&mut line).await.unwrap(); - - let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_7).unwrap(); - assert_eq!(payload.as_value()["event"], "agent.message"); - let message = &payload.as_value()["properties"]["event"]["AssistantMessage"]; - assert_eq!(message["reasoning"]["summary"], "inspect the sink first"); - assert_eq!( - message["reasoning"]["trace"], - "write the line, then read it back" - ); - } - - #[tokio::test] - async fn run_event_sink_map_applies_transform_before_fanout() { - let first = Arc::new(AsyncMutex::new(Vec::new())); - let second = Arc::new(AsyncMutex::new(Vec::new())); - let first_events = Arc::clone(&first); - let second_events = Arc::clone(&second); - let sink = RunEventSink::map( - |mut event| { - event.actor = Some(user_principal("alice")); - event - }, - RunEventSink::fanout(vec![ - RunEventSink::callback(move |event| { - let first_events = Arc::clone(&first_events); - async move { - first_events.lock().await.push(event); - Ok(()) - } - }), - RunEventSink::callback(move |event| { - let second_events = Arc::clone(&second_events); - async move { - second_events.lock().await.push(event); - Ok(()) - } - }), - ]), - ); - let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None }); - - sink.write_run_event(&event).await.unwrap(); - - let first = first.lock().await; - let second = second.lock().await; - assert_eq!(first.len(), 1); - assert_eq!(second.len(), 1); - assert_eq!(first[0].actor, Some(user_principal("alice"))); - assert_eq!(second[0].actor, Some(user_principal("alice"))); - } - - #[tokio::test] - async fn run_event_logger_registers_emitter_events_to_json_lines() { - use tokio::io::{AsyncBufReadExt, BufReader}; - - let (writer, reader) = tokio::io::duplex(4096); - let sink = RunEventSink::json_lines(writer); - let logger = RunEventLogger::new(sink); - let emitter = Emitter::new(fixtures::RUN_8); - logger.register(&emitter); - - emitter.emit(&Event::RunPaused); - logger.flush().await.unwrap(); - - let mut reader = BufReader::new(reader); - let mut line = String::new(); - reader.read_line(&mut line).await.unwrap(); - - let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_8).unwrap(); - assert_eq!(payload.as_value()["event"], "run.paused"); - } - - #[tokio::test] - async fn run_event_logger_latches_write_failure_and_preserves_cause_chain() { - let writes = Arc::new(AtomicUsize::new(0)); - let writes_for_sink = Arc::clone(&writes); - let sink = RunEventSink::callback(move |_| { - writes_for_sink.fetch_add(1, Ordering::SeqCst); - async { - Err( - anyhow::anyhow!("request failed with status 413 Payload Too Large") - .context("worker lost canonical run store during append run event"), - ) - } - }); - let logger = RunEventLogger::new(sink); - let emitter = Emitter::new(fixtures::RUN_8); - logger.register(&emitter); - - emitter.emit(&Event::RunPaused); - - let failure = logger.wait_for_failure().await; - let rendered = collect_chain(&failure).join(": "); - assert!(rendered.contains("run.paused"), "{rendered}"); - assert!( - rendered.contains("worker lost canonical run store"), - "{rendered}" - ); - assert!(rendered.contains("413 Payload Too Large"), "{rendered}"); - - emitter.emit(&Event::RunUnpaused); - let flush_failure = logger.flush().await.unwrap_err(); - assert_eq!(collect_chain(&flush_failure), collect_chain(&failure)); - assert_eq!(writes.load(Ordering::SeqCst), 1); - } -} diff --git a/lib/components/fabro-workflow/src/event/stored_fields.rs b/lib/components/fabro-workflow/src/event/stored_fields.rs deleted file mode 100644 index 21113025d..000000000 --- a/lib/components/fabro-workflow/src/event/stored_fields.rs +++ /dev/null @@ -1,377 +0,0 @@ -use ::fabro_types::{ParallelBranchId, Principal, StageId, SystemActorKind}; -use pebble_coding_agent::events::{Actor, CodingEvent}; - -use super::Event; -use crate::stage_scope::StageScope; - -#[derive(Debug, Default)] -pub(super) struct StoredEventFields { - pub(super) session_id: Option, - pub(super) parent_session_id: Option, - pub(super) node_id: Option, - pub(super) node_label: Option, - pub(super) stage_id: Option, - pub(super) parallel_group_id: Option, - pub(super) parallel_branch_id: Option, - pub(super) tool_call_id: Option, - pub(super) actor: Option, -} - -fn default_node_label(node_id: Option<&String>, node_label: Option) -> Option { - node_label.or_else(|| node_id.cloned()) -} - -fn node_stored_fields(node_id: Option) -> StoredEventFields { - let node_label = default_node_label(node_id.as_ref(), None); - StoredEventFields { - node_id, - node_label, - ..StoredEventFields::default() - } -} - -pub(super) fn stored_event_fields(event: &Event, scope: Option<&StageScope>) -> StoredEventFields { - let mut fields = stored_event_fields_for_variant(event); - if let Some(scope) = scope { - if fields.node_id.is_none() { - fields.node_id = Some(scope.node_id.clone()); - fields.node_label = default_node_label(Some(&scope.node_id), fields.node_label); - } - if fields.stage_id.is_none() { - fields.stage_id = Some(StageId::new(scope.node_id.clone(), scope.visit)); - } - if fields.parallel_group_id.is_none() { - fields - .parallel_group_id - .clone_from(&scope.parallel_group_id); - } - if fields.parallel_branch_id.is_none() { - fields - .parallel_branch_id - .clone_from(&scope.parallel_branch_id); - } - } - fields -} - -fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { - match event { - Event::RunCreated { provenance, .. } => StoredEventFields { - actor: Some(provenance.subject.clone()), - ..StoredEventFields::default() - }, - Event::RunCancelRequested { actor } - | Event::RunStartRequested { actor, .. } - | Event::RunPending { actor, .. } - | Event::RunApproved { actor } - | Event::RunDenied { actor, .. } - | Event::RunRunnable { actor, .. } - | Event::RunPauseRequested { actor } - | Event::RunUnpauseRequested { actor } - | Event::RunInterrupt { actor } - | Event::RunSteer { actor, .. } - | Event::RunPairStarted { actor, .. } - | Event::RunPairEnded { actor, .. } - | Event::RunPairFailed { actor, .. } - | Event::RunArchived { actor } - | Event::RunUnarchived { actor, .. } - | Event::RunTitleUpdated { actor, .. } - | Event::RunParentLinked { actor, .. } - | Event::RunParentUnlinked { actor, .. } - | Event::InterviewCompleted { actor, .. } - | Event::AgentSteerBuffered { actor, .. } => StoredEventFields { - actor: actor.clone(), - ..StoredEventFields::default() - }, - Event::StageCompleted { node_id, name, .. } - | Event::StageStarted { node_id, name, .. } - | Event::StageRetrying { node_id, name, .. } => { - let node_id_str = node_id.clone(); - let node_label = default_node_label(Some(&node_id_str), Some(name.clone())); - StoredEventFields { - node_id: Some(node_id_str), - node_label, - ..StoredEventFields::default() - } - } - Event::StageFailed { - node_id, - name, - actor, - .. - } => { - let node_id_str = node_id.clone(); - let node_label = default_node_label(Some(&node_id_str), Some(name.clone())); - StoredEventFields { - node_id: Some(node_id_str), - node_label, - actor: actor.clone(), - ..StoredEventFields::default() - } - } - Event::ParallelStarted { node_id, visit, .. } - | Event::ParallelCompleted { node_id, visit, .. } => { - let node_id_str = node_id.clone(); - let node_label = default_node_label(Some(&node_id_str), None); - let parallel_group_id = Some(StageId::new(node_id_str.clone(), *visit)); - StoredEventFields { - node_id: Some(node_id_str), - node_label, - parallel_group_id, - ..StoredEventFields::default() - } - } - Event::CheckpointCompleted { node_id, .. } - | Event::CheckpointFailed { node_id, .. } - | Event::SubgraphStarted { node_id, .. } - | Event::SubgraphCompleted { node_id, .. } - | Event::ArtifactCaptured { node_id, .. } - | Event::PromptCompleted { node_id, .. } - | Event::CommandStarted { node_id, .. } - | Event::CommandCompleted { node_id, .. } - | Event::AgentAcpCompleted { node_id, .. } - | Event::AgentAcpCancelled { node_id, .. } - | Event::AgentAcpTimedOut { node_id, .. } => node_stored_fields(Some(node_id.clone())), - Event::AgentAcpStarted { node_id, visit, .. } => { - let node_id_str = node_id.clone(); - let node_label = default_node_label(Some(&node_id_str), None); - StoredEventFields { - node_id: Some(node_id_str.clone()), - node_label, - stage_id: Some(StageId::new(node_id_str, *visit)), - ..StoredEventFields::default() - } - } - Event::AgentSessionActivated { - node_id, - visit, - session_id, - .. - } - | Event::AgentToolsAvailable { - node_id, - visit, - session_id, - .. - } - | Event::AgentSessionDeactivated { - node_id, - visit, - session_id, - } - | Event::AgentPairSystemMessage { - node_id, - visit, - session_id, - .. - } => { - let node_id_str = node_id.clone(); - let node_label = default_node_label(Some(&node_id_str), None); - StoredEventFields { - session_id: Some(session_id.clone()), - node_id: Some(node_id_str.clone()), - node_label, - stage_id: Some(StageId::new(node_id_str, *visit)), - ..StoredEventFields::default() - } - } - Event::AgentInterruptInjected { - node_id, - visit, - session_id, - actor, - } - | Event::AgentPairUserMessage { - node_id, - visit, - session_id, - actor, - .. - } => { - let node_id_str = node_id.clone(); - let node_label = default_node_label(Some(&node_id_str), None); - StoredEventFields { - session_id: Some(session_id.clone()), - node_id: Some(node_id_str.clone()), - node_label, - stage_id: Some(StageId::new(node_id_str, *visit)), - actor: actor.clone(), - ..StoredEventFields::default() - } - } - Event::AgentSteerDropped { - actor, - node_id, - visit, - .. - } => { - let node_id_str = node_id.clone(); - let node_label = node_id_str - .as_ref() - .and_then(|n| default_node_label(Some(n), None)); - let stage_id = match (node_id.clone(), visit) { - (Some(n), Some(v)) => Some(StageId::new(n, *v)), - _ => None, - }; - StoredEventFields { - node_id: node_id_str, - node_label, - stage_id, - actor: actor.clone(), - ..StoredEventFields::default() - } - } - Event::Agent { - stage, - visit, - event: envelope, - } => { - let node_id = Some(stage.clone()); - let node_label = default_node_label(node_id.as_ref(), None); - let stage_id = Some(StageId::new(stage.clone(), *visit)); - let tool_call_id = envelope - .tool_call_id - .clone() - .or_else(|| agent_tool_call_id(&envelope.event).map(str::to_string)); - let actor = agent_actor_for_event( - &envelope.event, - Some(envelope.session_id.as_str()), - envelope.parent_session_id.as_deref(), - ); - StoredEventFields { - session_id: Some(envelope.session_id.clone()), - parent_session_id: envelope.parent_session_id.clone(), - node_id, - node_label, - stage_id, - tool_call_id, - actor, - ..StoredEventFields::default() - } - } - Event::GitCommit { node_id, .. } => node_stored_fields(node_id.clone()), - Event::ParallelBranchStarted { - parallel_group_id, - parallel_branch_id, - branch, - .. - } - | Event::ParallelBranchCompleted { - parallel_group_id, - parallel_branch_id, - branch, - .. - } => { - let node_id = Some(branch.clone()); - let node_label = default_node_label(node_id.as_ref(), None); - StoredEventFields { - node_id, - node_label, - parallel_group_id: Some(parallel_group_id.clone()), - parallel_branch_id: Some(parallel_branch_id.clone()), - ..StoredEventFields::default() - } - } - Event::Prompt { stage, .. } - | Event::InterviewStarted { stage, .. } - | Event::Failover { stage, .. } => node_stored_fields(Some(stage.clone())), - Event::InterviewTimeout { actor, stage, .. } - | Event::InterviewInterrupted { actor, stage, .. } => { - let mut fields = node_stored_fields(Some(stage.clone())); - fields.actor.clone_from(actor); - fields - } - Event::StallWatchdogTimeout { node, .. } => { - let mut fields = node_stored_fields(Some(node.clone())); - fields.actor = Some(Principal::System { - system_kind: SystemActorKind::Watchdog, - }); - fields - } - _ => StoredEventFields::default(), - } -} - -fn agent_tool_call_id(event: &CodingEvent) -> Option<&str> { - match event { - CodingEvent::ToolCallStarted { tool_call_id, .. } - | CodingEvent::ToolCallCompleted { tool_call_id, .. } => Some(tool_call_id.as_str()), - _ => None, - } -} - -fn agent_actor_for_event( - event: &CodingEvent, - session_id: Option<&str>, - parent_session_id: Option<&str>, -) -> Option { - match event { - CodingEvent::AssistantMessage { model, .. } => Some(Principal::Agent { - session_id: session_id.map(str::to_string), - parent_session_id: parent_session_id.map(str::to_string), - model: Some(model.clone()), - }), - CodingEvent::ToolCallStarted { .. } - | CodingEvent::ToolCallOutputDelta { .. } - | CodingEvent::ToolCallCompleted { .. } - | CodingEvent::ToolProcessCompleted { .. } => Some(Principal::Agent { - session_id: session_id.map(str::to_string), - parent_session_id: parent_session_id.map(str::to_string), - model: None, - }), - CodingEvent::SteeringInjected { actor, .. } => { - actor.as_ref().and_then(principal_from_actor) - } - _ => None, - } -} - -/// The principal pebble's steering author stands for, where the mapping is -/// lossless. A human author cannot be rebuilt from pebble's `Actor`; the -/// durable `run.steer` event that delivered the steer carries the principal. -pub fn principal_from_actor(actor: &Actor) -> Option { - match actor { - Actor::Agent { id } => Some(Principal::Agent { - session_id: id.clone(), - parent_session_id: None, - model: None, - }), - Actor::System => Some(Principal::System { - system_kind: SystemActorKind::Engine, - }), - _ => None, - } -} - -/// The pebble author for a fabro principal steering a session. -#[must_use] -pub fn actor_from_principal(principal: &Principal) -> Actor { - match principal { - Principal::User(user) => Actor::User { - id: Some(format!( - "{}|{}", - user.identity.issuer(), - user.identity.subject() - )), - display_name: Some(user.login.clone()), - }, - Principal::Agent { session_id, .. } => Actor::Agent { - id: session_id.clone(), - }, - Principal::System { .. } | Principal::Worker { .. } => Actor::System, - Principal::Webhook { delivery_id } => Actor::External { - label: Some(format!("webhook:{delivery_id}")), - }, - Principal::Slack { - team_id, - user_id, - user_name, - } => Actor::External { - label: Some( - user_name - .clone() - .unwrap_or_else(|| format!("slack:{team_id}:{user_id}")), - ), - }, - } -} diff --git a/lib/components/fabro-workflow/src/event/test_support.rs b/lib/components/fabro-workflow/src/event/test_support.rs deleted file mode 100644 index 2e364fe78..000000000 --- a/lib/components/fabro-workflow/src/event/test_support.rs +++ /dev/null @@ -1,9 +0,0 @@ -use ::fabro_types::{AuthMethod, IdpIdentity, Principal}; - -pub(crate) fn user_principal(login: &str) -> Principal { - Principal::user( - IdpIdentity::new("https://github.com", "12345").unwrap(), - login.to_string(), - AuthMethod::Github, - ) -} diff --git a/lib/components/fabro-workflow/src/git.rs b/lib/components/fabro-workflow/src/git.rs index 88a3527ed..accd87785 100644 --- a/lib/components/fabro-workflow/src/git.rs +++ b/lib/components/fabro-workflow/src/git.rs @@ -370,15 +370,6 @@ pub fn sync_status(repo: &Path, remote: &str, branch: Option<&str>) -> GitSyncSt )] mod tests { use std::fs; - use std::sync::Arc; - use std::time::Duration; - - use fabro_dump::RunDump; - use fabro_store::Database; - use fabro_types::{ - CommandTermination, PetriAdmission, StageModelUsage, fixtures, test_support, - }; - use object_store::memory::InMemory; use super::*; @@ -487,15 +478,6 @@ mod tests { assert_eq!(observe_git_context(dir.path()).unwrap(), None); } - fn test_store() -> Arc { - Arc::new(fabro_store::test_support::test_database( - Arc::new(InMemory::new()), - "", - Duration::from_millis(1), - None, - )) - } - #[test] fn ensure_clean_on_clean_repo() { let dir = tempfile::tempdir().unwrap(); @@ -528,154 +510,6 @@ mod tests { assert!(sha.chars().all(|c| c.is_ascii_hexdigit())); } - #[tokio::test] - async fn scan_node_files_from_state_reconstructs_allowlisted_entries() { - use crate::event::{Event, append_event}; - - let store = test_store(); - let run = store.create_run(&fixtures::RUN_1).await.unwrap(); - append_event(&run, &fixtures::RUN_1, &Event::RunCreated { - run_id: fixtures::RUN_1, - title: None, - settings: serde_json::to_value(fabro_types::WorkflowSettings::default()) - .unwrap(), - graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(), - workflow_source: None, - labels: std::collections::BTreeMap::default(), - source_directory: None, - workflow_slug: None, - workflow_version_id: None, - target: None, - automation: None, - provenance: test_support::test_run_provenance(), - spec_blob: None, - git: None, - fork_source_ref: None, - retried_from: None, - parent_id: None, - web_url: None, - admission: PetriAdmission::default(), - }) - .await - .unwrap(); - append_event(&run, &fixtures::RUN_1, &Event::Prompt { - stage: "work".into(), - visit: 2, - text: "hello".into(), - mode: Some(StageModelUsage::MODE_PROMPT.to_string()), - provider: Some("openai".into()), - model: Some("gpt-5.4".into()), - reasoning_effort: None, - speed: None, - }) - .await - .unwrap(); - append_event(&run, &fixtures::RUN_1, &Event::PromptCompleted { - node_id: "work".into(), - response: "world".into(), - model: "gpt-5.4".into(), - provider: "openai".into(), - usage: None, - }) - .await - .unwrap(); - append_event(&run, &fixtures::RUN_1, &Event::StageCompleted { - node_id: "work".into(), - name: "Work".into(), - index: 2, - timing: fabro_types::StageTiming::wall_only(100), - status: "succeeded".into(), - preferred_label: None, - suggested_next_ids: Vec::new(), - usage_by_model: Vec::new(), - usage: None, - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: Some(std::collections::BTreeMap::from([("work".into(), 2)])), - loop_failure_signatures: None, - restart_failure_signatures: None, - response: Some("world".into()), - attempt: 1, - max_attempts: 1, - }) - .await - .unwrap(); - append_event(&run, &fixtures::RUN_1, &Event::CommandStarted { - node_id: "work".into(), - script: "echo hi".into(), - command: "echo hi".into(), - language: "shell".into(), - timeout_ms: None, - }) - .await - .unwrap(); - append_event(&run, &fixtures::RUN_1, &Event::CommandCompleted { - node_id: "work".into(), - output: "hi\n".into(), - exit_code: Some(0), - duration_ms: 10, - termination: CommandTermination::Exited, - output_bytes: 3, - live_streaming: true, - }) - .await - .unwrap(); - append_event(&run, &fixtures::RUN_1, &Event::ParallelCompleted { - node_id: "work".into(), - visit: 2, - duration_ms: 100, - success_count: 1, - failure_count: 0, - results: vec![fabro_types::ParallelBranchResult { - id: "a".to_string(), - index: Some(0), - item_label: None, - status: fabro_types::StageOutcome::Succeeded, - context_updates: std::collections::BTreeMap::new(), - }], - }) - .await - .unwrap(); - append_event(&run, &fixtures::RUN_1, &Event::CheckpointCompleted { - graph_visit: None, - resumed_from_stage_id: None, - node_id: "work".into(), - status: "succeeded".into(), - current_node: "work".into(), - completed_nodes: Vec::new(), - node_retries: std::collections::BTreeMap::new(), - context_values: std::collections::BTreeMap::new(), - node_outcomes: std::collections::BTreeMap::new(), - next_node_id: None, - git_commit_sha: None, - loop_failure_signatures: std::collections::BTreeMap::new(), - restart_failure_signatures: std::collections::BTreeMap::new(), - node_visits: std::collections::BTreeMap::from([("work".into(), 2)]), - diff: Some("diff --git a/story.txt b/story.txt".into()), - diff_summary: None, - }) - .await - .unwrap(); - - let state = run.state().await.unwrap(); - let files = RunDump::from_projection(&state) - .unwrap() - .git_entries() - .unwrap(); - let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); - assert!(paths.contains(&"stages/001-work@2/prompt.md")); - assert!(paths.contains(&"stages/001-work@2/response.md")); - assert!(paths.contains(&"stages/001-work@2/status.json")); - assert!(paths.contains(&"stages/001-work@2/provider_used.json")); - assert!(paths.contains(&"stages/001-work@2/script_invocation.json")); - assert!(paths.contains(&"stages/001-work@2/script_timing.json")); - assert!(paths.contains(&"stages/001-work@2/parallel_results.json")); - } - #[test] fn push_branch_fails_for_nonexistent_remote() { let dir = tempfile::tempdir().unwrap(); diff --git a/lib/components/fabro-workflow/src/lib.rs b/lib/components/fabro-workflow/src/lib.rs index 9c837f5fe..499612196 100644 --- a/lib/components/fabro-workflow/src/lib.rs +++ b/lib/components/fabro-workflow/src/lib.rs @@ -8,8 +8,7 @@ //! helpers a run's platform effects use (`git`, `git_identity`, //! `sandbox_git`), pull request creation (`pull_request`), the run tools an //! agent session calls (`run_tools`, `services`), the built-in web search -//! backend (`web_search`), and, until the legacy event store is deleted, -//! the legacy run event vocabulary (`event`, `runtime_store`). +//! backend (`web_search`). #![cfg_attr( test, @@ -29,7 +28,6 @@ )] pub mod error; -pub mod event; pub mod file_resolver; pub mod git; pub mod git_identity; @@ -50,18 +48,11 @@ pub use usage_rollup::{ pub mod run_materialization; pub mod run_status; pub mod run_tools; -pub mod runtime_store; pub mod sandbox_git; pub mod services; -mod stage_scope; #[cfg(any(test, feature = "test-support"))] pub mod test_support; #[doc(hidden)] pub mod transforms; pub mod web_search; pub mod workflow_bundle; - -/// Convert a Duration's milliseconds to u64, saturating on overflow. -pub(crate) fn millis_u64(d: std::time::Duration) -> u64 { - u64::try_from(d.as_millis()).unwrap_or(u64::MAX) -} diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index 5e5e2b24b..7bb58843f 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -491,7 +491,6 @@ pub fn make_run_dir(scratch_base: &Path, run_id: &RunId) -> PathBuf { #[cfg(test)] mod tests { use std::sync::Arc; - use std::time::Duration; use chrono::{Local, TimeZone, Utc}; use fabro_config::{ @@ -500,13 +499,12 @@ mod tests { }; use fabro_graphviz::graph::AttrValue; use fabro_store::Database; + use fabro_store::platform_records::StoredPlatformRecord; use fabro_types::diagnostic::Severity; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunMode; - use fabro_types::{EventBody, PetriAdmission, WorkflowSettings, fixtures, test_support}; + use fabro_types::{PetriAdmission, WorkflowSettings, fixtures, test_support}; use fabro_util::error::collect_chain; - use object_store::local::LocalFileSystem; - use object_store::memory::InMemory; use super::*; use crate::file_resolver::FileResolver; @@ -514,13 +512,41 @@ mod tests { use crate::pipeline::types::{GOAL_SELF_REFERENCE_RULE, TEMPLATE_UNDEFINED_VARIABLE_RULE}; use crate::transforms::Transform; use crate::workflow_bundle::BundledWorkflow; + /// The platform records the create operation appended for the run. + async fn platform_records(store: &Database, run_id: RunId) -> Vec { + store + .run_summary_store() + .platform_records() + .read(&run_id) + .await + .unwrap() + } + + /// The `run.created` record of the run. + fn run_created(records: &[StoredPlatformRecord]) -> &RunCreatedRecord { + records + .iter() + .find_map(|stored| match &stored.record { + PlatformRecord::RunCreated(created) => Some(created), + _ => None, + }) + .expect("run.created record should be persisted") + } + + /// The status the run's last lifecycle transition leads to. + fn last_lifecycle_status(records: &[StoredPlatformRecord]) -> Option { + records + .iter() + .rev() + .find_map(|stored| match &stored.record { + PlatformRecord::RunLifecycle(lifecycle) => Some(lifecycle.status), + _ => None, + }) + .flatten() + } + fn memory_store() -> Arc { - Arc::new(fabro_store::test_support::test_database( - Arc::new(InMemory::new()), - "", - Duration::from_millis(1), - None, - )) + Arc::new(fabro_store::test_support::test_database()) } fn settings_from_run_layer(run: RunLayer) -> WorkflowSettings { @@ -1622,28 +1648,30 @@ mod tests { assert_eq!(created.persisted.graph().goal(), "Compiled goal"); assert_eq!(created.persisted.source(), compiled_source); - let run_store = store.open_run_reader(&fixtures::RUN_2).await.unwrap(); - let state = run_store.state().await.unwrap(); - assert_eq!(state.spec.graph.goal(), "Compiled goal"); - assert_eq!(state.spec.automation, Some(automation)); - assert_eq!(state.spec.workflow_version_id, Some(workflow_version_id)); - let events = run_store.list_events().await.unwrap(); + let records = platform_records(&store, fixtures::RUN_2).await; assert_eq!( - events + records .iter() - .map(|event| event.event.event_name()) + .map(|stored| stored.record.kind().to_string()) .collect::>(), - vec!["run.created", "run.submitted"] + vec!["run.created", "run.lifecycle"] ); - let EventBody::RunCreated(created) = &events[0].event.body else { - panic!("first durable event should be run.created"); + let PlatformRecord::RunCreated(created) = &records[0].record else { + panic!("first durable record should be run.created"); }; + assert_eq!(created.spec.graph.goal(), "Compiled goal"); + assert_eq!(created.spec.automation, Some(automation)); + assert_eq!(created.spec.workflow_version_id, Some(workflow_version_id)); assert_eq!( - created.workflow_source.as_deref(), + created.spec.graph_source.as_deref(), Some(compiled_source.as_str()) ); - assert_eq!(created.workflow_version_id, Some(workflow_version_id)); - assert!(created.spec_blob.is_some()); + assert!(created.spec.spec_blob.is_some()); + let PlatformRecord::RunLifecycle(submitted) = &records[1].record else { + panic!("second durable record should be the submitted transition"); + }; + assert_eq!(submitted.transition, RunLifecycleKind::Submitted); + assert_eq!(submitted.status, Some(RunStatus::Submitted)); } #[expect( @@ -1756,10 +1784,9 @@ mod tests { created.persisted.run_spec().workflow_slug.as_deref(), Some("slug") ); - let run_store = store.open_run(&fixtures::RUN_1).await.unwrap(); assert_eq!( - run_store.state().await.unwrap().status, - crate::run_status::RunStatus::Submitted + last_lifecycle_status(&platform_records(&store, fixtures::RUN_1).await), + Some(crate::run_status::RunStatus::Submitted) ); assert_eq!( created.run_dir, @@ -1825,16 +1852,9 @@ mod tests { .await .unwrap(); - let run_store = store.open_run(&created.run_id).await.unwrap(); - let events = run_store.list_events().await.unwrap(); - let run_created = events - .iter() - .find_map(|event| match &event.event.body { - EventBody::RunCreated(props) => Some(props), - _ => None, - }) - .expect("run.created event should be persisted"); - let step = run_created + let records = platform_records(&store, created.run_id).await; + let step = run_created(&records) + .spec .settings .run .prepare @@ -2081,14 +2101,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let storage_dir = dir.path().join("storage"); std::fs::create_dir_all(storage_dir.join("store")).unwrap(); - let object_store = - Arc::new(LocalFileSystem::new_with_prefix(storage_dir.join("store")).unwrap()); - let store = Arc::new(fabro_store::test_support::test_database( - object_store, - "", - Duration::from_millis(1), - None, - )); + let store = Arc::new(fabro_store::test_support::test_database()); let automation = fabro_types::AutomationRef { id: "nightly".to_string(), name: Some("Nightly".to_string()), @@ -2123,16 +2136,17 @@ mod tests { ) .await .unwrap(); - let run_store = store.open_run_reader(&created.run_id).await.unwrap(); - let events = run_store.list_events().await.unwrap(); - let state = run_store.state().await.unwrap(); + let records = platform_records(&store, created.run_id).await; - assert_eq!(events.first().unwrap().event.event_name(), "run.created"); + assert_eq!( + records.first().unwrap().record.kind().to_string(), + "run.created" + ); assert_eq!( created.persisted.run_spec().automation, Some(automation.clone()) ); - assert_eq!(state.spec.automation, Some(automation)); + assert_eq!(run_created(&records).spec.automation, Some(automation)); } #[tokio::test] @@ -2140,14 +2154,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let storage_dir = dir.path().join("storage"); std::fs::create_dir_all(storage_dir.join("store")).unwrap(); - let object_store = - Arc::new(LocalFileSystem::new_with_prefix(storage_dir.join("store")).unwrap()); - let store = Arc::new(fabro_store::test_support::test_database( - object_store, - "", - Duration::from_millis(1), - None, - )); + let store = Arc::new(fabro_store::test_support::test_database()); let created = create( store.as_ref(), CreateRunInput { @@ -2191,10 +2198,8 @@ mod tests { .await .unwrap(); - let run_store = store.open_run_reader(&created.run_id).await.unwrap(); - let state = run_store.state().await.unwrap(); - let run = state.spec; - let provenance = run.provenance; + let records = platform_records(&store, created.run_id).await; + let provenance = run_created(&records).spec.provenance.clone(); assert_eq!(provenance.server.unwrap().version, "0.9.0"); assert_eq!( diff --git a/lib/components/fabro-workflow/src/pull_request.rs b/lib/components/fabro-workflow/src/pull_request.rs index 5232637d4..7be564502 100644 --- a/lib/components/fabro-workflow/src/pull_request.rs +++ b/lib/components/fabro-workflow/src/pull_request.rs @@ -666,7 +666,6 @@ pub async fn open_pull_request( mod tests { use std::collections::HashMap; use std::sync::Arc; - use std::time::Duration; use chrono::Utc; use fabro_auth::VaultCredentialSource; @@ -675,20 +674,17 @@ mod tests { use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::AdapterId; use fabro_llm::{Response, ResponseStream}; - use fabro_store::Database; use fabro_types::{ - PetriAdmission, RunProjection, RunSpec, SuccessReason, WorkflowSettings, first_event_seq, - fixtures, test_support, + PetriAdmission, RunProjection, RunSpec, WorkflowSettings, first_event_seq, fixtures, + test_support, }; use fabro_vault::{SecretType, Vault}; use httpmock::Method::{GET, POST}; use httpmock::MockServer; use lithos_llm::types::{ContentPart, CostSource, TokenCounts, Usage}; - use object_store::memory::InMemory; use tokio::sync::RwLock as AsyncRwLock; use super::*; - use crate::event::{Event, append_event}; use crate::records::StageSummary; /// Answers every completion with one fixed text, attributed to the route @@ -741,15 +737,6 @@ mod tests { } } - fn test_store() -> Arc { - Arc::new(fabro_store::test_support::test_database( - Arc::new(InMemory::new()), - "", - Duration::from_millis(1), - None, - )) - } - fn test_catalog_with_provider_base_url(provider: &str, base_url: &str) -> Arc { Arc::new(fabro_llm::test_support::test_catalog_with_provider_base_url(provider, base_url)) } @@ -1054,8 +1041,6 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr #[tokio::test] async fn build_pr_content_uses_in_memory_conclusion() { - let store = test_store(); - let _run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); let PrContent { title, body } = build_pr_content_with_client( "diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n", "Implement feature", @@ -1078,179 +1063,8 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr assert!(body.contains("| **Total** | **2m 30s** | **$0.42** | **0** |")); } - #[tokio::test] - async fn build_pr_content_uses_store_records_without_legacy_files() { - let store = test_store(); - let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); - - let run_spec = RunSpec { - run_id: fixtures::RUN_1, - settings: fabro_types::WorkflowSettings::default(), - graph: Graph::new("test"), - graph_source: None, - workflow_slug: Some("test".to_string()), - workflow_version_id: None, - target: None, - automation: None, - source_directory: Some("/tmp/project".to_string()), - git: Some(fabro_types::GitContext { - origin_url: String::new(), - branch: "main".to_string(), - sha: None, - dirty: fabro_types::DirtyStatus::Clean, - }), - labels: HashMap::new(), - provenance: test_support::test_run_provenance(), - definition_blob: None, - spec_blob: None, - fork_source_ref: None, - admission: PetriAdmission::default(), - }; - append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated { - run_id: fixtures::RUN_1, - title: None, - settings: serde_json::to_value(&run_spec.settings).unwrap(), - graph: serde_json::to_value(&run_spec.graph).unwrap(), - workflow_source: Some("digraph test { plan -> code }".to_string()), - labels: run_spec.labels.clone().into_iter().collect(), - source_directory: run_spec.source_directory.clone(), - workflow_slug: run_spec.workflow_slug.clone(), - workflow_version_id: run_spec.workflow_version_id, - target: run_spec.target.clone(), - automation: None, - provenance: run_spec.provenance.clone(), - spec_blob: None, - git: run_spec.git.clone(), - fork_source_ref: None, - retried_from: None, - parent_id: None, - web_url: None, - admission: PetriAdmission::default(), - }) - .await - .unwrap(); - let body = build_pr_content_with_client( - "diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n", - "Implement feature", - "mock-model", - &mock_catalog(), - Some(&make_test_conclusion()), - None, - explicit_client( - "mock", - &pr_content_json("Mock title", "Narrative from mock."), - ), - ) - .await - .unwrap() - .body; - - assert!(body.contains("Narrative from mock.")); - assert!(body.contains("### Fabro Details")); - assert!(body.contains("test.fabro")); - } - - #[tokio::test] - async fn build_pr_content_uses_plan_text_from_store_without_response_md() { - let store = test_store(); - let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); - - let run_spec = RunSpec { - run_id: fixtures::RUN_1, - settings: fabro_types::WorkflowSettings::default(), - graph: Graph::new("test"), - graph_source: None, - workflow_slug: Some("test".to_string()), - workflow_version_id: None, - target: None, - automation: None, - source_directory: Some("/tmp/project".to_string()), - git: Some(fabro_types::GitContext { - origin_url: String::new(), - branch: "main".to_string(), - sha: None, - dirty: fabro_types::DirtyStatus::Clean, - }), - labels: HashMap::new(), - provenance: test_support::test_run_provenance(), - definition_blob: None, - spec_blob: None, - fork_source_ref: None, - admission: PetriAdmission::default(), - }; - append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated { - run_id: fixtures::RUN_1, - title: None, - settings: serde_json::to_value(&run_spec.settings).unwrap(), - graph: serde_json::to_value(&run_spec.graph).unwrap(), - workflow_source: Some("digraph test { plan -> code }".to_string()), - labels: run_spec.labels.clone().into_iter().collect(), - source_directory: run_spec.source_directory.clone(), - workflow_slug: run_spec.workflow_slug.clone(), - workflow_version_id: run_spec.workflow_version_id, - target: run_spec.target.clone(), - automation: None, - provenance: run_spec.provenance.clone(), - spec_blob: None, - git: run_spec.git.clone(), - fork_source_ref: None, - retried_from: None, - parent_id: None, - web_url: None, - admission: PetriAdmission::default(), - }) - .await - .unwrap(); - append_event(&run_store, &fixtures::RUN_1, &Event::StageCompleted { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - timing: fabro_types::StageTiming::wall_only(1), - status: "succeeded".to_string(), - preferred_label: None, - suggested_next_ids: vec![], - usage_by_model: Vec::new(), - usage: None, - failure: None, - notes: None, - files_touched: vec![], - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: None, - restart_failure_signatures: None, - response: Some("Plan from store".to_string()), - attempt: 1, - max_attempts: 1, - }) - .await - .unwrap(); - - let body = build_pr_content_with_client( - "diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n", - "Implement feature", - "mock-model", - &mock_catalog(), - Some(&make_test_conclusion()), - None, - explicit_client( - "mock", - &pr_content_json("Mock title", "Narrative from mock."), - ), - ) - .await - .unwrap() - .body; - - assert!(body.contains("Full plan")); - assert!(body.contains("Plan from store")); - } - #[tokio::test] async fn build_pr_content_uses_explicit_llm_client() { - let store = test_store(); - let _run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); let body = build_pr_content_with_client( "diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n", "Implement feature", @@ -1304,9 +1118,6 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr // Use catalog settings to override base_url instead of env var let catalog = test_catalog_with_provider_base_url("openai", &server.url("/v1")); - let store = test_store(); - let _run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); - let PrContent { title, body } = build_pr_content( "diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n", "Implement feature", @@ -1528,8 +1339,6 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr /// [`build_pr_content_with_client`]. #[tokio::test] async fn build_pr_content_truncates_long_title() { - let store = test_store(); - let _run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); let long_title = "x".repeat(200); let payload = pr_content_json(&long_title, "Body content."); let title = build_pr_content_with_client( @@ -1551,8 +1360,6 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr #[tokio::test] async fn build_pr_content_uses_default_title_when_generated_and_goal_titles_empty() { - let store = test_store(); - let _run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); let payload = pr_content_json("", "Body content."); let title = build_pr_content_with_client( "diff --git a/src/lib.rs b/src/lib.rs\n+fn x() {}\n", @@ -1574,75 +1381,10 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr /// aborting PR creation. #[tokio::test] async fn build_pr_content_uses_skeleton_when_body_empty() { - let store = test_store(); - let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); - - let run_spec = RunSpec { - run_id: fixtures::RUN_1, - settings: fabro_types::WorkflowSettings::default(), - graph: Graph::new("test"), - graph_source: None, - workflow_slug: Some("test".to_string()), - workflow_version_id: None, - target: None, - automation: None, - source_directory: Some("/tmp/project".to_string()), - git: None, - labels: HashMap::new(), - provenance: test_support::test_run_provenance(), - definition_blob: None, - spec_blob: None, - fork_source_ref: None, - admission: PetriAdmission::default(), - }; - append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated { - run_id: fixtures::RUN_1, - title: None, - settings: serde_json::to_value(&run_spec.settings).unwrap(), - graph: serde_json::to_value(&run_spec.graph).unwrap(), - workflow_source: Some("digraph test { plan -> code }".to_string()), - labels: run_spec.labels.clone().into_iter().collect(), - source_directory: run_spec.source_directory.clone(), - workflow_slug: run_spec.workflow_slug.clone(), - workflow_version_id: run_spec.workflow_version_id, - target: run_spec.target.clone(), - automation: None, - provenance: test_support::test_run_provenance(), - spec_blob: None, - git: None, - fork_source_ref: None, - retried_from: None, - parent_id: None, - web_url: None, - admission: PetriAdmission::default(), - }) - .await - .unwrap(); - append_event(&run_store, &fixtures::RUN_1, &Event::StageCompleted { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - timing: fabro_types::StageTiming::wall_only(1), - status: "succeeded".to_string(), - preferred_label: None, - suggested_next_ids: vec![], - usage_by_model: Vec::new(), - usage: None, - failure: None, - notes: None, - files_touched: vec![], - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: None, - restart_failure_signatures: None, - response: Some("Plan from store".to_string()), - attempt: 1, - max_attempts: 1, - }) - .await - .unwrap(); + // The plan node's response is what the body quotes as the plan. + let mut state = test_projection(); + state.stage_entry("plan", 1, first_event_seq(1)).response = + Some("Plan from store".to_string()); let payload = pr_content_json("Mock", " \n"); let body = build_pr_content_with_client( "diff --git a/src/lib.rs b/src/lib.rs\n+fn x() {}\n", @@ -1650,7 +1392,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr "mock-model", &mock_catalog(), Some(&make_test_conclusion()), - None, + Some(&state), explicit_client("mock", &payload), ) .await @@ -1796,77 +1538,6 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr let creds = fabro_github::GitHubCredentials::Pat("test-token".to_string()); - let store = test_store(); - let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); - // Seed a completed run so the PR body can include run details. - let run_spec = RunSpec { - run_id: fixtures::RUN_1, - settings: fabro_types::WorkflowSettings::default(), - graph: Graph::new("test"), - graph_source: None, - workflow_slug: None, - workflow_version_id: None, - target: None, - automation: None, - source_directory: None, - git: None, - labels: HashMap::new(), - provenance: test_support::test_run_provenance(), - definition_blob: None, - spec_blob: None, - fork_source_ref: None, - admission: PetriAdmission::default(), - }; - append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated { - run_id: fixtures::RUN_1, - title: None, - settings: serde_json::to_value(&run_spec.settings).unwrap(), - graph: serde_json::to_value(&run_spec.graph).unwrap(), - workflow_source: None, - labels: run_spec.labels.clone().into_iter().collect(), - source_directory: None, - workflow_slug: None, - workflow_version_id: None, - target: None, - automation: None, - provenance: test_support::test_run_provenance(), - spec_blob: None, - git: None, - fork_source_ref: None, - retried_from: None, - parent_id: None, - web_url: None, - admission: PetriAdmission::default(), - }) - .await - .unwrap(); - append_event(&run_store, &fixtures::RUN_1, &Event::RunRunnable { - source: fabro_types::RunRunnableSource::StartRequested, - actor: None, - }) - .await - .unwrap(); - append_event(&run_store, &fixtures::RUN_1, &Event::RunStarting) - .await - .unwrap(); - append_event(&run_store, &fixtures::RUN_1, &Event::RunRunning) - .await - .unwrap(); - append_event(&run_store, &fixtures::RUN_1, &Event::WorkflowRunCompleted { - timing: fabro_types::RunTiming::wall_only(1), - artifact_count: 0, - status: "succeeded".to_string(), - reason: SuccessReason::Completed, - final_git_commit_sha: None, - final_patch: Some( - "diff --git a/src/lib.rs b/src/lib.rs\n+fn from_store() {}\n".to_string(), - ), - diff_summary: None, - usage: None, - }) - .await - .unwrap(); - let openai_mock_id = openai_mock.id; let branch_mock_id = branch_mock.id; let reconcile_mock_id = reconcile_mock.id; diff --git a/lib/components/fabro-workflow/src/run_lookup.rs b/lib/components/fabro-workflow/src/run_lookup.rs index 2b0521328..8d402977a 100644 --- a/lib/components/fabro-workflow/src/run_lookup.rs +++ b/lib/components/fabro-workflow/src/run_lookup.rs @@ -446,28 +446,17 @@ fn run_id_matches(run_id: RunId, prefix: &str) -> bool { #[cfg(test)] mod tests { use std::sync::Arc; - use std::time::Duration; - use fabro_store::Database; - use fabro_types::{PetriAdmission, RunStatus, fixtures, test_support}; - use object_store::memory::InMemory; + use fabro_store::RunSummaryStore; + use fabro_types::{RunProjection, RunStatus, fixtures, test_support}; use super::scan_runs_combined; - use crate::event::{Event, append_event}; use crate::operations::make_run_dir; use crate::records::RunSpec; - fn memory_store() -> Arc { - Arc::new(fabro_store::test_support::test_database( - Arc::new(InMemory::new()), - "", - Duration::from_millis(1), - None, - )) - } - fn sample_run_spec() -> RunSpec { RunSpec { + run_id: fixtures::RUN_1, workflow_slug: Some("test".to_string()), source_directory: Some("/tmp/project".to_string()), git: Some(fabro_types::GitContext { @@ -486,37 +475,24 @@ mod tests { let run_dir = make_run_dir(temp.path(), &fixtures::RUN_1); std::fs::create_dir_all(&run_dir).unwrap(); - let store = memory_store(); - let run_spec = sample_run_spec(); - let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); - append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated { - run_id: fixtures::RUN_1, - title: None, - settings: serde_json::to_value(&run_spec.settings).unwrap(), - graph: serde_json::to_value(&run_spec.graph).unwrap(), - workflow_source: None, - labels: run_spec.labels.clone().into_iter().collect(), - source_directory: run_spec.source_directory.clone(), - workflow_slug: run_spec.workflow_slug.clone(), - workflow_version_id: None, - target: None, - automation: None, - provenance: run_spec.provenance.clone(), - spec_blob: None, - git: run_spec.git.clone(), - fork_source_ref: run_spec.fork_source_ref.clone(), - retried_from: None, - parent_id: None, - web_url: None, - admission: PetriAdmission::default(), - }) - .await - .unwrap(); - append_event(&run_store, &fixtures::RUN_1, &Event::RunSubmitted { - definition_blob: None, - }) + let store = Arc::new(fabro_store::test_support::test_database()); + // The run's row as its projector writes it once the run is submitted. + let mut projection = RunProjection::new( + "test".to_string(), + sample_run_spec(), + fixtures::RUN_1.created_at(), + ); + projection.status = RunStatus::Submitted; + let summaries = store.run_summary_store(); + let mut transaction = summaries.pool().begin().await.unwrap(); + RunSummaryStore::write_petri_run_row_on_connection( + &mut transaction, + &fixtures::RUN_1, + &projection, + ) .await .unwrap(); + transaction.commit().await.unwrap(); let runs = scan_runs_combined(&store, temp.path()).await.unwrap(); let run = runs diff --git a/lib/components/fabro-workflow/src/runtime_store.rs b/lib/components/fabro-workflow/src/runtime_store.rs deleted file mode 100644 index 94e2c9054..000000000 --- a/lib/components/fabro-workflow/src/runtime_store.rs +++ /dev/null @@ -1,225 +0,0 @@ -use std::sync::Arc; - -use anyhow::Result; -use async_trait::async_trait; -use bytes::Bytes; -use fabro_store::{EventEnvelope, RunDatabase, RunProjection}; -use fabro_types::{BlobHash, RunEvent}; - -use crate::event::build_redacted_event_payload; - -#[async_trait] -pub trait RunStoreBackend: Send + Sync { - async fn load_state(&self) -> Result; - async fn list_events(&self) -> Result>; - async fn append_run_event(&self, event: &RunEvent) -> Result<()>; - async fn write_blob(&self, data: &[u8]) -> Result; - async fn read_blob(&self, blob_hash: &BlobHash) -> Result>; - async fn read_run_log(&self) -> Result>>; -} - -#[derive(Clone)] -pub struct RunStoreHandle { - backend: Arc, -} - -impl RunStoreHandle { - #[must_use] - pub fn new(backend: Arc) -> Self { - Self { backend } - } - - #[must_use] - pub fn local(run_store: RunDatabase) -> Self { - Self::new(Arc::new(LocalRunStoreBackend { run_store })) - } - - pub async fn state(&self) -> Result { - self.backend.load_state().await - } - - pub async fn list_events(&self) -> Result> { - self.backend.list_events().await - } - - pub async fn append_run_event(&self, event: &RunEvent) -> Result<()> { - self.backend.append_run_event(event).await - } - - pub async fn write_blob(&self, data: &[u8]) -> Result { - self.backend.write_blob(data).await - } - - pub async fn read_blob(&self, blob_hash: &BlobHash) -> Result> { - self.backend.read_blob(blob_hash).await - } - - pub async fn read_run_log(&self) -> Result>> { - self.backend.read_run_log().await - } -} - -impl From for RunStoreHandle { - fn from(value: RunDatabase) -> Self { - Self::local(value) - } -} - -struct LocalRunStoreBackend { - run_store: RunDatabase, -} - -#[async_trait] -impl RunStoreBackend for LocalRunStoreBackend { - async fn load_state(&self) -> Result { - self.run_store.state().await.map_err(anyhow::Error::from) - } - - async fn list_events(&self) -> Result> { - self.run_store - .list_events() - .await - .map_err(anyhow::Error::from) - } - - async fn append_run_event(&self, event: &RunEvent) -> Result<()> { - let payload = build_redacted_event_payload(event, &event.run_id)?; - self.run_store - .append_event(&payload) - .await - .map(|_| ()) - .map_err(anyhow::Error::from) - } - - async fn write_blob(&self, data: &[u8]) -> Result { - self.run_store - .write_blob(data) - .await - .map_err(anyhow::Error::from) - } - - async fn read_blob(&self, blob_hash: &BlobHash) -> Result> { - self.run_store - .read_blob(blob_hash) - .await - .map_err(anyhow::Error::from) - } - - async fn read_run_log(&self) -> Result>> { - Ok(None) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use std::time::Duration; - - use chrono::Utc; - use fabro_types::run_event::RunSubmittedProps; - use fabro_types::{EventBody, PetriAdmission, RunEvent, fixtures, test_support}; - use object_store::memory::InMemory; - - use super::RunStoreHandle; - use crate::event::{Event, append_event}; - use crate::records::RunSpec; - - async fn test_run_store() -> fabro_store::RunDatabase { - let store = Arc::new(fabro_store::test_support::test_database( - Arc::new(InMemory::new()), - "", - Duration::from_millis(1), - None, - )); - store.create_run(&fixtures::RUN_1).await.unwrap() - } - - fn test_run_spec() -> RunSpec { - RunSpec { - workflow_slug: Some("test".to_string()), - source_directory: Some("/tmp/test".to_string()), - ..test_support::test_run_spec() - } - } - - async fn append_created_event(run_store: &fabro_store::RunDatabase) { - let record = test_run_spec(); - append_event(run_store, &fixtures::RUN_1, &Event::RunCreated { - run_id: fixtures::RUN_1, - title: None, - settings: serde_json::to_value(&record.settings).unwrap(), - graph: serde_json::to_value(&record.graph).unwrap(), - workflow_source: Some("digraph test {}".to_string()), - labels: std::collections::BTreeMap::new(), - source_directory: Some("/tmp/test".to_string()), - workflow_slug: Some("test".to_string()), - workflow_version_id: None, - target: None, - automation: None, - provenance: test_support::test_run_provenance(), - spec_blob: None, - git: None, - fork_source_ref: None, - retried_from: None, - parent_id: None, - web_url: None, - admission: PetriAdmission::default(), - }) - .await - .unwrap(); - } - - #[tokio::test] - async fn local_handle_loads_state_and_events() { - let run_store = test_run_store().await; - append_created_event(&run_store).await; - - let handle = RunStoreHandle::local(run_store); - let state = handle.state().await.unwrap(); - let events = handle.list_events().await.unwrap(); - - assert_eq!(state.spec.workflow_slug.as_deref(), Some("test")); - assert_eq!(events.len(), 1); - } - - #[tokio::test] - async fn local_handle_appends_events_and_roundtrips_blobs() { - let run_store = test_run_store().await; - append_created_event(&run_store).await; - let handle = RunStoreHandle::local(run_store); - - let event = RunEvent { - id: "evt-run-submitted".to_string(), - ts: Utc::now(), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body: EventBody::RunSubmitted(RunSubmittedProps { - definition_blob: None, - }), - }; - handle.append_run_event(&event).await.unwrap(); - - let blob_hash = handle.write_blob(br#"{"ok":true}"#).await.unwrap(); - let blob = handle.read_blob(&blob_hash).await.unwrap().unwrap(); - let events = handle.list_events().await.unwrap(); - - assert_eq!(events.len(), 2); - assert_eq!(blob.as_ref(), br#"{"ok":true}"#); - } - - #[tokio::test] - async fn local_handle_returns_no_run_log() { - let run_store = test_run_store().await; - let handle = RunStoreHandle::local(run_store); - - assert_eq!(handle.read_run_log().await.unwrap(), None); - } -} diff --git a/lib/components/fabro-workflow/src/stage_scope.rs b/lib/components/fabro-workflow/src/stage_scope.rs deleted file mode 100644 index 89c55ad91..000000000 --- a/lib/components/fabro-workflow/src/stage_scope.rs +++ /dev/null @@ -1,22 +0,0 @@ -use fabro_types::{ParallelBranchId, StageId}; - -/// Stage-level scope threaded through event emission to populate -/// `stage_id` / `parallel_group_id` / `parallel_branch_id` on events -/// that happen inside a concrete stage execution. -/// -/// `visit` is the 1-based stage execution ordinal — the numeric component of -/// the external `StageId`. -#[derive(Clone, Debug)] -pub struct StageScope { - pub node_id: String, - pub visit: u32, - pub parallel_group_id: Option, - pub parallel_branch_id: Option, -} - -impl StageScope { - #[must_use] - pub fn stage_id(&self) -> StageId { - StageId::new(&self.node_id, self.visit) - } -} diff --git a/lib/components/fabro-workflow/src/test_support.rs b/lib/components/fabro-workflow/src/test_support.rs index 7f630a570..016bb95a7 100644 --- a/lib/components/fabro-workflow/src/test_support.rs +++ b/lib/components/fabro-workflow/src/test_support.rs @@ -1,11 +1,7 @@ -use std::sync::Arc; - use fabro_types::ModelRef; use lithos_llm::catalog::{ModelId, builtin}; use lithos_llm::types::{Cost, CostSource, TokenCounts, Usage}; -use crate::event::{Emitter, Event, append_event}; - /// Construct a fully-populated `ModelUsage` for tests: `input_tokens` and /// `output_tokens` on an OpenAI model, priced from the catalog at one micro /// per token. Centralised so callers don't keep rebuilding the same skeleton. @@ -30,34 +26,3 @@ pub fn test_usage( }, ) } - -/// Append the `RunStartRequested → RunRunnable → RunStarting → RunRunning` -/// sequence so subsequent calls observe the run as live. -pub async fn mark_run_running(run_store: &fabro_store::RunDatabase, run_id: &fabro_types::RunId) { - append_event(run_store, run_id, &Event::RunStartRequested { - resume: false, - actor: None, - }) - .await - .expect("seed run.start_requested"); - append_event(run_store, run_id, &Event::RunRunnable { - source: fabro_types::RunRunnableSource::StartRequested, - actor: None, - }) - .await - .expect("seed run.runnable"); - append_event(run_store, run_id, &Event::RunStarting) - .await - .expect("seed run.starting"); - append_event(run_store, run_id, &Event::RunRunning) - .await - .expect("seed run.running"); -} - -/// Record every event the emitter publishes, for assertions after a run. -pub fn collect_events(emitter: &Emitter) -> Arc>> { - let events = Arc::new(std::sync::Mutex::new(Vec::new())); - let captured = Arc::clone(&events); - emitter.on_event(move |event| captured.lock().unwrap().push(event.clone())); - events -} diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index 8ac10961b..aa7f67115 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -399,7 +399,7 @@ fn main() { ("PermissionLevel", "fabro_types::PermissionLevel", &[]), ( "AgentSessionActivatedProps", - "fabro_types::run_event::AgentSessionActivatedProps", + "fabro_types::AgentSessionActivatedProps", &[], ), ("TodoListProjection", "fabro_types::TodoListProjection", &[]), diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index f353fd93b..93bc88d4a 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -19,7 +19,6 @@ pub mod types { AutomationReplace as ReplaceAutomationRequest, AutomationTrigger, }; pub use fabro_environment::Environment; - pub use fabro_types::run_event::AgentSessionActivatedProps; pub use fabro_types::settings::run::{ McpHttpProtocol, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunModelControls, RunModelSettings, @@ -38,22 +37,22 @@ pub mod types { BlockedReason, FailureReason, PendingReason, RunControlAction, RunStatus, SuccessReason, }; pub use fabro_types::{ - AgentEventProps, AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BlobHash, - CommandTermination, Conclusion, ContextWindowBreakdownItem, ContextWindowCategory, - ContextWindowCountMethod, ContextWindowSnapshot, ContextWindowStaleness, - ContextWindowWarning, CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, - ExecOutputTail, FailureCategory, FailureDetail, FailureSignature, GitContext, GitRunTarget, - GitRunTarget as AutomationGitWorkflowSource, IdpIdentity, IntegrationConnectionKind, - IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, - IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind, - McpServerDraft as CreateMcpServerRequest, McpServerReplace as ReplaceMcpServerRequest, - McpServerView as McpServer, McpTransportView, Model, ModelControls, ModelCosts, - ModelFeatures, ModelLimits, ModelRef as UsageModelRef, ModelTestMode, ModelUsage, PairId, - PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, - PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId, - ParallelBranchResult, PendingInterviewRecord, PermissionLevel, PetriAdmission, - PetriGraphRef, Principal, Provider, PullRequest, PullRequestCreation, - PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails, + AgentEventProps, AgentSessionActivatedProps, AgentToolsAvailableProps, AskFabro, + AuthMethod, AutomationRef, BlobHash, CommandTermination, Conclusion, + ContextWindowBreakdownItem, ContextWindowCategory, ContextWindowCountMethod, + ContextWindowSnapshot, ContextWindowStaleness, ContextWindowWarning, CreateVariableRequest, + DiffStats, DiffSummary, DirtyStatus, ExecOutputTail, FailureCategory, FailureDetail, + FailureSignature, GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource, + IdpIdentity, IntegrationConnectionKind, IntegrationConnectionState, + IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, InterviewOption, + InterviewQuestionRecord, LlmOutputKind, McpServerDraft as CreateMcpServerRequest, + McpServerReplace as ReplaceMcpServerRequest, McpServerView as McpServer, McpTransportView, + Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ModelRef as UsageModelRef, + ModelTestMode, ModelUsage, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, + PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry, + PairTranscriptResponse, ParallelBranchId, ParallelBranchResult, PendingInterviewRecord, + PermissionLevel, PetriAdmission, PetriGraphRef, Principal, Provider, PullRequest, + PullRequestCreation, PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails, PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, ReviewTarget, ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance, RunFailure, diff --git a/lib/foundation/fabro-api/tests/agent_session_activated_props_round_trip.rs b/lib/foundation/fabro-api/tests/agent_session_activated_props_round_trip.rs index 4060a6fb7..cf4d01744 100644 --- a/lib/foundation/fabro-api/tests/agent_session_activated_props_round_trip.rs +++ b/lib/foundation/fabro-api/tests/agent_session_activated_props_round_trip.rs @@ -1,8 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::AgentSessionActivatedProps as ApiAgentSessionActivatedProps; -use fabro_types::run_event::AgentSessionActivatedProps; -use fabro_types::{PermissionLevel, SessionCapability}; +use fabro_types::{AgentSessionActivatedProps, PermissionLevel, SessionCapability}; use lithos_llm::types::{ReasoningEffort, Speed}; use serde_json::json; diff --git a/lib/foundation/fabro-api/tests/run_projection_round_trip.rs b/lib/foundation/fabro-api/tests/run_projection_round_trip.rs index c98535eed..3f115d8c4 100644 --- a/lib/foundation/fabro-api/tests/run_projection_round_trip.rs +++ b/lib/foundation/fabro-api/tests/run_projection_round_trip.rs @@ -24,10 +24,7 @@ fn run_projection_round_trips_populated_projection() { "checkpoint": { "timestamp": "2026-04-29T12:34:56Z", "current_node": "build", - "completed_nodes": ["build"], - "node_retries": {}, - "context_values": {}, - "node_visits": { "build": 2 } + "git_commit_sha": "abc123" }, "diff": {} } diff --git a/lib/foundation/fabro-types/src/run_event/agent.rs b/lib/foundation/fabro-types/src/agent_props.rs similarity index 59% rename from lib/foundation/fabro-types/src/run_event/agent.rs rename to lib/foundation/fabro-types/src/agent_props.rs index 4fea34cc1..5a96779ea 100644 --- a/lib/foundation/fabro-types/src/run_event/agent.rs +++ b/lib/foundation/fabro-types/src/agent_props.rs @@ -1,17 +1,12 @@ -//! Agent event bodies. -//! -//! Pebble owns the coding-agent event vocabulary. Every event a coding agent -//! publishes reaches the run event log as one [`AgentEventProps`]: pebble's -//! full [`CodingAgentEvent`] envelope plus the stage and visit fabro adds at -//! the workflow boundary. The remaining structs here are fabro's own lifecycle -//! events around a session: activation, steering delivery, pairing, and MCP -//! server startup, none of which pebble emits. +//! The agent-side shapes the projection and the API keep: a coding agent +//! event placed on a stage, the names Fabro gives pebble's events, a +//! session's activation and tools, and a stage's prompt. use lithos_llm::types::{ReasoningEffort, Speed}; use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent, ToolSummary}; use serde::{Deserialize, Serialize}; -use crate::{PairId, PairMessageId, PairSystemMessageKind, PermissionLevel}; +use crate::PermissionLevel; /// One coding-agent event placed on a workflow stage. /// @@ -181,11 +176,6 @@ pub struct AgentSessionActivatedProps { pub visit: u32, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentSessionDeactivatedProps { - pub visit: u32, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentToolsAvailableProps { #[serde(default)] @@ -194,136 +184,17 @@ pub struct AgentToolsAvailableProps { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentPairUserMessageProps { - pub pair_id: PairId, - pub message_id: PairMessageId, +pub struct StagePromptProps { + pub visit: u32, + pub text: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_message_id: Option, - pub text: String, - pub visit: u32, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentPairSystemMessageProps { - pub pair_id: PairId, - pub kind: PairSystemMessageKind, - pub text: String, - pub visit: u32, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentInterruptInjectedProps { - pub visit: u32, -} - -#[allow( - clippy::empty_structs_with_brackets, - reason = "This type must serialize as {} rather than null." -)] -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct AgentSteerBufferedProps {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentSteerDroppedReason { - QueueFull, - RunEnded, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentSteerDroppedProps { - pub reason: AgentSteerDroppedReason, - pub count: u32, -} - -#[cfg(test)] -mod tests { - use std::time::{Duration, UNIX_EPOCH}; - - use pebble_coding_agent::events::{ErrorData, ErrorKind, FailoverStop, Usage}; - use serde_json::json; - - use super::*; - - fn envelope(event: CodingEvent) -> CodingAgentEvent { - CodingAgentEvent::new("ses_root", event, UNIX_EPOCH + Duration::from_millis(1_500)) - .with_seq(7) - .with_stream_id("ses_root") - } - - #[test] - fn agent_event_props_flatten_pebbles_envelope() { - let props = AgentEventProps::new( - "code", - 2, - envelope(CodingEvent::ToolCallStarted { - tool_name: "shell".to_string(), - tool_call_id: "call_1".to_string(), - arguments: json!({"command": "ls"}), - }) - .with_tool_call_id("call_1"), - ); - - let value = serde_json::to_value(&props).unwrap(); - assert_eq!( - value, - json!({ - "stage": "code", - "visit": 2, - "seq": 7, - "stream_id": "ses_root", - "session_id": "ses_root", - "tool_call_id": "call_1", - "timestamp": "1970-01-01T00:00:01.500Z", - "event": { - "ToolCallStarted": { - "tool_name": "shell", - "tool_call_id": "call_1", - "arguments": {"command": "ls"} - } - } - }) - ); - assert_eq!(props.event_name(), "agent.tool.started"); - let parsed: AgentEventProps = serde_json::from_value(value).unwrap(); - assert_eq!(parsed, props); - } - - #[test] - fn every_derived_name_is_listed() { - let events = vec![ - CodingEvent::SessionEnded, - CodingEvent::ProcessingEnd, - CodingEvent::LoopDetected, - CodingEvent::McpServerDisconnected { - server: "github".to_string(), - error: "transport closed".to_string(), - }, - CodingEvent::AssistantMessage { - text: String::new(), - model: "gpt-5.4".to_string(), - usage: Usage::default(), - tool_call_count: 0, - context_window: None, - reasoning: None, - }, - ]; - for event in events { - assert!(is_coding_event_name(coding_event_name(&event))); - } - assert!(is_coding_event_name("todo.updated")); - assert!(!is_coding_event_name("agent.session.activated")); - } - - #[test] - fn a_stopped_failover_has_its_own_name() { - let stopped = CodingEvent::RouteFailoverStopped { - route: "anthropic/claude-fable-5".to_string(), - attempt: 2, - reason: FailoverStop::Exhausted, - error: ErrorData::new(ErrorKind::Llm, "overloaded"), - }; - assert_eq!(coding_event_name(&stopped), "agent.route.failover.stopped"); - assert!(is_coding_event_name("agent.route.failover.stopped")); - } + pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, } diff --git a/lib/foundation/fabro-types/src/checkpoint.rs b/lib/foundation/fabro-types/src/checkpoint.rs index ccd2a63f9..6c0434a15 100644 --- a/lib/foundation/fabro-types/src/checkpoint.rs +++ b/lib/foundation/fabro-types/src/checkpoint.rs @@ -1,30 +1,12 @@ -use std::collections::HashMap; - use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use serde_json::Value; -use crate::failure_signature::FailureSignature; -use crate::outcome::Outcome; -use crate::usage::ModelUsage; - -#[derive(Debug, Clone, Serialize, Deserialize)] +/// A checkpoint Fabro recorded for a run: when, at which node, and the +/// commit the workspace was checkpointed at, if it was committed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Checkpoint { - pub timestamp: DateTime, - pub current_node: String, - pub completed_nodes: Vec, - pub node_retries: HashMap, - pub context_values: HashMap, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub node_outcomes: HashMap>>, + pub timestamp: DateTime, + pub current_node: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub next_node_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git_commit_sha: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub loop_failure_signatures: HashMap, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub restart_failure_signatures: HashMap, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub node_visits: HashMap, + pub git_commit_sha: Option, } diff --git a/lib/foundation/fabro-types/src/event_envelope.rs b/lib/foundation/fabro-types/src/event_envelope.rs deleted file mode 100644 index e24de806f..000000000 --- a/lib/foundation/fabro-types/src/event_envelope.rs +++ /dev/null @@ -1,140 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::RunEvent; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct EventEnvelope { - pub seq: u32, - #[serde(flatten)] - pub event: RunEvent, -} - -#[cfg(test)] -mod tests { - use chrono::{TimeZone, Utc}; - - use super::EventEnvelope; - use crate::run_event::RunCompletedProps; - use crate::{ - EventBody, ParallelBranchId, Principal, RunEvent, RunTiming, StageId, SuccessReason, - fixtures, - }; - - #[test] - fn wire_event_envelope_round_trips() { - let event = RunEvent { - id: "evt_1".to_string(), - ts: Utc.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap(), - run_id: fixtures::RUN_1, - node_id: Some("code".to_string()), - node_label: Some("Code".to_string()), - stage_id: Some(StageId::new("code", 1)), - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body: EventBody::RunCompleted(RunCompletedProps { - timing: RunTiming::wall_only(42), - artifact_count: 0, - status: "success".to_string(), - reason: SuccessReason::Completed, - final_git_commit_sha: None, - final_patch: None, - diff_summary: None, - usage: None, - }), - }; - let envelope = EventEnvelope { seq: 7, event }; - - let wire = serde_json::to_value(&envelope).unwrap(); - assert_eq!(wire["seq"], 7); - assert_eq!(wire["id"], "evt_1"); - assert_eq!(wire["event"], "run.completed"); - - let parsed: EventEnvelope = serde_json::from_value(wire).unwrap(); - assert_eq!(parsed, envelope); - } - - #[test] - fn wire_event_envelope_round_trips_with_all_envelope_fields() { - let group = StageId::new("review", 2); - let branch = ParallelBranchId::new(group.clone(), 3); - let event = RunEvent { - id: "evt_2".to_string(), - ts: Utc.with_ymd_and_hms(2026, 4, 9, 13, 0, 0).unwrap(), - run_id: fixtures::RUN_1, - node_id: Some("review".to_string()), - node_label: Some("Review".to_string()), - stage_id: Some(StageId::new("review", 2)), - parallel_group_id: Some(group), - parallel_branch_id: Some(branch), - session_id: Some("ses_42".to_string()), - parent_session_id: Some("ses_root".to_string()), - tool_call_id: Some("tool_call_xyz".to_string()), - actor: Some(Principal::Agent { - session_id: Some("ses_42".to_string()), - parent_session_id: Some("ses_root".to_string()), - model: Some("claude-sonnet".to_string()), - }), - body: EventBody::RunCompleted(RunCompletedProps { - timing: RunTiming::wall_only(100), - artifact_count: 1, - status: "success".to_string(), - reason: SuccessReason::Completed, - final_git_commit_sha: None, - final_patch: None, - diff_summary: None, - usage: None, - }), - }; - let envelope = EventEnvelope { seq: 99, event }; - - let wire = serde_json::to_value(&envelope).unwrap(); - assert_eq!(wire["seq"], 99); - assert_eq!(wire["id"], "evt_2"); - assert_eq!(wire["stage_id"], "review@2"); - assert_eq!(wire["parallel_group_id"], "review@2"); - assert_eq!(wire["parallel_branch_id"], "review@2:3"); - assert_eq!(wire["session_id"], "ses_42"); - assert_eq!(wire["parent_session_id"], "ses_root"); - assert_eq!(wire["tool_call_id"], "tool_call_xyz"); - assert_eq!(wire["actor"]["kind"], "agent"); - assert_eq!(wire["actor"]["session_id"], "ses_42"); - assert_eq!(wire["actor"]["parent_session_id"], "ses_root"); - assert_eq!(wire["actor"]["model"], "claude-sonnet"); - assert_eq!(wire["event"], "run.completed"); - - let parsed: EventEnvelope = serde_json::from_value(wire).unwrap(); - assert_eq!(parsed, envelope); - } - - #[test] - fn preserves_unknown_event_names_and_properties() { - let wire = serde_json::json!({ - "seq": 7, - "id": "evt_unknown", - "ts": "2026-04-20T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "vendor.custom.event", - "properties": { - "answer": 42, - "nested": { "ok": true } - } - }); - - let parsed: EventEnvelope = serde_json::from_value(wire.clone()).unwrap(); - let serialized = serde_json::to_value(&parsed).unwrap(); - - assert_eq!(serialized["seq"], wire["seq"]); - assert_eq!(serialized["id"], wire["id"]); - assert_eq!(serialized["run_id"], wire["run_id"]); - assert_eq!(serialized["event"], wire["event"]); - assert_eq!(serialized["properties"], wire["properties"]); - assert_eq!( - chrono::DateTime::parse_from_rfc3339(serialized["ts"].as_str().unwrap()).unwrap(), - chrono::DateTime::parse_from_rfc3339(wire["ts"].as_str().unwrap()).unwrap(), - ); - } -} diff --git a/lib/foundation/fabro-types/src/interview.rs b/lib/foundation/fabro-types/src/interview.rs index 729ad6250..f603fbe97 100644 --- a/lib/foundation/fabro-types/src/interview.rs +++ b/lib/foundation/fabro-types/src/interview.rs @@ -2,7 +2,16 @@ use serde::de::Error as _; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::run_event::InterviewOption; +/// One choice a question offers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct InterviewOption { + pub key: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, +} const REVIEW_TARGET_LABEL_MAX_CHARS: usize = 200; const REVIEW_TARGET_URL_MAX_CHARS: usize = 2048; diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index 28e1ebbc8..e182b742e 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -1,5 +1,6 @@ extern crate self as fabro_types; +pub mod agent_props; pub mod artifact; pub mod auth; pub mod blob_hash; @@ -12,7 +13,6 @@ pub mod dense; pub mod diagnostic; pub mod diff; pub mod engine; -pub mod event_envelope; pub mod failure_signature; pub mod git_identity; pub mod graph; @@ -23,6 +23,7 @@ pub mod llm_backend; pub mod manifest_path; pub mod mcp_store; pub mod model_test; +pub mod notice; pub mod outcome; pub mod pair; pub mod parallel; @@ -30,7 +31,6 @@ pub mod principal; pub mod pull_request; pub mod repository; pub mod run; -pub mod run_event; pub mod run_failure; pub mod run_id; pub mod run_intent; @@ -65,6 +65,10 @@ pub mod workflow_path; pub mod workflow_version; pub mod workflow_version_id; +pub use agent_props::{ + AgentEventProps, AgentSessionActivatedProps, AgentToolsAvailableProps, CODING_EVENT_NAMES, + SessionCapability, StagePromptProps, coding_event_name, is_coding_event_name, +}; pub use artifact::ArtifactUpload; pub use auth::{IdpIdentity, IdpIdentityError}; pub use blob_hash::BlobHash; @@ -76,7 +80,6 @@ pub use conclusion::{Conclusion, StageSummary}; pub use dense::{ServerSettings, UserSettings, WorkflowSettings}; pub use diff::{DiffStats, DiffSummary, RunDiff}; pub use engine::{PetriAdmission, PetriGraphRef}; -pub use event_envelope::EventEnvelope; pub use failure_signature::FailureSignature; pub use git_identity::{GitIdentity, GitIdentitySource}; pub use graph::{ @@ -88,7 +91,8 @@ pub use input_scalar::{ toml_scalar_to_json_value, }; pub use interview::{ - InterviewQuestionRecord, QuestionType, ReviewTarget, ReviewTargetError, ReviewTargetKind, + InterviewOption, InterviewQuestionRecord, QuestionType, ReviewTarget, ReviewTargetError, + ReviewTargetKind, }; pub use llm_backend::AgentBackend; pub use manifest_path::{ManifestPath, ManifestPathParseError}; @@ -98,6 +102,7 @@ pub use mcp_store::{ validate_mcp_server_fields, }; pub use model_test::ModelTestMode; +pub use notice::{RunNoticeCode, RunNoticeLevel}; pub use outcome::{ FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageOutcome, StageState, }; @@ -133,12 +138,6 @@ pub use run::{ DirtyStatus, ForkSourceRef, GitContext, RunClientProvenance, RunProvenance, RunServerProvenance, RunSpec, }; -pub use run_event::{ - AgentEventProps, AgentToolsAvailableProps, CODING_EVENT_NAMES, EventBody, FailoverProps, - InterviewOption, MetadataSnapshotFailureKind, MetadataSnapshotPhase, RunEvent, RunNoticeCode, - RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunRunnableSource, SessionCapability, - coding_event_name, is_coding_event_name, sandbox_driver_event_name, -}; pub use run_failure::RunFailure; pub use run_id::{RunId, fixtures}; pub use run_intent::{ @@ -182,8 +181,8 @@ pub use stage_handler::StageHandler; pub use stage_id::{InvalidStageVisit, ParallelBranchId, StageId}; pub use start::StartRecord; pub use status::{ - BlockedReason, FailureReason, InvalidTransition, PendingReason, RunControlAction, RunStatus, - RunStatusKind, SuccessReason, TerminalStatus, + BlockedReason, FailureReason, InvalidTransition, PendingReason, RunControlAction, + RunRunnableSource, RunStatus, RunStatusKind, SuccessReason, TerminalStatus, }; pub use steering::SteeringMessage; pub use system_integrations::{ diff --git a/lib/foundation/fabro-types/src/notice.rs b/lib/foundation/fabro-types/src/notice.rs new file mode 100644 index 000000000..55e09ad6b --- /dev/null +++ b/lib/foundation/fabro-types/src/notice.rs @@ -0,0 +1,61 @@ +//! A run notice: something Fabro wants a reader to know about a run that +//! is not the engine's own event, recorded as a `run.notice` platform +//! record. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunNoticeLevel { + Info, + Warn, + Error, +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum RunNoticeCode { + ArtifactCollectionFailed, + ArtifactOffloadFailed, + ArtifactSyncFailed, + ArtifactUploadFailed, + CheckpointMetadataDegraded, + CheckpointMetadataPushFailed, + CheckpointMetadataWriteFailed, + DirtyWorktree, + GitDiffFailed, + GitIdentityFallback, + GitPushFailed, + GithubTokenFailed, + GithubTokenRefreshLimited, + ModelFallbackChainEmpty, + ModelFallbackSkipped, + PullRequestFailed, + SandboxCleanupFailed, + SandboxGitUnavailable, + SandboxPreserved, + WorktreeSkippedNoGit, +} + +impl RunNoticeCode { + #[must_use] + pub fn is_metadata_snapshot_compat(self) -> bool { + matches!( + self, + Self::CheckpointMetadataWriteFailed | Self::CheckpointMetadataPushFailed + ) + } +} diff --git a/lib/foundation/fabro-types/src/run_event/infra.rs b/lib/foundation/fabro-types/src/run_event/infra.rs deleted file mode 100644 index dac6f6b70..000000000 --- a/lib/foundation/fabro-types/src/run_event/infra.rs +++ /dev/null @@ -1,263 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::ExecOutputTail; -use crate::{GitIdentity, RunSandboxFailure, SandboxProviderKind}; - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum RunNoticeCode { - ArtifactCollectionFailed, - ArtifactOffloadFailed, - ArtifactSyncFailed, - ArtifactUploadFailed, - CheckpointMetadataDegraded, - CheckpointMetadataPushFailed, - CheckpointMetadataWriteFailed, - DirtyWorktree, - GitDiffFailed, - GitIdentityFallback, - GitPushFailed, - GithubTokenFailed, - GithubTokenRefreshLimited, - ModelFallbackChainEmpty, - ModelFallbackSkipped, - PullRequestFailed, - SandboxCleanupFailed, - SandboxGitUnavailable, - SandboxPreserved, - WorktreeSkippedNoGit, -} - -impl RunNoticeCode { - #[must_use] - pub fn is_metadata_snapshot_compat(self) -> bool { - matches!( - self, - Self::CheckpointMetadataWriteFailed | Self::CheckpointMetadataPushFailed - ) - } -} - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum MetadataSnapshotPhase { - Init, - Checkpoint, - Finalize, -} - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum MetadataSnapshotFailureKind { - LoadState, - Write, - Push, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct MetadataSnapshotStartedProps { - pub phase: MetadataSnapshotPhase, - pub branch: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct MetadataSnapshotCompletedProps { - pub phase: MetadataSnapshotPhase, - pub branch: String, - pub duration_ms: u64, - pub entry_count: usize, - pub bytes: u64, - pub commit_sha: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct MetadataSnapshotFailedProps { - pub phase: MetadataSnapshotPhase, - pub branch: String, - pub duration_ms: u64, - pub failure_kind: MetadataSnapshotFailureKind, - pub error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub causes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub commit_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub entry_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bytes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec_output_tail: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxInitializingProps { - pub provider: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxReadyProps { - pub provider: String, - pub duration_ms: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, -} - -pub type SandboxFailedProps = RunSandboxFailure; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxInitializedProps { - pub working_directory: String, - pub provider: SandboxProviderKind, - pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub snapshot: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub repo_cloned: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clone_origin_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clone_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_root: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub repos_root: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub primary_repo_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub primary_repo_link: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SetupStartedProps { - pub command_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SetupCommandStartedProps { - pub command: String, - pub index: usize, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SetupCommandCompletedProps { - pub command: String, - pub index: usize, - pub exit_code: i32, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SetupCompletedProps { - pub duration_ms: u64, -} - -/// The Git author/committer identity the run resolved for every commit it -/// creates, with the credential it was derived from. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GitIdentityResolvedProps { - #[serde(flatten)] - pub identity: GitIdentity, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SetupFailedProps { - pub command: String, - pub index: usize, - pub exit_code: i32, - pub stderr: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec_output_tail: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CliEnsureStartedProps { - pub cli_name: String, - pub provider: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CliEnsureCompletedProps { - pub cli_name: String, - pub provider: String, - pub already_installed: bool, - pub node_installed: bool, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CliEnsureFailedProps { - pub cli_name: String, - pub provider: String, - pub error: String, - pub duration_ms: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec_output_tail: Option, -} - -#[cfg(test)] -mod tests { - use super::ExecOutputTail; - - /// The trace summary is expanded into tracing fields, so it must carry - /// sizes and truncation flags only. - #[test] - fn exec_output_tail_trace_summary_exposes_sizes_not_content() { - let tail = ExecOutputTail { - stdout: Some("secret stdout bytes".to_string()), - stderr: Some("secret stderr".to_string()), - stdout_truncated: true, - stderr_truncated: false, - }; - - let summary = ExecOutputTail::trace_summary(Some(&tail)); - - assert!(summary.present); - assert_eq!(summary.stdout_bytes, 19); - assert_eq!(summary.stderr_bytes, 13); - assert!(summary.stdout_truncated); - assert!(!summary.stderr_truncated); - let rendered = format!("{summary:?}"); - assert!(!rendered.contains("secret"), "got: {rendered}"); - } -} diff --git a/lib/foundation/fabro-types/src/run_event/misc.rs b/lib/foundation/fabro-types/src/run_event/misc.rs deleted file mode 100644 index e931eac57..000000000 --- a/lib/foundation/fabro-types/src/run_event/misc.rs +++ /dev/null @@ -1,357 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::ExecOutputTail; -use crate::{ - CommandTermination, ParallelBranchResult, PullRequestCreationId, PullRequestLink, ReviewTarget, - StageId, StageOutcome, -}; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct InterviewOption { - pub key: String, - pub label: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preview: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ParallelStartedProps { - pub visit: u32, - pub branch_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ParallelBranchStartedProps { - pub index: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub item_label: Option, - /// Graph visit of the branch target for this dispatch. The envelope - /// `stage_id` ordinal counts executions, so a resumed fan-out's branches - /// keep visit metadata even though their ordinals advanced. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph_visit: Option, - /// Prior branch execution superseded by this resumed replay. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resumed_from_stage_id: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ParallelBranchCompletedProps { - pub index: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub item_label: Option, - pub duration_ms: u64, - pub status: StageOutcome, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ParallelCompletedProps { - pub visit: u32, - pub duration_ms: u64, - pub success_count: usize, - pub failure_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub results: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct InterviewStartedProps { - #[serde(default)] - pub question_id: String, - pub question: String, - #[serde(default)] - pub stage: String, - pub question_type: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub options: Vec, - #[serde(default)] - pub allow_freeform: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_display: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub review_target: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct InterviewCompletedProps { - #[serde(default)] - pub question_id: String, - pub question: String, - pub answer: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct InterviewTimeoutProps { - #[serde(default)] - pub question_id: String, - pub question: String, - #[serde(default)] - pub stage: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct InterviewInterruptedProps { - #[serde(default)] - pub question_id: String, - pub question: String, - #[serde(default)] - pub stage: String, - pub reason: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct GitCommitProps { - pub sha: String, -} - -/// Why a failed git push attempt is safe to retry. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum GitPushRetryReason { - /// A recently minted token may not have reached every GitHub git endpoint. - TokenReplication, - /// The failure came from transient network or service infrastructure. - TransientInfra, -} - -/// Non-secret origin of the token used by a git push attempt. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum GitTokenProvenance { - /// The token source minted the token for this resolve. - Minted, - /// The token source reused an earlier mint. - Reused, - /// The credential cannot be refreshed by Fabro. - Static, -} - -/// One attempt of a retried git push, nested inside [`GitPushProps`]. -/// -/// The durable projection of the sandbox layer's runtime attempt record. -/// Token identity is flattened into the three `token_*` fields — a nested -/// provenance enum never appears in stored events. `classified_reason` is the -/// retry classifier's verdict for a failed attempt (the terminal attempt -/// carries its classification too); whether an attempt was actually retried -/// is positional — every entry except the last. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct GitPushAttemptProps { - /// 1-based attempt number within this push operation. - pub attempt: u32, - pub started_at: chrono::DateTime, - pub success: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub classified_reason: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec_output_tail: Option, - /// Generation of the token embedded during this attempt (0 for static - /// credentials). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub token_generation: Option, - /// `minted`, `reused`, or `static`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub token_provenance: Option, - /// Token age at the attempt; absent for static credentials. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub token_age_ms: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct GitPushProps { - pub branch: String, - /// Final outcome of the whole push operation — one `git.push` event per - /// high-level push, so finality is unambiguous. - pub success: bool, - /// The final attempt's output tail, unchanged for existing consumers. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec_output_tail: Option, - /// Per-attempt history. Absent on events stored before attempts were - /// recorded. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attempts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct GitFetchProps { - pub branch: String, - pub success: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct GitResetProps { - pub sha: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct EdgeSelectedProps { - pub from_node: String, - pub to_node: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub condition: Option, - pub reason: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preferred_label: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub suggested_next_ids: Vec, - pub stage_status: String, - pub is_jump: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct LoopRestartProps { - pub from_node: String, - pub to_node: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SubgraphStartedProps { - pub start_node: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SubgraphCompletedProps { - pub steps_executed: usize, - pub status: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct StallWatchdogTimeoutProps { - pub idle_seconds: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ArtifactCapturedProps { - pub attempt: u32, - pub node_slug: String, - pub path: String, - pub mime: String, - pub content_md5: String, - pub content_sha256: String, - pub bytes: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SshAccessReadyProps { - pub ssh_command: String, -} - -/// A one-shot prompt stage moved to a fallback route. The stage walks its -/// plan itself, so this is fabro's own event; an agent stage's moves are -/// pebble's `agent.route.failover`, stored verbatim. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct FailoverProps { - pub from_provider: String, - pub from_model: String, - pub to_provider: String, - pub to_model: String, - /// How many routes the prompt had moved through, this one included. - /// `None` only on events recorded before it was kept. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub attempt: Option, - pub error: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CommandStartedProps { - pub script: String, - pub command: String, - pub language: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_ms: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CommandCompletedProps { - pub output: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - pub duration_ms: u64, - pub termination: CommandTermination, - #[serde(default)] - pub output_bytes: u64, - #[serde(default)] - pub live_streaming: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentAcpStartedProps { - pub visit: u32, - pub command: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_name: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentAcpCompletedProps { - pub stdout: String, - pub stderr: String, - pub stop_reason: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentAcpCancelledProps { - pub stdout: String, - pub stderr: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentAcpTimedOutProps { - pub stdout: String, - pub stderr: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PullRequestCreationRequestedProps { - pub creation_id: PullRequestCreationId, - pub model: String, - pub force: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PullRequestCreatedProps { - pub pr_url: String, - pub pr_number: u64, - pub owner: String, - pub repo: String, - pub base_branch: String, - pub head_branch: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub head_sha: Option, - pub title: String, - pub draft: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PullRequestLinkedProps { - pub pull_request: PullRequestLink, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PullRequestUnlinkedProps { - pub pull_request: PullRequestLink, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PullRequestFailedProps { - /// Set when the failure resolves an explicitly requested creation; absent - /// for pull request failures in the workflow publish stage. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creation_id: Option, - pub error: String, -} diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs deleted file mode 100644 index 9bbbb664d..000000000 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ /dev/null @@ -1,2300 +0,0 @@ -pub mod agent; -pub mod infra; -pub mod misc; -pub mod run; -pub mod session; -pub mod stage; - -pub use agent::*; -use chrono::{DateTime, Utc}; -pub use infra::*; -pub use misc::*; -pub use pebble_coding_agent::events::{ExecOutputTail, ExecOutputTailTrace}; -pub use run::*; -use serde::de::Error as DeError; -use serde::ser::Error as SerError; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::{Map, Value, json}; -pub use session::*; -pub use stage::*; - -use crate::{ParallelBranchId, Principal, RunId, StageId}; - -/// Maximum accepted body size for `POST /runs/{id}/events`. -/// -/// Producers that embed large payloads in an event (serialized tool output in -/// particular) must budget against this limit, leaving headroom for the rest -/// of the event envelope. The agent layer reserves half of it for serialized -/// tool output. -pub const MAX_RUN_EVENT_BODY_BYTES: usize = 3 * 1024 * 1024; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RunNoticeLevel { - Info, - Warn, - Error, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct RunEvent { - pub id: String, - pub ts: DateTime, - pub run_id: RunId, - pub node_id: Option, - pub node_label: Option, - pub stage_id: Option, - pub parallel_group_id: Option, - pub parallel_branch_id: Option, - pub session_id: Option, - pub parent_session_id: Option, - pub tool_call_id: Option, - pub actor: Option, - pub body: EventBody, -} - -#[allow( - clippy::large_enum_variant, - reason = "Run event bodies stay inline to match the tagged wire format." -)] -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[serde(tag = "event", content = "properties")] -pub enum EventBody { - #[serde(rename = "run.created")] - RunCreated(RunCreatedProps), - #[serde(rename = "run.started")] - RunStarted(RunStartedProps), - #[serde(rename = "run.submitted")] - RunSubmitted(RunSubmittedProps), - #[serde(rename = "run.start_requested")] - RunStartRequested(RunStartRequestedProps), - #[serde(rename = "run.pending")] - RunPending(RunPendingProps), - #[serde(rename = "run.approved")] - RunApproved(RunApprovedProps), - #[serde(rename = "run.denied")] - RunDenied(RunDeniedProps), - #[serde(rename = "run.runnable")] - RunRunnable(RunRunnableProps), - #[serde(rename = "run.starting")] - RunStarting(RunStatusTransitionProps), - #[serde(rename = "run.running")] - RunRunning(RunStatusTransitionProps), - #[serde(rename = "run.interrupt")] - RunInterrupt(RunInterruptProps), - #[serde(rename = "run.steer")] - RunSteer(RunSteerProps), - #[serde(rename = "run.pair.started")] - RunPairStarted(RunPairStartedProps), - #[serde(rename = "run.pair.ended")] - RunPairEnded(RunPairEndedProps), - #[serde(rename = "run.pair.failed")] - RunPairFailed(RunPairFailedProps), - #[serde(rename = "run.blocked")] - RunBlocked(RunBlockedProps), - #[serde(rename = "run.unblocked")] - RunUnblocked(RunStatusEffectProps), - #[serde(rename = "run.removing")] - RunRemoving(RunStatusTransitionProps), - #[serde(rename = "run.cancel.requested")] - RunCancelRequested(RunControlRequestedProps), - #[serde(rename = "run.pause.requested")] - RunPauseRequested(RunControlRequestedProps), - #[serde(rename = "run.unpause.requested")] - RunUnpauseRequested(RunControlRequestedProps), - #[serde(rename = "run.paused")] - RunPaused(RunControlEffectProps), - #[serde(rename = "run.unpaused")] - RunUnpaused(RunControlEffectProps), - #[serde(rename = "run.superseded_by")] - RunSupersededBy(RunSupersededByProps), - #[serde(rename = "run.archived")] - RunArchived(RunArchivedProps), - #[serde(rename = "run.unarchived")] - RunUnarchived(RunUnarchivedProps), - #[serde(rename = "run.title.updated")] - RunTitleUpdated(RunTitleUpdatedProps), - #[serde(rename = "run.session.created")] - RunSessionCreated(RunSessionCreatedProps), - #[serde(rename = "run.session.turn.started")] - RunSessionTurnStarted(RunSessionTurnStartedProps), - #[serde(rename = "run.session.user_message")] - RunSessionUserMessage(RunSessionUserMessageProps), - #[serde(rename = "run.session.assistant_delta")] - RunSessionAssistantDelta(RunSessionAssistantDeltaProps), - #[serde(rename = "run.session.assistant_message")] - RunSessionAssistantMessage(RunSessionAssistantMessageProps), - #[serde(rename = "run.session.tool_call.started")] - RunSessionToolCallStarted(RunSessionToolCallStartedProps), - #[serde(rename = "run.session.tool_call.completed")] - RunSessionToolCallCompleted(RunSessionToolCallCompletedProps), - #[serde(rename = "run.session.turn.succeeded")] - RunSessionTurnSucceeded(RunSessionTurnSucceededProps), - #[serde(rename = "run.session.turn.failed")] - RunSessionTurnFailed(RunSessionTurnFailedProps), - #[serde(rename = "run.session.turn.interrupted")] - RunSessionTurnInterrupted(RunSessionTurnInterruptedProps), - #[serde(rename = "run.parent.linked")] - RunParentLinked(RunParentLinkedProps), - #[serde(rename = "run.parent.unlinked")] - RunParentUnlinked(RunParentUnlinkedProps), - #[serde(rename = "run.completed")] - RunCompleted(RunCompletedProps), - #[serde(rename = "run.failed")] - RunFailed(RunFailedProps), - #[serde(rename = "run.notice")] - RunNotice(RunNoticeProps), - /// Historical metadata snapshot event. Retained for replay; no longer - /// emitted. - #[serde(rename = "metadata.snapshot.started")] - MetadataSnapshotStarted(MetadataSnapshotStartedProps), - /// Historical metadata snapshot event. Retained for replay; no longer - /// emitted. - #[serde(rename = "metadata.snapshot.completed")] - MetadataSnapshotCompleted(MetadataSnapshotCompletedProps), - /// Historical metadata snapshot event. Retained for replay; no longer - /// emitted. - #[serde(rename = "metadata.snapshot.failed")] - MetadataSnapshotFailed(MetadataSnapshotFailedProps), - #[serde(rename = "stage.started")] - StageStarted(StageStartedProps), - #[serde(rename = "stage.completed")] - StageCompleted(StageCompletedProps), - #[serde(rename = "stage.failed")] - StageFailed(StageFailedProps), - #[serde(rename = "stage.retrying")] - StageRetrying(StageRetryingProps), - #[serde(rename = "parallel.started")] - ParallelStarted(ParallelStartedProps), - #[serde(rename = "parallel.branch.started")] - ParallelBranchStarted(ParallelBranchStartedProps), - #[serde(rename = "parallel.branch.completed")] - ParallelBranchCompleted(ParallelBranchCompletedProps), - #[serde(rename = "parallel.completed")] - ParallelCompleted(ParallelCompletedProps), - #[serde(rename = "interview.started")] - InterviewStarted(InterviewStartedProps), - #[serde(rename = "interview.completed")] - InterviewCompleted(InterviewCompletedProps), - #[serde(rename = "interview.timeout")] - InterviewTimeout(InterviewTimeoutProps), - #[serde(rename = "interview.interrupted")] - InterviewInterrupted(InterviewInterruptedProps), - #[serde(rename = "checkpoint.completed")] - CheckpointCompleted(CheckpointCompletedProps), - #[serde(rename = "checkpoint.failed")] - CheckpointFailed(CheckpointFailedProps), - #[serde(rename = "git.commit")] - GitCommit(GitCommitProps), - #[serde(rename = "git.push")] - GitPush(GitPushProps), - #[serde(rename = "git.fetch")] - GitFetch(GitFetchProps), - #[serde(rename = "git.reset")] - GitReset(GitResetProps), - #[serde(rename = "edge.selected")] - EdgeSelected(EdgeSelectedProps), - #[serde(rename = "loop.restart")] - LoopRestart(LoopRestartProps), - #[serde(rename = "stage.prompt")] - StagePrompt(StagePromptProps), - #[serde(rename = "prompt.completed")] - PromptCompleted(PromptCompletedProps), - /// One pebble coding-agent event. The wire name is derived from the - /// inner `CodingEvent` variant (`agent.message`, `todo.created`, ...); - /// see [`AgentEventProps::event_name`]. The derive's own tag is only a - /// fallback for direct `EventBody` serialization; `RunEvent` writes and - /// reads the derived name. - #[serde(rename = "agent.event")] - Agent(AgentEventProps), - #[serde(rename = "agent.session.activated")] - AgentSessionActivated(AgentSessionActivatedProps), - #[serde(rename = "agent.tools.available")] - AgentToolsAvailable(AgentToolsAvailableProps), - #[serde(rename = "agent.session.deactivated")] - AgentSessionDeactivated(AgentSessionDeactivatedProps), - #[serde(rename = "agent.pair.user_message")] - AgentPairUserMessage(AgentPairUserMessageProps), - #[serde(rename = "agent.pair.system_message")] - AgentPairSystemMessage(AgentPairSystemMessageProps), - #[serde(rename = "agent.interrupt.injected")] - AgentInterruptInjected(AgentInterruptInjectedProps), - #[serde(rename = "agent.steer.buffered")] - AgentSteerBuffered(AgentSteerBufferedProps), - #[serde(rename = "agent.steer.dropped")] - AgentSteerDropped(AgentSteerDroppedProps), - #[serde(rename = "subgraph.started")] - SubgraphStarted(SubgraphStartedProps), - #[serde(rename = "subgraph.completed")] - SubgraphCompleted(SubgraphCompletedProps), - #[serde(rename = "sandbox.initializing")] - SandboxInitializing(SandboxInitializingProps), - #[serde(rename = "sandbox.ready")] - SandboxReady(SandboxReadyProps), - #[serde(rename = "sandbox.failed")] - SandboxFailed(SandboxFailedProps), - /// An event the sandbox driver reported about the run's sandbox, a - /// snapshot, a volume, or the provider, stored as the driver's own event - /// under a name derived from it (`sandbox.stop.completed`, - /// `snapshot.create.started`, `sandbox.state`); see - /// [`sandbox_driver_event_name`]. The derive never sees this variant: - /// the run event writes the name and the driver's event itself. - #[serde(skip)] - SandboxDriver { - name: String, - event: sandbox_driver::Event, - }, - #[serde(rename = "sandbox.initialized")] - SandboxInitialized(SandboxInitializedProps), - #[serde(rename = "setup.started")] - SetupStarted(SetupStartedProps), - #[serde(rename = "setup.command.started")] - SetupCommandStarted(SetupCommandStartedProps), - #[serde(rename = "setup.command.completed")] - SetupCommandCompleted(SetupCommandCompletedProps), - #[serde(rename = "setup.completed")] - SetupCompleted(SetupCompletedProps), - #[serde(rename = "git.identity.resolved")] - GitIdentityResolved(GitIdentityResolvedProps), - #[serde(rename = "setup.failed")] - SetupFailed(SetupFailedProps), - #[serde(rename = "watchdog.timeout")] - StallWatchdogTimeout(StallWatchdogTimeoutProps), - #[serde(rename = "artifact.captured")] - ArtifactCaptured(ArtifactCapturedProps), - #[serde(rename = "ssh.ready")] - SshAccessReady(SshAccessReadyProps), - #[serde(rename = "prompt.failover")] - Failover(FailoverProps), - #[serde(rename = "cli.ensure.started")] - CliEnsureStarted(CliEnsureStartedProps), - #[serde(rename = "cli.ensure.completed")] - CliEnsureCompleted(CliEnsureCompletedProps), - #[serde(rename = "cli.ensure.failed")] - CliEnsureFailed(CliEnsureFailedProps), - #[serde(rename = "command.started")] - CommandStarted(CommandStartedProps), - #[serde(rename = "command.completed")] - CommandCompleted(CommandCompletedProps), - #[serde(rename = "agent.acp.started")] - AgentAcpStarted(AgentAcpStartedProps), - #[serde(rename = "agent.acp.completed")] - AgentAcpCompleted(AgentAcpCompletedProps), - #[serde(rename = "agent.acp.cancelled")] - AgentAcpCancelled(AgentAcpCancelledProps), - #[serde(rename = "agent.acp.timed_out")] - AgentAcpTimedOut(AgentAcpTimedOutProps), - #[serde(rename = "pull_request.creation_requested")] - PullRequestCreationRequested(PullRequestCreationRequestedProps), - #[serde(rename = "pull_request.created")] - PullRequestCreated(PullRequestCreatedProps), - #[serde(rename = "pull_request.linked")] - PullRequestLinked(PullRequestLinkedProps), - #[serde(rename = "pull_request.unlinked")] - PullRequestUnlinked(PullRequestUnlinkedProps), - #[serde(rename = "pull_request.failed")] - PullRequestFailed(PullRequestFailedProps), - Unknown { - name: String, - properties: Value, - }, -} - -#[derive(Debug, Clone, Deserialize)] -struct RunEventRaw { - id: String, - ts: DateTime, - run_id: RunId, - #[serde(default)] - node_id: Option, - #[serde(default)] - node_label: Option, - #[serde(default)] - stage_id: Option, - #[serde(default)] - parallel_group_id: Option, - #[serde(default)] - parallel_branch_id: Option, - #[serde(default)] - session_id: Option, - #[serde(default)] - parent_session_id: Option, - #[serde(default)] - tool_call_id: Option, - #[serde(default)] - actor: Option, - event: String, - #[serde(default = "default_properties")] - properties: Value, -} - -fn default_properties() -> Value { - Value::Object(Map::new()) -} - -struct RunEventParts<'a> { - id: String, - ts: DateTime, - run_id: RunId, - node_id: Option, - node_label: Option, - stage_id: Option, - parallel_group_id: Option, - parallel_branch_id: Option, - session_id: Option, - parent_session_id: Option, - tool_call_id: Option, - actor: Option, - event: &'a str, - properties: &'a Value, -} - -impl EventBody { - /// The sandbox driver's event as a run event body, named by - /// [`sandbox_driver_event_name`]. - #[must_use] - pub fn sandbox_driver(event: sandbox_driver::Event) -> Self { - Self::SandboxDriver { - name: sandbox_driver_event_name(&event), - event, - } - } - - /// A stored driver event: `name` has the shape the driver's events are - /// stored under and `properties` decode to a driver event that yields - /// that name. Anything else, including an event stored under one of - /// these names before the driver's events were kept whole, is left to - /// the other variants. - fn sandbox_driver_from_stored(name: &str, properties: &Value) -> Option { - if !is_sandbox_driver_event_name(name) { - return None; - } - let event: sandbox_driver::Event = serde_json::from_value(properties.clone()).ok()?; - (sandbox_driver_event_name(&event) == name).then(|| Self::SandboxDriver { - name: name.to_owned(), - event, - }) - } -} - -/// Whether `name` has the shape the sandbox driver's events are stored -/// under: `..`, `.state`, `.notice`, -/// or `.event`, for the subjects the driver reports on. -fn is_sandbox_driver_event_name(name: &str) -> bool { - let Some((subject, rest)) = name.split_once('.') else { - return false; - }; - matches!(subject, "sandbox" | "snapshot" | "volume" | "provider") - && (matches!(rest, "state" | "notice" | "event") - || rest.split_once('.').is_some_and(|(_, phase)| { - matches!(phase, "started" | "progress" | "completed" | "failed") - })) -} - -/// The run event name for a sandbox driver event: the subject kind, the -/// action, and the phase, so a stop on the sandbox is `sandbox.stop.started`, -/// `sandbox.stop.completed`, or `sandbox.stop.failed`, an image pull inside -/// a create is `sandbox.create.progress`, and a snapshot build is -/// `snapshot.create.*`. A state observation is `.state`, a notice -/// `.notice`, and an event kind this build does not know -/// `.event`. -#[must_use] -pub fn sandbox_driver_event_name(event: &sandbox_driver::Event) -> String { - use sandbox_driver::{EventBody as Body, EventSubject}; - - let subject = match &event.subject { - EventSubject::Snapshot { .. } => "snapshot", - EventSubject::Volume { .. } => "volume", - EventSubject::Provider => "provider", - _ => "sandbox", - }; - let (action, phase) = match &event.body { - Body::OperationStarted { action } => (Some(*action), "started"), - Body::OperationProgress { action, .. } => (Some(*action), "progress"), - Body::OperationCompleted { action, .. } => (Some(*action), "completed"), - Body::OperationFailed { action, .. } => (Some(*action), "failed"), - Body::StateObserved { .. } => (None, "state"), - Body::Notice { .. } => (None, "notice"), - _ => (None, "event"), - }; - match action { - Some(action) => format!("{subject}.{}.{phase}", driver_action_name(action)), - None => format!("{subject}.{phase}"), - } -} - -/// The driver action's wire name (`stop`, `refresh_activity`). -fn driver_action_name(action: sandbox_driver::Action) -> String { - match serde_json::to_value(action) { - Ok(Value::String(name)) => name, - _ => "unknown".to_owned(), - } -} - -impl EventBody { - pub fn event_name(&self) -> &str { - match self { - Self::RunCreated(_) => "run.created", - Self::RunStarted(_) => "run.started", - Self::RunSubmitted(_) => "run.submitted", - Self::RunStartRequested(_) => "run.start_requested", - Self::RunPending(_) => "run.pending", - Self::RunApproved(_) => "run.approved", - Self::RunDenied(_) => "run.denied", - Self::RunRunnable(_) => "run.runnable", - Self::RunStarting(_) => "run.starting", - Self::RunRunning(_) => "run.running", - Self::RunInterrupt(_) => "run.interrupt", - Self::RunSteer(_) => "run.steer", - Self::RunPairStarted(_) => "run.pair.started", - Self::RunPairEnded(_) => "run.pair.ended", - Self::RunPairFailed(_) => "run.pair.failed", - Self::RunBlocked(_) => "run.blocked", - Self::RunUnblocked(_) => "run.unblocked", - Self::RunRemoving(_) => "run.removing", - Self::RunCancelRequested(_) => "run.cancel.requested", - Self::RunPauseRequested(_) => "run.pause.requested", - Self::RunUnpauseRequested(_) => "run.unpause.requested", - Self::RunPaused(_) => "run.paused", - Self::RunUnpaused(_) => "run.unpaused", - Self::RunSupersededBy(_) => "run.superseded_by", - Self::RunArchived(_) => "run.archived", - Self::RunUnarchived(_) => "run.unarchived", - Self::RunTitleUpdated(_) => "run.title.updated", - Self::RunSessionCreated(_) => "run.session.created", - Self::RunSessionTurnStarted(_) => "run.session.turn.started", - Self::RunSessionUserMessage(_) => "run.session.user_message", - Self::RunSessionAssistantDelta(_) => "run.session.assistant_delta", - Self::RunSessionAssistantMessage(_) => "run.session.assistant_message", - Self::RunSessionToolCallStarted(_) => "run.session.tool_call.started", - Self::RunSessionToolCallCompleted(_) => "run.session.tool_call.completed", - Self::RunSessionTurnSucceeded(_) => "run.session.turn.succeeded", - Self::RunSessionTurnFailed(_) => "run.session.turn.failed", - Self::RunSessionTurnInterrupted(_) => "run.session.turn.interrupted", - Self::RunParentLinked(_) => "run.parent.linked", - Self::RunParentUnlinked(_) => "run.parent.unlinked", - Self::RunCompleted(_) => "run.completed", - Self::RunFailed(_) => "run.failed", - Self::RunNotice(_) => "run.notice", - Self::MetadataSnapshotStarted(_) => "metadata.snapshot.started", - Self::MetadataSnapshotCompleted(_) => "metadata.snapshot.completed", - Self::MetadataSnapshotFailed(_) => "metadata.snapshot.failed", - Self::StageStarted(_) => "stage.started", - Self::StageCompleted(_) => "stage.completed", - Self::StageFailed(_) => "stage.failed", - Self::StageRetrying(_) => "stage.retrying", - Self::ParallelStarted(_) => "parallel.started", - Self::ParallelBranchStarted(_) => "parallel.branch.started", - Self::ParallelBranchCompleted(_) => "parallel.branch.completed", - Self::ParallelCompleted(_) => "parallel.completed", - Self::InterviewStarted(_) => "interview.started", - Self::InterviewCompleted(_) => "interview.completed", - Self::InterviewTimeout(_) => "interview.timeout", - Self::InterviewInterrupted(_) => "interview.interrupted", - Self::CheckpointCompleted(_) => "checkpoint.completed", - Self::CheckpointFailed(_) => "checkpoint.failed", - Self::GitCommit(_) => "git.commit", - Self::GitPush(_) => "git.push", - Self::GitFetch(_) => "git.fetch", - Self::GitReset(_) => "git.reset", - Self::EdgeSelected(_) => "edge.selected", - Self::LoopRestart(_) => "loop.restart", - Self::StagePrompt(_) => "stage.prompt", - Self::PromptCompleted(_) => "prompt.completed", - Self::Agent(props) => props.event_name(), - Self::AgentSessionActivated(_) => "agent.session.activated", - Self::AgentToolsAvailable(_) => "agent.tools.available", - Self::AgentSessionDeactivated(_) => "agent.session.deactivated", - Self::AgentPairUserMessage(_) => "agent.pair.user_message", - Self::AgentPairSystemMessage(_) => "agent.pair.system_message", - Self::AgentInterruptInjected(_) => "agent.interrupt.injected", - Self::AgentSteerBuffered(_) => "agent.steer.buffered", - Self::AgentSteerDropped(_) => "agent.steer.dropped", - Self::SubgraphStarted(_) => "subgraph.started", - Self::SubgraphCompleted(_) => "subgraph.completed", - Self::SandboxInitializing(_) => "sandbox.initializing", - Self::SandboxReady(_) => "sandbox.ready", - Self::SandboxFailed(_) => "sandbox.failed", - Self::SandboxInitialized(_) => "sandbox.initialized", - Self::SetupStarted(_) => "setup.started", - Self::SetupCommandStarted(_) => "setup.command.started", - Self::SetupCommandCompleted(_) => "setup.command.completed", - Self::SetupCompleted(_) => "setup.completed", - Self::GitIdentityResolved(_) => "git.identity.resolved", - Self::SetupFailed(_) => "setup.failed", - Self::StallWatchdogTimeout(_) => "watchdog.timeout", - Self::ArtifactCaptured(_) => "artifact.captured", - Self::SshAccessReady(_) => "ssh.ready", - Self::Failover(_) => "prompt.failover", - Self::CliEnsureStarted(_) => "cli.ensure.started", - Self::CliEnsureCompleted(_) => "cli.ensure.completed", - Self::CliEnsureFailed(_) => "cli.ensure.failed", - Self::CommandStarted(_) => "command.started", - Self::CommandCompleted(_) => "command.completed", - Self::AgentAcpStarted(_) => "agent.acp.started", - Self::AgentAcpCompleted(_) => "agent.acp.completed", - Self::AgentAcpCancelled(_) => "agent.acp.cancelled", - Self::AgentAcpTimedOut(_) => "agent.acp.timed_out", - Self::PullRequestCreationRequested(_) => "pull_request.creation_requested", - Self::PullRequestCreated(_) => "pull_request.created", - Self::PullRequestLinked(_) => "pull_request.linked", - Self::PullRequestUnlinked(_) => "pull_request.unlinked", - Self::PullRequestFailed(_) => "pull_request.failed", - Self::SandboxDriver { name, .. } | Self::Unknown { name, .. } => name.as_str(), - } - } - - pub fn is_run_session_event(&self) -> bool { - self.event_name().starts_with("run.session.") - } - - fn properties_value(&self) -> serde_json::Result { - match self { - Self::Unknown { properties, .. } => return Ok(properties.clone()), - Self::Agent(props) => return serde_json::to_value(props), - _ => {} - } - if let Self::SandboxDriver { event, .. } = self { - return serde_json::to_value(event); - } - - match serde_json::to_value(self)? { - Value::Object(mut map) => { - Ok(map.remove("properties").unwrap_or_else(default_properties)) - } - _ => Ok(default_properties()), - } - } -} - -fn is_known_event_name(event: &str) -> bool { - is_coding_event_name(event) - || matches!( - event, - "run.created" - | "run.started" - | "run.submitted" - | "run.start_requested" - | "run.pending" - | "run.approved" - | "run.denied" - | "run.runnable" - | "run.starting" - | "run.running" - | "run.interrupt" - | "run.steer" - | "run.pair.started" - | "run.pair.ended" - | "run.pair.failed" - | "run.blocked" - | "run.unblocked" - | "run.removing" - | "run.superseded_by" - | "run.archived" - | "run.unarchived" - | "run.title.updated" - | "run.session.created" - | "run.session.turn.started" - | "run.session.user_message" - | "run.session.assistant_delta" - | "run.session.assistant_message" - | "run.session.tool_call.started" - | "run.session.tool_call.completed" - | "run.session.turn.succeeded" - | "run.session.turn.failed" - | "run.session.turn.interrupted" - | "run.parent.linked" - | "run.parent.unlinked" - | "run.completed" - | "run.failed" - | "run.notice" - | "metadata.snapshot.started" - | "metadata.snapshot.completed" - | "metadata.snapshot.failed" - | "stage.started" - | "stage.completed" - | "stage.failed" - | "stage.retrying" - | "parallel.started" - | "parallel.branch.started" - | "parallel.branch.completed" - | "parallel.completed" - | "interview.started" - | "interview.completed" - | "interview.timeout" - | "interview.interrupted" - | "checkpoint.completed" - | "checkpoint.failed" - | "git.commit" - | "git.push" - | "git.fetch" - | "git.reset" - | "edge.selected" - | "loop.restart" - | "stage.prompt" - | "prompt.completed" - | "agent.session.activated" - | "agent.tools.available" - | "agent.session.deactivated" - | "agent.pair.user_message" - | "agent.pair.system_message" - | "agent.interrupt.injected" - | "agent.steer.buffered" - | "agent.steer.dropped" - | "subgraph.started" - | "subgraph.completed" - | "sandbox.initializing" - | "sandbox.ready" - | "sandbox.failed" - | "sandbox.cleanup.started" - | "sandbox.cleanup.completed" - | "sandbox.cleanup.failed" - | "sandbox.git.started" - | "sandbox.git.completed" - | "sandbox.git.failed" - | "sandbox.initialized" - | "setup.started" - | "setup.command.started" - | "setup.command.completed" - | "setup.completed" - | "setup.failed" - | "git.identity.resolved" - | "watchdog.timeout" - | "artifact.captured" - | "ssh.ready" - | "prompt.failover" - | "cli.ensure.started" - | "cli.ensure.completed" - | "cli.ensure.failed" - | "command.started" - | "command.completed" - | "agent.acp.started" - | "agent.acp.completed" - | "agent.acp.cancelled" - | "agent.acp.timed_out" - | "pull_request.created" - | "pull_request.linked" - | "pull_request.unlinked" - | "pull_request.failed" - | "agent.session.started" - | "agent.session.ended" - | "agent.processing.end" - | "agent.input" - | "agent.message" - | "agent.tool.started" - | "agent.tool.completed" - | "agent.tool.process.completed" - | "agent.error" - | "agent.warning" - | "agent.loop.detected" - | "agent.steering.injected" - | "agent.round.interrupted" - | "agent.compaction.started" - | "agent.compaction.completed" - | "agent.llm.started" - | "agent.llm.first_output" - | "agent.llm.retry" - | "agent.sub.spawned" - | "agent.sub.turn.started" - | "agent.sub.completed" - | "agent.sub.failed" - | "agent.sub.closed" - | "agent.memory.loaded" - | "agent.skills.discovered" - | "agent.skill.activated" - | "todo.created" - | "todo.updated" - | "todo.deleted" - ) -} - -impl RunEvent { - pub fn from_value(mut value: Value) -> serde_json::Result { - normalize_legacy_event(&mut value); - let raw: RunEventRaw = serde_json::from_value(value)?; - Self::from_parts(RunEventParts { - id: raw.id, - ts: raw.ts, - run_id: raw.run_id, - node_id: raw.node_id, - node_label: raw.node_label, - stage_id: raw.stage_id, - parallel_group_id: raw.parallel_group_id, - parallel_branch_id: raw.parallel_branch_id, - session_id: raw.session_id, - parent_session_id: raw.parent_session_id, - tool_call_id: raw.tool_call_id, - actor: raw.actor, - event: &raw.event, - properties: &raw.properties, - }) - } - - pub fn from_ref(value: &Value) -> serde_json::Result { - fn opt_field Deserialize<'a>>( - obj: &Map, - key: &str, - ) -> serde_json::Result> { - match obj.get(key) { - Some(value) if !value.is_null() => Ok(Some(T::deserialize(value)?)), - _ => Ok(None), - } - } - - let obj = value.as_object().ok_or_else(|| { - ::custom("run event must be a JSON object") - })?; - let opt_str = |key: &str| obj.get(key).and_then(Value::as_str).map(str::to_string); - let id = obj.get("id").and_then(Value::as_str).ok_or_else(|| { - ::custom("missing or non-string field: id") - })?; - let ts = obj - .get("ts") - .ok_or_else(|| ::custom("missing field: ts")) - .and_then(DateTime::::deserialize)?; - let run_id = obj - .get("run_id") - .ok_or_else(|| ::custom("missing field: run_id")) - .and_then(RunId::deserialize)?; - let event = obj.get("event").and_then(Value::as_str).ok_or_else(|| { - ::custom("missing or non-string field: event") - })?; - let mut properties = obj - .get("properties") - .cloned() - .unwrap_or_else(default_properties); - normalize_legacy_event_properties(event, &mut properties); - Self::from_parts(RunEventParts { - id: id.to_string(), - ts, - run_id, - node_id: opt_str("node_id"), - node_label: opt_str("node_label"), - stage_id: opt_field(obj, "stage_id")?, - parallel_group_id: opt_field(obj, "parallel_group_id")?, - parallel_branch_id: opt_field(obj, "parallel_branch_id")?, - session_id: opt_str("session_id"), - parent_session_id: opt_str("parent_session_id"), - tool_call_id: opt_str("tool_call_id"), - actor: opt_field(obj, "actor")?, - event, - properties: &properties, - }) - } - - fn from_parts(parts: RunEventParts<'_>) -> serde_json::Result { - let body: EventBody = if is_coding_event_name(parts.event) { - EventBody::Agent(serde_json::from_value(parts.properties.clone())?) - } else { - let body_payload = json!({ - "event": parts.event, - "properties": parts.properties, - }); - match EventBody::sandbox_driver_from_stored(parts.event, parts.properties) { - Some(body) => body, - None => match serde_json::from_value(body_payload) { - Ok(body) => body, - Err(err) if is_known_event_name(parts.event) => return Err(err), - Err(_) => EventBody::Unknown { - name: parts.event.to_string(), - properties: parts.properties.clone(), - }, - }, - } - }; - Ok(Self { - id: parts.id, - ts: parts.ts, - run_id: parts.run_id, - node_id: parts.node_id, - node_label: parts.node_label, - stage_id: parts.stage_id, - parallel_group_id: parts.parallel_group_id, - parallel_branch_id: parts.parallel_branch_id, - session_id: parts.session_id, - parent_session_id: parts.parent_session_id, - tool_call_id: parts.tool_call_id, - actor: parts.actor, - body, - }) - } - - pub fn from_json_str(line: &str) -> serde_json::Result { - Self::from_value(serde_json::from_str(line)?) - } - - pub fn to_value(&self) -> serde_json::Result { - fn insert_opt( - map: &mut Map, - key: &str, - value: Option<&T>, - ) -> serde_json::Result<()> { - if let Some(v) = value { - map.insert(key.to_string(), serde_json::to_value(v)?); - } - Ok(()) - } - - let mut map = Map::new(); - map.insert("id".to_string(), Value::String(self.id.clone())); - map.insert("ts".to_string(), serde_json::to_value(self.ts)?); - map.insert("run_id".to_string(), serde_json::to_value(self.run_id)?); - map.insert( - "event".to_string(), - Value::String(self.body.event_name().to_string()), - ); - insert_opt(&mut map, "session_id", self.session_id.as_ref())?; - insert_opt( - &mut map, - "parent_session_id", - self.parent_session_id.as_ref(), - )?; - insert_opt(&mut map, "node_id", self.node_id.as_ref())?; - insert_opt(&mut map, "node_label", self.node_label.as_ref())?; - insert_opt(&mut map, "stage_id", self.stage_id.as_ref())?; - insert_opt( - &mut map, - "parallel_group_id", - self.parallel_group_id.as_ref(), - )?; - insert_opt( - &mut map, - "parallel_branch_id", - self.parallel_branch_id.as_ref(), - )?; - insert_opt(&mut map, "tool_call_id", self.tool_call_id.as_ref())?; - insert_opt(&mut map, "actor", self.actor.as_ref())?; - map.insert("properties".to_string(), self.body.properties_value()?); - Ok(Value::Object(map)) - } - - pub fn event_name(&self) -> &str { - self.body.event_name() - } - - pub fn properties(&self) -> serde_json::Result { - self.body.properties_value() - } -} - -/// Upgrades historical envelope shapes only in the value being decoded. -/// -/// Event bodies carry no compatibility rewrites: Fabro is greenfield, so a -/// stored body either matches the current schema or fails to decode. -fn normalize_legacy_event(value: &mut Value) { - let Some(event) = value - .get("event") - .and_then(Value::as_str) - .map(str::to_owned) - else { - return; - }; - let Some(properties) = value.get_mut("properties") else { - return; - }; - normalize_legacy_event_properties(&event, properties); -} - -fn normalize_legacy_event_properties(event: &str, properties: &mut Value) { - let Some(object) = properties.as_object_mut() else { - return; - }; - match event { - "run.completed" => normalize_legacy_timing(object, false), - "run.failed" => { - normalize_legacy_run_failure(object); - normalize_legacy_timing(object, false); - } - "stage.completed" => normalize_legacy_timing(object, true), - "sandbox.initialized" => normalize_legacy_sandbox_id(object), - _ => {} - } -} - -fn normalize_legacy_timing(properties: &mut Map, stage: bool) { - if properties.contains_key("timing") { - return; - } - let Some(wall_time_ms) = properties.get("duration_ms").and_then(Value::as_u64) else { - return; - }; - let timing = json!({ - "wall_time_ms": wall_time_ms, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0, - }); - properties.insert("timing".to_owned(), timing); - if stage { - properties.remove("duration_ms"); - } -} - -fn normalize_legacy_run_failure(properties: &mut Map) { - if !properties.contains_key("failure") { - if let (Some(message), Some(reason)) = ( - properties.get("error").cloned(), - properties.get("reason").cloned(), - ) { - let causes = properties - .get("causes") - .cloned() - .unwrap_or_else(|| Value::Array(Vec::new())); - properties.insert( - "failure".to_owned(), - json!({ - "reason": reason, - "detail": { - "message": message, - "causes": causes, - "category": "deterministic", - } - }), - ); - } - } - if !properties.contains_key("final_git_commit_sha") { - if let Some(commit) = properties.get("git_commit_sha").cloned() { - properties.insert("final_git_commit_sha".to_owned(), commit); - } - } -} - -fn normalize_legacy_sandbox_id(properties: &mut Map) { - if properties.contains_key("id") { - return; - } - let id = properties - .get("identifier") - .and_then(Value::as_str) - .unwrap_or_default(); - properties.insert("id".to_owned(), Value::String(id.to_owned())); -} - -impl Serialize for RunEvent { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - self.to_value() - .map_err(S::Error::custom)? - .serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for RunEvent { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = Value::deserialize(deserializer)?; - Self::from_value(value).map_err(D::Error::custom) - } -} - -#[cfg(test)] -mod tests { - use std::time::{Duration, UNIX_EPOCH}; - - use pebble_coding_agent::events::{ - CodingAgentEvent, CodingEvent, Cost, CostSource, TodoCreatedProps, TodoListKind, - TodoStatus, TokenCounts, ToolCategory, ToolSource, ToolSummary, Usage, - }; - use serde_json::json; - - use super::*; - use crate::{ - AuthMethod, BlobHash, Edge, Graph, IdpIdentity, Node, PendingReason, WorkflowSettings, - fixtures, test_support, - }; - - fn coding_event(stage: &str, visit: u32, event: CodingEvent) -> AgentEventProps { - AgentEventProps::new( - stage, - visit, - CodingAgentEvent::new( - "ses_1", - event, - UNIX_EPOCH + Duration::from_secs(1_700_000_000), - ) - .with_seq(3), - ) - } - - #[test] - fn agent_events_store_under_their_derived_name_and_read_back() { - let body = EventBody::Agent(coding_event("code", 2, CodingEvent::RoundInterrupted { - generation: 3, - })); - let event = RunEvent { - id: "evt_round_interrupted".to_string(), - ts: DateTime::parse_from_rfc3339("2026-04-04T12:00:00.000Z") - .unwrap() - .with_timezone(&Utc), - run_id: fixtures::RUN_1, - node_id: Some("code".to_string()), - node_label: Some("code".to_string()), - stage_id: Some(StageId::new("code", 2)), - parallel_group_id: None, - parallel_branch_id: None, - session_id: Some("ses_1".to_string()), - parent_session_id: None, - tool_call_id: None, - actor: None, - body, - }; - - let value = event.to_value().unwrap(); - assert_eq!(value["event"], "agent.round.interrupted"); - assert_eq!(value["properties"]["stage"], "code"); - assert_eq!(value["properties"]["visit"], 2); - assert_eq!(value["properties"]["seq"], 3); - assert_eq!(value["properties"]["session_id"], "ses_1"); - assert_eq!( - value["properties"]["event"], - json!({"RoundInterrupted": {"generation": 3}}) - ); - - let parsed = RunEvent::from_value(value).unwrap(); - assert_eq!(parsed, event); - assert_eq!(parsed.event_name(), "agent.round.interrupted"); - } - - #[test] - fn todo_events_store_under_todo_names() { - let body = EventBody::Agent(coding_event( - "code", - 1, - CodingEvent::TodoCreated(TodoCreatedProps { - list_id: "openai_plan:ses_1".to_string(), - list_kind: TodoListKind::OpenAiPlan, - todo_id: "todo_1".to_string(), - status: TodoStatus::Pending, - order: 0, - subject: "do the thing".to_string(), - description: String::new(), - active_form: None, - owner: None, - blocks: Vec::new(), - blocked_by: Vec::new(), - metadata: std::collections::BTreeMap::new(), - }), - )); - assert_eq!(body.event_name(), "todo.created"); - assert!(is_known_event_name("todo.created")); - assert!(is_known_event_name("agent.message")); - assert!(is_known_event_name("agent.compaction.failed")); - } - - #[test] - fn bare_agent_events_serialize_as_their_variant_name() { - let body = EventBody::Agent(coding_event("code", 1, CodingEvent::SessionEnded)); - let value = RunEvent { - id: "evt_session_ended".to_string(), - ts: Utc::now(), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: Some("ses_1".to_string()), - parent_session_id: None, - tool_call_id: None, - actor: None, - body, - } - .to_value() - .unwrap(); - assert_eq!(value["event"], "agent.session.ended"); - assert_eq!(value["properties"]["event"], "SessionEnded"); - } - - #[test] - fn a_malformed_agent_event_is_rejected_not_demoted_to_unknown() { - let value = json!({ - "id": "evt_bad", - "ts": "2026-04-04T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "agent.message", - "properties": {"stage": "code", "visit": 1} - }); - assert!(RunEvent::from_value(value).is_err()); - } - - #[test] - fn agent_message_carries_pebbles_usage_shape() { - let body = EventBody::Agent(coding_event("code", 1, CodingEvent::AssistantMessage { - text: "ok".to_string(), - model: "gpt-5.4".to_string(), - usage: Usage { - tokens: TokenCounts { - input: 10, - output: 5, - ..TokenCounts::default() - }, - cost: Some(Cost { - usd_micros: 42, - source: CostSource::Provider, - }), - }, - tool_call_count: 0, - context_window: None, - reasoning: None, - })); - let value = serde_json::to_value(&body).unwrap(); - assert_eq!( - value["properties"]["event"]["AssistantMessage"]["usage"], - json!({ - "tokens": {"input": 10, "output": 5, "reasoning": 0, "cache_read": 0, "cache_write": 0}, - "cost": {"usd_micros": 42, "source": "provider"} - }) - ); - } - - fn user_principal(login: &str) -> Principal { - Principal::user( - IdpIdentity::new("https://github.com", "12345").unwrap(), - login.to_string(), - AuthMethod::Github, - ) - } - - fn stored_event(event: &str, properties: &Value) -> Value { - json!({ - "id": format!("evt_{event}"), - "ts": "2026-04-04T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": event, - "properties": properties, - }) - } - - #[test] - fn run_event_round_trips_json() { - let event = RunEvent { - id: "evt_1".to_string(), - ts: DateTime::parse_from_rfc3339("2026-04-04T12:00:00.000Z") - .unwrap() - .with_timezone(&Utc), - run_id: fixtures::RUN_1, - node_id: Some("build".to_string()), - node_label: Some("Build".to_string()), - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body: EventBody::StageCompleted(StageCompletedProps { - index: 1, - timing: crate::StageTiming::wall_only(1234), - status: crate::StageOutcome::Succeeded, - preferred_label: None, - suggested_next_ids: vec!["next".to_string()], - usage_by_model: Vec::new(), - usage: None, - failure: None, - notes: Some("done".to_string()), - files_touched: vec!["src/main.rs".to_string()], - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: None, - restart_failure_signatures: None, - response: None, - attempt: 1, - max_attempts: 1, - }), - }; - - let value = event.to_value().unwrap(); - let parsed = RunEvent::from_value(value).unwrap(); - - assert_eq!(parsed, event); - } - - #[test] - fn run_event_deserializes_adjacent_layout() { - let settings = WorkflowSettings::default(); - let mut graph = Graph::new("test"); - graph.nodes.insert("start".to_string(), Node::new("start")); - graph.edges.push(Edge::new("start", "done")); - - let line = json!({ - "id": "evt_2", - "ts": "2026-04-04T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "run.created", - "properties": { - "settings": settings, - "graph": graph, - "labels": {}, - "source_directory": "/tmp/run", - "provenance": test_support::test_run_provenance() - } - }); - - let parsed = RunEvent::from_value(line).unwrap(); - assert!(matches!(parsed.body, EventBody::RunCreated(_))); - } - - #[test] - fn historical_prompt_failover_event_defaults_its_attempt() { - let line = json!({ - "id": "evt_failover", - "ts": "2026-04-04T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "prompt.failover", - "properties": { - "from_provider": "anthropic", - "from_model": "claude-fable-5", - "to_provider": "openai", - "to_model": "gpt-5.6-sol", - "error": "provider unavailable" - } - }); - - let parsed = RunEvent::from_value(line).unwrap(); - let EventBody::Failover(props) = parsed.body else { - panic!("expected prompt.failover"); - }; - assert_eq!(props.attempt, None); - assert_eq!(props.to_model, "gpt-5.6-sol"); - } - - #[test] - fn prompt_failover_event_round_trips() { - let body = EventBody::Failover(FailoverProps { - from_provider: "anthropic".to_string(), - from_model: "claude-fable-5".to_string(), - to_provider: "openai".to_string(), - to_model: "gpt-5.6-sol".to_string(), - attempt: Some(1), - error: "overloaded".to_string(), - }); - let value = serde_json::to_value(&body).unwrap(); - assert_eq!(value["event"], "prompt.failover"); - assert_eq!( - value["properties"], - json!({ - "from_provider": "anthropic", - "from_model": "claude-fable-5", - "to_provider": "openai", - "to_model": "gpt-5.6-sol", - "attempt": 1, - "error": "overloaded" - }) - ); - let parsed: EventBody = serde_json::from_value(value).unwrap(); - assert_eq!(parsed, body); - } - - #[test] - fn historical_run_created_defaults_new_run_settings() { - let mut settings = serde_json::to_value(WorkflowSettings::default()).unwrap(); - let run = settings["run"].as_object_mut().unwrap(); - for field in ["clone", "run_branch", "integrations"] { - run.remove(field); - } - let line = stored_event( - "run.created", - &json!({ - "settings": settings, - "graph": Graph::new("test"), - "labels": {}, - "source_directory": "/tmp/run", - "provenance": test_support::test_run_provenance() - }), - ); - - let parsed = RunEvent::from_value(line).unwrap(); - let EventBody::RunCreated(props) = parsed.body else { - panic!("expected run.created"); - }; - - assert_eq!(props.settings.run, WorkflowSettings::default().run); - } - - #[test] - fn historical_terminal_and_sandbox_events_are_upgraded() { - let completed = stored_event( - "run.completed", - &json!({ - "duration_ms": 123, - "artifact_count": 0, - "status": "succeeded", - "reason": "completed" - }), - ); - let failed = stored_event( - "run.failed", - &json!({ - "error": "cancelled by user", - "causes": ["interrupt requested"], - "duration_ms": 456, - "reason": "cancelled", - "git_commit_sha": "abc123" - }), - ); - let sandbox = stored_event( - "sandbox.initialized", - &json!({ - "provider": "local", - "working_directory": "/tmp/run" - }), - ); - - let completed = RunEvent::from_value(completed).unwrap().to_value().unwrap(); - let failed = RunEvent::from_value(failed).unwrap().to_value().unwrap(); - let sandbox = RunEvent::from_value(sandbox).unwrap().to_value().unwrap(); - - assert_eq!(completed["properties"]["timing"]["wall_time_ms"], 123); - assert_eq!(failed["properties"]["failure"]["reason"], "cancelled"); - assert_eq!( - failed["properties"]["failure"]["detail"]["message"], - "cancelled by user" - ); - assert_eq!( - failed["properties"]["failure"]["detail"]["causes"], - json!(["interrupt requested"]) - ); - assert_eq!(failed["properties"]["timing"]["wall_time_ms"], 456); - assert_eq!(failed["properties"]["final_git_commit_sha"], "abc123"); - assert_eq!(sandbox["properties"]["id"], ""); - } - - #[test] - fn interview_interrupted_kind_matches_event_name() { - let body = EventBody::InterviewInterrupted(InterviewInterruptedProps { - question_id: "q-1".to_string(), - question: "approve?".to_string(), - stage: "gate".to_string(), - reason: "interrupted".to_string(), - duration_ms: 12, - }); - - assert_eq!(body.event_name(), "interview.interrupted"); - } - - #[test] - fn run_interrupt_round_trips_with_empty_properties_and_actor() { - let line = json!({ - "id": "evt_interrupt", - "ts": "2026-04-04T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "run.interrupt", - "actor": { "kind": "system", "system_kind": "engine" }, - "properties": {} - }); - - let parsed = RunEvent::from_value(line.clone()).unwrap(); - assert!(matches!(parsed.body, EventBody::RunInterrupt(_))); - assert_eq!(parsed.to_value().unwrap(), line); - } - - #[test] - fn run_steer_round_trips_with_text_and_actor() { - let line = json!({ - "id": "evt_steer", - "ts": "2026-04-04T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "run.steer", - "actor": { "kind": "system", "system_kind": "engine" }, - "properties": { "text": "try another approach" } - }); - - let parsed = RunEvent::from_value(line.clone()).unwrap(); - assert!(matches!( - &parsed.body, - EventBody::RunSteer(props) if props.text == "try another approach" - )); - assert_eq!(parsed.to_value().unwrap(), line); - } - - #[test] - fn pre_execution_lifecycle_events_round_trip() { - let cases = [ - ( - EventBody::RunStartRequested(RunStartRequestedProps { resume: false }), - json!("run.start_requested"), - json!({ "resume": false }), - ), - ( - EventBody::RunPending(RunPendingProps { - reason: PendingReason::ApprovalRequired, - }), - json!("run.pending"), - json!({ "reason": "approval_required" }), - ), - ( - EventBody::RunApproved(RunApprovedProps::default()), - json!("run.approved"), - json!({}), - ), - ( - EventBody::RunDenied(RunDeniedProps { - reason: Some("Not approved for execution".to_string()), - }), - json!("run.denied"), - json!({ "reason": "Not approved for execution" }), - ), - ( - EventBody::RunRunnable(RunRunnableProps { - source: RunRunnableSource::Approved, - }), - json!("run.runnable"), - json!({ "source": "approved" }), - ), - ]; - - for (body, event_name, properties) in cases { - let event = RunEvent { - id: format!("evt_{}", event_name.as_str().unwrap()), - ts: DateTime::parse_from_rfc3339("2026-05-23T12:00:00Z") - .unwrap() - .with_timezone(&Utc), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: Some(Principal::System { - system_kind: crate::SystemActorKind::Engine, - }), - body, - }; - let value = event.to_value().unwrap(); - assert_eq!(value["event"], event_name); - assert_eq!(value["properties"], properties); - assert_eq!(RunEvent::from_value(value).unwrap(), event); - } - } - - #[test] - fn agent_interrupt_injected_round_trips_with_stage_session_and_actor() { - let line = json!({ - "id": "evt_interrupt_injected", - "ts": "2026-04-04T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "agent.interrupt.injected", - "node_id": "code", - "node_label": "code", - "stage_id": "code@2", - "session_id": "ses_1", - "actor": { "kind": "system", "system_kind": "engine" }, - "properties": { "visit": 2 } - }); - - let parsed = RunEvent::from_value(line.clone()).unwrap(); - assert!(matches!( - &parsed.body, - EventBody::AgentInterruptInjected(props) if props.visit == 2 - )); - assert_eq!(parsed.to_value().unwrap(), line); - } - - #[test] - fn run_interrupt_then_steer_is_not_a_known_persisted_event() { - let line = json!({ - "id": "evt_combined", - "ts": "2026-04-04T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "run.interrupt_then_steer", - "properties": { "text": "try another approach" } - }); - - let parsed = RunEvent::from_value(line).unwrap(); - assert!(matches!( - parsed.body, - EventBody::Unknown { ref name, .. } if name == "run.interrupt_then_steer" - )); - } - - #[test] - fn patch_bearing_events_round_trip_diff_summary() { - for (event_name, properties) in [ - ( - "checkpoint.completed", - json!({ - "status": "running", - "current_node": "build", - "completed_nodes": ["build"], - "diff_summary": { - "files_changed": 2, - "additions": 10, - "deletions": 3 - } - }), - ), - ( - "run.completed", - json!({ - "timing": { - "wall_time_ms": 42, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - }, - "artifact_count": 0, - "status": "succeeded", - "reason": "completed", - "diff_summary": { - "files_changed": 2, - "additions": 10, - "deletions": 3 - } - }), - ), - ( - "run.failed", - json!({ - "failure": { - "reason": "workflow_error", - "detail": { - "message": "boom", - "category": "deterministic" - } - }, - "timing": { - "wall_time_ms": 42, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - }, - "diff_summary": { - "files_changed": 2, - "additions": 10, - "deletions": 3 - } - }), - ), - ] { - let line = json!({ - "id": format!("evt_{event_name}"), - "ts": "2026-04-04T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": event_name, - "node_id": "build", - "properties": properties - }); - - let parsed = RunEvent::from_value(line).unwrap(); - let serialized = parsed.to_value().unwrap(); - - assert_eq!( - serialized["properties"]["diff_summary"], - json!({ - "files_changed": 2, - "additions": 10, - "deletions": 3 - }), - "{event_name} should preserve diff_summary" - ); - } - } - - #[test] - fn run_submitted_round_trip_preserves_definition_blob() { - let line = json!({ - "id": "evt_submitted_blob", - "ts": "2026-04-04T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "run.submitted", - "properties": { - "definition_blob": BlobHash::new(br#"{"workflow_path":"workflow.fabro"}"#).to_string() - } - }); - - let parsed = RunEvent::from_value(line.clone()).unwrap(); - let serialized = parsed.to_value().unwrap(); - - assert_eq!( - serialized["properties"]["definition_blob"], - line["properties"]["definition_blob"] - ); - } - - #[test] - fn event_body_event_name_matches_wire_name() { - let body = EventBody::StageCompleted(StageCompletedProps { - index: 1, - timing: crate::StageTiming::wall_only(1234), - status: crate::StageOutcome::Succeeded, - preferred_label: None, - suggested_next_ids: vec!["next".to_string()], - usage_by_model: Vec::new(), - usage: None, - failure: None, - notes: Some("done".to_string()), - files_touched: vec!["src/main.rs".to_string()], - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: None, - restart_failure_signatures: None, - response: None, - attempt: 1, - max_attempts: 1, - }); - - assert_eq!(body.event_name(), "stage.completed"); - } - - #[test] - fn run_event_preserves_unknown_event_name_and_properties() { - let value = json!({ - "id": "evt_unknown", - "ts": "2026-04-04T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "vendor.custom.event", - "properties": { - "answer": 42, - "nested": { "ok": true } - } - }); - - let parsed = RunEvent::from_value(value.clone()).unwrap(); - let serialized = parsed.to_value().unwrap(); - - assert_eq!(parsed.event_name(), "vendor.custom.event"); - assert_eq!(parsed.properties().unwrap(), value["properties"]); - assert_eq!(serialized["event"], value["event"]); - assert_eq!(serialized["properties"], value["properties"]); - } - - #[test] - fn run_event_round_trips_new_envelope_fields() { - let value = json!({ - "id": "evt_envelope", - "ts": "2026-04-08T16:21:11.106Z", - "run_id": fixtures::RUN_1, - "event": "agent.tool.completed", - "stage_id": "code@1", - "node_id": "code", - "node_label": "Code", - "parallel_group_id": "code@1", - "parallel_branch_id": "code@1:0", - "session_id": "ses_child", - "parent_session_id": "ses_parent", - "tool_call_id": "call_1", - "actor": { - "kind": "agent", - "session_id": "ses_child", - "parent_session_id": "ses_parent", - "model": "claude-sonnet" - }, - "properties": { - "stage": "code", - "visit": 1, - "seq": 9, - "stream_id": "ses_parent", - "session_id": "ses_child", - "parent_session_id": "ses_parent", - "tool_call_id": "call_1", - "timestamp": "2026-04-08T16:21:11.106Z", - "event": { - "ToolCallCompleted": { - "tool_name": "read_file", - "tool_call_id": "call_1", - "output": {"summary": "read"}, - "is_error": false - } - } - } - }); - - let parsed = RunEvent::from_value(value.clone()).unwrap(); - assert_eq!(parsed.stage_id, Some(StageId::new("code", 1))); - assert_eq!(parsed.parallel_group_id, Some(StageId::new("code", 1))); - assert_eq!( - parsed.parallel_branch_id, - Some(ParallelBranchId::new(StageId::new("code", 1), 0)) - ); - assert_eq!(parsed.tool_call_id.as_deref(), Some("call_1")); - let actor = parsed.actor.as_ref().expect("actor present"); - assert_eq!(actor, &Principal::Agent { - session_id: Some("ses_child".to_string()), - parent_session_id: Some("ses_parent".to_string()), - model: Some("claude-sonnet".to_string()), - }); - - let serialized = parsed.to_value().unwrap(); - assert_eq!(serialized["stage_id"], value["stage_id"]); - assert_eq!(serialized["parallel_group_id"], value["parallel_group_id"]); - assert_eq!( - serialized["parallel_branch_id"], - value["parallel_branch_id"] - ); - assert_eq!(serialized["tool_call_id"], value["tool_call_id"]); - assert_eq!(serialized["actor"], value["actor"]); - } - - #[test] - fn run_event_omits_absent_envelope_fields() { - let event = RunEvent { - id: "evt_bare".to_string(), - ts: DateTime::parse_from_rfc3339("2026-04-04T12:00:00.000Z") - .unwrap() - .with_timezone(&Utc), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body: EventBody::RunStarted(RunStartedProps { - name: "demo".to_string(), - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: None, - }), - }; - - let serialized = event.to_value().unwrap(); - let obj = serialized.as_object().unwrap(); - assert!(!obj.contains_key("stage_id")); - assert!(!obj.contains_key("parallel_group_id")); - assert!(!obj.contains_key("parallel_branch_id")); - assert!(!obj.contains_key("tool_call_id")); - assert!(!obj.contains_key("actor")); - } - - #[test] - fn canonical_run_lifecycle_events_are_known() { - for event in [ - "run.start_requested", - "run.pending", - "run.approved", - "run.denied", - "run.runnable", - "run.blocked", - "run.unblocked", - ] { - assert!( - is_known_event_name(event), - "{event} should be a known event" - ); - } - } - - #[test] - fn run_blocked_round_trips_as_typed_event() { - let value = json!({ - "id": "evt_run_blocked", - "ts": "2026-04-19T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "run.blocked", - "properties": { - "blocked_reason": "human_input_required" - } - }); - - let parsed = RunEvent::from_value(value.clone()).unwrap(); - assert!( - !matches!(parsed.body, EventBody::Unknown { .. }), - "run.blocked should deserialize into a typed event body" - ); - - let serialized = parsed.to_value().unwrap(); - assert_eq!(serialized["event"], "run.blocked"); - assert_eq!( - serialized["properties"]["blocked_reason"], - value["properties"]["blocked_reason"] - ); - } - - #[test] - fn run_archived_serializes_with_dotted_event_name_without_actor_property() { - let body = EventBody::RunArchived(RunArchivedProps::default()); - let value = serde_json::to_value(&body).unwrap(); - assert_eq!(value["event"], "run.archived"); - assert_eq!(value["properties"], json!({})); - } - - #[test] - fn run_unarchived_serializes_without_actor_property() { - let body = EventBody::RunUnarchived(RunUnarchivedProps::default()); - let value = serde_json::to_value(&body).unwrap(); - assert_eq!(value["event"], "run.unarchived"); - assert_eq!(value["properties"], json!({})); - } - - #[test] - fn run_archived_round_trips_through_from_value() { - let value = json!({ - "id": "evt_archived", - "ts": "2026-04-19T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "run.archived", - "actor": { - "kind": "user", - "identity": { - "issuer": "https://github.com", - "subject": "12345" - }, - "login": "alice", - "auth_method": "github" - }, - "properties": {} - }); - - let parsed = RunEvent::from_value(value.clone()).unwrap(); - assert!(matches!(parsed.body, EventBody::RunArchived(_))); - assert_eq!(parsed.actor, Some(user_principal("alice"))); - let serialized = parsed.to_value().unwrap(); - assert_eq!(serialized["event"], "run.archived"); - assert_eq!(serialized["actor"], value["actor"]); - assert_eq!(serialized["properties"], json!({})); - } - - #[test] - fn run_unarchived_round_trips_through_from_value() { - let value = json!({ - "id": "evt_unarchived", - "ts": "2026-04-19T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "run.unarchived", - "properties": {} - }); - - let parsed = RunEvent::from_value(value.clone()).unwrap(); - match &parsed.body { - EventBody::RunUnarchived(_) => {} - other => panic!("expected RunUnarchived body, got {other:?}"), - } - } - - #[test] - fn run_runnable_and_unblocked_round_trip_as_typed_events() { - for value in [ - json!({ - "id": "evt_run_runnable", - "ts": "2026-04-19T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "run.runnable", - "properties": { "source": "start_requested" } - }), - json!({ - "id": "evt_run_unblocked", - "ts": "2026-04-19T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": "run.unblocked", - "properties": {} - }), - ] { - let parsed = RunEvent::from_value(value.clone()).unwrap(); - assert!( - !matches!(parsed.body, EventBody::Unknown { .. }), - "{} should deserialize into a typed event body", - value["event"].as_str().unwrap() - ); - assert_eq!(parsed.to_value().unwrap()["event"], value["event"]); - } - } - - #[test] - fn metadata_snapshot_events_are_known_and_round_trip_json() { - let completed = RunEvent { - id: "evt_metadata_completed".to_string(), - ts: DateTime::parse_from_rfc3339("2026-04-29T12:00:00.000Z") - .unwrap() - .with_timezone(&Utc), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body: EventBody::MetadataSnapshotCompleted( - MetadataSnapshotCompletedProps { - phase: MetadataSnapshotPhase::Checkpoint, - branch: "fabro/metadata/run".to_string(), - duration_ms: 2800, - entry_count: 3, - bytes: 42, - commit_sha: "abc123".to_string(), - }, - ), - }; - - let serialized = completed.to_value().unwrap(); - assert_eq!(serialized["event"], "metadata.snapshot.completed"); - assert_eq!(serialized["properties"]["phase"], "checkpoint"); - assert_eq!(serialized["properties"]["branch"], "fabro/metadata/run"); - assert_eq!(serialized["properties"]["duration_ms"], 2800); - assert_eq!(serialized["properties"]["entry_count"], 3); - assert_eq!(serialized["properties"]["bytes"], 42); - assert_eq!(serialized["properties"]["commit_sha"], "abc123"); - - let parsed = RunEvent::from_value(serialized).unwrap(); - assert_eq!(parsed.event_name(), "metadata.snapshot.completed"); - assert!(matches!( - parsed.body, - EventBody::MetadataSnapshotCompleted(MetadataSnapshotCompletedProps { - phase: MetadataSnapshotPhase::Checkpoint, - .. - }) - )); - } - - #[test] - fn pull_request_linked_round_trips_json() { - let event = RunEvent { - id: "evt_pr_linked".to_string(), - ts: DateTime::parse_from_rfc3339("2026-05-15T12:00:00.000Z") - .unwrap() - .with_timezone(&Utc), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body: EventBody::PullRequestLinked(PullRequestLinkedProps { - pull_request: crate::PullRequestLink { - owner: "acme".to_string(), - repo: "widgets".to_string(), - number: 42, - }, - }), - }; - - let value = event.to_value().unwrap(); - assert_eq!(value["event"], "pull_request.linked"); - assert_eq!( - value["properties"]["pull_request"]["html_url"], - "https://github.com/acme/widgets/pull/42" - ); - - let parsed = RunEvent::from_value(value).unwrap(); - assert_eq!(parsed, event); - } - - #[test] - fn pull_request_unlinked_round_trips_json() { - let event = RunEvent { - id: "evt_pr_unlinked".to_string(), - ts: DateTime::parse_from_rfc3339("2026-05-15T12:05:00.000Z") - .unwrap() - .with_timezone(&Utc), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body: EventBody::PullRequestUnlinked(PullRequestUnlinkedProps { - pull_request: crate::PullRequestLink { - owner: "acme".to_string(), - repo: "widgets".to_string(), - number: 42, - }, - }), - }; - - let value = event.to_value().unwrap(); - assert_eq!(value["event"], "pull_request.unlinked"); - assert_eq!( - value["properties"]["pull_request"]["number"], - serde_json::json!(42) - ); - - let parsed = RunEvent::from_value(value).unwrap(); - assert_eq!(parsed, event); - } - - #[test] - fn retired_sandbox_snapshot_events_deserialize_as_unknown() { - for (event_name, expected_properties) in [ - ( - "sandbox.snapshot.pulled", - json!({"name": "buildpack-deps:noble", "duration_ms": 5000}), - ), - ("sandbox.snapshot.ensuring", json!({"name": "fabro-v8"})), - ] { - let value = json!({ - "id": "evt_retired_snapshot", - "ts": "2026-04-29T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": event_name, - "properties": expected_properties - }); - - let parsed = RunEvent::from_value(value).unwrap(); - match parsed.body { - EventBody::Unknown { name, properties } => { - assert_eq!(name, event_name); - assert_eq!(properties, expected_properties); - } - other => panic!("expected Unknown body, got {other:?}"), - } - } - } - - #[test] - fn retired_retro_events_deserialize_as_unknown() { - for (event_name, expected_properties) in [ - ( - "retro.started", - json!({"prompt": "Analyze the run", "provider": "openai", "model": "gpt-5"}), - ), - ( - "retro.completed", - json!({"duration_ms": 1200, "response": "done", "retro": {"smoothness": "smooth"}}), - ), - ( - "retro.failed", - json!({"duration_ms": 1200, "error": "state unavailable"}), - ), - ] { - let value = json!({ - "id": "evt_retired_retro", - "ts": "2026-05-08T12:00:00.000Z", - "run_id": fixtures::RUN_1, - "event": event_name, - "properties": expected_properties - }); - - let parsed = RunEvent::from_value(value).unwrap(); - match parsed.body { - EventBody::Unknown { name, properties } => { - assert_eq!(name, event_name); - assert_eq!(properties, expected_properties); - } - other => panic!("expected Unknown body, got {other:?}"), - } - } - } - - #[test] - fn metadata_snapshot_failed_omits_empty_optional_fields() { - let body = EventBody::MetadataSnapshotFailed(MetadataSnapshotFailedProps { - phase: MetadataSnapshotPhase::Init, - branch: "fabro/metadata/run".to_string(), - duration_ms: 15, - failure_kind: MetadataSnapshotFailureKind::LoadState, - error: "state unavailable".to_string(), - causes: Vec::new(), - commit_sha: None, - entry_count: None, - bytes: None, - exec_output_tail: None, - }); - - let value = serde_json::to_value(&body).unwrap(); - assert_eq!(value["event"], "metadata.snapshot.failed"); - assert_eq!( - value["properties"], - json!({ - "phase": "init", - "branch": "fabro/metadata/run", - "duration_ms": 15, - "failure_kind": "load_state", - "error": "state unavailable" - }) - ); - } - - #[test] - fn metadata_snapshot_failed_serializes_exec_output_tail_additively() { - let body = EventBody::MetadataSnapshotFailed(MetadataSnapshotFailedProps { - phase: MetadataSnapshotPhase::Checkpoint, - branch: "fabro/metadata/run".to_string(), - duration_ms: 20, - failure_kind: MetadataSnapshotFailureKind::Push, - error: "push failed".to_string(), - causes: Vec::new(), - commit_sha: None, - entry_count: None, - bytes: None, - exec_output_tail: Some(ExecOutputTail { - stdout: Some("last stdout line".to_string()), - stderr: Some("last stderr line".to_string()), - stdout_truncated: false, - stderr_truncated: true, - }), - }); - - let value = serde_json::to_value(&body).unwrap(); - assert_eq!( - value["properties"]["exec_output_tail"]["stdout"], - "last stdout line" - ); - assert_eq!( - value["properties"]["exec_output_tail"]["stderr"], - "last stderr line" - ); - assert_eq!( - value["properties"]["exec_output_tail"]["stderr_truncated"], - true - ); - assert!( - value["properties"]["exec_output_tail"] - .as_object() - .expect("exec output tail object") - .get("stdout_truncated") - .is_none() - ); - - let body_without_tail = EventBody::MetadataSnapshotFailed(MetadataSnapshotFailedProps { - phase: MetadataSnapshotPhase::Checkpoint, - branch: "fabro/metadata/run".to_string(), - duration_ms: 20, - failure_kind: MetadataSnapshotFailureKind::Push, - error: "push failed".to_string(), - causes: Vec::new(), - commit_sha: None, - entry_count: None, - bytes: None, - exec_output_tail: None, - }); - let value_without_tail = serde_json::to_value(&body_without_tail).unwrap(); - assert!( - value_without_tail["properties"] - .as_object() - .expect("properties object") - .get("exec_output_tail") - .is_none() - ); - } - - #[test] - fn exec_output_tail_fields_are_additive_on_failure_props() { - let tail = ExecOutputTail { - stdout: Some("last stdout line".to_string()), - stderr: Some("last stderr line".to_string()), - stdout_truncated: false, - stderr_truncated: true, - }; - - for body in [ - EventBody::RunNotice(RunNoticeProps { - level: RunNoticeLevel::Warn, - code: RunNoticeCode::GitDiffFailed.to_string(), - message: "git diff failed".to_string(), - exec_output_tail: Some(tail.clone()), - }), - EventBody::CheckpointFailed(CheckpointFailedProps { - error: "git commit failed".to_string(), - exec_output_tail: Some(tail.clone()), - }), - EventBody::GitPush(GitPushProps { - branch: "refs/heads/run:refs/heads/run".to_string(), - success: false, - exec_output_tail: Some(tail.clone()), - attempts: Vec::new(), - }), - ] { - let value = serde_json::to_value(&body).unwrap(); - assert_eq!( - value["properties"]["exec_output_tail"]["stderr"], - "last stderr line" - ); - assert_eq!( - value["properties"]["exec_output_tail"]["stderr_truncated"], - true - ); - } - } - - #[test] - fn absent_exec_output_tail_is_omitted_from_new_failure_props() { - for body in [ - EventBody::RunNotice(RunNoticeProps { - level: RunNoticeLevel::Warn, - code: RunNoticeCode::GitDiffFailed.to_string(), - message: "git diff failed".to_string(), - exec_output_tail: None, - }), - EventBody::CheckpointFailed(CheckpointFailedProps { - error: "git commit failed".to_string(), - exec_output_tail: None, - }), - EventBody::GitPush(GitPushProps { - branch: "refs/heads/run:refs/heads/run".to_string(), - success: false, - exec_output_tail: None, - attempts: Vec::new(), - }), - ] { - let value = serde_json::to_value(&body).unwrap(); - assert!( - value["properties"] - .as_object() - .expect("properties object") - .get("exec_output_tail") - .is_none() - ); - } - } - - #[test] - fn agent_tools_available_round_trips_without_parameter_schemas() { - let body = EventBody::AgentToolsAvailable(AgentToolsAvailableProps { - tools: vec![ - ToolSummary { - name: "apply_patch".to_string(), - description: "Apply a unified diff patch".to_string(), - source: ToolSource::Native, - category: ToolCategory::Write, - invoked: false, - }, - ToolSummary { - name: "mcp__filesystem__read_file".to_string(), - description: "Read a file through the filesystem MCP server".to_string(), - source: ToolSource::Mcp { - server_name: "filesystem".to_string(), - original_name: "read_file".to_string(), - }, - category: ToolCategory::Other, - invoked: false, - }, - ], - visit: 1, - }); - - let value = serde_json::to_value(&body).unwrap(); - assert_eq!(value["event"], "agent.tools.available"); - assert_eq!(value["properties"]["visit"], 1); - assert_eq!(value["properties"]["tools"][0]["name"], "apply_patch"); - assert_eq!(value["properties"]["tools"][0]["source"]["kind"], "native"); - assert_eq!(value["properties"]["tools"][0]["category"], "write"); - assert!( - value["properties"]["tools"][0] - .as_object() - .unwrap() - .get("parameters") - .is_none(), - "StageProjection tool summaries must not expose full parameter schemas" - ); - - let parsed: EventBody = serde_json::from_value(value).unwrap(); - assert_eq!(parsed, body); - } -} diff --git a/lib/foundation/fabro-types/src/run_event/run.rs b/lib/foundation/fabro-types/src/run_event/run.rs deleted file mode 100644 index 8c2710b96..000000000 --- a/lib/foundation/fabro-types/src/run_event/run.rs +++ /dev/null @@ -1,283 +0,0 @@ -use std::collections::BTreeMap; - -use lithos_llm::types::Usage; -use serde::{Deserialize, Serialize}; - -use super::{ExecOutputTail, RunNoticeLevel}; -use crate::status::{BlockedReason, PendingReason, SuccessReason}; -use crate::{ - AutomationRef, BlobHash, DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, - PetriAdmission, RunControlAction, RunFailure, RunId, RunProvenance, RunTarget, RunTiming, - WorkflowSettings, WorkflowVersionId, -}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunCreatedProps { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, - pub settings: WorkflowSettings, - pub graph: Graph, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_source: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub labels: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_directory: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_slug: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_version_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub automation: Option, - pub provenance: RunProvenance, - /// Unredacted copy of the run spec in the blob store. The settings and - /// graph on this event are redacted at the sink; execution loads the - /// spec from this blob instead. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub spec_blob: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fork_source_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub retried_from: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web_url: Option, - /// What Petri admitted for the run at create time. - pub admission: PetriAdmission, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunParentLinkedProps { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub previous_parent_id: Option, - pub parent_id: RunId, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunParentUnlinkedProps { - pub previous_parent_id: RunId, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunStartedProps { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub run_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub worktree_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal: Option, -} - -#[allow( - clippy::empty_structs_with_brackets, - reason = "This type must serialize as {} rather than null." -)] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct RunStatusTransitionProps {} - -#[allow( - clippy::empty_structs_with_brackets, - reason = "This type must serialize as {} rather than null." -)] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct RunStatusEffectProps {} - -#[allow( - clippy::empty_structs_with_brackets, - reason = "This type must serialize as {} rather than null." -)] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct RunInterruptProps {} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunSteerProps { - pub text: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunPairStartedProps { - pub pair_id: PairId, - pub target: PairTarget, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RunPairEndedReason { - UserRequested, - RunEnded, - SessionEnded, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunPairEndedProps { - pub pair_id: PairId, - pub reason: RunPairEndedReason, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RunPairFailedReason { - WorkerGone, - RuntimeFailed, - SessionFailed, - RunFailed, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunPairFailedProps { - pub pair_id: PairId, - pub reason: RunPairFailedReason, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunSubmittedProps { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub definition_blob: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunStartRequestedProps { - pub resume: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunPendingProps { - pub reason: PendingReason, -} - -#[allow( - clippy::empty_structs_with_brackets, - reason = "This type must serialize as {} rather than null." -)] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct RunApprovedProps {} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunDeniedProps { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum RunRunnableSource { - StartRequested, - Approved, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunRunnableProps { - pub source: RunRunnableSource, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunControlRequestedProps { - pub action: RunControlAction, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunBlockedProps { - pub blocked_reason: BlockedReason, -} - -#[allow( - clippy::empty_structs_with_brackets, - reason = "This type must serialize as {} rather than null." -)] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct RunControlEffectProps {} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunSupersededByProps { - pub new_run_id: RunId, - pub target_checkpoint_ordinal: usize, - pub target_node_id: String, - pub target_visit: usize, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunTitleUpdatedProps { - pub title: String, -} - -#[allow( - clippy::empty_structs_with_brackets, - reason = "This type must serialize as {} rather than null." -)] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct RunArchivedProps {} - -#[allow( - clippy::empty_structs_with_brackets, - reason = "This type must serialize as {} rather than null." -)] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub struct RunUnarchivedProps {} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunCompletedProps { - /// Run wall-clock time, with active timing breakdown for the run rollup. - pub timing: RunTiming, - pub artifact_count: usize, - pub status: String, - pub reason: SuccessReason, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub final_git_commit_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub final_patch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub diff_summary: Option, - /// The run's usage summed across every stage visit; absent for a run - /// that made no model calls. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub usage: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunFailedProps { - pub failure: RunFailure, - /// Run wall-clock time at failure, with active timing breakdown. - pub timing: RunTiming, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub final_git_commit_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub final_patch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub diff_summary: Option, - /// What the run spent before it failed, as on `run.completed`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub usage: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunNoticeProps { - pub level: RunNoticeLevel, - pub code: String, - pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec_output_tail: Option, -} diff --git a/lib/foundation/fabro-types/src/run_event/session.rs b/lib/foundation/fabro-types/src/run_event/session.rs deleted file mode 100644 index 63e4d26d7..000000000 --- a/lib/foundation/fabro-types/src/run_event/session.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! The legacy event log's names for the session event properties, which -//! live in `crate::session_event`. - -pub use crate::session_event::{ - SessionAssistantDeltaProps as RunSessionAssistantDeltaProps, - SessionAssistantMessageProps as RunSessionAssistantMessageProps, - SessionCreatedProps as RunSessionCreatedProps, - SessionToolCallCompletedProps as RunSessionToolCallCompletedProps, - SessionToolCallStartedProps as RunSessionToolCallStartedProps, - SessionTurnFailedCode as RunSessionTurnFailedCode, - SessionTurnFailedProps as RunSessionTurnFailedProps, - SessionTurnInterruptedProps as RunSessionTurnInterruptedProps, - SessionTurnStartedProps as RunSessionTurnStartedProps, - SessionTurnSucceededProps as RunSessionTurnSucceededProps, - SessionUserMessageProps as RunSessionUserMessageProps, -}; diff --git a/lib/foundation/fabro-types/src/run_event/stage.rs b/lib/foundation/fabro-types/src/run_event/stage.rs deleted file mode 100644 index 296dc3d41..000000000 --- a/lib/foundation/fabro-types/src/run_event/stage.rs +++ /dev/null @@ -1,161 +0,0 @@ -use std::collections::BTreeMap; - -use lithos_llm::types::{ReasoningEffort, Speed}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use super::ExecOutputTail; -use crate::{DiffSummary, FailureDetail, ModelUsage, Outcome, StageId, StageOutcome, StageTiming}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct StageStartedProps { - pub index: usize, - pub handler_type: String, - pub attempt: usize, - pub max_attempts: usize, - /// Graph visit that produced this stage execution. The envelope - /// `stage_id` ordinal counts executions, which diverges from the graph - /// visit when post-checkpoint work is replayed after - /// resume. Absent on events written before stage execution identity. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph_visit: Option, - /// Prior execution superseded by this resumed replay. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resumed_from_stage_id: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct StageCompletedProps { - pub index: usize, - /// Per-attempt timing breakdown for this stage visit. - pub timing: StageTiming, - pub status: StageOutcome, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preferred_label: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub suggested_next_ids: Vec, - /// The stage's usage: for an agent stage, the whole session tree's - /// tokens (the root session and every subagent) under the root's route. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub usage: Option, - /// `usage` split by model: the root session's route and each subagent's - /// own model, a subagent whose model the catalog does not know priced at - /// the root's. Sums to `usage`. Empty for stages without a coding agent - /// and on events written before it existed. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub usage_by_model: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notes: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub files_touched: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_updates: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jump_to_node: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_values: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub node_visits: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub loop_failure_signatures: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub restart_failure_signatures: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub response: Option, - pub attempt: usize, - pub max_attempts: usize, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct StageFailedProps { - pub index: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure: Option, - pub will_retry: bool, - /// Per-attempt timing breakdown for this stage visit. - #[serde(default)] - pub timing: StageTiming, - /// The stage's usage: for an agent stage that failed after spending, - /// the whole session tree's tokens under the root's route. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub usage: Option, - /// `usage` split by model, as on `stage.completed`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub usage_by_model: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct StageRetryingProps { - pub index: usize, - pub attempt: usize, - pub max_attempts: usize, - pub delay_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct StagePromptProps { - pub visit: u32, - pub text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub speed: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PromptCompletedProps { - pub response: String, - pub model: String, - pub provider: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub usage: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CheckpointCompletedProps { - pub status: String, - pub current_node: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub completed_nodes: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub node_retries: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub context_values: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub node_outcomes: BTreeMap>>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub next_node_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git_commit_sha: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub loop_failure_signatures: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub restart_failure_signatures: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub node_visits: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub diff: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub diff_summary: Option, - /// Graph visit of the checkpointed stage execution; used when this - /// checkpoint is the event that first materializes a skipped stage. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph_visit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resumed_from_stage_id: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CheckpointFailedProps { - pub error: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec_output_tail: Option, -} diff --git a/lib/foundation/fabro-types/src/run_projection.rs b/lib/foundation/fabro-types/src/run_projection.rs index f9a1a4a39..c13077bf3 100644 --- a/lib/foundation/fabro-types/src/run_projection.rs +++ b/lib/foundation/fabro-types/src/run_projection.rs @@ -11,7 +11,7 @@ use pebble_coding_agent::events::{ use pebble_coding_agent::projection::SessionProjection; use strum::{Display, EnumString, IntoStaticStr}; -use crate::run_event::{AgentSessionActivatedProps, StagePromptProps}; +use crate::agent_props::{AgentSessionActivatedProps, StagePromptProps}; use crate::{ AgentBackend, Checkpoint, Conclusion, GitIdentity, InterviewQuestionRecord, InvalidTransition, ModelRef, ModelUsage, ParallelBranchId, PullRequestCreation, PullRequestLink, RunApproval, diff --git a/lib/foundation/fabro-types/src/status.rs b/lib/foundation/fabro-types/src/status.rs index 31eb62d86..2b2c63e75 100644 --- a/lib/foundation/fabro-types/src/status.rs +++ b/lib/foundation/fabro-types/src/status.rs @@ -578,3 +578,23 @@ mod tests { assert_eq!(err, InvalidTransition { from, to }); } } + +/// What made a run runnable: its start request, or its approval. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum RunRunnableSource { + StartRequested, + Approved, +} diff --git a/lib/foundation/fabro-types/src/usage_rollup.rs b/lib/foundation/fabro-types/src/usage_rollup.rs index 9b236c24f..1615fe397 100644 --- a/lib/foundation/fabro-types/src/usage_rollup.rs +++ b/lib/foundation/fabro-types/src/usage_rollup.rs @@ -43,82 +43,53 @@ impl ProjectionUsageRollup { (self.usage_visit_count > 0).then_some(self.totals) } - /// Reconstruct the conclusion's per-node summaries from checkpoint and - /// stage events. Repeated visits share one row, ordered by the node's - /// first stage event. + /// The conclusion's per-node summaries: one row per node the run + /// visited, ordered by the node's first stage event, with the usage + /// and timing summed over its visits and the retries counted past the + /// first visit. #[must_use] pub fn conclusion_stages(&self, projection: &RunProjection) -> (Vec, u32) { let projection_order = stage_projection_order(projection); - // Looping workflows revisit nodes; `completed_nodes` accumulates duplicates - // while the other checkpoint maps are keyed by node_id. Dedupe to one row - // per node so the stages table matches the deduped usage total. - if let Some(cp) = projection.current_checkpoint() { - let usage_by_node = self - .stages - .iter() - .map(|stage| (stage.node_id.as_str(), stage)) - .collect::>(); - let mut stage_rows = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut retries_sum: u32 = 0; - let mut stage_order = Vec::new(); - - for (original_checkpoint_order, node_id) in cp.completed_nodes.iter().enumerate() { - if !seen.insert(node_id.as_str()) { - continue; - } - stage_order.push((original_checkpoint_order, node_id.as_str())); + let usage_by_node = self + .stages + .iter() + .map(|stage| (stage.node_id.as_str(), stage)) + .collect::>(); + let mut nodes = projection + .iter_stages() + .map(|(stage_id, _)| stage_id.node_id()) + .collect::>(); + nodes.dedup(); + let mut seen = std::collections::HashSet::new(); + let mut retries_sum: u32 = 0; + let mut stage_rows = Vec::new(); + for node_id in nodes { + if !seen.insert(node_id) { + continue; } - let mut extra_node_outcomes = cp - .node_outcomes - .keys() - .filter(|node_id| !seen.contains(node_id.as_str())) - .map(String::as_str) - .collect::>(); - extra_node_outcomes.sort_unstable(); - let extra_offset = stage_order.len(); - for (extra_index, node_id) in extra_node_outcomes.into_iter().enumerate() { - seen.insert(node_id); - stage_order.push((extra_offset + extra_index, node_id)); - } - - for (original_checkpoint_order, node_id) in stage_order { - let retries = cp - .node_retries - .get(node_id) - .copied() - .unwrap_or(1) - .saturating_sub(1); - retries_sum += retries; - let row = usage_by_node.get(node_id); - - let summary = StageSummary { - stage_id: node_id.to_string(), - stage_label: node_id.to_string(), - timing: row.map_or_else(StageTiming::default, |stage| stage.timing), - usage: row.map_or_else(Usage::default, |stage| stage.usage), - retries, - }; - stage_rows.push(( - projection_order.get(node_id).copied().unwrap_or(u32::MAX), - original_checkpoint_order, - summary, - )); - } - stage_rows.sort_by(|left, right| { - left.0 - .cmp(&right.0) - .then_with(|| left.1.cmp(&right.1)) - .then_with(|| left.2.stage_id.cmp(&right.2.stage_id)) - }); - let stages = stage_rows - .into_iter() - .map(|(_, _, summary)| summary) - .collect(); - (stages, retries_sum) - } else { - (vec![], 0) + let visits = projection.list_node_visits(node_id).len(); + let retries = u32::try_from(visits.saturating_sub(1)).unwrap_or(u32::MAX); + retries_sum = retries_sum.saturating_add(retries); + let row = usage_by_node.get(node_id); + let summary = StageSummary { + stage_id: node_id.to_string(), + stage_label: node_id.to_string(), + timing: row.map_or_else(StageTiming::default, |stage| stage.timing), + usage: row.map_or_else(Usage::default, |stage| stage.usage), + retries, + }; + stage_rows.push(( + projection_order.get(node_id).copied().unwrap_or(u32::MAX), + summary, + )); } + stage_rows.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| left.1.stage_id.cmp(&right.1.stage_id)) + }); + let stages = stage_rows.into_iter().map(|(_, summary)| summary).collect(); + (stages, retries_sum) } } diff --git a/lib/foundation/fabro-types/tests/run_event_serde.rs b/lib/foundation/fabro-types/tests/run_event_serde.rs deleted file mode 100644 index eadb1b793..000000000 --- a/lib/foundation/fabro-types/tests/run_event_serde.rs +++ /dev/null @@ -1,261 +0,0 @@ -use std::collections::BTreeMap; - -use fabro_types::graph::Graph; -use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext}; -use fabro_types::run_event::run::{RunCreatedProps, RunParentLinkedProps, RunParentUnlinkedProps}; -use fabro_types::run_event::{RunSessionTurnFailedCode, RunSessionTurnFailedProps}; -use fabro_types::settings::InterpString; -use fabro_types::settings::run::RunGoal; -use fabro_types::test_support::{test_run_provenance, test_workflow_version_id}; -use fabro_types::{ - AutomationRef, EventBody, GitRunTarget, PetriAdmission, ResolvedAutomationGitWorkflowSource, - RunTarget, TurnId, WorkflowSettings, fixtures, -}; - -fn templated_settings() -> WorkflowSettings { - let mut settings = WorkflowSettings::default(); - settings.run.goal = Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}"))); - settings -} - -#[test] -fn run_created_props_round_trip_templated_settings() { - let props = RunCreatedProps { - title: Some("Ship task".to_string()), - settings: templated_settings(), - graph: Graph::new("ship"), - workflow_source: Some("digraph Ship { start -> exit }".to_string()), - labels: BTreeMap::from([("team".to_string(), "platform".to_string())]), - source_directory: Some("/Users/client/project".to_string()), - workflow_slug: Some("demo".to_string()), - workflow_version_id: Some(test_workflow_version_id()), - target: Some(RunTarget::Git(GitRunTarget { - repo: "fabro-sh/fabro".to_string(), - branch: "main".to_string(), - tag: None, - sha: None, - })), - automation: Some(AutomationRef { - id: "nightly".to_string(), - name: Some("Nightly".to_string()), - trigger_id: Some("schedule_1".to_string()), - workflow_source: Some(Box::new(ResolvedAutomationGitWorkflowSource { - repo: "fabro-sh/workflows".to_string(), - branch: "main".to_string(), - tag: Some("v1".to_string()), - sha: None, - resolved_sha: "0123456789abcdef0123456789abcdef01234567".to_string(), - })), - }), - provenance: test_run_provenance(), - spec_blob: None, - git: Some(GitContext { - origin_url: "https://github.com/fabro-sh/fabro.git".to_string(), - branch: "main".to_string(), - sha: None, - dirty: DirtyStatus::Unknown, - }), - fork_source_ref: Some(ForkSourceRef { - source_run_id: fixtures::RUN_2, - checkpoint_sha: "def456".to_string(), - }), - retried_from: Some(fixtures::RUN_1), - parent_id: Some(fixtures::RUN_2), - web_url: Some( - "http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z".to_string(), - ), - admission: PetriAdmission::default(), - }; - - let json = serde_json::to_value(&props).expect("props should serialize"); - assert!(json.get("working_directory").is_none()); - assert!(json.get("host_repo_path").is_none()); - assert_eq!(json["source_directory"], "/Users/client/project"); - assert_eq!( - json["git"]["origin_url"], - "https://github.com/fabro-sh/fabro.git" - ); - assert_eq!(json["git"]["branch"], "main"); - assert_eq!(json["git"]["dirty"], "unknown"); - assert!(json["git"].get("push_outcome").is_none()); - assert_eq!( - json["web_url"], - "http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z" - ); - assert_eq!(json["retried_from"], fixtures::RUN_1.to_string()); - assert_eq!(json["parent_id"], fixtures::RUN_2.to_string()); - assert_eq!(json["automation"]["id"], "nightly"); - assert_eq!(json["automation"]["trigger_id"], "schedule_1"); - assert_eq!(json["automation"]["workflow_source"]["branch"], "main"); - assert_eq!(json["automation"]["workflow_source"]["tag"], "v1"); - assert_eq!( - json["automation"]["workflow_source"]["resolved_sha"], - "0123456789abcdef0123456789abcdef01234567" - ); - assert_eq!( - json["workflow_version_id"], - test_workflow_version_id().to_string() - ); - - let round_trip: RunCreatedProps = - serde_json::from_value(json.clone()).expect("props should deserialize"); - - assert_eq!( - serde_json::to_value(&round_trip).expect("round-trip should serialize"), - json - ); - assert_eq!( - round_trip.settings.run.goal, - Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}"))) - ); -} - -#[test] -fn run_created_props_omits_web_url_when_absent() { - let props = RunCreatedProps { - title: None, - settings: WorkflowSettings::default(), - graph: Graph::new("ship"), - workflow_source: None, - labels: BTreeMap::new(), - source_directory: None, - workflow_slug: None, - workflow_version_id: None, - target: None, - automation: None, - provenance: test_run_provenance(), - spec_blob: None, - git: None, - fork_source_ref: None, - retried_from: None, - parent_id: None, - web_url: None, - admission: PetriAdmission::default(), - }; - - let json = serde_json::to_value(&props).expect("props should serialize"); - assert!( - json.get("web_url").is_none(), - "web_url must be omitted when None, got {json}" - ); - assert!( - json.get("parent_id").is_none(), - "parent_id must be omitted when None, got {json}" - ); - assert!( - json.get("retried_from").is_none(), - "retried_from must be omitted when None, got {json}" - ); - assert!( - json.get("workflow_version_id").is_none(), - "workflow_version_id must be omitted when None, got {json}" - ); - - let round_trip: RunCreatedProps = - serde_json::from_value(json.clone()).expect("props should deserialize"); - assert_eq!(round_trip.web_url, None); - assert_eq!(round_trip.parent_id, None); - assert_eq!(round_trip.retried_from, None); -} - -#[test] -fn run_created_props_defaults_additive_fields_for_legacy_events() { - let json = serde_json::json!({ - "title": null, - "settings": WorkflowSettings::default(), - "graph": Graph::new("ship"), - "labels": {}, - "provenance": test_run_provenance() - }); - - let props: RunCreatedProps = - serde_json::from_value(json).expect("legacy props should deserialize"); - assert_eq!(props.retried_from, None); - assert_eq!(props.automation, None); - assert_eq!(props.workflow_version_id, None); -} - -#[test] -fn run_created_props_tolerates_legacy_git_push_outcome() { - let json = serde_json::json!({ - "title": null, - "settings": WorkflowSettings::default(), - "graph": Graph::new("ship"), - "labels": {}, - "provenance": test_run_provenance(), - "git": { - "origin_url": "https://github.com/fabro-sh/fabro.git", - "branch": "main", - "sha": "abc123", - "dirty": "clean", - "push_outcome": { - "type": "failed", - "remote": "origin", - "branch": "main", - "message": "remote rejected" - } - } - }); - - let props: RunCreatedProps = - serde_json::from_value(json).expect("legacy event with push_outcome should deserialize"); - let git = props.git.as_ref().expect("git context should be present"); - assert_eq!(git.origin_url, "https://github.com/fabro-sh/fabro.git"); - assert_eq!(git.branch, "main"); - assert_eq!(git.sha.as_deref(), Some("abc123")); - assert_eq!(git.dirty, DirtyStatus::Clean); - - let reserialized = serde_json::to_value(&props).expect("props should reserialize"); - assert!(reserialized["git"].get("push_outcome").is_none()); - assert_eq!(reserialized["git"]["origin_url"], git.origin_url); -} - -#[test] -fn run_parent_events_round_trip_parent_ids() { - let linked = EventBody::RunParentLinked(RunParentLinkedProps { - previous_parent_id: None, - parent_id: fixtures::RUN_2, - }); - let linked_json = serde_json::to_value(&linked).expect("linked event should serialize"); - assert_eq!(linked_json["event"], "run.parent.linked"); - assert_eq!( - linked_json["properties"]["parent_id"], - fixtures::RUN_2.to_string() - ); - - let linked_round_trip: EventBody = - serde_json::from_value(linked_json).expect("linked event should deserialize"); - assert_eq!(linked_round_trip.event_name(), "run.parent.linked"); - - let unlinked = EventBody::RunParentUnlinked(RunParentUnlinkedProps { - previous_parent_id: fixtures::RUN_2, - }); - let unlinked_json = serde_json::to_value(&unlinked).expect("unlinked event should serialize"); - assert_eq!(unlinked_json["event"], "run.parent.unlinked"); - assert_eq!( - unlinked_json["properties"]["previous_parent_id"], - fixtures::RUN_2.to_string() - ); - assert!(unlinked_json["properties"].get("parent_id").is_none()); - - let unlinked_round_trip: EventBody = - serde_json::from_value(unlinked_json).expect("unlinked event should deserialize"); - assert_eq!(unlinked_round_trip.event_name(), "run.parent.unlinked"); -} - -#[test] -fn run_session_turn_failed_defaults_code_for_old_events() { - let turn_id = TurnId::new(); - let props: RunSessionTurnFailedProps = serde_json::from_value(serde_json::json!({ - "turn_id": turn_id, - "error": "legacy failure" - })) - .expect("legacy failed props should deserialize"); - - assert_eq!(props.code, RunSessionTurnFailedCode::AgentError); - assert!(!props.retryable); - - let json = serde_json::to_value(props).expect("props should serialize"); - assert_eq!(json["code"], "agent_error"); - assert_eq!(json["retryable"], false); -} diff --git a/lib/foundation/fabro-types/tests/run_failure_serde.rs b/lib/foundation/fabro-types/tests/run_failure_serde.rs index 100de4200..51bcf118a 100644 --- a/lib/foundation/fabro-types/tests/run_failure_serde.rs +++ b/lib/foundation/fabro-types/tests/run_failure_serde.rs @@ -1,116 +1,75 @@ -use fabro_types::run_event::run::RunFailedProps; use fabro_types::{ - Conclusion, EventBody, ExecOutputTail, FailureCategory, FailureDetail, FailureReason, - FailureSignature, RunDiff, RunFailure, RunTiming, StageOutcome, SystemActorKind, + Conclusion, ExecOutputTail, FailureCategory, FailureDetail, FailureReason, FailureSignature, + RunDiff, RunFailure, RunTiming, StageOutcome, SystemActorKind, }; use serde_json::json; #[test] -fn run_failed_serializes_nested_failure_contract() { - let body = EventBody::RunFailed(RunFailedProps { - failure: RunFailure { - reason: FailureReason::SandboxInitFailed, - detail: { - let mut detail = FailureDetail::new( - "Failed to initialize sandbox", - FailureCategory::TransientInfra, - ); - detail.causes = vec![ - "Failed to pull Docker image buildpack-deps:noble".to_string(), - "connection refused".to_string(), - ]; - detail.system_actor = Some(SystemActorKind::Engine); - detail.signature = Some(FailureSignature( - "init|transient_infra|docker-pull".to_string(), - )); - detail.exec_output_tail = Some(ExecOutputTail { - stdout: Some("last stdout line".to_string()), - stderr: Some("last stderr line".to_string()), - stdout_truncated: false, - stderr_truncated: true, - }); - detail - }, +fn run_failure_serializes_nested_failure_contract() { + let failure = RunFailure { + reason: FailureReason::SandboxInitFailed, + detail: { + let mut detail = FailureDetail::new( + "Failed to initialize sandbox", + FailureCategory::TransientInfra, + ); + detail.causes = vec![ + "Failed to pull Docker image buildpack-deps:noble".to_string(), + "connection refused".to_string(), + ]; + detail.system_actor = Some(SystemActorKind::Engine); + detail.signature = Some(FailureSignature( + "init|transient_infra|docker-pull".to_string(), + )); + detail.exec_output_tail = Some(ExecOutputTail { + stdout: Some("last stdout line".to_string()), + stderr: Some("last stderr line".to_string()), + stdout_truncated: false, + stderr_truncated: true, + }); + detail }, - timing: RunTiming::wall_only(42), - final_git_commit_sha: Some("abc123".to_string()), - final_patch: Some("diff --git a/file b/file".to_string()), - diff_summary: None, - usage: None, - }); + }; - let value = serde_json::to_value(&body).expect("run.failed body should serialize"); + let value = serde_json::to_value(&failure).expect("the failure should serialize"); - assert_eq!(value["event"], "run.failed"); assert_eq!( - value["properties"], + value, json!({ - "failure": { - "reason": "sandbox_init_failed", - "detail": { - "message": "Failed to initialize sandbox", - "causes": [ - "Failed to pull Docker image buildpack-deps:noble", - "connection refused" - ], - "category": "transient_infra", - "system_actor": "engine", - "signature": "init|transient_infra|docker-pull", - "exec_output_tail": { - "stdout": "last stdout line", - "stderr": "last stderr line", - "stderr_truncated": true - } + "reason": "sandbox_init_failed", + "detail": { + "message": "Failed to initialize sandbox", + "causes": [ + "Failed to pull Docker image buildpack-deps:noble", + "connection refused" + ], + "category": "transient_infra", + "system_actor": "engine", + "signature": "init|transient_infra|docker-pull", + "exec_output_tail": { + "stdout": "last stdout line", + "stderr": "last stderr line", + "stderr_truncated": true } - }, - "timing": { - "wall_time_ms": 42, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - }, - "final_git_commit_sha": "abc123", - "final_patch": "diff --git a/file b/file" + } }) ); - assert!(value["properties"].get("error").is_none()); - assert!(value["properties"].get("causes").is_none()); - assert!(value["properties"].get("reason").is_none()); - assert!(value["properties"].get("git_commit_sha").is_none()); } #[test] -fn run_failed_omits_empty_failure_optional_fields() { - let body = EventBody::RunFailed(RunFailedProps { - failure: RunFailure { - reason: FailureReason::WorkflowError, - detail: FailureDetail::new("boom", FailureCategory::Deterministic), - }, - timing: RunTiming::wall_only(1), - final_git_commit_sha: None, - final_patch: None, - diff_summary: None, - usage: None, - }); +fn run_failure_omits_empty_optional_fields() { + let failure = RunFailure { + reason: FailureReason::WorkflowError, + detail: FailureDetail::new("boom", FailureCategory::Deterministic), + }; - let value = serde_json::to_value(&body).expect("run.failed body should serialize"); + let value = serde_json::to_value(&failure).expect("the failure should serialize"); assert_eq!( - value["properties"], + value, json!({ - "failure": { - "reason": "workflow_error", - "detail": { - "message": "boom", - "category": "deterministic" - } - }, - "timing": { - "wall_time_ms": 1, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - } + "reason": "workflow_error", + "detail": { "message": "boom", "category": "deterministic" } }) ); } diff --git a/lib/foundation/fabro-types/tests/run_spec_serde.rs b/lib/foundation/fabro-types/tests/run_spec_serde.rs index b2d0d6a4e..2d3d3bca5 100644 --- a/lib/foundation/fabro-types/tests/run_spec_serde.rs +++ b/lib/foundation/fabro-types/tests/run_spec_serde.rs @@ -102,23 +102,3 @@ fn run_spec_round_trips_templated_settings() { Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}"))) ); } - -#[test] -fn run_spec_defaults_automation_for_legacy_specs() { - let json = serde_json::json!({ - "run_id": fixtures::RUN_1, - "settings": WorkflowSettings::default(), - "graph": Graph::new("ship"), - "labels": {}, - "provenance": test_run_provenance() - }); - - let record: RunSpec = serde_json::from_value(json).expect("legacy spec should deserialize"); - - assert_eq!(record.automation, None); - assert_eq!(record.workflow_version_id, None); - assert_eq!(record.target, None); - - let round_trip = serde_json::to_value(&record).expect("record should serialize"); - assert!(round_trip.get("workflow_version_id").is_none()); -}