diff --git a/Cargo.lock b/Cargo.lock index d437cffab..ea3b22857 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -116,9 +116,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] @@ -263,9 +263,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", @@ -453,9 +453,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", @@ -506,9 +506,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.2" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "shlex", @@ -804,18 +804,8 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" -dependencies = [ - "darling_core 0.24.0", - "darling_macro 0.24.0", + "darling_core", + "darling_macro", ] [[package]] @@ -831,46 +821,22 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "darling_core" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 3.0.3", -] - [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.119", ] -[[package]] -name = "darling_macro" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" -dependencies = [ - "darling_core 0.24.0", - "quote", - "syn 3.0.3", -] - [[package]] name = "data-encoding" -version = "2.11.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "defmt" @@ -900,7 +866,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.19", ] [[package]] @@ -1181,9 +1147,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -1255,9 +1221,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1270,9 +1236,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1293,15 +1259,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1310,9 +1276,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -1329,26 +1295,26 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-timer" @@ -1358,9 +1324,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1573,9 +1539,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.5" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1696,7 +1662,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -1861,15 +1827,15 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.13" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling 0.24.0", + "darling", "indoc", "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.119", ] [[package]] @@ -1915,9 +1881,9 @@ dependencies = [ [[package]] name = "jaq-json" -version = "2.0.2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d801b0b57f10064c4e9f5a4f6c97d0ccf62649b179ff8ac23cd494a3120ee9" +checksum = "d4ec9aaad7340e6990c6c1878ef3b46dbec624e535d7f786cc9ddcf94f773d33" dependencies = [ "bstr", "bytes", @@ -2005,9 +1971,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -2058,9 +2024,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.38.2" +version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" dependencies = [ "cc", "pkg-config", @@ -2160,13 +2126,13 @@ dependencies = [ "medulla-link", "openhuman", "regex", - "reqwest", + "reqwest 0.12.28", "rust_socketio", "serde", "serde_json", "sha2 0.10.9", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.19", "tinyflows", "tinyhumans-sdk", "tokio", @@ -2189,7 +2155,7 @@ dependencies = [ "sha2 0.10.9", "subtle", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", ] @@ -2272,16 +2238,14 @@ dependencies = [ [[package]] name = "motosan-ai-oauth" version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "244943168db97b6874d3a88f5fff7029ab002d84c9c572e3bfe6e8cd92af0bae" dependencies = [ "base64 0.22.1", "percent-encoding", "rand 0.9.5", - "reqwest", + "reqwest 0.12.28", "serde", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", ] @@ -2370,9 +2334,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.47" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] @@ -2419,7 +2383,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.63.7" +version = "0.63.8" dependencies = [ "aes-gcm", "aho-corasick", @@ -2456,7 +2420,7 @@ dependencies = [ "parking_lot", "rand 0.10.2", "regex", - "reqwest", + "reqwest 0.12.28", "ring", "rusqlite", "rustls", @@ -2469,7 +2433,7 @@ dependencies = [ "sysinfo 0.33.1", "tar", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.19", "tinyagents", "tinybus", "tinychannels", @@ -2717,9 +2681,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.15.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" @@ -2798,7 +2762,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -2820,7 +2784,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -2995,7 +2959,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.20", + "thiserror 2.0.19", ] [[package]] @@ -3032,9 +2996,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.18" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3102,6 +3066,37 @@ dependencies = [ "webpki-roots 1.0.9", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -3123,7 +3118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.19", ] [[package]] @@ -3155,7 +3150,7 @@ dependencies = [ "futures-util", "http", "native-tls", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "thiserror 1.0.69", @@ -3256,9 +3251,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -3495,9 +3490,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.22.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -3505,7 +3500,6 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", - "jiff", "schemars 0.9.0", "schemars 1.2.2", "serde_core", @@ -3516,11 +3510,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.22.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling 0.23.0", + "darling", "proc-macro2", "quote", "syn 2.0.119", @@ -3902,11 +3896,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.19", ] [[package]] @@ -3922,9 +3916,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", @@ -3978,12 +3972,12 @@ dependencies = [ "bytes", "chrono", "futures", - "reqwest", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", "sha2 0.11.0", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", "tracing", ] @@ -3995,7 +3989,7 @@ dependencies = [ "async-trait", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.19", "tinybus-macros", "tokio", "tracing", @@ -4025,7 +4019,7 @@ dependencies = [ "hmac", "parking_lot", "rand 0.10.2", - "reqwest", + "reqwest 0.12.28", "rusqlite", "rustls", "rustls-pki-types", @@ -4034,7 +4028,7 @@ dependencies = [ "serde_json", "sha1", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", "tokio-rustls", "tokio-tungstenite 0.29.0", @@ -4058,13 +4052,13 @@ dependencies = [ "parking_lot", "rand 0.10.2", "regex", - "reqwest", + "reqwest 0.12.28", "rusqlite", "schemars 1.2.2", "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.19", "tinyagents", "tinycortex-api", "tokio", @@ -4084,7 +4078,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.19", "uuid", ] @@ -4094,16 +4088,19 @@ version = "0.6.1" dependencies = [ "async-trait", "axum", + "fs2", "futures-timer", "futures-util", "getrandom 0.4.3", "jaq-core", "jaq-json", "jaq-std", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", - "thiserror 2.0.20", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.19", "tinyagents", "tokio", "tracing", @@ -4116,10 +4113,10 @@ dependencies = [ "base64 0.22.1", "futures", "percent-encoding", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", "url", ] @@ -4137,7 +4134,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", "unicode-segmentation", "unicode-width 0.2.0", @@ -4167,7 +4164,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "thiserror 2.0.20", + "thiserror 2.0.19", "uuid", ] @@ -4185,12 +4182,12 @@ dependencies = [ "parking_lot", "rand 0.8.7", "regex", - "reqwest", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.19", "tinyagents", "tinycortex", "tinycortex-api", @@ -4230,11 +4227,11 @@ dependencies = [ "hkdf", "hmac", "rand 0.8.7", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", "tokio-tungstenite 0.29.0", "url", @@ -4589,7 +4586,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.20", + "thiserror 2.0.19", ] [[package]] @@ -4811,9 +4808,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -4824,9 +4821,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4834,9 +4831,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4844,9 +4841,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -4857,9 +4854,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -4879,9 +4876,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -5003,19 +5000,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-future" version = "0.2.1" @@ -5472,18 +5456,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", @@ -5577,7 +5561,7 @@ dependencies = [ "flate2", "indexmap 2.14.0", "memchr", - "thiserror 2.0.20", + "thiserror 2.0.19", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index 7ff64576e..47c78736c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ tinyhumans-sdk = { path = "vendor/openhuman/vendor/tinyhumans-sdk" } # openhuman host consumes it — one shared pin, one shared upgrade cadence. # `mock` is a normal (not dev-only) feature: dry-run authoring uses the engine's # deterministic capability stand-ins in ordinary builds. -tinyflows = { version = "0.6", features = ["mock"] } +tinyflows = { version = "0.6", features = ["mock", "host-caps", "store"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" @@ -120,9 +120,12 @@ unicode-width = "0.2" tinyagents = { path = "vendor/openhuman/vendor/tinyagents" } tinychannels = { path = "vendor/openhuman/vendor/tinychannels" } tinycortex = { path = "vendor/openhuman/vendor/tinycortex" } -# OpenHuman's extracted TinyMemory core names this contract by version. The -# vendored OpenHuman manifest patches it, but path dependencies do not inherit -# their own patch tables, so Medulla must bind it at this workspace root too. +# `tinymemory-core` names `tinycortex-api` by version requirement while this +# graph depends on tinycortex by path, so without this the resolver goes to +# crates.io for it and fails outright — the crate is not published. The +# vendored OpenHuman manifest patches it too, but path dependencies do not +# inherit their own patch tables, so Medulla must bind it at this workspace +# root as well. tinycortex-api = { path = "vendor/openhuman/vendor/tinycortex/api" } tinyflows = { path = "vendor/openhuman/vendor/tinyflows" } tinyjuice = { path = "vendor/openhuman/vendor/tinyjuice" } @@ -130,6 +133,11 @@ tinyjuice = { path = "vendor/openhuman/vendor/tinyjuice" } # the vendored OpenHuman core still does, and without the redirect Cargo would # resolve the published crate instead of the vendored tree. tinyplace = { path = "vendor/openhuman/vendor/tinyplace/sdk/rust" } +# Not a tiny* crate, but patched for the same reason the others are: OpenHuman +# vendors its own copy and this table is the only one Cargo reads, so an entry +# missing here silently resolves the published crate instead of the vendored +# tree. +motosan-ai-oauth = { path = "vendor/openhuman/vendor/motosan-ai-oauth" } # NOT copied from OpenHuman: its whisper-rs-sys git patch. Cargo fetches a git # patch source during resolution even when the patched crate is absent from the # graph, which would put a network fetch in the critical path of every diff --git a/scripts/init-submodules.sh b/scripts/init-submodules.sh index f145dcb46..eb75bc628 100755 --- a/scripts/init-submodules.sh +++ b/scripts/init-submodules.sh @@ -38,6 +38,7 @@ git submodule update --init --depth 1 vendor/openhuman # root manifest must stay in lockstep with its `[patch.crates-io]` table; # tinybus and tinymemory are unpublished direct paths from OpenHuman's manifest. git -C vendor/openhuman submodule update --init --depth 1 \ + vendor/motosan-ai-oauth \ vendor/tinyagents \ vendor/tinybus \ vendor/tinychannels \ @@ -45,7 +46,15 @@ git -C vendor/openhuman submodule update --init --depth 1 \ vendor/tinyflows \ vendor/tinyhumans-sdk \ vendor/tinyjuice \ - vendor/tinyplace \ - vendor/tinymemory + vendor/tinymemory \ + vendor/tinyplace -echo "Submodules initialized (OpenHuman core + its nine required dependencies)." +# Deliberately NOT recursive. Three of these vendor crates of their own — +# tinyflows has its own `tinyagents`, tinymemory its own `tinyagents`, `tinybus` +# and `tinycortex` — and none of those nested copies is ever resolved: the root +# patch table redirects each name to the copy beside OpenHuman, so the graph +# holds one of each. Initializing them recursively would clone several hundred +# megabytes that nothing links, and would put two checkouts of one crate on +# disk for anyone reading the tree. + +echo "Submodules initialized (OpenHuman core + its ten vendored crates)." diff --git a/src/sdk/src/daemon/task_loop/workflow/handle.rs b/src/sdk/src/daemon/task_loop/workflow/handle.rs index caf39281f..59fdada59 100644 --- a/src/sdk/src/daemon/task_loop/workflow/handle.rs +++ b/src/sdk/src/daemon/task_loop/workflow/handle.rs @@ -18,8 +18,7 @@ use crate::protocol::{TaskFrame, TaskFrameKind, WorkflowAdvert, WorkflowInputAdv use crate::workflows::bridge::trigger_input; use crate::workflows::evolve::{EvolveConfig, EvolveSession, EvolveTrigger}; use crate::workflows::{ - run_workflow_versioned, FileWorkflowStore, RunContext, RunStatus, StoreWorkflowResolver, - WorkflowStore, + run_workflow_versioned, RunContext, RunStatus, StoreWorkflowResolver, WorkflowStore, }; use super::super::super::types::{DaemonRuntime, FrameAttachments, CAPACITY_REJECTION_PREFIX}; @@ -72,7 +71,10 @@ impl DaemonRuntime { /// resolves it. fn workflow_store(&self) -> Arc { let cwd = std::path::Path::new(&self.inner.config.workspace); - Arc::new(FileWorkflowStore::discover(&self.inner.config.env, cwd)) + Arc::new(crate::workflows::store::discover( + &self.inner.config.env, + cwd, + )) } /// Run the workflow a `task` frame named, replying with its outcome. diff --git a/src/sdk/src/flow_engine/README.md b/src/sdk/src/flow_engine/README.md index 9e54d9774..0f2d00cd0 100644 --- a/src/sdk/src/flow_engine/README.md +++ b/src/sdk/src/flow_engine/README.md @@ -4,7 +4,7 @@ The adapter seam between Medulla and the `tinyflows` workflow engine. ## Contents -- [`caps/`](./caps/) — Assembling the capability bundle the engine runs against. +- [`caps/`](./caps/) — Assembling the capability bundle the engine runs against. The host-agnostic implementations moved to `tinyflows::caps::host`; what is left is the harness seam. - [`execute.rs`](./execute.rs) — Driving the engine: compile, run, resume, simulate. - [`harness_choice.rs`](./harness_choice.rs) — Which harness and model an `agent` node runs on. - [`harness_choice_tests.rs`](./harness_choice_tests.rs) — Unit tests for harness and model selection. @@ -13,7 +13,7 @@ The adapter seam between Medulla and the `tinyflows` workflow engine. - [`observability_tests.rs`](./observability_tests.rs) — Tests for the run observer. - [`settings.rs`](./settings.rs) — What the capability adapters are allowed to do, and where they keep things. - [`settings_tests.rs`](./settings_tests.rs) — Tests for capability settings and the config that produces them. -- [`tests/`](./tests/) — The offline capability-seam suite. `mod.rs` holds the shared fixtures; submodules cover the `medulla:shell` tool, harness/model selection, the state store, the tool invoker, and the HTTP capsule. +- [`tests/`](./tests/) — The offline capability-seam suite. `mod.rs` holds the shared fixtures; submodules cover the `medulla:shell` tool, harness/model selection, the tool invoker, and dry-run simulation. The state store's and HTTP capsule's own cases moved to `tinyflows::caps::host` with the code. ## Maintenance diff --git a/src/sdk/src/flow_engine/agent_evidence/tests.rs b/src/sdk/src/flow_engine/agent_evidence/tests.rs index 44788654d..c63d1826a 100644 --- a/src/sdk/src/flow_engine/agent_evidence/tests.rs +++ b/src/sdk/src/flow_engine/agent_evidence/tests.rs @@ -95,7 +95,9 @@ fn prompt_queue_is_bounded_before_the_run_finishes() { let input = serde_json::to_vec(&steps[0].input).unwrap(); assert!(input.len() <= 64 * 1024 + 256); - assert_eq!(steps[0].input.as_ref().unwrap()["_medullaTruncated"], true); + assert!(tinyflows::store::is_truncated( + steps[0].input.as_ref().expect("an input") + )); } #[test] diff --git a/src/sdk/src/flow_engine/caps/README.md b/src/sdk/src/flow_engine/caps/README.md index 4bef3a1d4..b8596f3da 100644 --- a/src/sdk/src/flow_engine/caps/README.md +++ b/src/sdk/src/flow_engine/caps/README.md @@ -5,14 +5,20 @@ Assembling the capability bundle the engine runs against. ## Contents - [`agent.rs`](./agent.rs) — `agent` nodes, run on a real harness. -- [`code.rs`](./code.rs) — The `code` node's runner — refusing by default. - [`dispatch.rs`](./dispatch.rs) — Handing a workflow node's instruction to a harness. -- [`http.rs`](./http.rs) — Outbound HTTP for `http_request` nodes, behind a host allowlist. -- [`mocks.rs`](./mocks.rs) — Capability stand-ins for dry runs. - [`mod.rs`](./mod.rs) — Assembling the capability bundle the engine runs against. -- [`state.rs`](./state.rs) — Durable key/value state for stateful workflows. - [`tools.rs`](./tools.rs) — `tool_call` dispatch across two namespaces. +## What is no longer here + +The capability implementations with no Medulla in them — the out-of-process +script runner and its path policy, the `code` and `shell` runners, the file +state store, the allowlisted HTTP client, and the dry-run stand-ins — moved to +`tinyflows::caps::host`, behind that crate's `host-caps` feature. Every host +embedding the engine needs them, and each one that wrote them itself rewrote the +same subtle parts. [`mod.rs`](./mod.rs) re-exports them, so a call site in this +crate still names one place. + ## Maintenance Keep this index synchronized when responsibilities move. Put shared data structures in `types.rs`, focused unit tests in `tests.rs` or a sibling `_tests.rs`, and preserve the module-level Rust documentation as the API source of truth. diff --git a/src/sdk/src/flow_engine/caps/code.rs b/src/sdk/src/flow_engine/caps/code.rs deleted file mode 100644 index f09c401d1..000000000 --- a/src/sdk/src/flow_engine/caps/code.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! The `code` node's runner — refusing by default, executing when opted in. -//! -//! The sibling `openhuman` host runs `code` nodes inside its sandbox, with an -//! autonomy tier deciding whether the call needs approval. Medulla has no -//! equivalent sandbox: a daemon here already has the privileges of the user who -//! started it, so executing a workflow author's script executes it with those -//! privileges and no boundary at all. -//! -//! Rather than pretend otherwise, this adapter refuses unless a host explicitly -//! opts in, and says why. What it does *not* do any more is refuse and leave the -//! author with nothing: a workflow whose real work is a fifty-line script should -//! be able to say so, and the alternative — an `agent` node whose prompt asks a -//! harness to run it — costs a whole coding session for work that takes -//! milliseconds. -//! -//! The execution itself lives in [`super::script`], shared with the -//! `medulla:shell` tool so an author learns one calling convention. - -use async_trait::async_trait; -use serde_json::Value; -use tinyflows::caps::{CodeLanguage, CodeRunner}; -use tinyflows::error::{EngineError, Result}; - -use super::script::{run_script, ScriptLanguage, ScriptRequest}; - -/// A [`CodeRunner`] that refuses every request, explaining the missing sandbox. -pub struct DeniedCodeRunner; - -#[async_trait] -impl CodeRunner for DeniedCodeRunner { - async fn run(&self, _language: CodeLanguage, _source: &str, _input: Value) -> Result { - Err(EngineError::Capability( - "code nodes are disabled: this host has no sandbox, so workflow code would run with \ - the daemon's full privileges. Enable `workflows.allowCode` only where that is \ - acceptable, or use a `transform` node's expressions instead." - .to_string(), - )) - } -} - -/// A [`CodeRunner`] that runs code out-of-process, for hosts that opted in. -/// -/// Still not a sandbox — it is the operator's explicit decision to trust the -/// workflow author — but bounded: a temporary working directory and a -/// wall-clock limit. -/// -/// The script is given its input on **stdin as JSON**, and also as a file whose -/// path is `argv[1]` and `$MEDULLA_INPUT`. It returns its result on **stdout**; -/// stdout that parses as JSON becomes structured output, anything else becomes a -/// string. See [`super::script`] for the whole contract. -pub struct ProcessCodeRunner { - /// How long one execution may take. - timeout: std::time::Duration, -} - -impl ProcessCodeRunner { - /// A runner with the given per-execution timeout. - pub fn new(timeout: std::time::Duration) -> Self { - Self { timeout } - } -} - -#[async_trait] -impl CodeRunner for ProcessCodeRunner { - async fn run(&self, language: CodeLanguage, source: &str, input: Value) -> Result { - // A `code` node runs in a temporary directory rather than the operator's - // project: the engine's own contract for the kind is a computation over - // its input, and a step that means to touch the repo is a - // `medulla:shell` call, which says so in the graph. - run_script(ScriptRequest::plain( - ScriptLanguage::from(language), - source, - &input, - self.timeout, - &std::collections::BTreeMap::new(), - )) - .await - .map(|output| output.value) - } -} diff --git a/src/sdk/src/flow_engine/caps/http.rs b/src/sdk/src/flow_engine/caps/http.rs deleted file mode 100644 index 5f7b069e2..000000000 --- a/src/sdk/src/flow_engine/caps/http.rs +++ /dev/null @@ -1,353 +0,0 @@ -//! Outbound HTTP for `http_request` nodes, behind a host allowlist. -//! -//! Two guards stand between a workflow author and the network, and they are -//! deliberately separate: -//! -//! 1. **The allowlist**, which is policy: a host an operator has agreed this -//! workflow may reach. Empty by default, so a freshly installed workflow -//! cannot become an exfiltration path. -//! 2. **The loopback and private-range refusal**, which is not policy: reaching -//! `127.0.0.1` or `10.x` from a workflow means reaching services that trusted -//! the network boundary, so it is refused whatever the allowlist says. -//! -//! Credentials never appear in the graph. A node names one with an opaque -//! `connection_ref` of the form `http_cred:`, resolved here against the -//! host's store and injected into the request *after* any summary of the call -//! has been taken — so a secret cannot reach a log, an approval prompt, or a -//! node's recorded output. - -use std::collections::HashMap; -use std::sync::Arc; - -use async_trait::async_trait; -use serde_json::Value; -use tinyflows::caps::HttpClient; -use tinyflows::error::{EngineError, Result}; - -use crate::flow_engine::settings::CapabilitySettings; - -/// The `connection_ref` prefix naming an HTTP credential. -pub const HTTP_CRED_PREFIX: &str = "http_cred:"; - -/// How long a workflow HTTP request waits for a TCP/TLS connection. -/// -/// A black-holed peer must fail the node, not hang it forever. Mirrors the -/// SDK client defaults (`src/sdk/src/client/mod.rs`) so outbound workflow -/// HTTP is held to the same liveness budget as the rest of the product. -const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); - -/// How long a workflow HTTP request tolerates a silent socket mid-response. -/// -/// An idle timeout, not a whole-request one: a peer that keeps sending bytes -/// (a streamed response) must not be cut off schedule. -const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); - -/// A credential the host injects into an outbound request. -#[derive(Debug, Clone)] -pub struct HttpCredential { - /// The header to set, e.g. `Authorization`. - pub header: String, - /// The header's value, e.g. `Bearer …`. Never logged. - pub value: String, -} - -/// The credential name inside a `connection_ref`, if it names one. -/// -/// Fails closed: a `connection_ref` that is present but not an HTTP credential -/// reference is an error rather than a silently unauthenticated request, because -/// silently dropping the credential would send the call anyway. -pub fn http_cred_name(conn: Option<&str>) -> Result> { - let Some(conn) = conn.map(str::trim).filter(|c| !c.is_empty()) else { - return Ok(None); - }; - conn.strip_prefix(HTTP_CRED_PREFIX) - .map(Some) - .filter(|name| name.is_some_and(|n| !n.is_empty())) - .ok_or_else(|| { - EngineError::Capability(format!( - "http_request: unrecognised connection_ref '{conn}'; expected \ - '{HTTP_CRED_PREFIX}'" - )) - }) -} - -/// Merge `cred` into `request`'s headers, returning the request to send. -/// -/// Called last, after the request has been described for logs or approval, so -/// the secret exists only in the value handed to the transport. -pub fn inject_credential(mut request: Value, cred: &HttpCredential) -> Value { - if let Some(object) = request.as_object_mut() { - let headers = object - .entry("headers") - .or_insert_with(|| Value::Object(Default::default())); - if let Some(headers) = headers.as_object_mut() { - headers.insert(cred.header.clone(), Value::String(cred.value.clone())); - } - } - request -} - -/// A description of a request safe to log or show for approval: method and URL -/// only, never headers or body. -pub fn redacted_summary(request: &Value) -> String { - let method = request - .get("method") - .and_then(Value::as_str) - .unwrap_or("GET") - .to_ascii_uppercase(); - let url = request.get("url").and_then(Value::as_str).unwrap_or(""); - format!("{method} {url}") -} - -/// An [`HttpClient`] over `reqwest`, gated by the host's allowlist. -pub struct AllowlistHttpClient { - settings: Arc, - credentials: HashMap, - client: reqwest::Client, -} - -impl AllowlistHttpClient { - /// A client permitting only what `settings` allows, resolving - /// `connection_ref`s against `credentials`. - pub fn new( - settings: Arc, - credentials: HashMap, - ) -> Self { - Self { - settings, - credentials, - // Redirects are refused rather than followed. A permitted host that - // 302s to `169.254.169.254` or to an unlisted domain would - // otherwise walk straight past both guards, since only the first - // URL is ever checked. A workflow that genuinely needs to follow one - // can make the second request itself, where it is checked again. - client: reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .connect_timeout(CONNECT_TIMEOUT) - .read_timeout(READ_TIMEOUT) - .build() - .unwrap_or_default(), - } - } - - /// Check the URL against both guards, returning the parsed URL and the - /// vetted addresses the request may connect to. - /// - /// The address list is returned rather than discarded because vetting a - /// name and then letting the transport resolve it a second time is a - /// rebinding window: a short-TTL answer can be private by the time the - /// connection is made. The caller pins the transport to exactly these. - fn permit(&self, request: &Value) -> Result<(reqwest::Url, Vec)> { - let raw = request - .get("url") - .and_then(Value::as_str) - .ok_or_else(|| EngineError::Capability("http_request: no url".to_string()))?; - let url = reqwest::Url::parse(raw) - .map_err(|err| EngineError::Capability(format!("http_request: invalid url: {err}")))?; - - if !matches!(url.scheme(), "http" | "https") { - return Err(EngineError::Capability(format!( - "http_request: refusing scheme '{}'", - url.scheme() - ))); - } - let host = url - .host_str() - .ok_or_else(|| EngineError::Capability("http_request: url has no host".to_string()))?; - if is_private_host(host) { - return Err(EngineError::Capability(format!( - "http_request: refusing '{host}': loopback and private addresses are not \ - reachable from a workflow" - ))); - } - if !self.settings.http_host_allowed(host) { - return Err(EngineError::Capability(format!( - "http_request: '{host}' is not in the configured http allowlist" - ))); - } - // Last, because it is the only check that touches the network: an - // allowlisted name must not resolve into a range the guard above - // refuses by literal. - let port = url - .port_or_known_default() - .unwrap_or(if url.scheme() == "https" { 443 } else { 80 }); - let vetted = vet_resolution(host, port)?; - Ok((url, vetted)) - } - - /// A client that can only connect to `addrs` when it resolves `host`. - /// - /// Built per request rather than shared, because the override is a builder - /// option and the host is not known until one arrives. That costs a client - /// construction per call and gives up connection pooling; the alternative - /// is letting the transport perform its own lookup, which is precisely the - /// second resolution this exists to remove. - /// - /// An IP-literal host needs no override — there is no name to resolve, and - /// the literal has already been judged by `is_private_host`. - fn pinned(&self, host: &str, addrs: &[std::net::SocketAddr]) -> Result { - let bare = host.trim_matches(['[', ']']); - if addrs.is_empty() || bare.parse::().is_ok() { - return Ok(self.client.clone()); - } - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .resolve_to_addrs(bare, addrs) - .connect_timeout(CONNECT_TIMEOUT) - .read_timeout(READ_TIMEOUT) - .build() - .map_err(|err| { - EngineError::Capability(format!("http_request: cannot build client: {err}")) - }) - } -} - -/// Whether an address is one a workflow must never reach. -/// -/// Loopback, link-local (which includes the cloud metadata endpoint at -/// `169.254.169.254`), and the RFC 1918 ranges. Reaching any of them from a -/// workflow means reaching services that trusted the network boundary. -pub fn is_private_addr(addr: &std::net::IpAddr) -> bool { - match addr { - std::net::IpAddr::V4(v4) => is_private_v4(v4), - std::net::IpAddr::V6(v6) => { - // An IPv4-mapped address is an IPv4 address wearing a hat: - // `::ffff:127.0.0.1` reaches loopback just as `127.0.0.1` does, so - // it must be judged by the same rules rather than falling through - // the v6 checks below. - if let Some(mapped) = v6.to_ipv4_mapped() { - return is_private_v4(&mapped); - } - v6.is_loopback() - || v6.is_unspecified() - // link-local fe80::/10 - || v6.segments()[0] & 0xffc0 == 0xfe80 - // unique-local fc00::/7 — the v6 answer to RFC 1918 - || v6.segments()[0] & 0xfe00 == 0xfc00 - } - } -} - -/// The IPv4 ranges a workflow must never reach. -fn is_private_v4(addr: &std::net::Ipv4Addr) -> bool { - addr.is_loopback() || addr.is_private() || addr.is_link_local() || addr.is_unspecified() -} - -/// Every address `host` resolves to, refused if any is private. -/// -/// The textual check alone is not enough: an allowlisted name whose DNS answer -/// is `127.0.0.1` would otherwise pass both guards. Resolving makes the guard -/// depend on the network it is guarding, which is the trade — but the failure -/// mode of not resolving is an authored workflow reaching internal services, -/// and that is worse than a lookup. -/// -/// The vetted addresses are *returned*, not just judged: the caller pins the -/// transport to them, so the answer checked here is the answer connected to. A -/// second, independent lookup by the transport would reopen the rebinding gap -/// this closes — a name whose record flips to `169.254.169.254` between the two -/// resolutions passes the guard and reaches metadata anyway. -/// -/// A name that cannot be resolved at all is refused rather than allowed: the -/// request would fail anyway, and failing here says why. So is one that -/// resolves to nothing, which would otherwise pin the transport to an empty -/// set and let it fall back to its own lookup. -pub(crate) fn vet_resolution(host: &str, port: u16) -> Result> { - use std::net::ToSocketAddrs; - - let resolved: Vec = (host, port) - .to_socket_addrs() - .map_err(|err| { - EngineError::Capability(format!("http_request: cannot resolve '{host}': {err}")) - })? - .collect(); - - if let Some(private) = resolved.iter().find(|addr| is_private_addr(&addr.ip())) { - return Err(EngineError::Capability(format!( - "http_request: refusing '{host}': it resolves to {}, which is loopback or private", - private.ip() - ))); - } - if resolved.is_empty() { - return Err(EngineError::Capability(format!( - "http_request: cannot resolve '{host}': it has no addresses" - ))); - } - Ok(resolved) -} - -/// Whether a host *names* loopback, a link-local address, or an RFC 1918 range. -/// -/// The cheap textual guard, applied before any lookup. The authoritative check -/// is `vet_resolution`, which catches the names this cannot. -pub fn is_private_host(host: &str) -> bool { - let host = host.trim_matches(['[', ']']).to_ascii_lowercase(); - if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".internal") { - return true; - } - if let Ok(addr) = host.parse::() { - return is_private_addr(&addr); - } - false -} - -#[async_trait] -impl HttpClient for AllowlistHttpClient { - async fn request(&self, request: Value, conn: Option<&str>) -> Result { - let (url, vetted) = self.permit(&request)?; - let summary = redacted_summary(&request); - // Pinned to the addresses just vetted, so the connection cannot go - // anywhere a second DNS answer might point. - let host = url.host_str().unwrap_or_default().to_string(); - let client = self.pinned(&host, &vetted)?; - - // Resolve the credential before building the request, so an unknown name - // fails before anything leaves the process. - let credential = match http_cred_name(conn)? { - Some(name) => Some(self.credentials.get(name).cloned().ok_or_else(|| { - EngineError::Capability(format!("http_request: unknown credential '{name}'")) - })?), - None => None, - }; - let request = match &credential { - Some(cred) => inject_credential(request, cred), - None => request, - }; - - let method = request - .get("method") - .and_then(Value::as_str) - .unwrap_or("GET") - .to_ascii_uppercase(); - let method = reqwest::Method::from_bytes(method.as_bytes()) - .map_err(|err| EngineError::Capability(format!("http_request: {err}")))?; - - let mut builder = client.request(method, url); - if let Some(headers) = request.get("headers").and_then(Value::as_object) { - for (name, value) in headers { - if let Some(value) = value.as_str() { - builder = builder.header(name, value); - } - } - } - if let Some(body) = request.get("body") { - builder = builder.json(body); - } - - let response = builder - .send() - .await - .map_err(|err| EngineError::Capability(format!("http_request: {summary}: {err}")))?; - let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|err| EngineError::Capability(format!("http_request: {summary}: {err}")))?; - let json: Option = serde_json::from_str(&text).ok(); - - Ok(serde_json::json!({ - "status": status, - "text": text, - "json": json, - })) - } -} diff --git a/src/sdk/src/flow_engine/caps/mocks.rs b/src/sdk/src/flow_engine/caps/mocks.rs deleted file mode 100644 index bd5b3789c..000000000 --- a/src/sdk/src/flow_engine/caps/mocks.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Capability stand-ins for dry runs. -//! -//! The engine ships mocks that echo their request back. That is enough to prove -//! a graph *executes*, but not that it is correct: a node declaring an -//! `output_parser.schema` will have its echoed response fail validation, so a -//! perfectly good graph fails a simulation for a reason that has nothing to do -//! with the graph. The sibling `openhuman` host hit exactly that and answered it -//! with schema-aware mocks; these are the same idea. -//! -//! A dry run therefore means: every expression resolved, every node's declared -//! output shape was satisfiable, and nothing left the process. - -use async_trait::async_trait; -use serde_json::{json, Value}; -use tinyflows::caps::{AgentRunner, LlmProvider}; -use tinyflows::error::Result; - -/// Synthesize a value satisfying a JSON Schema well enough to pass validation. -/// -/// Deliberately shallow — it honours `type`, `properties`, `required`, and -/// `enum`, which is what node schemas in practice use. Anything it does not -/// understand becomes null, and a schema strict enough to reject that is a -/// schema whose graph deserves a real run before being trusted. -pub fn sample_for_schema(schema: &Value) -> Value { - let Some(object) = schema.as_object() else { - return Value::Null; - }; - if let Some(first) = object - .get("enum") - .and_then(Value::as_array) - .and_then(|v| v.first()) - { - return first.clone(); - } - match object.get("type").and_then(Value::as_str) { - Some("object") => { - let mut out = serde_json::Map::new(); - if let Some(properties) = object.get("properties").and_then(Value::as_object) { - // Every declared property, not only the required ones: a graph - // binding `=item.json.optional_field` should still resolve. - for (name, property) in properties { - out.insert(name.clone(), sample_for_schema(property)); - } - } - Value::Object(out) - } - Some("array") => match object.get("items") { - // One element, so a downstream `per_item` node has something to map - // over and a `[0]` expression resolves. - Some(items) => json!([sample_for_schema(items)]), - None => json!([]), - }, - Some("string") => json!("sample"), - Some("integer") | Some("number") => json!(0), - Some("boolean") => json!(false), - _ => Value::Null, - } -} - -/// The `output_parser.schema` a request declares, if any. -fn declared_schema(request: &Value) -> Option<&Value> { - request.get("output_parser")?.get("schema") -} - -/// The response a schema-aware mock returns for `request`. -fn mock_response(request: &Value, source: &str) -> Value { - match declared_schema(request) { - Some(schema) => { - let sample = sample_for_schema(schema); - json!({ - "text": serde_json::to_string(&sample).unwrap_or_default(), - "json": sample, - "mock": source, - }) - } - None => json!({ - "text": format!("[{source} dry run]"), - "json": Value::Null, - "mock": source, - }), - } -} - -/// An [`LlmProvider`] whose response satisfies the node's declared schema. -pub struct SchemaAwareMockLlm; - -#[async_trait] -impl LlmProvider for SchemaAwareMockLlm { - async fn complete(&self, request: Value, _conn: Option<&str>) -> Result { - Ok(mock_response(&request, "llm")) - } -} - -/// An [`AgentRunner`] whose response satisfies the node's declared schema. -/// -/// Dispatches nothing: the whole point of a dry run is that no harness session -/// is started and no repository is touched. -pub struct SchemaAwareMockAgentRunner; - -#[async_trait] -impl AgentRunner for SchemaAwareMockAgentRunner { - async fn run_agent( - &self, - agent_ref: &str, - request: Value, - _conn: Option<&str>, - ) -> Result { - let mut response = mock_response(&request, "agent"); - if let Some(object) = response.as_object_mut() { - // Recorded so a dry run's output shows *which* worker each node - // would have gone to — the thing an author most often gets wrong. - object.insert("agent_ref".into(), Value::String(agent_ref.to_string())); - } - Ok(response) - } -} diff --git a/src/sdk/src/flow_engine/caps/mod.rs b/src/sdk/src/flow_engine/caps/mod.rs index 3fd014325..248b40d64 100644 --- a/src/sdk/src/flow_engine/caps/mod.rs +++ b/src/sdk/src/flow_engine/caps/mod.rs @@ -8,21 +8,27 @@ //! //! Each capability lives in its own submodule beside this one, named for the //! engine trait it satisfies. +//! +//! Only the ones that are *about Medulla* are still written here: dispatching a +//! node to a harness, choosing which harness, and the `medulla:` tool namespace. +//! The rest — running a script out of process, keying state onto disk, refusing +//! an outbound URL that resolves into a private range — had no Medulla in them, +//! and now live in [`tinyflows::caps::host`] where the other hosts can have +//! them too. They are re-exported below so a call site here still names one +//! place. pub mod agent; -pub mod code; pub mod dispatch; -pub mod http; -pub mod mocks; -pub mod script; -pub mod script_policy; -pub mod state; pub mod tools; use std::collections::HashMap; use std::sync::Arc; use serde_json::Value; +use tinyflows::caps::host::{ + AllowlistHttpClient, DeniedCodeRunner, FileStateStore, HostAllowlist, ProcessCodeRunner, + ProcessShellRunner, ScriptPolicy, +}; use tinyflows::caps::{Capabilities, WorkflowResolver}; use tinyflows::engine::{Checkpointer, FileCheckpointer}; @@ -31,12 +37,21 @@ use crate::flow_engine::observability::NodeProgressSink; use crate::flow_engine::settings::CapabilitySettings; use self::agent::{HarnessAgentRunner, HarnessLlm}; -use self::code::{DeniedCodeRunner, ProcessCodeRunner}; use self::dispatch::HarnessDispatch; -use self::http::{AllowlistHttpClient, HttpCredential}; -use self::state::FileStateStore; use self::tools::MedullaToolInvoker; +/// Schema-aware capability stand-ins for dry runs, now owned by the engine +/// crate. +pub use tinyflows::caps::host::mocks; +/// The out-of-process script runner and its calling convention, now owned by +/// the engine crate. +pub use tinyflows::caps::host::script; +/// Which files a script step may read and run in, now owned by the engine +/// crate. +pub use tinyflows::caps::host::script_policy; +/// The HTTP credential a `connection_ref` names. +pub use tinyflows::caps::host::{http_cred_name, HttpCredential, HTTP_CRED_PREFIX}; + /// Everything a run needs from the host, other than its settings. /// /// Grouped into one struct because [`build_capabilities`] would otherwise take @@ -168,21 +183,28 @@ fn build_capabilities_inner( ), }; + // A `shell` node is offered exactly when a `code` node is: both run an + // author's script with this daemon's privileges, so a host that refused one + // and allowed the other would be drawing a line that does not exist. + // `None` rather than a refusing runner, because the engine's own answer for + // an absent capability already says the node cannot run here. + let shell: Option> = settings.allow_code.then(|| { + Arc::new(ProcessShellRunner::new( + ScriptPolicy::new(&settings.workspace), + settings.script_timeout(), + )) as Arc + }); + Capabilities { llm, agent: Some(agent), + shell, tools: Arc::new(MedullaToolInvoker::new(settings.clone())), http: Arc::new(AllowlistHttpClient::new( - settings.clone(), + HostAllowlist::new(settings.http_allowlist.clone()), services.http_credentials, )), code, - // TinyFlows owns the shell-node contract, but Medulla has not yet - // adapted its path, environment, and interpreter policy to that - // capability. Refuse shell nodes explicitly until that boundary exists - // rather than running an author-controlled command with the code - // runner's looser shape. - shell: None, state: Arc::new(FileStateStore::new(&settings.state_dir, state_namespace)), resolver: services.resolver, // `None` until the host exposes a memory store: the engine then fails a diff --git a/src/sdk/src/flow_engine/caps/script.rs b/src/sdk/src/flow_engine/caps/script.rs deleted file mode 100644 index 9d65a5344..000000000 --- a/src/sdk/src/flow_engine/caps/script.rs +++ /dev/null @@ -1,457 +0,0 @@ -//! Running a script out-of-process, for `code` nodes and the `medulla:shell` -//! tool. -//! -//! One executor behind both, so a workflow author learns one calling convention -//! rather than two. What it does is deliberately small: write the source to a -//! temporary file, run it, read stdout. -//! -//! # The calling convention -//! -//! A script gets its input **on stdin, as JSON**, and returns its result **on -//! stdout**. Stdout that parses as JSON becomes structured output; anything else -//! becomes a string, so a script that prints one line is still usable. -//! -//! Stdin rather than an argument because it is the one channel every language -//! reads the same way — `JSON.parse(require('fs').readFileSync(0,'utf8'))`, -//! `json.load(sys.stdin)`, `cat`. The input is *also* written to a file whose -//! path is `argv[1]` and `$MEDULLA_INPUT`, because a large payload through a -//! pipe is awkward in shell and a path is not. -//! -//! # What this is not -//! -//! Not a sandbox. The child inherits this process's environment and privileges, -//! and the only boundary is a temporary working directory, which is not one. -//! What *is* checked, at the boundary above this one, is where a script may come -//! from and where it may run: see [`super::script_policy`]. -//! `workflows.allowCode` is on by default for locally authored workflows, but -//! can be explicitly disabled when definitions come from an untrusted source. -//! Everything here is about making a trusted script *work correctly*, not about -//! containing an untrusted one. - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use serde_json::Value; -use tinyflows::error::{EngineError, Result}; - -/// A language this host can execute. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScriptLanguage { - /// Node.js. - JavaScript, - /// CPython 3. - Python, - /// POSIX shell, run with `bash` unless an [`Interpreter`] says otherwise. - Shell, -} - -impl ScriptLanguage { - /// The interpreter and the extension its file wants. - fn program(self) -> (&'static str, &'static str) { - match self { - Self::JavaScript => ("node", "js"), - Self::Python => ("python3", "py"), - Self::Shell => (DEFAULT_SHELL, "sh"), - } - } - - /// The name an author writes in a node's config. - pub fn as_str(self) -> &'static str { - match self { - Self::JavaScript => "javascript", - Self::Python => "python", - Self::Shell => "shell", - } - } - - /// Parse the name an author wrote, accepting the obvious spellings. - /// - /// Forgiving on purpose: an author who writes `bash`, `sh`, `js`, or `py` - /// meant something unambiguous, and refusing it teaches nothing. - pub fn parse(name: &str) -> Option { - match name.trim().to_ascii_lowercase().as_str() { - "javascript" | "js" | "node" | "nodejs" => Some(Self::JavaScript), - "python" | "python3" | "py" => Some(Self::Python), - "shell" | "sh" | "bash" => Some(Self::Shell), - _ => None, - } - } - - /// Every spelling an author may write, for an error that teaches. - pub const NAMES: [&'static str; 3] = ["javascript", "python", "shell"]; - - /// Whether `name` picks a specific interpreter rather than naming the - /// shell family generically. - /// - /// `"bash"` and `"sh"` are an author saying *which* shell, and they said it - /// before `workflows.shell` existed — a step spelled that way keeps - /// [`DEFAULT_SHELL`] even on a host that configured another shell, so - /// enabling `shell = "zsh"` cannot silently re-run bash-specific scripts - /// somewhere else. Only the generic `"shell"` follows the host. - #[must_use] - pub fn pins_interpreter(name: &str) -> bool { - matches!(name.trim().to_ascii_lowercase().as_str(), "bash" | "sh") - } -} - -/// The shell a `shell` script runs under when nothing chooses another. -/// -/// Not the operator's login shell: an existing workflow's script was written -/// against *this*, and quietly re-running it under `fish` or `dash` because that -/// is what `$SHELL` happens to say would break it in ways that look like the -/// script's fault. Tracking the login shell is available, but it is opted into — -/// see [`Interpreter::resolve`]. -pub const DEFAULT_SHELL: &str = "bash"; - -/// The configured value that means "whatever the operator's login shell is". -pub const USER_SHELL: &str = "user"; - -/// The program a script runs under, and the arguments that precede its path. -/// -/// Exists so the shell is a decision rather than a constant. The reason an -/// operator reaches for it is almost always the same one: their own functions, -/// aliases, and `PATH` live in `~/.zshrc`, and a script run as -/// `bash ` — non-login, non-interactive — sees none of it. Naming `zsh` -/// with `args: ["-l"]` is what puts those back in scope. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Interpreter { - /// The program to spawn: a bare name resolved on `PATH`, or an absolute - /// path. - pub program: String, - /// Arguments passed before the script path — `["-l"]` for a login shell, - /// `["-l", "-i"]` to also get aliases, which are not exported. - pub args: Vec, -} - -impl Interpreter { - /// The default shell, with no leading arguments. - #[must_use] - pub fn default_shell() -> Self { - Self { - program: DEFAULT_SHELL.to_string(), - args: Vec::new(), - } - } - - /// Choose the interpreter an operator's configuration asks for. - /// - /// `configured` is `workflows.shell` (or a node's own `args.shell`): - /// - /// - empty — [`DEFAULT_SHELL`], so an unconfigured host is unchanged. - /// - [`USER_SHELL`] — the login shell `login_shell` reports, falling back to - /// [`DEFAULT_SHELL`] when the environment names none. This is the opt-in - /// that makes scripts run under whatever the operator actually uses. - /// - anything else — that program. - /// - /// `login_shell` is passed in rather than read from the environment here so - /// the choice is a pure function of its inputs, testable without mutating - /// process-global state. - /// - /// # Errors - /// - /// Refuses a program that is not a bare name or an absolute path, and any - /// program or argument carrying an interior NUL. A relative path is refused - /// rather than resolved because what it would resolve *against* is the - /// script's working directory, which the workflow author chose — so - /// `workflows.shell = "./sh"` would let a graph decide which binary the - /// operator's own configuration named. - pub fn resolve(configured: &str, args: &[String], login_shell: Option<&str>) -> Result { - let configured = configured.trim(); - let program = match configured { - "" => DEFAULT_SHELL, - USER_SHELL => login_shell - .map(str::trim) - .filter(|shell| !shell.is_empty()) - .unwrap_or(DEFAULT_SHELL), - other => other, - }; - Self::validated(program, args) - } - - /// An interpreter from an already-chosen program name, checked. - /// - /// # Errors - /// - /// As [`resolve`](Self::resolve). - pub fn validated(program: &str, args: &[String]) -> Result { - let program = program.trim(); - if program.is_empty() { - return Err(refused("the interpreter must not be empty")); - } - if program.contains('\0') { - return Err(refused(format!( - "the interpreter {program:?} contains a NUL byte, which cannot be passed to a \ - process" - ))); - } - // `is_separator` rather than `MAIN_SEPARATOR`: Windows accepts `/` as - // well as `\`, so matching only the platform's *preferred* separator - // would wave `./sh` straight through on the one platform where two - // spellings exist. It stays exact on unix, where `\` is an ordinary - // filename character. - if program.chars().any(std::path::is_separator) && !Path::new(program).is_absolute() { - return Err(refused(format!( - "the interpreter {program:?} is a relative path; name a program on PATH (\"zsh\") \ - or give an absolute path (\"/bin/zsh\")" - ))); - } - for arg in args { - if arg.contains('\0') { - return Err(refused(format!( - "the interpreter argument {arg:?} contains a NUL byte, which cannot be passed \ - to a process" - ))); - } - } - Ok(Self { - program: program.to_string(), - args: args.to_vec(), - }) - } - - /// The operator's login shell, as the environment reports it. - /// - /// `$SHELL` only — no `/etc/passwd` lookup, because the environment is what - /// a daemon started from a login session actually carries, and a passwd - /// entry would disagree with it exactly when a user has changed shells - /// without re-logging in. - #[must_use] - pub fn login_shell() -> Option { - std::env::var("SHELL").ok() - } -} - -/// A refusal about the interpreter, prefixed so a run record says what refused. -fn refused(message: impl AsRef) -> EngineError { - EngineError::Capability(format!("script: {}", message.as_ref())) -} - -impl From for ScriptLanguage { - fn from(language: tinyflows::caps::CodeLanguage) -> Self { - match language { - tinyflows::caps::CodeLanguage::JavaScript => Self::JavaScript, - tinyflows::caps::CodeLanguage::Python => Self::Python, - } - } -} - -/// What a script run produced. -#[derive(Debug)] -pub struct ScriptOutput { - /// Stdout, parsed as JSON when it is JSON. - pub value: Value, - /// Stderr, kept whether or not the script succeeded. - /// - /// A script that works and warns is the normal case, and discarding what it - /// said would hide the one thing its author wrote for a reader. - pub stderr: String, -} - -/// What to run: source this host stages, or a file that already exists. -#[derive(Debug, Clone, Copy)] -pub enum ScriptSource<'a> { - /// Source text, written to a temporary file before it is run. - Inline(&'a str), - /// An existing script file. - /// - /// Whether a workflow may reach this path is decided *before* it gets here, - /// by [`super::script_policy`]; nothing below re-checks it. - File(&'a Path), -} - -/// Everything one script run needs. -/// -/// A struct rather than six positional arguments, two of which are paths and -/// three of which are optional — an order a caller would eventually get wrong -/// without the compiler noticing. -#[derive(Debug, Clone, Copy)] -pub struct ScriptRequest<'a> { - /// The language the script is written in. Decides the interpreter, unless - /// `interpreter` names one, and always decides the staged file's extension. - pub language: ScriptLanguage, - /// The interpreter to run under, overriding the one `language` implies. - /// - /// `None` keeps the language's own program, which is what a `code` node - /// wants: its `javascript` and `python` are the contract, not a preference. - pub interpreter: Option<&'a Interpreter>, - /// The script itself. - pub source: ScriptSource<'a>, - /// The JSON handed to the script on stdin, and written to `argv[1]`. - pub input: &'a Value, - /// How long the script may run before it is abandoned. - pub timeout: Duration, - /// The directory to run in. `None` uses the temporary directory holding the - /// staged script — right for a pure computation, wrong for anything that - /// means to touch the operator's project. - pub cwd: Option<&'a Path>, - /// Environment variables layered over the inherited environment. - pub env: &'a BTreeMap, -} - -impl<'a> ScriptRequest<'a> { - /// A request with no working directory and no extra environment — the shape - /// a pure computation over `input` wants. - pub fn plain( - language: ScriptLanguage, - source: &'a str, - input: &'a Value, - timeout: Duration, - env: &'a BTreeMap, - ) -> Self { - Self { - language, - interpreter: None, - source: ScriptSource::Inline(source), - input, - timeout, - cwd: None, - env, - } - } -} - -/// Run the script `request` describes. -/// -/// # Errors -/// -/// Fails when the interpreter is missing, the script exits non-zero, or it -/// outlives `request.timeout`. The message carries the interpreter's own stderr, -/// which is the only thing that says what actually went wrong. -pub async fn run_script(request: ScriptRequest<'_>) -> Result { - use tokio::io::AsyncWriteExt; - - let ScriptRequest { - language, - interpreter, - source, - input, - timeout, - cwd, - env, - } = request; - - // Refused rather than emulated. `argv[1]` and `MEDULLA_INPUT` are real - // filesystem paths this host wrote (`C:\...` on Windows), and Git Bash — the - // only `bash` a Windows host is likely to have — cannot open a Windows path - // without translating it, which is exactly the kind of per-platform - // reinterpretation that would make a workflow look portable while quietly - // behaving differently by host. `javascript` and `python` need no such - // translation and stay available everywhere their interpreter is. - #[cfg(windows)] - if language == ScriptLanguage::Shell { - return Err(EngineError::Capability( - "script: shell scripts are not supported on Windows (no portable POSIX shell to \ - run them in); use language: \"javascript\" or \"python\" instead" - .to_string(), - )); - } - - // The extension always comes from the language; only the program is - // negotiable. A `.sh` staged for `zsh` is still a shell script. - let (default_program, extension) = language.program(); - let (program, leading_args) = match interpreter { - Some(chosen) => (chosen.program.as_str(), chosen.args.as_slice()), - None => (default_program, &[][..]), - }; - let dir = - tempfile::tempdir().map_err(|err| EngineError::Capability(format!("script: {err}")))?; - - let script: PathBuf = match source { - ScriptSource::Inline(source) => { - let staged = dir.path().join(format!("script.{extension}")); - std::fs::write(&staged, source) - .map_err(|err| EngineError::Capability(format!("script: {err}")))?; - staged - } - ScriptSource::File(path) => path.to_path_buf(), - }; - - // The input reaches the script two ways because the languages want - // different ones: a pipe reads naturally in node and python, a path reads - // naturally in shell. Writing both costs one small file. - let input_path = dir.path().join("input.json"); - let body = serde_json::to_vec(input) - .map_err(|err| EngineError::Capability(format!("script: {err}")))?; - std::fs::write(&input_path, &body) - .map_err(|err| EngineError::Capability(format!("script: {err}")))?; - - let mut command = tokio::process::Command::new(program); - command - .args(leading_args) - .arg(&script) - .arg(&input_path) - .env("MEDULLA_INPUT", &input_path) - // Layered after `MEDULLA_INPUT` so a workflow's own declaration wins - // over the inherited value of the same name — that is what declaring - // one is for. - .envs(env) - .current_dir(cwd.unwrap_or_else(|| dir.path())) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - // Tokio does not kill a child when its future is dropped, so a timeout - // would otherwise leave an infinite script running forever with nothing - // holding a handle to it. - .kill_on_drop(true); - - let mut child = command.spawn().map_err(|err| { - EngineError::Capability(format!( - "script: cannot run `{program}` ({err}). Is it installed and on PATH?" - )) - })?; - - // Writing stdin and draining stdout/stderr happen concurrently, not one - // after the other: a script that prints before it finishes reading stdin - // fills its stdout pipe while this side is still blocked in `write_all` on - // stdin, and neither side would ever unblock the other — a real deadlock, - // not just a slow path, for any input near the OS pipe buffer size. The - // writer runs on its own task so `wait_with_output` starts reading - // immediately; if the child exits without reading all of stdin, the pipe - // simply closes underneath the writer, which surfaces as a write error we - // ignore (the exit status and stderr are the story in that case, not this). - let mut stdin = child.stdin.take(); - let writer = tokio::spawn(async move { - if let Some(mut stdin) = stdin.take() { - let _ = stdin.write_all(&body).await; - let _ = stdin.shutdown().await; - } - }); - let output = tokio::time::timeout(timeout, async { - let output = child.wait_with_output().await; - // Joined so a slow writer is still bounded by `timeout` above, not left - // running past the point this function returns. - let _ = writer.await; - output - }) - .await - .map_err(|_| { - EngineError::Capability(format!("script: timed out after {}s", timeout.as_secs())) - })? - .map_err(|err| EngineError::Capability(format!("script: {program}: {err}")))?; - - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if !output.status.success() { - return Err(EngineError::Capability(format!( - "script: {program} exited with {}{}", - output.status, - if stderr.is_empty() { - String::new() - } else { - format!(": {stderr}") - } - ))); - } - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(ScriptOutput { - // Structured when the script printed JSON, the raw text otherwise — a - // script that just prints a line should still be usable downstream. - value: serde_json::from_str(&stdout).unwrap_or(Value::String(stdout)), - stderr, - }) -} - -#[cfg(test)] -#[path = "script_tests.rs"] -mod tests; diff --git a/src/sdk/src/flow_engine/caps/script_policy.rs b/src/sdk/src/flow_engine/caps/script_policy.rs deleted file mode 100644 index 28151797c..000000000 --- a/src/sdk/src/flow_engine/caps/script_policy.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Validating the untrusted parts of a script step's arguments. -//! -//! A workflow arrives as a file — possibly written by an agent, possibly copied -//! from somewhere. Its `script_path`, `cwd`, and `env` are author-supplied -//! strings that reach the operating system, so they are treated like every other -//! untrusted input in this crate: checked at the boundary, against the -//! operator's own configuration, before anything is spawned. -//! -//! The boundary is the configured workspace. [`super::script`] is deliberately -//! not a sandbox and does not pretend to be one; what this module bounds is -//! narrower and worth having anyway — *which file* a step may execute and -//! *which directory* it may run in. A workflow naming `../../../.ssh/id_rsa` -//! or `/etc/cron.d/x` as its script is answered rather than obeyed. -//! -//! Nothing here restricts what a script does once it runs. It cannot: this host -//! has no sandbox, which is exactly what `workflows.allowCode` says. - -use std::collections::BTreeMap; -use std::path::{Component, Path, PathBuf}; - -use tinyflows::error::{EngineError, Result}; - -/// Where a script step may read a script from and run. -#[derive(Debug, Clone, Default)] -pub struct ScriptPolicy { - /// The operator's workspace, and the only directory a `script_path` or a - /// `cwd` may resolve inside. - /// - /// `None` when no workspace is configured, which refuses both: a step can - /// still run an inline script, in a scratch directory, which needs no - /// filesystem policy at all. - workspace: Option, -} - -impl ScriptPolicy { - /// A policy rooted at `workspace`, or refusing paths entirely when it is - /// empty or is not a directory. - pub fn new(workspace: &str) -> Self { - let candidate = Path::new(workspace); - Self { - workspace: (!workspace.trim().is_empty() && candidate.is_dir()) - .then(|| candidate.to_path_buf()), - } - } - - /// The workspace itself, when one is configured. - /// - /// This is the directory a script runs in unless the step named another. - pub fn workspace(&self) -> Option<&Path> { - self.workspace.as_deref() - } - - /// Resolves an author-supplied script path to a readable file in the - /// workspace. - /// - /// # Errors - /// Refuses when no workspace is configured, when `raw` is absolute or - /// traverses upwards, when the resolved path escapes the workspace - /// (following symlinks), or when it is not an existing regular file. - pub fn resolve_script(&self, raw: &str) -> Result { - let resolved = self.resolve(raw, "script_path")?; - if !resolved.is_file() { - return Err(refused(format!( - "`args.script_path` ('{raw}') is not a file in the workspace" - ))); - } - Ok(resolved) - } - - /// Resolves an author-supplied working directory in the workspace. - /// - /// # Errors - /// As [`Self::resolve_script`], except the result must be a directory. - pub fn resolve_cwd(&self, raw: &str) -> Result { - let resolved = self.resolve(raw, "cwd")?; - if !resolved.is_dir() { - return Err(refused(format!( - "`args.cwd` ('{raw}') is not a directory in the workspace" - ))); - } - Ok(resolved) - } - - /// The shared resolution: reject the shape, then reject the destination. - /// - /// Both halves are load-bearing. The syntactic check answers the obvious - /// `../../etc/passwd` without touching the disk; canonicalizing and - /// re-checking afterwards is what catches a symlink *inside* the workspace - /// pointing out of it, which no amount of string inspection would have seen. - fn resolve(&self, raw: &str, field: &str) -> Result { - let Some(workspace) = &self.workspace else { - return Err(refused(format!( - "`args.{field}` needs a configured workspace to resolve against, and this host \ - has none; pass `args.script` instead" - ))); - }; - - let candidate = Path::new(raw); - if candidate.is_absolute() { - return Err(refused(format!( - "`args.{field}` ('{raw}') must be relative to the workspace, not absolute" - ))); - } - if candidate.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) { - return Err(refused(format!( - "`args.{field}` ('{raw}') must not traverse outside the workspace" - ))); - } - - let workspace = workspace.canonicalize().map_err(|err| { - refused(format!( - "the configured workspace ({}) is unreadable: {err}", - workspace.display() - )) - })?; - let resolved = workspace.join(candidate).canonicalize().map_err(|err| { - refused(format!( - "`args.{field}` ('{raw}') does not resolve inside the workspace: {err}" - )) - })?; - if !resolved.starts_with(&workspace) { - return Err(refused(format!( - "`args.{field}` ('{raw}') resolves outside the workspace" - ))); - } - Ok(resolved) - } -} - -/// Whether `name` is a usable environment-variable name. -/// -/// Stricter than the kernel on purpose: a name carrying `=` or a NUL would be -/// rejected by `execve` anyway, and one carrying a space or a newline is far -/// more likely to be a mistake in the workflow than a deliberate choice. -pub fn is_valid_env_name(name: &str) -> bool { - let mut chars = name.chars(); - match chars.next() { - Some(first) if first.is_ascii_alphabetic() || first == '_' => {} - _ => return false, - } - chars.all(|c| c.is_ascii_alphanumeric() || c == '_') -} - -/// Reads an author-supplied `args.env` object into a string map. -/// -/// Only strings are accepted: coercing a number or a boolean would make the -/// value a script actually sees depend on JSON formatting rather than on what -/// the author wrote. -/// -/// # Errors -/// Refuses a non-object, a non-string value, a malformed variable name, or a -/// value containing an interior NUL. -pub fn read_env(value: Option<&serde_json::Value>) -> Result> { - let Some(value) = value else { - return Ok(BTreeMap::new()); - }; - let object = value.as_object().ok_or_else(|| { - refused("`args.env` must be an object mapping variable names to string values") - })?; - - object - .iter() - .map(|(name, value)| { - if !is_valid_env_name(name) { - return Err(refused(format!( - "`args.env` has an invalid variable name '{name}'; expected letters, digits, \ - and underscores, not starting with a digit" - ))); - } - let value = value.as_str().ok_or_else(|| { - refused(format!( - "`args.env.{name}` must be a string, not {}", - kind_of(value) - )) - })?; - if value.contains('\0') { - return Err(refused(format!( - "`args.env.{name}` contains a NUL byte, which cannot be passed to a process" - ))); - } - Ok((name.clone(), value.to_string())) - }) - .collect() -} - -/// A short name for a JSON value's type, for an error that teaches. -fn kind_of(value: &serde_json::Value) -> &'static str { - match value { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "a boolean", - serde_json::Value::Number(_) => "a number", - serde_json::Value::String(_) => "a string", - serde_json::Value::Array(_) => "an array", - serde_json::Value::Object(_) => "an object", - } -} - -/// A refusal, prefixed so a run record says which step surface refused. -fn refused(message: impl AsRef) -> EngineError { - EngineError::Capability(format!("medulla:shell: {}", message.as_ref())) -} - -#[cfg(test)] -#[path = "script_policy_tests.rs"] -mod tests; diff --git a/src/sdk/src/flow_engine/caps/script_policy_tests.rs b/src/sdk/src/flow_engine/caps/script_policy_tests.rs deleted file mode 100644 index d1e5742a9..000000000 --- a/src/sdk/src/flow_engine/caps/script_policy_tests.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! Tests for the script-step boundary: which files a step may run, which -//! directories it may run in, and which environment it may declare. -//! -//! Filesystem-only and offline — nothing here spawns a process. - -use serde_json::json; - -use super::{is_valid_env_name, read_env, ScriptPolicy}; - -/// A workspace with `scripts/build.sh` and a `project/` directory in it. -fn workspace() -> tempfile::TempDir { - let root = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir(root.path().join("scripts")).expect("mkdir"); - std::fs::create_dir(root.path().join("project")).expect("mkdir"); - std::fs::write(root.path().join("scripts/build.sh"), "printf built\n").expect("write"); - root -} - -fn policy_at(root: &tempfile::TempDir) -> ScriptPolicy { - ScriptPolicy::new(&root.path().to_string_lossy()) -} - -/// A path this platform actually considers absolute. -/// -/// `/etc/passwd` is *not* absolute on Windows — it has no drive prefix — so a -/// test hard-coding it would take the traversal branch there and assert against -/// the wrong message. -const fn absolute_path() -> &'static str { - #[cfg(windows)] - { - r"C:\Windows\System32\drivers\etc\hosts" - } - #[cfg(not(windows))] - { - "/etc/passwd" - } -} - -#[test] -fn a_script_in_the_workspace_resolves() { - let root = workspace(); - let resolved = policy_at(&root) - .resolve_script("scripts/build.sh") - .expect("a file in the workspace resolves"); - - assert!(resolved.ends_with("scripts/build.sh")); - assert!(resolved.is_absolute(), "the caller gets a usable path"); -} - -#[test] -fn a_directory_in_the_workspace_resolves_as_a_working_directory() { - let root = workspace(); - let resolved = policy_at(&root) - .resolve_cwd("project") - .expect("a directory in the workspace resolves"); - - assert!(resolved.ends_with("project")); -} - -#[test] -fn the_workspace_is_the_default_directory_a_step_runs_in() { - let root = workspace(); - assert_eq!(policy_at(&root).workspace(), Some(root.path())); -} - -#[test] -fn absolute_and_traversing_paths_are_refused() { - let root = workspace(); - let policy = policy_at(&root); - - for (raw, needle) in [ - (absolute_path(), "must be relative to the workspace"), - ("../../etc/passwd", "must not traverse outside"), - ("scripts/../../escape.sh", "must not traverse outside"), - ] { - let error = policy - .resolve_script(raw) - .expect_err("a path outside the workspace must be refused"); - assert!(error.to_string().contains(needle), "{raw}: {error}"); - } - - let error = policy - .resolve_cwd("../elsewhere") - .expect_err("a cwd outside the workspace must be refused"); - assert!(error.to_string().contains("must not traverse outside")); -} - -#[cfg(unix)] -#[test] -fn a_symlink_out_of_the_workspace_is_refused() { - // The syntactic check cannot see this one: the path has no `..` in it, and - // only canonicalizing reveals where it lands. - let root = workspace(); - let outside = tempfile::tempdir().expect("tempdir"); - std::fs::write(outside.path().join("secret.sh"), "printf leaked").expect("write"); - std::os::unix::fs::symlink( - outside.path().join("secret.sh"), - root.path().join("link.sh"), - ) - .expect("symlink"); - - let error = policy_at(&root) - .resolve_script("link.sh") - .expect_err("a symlink out of the workspace must be refused"); - assert!( - error.to_string().contains("resolves outside the workspace"), - "unexpected error: {error}" - ); -} - -#[test] -fn a_missing_script_is_refused_rather_than_run_empty() { - let root = workspace(); - let error = policy_at(&root) - .resolve_script("scripts/absent.sh") - .expect_err("a missing script must be refused"); - assert!(error.to_string().contains("does not resolve inside")); -} - -#[test] -fn a_directory_is_not_a_script_and_a_file_is_not_a_directory() { - let root = workspace(); - let policy = policy_at(&root); - - let error = policy - .resolve_script("scripts") - .expect_err("a directory is not a script"); - assert!(error.to_string().contains("is not a file")); - - let error = policy - .resolve_cwd("scripts/build.sh") - .expect_err("a file is not a working directory"); - assert!(error.to_string().contains("is not a directory")); -} - -#[test] -fn a_host_without_a_workspace_refuses_paths_and_says_what_to_use_instead() { - let policy = ScriptPolicy::new(""); - assert_eq!(policy.workspace(), None); - - let error = policy - .resolve_script("scripts/build.sh") - .expect_err("no workspace means no path may resolve"); - assert!( - error.to_string().contains("pass `args.script` instead"), - "unexpected error: {error}" - ); -} - -#[test] -fn a_workspace_that_is_not_a_directory_is_treated_as_absent() { - let root = tempfile::tempdir().expect("tempdir"); - let file = root.path().join("not-a-dir"); - std::fs::write(&file, "x").expect("write"); - - assert_eq!(ScriptPolicy::new(&file.to_string_lossy()).workspace(), None); -} - -#[test] -fn a_declared_environment_reads_back_as_a_string_map() { - let env = read_env(Some(&json!({ "PROFILE": "release", "TARGET": "wasm" }))) - .expect("a string map is valid"); - - assert_eq!(env.get("PROFILE").map(String::as_str), Some("release")); - assert_eq!(env.get("TARGET").map(String::as_str), Some("wasm")); -} - -#[test] -fn no_environment_at_all_is_an_empty_map() { - assert!(read_env(None).expect("absent is valid").is_empty()); -} - -#[test] -fn a_malformed_environment_is_refused_before_anything_spawns() { - for (value, needle) in [ - (json!([]), "must be an object"), - (json!({ "not a name": "x" }), "invalid variable name"), - (json!({ "COUNT": 3 }), "must be a string, not a number"), - (json!({ "TOKEN": "a\0b" }), "NUL byte"), - ] { - let error = read_env(Some(&value)).expect_err("malformed env must be refused"); - assert!(error.to_string().contains(needle), "{value}: {error}"); - } -} - -#[test] -fn environment_names_follow_the_usual_shape() { - for name in ["PATH", "_private", "A1", "a_b_1"] { - assert!(is_valid_env_name(name), "{name} should be valid"); - } - for name in ["", "1ST", "with space", "with-dash", "with=equals", "a\0b"] { - assert!(!is_valid_env_name(name), "{name} should be invalid"); - } -} diff --git a/src/sdk/src/flow_engine/caps/script_tests.rs b/src/sdk/src/flow_engine/caps/script_tests.rs deleted file mode 100644 index 50e089930..000000000 --- a/src/sdk/src/flow_engine/caps/script_tests.rs +++ /dev/null @@ -1,530 +0,0 @@ -//! Tests for the out-of-process script runner. -//! -//! These spawn real interpreters, which is the point: the bug this module was -//! written to fix was a calling convention that was documented one way and -//! implemented another, and only actually running something catches that. -//! -//! `bash` is assumed present on unix — this crate already assumes a unix host -//! in its bridge and daemon tests — and the `ScriptLanguage::Shell` cases are -//! `#[cfg(unix)]` because `run_script` itself refuses that language on -//! Windows (see its doc comment): there is no portable POSIX shell there to -//! run them in, and emulating one is exactly the per-platform behavior this -//! module exists to avoid. `node` and `python3` are cross-platform but not -//! guaranteed installed, so those cases skip rather than fail on a machine -//! without them. - -use super::*; -use serde_json::json; - -/// The plain shape most cases here want: an inline script, no declared -/// environment. Cases that exercise `cwd`, a script file, or `env` build a -/// [`ScriptRequest`] themselves. -async fn run( - language: ScriptLanguage, - source: &str, - input: &Value, - timeout: Duration, - cwd: Option<&Path>, -) -> Result { - let env = BTreeMap::new(); - run_script(ScriptRequest { - language, - interpreter: None, - source: ScriptSource::Inline(source), - input, - timeout, - cwd, - env: &env, - }) - .await -} - -const TIMEOUT: Duration = Duration::from_secs(30); - -/// Whether an interpreter is on `PATH`, so a test can skip rather than fail. -fn available(program: &str) -> bool { - std::process::Command::new(program) - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|status| status.success()) -} - -// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses -// on Windows because there is no portable POSIX shell to run it in (see -// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. -#[cfg(unix)] -#[tokio::test] -async fn a_shell_script_reads_its_input_on_stdin_and_returns_stdout() { - let output = run( - ScriptLanguage::Shell, - "cat", - &json!({ "name": "sweep" }), - TIMEOUT, - None, - ) - .await - .expect("runs"); - - // Round-tripped through stdin and back out, and parsed as JSON on the way - // back because it is JSON. - assert_eq!(output.value, json!({ "name": "sweep" })); -} - -// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses -// on Windows because there is no portable POSIX shell to run it in (see -// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. -#[cfg(unix)] -#[tokio::test] -async fn a_shell_script_can_read_its_input_from_the_path_instead() { - // Shell reads a path more naturally than a pipe, so both are offered. - let output = run( - ScriptLanguage::Shell, - "cat \"$MEDULLA_INPUT\"", - &json!({ "n": 1 }), - TIMEOUT, - None, - ) - .await - .expect("runs"); - - assert_eq!(output.value, json!({ "n": 1 })); -} - -// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses -// on Windows because there is no portable POSIX shell to run it in (see -// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. -#[cfg(unix)] -#[tokio::test] -async fn the_input_path_is_also_the_first_argument() { - let output = run( - ScriptLanguage::Shell, - "cat \"$1\"", - &json!(42), - TIMEOUT, - None, - ) - .await - .expect("runs"); - - assert_eq!(output.value, json!(42)); -} - -// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses -// on Windows because there is no portable POSIX shell to run it in (see -// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. -#[cfg(unix)] -#[tokio::test] -async fn output_that_is_not_json_comes_back_as_a_string() { - let output = run( - ScriptLanguage::Shell, - "echo hello there", - &json!(null), - TIMEOUT, - None, - ) - .await - .expect("runs"); - - // A script that just prints a line should still be usable downstream. - assert_eq!(output.value, json!("hello there")); -} - -// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses -// on Windows because there is no portable POSIX shell to run it in (see -// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. -#[cfg(unix)] -#[tokio::test] -async fn stderr_survives_a_script_that_succeeded() { - let output = run( - ScriptLanguage::Shell, - "echo warning: skipped one >&2; echo done", - &json!(null), - TIMEOUT, - None, - ) - .await - .expect("runs"); - - assert_eq!(output.value, json!("done")); - // A script that works and warns wrote that warning for a reader. - assert_eq!(output.stderr, "warning: skipped one"); -} - -// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses -// on Windows because there is no portable POSIX shell to run it in (see -// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. -#[cfg(unix)] -#[tokio::test] -async fn a_failing_script_reports_its_stderr_rather_than_only_its_code() { - let err = run( - ScriptLanguage::Shell, - "echo could not reach the host >&2; exit 3", - &json!(null), - TIMEOUT, - None, - ) - .await - .expect_err("fails"); - - // The exit code alone says nothing an author can act on. - assert!( - err.to_string().contains("could not reach the host"), - "{err}" - ); -} - -// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses -// on Windows because there is no portable POSIX shell to run it in (see -// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. -#[cfg(unix)] -#[tokio::test] -async fn a_script_that_never_ends_is_stopped_and_says_so() { - let err = run( - ScriptLanguage::Shell, - "sleep 30", - &json!(null), - Duration::from_millis(300), - None, - ) - .await - .expect_err("times out"); - - assert!(err.to_string().contains("timed out"), "{err}"); -} - -#[tokio::test] -async fn a_missing_interpreter_says_what_is_missing_rather_than_failing_opaquely() { - let err = run( - ScriptLanguage::Python, - "print(1)", - &json!(null), - TIMEOUT, - None, - ) - .await; - - // Only meaningful when python3 is genuinely absent; where it exists this - // case cannot arise and the assertion is skipped. - if let Err(err) = err { - if err.to_string().contains("cannot run") { - assert!(err.to_string().contains("PATH"), "{err}"); - } - } -} - -// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses -// on Windows because there is no portable POSIX shell to run it in (see -// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. -#[cfg(unix)] -#[tokio::test] -async fn a_script_runs_where_it_was_told_to() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::write(dir.path().join("marker.txt"), "found me").expect("write"); - - let output = run( - ScriptLanguage::Shell, - "cat marker.txt", - &json!(null), - TIMEOUT, - Some(dir.path()), - ) - .await - .expect("runs"); - - // This is what makes `medulla:shell` useful: a step that means to touch the - // operator's project has to actually be in it. - assert_eq!(output.value, json!("found me")); -} - -// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses -// on Windows because there is no portable POSIX shell to run it in (see -// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. -#[cfg(unix)] -#[tokio::test] -async fn a_script_with_no_directory_given_runs_somewhere_disposable() { - let output = run(ScriptLanguage::Shell, "pwd", &json!(null), TIMEOUT, None) - .await - .expect("runs"); - - // A `code` node is a computation over its input, so it gets a scratch - // directory rather than the repository. - let cwd = output.value.as_str().expect("a path"); - assert_ne!( - cwd, - std::env::current_dir().unwrap().to_string_lossy(), - "a code node must not default to the process's own directory" - ); -} - -#[tokio::test] -async fn javascript_reads_the_same_input_the_same_way() { - if !available("node") { - return; - } - - let output = run( - ScriptLanguage::JavaScript, - "const fs = require('fs');\n\ - const input = JSON.parse(fs.readFileSync(0, 'utf8'));\n\ - console.log(JSON.stringify({ doubled: input.n * 2 }));", - &json!({ "n": 21 }), - TIMEOUT, - None, - ) - .await - .expect("runs"); - - assert_eq!(output.value, json!({ "doubled": 42 })); -} - -#[tokio::test] -async fn python_reads_the_same_input_the_same_way() { - if !available("python3") { - return; - } - - let output = run( - ScriptLanguage::Python, - "import json, sys\nprint(json.dumps({'doubled': json.load(sys.stdin)['n'] * 2}))", - &json!({ "n": 21 }), - TIMEOUT, - None, - ) - .await - .expect("runs"); - - assert_eq!(output.value, json!({ "doubled": 42 })); -} - -#[test] -fn a_language_name_is_read_the_way_an_author_would_write_it() { - for name in ["shell", "sh", "bash", "SHELL", " bash "] { - assert_eq!( - ScriptLanguage::parse(name), - Some(ScriptLanguage::Shell), - "{name}" - ); - } - for name in ["javascript", "js", "node", "nodejs"] { - assert_eq!( - ScriptLanguage::parse(name), - Some(ScriptLanguage::JavaScript), - "{name}" - ); - } - for name in ["python", "python3", "py"] { - assert_eq!( - ScriptLanguage::parse(name), - Some(ScriptLanguage::Python), - "{name}" - ); - } - // Refused rather than guessed: running the wrong interpreter on someone's - // script is worse than telling them the name was not recognised. - assert_eq!(ScriptLanguage::parse("ruby"), None); -} - -#[cfg(windows)] -#[tokio::test] -async fn shell_is_refused_on_windows_rather_than_emulated() { - // The one Windows-specific behavior this module has: refuse plainly, - // pointing at the languages that do work everywhere, instead of - // path-translating into Git Bash or swapping in `cmd`/PowerShell — either - // of which would make a workflow look portable while quietly behaving - // differently by host. - let err = run( - ScriptLanguage::Shell, - "echo hi", - &json!(null), - TIMEOUT, - None, - ) - .await - .expect_err("shell must be refused on Windows"); - - let message = err.to_string(); - assert!(message.contains("Windows"), "{message}"); - assert!(message.contains("javascript"), "{message}"); - assert!(message.contains("python"), "{message}"); -} - -/// Runs `source` under an explicitly chosen interpreter. -async fn run_under(interpreter: &Interpreter, source: &str) -> Result { - let env = BTreeMap::new(); - let input = json!(null); - run_script(ScriptRequest { - language: ScriptLanguage::Shell, - interpreter: Some(interpreter), - source: ScriptSource::Inline(source), - input: &input, - timeout: TIMEOUT, - cwd: None, - env: &env, - }) - .await -} - -#[test] -fn an_unconfigured_host_keeps_the_default_shell() { - // The whole point of the empty default: a workflow whose scripts were - // written against `bash` must not change interpreter because this field - // was added. - let chosen = Interpreter::resolve("", &[], Some("/usr/bin/fish")).expect("valid"); - - assert_eq!(chosen.program, DEFAULT_SHELL); - assert!(chosen.args.is_empty()); -} - -#[test] -fn the_user_sentinel_follows_the_login_shell() { - // The fixture has to be absolute *on this platform*: a `$SHELL` of - // `/bin/zsh` is rooted but drive-less on Windows, which is exactly the - // relative-path shape `validated` refuses. - let login = absolute_interpreter(); - - let chosen = Interpreter::resolve(USER_SHELL, &["-l".to_string()], Some(login)).expect("valid"); - - assert_eq!(chosen.program, login); - assert_eq!(chosen.args, vec!["-l".to_string()]); -} - -#[test] -fn a_login_shell_the_platform_cannot_use_is_refused_rather_than_spawned() { - // The other half of the case above: `$SHELL` is ordinary environment data, - // so a value this platform would treat as relative has to be refused with - // the rest, not waved through because it came from the environment. - #[cfg(windows)] - { - let err = Interpreter::resolve(USER_SHELL, &[], Some("/bin/zsh")).expect_err("drive-less"); - assert!(err.to_string().contains("relative path"), "{err}"); - } - #[cfg(not(windows))] - { - let err = Interpreter::resolve(USER_SHELL, &[], Some("bin/zsh")).expect_err("relative"); - assert!(err.to_string().contains("relative path"), "{err}"); - } -} - -#[test] -fn the_user_sentinel_falls_back_when_the_environment_names_no_shell() { - // A daemon started by systemd has no `$SHELL`, and an empty one is the - // same absence spelled differently. Neither may leave the program empty. - for absent in [None, Some(""), Some(" ")] { - let chosen = Interpreter::resolve(USER_SHELL, &[], absent).expect("valid"); - assert_eq!(chosen.program, DEFAULT_SHELL, "for {absent:?}"); - } -} - -#[test] -fn a_named_interpreter_wins_over_the_login_shell() { - let chosen = Interpreter::resolve("zsh", &[], Some("/usr/bin/fish")).expect("valid"); - - assert_eq!(chosen.program, "zsh"); -} - -#[test] -fn a_relative_interpreter_path_is_refused() { - // The path would resolve against the script's working directory, which the - // *workflow author* chooses — so accepting it would let a graph decide - // which binary the operator's own configuration named. - let err = Interpreter::resolve("./sh", &[], None).expect_err("relative path"); - - let message = err.to_string(); - assert!(message.contains("relative path"), "{message}"); - assert!( - message.contains("/bin/zsh"), - "the error must teach the fix: {message}" - ); -} - -#[test] -fn an_absolute_interpreter_path_is_accepted() { - let chosen = Interpreter::resolve(absolute_interpreter(), &[], None).expect("valid"); - - assert_eq!(chosen.program, absolute_interpreter()); -} - -/// An absolute path this platform actually considers absolute. -fn absolute_interpreter() -> &'static str { - #[cfg(windows)] - { - r"C:\Windows\System32\cmd.exe" - } - #[cfg(not(windows))] - { - "/bin/zsh" - } -} - -#[test] -fn a_blank_configured_shell_reads_as_unconfigured() { - // Whitespace is how a half-edited config file spells "I did not set this", - // and reading it as an empty program name would break every script. - let chosen = Interpreter::resolve(" ", &[], None).expect("valid"); - - assert_eq!(chosen.program, DEFAULT_SHELL); -} - -#[test] -fn an_empty_interpreter_is_refused() { - // `resolve` maps blank to the default; `validated` is the direct path, and - // there an empty program is a caller's mistake rather than an absence. - let err = Interpreter::validated(" ", &[]).expect_err("blank"); - - assert!(err.to_string().contains("must not be empty"), "{err}"); -} - -#[test] -fn a_nul_byte_is_refused_in_the_program_and_in_an_argument() { - // Neither can be passed to a process; refusing here beats a spawn error - // that names nothing an author wrote. - let program = Interpreter::resolve("z\0sh", &[], None).expect_err("NUL program"); - assert!(program.to_string().contains("NUL"), "{program}"); - - let argument = - Interpreter::resolve("zsh", &["-l\0".to_string()], None).expect_err("NUL argument"); - assert!(argument.to_string().contains("NUL"), "{argument}"); -} - -#[cfg(unix)] -#[tokio::test] -async fn a_chosen_interpreter_actually_runs_the_script() { - // `sh` rather than `zsh`: every unix host has it, so this pins that the - // override reaches the spawn rather than that a particular shell exists. - let chosen = Interpreter::validated("sh", &[]).expect("valid"); - - let output = run_under(&chosen, "echo chosen").await.expect("runs"); - - assert_eq!(output.value, json!("chosen")); -} - -#[cfg(unix)] -#[tokio::test] -async fn interpreter_arguments_reach_the_command_line() { - // `-x` traces to stderr, which is the observable proof the argument landed - // *before* the script path rather than being dropped. - let chosen = Interpreter::validated("sh", &["-x".to_string()]).expect("valid"); - - let output = run_under(&chosen, "echo traced").await.expect("runs"); - - assert_eq!(output.value, json!("traced")); - assert!( - output.stderr.contains("echo traced"), - "expected an -x trace, got {:?}", - output.stderr - ); -} - -#[cfg(unix)] -#[tokio::test] -async fn a_missing_interpreter_names_itself() { - let chosen = Interpreter::validated("medulla-no-such-shell", &[]).expect("valid"); - - let err = run_under(&chosen, "echo hi").await.expect_err("missing"); - - let message = err.to_string(); - assert!(message.contains("medulla-no-such-shell"), "{message}"); - assert!(message.contains("PATH"), "{message}"); -} diff --git a/src/sdk/src/flow_engine/caps/state.rs b/src/sdk/src/flow_engine/caps/state.rs deleted file mode 100644 index 213cd6d00..000000000 --- a/src/sdk/src/flow_engine/caps/state.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Durable key/value state for stateful workflows. -//! -//! One JSON file per key, under a per-workflow namespace directory, so two -//! workflows can use the same key name without colliding. Keys are hashed into -//! their filename rather than used verbatim: a key is author-supplied and may -//! contain path separators, and a `StateStore` must never be a way to write -//! outside its own directory. -//! -//! Writes are staged and renamed, never made in place, so a key can never be -//! left holding a half-written document that no later read can recover from. - -use std::path::{Path, PathBuf}; - -use async_trait::async_trait; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use tinyflows::caps::StateStore; -use tinyflows::error::{EngineError, Result}; - -/// A [`StateStore`] over files beneath a namespace directory. -pub struct FileStateStore { - /// The namespace's directory: `/`. - dir: PathBuf, -} - -impl FileStateStore { - /// A store for `namespace` (conventionally `workflow:`) under `root`. - pub fn new(root: &Path, namespace: &str) -> Self { - Self { - dir: root.join(digest(namespace)), - } - } - - /// The file a key is stored in. - fn path(&self, key: &str) -> PathBuf { - self.dir.join(format!("{}.json", digest(key))) - } -} - -/// A hex SHA-256 digest, used to turn an arbitrary author-supplied string into -/// one safe path component. -fn digest(value: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(value.as_bytes()); - format!("{:x}", hasher.finalize()) -} - -#[async_trait] -impl StateStore for FileStateStore { - async fn load(&self, key: &str) -> Result> { - let path = self.path(key); - // `tokio::fs` rather than `std::fs`: these run on the runtime's worker - // threads alongside every other node in the graph, and a blocking read - // here stalls whatever else is scheduled there. - match tokio::fs::read(&path).await { - Ok(body) => serde_json::from_slice(&body) - .map(Some) - .map_err(|err| EngineError::Capability(format!("state: {key}: {err}"))), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(EngineError::Capability(format!("state: {key}: {err}"))), - } - } - - async fn store(&self, key: &str, value: Value) -> Result<()> { - tokio::fs::create_dir_all(&self.dir) - .await - .map_err(|err| EngineError::Capability(format!("state: {key}: {err}")))?; - let body = serde_json::to_vec(&value) - .map_err(|err| EngineError::Capability(format!("state: {key}: {err}")))?; - let path = self.path(key); - // Staged and renamed rather than written in place. A plain write - // truncates and then fills, so a kill in that window — or a second - // writer for the same key — leaves a prefix of JSON on disk, and `load` - // has no way to read a prefix: the key would be wedged for good. A - // rename publishes either the whole previous value or the whole new - // one. The temp name carries a unique token so two writers racing on - // one key cannot scribble over each other's scratch file, and it sits - // beside the target so the rename stays within one filesystem. - let tmp = self - .dir - .join(format!("{}.tmp", uuid::Uuid::new_v4().simple())); - // A failed write must not leave the scratch file behind either: like a - // failed rename, it would otherwise accumulate under the namespace - // directory, and because every attempt names a fresh UUID, retries - // under a full disk would pile up partial `.tmp` files. - if let Err(err) = tokio::fs::write(&tmp, body).await { - let _ = tokio::fs::remove_file(&tmp).await; - return Err(EngineError::Capability(format!("state: {key}: {err}"))); - } - if let Err(err) = tokio::fs::rename(&tmp, &path).await { - // A failed rename must not leave scratch files accumulating in the - // namespace directory. - let _ = tokio::fs::remove_file(&tmp).await; - return Err(EngineError::Capability(format!("state: {key}: {err}"))); - } - Ok(()) - } -} diff --git a/src/sdk/src/flow_engine/observability.rs b/src/sdk/src/flow_engine/observability.rs index 306d1d8e5..6e01619b9 100644 --- a/src/sdk/src/flow_engine/observability.rs +++ b/src/sdk/src/flow_engine/observability.rs @@ -155,7 +155,7 @@ impl WorkflowRunObserver { /// The engine's own steps, in completion order. /// - /// What [`crate::workflows::run::diagnose::diagnose`] needs: it reads each + /// What [`crate::workflows::run::diagnose`] needs: it reads each /// step's `output` to find errors an `on_error` policy swallowed, and the /// graph to find nodes that never ran at all. pub fn execution_steps(&self) -> Vec { diff --git a/src/sdk/src/flow_engine/tests/dry_run_tests.rs b/src/sdk/src/flow_engine/tests/dry_run_tests.rs new file mode 100644 index 000000000..d1e45f832 --- /dev/null +++ b/src/sdk/src/flow_engine/tests/dry_run_tests.rs @@ -0,0 +1,37 @@ +//! Dry-run simulation: a graph validated by running it, with nothing dispatched. +//! +//! What remains of the old `http_tests` module after the HTTP capsule and the +//! schema sampler moved to `tinyflows::caps::host`. This case is not about +//! either: it is about the bundle Medulla assembles for a simulation, and the +//! claim that a validation run costs no harness session. + +use serde_json::json; + +use super::{agent_graph, empty_resolver}; + +#[tokio::test] +async fn a_dry_run_starts_no_harness_session_but_still_satisfies_declared_schemas() { + let root = tempfile::tempdir().unwrap(); + let caps = super::super::build_dry_run_capabilities(empty_resolver(root.path())); + + let compiled = tinyflows::compiler::compile(&agent_graph(json!({ + "prompt": "summarise", + "agent_ref": "builder", + "output_parser": { "schema": { "type": "object", "properties": { + "summary": { "type": "string" } + }}} + }))) + .unwrap(); + let outcome = tinyflows::engine::run(&compiled, json!({}), &caps) + .await + .expect("a dry run of a valid graph must succeed"); + + // `items[0].json` is the engine's `{ json, text, raw }` envelope, whose + // `json` is the mock's own response object — hence the parsed payload sits + // one level further in, at `.json.json`. + assert!( + outcome.output["nodes"]["step"]["items"][0]["json"]["json"]["json"]["summary"].is_string(), + "the declared schema should be satisfied: {}", + outcome.output + ); +} diff --git a/src/sdk/src/flow_engine/tests/http_tests.rs b/src/sdk/src/flow_engine/tests/http_tests.rs deleted file mode 100644 index 8a95d86ae..000000000 --- a/src/sdk/src/flow_engine/tests/http_tests.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! HTTP capability behaviour: allowlist and SSRF guards, credential handling, -//! schema sampling, and dry-run simulation. -//! -//! Split out of [`super`] (see that module's doc comment) when the -//! network-capsule cases pushed the file over the repository's 500-line ceiling. - -use std::collections::HashMap; -use std::sync::Arc; - -use serde_json::json; -use tinyflows::caps::HttpClient; - -use super::super::caps::http::{ - http_cred_name, inject_credential, is_private_addr, is_private_host, redacted_summary, - vet_resolution, AllowlistHttpClient, HttpCredential, -}; -use super::super::caps::mocks::sample_for_schema; -use super::super::settings::CapabilitySettings; -use super::{agent_graph, empty_resolver}; - -#[tokio::test] -async fn http_refuses_loopback_and_anything_off_the_allowlist() { - let root = tempfile::tempdir().unwrap(); - let mut allowed = CapabilitySettings::rooted_at(root.path()); - allowed.http_allowlist = vec!["example.com".into()]; - let client = AllowlistHttpClient::new(Arc::new(allowed), HashMap::new()); - - // Loopback is refused even though the test could otherwise serve it — a - // workflow reaching localhost is reaching services that trusted the network - // boundary. - let loopback = client - .request(json!({ "url": "http://127.0.0.1:8080/x" }), None) - .await - .expect_err("loopback"); - assert!(loopback.to_string().contains("private"), "got {loopback}"); - - let off_list = client - .request(json!({ "url": "https://elsewhere.test/x" }), None) - .await - .expect_err("not allowlisted"); - assert!(off_list.to_string().contains("allowlist"), "got {off_list}"); -} - -#[test] -fn private_host_detection_covers_loopback_names_and_ranges_but_not_lookalikes() { - for private in [ - "localhost", - "127.0.0.1", - "10.1.2.3", - "192.168.0.1", - "169.254.1.1", - "::1", - "db.internal", - ] { - assert!(is_private_host(private), "{private} should be refused"); - } - for public in ["example.com", "notlocalhost.com", "8.8.8.8"] { - assert!(!is_private_host(public), "{public} should be reachable"); - } -} - -#[test] -fn an_unrecognised_connection_ref_fails_closed_rather_than_sending_unauthenticated() { - assert_eq!(http_cred_name(None).unwrap(), None); - assert_eq!(http_cred_name(Some("http_cred:ci")).unwrap(), Some("ci")); - - // Silently dropping it would send the request anyway, without the - // credential the author asked for. - assert!(http_cred_name(Some("composio:abc")).is_err()); - assert!(http_cred_name(Some("http_cred:")).is_err()); -} - -#[test] -fn a_credential_is_injected_after_the_summary_is_taken() { - let request = json!({ "method": "post", "url": "https://example.com/x" }); - let summary = redacted_summary(&request); - let sent = inject_credential( - request, - &HttpCredential { - header: "Authorization".into(), - value: "Bearer super-secret".into(), - }, - ); - - assert_eq!(summary, "POST https://example.com/x"); - assert!( - !summary.contains("super-secret"), - "a secret must never reach a log or an approval prompt" - ); - assert_eq!(sent["headers"]["Authorization"], "Bearer super-secret"); -} - -#[test] -fn a_dry_run_sample_satisfies_the_shape_a_node_declared() { - let sample = sample_for_schema(&json!({ - "type": "object", - "properties": { - "title": { "type": "string" }, - "count": { "type": "integer" }, - "tags": { "type": "array", "items": { "type": "string" } }, - "state": { "enum": ["open", "closed"] } - } - })); - - assert!(sample["title"].is_string()); - assert!(sample["count"].is_number()); - // One element, so a downstream per-item node has something to map over. - assert_eq!(sample["tags"].as_array().unwrap().len(), 1); - assert_eq!(sample["state"], "open"); -} - -#[tokio::test] -async fn a_dry_run_starts_no_harness_session_but_still_satisfies_declared_schemas() { - let root = tempfile::tempdir().unwrap(); - let caps = super::super::build_dry_run_capabilities(empty_resolver(root.path())); - - let compiled = tinyflows::compiler::compile(&agent_graph(json!({ - "prompt": "summarise", - "agent_ref": "builder", - "output_parser": { "schema": { "type": "object", "properties": { - "summary": { "type": "string" } - }}} - }))) - .unwrap(); - let outcome = tinyflows::engine::run(&compiled, json!({}), &caps) - .await - .expect("a dry run of a valid graph must succeed"); - - // `items[0].json` is the engine's `{ json, text, raw }` envelope, whose - // `json` is the mock's own response object — hence the parsed payload sits - // one level further in, at `.json.json`. - assert!( - outcome.output["nodes"]["step"]["items"][0]["json"]["json"]["json"]["summary"].is_string(), - "the declared schema should be satisfied: {}", - outcome.output - ); -} - -#[test] -fn private_address_detection_covers_the_cloud_metadata_endpoint() { - // The one an SSRF is usually aiming for, and the reason link-local is - // refused rather than only loopback. - let metadata: std::net::IpAddr = "169.254.169.254".parse().unwrap(); - assert!(is_private_addr(&metadata)); - - for private in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1", "::1"] { - let addr: std::net::IpAddr = private.parse().unwrap(); - assert!(is_private_addr(&addr), "{private}"); - } - for public in ["8.8.8.8", "1.1.1.1"] { - let addr: std::net::IpAddr = public.parse().unwrap(); - assert!(!is_private_addr(&addr), "{public}"); - } -} - -#[tokio::test] -async fn an_allowlisted_name_that_resolves_to_loopback_is_still_refused() { - // The textual guard cannot catch this: `localtest.me` and friends are - // ordinary names whose DNS answer is 127.0.0.1. Resolving is what closes - // the rebinding gap. - let root = tempfile::tempdir().unwrap(); - let mut allowed = CapabilitySettings::rooted_at(root.path()); - allowed.http_allowlist = vec!["localtest.me".into()]; - let client = AllowlistHttpClient::new(Arc::new(allowed), HashMap::new()); - - let result = client - .request(json!({ "url": "http://localtest.me/x" }), None) - .await; - - // Either it resolved to loopback and was refused for that, or this machine - // has no DNS for the name and it was refused for that — never sent. - let err = result.expect_err("must not be sent"); - let message = err.to_string(); - assert!( - message.contains("loopback or private") || message.contains("cannot resolve"), - "got {message}" - ); -} - -#[test] -fn vetting_returns_the_very_addresses_the_request_will_be_pinned_to() { - // The vetted list is the point: it is handed to the transport as a DNS - // override, so the answer checked here is the answer connected to and a - // second lookup cannot rebind the name to something private in between. - let refused = vet_resolution("localhost", 80).expect_err("loopback must not be vetted"); - assert!( - refused.to_string().contains("loopback or private"), - "{refused}" - ); - - // An IP literal resolves to itself, so a public one vets to exactly one - // address and pins the transport to it. - let vetted = vet_resolution("93.184.216.34", 443).expect("a public literal"); - assert_eq!( - vetted, - vec!["93.184.216.34:443".parse::().unwrap()] - ); -} - -#[test] -fn an_ipv4_mapped_ipv6_loopback_is_recognised_as_private() { - // `::ffff:127.0.0.1` reaches loopback exactly as `127.0.0.1` does, so - // judging it by the v6 rules alone would let it through. - for mapped in [ - "::ffff:127.0.0.1", - "::ffff:10.0.0.1", - "::ffff:169.254.169.254", - ] { - let addr: std::net::IpAddr = mapped.parse().unwrap(); - assert!(is_private_addr(&addr), "{mapped}"); - } - // Unique-local fc00::/7 — the v6 answer to RFC 1918. - let ula: std::net::IpAddr = "fd00::1".parse().unwrap(); - assert!(is_private_addr(&ula)); - - let public: std::net::IpAddr = "::ffff:8.8.8.8".parse().unwrap(); - assert!(!is_private_addr(&public)); -} diff --git a/src/sdk/src/flow_engine/tests/mod.rs b/src/sdk/src/flow_engine/tests/mod.rs index 848dd1f36..28900a3d8 100644 --- a/src/sdk/src/flow_engine/tests/mod.rs +++ b/src/sdk/src/flow_engine/tests/mod.rs @@ -6,17 +6,21 @@ //! workflow step is a harness session" reduced to something checkable. //! //! The `medulla:shell` tool's own cases live in the `shell_tests` submodule, -//! and harness/model selection in `harness_selection_tests`, both split out -//! once they pushed this file over the 500-line ceiling. State-store, -//! tool-invoker, and HTTP-capsule cases live in `state_tests`, `tools_tests`, -//! and `http_tests` for the same reason. The fixtures these modules share — -//! `settings`, `RecordingDispatch`, `empty_resolver`, `agent_graph` — are -//! `pub(super)` for that reason. +//! harness/model selection in `harness_selection_tests`, and tool-invoker cases +//! in `tools_tests`, each split out once they pushed this file over the +//! 500-line ceiling. The fixtures these modules share — `settings`, +//! `RecordingDispatch`, `empty_resolver`, `agent_graph` — are `pub(super)` for +//! that reason. +//! +//! What is *not* here any more: the state store's and the HTTP capsule's own +//! cases. Both implementations moved to `tinyflows::caps::host`, and their tests +//! went with them — testing them from here would be this crate asserting on +//! another crate's internals. `dry_run_tests` keeps the part that is still about +//! Medulla: that a simulated run starts no harness session. +mod dry_run_tests; mod harness_selection_tests; -mod http_tests; mod shell_tests; -mod state_tests; mod tools_tests; use std::collections::HashMap; @@ -272,10 +276,16 @@ fn a_node_may_name_its_instruction_prompt_or_instruction() { } #[test] -fn production_capabilities_refuse_shell_execution_until_its_policy_exists() { +fn production_capabilities_gate_shell_execution_on_the_same_switch_as_code() { let root = tempfile::tempdir().unwrap(); + + // `allow_code` on: a `shell` node runs with this host's policy, same as + // `build_capabilities_inner`'s comment promises ("offered exactly when a + // `code` node is"). + let mut allowed = settings(root.path()); + Arc::get_mut(&mut allowed).unwrap().allow_code = true; let caps = build_capabilities( - settings(root.path()), + allowed, HostServices { node_progress: None, dispatch: RecordingDispatch::replying("unused"), @@ -285,10 +295,28 @@ fn production_capabilities_refuse_shell_execution_until_its_policy_exists() { "workflow:demo", "run-shell-boundary", ); + assert!( + caps.shell.is_some(), + "shell nodes must be available once this host's script policy (path, environment, interpreter) is wired up" + ); + // `allow_code` off: refused, exactly like `code` nodes are. + let mut denied = settings(root.path()); + Arc::get_mut(&mut denied).unwrap().allow_code = false; + let caps = build_capabilities( + denied, + HostServices { + node_progress: None, + dispatch: RecordingDispatch::replying("unused"), + resolver: empty_resolver(root.path()), + http_credentials: HashMap::new(), + }, + "workflow:demo", + "run-shell-boundary-denied", + ); assert!( caps.shell.is_none(), - "shell nodes must remain unavailable until Medulla supplies path, environment, and interpreter policy" + "an operator who disabled code execution must not get shell execution through the back door" ); } diff --git a/src/sdk/src/flow_engine/tests/state_tests.rs b/src/sdk/src/flow_engine/tests/state_tests.rs deleted file mode 100644 index ab9c32191..000000000 --- a/src/sdk/src/flow_engine/tests/state_tests.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! State-store behaviour: per-namespace scoping, path containment, and the -//! atomic-write guarantee. -//! -//! Split out of [`super`] (see that module's doc comment) when the store cases -//! pushed the file over the repository's 500-line ceiling. - -use std::sync::Arc; - -use serde_json::json; -use tinyflows::caps::StateStore; - -use super::super::caps::state::FileStateStore; - -#[tokio::test] -async fn state_is_scoped_per_namespace_so_two_workflows_cannot_collide() { - let root = tempfile::tempdir().unwrap(); - let alpha = FileStateStore::new(root.path(), "workflow:alpha"); - let beta = FileStateStore::new(root.path(), "workflow:beta"); - - alpha.store("cursor", json!(1)).await.unwrap(); - beta.store("cursor", json!(2)).await.unwrap(); - - assert_eq!(alpha.load("cursor").await.unwrap(), Some(json!(1))); - assert_eq!(beta.load("cursor").await.unwrap(), Some(json!(2))); - assert_eq!(alpha.load("missing").await.unwrap(), None); -} - -#[tokio::test] -async fn a_state_key_containing_path_separators_cannot_escape_its_directory() { - let root = tempfile::tempdir().unwrap(); - let store = FileStateStore::new(&root.path().join("state"), "workflow:alpha"); - - store.store("../../escaped", json!("x")).await.unwrap(); - - // Everything written must live under the namespace directory. - let escaped = root.path().join("escaped.json"); - assert!(!escaped.exists(), "a key must not choose its own path"); - assert_eq!( - store.load("../../escaped").await.unwrap(), - Some(json!("x")), - "and it must still round-trip" - ); -} - -#[tokio::test] -async fn concurrent_writers_of_one_key_never_leave_a_torn_document() { - // A plain `fs::write` truncates and then fills, so two writers of the same - // key — or a kill mid-write — can leave a prefix of JSON that no later read - // can recover from, wedging the key for good. Staging and renaming makes - // every observable state a whole document. - let root = tempfile::tempdir().unwrap(); - let store = Arc::new(FileStateStore::new(root.path(), "workflow:alpha")); - - // Bodies large enough that a truncating write would be observable in parts. - let writes: Vec<_> = (0..16) - .map(|n| { - let store = store.clone(); - tokio::spawn(async move { - let body = json!({ "n": n, "pad": "x".repeat(64 * 1024) }); - store.store("cursor", body).await - }) - }) - .collect(); - for write in writes { - write.await.unwrap().expect("stores"); - } - - let loaded = store - .load("cursor") - .await - .expect("parses") - .expect("present"); - assert_eq!(loaded["pad"].as_str().map(str::len), Some(64 * 1024)); - - // And no scratch file is left behind: the namespace holds the one document. - let namespace = std::fs::read_dir(root.path()) - .unwrap() - .next() - .expect("the namespace directory") - .unwrap() - .path(); - let leftovers: Vec<_> = std::fs::read_dir(namespace) - .unwrap() - .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string()) - .collect(); - assert_eq!(leftovers.len(), 1, "{leftovers:?}"); - assert!(leftovers[0].ends_with(".json"), "{leftovers:?}"); -} diff --git a/src/sdk/src/flow_engine/tests/tools_tests.rs b/src/sdk/src/flow_engine/tests/tools_tests.rs index 960424349..bb47a7387 100644 --- a/src/sdk/src/flow_engine/tests/tools_tests.rs +++ b/src/sdk/src/flow_engine/tests/tools_tests.rs @@ -9,10 +9,10 @@ use std::sync::Arc; use serde_json::{json, Value}; use tinyflows::caps::{CodeLanguage, CodeRunner, ToolInvoker}; -use super::super::caps::code::DeniedCodeRunner; use super::super::caps::tools::{MedullaToolInvoker, PreflightToolInvoker}; use super::super::settings::CapabilitySettings; use super::settings; +use tinyflows::caps::host::DeniedCodeRunner; #[tokio::test] async fn code_nodes_are_refused_by_default_with_a_reason() { diff --git a/src/sdk/src/harness_transcript/types.rs b/src/sdk/src/harness_transcript/types.rs index 18dac82c6..06d2834a8 100644 --- a/src/sdk/src/harness_transcript/types.rs +++ b/src/sdk/src/harness_transcript/types.rs @@ -1,7 +1,5 @@ //! The durable transcript entry and the bounded collector that produces one. -use serde::{Deserialize, Serialize}; - /// Entries kept for one node's turn before the collector starts dropping. /// /// Sized for a turn an operator would actually read back. A node that emits @@ -28,26 +26,12 @@ const ELISION: &str = " … "; /// One thing a harness did, in the order it did it. /// -/// Deliberately flat and stringly-typed. The alternative — mirroring -/// [`HarnessEventKind`](crate::protocol::HarnessEventKind) into the run record -/// — would make every future event kind a breaking change to a file format that -/// must stay readable by older builds. A reader that meets an unfamiliar `kind` -/// still has a timestamp and a line of text to render. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TranscriptEntry { - /// Epoch milliseconds, as the mapper stamped the event. - pub at_ms: i64, - /// The harness event kind this was folded from — `agent_message`, - /// `tool_call`, `tool_result`, `agent_thinking`, `error`, and so on. - /// - /// Carried verbatim rather than mapped to a closed set, so a kind added to - /// the wire vocabulary later shows up here without a change to this file. - pub kind: String, - /// The renderable line: the message text, the tool's one-line summary, the - /// error message. - pub text: String, -} +/// The type itself lives in the engine crate now, beside the run record that +/// stores it: a bounded, flat, stringly-typed line is what every host embedding +/// the engine needs, not something particular to Medulla. What stays here is the +/// half that *is* Medulla's — folding this host's harness event stream into +/// these entries, under the caps below. +pub use tinyflows::store::TranscriptEntry; /// Accumulates a bounded transcript from a harness's semantic event stream. /// diff --git a/src/sdk/src/mcp/tests/runs.rs b/src/sdk/src/mcp/tests/runs.rs index 94423f42f..abd9963ca 100644 --- a/src/sdk/src/mcp/tests/runs.rs +++ b/src/sdk/src/mcp/tests/runs.rs @@ -44,7 +44,9 @@ async fn reading_one_run_summarizes_it_unless_the_whole_thing_is_asked_for() { // bounded, and the 100KB prompt that says nothing about the outcome is not. assert_eq!(summary["stepDetail"], "summary"); assert_eq!(summary["steps"][0]["nodeId"], "work"); - assert_eq!(summary["steps"][0]["output"]["_medullaTruncated"], true); + assert!(tinyflows::store::is_truncated( + &summary["steps"][0]["output"] + )); assert!(summary["steps"][0].get("input").is_none(), "{summary}"); let (full, is_error) = call( diff --git a/src/sdk/src/ui/workflows/graph.rs b/src/sdk/src/ui/workflows/graph.rs index afc1f790e..730c50371 100644 --- a/src/sdk/src/ui/workflows/graph.rs +++ b/src/sdk/src/ui/workflows/graph.rs @@ -468,6 +468,8 @@ pub fn kind_glyph(kind: &NodeKind) -> &'static str { NodeKind::ToolCall => "⚒", NodeKind::HttpRequest => "⇅", NodeKind::Code => "λ", + // A prompt, because that is what the step is: a script handed to a + // shell, not a computation the graph carries. NodeKind::Shell => "$", NodeKind::Condition => "◆", NodeKind::Switch => "⑂", @@ -560,11 +562,10 @@ pub fn node_summary(node: &Node) -> String { } } NodeKind::Code => text("language").unwrap_or_default(), - NodeKind::Shell => text("script_path") - .or_else(|| { - text("source") - .map(|source| source.split('\n').next().unwrap_or(&source).to_string()) - }) + // The script itself, when it is inline and short enough to read; the + // path otherwise, which is the other thing that says what will run. + NodeKind::Shell => text("script") + .or_else(|| text("script_path")) .unwrap_or_default(), NodeKind::Condition => text("expression") .or_else(|| text("left")) diff --git a/src/sdk/src/ui/workflows/graph_tests.rs b/src/sdk/src/ui/workflows/graph_tests.rs index 0de2a7e54..89127aed0 100644 --- a/src/sdk/src/ui/workflows/graph_tests.rs +++ b/src/sdk/src/ui/workflows/graph_tests.rs @@ -411,15 +411,15 @@ fn a_summary_names_what_identifies_the_node_for_its_kind() { node( "s", NodeKind::Shell, - json!({"script_path":"scripts/backup.sh","source":"echo ignored"}), + json!({"script_path":"scripts/backup.sh"}), ), "scripts/backup.sh", ), ( node( - "s-source", + "s-script", NodeKind::Shell, - json!({"source":"echo first\necho second"}), + json!({"script":"echo first\necho second","script_path":"scripts/ignored.sh"}), ), "echo first", ), diff --git a/src/sdk/src/ui/workflows/rows_tests.rs b/src/sdk/src/ui/workflows/rows_tests.rs index f4f557e61..44bc0b160 100644 --- a/src/sdk/src/ui/workflows/rows_tests.rs +++ b/src/sdk/src/ui/workflows/rows_tests.rs @@ -188,9 +188,9 @@ fn a_workflow_with_no_inputs_still_reads_as_it_always_did() { #[test] fn a_bounded_input_shows_its_text_rather_than_the_wrapper() { - // An input over `MAX_INPUT_BYTES` is stored as a `_medullaTruncated` - // wrapper. A rail row that serialized the wrapper would spend all 48 - // characters on our own bookkeeping instead of the argument. + // An input over `MAX_INPUT_BYTES` is stored as a truncation wrapper. A rail + // row that serialized the wrapper would spend all 48 characters on our own + // bookkeeping instead of the argument. let record = run("run-1", RunStatus::Succeeded).with_inputs( &serde_json::json!({ "instruction": format!("rebuild the index {}", "y".repeat(8_000)) }) .as_object() @@ -199,7 +199,7 @@ fn a_bounded_input_shows_its_text_rather_than_the_wrapper() { &serde_json::json!({}), ); assert!( - record.inputs["instruction"]["_medullaTruncated"] == serde_json::json!(true), + tinyflows::store::is_truncated(&record.inputs["instruction"]), "the fixture must actually be bounded" ); let rows = run_rows(&[record]); diff --git a/src/sdk/src/ui/workflows/run_view.rs b/src/sdk/src/ui/workflows/run_view.rs index 777dc67c9..96556154f 100644 --- a/src/sdk/src/ui/workflows/run_view.rs +++ b/src/sdk/src/ui/workflows/run_view.rs @@ -146,10 +146,10 @@ fn render_value(value: &serde_json::Value) -> String { /// kind of literalism that makes a pane look like a debugger. Everything else is /// compact JSON, which is what a number, a flag, or a small list wants. pub fn value_text(value: &serde_json::Value) -> String { - if let Some(preview) = value - .get("_medullaTruncated") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) + // Both the current marker and the one written before the bounding moved to + // the engine crate — a run record is written once, so older ones stay + // readable only because this asks the engine rather than matching a literal. + if let Some(preview) = tinyflows::store::is_truncated(value) .then(|| value.get("preview").and_then(serde_json::Value::as_str)) .flatten() { diff --git a/src/sdk/src/ui/workflows/run_view_tests.rs b/src/sdk/src/ui/workflows/run_view_tests.rs index 73f637f9f..3d58b04a8 100644 --- a/src/sdk/src/ui/workflows/run_view_tests.rs +++ b/src/sdk/src/ui/workflows/run_view_tests.rs @@ -151,6 +151,9 @@ fn a_bounded_input_row_reads_as_the_text_it_was_given() { let mut record = settled(); record.inputs = serde_json::json!({ "instruction": { + // Deliberately the pre-move marker: this pins that a run record + // written by an older build still renders as an elision rather + // than as an object full of our own bookkeeping. "_medullaTruncated": true, "originalBytes": 9_001, "preview": "rebuild the index", diff --git a/src/sdk/src/workflows/README.md b/src/sdk/src/workflows/README.md index a552d8abd..d8e9f83da 100644 --- a/src/sdk/src/workflows/README.md +++ b/src/sdk/src/workflows/README.md @@ -8,9 +8,9 @@ Authored, durable, multi-step work: workflow definitions and their runs. - [`mcp/`](./mcp/) — A Model Context Protocol server exposing the workflow operations. - [`run/`](./run/) — Running a workflow, and resuming one that paused. - [`skills/`](./skills/) — Harness-native skills that trigger saved workflows over MCP. -- [`store/`](./store/) — Where workflows and their run records live. -- [`authoring_tests.rs`](./authoring_tests.rs) — Tests for patch-based workflow editing. -- [`authoring.rs`](./authoring.rs) — Editing a workflow as a series of patches. +- [`store/`](./store/) — Medulla's half of the store: the home layout, and the harness rule for a `defaults` block. The store itself is `tinyflows::store`. +- [`authoring_tests.rs`](./authoring_tests.rs) — The one patch-editing test that needs this host's own operations. +- [`dispatch_error.rs`](./dispatch_error.rs) — Turning a hub dispatch failure into a workflow failure. - [`local.rs`](./local.rs) — Running workflows on this machine, with no orchestrator involved. - [`mod.rs`](./mod.rs) — Authored, durable, multi-step work: workflow definitions and their runs. - [`node_contracts_tests.rs`](./node_contracts_tests.rs) — Tests for the host overlay on the node-kind catalogue. @@ -19,7 +19,21 @@ Authored, durable, multi-step work: workflow definitions and their runs. - [`ops.rs`](./ops.rs) — The workflow operations, as one JSON-in/JSON-out surface. - [`registry.rs`](./registry.rs) — Resolving a workflow id to a graph for the engine. - [`tests.rs`](./tests.rs) — Unit tests for the workflow record's derived views and for resolving sub-workflows out of a store. -- [`types.rs`](./types.rs) — The data model for stored workflows and their runs. + +## What is no longer here + +The stored model (`types/`) and the file-backed store (`store/file/`) moved to +`tinyflows::store`, behind that crate's `store` feature: a workflow document is +the engine's own graph plus bookkeeping, and every host embedding the engine +needs the same bookkeeping. So did the run diagnosis (`run/diagnose.rs`) and the +expression-binding reader (`gates/bindings.rs`), patch-based editing +(`authoring.rs`), and the host-agnostic authoring gates. All are re-exported +from here, so a call site still writes `crate::workflows::WorkflowRecord` and +`crate::workflows::authoring::apply_workflow_ops`. + +What stays is what needs this host's vocabulary: `gates/harness.rs`, and +`gates::MedullaPolicy`, which is how a store applies both the engine's rules and +this host's to every document it loads and every edit it writes. ## Maintenance diff --git a/src/sdk/src/workflows/authoring.rs b/src/sdk/src/workflows/authoring.rs deleted file mode 100644 index 2562bce97..000000000 --- a/src/sdk/src/workflows/authoring.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Editing a workflow as a series of patches. -//! -//! Workflows here are written by agents as often as by people, and an agent -//! editing a graph by rewriting the whole JSON document loses information every -//! time it misremembers a field. The engine's [`GraphOp`] patch language exists -//! for exactly this: small, named, checkable edits — add a node, merge-patch a -//! config, rewire an edge — that fail loudly rather than silently dropping what -//! they did not mention. -//! -//! Every edit here is apply → validate → save, in that order, and a graph that -//! fails validation is never written. An author's mistake costs them an error -//! message, not their saved workflow. - -use std::sync::Arc; - -use tinyflows::graph_ops::{apply_ops, GraphOp}; -use tinyflows::model::WorkflowGraph; - -use crate::workflows::store::{require, validate_graph}; -use crate::workflows::{WorkflowError, WorkflowRecord, WorkflowStore}; - -/// Apply `ops` to the workflow `id` and save the result. -/// -/// Returns the saved record. The workflow is left untouched if any op fails to -/// apply or the result fails validation — the ops are applied to a copy, and -/// only a graph that would compile is written back. -pub fn apply_workflow_ops( - store: &Arc, - id: &str, - ops: &[GraphOp], -) -> Result { - // A copilot, CLI, and another MCP process may all edit the same file through - // independent store instances. Rebase the patch when another writer wins - // between read and save instead of reporting two successes while silently - // discarding the earlier edit. - apply_workflow_ops_observed(store, id, ops, |_| {}).map(|(record, _)| record) -} - -/// Apply graph operations while observing each freshly read save attempt. -/// -/// The observer is used by concurrency tests to synchronize both writers after -/// their first read without relying on scheduler timing. Production callers use -/// [`apply_workflow_ops`], whose observer is a no-op. -pub(crate) fn apply_workflow_ops_observed( - store: &Arc, - id: &str, - ops: &[GraphOp], - observer: impl FnMut(usize), -) -> Result<(WorkflowRecord, usize), WorkflowError> { - mutate_workflow_record( - store, - id, - |record| { - record.graph = apply_ops(&record.graph, ops) - .map_err(|err| WorkflowError::Engine(format!("workflow '{id}': {err}")))?; - validate_graph(id, &record.graph)?; - crate::workflows::gates::check(id, &record.graph) - }, - observer, - "kept changing while the edit was being saved; retry the edit", - ) -} - -/// Mutate and atomically save a workflow record, rebasing after CAS conflicts. -pub(crate) fn mutate_workflow_record( - store: &Arc, - id: &str, - mut mutate: impl FnMut(&mut WorkflowRecord) -> Result<(), WorkflowError>, - mut observer: impl FnMut(usize), - failure: &str, -) -> Result<(WorkflowRecord, usize), WorkflowError> { - const MAX_RETRIES: usize = 16; - for attempt in 1..=MAX_RETRIES { - let mut record = require(store.as_ref(), id)?; - let expected = crate::workflows::record_fingerprint(&record); - mutate(&mut record)?; - observer(attempt); - if store.save_if_record_fingerprint(&record, &expected)? { - return Ok((record, attempt)); - } - } - Err(WorkflowError::Engine(format!("workflow '{id}' {failure}"))) -} - -/// Apply `ops` only if the graph still matches `expected_fingerprint`. -/// -/// Returns `None` when the expected fingerprint is stale, including when the -/// graph changes between the initial read and persistence. Returns `Some` only -/// after applying the ops, validating the graph and host semantic gates, and -/// durably saving the result. -/// -/// # Errors -/// -/// Returns an error when the workflow is missing, an op cannot be applied, the -/// resulting graph fails validation or semantic checks, or persistence fails. -pub fn apply_workflow_ops_if_unchanged( - store: &Arc, - id: &str, - ops: &[GraphOp], - expected_fingerprint: &str, -) -> Result, WorkflowError> { - let mut record = require(store.as_ref(), id)?; - if crate::workflows::fingerprint(&record.graph) != expected_fingerprint { - return Ok(None); - } - record.graph = apply_ops(&record.graph, ops) - .map_err(|err| WorkflowError::Engine(format!("workflow '{id}': {err}")))?; - validate_graph(id, &record.graph)?; - crate::workflows::gates::check(id, &record.graph)?; - if !store.save_if_fingerprint(&record, expected_fingerprint)? { - return Ok(None); - } - Ok(Some(record)) -} - -/// Preview `ops` against the workflow `id` without saving. -/// -/// The same checks as [`apply_workflow_ops`], minus the write. What an author -/// calls to see whether an edit is sound before committing to it. -pub fn preview_workflow_ops( - store: &Arc, - id: &str, - ops: &[GraphOp], -) -> Result { - let record = require(store.as_ref(), id)?; - let graph = apply_ops(&record.graph, ops) - .map_err(|err| WorkflowError::Engine(format!("workflow '{id}': {err}")))?; - validate_graph(id, &graph)?; - crate::workflows::gates::check(id, &graph)?; - Ok(graph) -} - -/// Create a workflow from a whole graph document, replacing any existing one of -/// the same id. -/// -/// Parses, then validates, then saves — the same order [`apply_workflow_ops`] -/// uses, and for the same reason. A document that parses is not necessarily a -/// graph the engine would compile, and a create path that skipped validation -/// would be the one way to get an unrunnable workflow into a store whose -/// listings are otherwise trustworthy. -pub fn create_workflow( - store: &Arc, - document: &str, - id_fallback: &str, -) -> Result { - let record = crate::workflows::store::parse_workflow(document, id_fallback) - .map_err(WorkflowError::Malformed)?; - validate_graph(&record.id, &record.graph)?; - crate::workflows::gates::check(&record.id, &record.graph)?; - store.save(&record)?; - Ok(record) -} - -/// A graph an author handed in, resolved from one of the ways they can name it. -/// -/// Two ways to say "the graph I mean" — a saved id, or an inline document — so -/// validate, preview, and dry-run all take the same argument whether the author -/// is editing something saved or checking something they have not saved yet. -pub enum GraphHandle<'a> { - /// A workflow already in the store. - Saved(&'a str), - /// A graph document supplied inline. - Inline(&'a str), -} - -impl GraphHandle<'_> { - /// Resolve to a record, without saving anything. - pub fn resolve(&self, store: &Arc) -> Result { - match self { - GraphHandle::Saved(id) => require(store.as_ref(), id), - GraphHandle::Inline(document) => { - crate::workflows::store::parse_workflow(document, "inline") - .map_err(WorkflowError::Malformed) - } - } - } -} - -/// Validate a graph the author has not necessarily saved. -/// -/// Reports every failure, not the first, so one round-trip tells an author -/// everything wrong with what they wrote. -pub fn validate_handle( - store: &Arc, - handle: &GraphHandle<'_>, -) -> Result { - let record = handle.resolve(store)?; - validate_graph(&record.id, &record.graph)?; - crate::workflows::gates::check(&record.id, &record.graph)?; - Ok(record) -} - -#[cfg(test)] -#[path = "authoring_tests.rs"] -mod tests; diff --git a/src/sdk/src/workflows/authoring_tests.rs b/src/sdk/src/workflows/authoring_tests.rs index 4e3fff634..81f18a161 100644 --- a/src/sdk/src/workflows/authoring_tests.rs +++ b/src/sdk/src/workflows/authoring_tests.rs @@ -1,18 +1,18 @@ -//! Tests for patch-based workflow editing. +//! Tests for patch-based editing that need Medulla's own operations. //! -//! The theme is that a bad edit costs an error message, never the saved -//! workflow. +//! The rest of the authoring suite moved to `tinyflows::store::authoring` with +//! the code. What stays is the case that crosses into this crate: a graph edit +//! and a `defaults` edit racing through two independent store instances, where +//! the second writer is `workflows::ops::set_defaults_observed` and so cannot be +//! expressed from inside the engine crate. use std::sync::Arc; use serde_json::json; use tinyflows::graph_ops::GraphOp; +use tinyflows::store::{apply_workflow_ops_observed, create_workflow}; -use super::{ - apply_workflow_ops, apply_workflow_ops_observed, create_workflow, preview_workflow_ops, - validate_handle, GraphHandle, -}; -use crate::workflows::{FileWorkflowStore, WorkflowError, WorkflowStore}; +use crate::workflows::{FileWorkflowStore, WorkflowStore}; fn document(id: &str) -> String { json!({ @@ -29,81 +29,6 @@ fn document(id: &str) -> String { .to_string() } -fn store() -> (tempfile::TempDir, Arc) { - let root = tempfile::tempdir().unwrap(); - let store: Arc = Arc::new(FileWorkflowStore::new( - vec![root.path().join("workflows")], - root.path().join("runs"), - )); - (root, store) -} - -#[test] -fn concurrent_store_instances_rebase_graph_ops_instead_of_losing_one() { - let root = tempfile::tempdir().unwrap(); - let definitions = root.path().join("workflows"); - let runs = root.path().join("runs"); - let first: Arc = Arc::new(FileWorkflowStore::new( - vec![definitions.clone()], - runs.clone(), - )); - let second: Arc = - Arc::new(FileWorkflowStore::new(vec![definitions.clone()], runs)); - create_workflow(&first, &document("sweep"), "sweep").unwrap(); - - // Both first attempts pause after reading the same record, immediately - // before their CAS. One must then lose and retry; no scheduler sleep or - // knowledge of the store's private lock path is involved. - let barrier = Arc::new(std::sync::Barrier::new(2)); - - let name_barrier = barrier.clone(); - let name_edit = std::thread::spawn(move || { - apply_workflow_ops_observed( - &first, - "sweep", - &[GraphOp::SetNodeName { - id: "work".into(), - name: "Renamed".into(), - }], - |attempt| { - if attempt == 1 { - name_barrier.wait(); - } - }, - ) - }); - let config_barrier = barrier.clone(); - let config_edit = std::thread::spawn(move || { - apply_workflow_ops_observed( - &second, - "sweep", - &[GraphOp::UpdateNodeConfig { - id: "work".into(), - config: json!({ "prompt": "carefully" }), - }], - |attempt| { - if attempt == 1 { - config_barrier.wait(); - } - }, - ) - }); - let (_, name_attempts) = name_edit.join().unwrap().unwrap(); - let (_, config_attempts) = config_edit.join().unwrap().unwrap(); - assert!( - name_attempts > 1 || config_attempts > 1, - "one stale CAS must rebase" - ); - let check: Arc = Arc::new(FileWorkflowStore::new( - vec![definitions], - root.path().join("check-runs"), - )); - let record = check.get("sweep").unwrap().unwrap(); - let node = record.graph.node("work").unwrap(); - assert_eq!(node.name, "Renamed"); - assert_eq!(node.config["prompt"], "carefully"); -} - #[test] fn graph_ops_and_defaults_rebase_without_reverting_each_other() { let root = tempfile::tempdir().unwrap(); @@ -164,231 +89,3 @@ fn graph_ops_and_defaults_rebase_without_reverting_each_other() { assert_eq!(record.defaults.harness.as_deref(), Some("codex")); assert_eq!(record.defaults.model.as_deref(), Some("gpt-5")); } - -#[test] -fn a_config_patch_merges_rather_than_replacing_the_whole_config() { - let (_root, store) = store(); - create_workflow(&store, &document("sweep"), "sweep").unwrap(); - - let record = apply_workflow_ops( - &store, - "sweep", - &[GraphOp::UpdateNodeConfig { - id: "work".into(), - config: json!({ "prompt": "do it carefully" }), - }], - ) - .expect("applies"); - - let node = record.graph.node("work").unwrap(); - assert_eq!(node.config["prompt"], "do it carefully"); - assert_eq!( - node.config["agent_ref"], "builder", - "a merge patch must not drop the fields it did not mention" - ); -} - -#[test] -fn a_null_leaf_in_a_patch_deletes_that_key() { - let (_root, store) = store(); - create_workflow(&store, &document("sweep"), "sweep").unwrap(); - - let record = apply_workflow_ops( - &store, - "sweep", - &[GraphOp::UpdateNodeConfig { - id: "work".into(), - config: json!({ "agent_ref": null }), - }], - ) - .expect("applies"); - - assert!( - record - .graph - .node("work") - .unwrap() - .config - .get("agent_ref") - .is_none(), - "the node should fall back to the default worker" - ); -} - -#[test] -fn an_op_naming_a_node_that_does_not_exist_leaves_the_workflow_untouched() { - let (_root, store) = store(); - create_workflow(&store, &document("sweep"), "sweep").unwrap(); - - let err = apply_workflow_ops( - &store, - "sweep", - &[GraphOp::SetNodeName { - id: "ghost".into(), - name: "nope".into(), - }], - ) - .expect_err("no such node"); - - assert!(err.to_string().contains("ghost"), "name the node: {err}"); - assert_eq!( - store - .get("sweep") - .unwrap() - .unwrap() - .graph - .node("work") - .unwrap() - .name, - "Work", - "a failed edit must not have been half-applied" - ); -} - -#[test] -fn an_edit_that_would_break_the_graph_is_refused_before_it_is_saved() { - let (_root, store) = store(); - create_workflow(&store, &document("sweep"), "sweep").unwrap(); - - // Removing the trigger leaves a graph the engine will not compile. - let err = apply_workflow_ops(&store, "sweep", &[GraphOp::RemoveNode { id: "t".into() }]) - .expect_err("must be refused"); - - assert!(matches!(err, WorkflowError::Invalid { .. }), "got {err:?}"); - assert!( - store - .get("sweep") - .unwrap() - .unwrap() - .graph - .node("t") - .is_some(), - "the saved workflow must still have its trigger" - ); -} - -#[test] -fn a_batch_of_ops_reports_which_one_failed() { - let (_root, store) = store(); - create_workflow(&store, &document("sweep"), "sweep").unwrap(); - - let err = apply_workflow_ops( - &store, - "sweep", - &[ - GraphOp::SetNodeName { - id: "work".into(), - name: "Renamed".into(), - }, - GraphOp::SetNodeName { - id: "ghost".into(), - name: "nope".into(), - }, - ], - ) - .expect_err("the second op fails"); - - assert!( - err.to_string().contains("ghost"), - "the message should identify the failing op: {err}" - ); - assert_eq!( - store - .get("sweep") - .unwrap() - .unwrap() - .graph - .node("work") - .unwrap() - .name, - "Work", - "the first op must not survive the batch failing" - ); -} - -#[test] -fn a_preview_checks_the_edit_without_writing_it() { - let (_root, store) = store(); - create_workflow(&store, &document("sweep"), "sweep").unwrap(); - - let previewed = preview_workflow_ops( - &store, - "sweep", - &[GraphOp::SetNodeName { - id: "work".into(), - name: "Renamed".into(), - }], - ) - .expect("previews"); - - assert_eq!(previewed.node("work").unwrap().name, "Renamed"); - assert_eq!( - store - .get("sweep") - .unwrap() - .unwrap() - .graph - .node("work") - .unwrap() - .name, - "Work", - "a preview must not save" - ); -} - -#[test] -fn an_inline_graph_can_be_validated_without_saving_it_first() { - let (_root, store) = store(); - - let record = - validate_handle(&store, &GraphHandle::Inline(&document("draft"))).expect("a valid draft"); - - assert_eq!(record.id, "draft"); - assert!( - store.list().unwrap().is_empty(), - "validating a draft must not install it" - ); -} - -#[test] -fn validating_an_inline_graph_reports_every_problem_at_once() { - let (_root, store) = store(); - let broken = json!({ - "id": "broken", - "nodes": [{ "id": "a", "kind": "transform", "name": "a" }], - "edges": [{ "from_node": "a", "to_node": "ghost" }] - }) - .to_string(); - - let err = validate_handle(&store, &GraphHandle::Inline(&broken)).expect_err("invalid"); - - let WorkflowError::Invalid { messages, .. } = err else { - panic!("expected Invalid"); - }; - assert!( - messages.len() >= 2, - "one round-trip should tell an author everything: {messages:?}" - ); -} - -#[test] -fn a_saved_handle_and_an_inline_handle_resolve_the_same_way() { - let (_root, store) = store(); - create_workflow(&store, &document("sweep"), "sweep").unwrap(); - - let saved = GraphHandle::Saved("sweep").resolve(&store).unwrap(); - let inline = GraphHandle::Inline(&document("sweep")) - .resolve(&store) - .unwrap(); - - assert_eq!(saved.graph, inline.graph); -} - -#[test] -fn editing_a_workflow_that_does_not_exist_says_so() { - let (_root, store) = store(); - - let err = apply_workflow_ops(&store, "ghost", &[]).expect_err("no such workflow"); - - assert!(matches!(err, WorkflowError::NotFound(_)), "got {err:?}"); -} diff --git a/src/sdk/src/workflows/dispatch_error.rs b/src/sdk/src/workflows/dispatch_error.rs new file mode 100644 index 000000000..46976fbd0 --- /dev/null +++ b/src/sdk/src/workflows/dispatch_error.rs @@ -0,0 +1,35 @@ +//! Turning a hub dispatch failure into a workflow failure. +//! +//! The one piece of [`WorkflowError`] that could not move to the engine crate +//! with the rest of the stored model: [`crate::hub::RunError`] is Medulla's, and +//! the engine has never heard of a harness fleet. It stays here as a single +//! `From` impl, which the orphan rule permits because the type being converted +//! *from* is local to this crate. + +use tinyflows::store::WorkflowError; + +use crate::hub::RunError; + +impl From for WorkflowError { + /// Preserve the shape of a dispatch failure rather than flattening it. + /// + /// The hub already distinguishes these; collapsing them into one string + /// made a missing harness and a deliberate abort read identically at every + /// call site above. + fn from(err: RunError) -> Self { + match err { + RunError::Timeout => Self::DispatchTimeout, + RunError::Aborted => Self::DispatchAborted, + RunError::Worker(message) => Self::Harness(message), + // A failed node's transcript is the step's own account — the + // workflow error that surfaces the failure carries only the message. + RunError::WorkerWithTranscript { message, .. } => Self::Harness(message), + RunError::Busy(message) => Self::Unreachable(message), + // Same shape as backpressure from a workflow's point of view: the + // harness exists and is fine, it simply cannot be reached for this + // run. A workflow has no operator to hand anything back to. + RunError::Held(message) => Self::Unreachable(message), + RunError::Transport(message) => Self::Unreachable(message), + } + } +} diff --git a/src/sdk/src/workflows/evolve/verify.rs b/src/sdk/src/workflows/evolve/verify.rs index 20fe1a032..fa100593b 100644 --- a/src/sdk/src/workflows/evolve/verify.rs +++ b/src/sdk/src/workflows/evolve/verify.rs @@ -155,8 +155,8 @@ fn sample_inputs( /// Whether every blocking candidate finding already existed in the baseline. fn has_no_new_blockers( - baseline: &crate::workflows::run::diagnose::Diagnosis, - candidate: &crate::workflows::run::diagnose::Diagnosis, + baseline: &crate::workflows::run::Diagnosis, + candidate: &crate::workflows::run::Diagnosis, ) -> bool { candidate .null_bindings diff --git a/src/sdk/src/workflows/gates/bindings.rs b/src/sdk/src/workflows/gates/bindings.rs deleted file mode 100644 index c8fc0bd79..000000000 --- a/src/sdk/src/workflows/gates/bindings.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Reading the `=`-expressions out of a graph, and deciding which of them are -//! already known to be wrong. -//! -//! Everything here is a pure function of the graph. That is what makes it a -//! *gate* rather than a diagnostic: it runs before a write, costs nothing, and -//! can refuse an edit outright. - -use serde_json::Value; -use tinyflows::model::{NodeKind, WorkflowGraph}; - -/// Node kinds whose output is wrapped in the engine's `{json, text, raw}` -/// envelope. -/// -/// The distinction the envelope creates is the single most common way a graph -/// compiles, validates, dry-runs green, and then does nothing: `=nodes.x.item.f` -/// reads a field that lives at `=nodes.x.item.json.f`, so it resolves to null -/// and the step runs with an empty value. -const ENVELOPING_KINDS: [NodeKind; 3] = - [NodeKind::Agent, NodeKind::ToolCall, NodeKind::HttpRequest]; - -/// jq keywords that read as syntax rather than as prose. -/// -/// Used by [`reads_as_prose`] so a genuine jq program — `if`/`then`/`else`, -/// `reduce`, a `def` — is never mistaken for natural language. -const JQ_KEYWORDS: &[&str] = &[ - "and", "or", "not", "if", "then", "elif", "else", "end", "as", "def", "reduce", "foreach", - "try", "catch", "import", "include", "label", -]; - -/// Every `=`-expression in `value`, paired with its dotted location. -/// -/// Walks objects and arrays, so a binding nested inside a tool call's `args` -/// is found and can be named precisely in an error an author has to act on. -pub fn collect_expressions(value: &Value) -> Vec<(String, String)> { - fn walk(value: &Value, location: &str, out: &mut Vec<(String, String)>) { - match value { - Value::Object(map) => { - for (key, child) in map { - let path = if location.is_empty() { - key.clone() - } else { - format!("{location}.{key}") - }; - walk(child, &path, out); - } - } - Value::Array(items) => { - for (index, child) in items.iter().enumerate() { - let path = if location.is_empty() { - index.to_string() - } else { - format!("{location}.{index}") - }; - walk(child, &path, out); - } - } - Value::String(text) if tinyflows::expr::is_expression(text) => { - out.push((location.to_string(), text.clone())); - } - _ => {} - } - } - let mut out = Vec::new(); - walk(value, "", &mut out); - out -} - -/// One node-output binding, taken apart. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NodeBinding { - /// The node whose output is being read. - pub node_id: String, - /// Whether the expression went through the `{json, text, raw}` envelope. - pub through_envelope: bool, - /// The remaining dotted path, whole — `data.messages`, not `data`. - pub field_path: String, -} - -/// Parse `=nodes..item[.json].`, if the expression is one. -/// -/// Deliberately narrow. An expression this does not match is not reported on at -/// all, because a gate that guessed at arbitrary jq would refuse graphs that are -/// fine — and a false refusal costs an author their edit. -pub fn parse_node_binding(expr: &str) -> Option { - fn pattern() -> &'static regex::Regex { - static RE: std::sync::OnceLock = std::sync::OnceLock::new(); - RE.get_or_init(|| { - regex::Regex::new( - r"^=nodes\.([A-Za-z_][A-Za-z0-9_]*)\.item(?:\.(json))?\.([A-Za-z_][A-Za-z0-9_.]*)", - ) - .expect("static regex is valid") - }) - } - let captures = pattern().captures(expr)?; - let field_path = captures.get(3)?.as_str().trim_end_matches('.').to_string(); - if field_path.is_empty() { - return None; - } - Some(NodeBinding { - node_id: captures.get(1)?.as_str().to_string(), - through_envelope: captures.get(2).is_some(), - field_path, - }) -} - -/// Whether an expression body reads as prose rather than as a jq program. -/// -/// The failure this catches is specific and common: an author writes -/// `"=You are given an issue: .item. Summarise it"` in a node's `prompt`, -/// believing `=` interpolates. It does not — jq has no rule for two bare words -/// in a row — so the whole expression resolves to null and the step runs with an -/// empty prompt. Nothing else notices: it parses, it validates, and it produces -/// a plausible-looking run. -/// -/// Best-effort by construction, and biased toward *not* firing: two consecutive -/// bare alphabetic words outside a string literal, none of them jq keywords. -pub fn reads_as_prose(expr_body: &str) -> bool { - // String literals are stripped first: `"two words"` inside a real jq - // program is data, not prose, and would otherwise trip the scan. - let mut stripped = String::with_capacity(expr_body.len()); - let mut in_string = false; - let mut chars = expr_body.chars(); - while let Some(c) = chars.next() { - // An escaped character inside a literal — consume both, so `\"` does - // not read as the end of the string. - if in_string && c == '\\' { - chars.next(); - continue; - } - if c == '"' { - in_string = !in_string; - continue; - } - if !in_string { - stripped.push(c); - } - } - - let mut consecutive = 0u32; - for token in stripped.split_whitespace() { - let core = token.trim_matches(|c: char| !c.is_ascii_alphabetic()); - let bare = !core.is_empty() - && core.chars().all(|c| c.is_ascii_alphabetic()) - && !token.starts_with('.') - && !token.contains('.') - && !JQ_KEYWORDS.contains(&core.to_ascii_lowercase().as_str()); - if bare { - consecutive += 1; - if consecutive >= 2 { - return true; - } - } else { - consecutive = 0; - } - } - false -} - -/// Whether `kind` wraps its output in the `{json, text, raw}` envelope. -pub fn wraps_output(kind: &NodeKind) -> bool { - ENVELOPING_KINDS.contains(kind) -} - -/// A node kind named the way an error message should name it. -pub fn kind_article(kind: &NodeKind) -> &'static str { - match kind { - NodeKind::Agent => "an agent", - NodeKind::ToolCall => "a tool_call", - NodeKind::HttpRequest => "an http_request", - _ => "a node", - } -} - -/// The node with `id`, if the graph has one. -pub fn node_of<'a>(graph: &'a WorkflowGraph, id: &str) -> Option<&'a tinyflows::model::Node> { - graph.nodes.iter().find(|node| node.id == id) -} diff --git a/src/sdk/src/workflows/gates/mod.rs b/src/sdk/src/workflows/gates/mod.rs index 49cb2773a..a3bdf7d70 100644 --- a/src/sdk/src/workflows/gates/mod.rs +++ b/src/sdk/src/workflows/gates/mod.rs @@ -1,40 +1,25 @@ -//! Checks that run before an authoring write lands. +//! Medulla's authoring gates: the engine's, plus the one about harnesses. //! -//! The engine's own [`validate`](tinyflows::validate) answers "would this -//! compile" — no trigger, an edge to a node that is not there. That is a real -//! bar and it is not the one authors keep failing. The graphs that cost people -//! an afternoon *do* compile: they have a binding that resolves to null at run -//! time, so a step executes with an empty value and the run reports success -//! having done nothing. +//! The gates that are true on any host — a prompt written as a `=`-expression, +//! a binding that reads through the wrong shape, a `code` node naming a language +//! the engine does not distinguish — live in [`tinyflows::gates`] now. They were +//! never about Medulla, and every host embedding the engine loses the same +//! afternoons to the same graphs. //! -//! Nothing downstream catches that. A null is a legal value, so the engine has -//! no complaint; the run record shows every node green. The only place it can be -//! caught is here, before the write, while there is still an author on the other -//! end to tell. -//! -//! Two rules the gates hold themselves to: -//! -//! - **Refuse only what is *guaranteed* wrong.** A gate that fires on a graph -//! that would have worked costs an author their edit and teaches them to -//! distrust the tool. Everything merely suspicious belongs in the dry run's -//! diagnostics ([`crate::workflows::ops::dry_run`]), which advise rather than -//! refuse. -//! - **Say what to do.** Every message names the node, the binding, and the -//! correction. The reader is usually an agent with one round trip to spend. -//! -//! Adapted from the sibling `openhuman` host's gate stack, minus the parts that -//! are about its integration registry rather than about graphs. +//! What is left here is the gate that *needs* this host's vocabulary: which +//! harnesses exist, and the refusal of a harness chosen by a `=`-expression. +//! [`failures`] runs both, and [`MedullaPolicy`] is how a store applies them to +//! every write. -mod bindings; mod harness; -pub use bindings::{collect_expressions, parse_node_binding, reads_as_prose, NodeBinding}; /// The harness-choice gate alone, re-exported so a run boundary can re-check /// it against a persisted graph that may never have passed through an /// authoring write — see [`crate::workflows::run`]'s use of it. pub use harness::failures as harness_failures; -use tinyflows::model::{NodeKind, WorkflowGraph}; +use tinyflows::model::WorkflowGraph; +use tinyflows::store::{gate_failures_into_error, HostPolicy, WorkflowDefaults}; use crate::workflows::WorkflowError; @@ -48,134 +33,39 @@ use crate::workflows::WorkflowError; /// Returns [`WorkflowError::Invalid`] listing every failure. An empty result is /// a pass. pub fn check(id: &str, graph: &WorkflowGraph) -> Result<(), WorkflowError> { - let messages = failures(graph); - if messages.is_empty() { - return Ok(()); - } - Err(WorkflowError::Invalid { - id: id.to_string(), - messages, - }) + gate_failures_into_error(id, failures(graph)) } -/// Every gate failure in `graph`. +/// Every gate failure in `graph`: the engine's, then this host's. +#[must_use] pub fn failures(graph: &WorkflowGraph) -> Vec { - let mut failures = agent_prompt_failures(graph); - failures.extend(binding_failures(graph)); - failures.extend(code_language_failures(graph)); + let mut failures = tinyflows::gates::failures(graph); failures.extend(harness::failures(graph)); failures } -/// `code` nodes whose language the engine will not read the way it was written. +/// The rules Medulla judges a workflow document and an authoring write by. /// -/// The engine matches the literal string `"python"` and treats *everything else* -/// as JavaScript — silently. So `"language": "python3"` runs a Python program -/// through node, and `"language": "shell"` runs a shell script through node. -/// Both fail with a syntax error from an interpreter the author never named, -/// which is among the least helpful failures this host can produce. -/// -/// Refused here rather than documented, because documentation does not stop a -/// plausible spelling. -fn code_language_failures(graph: &WorkflowGraph) -> Vec { - // The two the engine actually distinguishes. Not `ScriptLanguage::NAMES`: - // that includes `shell`, which this node kind cannot reach. - const ACCEPTED: [&str; 2] = ["javascript", "python"]; +/// Both halves exist for the same reason: the engine holds `defaults.harness` +/// and an `agent` node's `harness` as opaque strings, because which harnesses +/// exist is this host's vocabulary and not the engine's. A store carrying this +/// policy refuses a bad one at the boundary — at load, and before a write lands +/// — rather than minutes into a run, after the steps before it have had their +/// effects. +#[derive(Debug, Clone, Copy, Default)] +pub struct MedullaPolicy; - let mut failures = Vec::new(); - for node in &graph.nodes { - if node.kind != NodeKind::Code { - continue; - } - let Some(language) = node.config.get("language").and_then(|v| v.as_str()) else { - // Absent is legal and means JavaScript, which the engine's own - // default already says. - continue; - }; - if ACCEPTED.contains(&language) { - continue; - } - let hint = if crate::flow_engine::caps::script::ScriptLanguage::parse(language) - == Some(crate::flow_engine::caps::script::ScriptLanguage::Shell) - { - " A `code` node cannot run shell: use a `tool_call` with the `medulla:shell` slug, \ - which runs in the operator's project directory." - } else { - "" - }; - failures.push(format!( - "node '{}': `language` is `{language}`, which this engine does not recognise — it \ - matches only the exact strings `javascript` and `python`, and silently treats \ - anything else as JavaScript. Your program would be run through node and fail with a \ - syntax error naming an interpreter you did not choose.{hint}", - node.id - )); +impl HostPolicy for MedullaPolicy { + fn check_defaults(&self, defaults: &WorkflowDefaults) -> Result<(), String> { + crate::workflows::store::preference(defaults).map(|_| ()) } - failures -} -/// Agent nodes whose `prompt` is prose written as an expression. -/// -/// The node would run with an empty instruction — for Medulla that means -/// dispatching a whole harness session with nothing to do. -fn agent_prompt_failures(graph: &WorkflowGraph) -> Vec { - let mut failures = Vec::new(); - for node in &graph.nodes { - if node.kind != NodeKind::Agent { - continue; - } - // `instruction` is the alias the rest of Medulla uses for the same - // field, and the engine accepts it, so it has to be checked too. - for key in ["prompt", "instruction"] { - let Some(text) = node.config.get(key).and_then(|v| v.as_str()) else { - continue; - }; - if !tinyflows::expr::is_expression(text) { - continue; - } - if reads_as_prose(text[1..].trim()) { - failures.push(format!( - "node '{}': `{key}` (`{text}`) reads as an instruction written as a \ - `=`-expression, not as a jq program. `=` does not interpolate — the whole \ - thing resolves to null and the node dispatches a harness session with an \ - empty prompt. Fix: drop the leading `=` and write the instruction plainly, \ - referring to upstream data with a separate `=` binding.", - node.id - )); - } - } - } - failures -} - -/// Bindings that read a node's output through the wrong shape. -fn binding_failures(graph: &WorkflowGraph) -> Vec { - let mut failures = Vec::new(); - for node in &graph.nodes { - for (location, expr) in collect_expressions(&node.config) { - let Some(binding) = parse_node_binding(&expr) else { - continue; - }; - // A binding to a node that does not exist is the engine's to - // report, and it already does. - let Some(target) = bindings::node_of(graph, &binding.node_id) else { - continue; - }; - if bindings::wraps_output(&target.kind) && !binding.through_envelope { - failures.push(format!( - "node '{}': `{location}` (`{expr}`) reads `.item.{path}` from {article} node \ - `{target_id}`, whose output is wrapped as {{json, text, raw}} — so this \ - resolves to null at run time and the step gets nothing. Fix: \ - `=nodes.{target_id}.item.json.{path}`.", - node.id, - path = binding.field_path, - article = bindings::kind_article(&target.kind), - target_id = binding.node_id, - )); - } - } + fn check_graph(&self, id: &str, graph: &WorkflowGraph) -> Result<(), WorkflowError> { + // `failures` above, not just the harness gate: overriding this replaces + // the engine's default wholesale, so dropping `tinyflows::gates` here + // would silently stop catching everything it catches. + check(id, graph) } - failures } #[cfg(test)] diff --git a/src/sdk/src/workflows/gates/tests.rs b/src/sdk/src/workflows/gates/tests.rs index 40fc4b95a..7d608bd57 100644 --- a/src/sdk/src/workflows/gates/tests.rs +++ b/src/sdk/src/workflows/gates/tests.rs @@ -1,12 +1,19 @@ -//! Tests for the authoring gates. +//! Tests for the harness gate, and for the composition around it. //! //! Two obligations, and the second is the one that keeps a gate trustworthy: //! it fires on the graph that is guaranteed broken, and it stays silent on //! everything else. A gate with false positives costs authors their edits and //! teaches them to route around it. +//! +//! The host-agnostic gates are tested in `tinyflows::gates`, next to the code. +//! What is checked here is the harness gate — which needs this host's list of +//! harnesses — and that [`failures`] still runs the engine's gates as well as +//! this one. -use super::*; use serde_json::json; +use tinyflows::model::WorkflowGraph; + +use super::*; /// A graph from a node list, with no edges — the gates read configs, not /// topology, and a trigger would only be noise here. @@ -15,256 +22,6 @@ fn graph(nodes: serde_json::Value) -> WorkflowGraph { .expect("graph parses") } -// ---- prompts written as expressions ---- - -#[test] -fn an_instruction_written_as_an_expression_is_refused() { - let graph = graph(json!([ - { "id": "work", "kind": "agent", "name": "Work", - "config": { "prompt": "=You are given an issue: .item. Summarise it" } }, - ])); - - let failures = failures(&graph); - - // `=` does not interpolate. The whole expression resolves to null and the - // node dispatches a harness session with nothing to do. - assert_eq!(failures.len(), 1, "{failures:?}"); - assert!(failures[0].contains("does not interpolate"), "{failures:?}"); - assert!(failures[0].contains("work"), "the node has to be named"); -} - -#[test] -fn the_instruction_alias_is_checked_too() { - // `instruction` is what the rest of Medulla calls the same field, and the - // engine accepts it — so a gate that only read `prompt` would miss half of - // what authors actually write. - let graph = graph(json!([ - { "id": "work", "kind": "agent", "name": "Work", - "config": { "instruction": "=Look at the diff and fix it" } }, - ])); - - assert_eq!(failures(&graph).len(), 1, "{:?}", failures(&graph)); -} - -#[test] -fn a_plain_instruction_is_left_alone() { - let graph = graph(json!([ - { "id": "work", "kind": "agent", "name": "Work", - "config": { "prompt": "You are given an issue. Summarise it." } }, - ])); - - assert!(failures(&graph).is_empty()); -} - -#[test] -fn a_real_expression_is_not_mistaken_for_prose() { - for expr in [ - "=.item.text", - "=nodes.fetch.item.json.title", - "=if .item.ok then .item.text else \"none\" end", - "=.item.issues | map(.title) | join(\", \")", - "=\"Summarise this issue for me\"", - ] { - let graph = graph(json!([ - { "id": "work", "kind": "agent", "name": "Work", - "config": { "prompt": expr } }, - ])); - - assert!( - failures(&graph).is_empty(), - "{expr} is valid jq and must not be refused: {:?}", - failures(&graph) - ); - } -} - -// ---- the output envelope ---- - -#[test] -fn reading_an_agents_output_without_the_envelope_is_refused() { - let graph = graph(json!([ - { "id": "fetch", "kind": "agent", "name": "Fetch", "config": { "prompt": "get it" } }, - { "id": "notify", "kind": "tool_call", "name": "Notify", - "config": { "slug": "medulla:echo", "args": { "text": "=nodes.fetch.item.title" } } }, - ])); - - let failures = failures(&graph); - - assert_eq!(failures.len(), 1, "{failures:?}"); - assert!(failures[0].contains("args.text"), "{failures:?}"); - // The message has to carry the correction, not just the complaint. - assert!( - failures[0].contains("=nodes.fetch.item.json.title"), - "{failures:?}" - ); -} - -#[test] -fn reading_through_the_envelope_is_accepted() { - let graph = graph(json!([ - { "id": "fetch", "kind": "agent", "name": "Fetch", "config": { "prompt": "get it" } }, - { "id": "notify", "kind": "tool_call", "name": "Notify", - "config": { "slug": "medulla:echo", - "args": { "text": "=nodes.fetch.item.json.title" } } }, - ])); - - assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); -} - -#[test] -fn a_node_kind_that_does_not_wrap_its_output_is_read_directly() { - // A transform's output is the item itself, so `.item.` is correct - // there and refusing it would be a false positive. - let graph = graph(json!([ - { "id": "shape", "kind": "transform", "name": "Shape", - "config": { "set": { "title": "=.item.name" } } }, - { "id": "notify", "kind": "tool_call", "name": "Notify", - "config": { "slug": "medulla:echo", "args": { "text": "=nodes.shape.item.title" } } }, - ])); - - assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); -} - -#[test] -fn a_binding_nested_deep_inside_args_is_still_found_and_named() { - let graph = graph(json!([ - { "id": "fetch", "kind": "agent", "name": "Fetch", "config": { "prompt": "get it" } }, - { "id": "notify", "kind": "tool_call", "name": "Notify", - "config": { "slug": "medulla:echo", "args": { - "blocks": [{ "fields": { "value": "=nodes.fetch.item.title" } }] } } }, - ])); - - let failures = failures(&graph); - - assert_eq!(failures.len(), 1, "{failures:?}"); - // Named precisely enough for an author to find it in a large config. - assert!( - failures[0].contains("args.blocks.0.fields.value"), - "{failures:?}" - ); -} - -#[test] -fn a_binding_to_a_node_that_does_not_exist_is_left_to_the_engine() { - let graph = graph(json!([ - { "id": "notify", "kind": "tool_call", "name": "Notify", - "config": { "slug": "medulla:echo", "args": { "text": "=nodes.ghost.item.title" } } }, - ])); - - // The engine already reports a reference to a node that is not there; - // saying it twice in different words helps nobody. - assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); -} - -#[test] -fn an_expression_that_is_not_a_node_binding_is_not_second_guessed() { - let graph = graph(json!([ - { "id": "notify", "kind": "tool_call", "name": "Notify", - "config": { "slug": "medulla:echo", - "args": { "text": "=.item.text | ascii_downcase", - "count": "=run.trigger.n" } } }, - ])); - - // A gate that guessed at arbitrary jq would refuse graphs that work. - assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); -} - -// ---- the error surface ---- - -#[test] -fn check_reports_every_failure_at_once_rather_than_the_first() { - let graph = graph(json!([ - { "id": "fetch", "kind": "agent", "name": "Fetch", - "config": { "prompt": "=Go and fetch the issues" } }, - { "id": "notify", "kind": "tool_call", "name": "Notify", - "config": { "slug": "medulla:echo", "args": { "text": "=nodes.fetch.item.title" } } }, - ])); - - let err = check("sweep", &graph).expect_err("both are wrong"); - - let WorkflowError::Invalid { id, messages } = err else { - panic!("expected Invalid"); - }; - assert_eq!(id, "sweep"); - // One round trip has to tell an agent everything, or it spends a turn per - // mistake. - assert_eq!(messages.len(), 2, "{messages:?}"); -} - -#[test] -fn a_clean_graph_passes() { - let graph = graph(json!([ - { "id": "t", "kind": "trigger", "name": "Start", - "config": { "trigger_kind": "manual" } }, - { "id": "work", "kind": "agent", "name": "Work", - "config": { "prompt": "summarise the open issues" } }, - ])); - - assert!(check("sweep", &graph).is_ok()); -} - -// ---- code node languages ---- - -#[test] -fn a_code_node_asking_for_shell_is_refused_and_pointed_at_the_shell_tool() { - let graph = graph(json!([ - { "id": "compute", "kind": "code", "name": "Compute", - "config": { "language": "shell", "source": "echo hi" } }, - ])); - - let failures = failures(&graph); - - // The engine treats anything but the literal "python" as JavaScript, so - // this would run a shell script through node and fail with a syntax error - // naming an interpreter the author never chose. - assert_eq!(failures.len(), 1, "{failures:?}"); - assert!(failures[0].contains("medulla:shell"), "{failures:?}"); -} - -#[test] -fn a_near_miss_language_spelling_is_refused_rather_than_silently_becoming_javascript() { - for spelling in ["python3", "py", "js", "node"] { - let graph = graph(json!([ - { "id": "compute", "kind": "code", "name": "Compute", - "config": { "language": spelling, "source": "print(1)" } }, - ])); - - assert_eq!( - failures(&graph).len(), - 1, - "{spelling} must not silently become javascript" - ); - } -} - -#[test] -fn the_two_spellings_the_engine_actually_reads_are_accepted() { - for spelling in ["javascript", "python"] { - let graph = graph(json!([ - { "id": "compute", "kind": "code", "name": "Compute", - "config": { "language": spelling, "source": "x" } }, - ])); - - assert!( - failures(&graph).is_empty(), - "{spelling} is exactly what the engine matches: {:?}", - failures(&graph) - ); - } -} - -#[test] -fn a_code_node_that_names_no_language_is_left_alone() { - let graph = graph(json!([ - { "id": "compute", "kind": "code", "name": "Compute", - "config": { "source": "console.log(1)" } }, - ])); - - // Absent is legal and means JavaScript, which the engine's own default - // already says — refusing it would be a false positive. - assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); -} - // ---- harness and model selection ---- #[test] @@ -353,3 +110,46 @@ fn a_harness_on_a_node_that_is_not_an_agent_is_not_this_gate_s_business() { assert!(failures(&graph).is_empty()); } + +// ---- composition ---- + +#[test] +fn the_engines_own_gates_still_run_alongside_the_harness_one() { + // The trap in overriding `HostPolicy::check_graph`: replacing the default + // wholesale is easy, and it would silently stop catching everything + // `tinyflows::gates` catches. One graph, one failure from each side. + let graph = graph(json!([ + { "id": "fetch", "kind": "agent", "name": "Fetch", + "config": { "prompt": "=Go and fetch the issues" } }, + { "id": "work", "kind": "agent", "name": "Work", + "config": { "prompt": "go", "harness": "claude code" } }, + ])); + + let failures = failures(&graph); + + assert_eq!(failures.len(), 2, "{failures:?}"); + assert!( + failures.iter().any(|f| f.contains("does not interpolate")), + "the engine's prompt gate must still run: {failures:?}" + ); + assert!( + failures.iter().any(|f| f.contains("custom harness id")), + "this host's harness gate must run: {failures:?}" + ); +} + +#[test] +fn check_turns_the_combined_list_into_one_error() { + let graph = graph(json!([ + { "id": "work", "kind": "agent", "name": "Work", + "config": { "prompt": "go", "harness": "claude code" } }, + ])); + + let err = check("sweep", &graph).expect_err("refused"); + + let WorkflowError::Invalid { id, messages } = err else { + panic!("expected Invalid"); + }; + assert_eq!(id, "sweep"); + assert_eq!(messages.len(), 1, "{messages:?}"); +} diff --git a/src/sdk/src/workflows/mod.rs b/src/sdk/src/workflows/mod.rs index 14f6d15b0..f8669aee5 100644 --- a/src/sdk/src/workflows/mod.rs +++ b/src/sdk/src/workflows/mod.rs @@ -22,9 +22,9 @@ //! The store is behind the [`WorkflowStore`] trait, so a remote catalog is a new //! implementation rather than a rewrite. -pub mod authoring; pub mod bridge; pub mod copilot; +mod dispatch_error; pub mod evolve; pub mod gates; pub mod local; @@ -36,16 +36,16 @@ pub mod report; pub mod run; pub mod skills; pub mod store; -mod types; pub mod workspace; +#[cfg(test)] +#[path = "authoring_tests.rs"] +mod authoring_tests; #[cfg(test)] mod tests; -pub use authoring::{ - apply_workflow_ops, apply_workflow_ops_if_unchanged, create_workflow, preview_workflow_ops, - validate_handle, GraphHandle, -}; +// Patch-based editing moved to the engine crate with the store it writes to; +// the gates an edit is judged by ride along on the store's own policy. pub use bridge::{cancel_task_workflow, run_task_workflow, StoreWorkflowBridge}; pub use copilot::{CopilotOutcome, CopilotRequest, CopilotSession, FailedRun}; pub use local::{LocalCopilotDispatch, LocalWorkflowHost, LOCAL_WORKER_ADDRESS}; @@ -55,9 +55,13 @@ pub use registry::StoreWorkflowResolver; pub use report::RunReporter; pub use run::{dry_run, resume_workflow, run_workflow, run_workflow_versioned, RunContext}; pub use store::{ - current_notes, mint_note_id, mint_proposal_id, new_run_record, parse_workflow, require, - require_proposal, require_run, rollback, undo_last, validate_graph, FileWorkflowStore, - LoadReport, WorkflowStore, MAX_NOTES, MAX_REVISIONS, + bounded_evidence, bounded_within, current_notes, mint_note_id, mint_proposal_id, + new_run_record, parse_workflow, require, require_proposal, require_run, rollback, undo_last, + validate_graph, LoadReport, WorkflowStore, MAX_NOTES, MAX_REVISIONS, +}; +pub use tinyflows::store::{ + apply_workflow_ops, apply_workflow_ops_if_unchanged, create_workflow, preview_workflow_ops, + validate_handle, GraphHandle, }; // The engine's own graph model, re-exported so hosts above this crate (the TUI) // can name a workflow's graph without taking a direct dependency on the engine. @@ -67,10 +71,18 @@ pub use store::{ // input, and a second declaration of that shape would be free to disagree with // the engine's about what a `number` accepts. pub use tinyflows::model::{InputType, WorkflowGraph, WorkflowInput}; -pub(crate) use types::bounded_evidence; -pub use types::{ - fingerprint, record_fingerprint, NoteId, NoteKind, NoteSource, ProposalId, ProposalStatus, - ProposalVerification, RunId, RunOrigin, RunRecord, RunStatus, RunStep, WorkflowDefaults, - WorkflowError, WorkflowId, WorkflowNote, WorkflowProposal, WorkflowRecord, WorkflowRevision, - WorkflowSummary, +// The stored model moved to the engine crate with the store that persists it. +// Re-exported unchanged so a call site in this crate — and the TUI above it — +// still writes `crate::workflows::WorkflowRecord`. +pub use gates::MedullaPolicy; +pub use store::{fingerprint, record_fingerprint}; +pub use store::{preference as defaults_preference, with_medulla_policy}; +// Aliased as well as re-exported, so `workflows::authoring::…` — the path this +// crate already used everywhere — keeps resolving after the move. +pub use tinyflows::store::authoring; +pub use tinyflows::store::{ + Diagnosis, FileWorkflowStore, NoteId, NoteKind, NoteSource, ProposalId, ProposalStatus, + ProposalVerification, RunId, RunOrigin, RunRecord, RunStatus, RunStep, TranscriptEntry, + WorkflowDefaults, WorkflowError, WorkflowId, WorkflowNote, WorkflowProposal, WorkflowRecord, + WorkflowRevision, WorkflowSummary, }; diff --git a/src/sdk/src/workflows/ops/graph.rs b/src/sdk/src/workflows/ops/graph.rs index fd43a1c70..4d38f9e7a 100644 --- a/src/sdk/src/workflows/ops/graph.rs +++ b/src/sdk/src/workflows/ops/graph.rs @@ -82,13 +82,12 @@ pub(crate) fn set_defaults_observed( if let Some(model) = model { record.defaults.model = Some(model.trim().to_string()).filter(|s| !s.is_empty()); } - record - .defaults - .preference() - .map_err(|message| WorkflowError::Invalid { + crate::workflows::defaults_preference(&record.defaults).map_err(|message| { + WorkflowError::Invalid { id: id.to_string(), messages: vec![format!("`defaults`: {message}")], - })?; + } + })?; Ok(()) }, observer, diff --git a/src/sdk/src/workflows/ops/mod.rs b/src/sdk/src/workflows/ops/mod.rs index 9fdb281fe..cfecae414 100644 --- a/src/sdk/src/workflows/ops/mod.rs +++ b/src/sdk/src/workflows/ops/mod.rs @@ -29,7 +29,7 @@ use std::sync::Arc; use serde_json::{json, Value}; use tinyflows::graph_ops::GraphOp; -use crate::workflows::{FileWorkflowStore, WorkflowError, WorkflowRecord, WorkflowStore}; +use crate::workflows::{WorkflowError, WorkflowRecord, WorkflowStore}; pub use evolve::{ accept_proposal, add_note, author, evolve, notes, proposals, propose, reject_proposal, @@ -47,7 +47,7 @@ pub use runs::{ /// The store every operation reads and writes, discovered for this environment. pub fn discover_store(env: &HashMap, cwd: &Path) -> Arc { - Arc::new(FileWorkflowStore::discover(env, cwd)) + Arc::new(crate::workflows::store::discover(env, cwd)) } /// A record as the document an author sees: the graph, with the host fields diff --git a/src/sdk/src/workflows/ops/runs/tests.rs b/src/sdk/src/workflows/ops/runs/tests.rs index a4aadb2a9..5319c7b1d 100644 --- a/src/sdk/src/workflows/ops/runs/tests.rs +++ b/src/sdk/src/workflows/ops/runs/tests.rs @@ -67,7 +67,7 @@ fn a_summary_bounds_an_output_that_would_swamp_the_reply() { let run = get_run(&store, "run-1", StepDetail::Summary).unwrap(); let output = &run["steps"][0]["output"]; - assert_eq!(output["_medullaTruncated"], true); + assert!(tinyflows::store::is_truncated(output)); assert!(output["preview"].as_str().unwrap().starts_with("\"oo")); // The point of the level: whatever the step emitted, the projection is // small enough that a caller can hold a hundred of them. diff --git a/src/sdk/src/workflows/ops/runs/view.rs b/src/sdk/src/workflows/ops/runs/view.rs index 8f1c95023..7c3918f1f 100644 --- a/src/sdk/src/workflows/ops/runs/view.rs +++ b/src/sdk/src/workflows/ops/runs/view.rs @@ -13,7 +13,7 @@ use serde_json::{json, Map, Value}; -use crate::workflows::types::bounded_within; +use crate::workflows::bounded_within; use crate::workflows::{RunRecord, RunStep, WorkflowError}; /// Bytes of one step's output kept by [`StepDetail::Summary`]. diff --git a/src/sdk/src/workflows/registry.rs b/src/sdk/src/workflows/registry.rs index 907b9077e..09c43615d 100644 --- a/src/sdk/src/workflows/registry.rs +++ b/src/sdk/src/workflows/registry.rs @@ -129,7 +129,7 @@ fn apply_defaults(graph: &mut WorkflowGraph, defaults: &WorkflowDefaults) { if defaults.is_empty() { return; } - let Ok(workflow_layer) = defaults.preference() else { + let Ok(workflow_layer) = crate::workflows::defaults_preference(defaults) else { // Cannot happen for a record that passed authoring validation, but a // resolver has no business panicking over a corrupted document. return; diff --git a/src/sdk/src/workflows/run/diagnose.rs b/src/sdk/src/workflows/run/diagnose.rs deleted file mode 100644 index 7677d94d1..000000000 --- a/src/sdk/src/workflows/run/diagnose.rs +++ /dev/null @@ -1,304 +0,0 @@ -//! Reading a simulation's steps for the failures a green run hides. -//! -//! A dry run that "passes" proves less than it looks like it does. Every node -//! ran, every node returned, the outcome is a JSON object — and a step whose -//! only input expression resolved to `null` looks exactly the same as one that -//! got what it needed. Null is a legal value: the engine has no complaint, the -//! run record is all green, and the workflow does nothing when it runs for real. -//! -//! So the point of a dry run at authoring time is not the outcome. It is the -//! *steps*, and specifically four things in them that the outcome cannot say: -//! -//! - a binding that resolved to null, -//! - an `agent` node that would dispatch a harness session with an empty prompt, -//! - a node that errored but whose `on_error` policy swallowed it, -//! - a node that never ran at all because a condition routed the sample past it. -//! -//! The last two are the ones a naive reading misses. An error hidden by -//! `on_error: continue` leaves a step marked failed with *empty* diagnostics, so -//! a check that only looked at diagnostics sees nothing; and a node that never -//! executed produces no step at all, so a check that only walked the steps it -//! got would report a clean run on a graph where half the work was skipped. -//! -//! # What this cannot see -//! -//! The engine traces null-resolved expressions for `agent`, `tool_call`, and -//! `http_request` nodes only — the kinds that hand a resolved config to -//! something outside itself. A `transform` or `condition` whose expression -//! resolves to null emits no diagnostic, so a transform that quietly sets a -//! field to null passes here. That is a real gap and the reason the *gates* -//! ([`crate::workflows::gates`]) exist alongside this: they read the graph -//! statically and catch the shapes that are wrong before anything runs. -//! -//! Adapted from the sibling `openhuman` host's dry-run diagnostics. - -use std::collections::{HashSet, VecDeque}; -use std::sync::{Arc, Mutex}; - -use serde::{Deserialize, Serialize}; -use tinyflows::model::{NodeKind, WorkflowGraph}; -use tinyflows::observability::{ExecutionStep, Run, RunObserver, StepStatus}; - -/// Collects every step a run reports, for reading once it settles. -/// -/// The whole of the observer a simulation needs: no events, no progress, no -/// plan — just the record. -#[derive(Default)] -pub struct CapturingObserver { - steps: Mutex>, -} - -impl CapturingObserver { - /// The steps captured so far, in completion order. - pub fn steps(&self) -> Vec { - self.steps.lock().expect("steps lock").clone() - } -} - -impl RunObserver for CapturingObserver { - fn on_step_finish(&self, step: &ExecutionStep) { - self.steps.lock().expect("steps lock").push(step.clone()); - } - - fn on_run_finish(&self, _run: &Run) {} -} - -/// One expression that resolved to null during the simulation. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct NullBinding { - /// The node whose config held it. - pub node_id: String, - /// The dotted config location — `args.to`, `args.cc.0`. - pub location: String, - /// The expression as written. - pub expression: String, - /// Whether a dry run can actually settle this. - /// - /// The honest half of the diagnostic. When a binding reads from an upstream - /// node the sandbox cannot faithfully stand in for — an `agent` node, whose - /// real output is whatever a harness replies — a null here is what the - /// *mock* produced, not proof the wiring is wrong. Saying so is what stops - /// an agent rewiring a correct graph over and over against a check that was - /// never going to go green. - pub unverifiable: bool, - /// The upstream node the binding reads from, when it reads from one. - #[serde(skip_serializing_if = "Option::is_none")] - pub reads_from: Option, - /// What to do about it. - pub suggestion: String, -} - -/// A node that errored where the graph's own error policy hid it. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct HiddenError { - /// The node that failed. - pub node_id: String, - /// What it reported, if anything readable came back. - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -/// A node the simulation never reached. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct NeverRan { - /// The node that did not execute. - pub node_id: String, - /// The condition upstream of it that routed the run elsewhere, if one was - /// found. - #[serde(skip_serializing_if = "Option::is_none")] - pub routed_by: Option, -} - -/// What a simulation says about a graph, beyond whether it completed. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Diagnosis { - /// Bindings that resolved to null. - pub null_bindings: Vec, - /// Agent nodes that would run with an empty instruction. - pub empty_prompts: Vec, - /// Failures the graph's error policy swallowed. - pub hidden_errors: Vec, - /// Nodes the sample never reached. A warning, not a failure. - pub never_ran: Vec, -} - -impl Diagnosis { - /// Whether anything here should stop an author calling the graph done. - /// - /// [`never_ran`](Self::never_ran) is deliberately excluded: a condition - /// routing one sample down one branch is what a condition is *for*, and - /// failing on it would make every branching graph unbuildable. - pub fn is_clean(&self) -> bool { - self.null_bindings.iter().all(|b| b.unverifiable) - && self.empty_prompts.is_empty() - && self.hidden_errors.is_empty() - } -} - -/// Read `steps` against `graph` for what the outcome cannot say. -pub fn diagnose(graph: &WorkflowGraph, steps: &[ExecutionStep]) -> Diagnosis { - let mut diagnosis = Diagnosis::default(); - - for step in steps { - let node = graph.nodes.iter().find(|n| n.id == step.node_id); - - for null in &step.diagnostics { - // An agent node whose *instruction* resolved to null is its own - // class: the node still runs, and dispatches a whole harness - // session with nothing to do. - let is_prompt = matches!(node.map(|n| &n.kind), Some(NodeKind::Agent)) - && matches!(null.location.as_str(), "prompt" | "instruction"); - if is_prompt { - diagnosis.empty_prompts.push(step.node_id.clone()); - continue; - } - diagnosis - .null_bindings - .push(null_binding(graph, &step.node_id, null)); - } - - // An error hidden by `on_error: continue|route` carries no diagnostics - // at all, so it has to be read off the step's status and its output — - // which is the only place the message survives. - if matches!(step.status, StepStatus::Error) { - diagnosis.hidden_errors.push(HiddenError { - node_id: step.node_id.clone(), - message: error_message(&step.output), - }); - } - } - - let ran: HashSet<&str> = steps.iter().map(|s| s.node_id.as_str()).collect(); - for node in &graph.nodes { - // Only the kinds that do outside work are worth reporting. A transform - // that was routed past is not a surprise worth a warning. - if !matches!( - node.kind, - NodeKind::Agent | NodeKind::ToolCall | NodeKind::HttpRequest - ) { - continue; - } - if ran.contains(node.id.as_str()) { - continue; - } - diagnosis.never_ran.push(NeverRan { - node_id: node.id.clone(), - routed_by: upstream_condition(graph, &node.id), - }); - } - - diagnosis -} - -/// Build one null-binding entry, deciding whether a dry run could settle it. -fn null_binding( - graph: &WorkflowGraph, - node_id: &str, - null: &tinyflows::expr::NullResolution, -) -> NullBinding { - let reads_from = super::super::gates::parse_node_binding(&null.expression) - .map(|binding| binding.node_id) - .filter(|id| graph.nodes.iter().any(|n| &n.id == id)); - - // A binding onto an `agent` node cannot be checked here. The sandbox stands - // in for a harness with a canned reply, so a null says the *mock* had no - // such field — not that a real session would not produce one. - let unverifiable = reads_from - .as_deref() - .and_then(|id| graph.nodes.iter().find(|n| n.id == id)) - .is_some_and(|node| node.kind == NodeKind::Agent); - - let suggestion = if unverifiable { - format!( - "reads from agent node `{}`, whose real output is whatever the harness replies — a \ - dry run cannot confirm this field exists. Check it against what you asked that node \ - to produce rather than re-wiring against the sandbox.", - reads_from.clone().unwrap_or_default() - ) - } else { - "resolved to null, so this step ran with an empty value. Check the path against the \ - upstream node's actual output shape; `workflow_catalog` describes each kind's." - .to_string() - }; - - NullBinding { - node_id: node_id.to_string(), - location: null.location.clone(), - expression: null.expression.clone(), - unverifiable, - reads_from, - suggestion, - } -} - -/// The nearest `condition` upstream of `node_id`, walking edges backwards. -/// -/// Named so the warning can say *why* a node was skipped. "`notify` never ran" -/// sends an author looking at `notify`; "`notify` never ran — `check` routed -/// past it" sends them to the node that actually decided. -fn upstream_condition(graph: &WorkflowGraph, node_id: &str) -> Option { - let mut seen: HashSet<&str> = HashSet::from([node_id]); - let mut queue: VecDeque<&str> = VecDeque::from([node_id]); - - while let Some(current) = queue.pop_front() { - for edge in &graph.edges { - if edge.to_node != current { - continue; - } - let from = edge.from_node.as_str(); - if !seen.insert(from) { - continue; - } - if graph - .nodes - .iter() - .any(|n| n.id == from && n.kind == NodeKind::Condition) - { - return Some(from.to_string()); - } - queue.push_back(from); - } - } - None -} - -/// The message an errored step left in its output, if it left a readable one. -fn error_message(output: &serde_json::Value) -> Option { - output - .get("error") - .and_then(|e| { - e.as_str() - .map(str::to_string) - .or_else(|| Some(e.to_string())) - }) - .filter(|message| !message.trim().is_empty()) -} - -/// A [`CapturingObserver`] as the engine's observer handle. -pub fn capturing() -> (Arc, Arc) { - let observer = Arc::new(CapturingObserver::default()); - (observer.clone(), observer as Arc) -} - -/// What one simulation produced. -/// -/// The output and the diagnosis together, because either alone misleads: the -/// output of a graph that did nothing looks like the output of one that worked, -/// and a diagnosis with nothing to show is only meaningful next to a run that -/// actually completed. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DryRun { - /// The final run state: every node's output, keyed by node id. - pub output: serde_json::Value, - /// What the steps said on the way. - pub diagnosis: Diagnosis, -} - -#[cfg(test)] -#[path = "diagnose_tests.rs"] -mod tests; diff --git a/src/sdk/src/workflows/run/diagnose_tests.rs b/src/sdk/src/workflows/run/diagnose_tests.rs deleted file mode 100644 index 6277b709a..000000000 --- a/src/sdk/src/workflows/run/diagnose_tests.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Tests for reading a simulation's steps. -//! -//! The steps are synthesised rather than produced by a real run: what is under -//! test is the *reading*, and building a graph that makes the engine emit a -//! particular diagnostic would be testing the engine instead. The one case that -//! needs a real run — that a green outcome can still hide a null — is covered -//! end-to-end in `ops_tests`. - -use super::*; -use serde_json::json; - -fn graph(nodes: serde_json::Value, edges: serde_json::Value) -> WorkflowGraph { - serde_json::from_value(json!({ "name": "test", "nodes": nodes, "edges": edges })) - .expect("graph parses") -} - -fn step(node_id: &str, nulls: &[(&str, &str)]) -> ExecutionStep { - ExecutionStep { - node_id: node_id.to_string(), - status: StepStatus::Success, - output: json!({}), - duration_ms: 1, - diagnostics: nulls - .iter() - .map(|(location, expression)| tinyflows::expr::NullResolution { - location: location.to_string(), - expression: expression.to_string(), - }) - .collect(), - } -} - -fn failed(node_id: &str, output: serde_json::Value) -> ExecutionStep { - ExecutionStep { - node_id: node_id.to_string(), - status: StepStatus::Error, - output, - duration_ms: 1, - // The point of this case: an error hidden by an `on_error` policy - // carries no diagnostics at all. - diagnostics: Vec::new(), - } -} - -// ---- null bindings ---- - -#[test] -fn a_binding_that_resolved_to_null_is_reported_with_where_it_was() { - let graph = graph( - json!([ - { "id": "shape", "kind": "transform", "name": "Shape", "config": {} }, - { "id": "notify", "kind": "tool_call", "name": "Notify", "config": {} }, - ]), - json!([]), - ); - - let diagnosis = diagnose( - &graph, - &[step("notify", &[("args.text", "=nodes.shape.item.title")])], - ); - - assert_eq!(diagnosis.null_bindings.len(), 1); - assert_eq!(diagnosis.null_bindings[0].node_id, "notify"); - assert_eq!(diagnosis.null_bindings[0].location, "args.text"); - assert_eq!( - diagnosis.null_bindings[0].reads_from.as_deref(), - Some("shape") - ); - assert!(!diagnosis.is_clean(), "a null binding is not a clean run"); -} - -#[test] -fn a_null_reading_from_an_agent_is_marked_unverifiable_and_does_not_fail_the_run() { - let graph = graph( - json!([ - { "id": "fetch", "kind": "agent", "name": "Fetch", "config": { "prompt": "go" } }, - { "id": "notify", "kind": "tool_call", "name": "Notify", "config": {} }, - ]), - json!([]), - ); - - let diagnosis = diagnose( - &graph, - &[step( - "notify", - &[("args.text", "=nodes.fetch.item.json.title")], - )], - ); - - // The sandbox stands in for a harness with a canned reply, so a null here - // says the mock had no such field — not that a real session would not - // produce one. Failing on it makes an agent re-wire a correct graph. - assert!(diagnosis.null_bindings[0].unverifiable); - assert!(diagnosis.null_bindings[0] - .suggestion - .contains("cannot confirm")); - assert!(diagnosis.is_clean(), "an unverifiable null must not block"); -} - -#[test] -fn a_null_that_reads_from_nothing_is_still_reported_as_checkable() { - let graph = graph( - json!([{ "id": "notify", "kind": "tool_call", "name": "Notify", "config": {} }]), - json!([]), - ); - - let diagnosis = diagnose( - &graph, - &[step("notify", &[("args.text", "=run.trigger.x")])], - ); - - assert!(!diagnosis.null_bindings[0].unverifiable); - assert_eq!(diagnosis.null_bindings[0].reads_from, None); - assert!(!diagnosis.is_clean()); -} - -// ---- empty prompts ---- - -#[test] -fn an_agent_whose_instruction_resolved_to_null_is_its_own_class() { - let graph = graph( - json!([{ "id": "work", "kind": "agent", "name": "Work", "config": {} }]), - json!([]), - ); - - let diagnosis = diagnose( - &graph, - &[step("work", &[("prompt", "=nodes.missing.item.x")])], - ); - - // Not a generic null: this node still runs, and dispatches a whole harness - // session with nothing to do. - assert_eq!(diagnosis.empty_prompts, vec!["work".to_string()]); - assert!(diagnosis.null_bindings.is_empty()); - assert!(!diagnosis.is_clean()); -} - -#[test] -fn the_instruction_alias_counts_as_a_prompt_too() { - let graph = graph( - json!([{ "id": "work", "kind": "agent", "name": "Work", "config": {} }]), - json!([]), - ); - - let diagnosis = diagnose(&graph, &[step("work", &[("instruction", "=.item.text")])]); - - assert_eq!(diagnosis.empty_prompts, vec!["work".to_string()]); -} - -#[test] -fn a_null_prompt_on_a_node_that_is_not_an_agent_is_an_ordinary_null() { - let graph = graph( - json!([{ "id": "shape", "kind": "transform", "name": "Shape", "config": {} }]), - json!([]), - ); - - let diagnosis = diagnose(&graph, &[step("shape", &[("prompt", "=.item.x")])]); - - assert!(diagnosis.empty_prompts.is_empty()); - assert_eq!(diagnosis.null_bindings.len(), 1); -} - -// ---- hidden errors ---- - -#[test] -fn a_failure_the_error_policy_swallowed_is_surfaced_with_its_message() { - let graph = graph( - json!([{ "id": "notify", "kind": "tool_call", "name": "Notify", "config": {} }]), - json!([]), - ); - - let diagnosis = diagnose( - &graph, - &[failed("notify", json!({ "error": "slug not allowlisted" }))], - ); - - // The step carries no diagnostics, so a check that only read those would - // report this run clean — with a node that failed in it. - assert_eq!(diagnosis.hidden_errors.len(), 1); - assert_eq!(diagnosis.hidden_errors[0].node_id, "notify"); - assert_eq!( - diagnosis.hidden_errors[0].message.as_deref(), - Some("slug not allowlisted") - ); - assert!(!diagnosis.is_clean()); -} - -#[test] -fn a_failure_with_no_readable_message_is_still_reported() { - let graph = graph( - json!([{ "id": "notify", "kind": "tool_call", "name": "Notify", "config": {} }]), - json!([]), - ); - - let diagnosis = diagnose(&graph, &[failed("notify", json!(null))]); - - assert_eq!(diagnosis.hidden_errors.len(), 1); - assert_eq!(diagnosis.hidden_errors[0].message, None); - assert!( - !diagnosis.is_clean(), - "a failure without a message is still a failure" - ); -} - -// ---- nodes that never ran ---- - -#[test] -fn a_node_a_condition_routed_past_names_the_condition_that_decided() { - let graph = graph( - json!([ - { "id": "t", "kind": "trigger", "name": "Start", - "config": { "trigger_kind": "manual" } }, - { "id": "check", "kind": "condition", "name": "Check", - "config": { "expression": "=.item.ok" } }, - { "id": "yes", "kind": "agent", "name": "Yes", "config": { "prompt": "go" } }, - { "id": "no", "kind": "agent", "name": "No", "config": { "prompt": "stop" } }, - ]), - json!([ - { "from_node": "t", "to_node": "check" }, - { "from_node": "check", "from_port": "true", "to_node": "yes" }, - { "from_node": "check", "from_port": "false", "to_node": "no" }, - ]), - ); - - let diagnosis = diagnose( - &graph, - &[step("t", &[]), step("check", &[]), step("yes", &[])], - ); - - // "`no` never ran" sends an author to look at `no`. Naming the condition - // sends them to the node that actually decided. - assert_eq!(diagnosis.never_ran.len(), 1); - assert_eq!(diagnosis.never_ran[0].node_id, "no"); - assert_eq!(diagnosis.never_ran[0].routed_by.as_deref(), Some("check")); - // A condition sending one sample down one branch is what a condition is - // for, so this warns without failing. - assert!(diagnosis.is_clean()); -} - -#[test] -fn only_nodes_that_do_outside_work_are_reported_as_skipped() { - let graph = graph( - json!([ - { "id": "check", "kind": "condition", "name": "Check", - "config": { "expression": "=.item.ok" } }, - { "id": "shape", "kind": "transform", "name": "Shape", "config": {} }, - ]), - json!([{ "from_node": "check", "to_node": "shape" }]), - ); - - let diagnosis = diagnose(&graph, &[step("check", &[])]); - - // A transform that was routed past is not a surprise worth a warning. - assert!(diagnosis.never_ran.is_empty(), "{:?}", diagnosis.never_ran); -} - -#[test] -fn a_skipped_node_with_no_condition_above_it_reports_no_culprit_rather_than_guessing() { - let graph = graph( - json!([ - { "id": "t", "kind": "trigger", "name": "Start", - "config": { "trigger_kind": "manual" } }, - { "id": "work", "kind": "agent", "name": "Work", "config": { "prompt": "go" } }, - ]), - json!([{ "from_node": "t", "to_node": "work" }]), - ); - - let diagnosis = diagnose(&graph, &[step("t", &[])]); - - assert_eq!(diagnosis.never_ran.len(), 1); - assert_eq!(diagnosis.never_ran[0].routed_by, None); -} - -#[test] -fn the_search_for_a_routing_condition_terminates_on_a_cycle() { - // Not a graph the engine would run, but the walk must not hang on one: - // this function is reached from an authoring tool, on whatever was written. - let graph = graph( - json!([ - { "id": "a", "kind": "transform", "name": "A", "config": {} }, - { "id": "b", "kind": "transform", "name": "B", "config": {} }, - { "id": "work", "kind": "agent", "name": "Work", "config": { "prompt": "go" } }, - ]), - json!([ - { "from_node": "a", "to_node": "b" }, - { "from_node": "b", "to_node": "a" }, - { "from_node": "b", "to_node": "work" }, - ]), - ); - - let diagnosis = diagnose(&graph, &[]); - - assert_eq!(diagnosis.never_ran[0].routed_by, None); -} - -// ---- the whole picture ---- - -#[test] -fn a_run_with_nothing_to_report_is_clean() { - let graph = graph( - json!([{ "id": "work", "kind": "agent", "name": "Work", "config": { "prompt": "go" } }]), - json!([]), - ); - - let diagnosis = diagnose(&graph, &[step("work", &[])]); - - assert!(diagnosis.is_clean()); - assert_eq!(diagnosis, Diagnosis::default()); -} diff --git a/src/sdk/src/workflows/run/mod.rs b/src/sdk/src/workflows/run/mod.rs index 884b07511..0d0dfea65 100644 --- a/src/sdk/src/workflows/run/mod.rs +++ b/src/sdk/src/workflows/run/mod.rs @@ -25,7 +25,6 @@ //! the caller to name at least one node the persisted record actually lists as //! pending, so a stale or invented resume cannot walk a run past its gate. -pub mod diagnose; pub mod dispatches; mod preflight; mod registry; @@ -34,11 +33,16 @@ mod summary; #[cfg(test)] mod tests; -pub use diagnose::{diagnose, Diagnosis, DryRun, HiddenError, NeverRan, NullBinding}; +// Run diagnosis moved to the engine crate with the run record it explains. +// Aliased as well as re-exported, so `diagnose::Diagnosis` still resolves here. pub use dispatches::{in_flight, InFlightDispatch}; pub(crate) use preflight::clamp_loop_iterations; pub use registry::{cancel, is_running, CancelSignal, RunClaim, RunGuard}; pub use summary::summarize; +use tinyflows::store::types::diagnosis as diagnose; +pub use tinyflows::store::types::diagnosis::{ + capturing, diagnose, CapturingObserver, Diagnosis, DryRun, HiddenError, NeverRan, NullBinding, +}; use std::sync::Arc; use std::time::Duration; diff --git a/src/sdk/src/workflows/run/preflight.rs b/src/sdk/src/workflows/run/preflight.rs index 1995a41c5..7c2ab600d 100644 --- a/src/sdk/src/workflows/run/preflight.rs +++ b/src/sdk/src/workflows/run/preflight.rs @@ -107,12 +107,12 @@ pub(super) fn settings_for( if workflow.defaults.is_empty() { return Ok(host.clone()); } - let preference = workflow - .defaults - .preference() - .map_err(|message| WorkflowError::Invalid { - id: workflow.id.clone(), - messages: vec![format!("`defaults`: {message}")], + let preference = + crate::workflows::defaults_preference(&workflow.defaults).map_err(|message| { + WorkflowError::Invalid { + id: workflow.id.clone(), + messages: vec![format!("`defaults`: {message}")], + } })?; let choice = crate::flow_engine::HarnessChoice::resolve(&[preference, host.harness_preference()]); diff --git a/src/sdk/src/workflows/run/tests/cases/finalize.rs b/src/sdk/src/workflows/run/tests/cases/finalize.rs index 1ff636650..4c6f46b54 100644 --- a/src/sdk/src/workflows/run/tests/cases/finalize.rs +++ b/src/sdk/src/workflows/run/tests/cases/finalize.rs @@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; -use crate::workflows::types::{ +use crate::workflows::{ RunRecord, WorkflowNote, WorkflowProposal, WorkflowRecord, WorkflowRevision, WorkflowSummary, }; diff --git a/src/sdk/src/workflows/skills/refresh.rs b/src/sdk/src/workflows/skills/refresh.rs index 645fec979..9dbd43452 100644 --- a/src/sdk/src/workflows/skills/refresh.rs +++ b/src/sdk/src/workflows/skills/refresh.rs @@ -53,8 +53,7 @@ use std::path::{Path, PathBuf}; use fs2::FileExt; use crate::protocol::HarnessProvider; -use crate::workflows::store::FileWorkflowStore; -use crate::workflows::types::WorkflowRecord; +use crate::workflows::WorkflowRecord; use super::install::sync; use super::targets::{legacy_managed_root, managed_root, spawn_args}; @@ -157,7 +156,7 @@ pub fn sync_managed( let root = managed_root(env, cwd); let _guard = RefreshLock::acquire(&root)?; - let report = FileWorkflowStore::discover(env, cwd).load(); + let report = crate::workflows::store::discover(env, cwd).load(); let workflows: Vec<_> = report .workflows .iter() diff --git a/src/sdk/src/workflows/skills/refresh_tests.rs b/src/sdk/src/workflows/skills/refresh_tests.rs index cb7ea75b4..5e8010d01 100644 --- a/src/sdk/src/workflows/skills/refresh_tests.rs +++ b/src/sdk/src/workflows/skills/refresh_tests.rs @@ -449,12 +449,10 @@ fn the_unscoped_managed_root_is_retired() { // What the old layout wrote: the same install, at the Medulla home itself. let legacy = crate::home::medulla_home(&env).join("claude-skills"); install( - &[ - crate::workflows::store::FileWorkflowStore::discover(&env, cwd.path()) - .load() - .workflows[0] - .summary(), - ], + &[crate::workflows::store::discover(&env, cwd.path()) + .load() + .workflows[0] + .summary()], &InstallOptions { targets: vec![SkillTarget::Claude], scope: SkillScope::Project, diff --git a/src/sdk/src/workflows/store/README.md b/src/sdk/src/workflows/store/README.md index f0a49891b..01bea563f 100644 --- a/src/sdk/src/workflows/store/README.md +++ b/src/sdk/src/workflows/store/README.md @@ -1,12 +1,16 @@ # Store -Where workflows and their run records live. +Where workflows and their run records live — Medulla's half of it. The store +itself (the `WorkflowStore` trait, the file-backed implementation, revisions, +the journal, proposal locks) moved to `tinyflows::store`, behind that crate's +`store` feature: none of that was ever about Medulla, and the sibling hosts +that embed the engine need exactly the same bookkeeping. Re-exported from here +so a call site still writes `crate::workflows::store::…`. ## Contents -- [`file.rs`](./file.rs) — JSON workflow documents under `.medulla/workflows`, one graph per file. -- [`mod.rs`](./mod.rs) — Where workflows and their run records live. -- [`tests.rs`](./tests.rs) — Unit tests for workflow directory layering, document parsing, and the file-backed store's read/write/delete and run-history behaviour. +- [`mod.rs`](./mod.rs) — The home layout (`workflow_dirs`, `workspace_state_dir`) and `MedullaPolicy`'s harness rule for a `defaults` block; everything else is re-exported from `tinyflows::store`. +- [`tests.rs`](./tests.rs) — Unit tests for what this crate contributes: the home layout and the harness-preference rule. The store's own behaviour (layered reads, atomic writes, revisions, the journal) is tested in `tinyflows::store`. ## Maintenance diff --git a/src/sdk/src/workflows/store/concurrency_tests.rs b/src/sdk/src/workflows/store/concurrency_tests.rs deleted file mode 100644 index 32f500c57..000000000 --- a/src/sdk/src/workflows/store/concurrency_tests.rs +++ /dev/null @@ -1,422 +0,0 @@ -//! Regression coverage for concurrent `save`/`delete` on one store instance. -//! -//! Split out of the sibling `tests` module (already at the repository's -//! 500-line file ceiling) rather than grown into it. What is proven here is -//! narrow but load-bearing: two threads sharing a cloned [`FileWorkflowStore`] -//! — the shape a copilot autosave racing a manual TUI edit takes, both -//! holding `Arc` clones of the same store — must not -//! interleave a save's read-modify-write and lose a revision or a write. - -use std::path::Path; -use std::sync::{Arc, Barrier}; - -use fs2::FileExt; -use serde_json::json; - -use super::file::{definition_state_dir, FileWorkflowStore}; -use super::WorkflowStore; -use crate::workflows::types::WorkflowRecord; - -/// A store rooted in a temporary directory. Mirrors `tests::store_in`, kept -/// local so this file has no dependency on that module's internals. -fn store_in(root: &Path) -> FileWorkflowStore { - FileWorkflowStore::new(vec![root.join("workflows")], root.join("runs")) -} - -/// A minimal valid document, distinguished only by its `name` so two writers -/// racing on the same id can be told apart afterwards. -fn document(id: &str, name: &str) -> WorkflowRecord { - let graph = serde_json::from_value(json!({ - "id": id, - "name": name, - "nodes": [ - { "id": "t", "kind": "trigger", "name": "start", - "config": { "trigger_kind": "manual" } }, - ], - "edges": [], - })) - .expect("graph parses"); - WorkflowRecord { - id: id.to_string(), - name: name.to_string(), - description: String::new(), - enabled: true, - defaults: Default::default(), - graph, - source_path: None, - } -} - -#[test] -fn two_threads_saving_the_same_id_at_once_never_lose_a_revision() { - let root = tempfile::tempdir().expect("tempdir"); - let store = store_in(root.path()); - store.save(&document("race", "v0")).expect("seed save"); - - // Released together so both threads' read-modify-write genuinely overlaps - // rather than happening to run one after the other by scheduling luck — - // the failure mode under test is a race, so the test has to race. - let barrier = Arc::new(Barrier::new(2)); - let handles: Vec<_> = ["v1", "v2"] - .into_iter() - .map(|name| { - let store = store.clone(); - let barrier = barrier.clone(); - std::thread::spawn(move || { - barrier.wait(); - store.save(&document("race", name)).expect("save") - }) - }) - .collect(); - for handle in handles { - handle.join().expect("thread panicked"); - } - - // Three versions existed in total: the seed, and one per racing save. The - // lock does not decide which save wins the final file — that is still a - // last-write-wins race, same as any single-writer save — only that each - // save's own read-then-snapshot-then-write cannot be torn by the other's. - // Without it, a save could snapshot what the *other* save had already - // half-written, or overwrite the other's snapshot before it captured - // anything — either way losing one of the three versions below. - let revisions = store.list_revisions("race").expect("list revisions"); - assert_eq!( - revisions.len(), - 2, - "both saves must have captured the version they superseded: {revisions:?}" - ); - let current = store.get("race").expect("get").expect("still exists"); - assert!( - current.name == "v1" || current.name == "v2", - "the surviving write must be one of the two racing saves, not a torn mix: {}", - current.name - ); -} - -#[test] -fn a_save_racing_a_delete_leaves_the_deletion_recoverable() { - let root = tempfile::tempdir().expect("tempdir"); - let store = store_in(root.path()); - store.save(&document("race", "v0")).expect("seed save"); - - let barrier = Arc::new(Barrier::new(2)); - let save_store = store.clone(); - let save_barrier = barrier.clone(); - let saver = std::thread::spawn(move || { - save_barrier.wait(); - // Either order is fine — this is a race — but it must not panic or - // corrupt the store either way. - let _ = save_store.save(&document("race", "v1")); - }); - let delete_store = store.clone(); - let deleter = std::thread::spawn(move || { - barrier.wait(); - let _ = delete_store.delete("race"); - }); - saver.join().expect("save thread panicked"); - deleter.join().expect("delete thread panicked"); - - // Whatever order the two actually ran in, `list_revisions` must still - // reflect every version that existed before whichever write settled last - // — the same "nothing gets torn" property, just across the two different - // operations that share the lock. - let revisions = store.list_revisions("race").expect("list revisions"); - assert!( - !revisions.is_empty(), - "at least the seed version must have been captured: {revisions:?}" - ); -} - -#[test] -fn failed_definition_publish_does_not_leave_a_revision() { - let root = tempfile::tempdir().expect("tempdir"); - let lower = root.path().join("defaults"); - let upper = root.path().join("workflows"); - std::fs::create_dir_all(&lower).expect("lower definitions"); - let seed = document("blocked", "v0"); - let seed_store = FileWorkflowStore::new(vec![lower.clone()], root.path().join("seed-runs")); - seed_store.save(&seed).expect("seed lower definition"); - - std::fs::create_dir_all(upper.join("blocked.json")).expect("blocking destination directory"); - let store = FileWorkflowStore::new(vec![lower, upper], root.path().join("runs")); - assert!(store.save(&document("blocked", "v1")).is_err()); - assert!( - store - .list_revisions("blocked") - .expect("list revisions") - .is_empty(), - "a source version that was never superseded must not enter history" - ); -} - -#[test] -fn separate_store_instances_use_the_same_definition_lock() { - let root = tempfile::tempdir().expect("tempdir"); - let first = store_in(root.path()); - let second = store_in(root.path()); - first.save(&document("race", "v0")).expect("seed save"); - - let lock_path = definition_state_dir( - &root.path().join("state/workflows"), - &[root.path().join("workflows")], - ) - .join("locks/.race.lock"); - let lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(lock_path) - .expect("definition lock exists"); - lock.lock_exclusive().expect("claim definition lock"); - - let (sent, received) = std::sync::mpsc::channel(); - let writer = std::thread::spawn(move || { - sent.send(second.save(&document("race", "v1"))) - .expect("report save"); - }); - assert!( - received - .recv_timeout(std::time::Duration::from_millis(100)) - .is_err(), - "a separate store must wait for the filesystem lock" - ); - - FileExt::unlock(&lock).expect("release definition lock"); - received - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("save completes after unlock") - .expect("save succeeds"); - writer.join().expect("writer thread"); -} - -#[test] -fn workspace_scoped_stores_share_the_global_definition_lock() { - let root = tempfile::tempdir().expect("tempdir"); - let definitions = vec![root.path().join("workflows")]; - let state = root.path().join("state"); - let first = FileWorkflowStore::with_workspace_state( - definitions.clone(), - &state, - &root.path().join("workspace-a"), - ); - let second = FileWorkflowStore::with_workspace_state( - definitions, - &state, - &root.path().join("workspace-b"), - ); - first.save(&document("race", "v0")).expect("seed save"); - - let lock_path = - definition_state_dir(&state, &[root.path().join("workflows")]).join("locks/.race.lock"); - let lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(lock_path) - .expect("global definition lock exists"); - lock.lock_exclusive().expect("claim definition lock"); - - let (sent, received) = std::sync::mpsc::channel(); - let writer = std::thread::spawn(move || { - sent.send(second.save(&document("race", "v1"))) - .expect("report save"); - }); - assert!( - received - .recv_timeout(std::time::Duration::from_millis(100)) - .is_err(), - "a store for another workspace must wait on the shared definition lock" - ); - - FileExt::unlock(&lock).expect("release definition lock"); - received - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("save completes after unlock") - .expect("save succeeds"); - writer.join().expect("writer thread"); - - let history = first.list_revisions("race").expect("shared history"); - assert_eq!(history.len(), 1); - assert_eq!(history[0].record.name, "v0"); -} - -#[test] -fn explicit_stores_derive_locks_from_the_shared_definition_destination() { - let root = tempfile::tempdir().expect("tempdir"); - let definitions = vec![root.path().join("workflows")]; - let first = FileWorkflowStore::new(definitions.clone(), root.path().join("a/runs")); - let second = FileWorkflowStore::new(definitions, root.path().join("b/runs")); - first.save(&document("race", "v0")).expect("seed save"); - - let lock_path = definition_state_dir( - &root.path().join("state/workflows"), - &[root.path().join("workflows")], - ) - .join("locks/.race.lock"); - let lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(lock_path) - .expect("definition-derived lock exists"); - lock.lock_exclusive().expect("claim definition lock"); - - let (sent, received) = std::sync::mpsc::channel(); - let writer = std::thread::spawn(move || { - sent.send(second.save(&document("race", "v1"))) - .expect("report save"); - }); - assert!( - received - .recv_timeout(std::time::Duration::from_millis(100)) - .is_err(), - "different run roots must not split the shared definition lock" - ); - FileExt::unlock(&lock).expect("release definition lock"); - received - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("save completes after unlock") - .expect("save succeeds"); - writer.join().expect("writer thread"); -} - -#[test] -fn lexical_catalog_aliases_derive_the_same_definition_state() { - let root = tempfile::tempdir().expect("tempdir"); - let direct = root.path().join("catalog"); - let aliased = root.path().join("missing/../catalog"); - - assert_eq!( - super::file::definition_state_dir(root.path(), &[direct]), - super::file::definition_state_dir(root.path(), &[aliased]) - ); -} - -#[cfg(unix)] -#[test] -fn symlinked_catalog_aliases_derive_the_same_definition_state() { - let root = tempfile::tempdir().expect("tempdir"); - let direct = root.path().join("catalog"); - std::fs::create_dir(&direct).expect("catalog"); - let alias = root.path().join("catalog-link"); - std::os::unix::fs::symlink(&direct, &alias).expect("symlink"); - - assert_eq!( - super::file::definition_state_dir(root.path(), &[direct]), - super::file::definition_state_dir(root.path(), &[alias]) - ); -} - -#[test] -fn sibling_definition_catalogs_do_not_share_revision_history() { - let root = tempfile::tempdir().expect("tempdir"); - let first = FileWorkflowStore::new( - vec![root.path().join("catalog-a")], - root.path().join("runs-a"), - ); - let second = FileWorkflowStore::new( - vec![root.path().join("catalog-b")], - root.path().join("runs-b"), - ); - first.save(&document("same", "a0")).expect("seed first"); - first.save(&document("same", "a1")).expect("edit first"); - second.save(&document("same", "b0")).expect("seed second"); - - assert_eq!( - first.list_revisions("same").expect("first history").len(), - 1 - ); - assert!(second - .list_revisions("same") - .expect("second history") - .is_empty()); -} - -#[cfg(unix)] -#[test] -fn symlinked_catalog_paths_share_definition_state() { - let root = tempfile::tempdir().expect("tempdir"); - let real = root.path().join("real/workflows"); - std::fs::create_dir_all(&real).expect("real catalog"); - let alias = root.path().join("alias"); - std::os::unix::fs::symlink(root.path().join("real"), &alias).expect("catalog alias"); - let first = FileWorkflowStore::new(vec![real], root.path().join("runs-a")); - let second = FileWorkflowStore::new(vec![alias.join("workflows")], root.path().join("runs-b")); - - first - .save(&document("same", "v0")) - .expect("seed definition"); - second - .save(&document("same", "v1")) - .expect("edit through alias"); - assert_eq!( - first.list_revisions("same").expect("shared history").len(), - 1 - ); -} - -#[cfg(unix)] -#[test] -fn symlinked_catalog_destination_shares_inferred_state_root() { - let root = tempfile::tempdir().expect("tempdir"); - let real = root.path().join("real/catalog"); - let alias_parent = root.path().join("alias"); - std::fs::create_dir_all(&real).expect("real catalog"); - std::fs::create_dir(&alias_parent).expect("alias parent"); - let alias = alias_parent.join("catalog-link"); - std::os::unix::fs::symlink(&real, &alias).expect("catalog symlink"); - let first = FileWorkflowStore::new(vec![real], root.path().join("runs-a")); - let second = FileWorkflowStore::new(vec![alias], root.path().join("runs-b")); - first - .save(&document("same", "v0")) - .expect("seed definition"); - second - .save(&document("same", "v1")) - .expect("edit through alias"); - assert_eq!( - first.list_revisions("same").expect("shared history").len(), - 1 - ); -} - -#[cfg(unix)] -#[test] -fn parent_after_symlink_uses_filesystem_catalog_identity() { - let root = tempfile::tempdir().expect("tempdir"); - let other = root.path().join("other"); - std::fs::create_dir_all(other.join("inner")).expect("symlink target"); - std::fs::create_dir(other.join("catalog")).expect("real catalog"); - std::os::unix::fs::symlink(other.join("inner"), root.path().join("link")) - .expect("directory symlink"); - let aliased = root.path().join("link/../catalog"); - assert_eq!( - definition_state_dir(root.path(), &[aliased]), - definition_state_dir(root.path(), &[other.join("catalog")]) - ); -} - -#[test] -fn separate_store_instances_serialize_proposal_decisions() { - let root = tempfile::tempdir().expect("tempdir"); - let first = store_in(root.path()); - let second = store_in(root.path()); - let first_claim = first - .lock_proposal_decision("race") - .expect("claim proposal decisions"); - - let (sent, received) = std::sync::mpsc::channel(); - let contender = std::thread::spawn(move || { - let claim = second.lock_proposal_decision("race"); - sent.send(claim.map(drop)).expect("report decision claim"); - }); - assert!( - received - .recv_timeout(std::time::Duration::from_millis(100)) - .is_err(), - "another store must wait before deciding the same workflow" - ); - - drop(first_claim); - received - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("claim completes after unlock") - .expect("claim succeeds"); - contender.join().expect("contender thread"); -} diff --git a/src/sdk/src/workflows/store/file/dirs.rs b/src/sdk/src/workflows/store/file/dirs.rs deleted file mode 100644 index 32f898efd..000000000 --- a/src/sdk/src/workflows/store/file/dirs.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Which directories hold workflows. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use sha2::{Digest, Sha256}; - -use crate::home::medulla_home; - -/// The workflow directories, lowest precedence first: project-local -/// `/.medulla/workflows`, then user-global `/workflows`. -/// -/// Project definitions remain readable as repository-provided defaults, while -/// authored and edited definitions are written to the final, user-global layer -/// beside the rest of Medulla's persistent data. -/// -/// The two are always distinct directories. They used to be able to collapse -/// into one — under `MEDULLA_DEV=1` the home *was* `./.medulla`, and reading it -/// twice made every workflow shadow itself — but the home is now the account -/// directory one level inside the root (`./.medulla/`), which no -/// project store can name. -pub fn workflow_dirs(env: &HashMap, cwd: &Path) -> Vec { - vec![ - cwd.join(".medulla").join("workflows"), - medulla_home(env).join("workflows"), - ] -} - -/// State shared by stores writing the same catalog, beneath the caller's root. -pub(crate) fn definition_state_dir(state_root: &Path, dirs: &[PathBuf]) -> PathBuf { - let write_dir = catalog_identity(dirs); - let scope = format!( - "{:x}", - Sha256::digest(write_dir.as_os_str().as_encoded_bytes()) - ); - state_root.join("definitions").join(scope) -} - -/// Canonical identity of the catalog's write destination. -pub(crate) fn catalog_identity(dirs: &[PathBuf]) -> PathBuf { - let raw = dirs.last().map_or_else( - || PathBuf::from("."), - |dir| { - if dir.is_absolute() { - dir.clone() - } else { - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(dir) - } - }, - ); - canonical_path_identity(&raw) -} - -/// Resolve existing symlinks while retaining lexical semantics for missing parts. -fn canonical_path_identity(path: &Path) -> PathBuf { - let mut resolved = PathBuf::new(); - for component in path.components() { - match component { - std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - if resolved.exists() { - resolved = std::fs::canonicalize(&resolved).unwrap_or(resolved); - } - resolved.pop(); - } - other => { - resolved.push(other.as_os_str()); - if resolved.exists() { - resolved = std::fs::canonicalize(&resolved).unwrap_or(resolved); - } - } - } - } - resolved -} - -#[cfg(test)] -#[path = "dirs_tests.rs"] -mod tests; diff --git a/src/sdk/src/workflows/store/file/document.rs b/src/sdk/src/workflows/store/file/document.rs deleted file mode 100644 index b511bb82f..000000000 --- a/src/sdk/src/workflows/store/file/document.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! The on-disk workflow document: reading it, writing it, checking it. -//! -//! A document is the engine's `WorkflowGraph` JSON with this host's own fields -//! (`id`, `name`, `description`, `enabled`, `defaults`) merged in beside it, -//! rather than nested under a wrapper. That shape is deliberate: a file an operator opens -//! reads as a graph, and a graph exported from anywhere else loads here without -//! being re-wrapped. - -use std::path::Path; - -use serde_json::Value; -use tinyflows::model::WorkflowGraph; - -use crate::workflows::types::{ - RunRecord, RunStatus, WorkflowDefaults, WorkflowError, WorkflowRecord, -}; - -/// Read and parse one workflow document, naming errors by path. -pub fn read_workflow(path: &Path) -> Result { - let text = std::fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?; - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or_default(); - let mut record = - parse_workflow(&text, stem).map_err(|err| format!("{}: {err}", path.display()))?; - // A document can deserialize cleanly and still be a graph the engine will - // not compile — no trigger, an edge to a node that is not there. Catching - // it here means a listing only ever shows workflows that would actually - // run, and the operator hears about the broken file by name. - validate_graph(&record.id, &record.graph) - .map_err(|err| format!("{}: {err}", path.display()))?; - record.source_path = Some(path.to_path_buf()); - Ok(record) -} - -/// Parse one workflow document. -/// -/// The document is the engine's `WorkflowGraph` JSON with optional host fields -/// (`description`, `enabled`, `defaults`) alongside it. `id` defaults to -/// `id_fallback` — the filename, for a file — and `name` to the id, so the -/// smallest useful document is a set of nodes and edges. -/// -/// The pipeline is the engine's documented one: migrate the persisted JSON to -/// the current schema *before* deserializing, so a definition saved by an older -/// build keeps loading. -pub fn parse_workflow(text: &str, id_fallback: &str) -> Result { - let raw: Value = serde_json::from_str(text).map_err(|err| format!("invalid JSON: {err}"))?; - let migrated = tinyflows::migrate::migrate(raw).map_err(|err| err.to_string())?; - - let object = migrated - .as_object() - .ok_or_else(|| "workflow document must be a JSON object".to_string())?; - - let id = object - .get("id") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .unwrap_or(id_fallback) - .to_string(); - let description = object - .get("description") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let enabled = object - .get("enabled") - .and_then(Value::as_bool) - .unwrap_or(true); - - // Parsed before the graph, because parsing consumes `migrated`. An - // unreadable `defaults` block is a hard error rather than an ignored one: a - // workflow that meant to run on Codex and silently ran on the host default - // is exactly the kind of quiet wrongness this store exists to refuse. - let defaults: WorkflowDefaults = match object.get("defaults") { - Some(Value::Null) | None => WorkflowDefaults::default(), - Some(value) => serde_json::from_value(value.clone()) - .map_err(|err| format!("invalid `defaults`: {err}"))?, - }; - defaults - .preference() - .map_err(|err| format!("invalid `defaults`: {err}"))?; - - let graph: WorkflowGraph = - serde_json::from_value(migrated).map_err(|err| format!("invalid workflow: {err}"))?; - let name = if graph.name.is_empty() { - id.clone() - } else { - graph.name.clone() - }; - - Ok(WorkflowRecord { - id, - name, - description, - enabled, - defaults, - graph, - source_path: None, - }) -} - -/// Run the engine's validation, collecting every failure rather than the first. -/// -/// One round-trip then tells an author everything wrong with their graph, which -/// matters most when the author is an agent editing over a tool call. -pub fn validate_graph(id: &str, graph: &WorkflowGraph) -> Result<(), WorkflowError> { - let errors = tinyflows::validate::validate_all(graph); - if errors.is_empty() { - return Ok(()); - } - Err(WorkflowError::Invalid { - id: id.to_string(), - messages: errors - .iter() - .map(|err| match err.node_id() { - Some(node) => format!("[{}] {node}: {err}", err.code()), - None => format!("[{}] {err}", err.code()), - }) - .collect(), - }) -} - -/// Serialize a record into the on-disk document shape: the graph, with the host -/// fields merged in beside it. -pub fn to_document(record: &WorkflowRecord) -> Result, WorkflowError> { - let mut value = serde_json::to_value(&record.graph) - .map_err(|err| WorkflowError::Malformed(err.to_string()))?; - if let Some(object) = value.as_object_mut() { - object.insert("id".into(), Value::String(record.id.clone())); - object.insert("name".into(), Value::String(record.name.clone())); - object.insert( - "description".into(), - Value::String(record.description.clone()), - ); - object.insert("enabled".into(), Value::Bool(record.enabled)); - // Omitted entirely when the workflow states no preference, so an - // untouched document does not grow a block of nulls. - if !record.defaults.is_empty() { - let defaults = serde_json::to_value(&record.defaults) - .map_err(|err| WorkflowError::Malformed(err.to_string()))?; - object.insert("defaults".into(), defaults); - } else { - object.remove("defaults"); - } - } - serde_json::to_vec_pretty(&value).map_err(|err| WorkflowError::Malformed(err.to_string())) -} - -/// A run record for a run that has just started. -pub fn new_run_record(id: &str, workflow_id: &str, started_at: u64) -> RunRecord { - RunRecord { - id: id.to_string(), - workflow_id: workflow_id.to_string(), - status: RunStatus::Running, - started_at, - finished_at: None, - steps: Vec::new(), - pending_approvals: Vec::new(), - error: None, - // Supplied by the caller through `RunRecord::with_inputs` and - // `with_origin`, which every real door does. A record built without - // them is still honest — it simply says nothing about what it was - // started with, which is what an older record says too. - inputs: serde_json::Map::new(), - trigger: None, - origin: None, - // Both are evidence about a run that has ended, so a run that has only - // just started has neither. They are filled in when it settles. - summary: None, - diagnosis: None, - } -} diff --git a/src/sdk/src/workflows/store/file/journal/mod.rs b/src/sdk/src/workflows/store/file/journal/mod.rs deleted file mode 100644 index a7835d24d..000000000 --- a/src/sdk/src/workflows/store/file/journal/mod.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! One workflow's notes on disk. -//! -//! Stored under the state directory beside run records rather than beside the -//! definitions, because a journal is *host* knowledge: it is what this machine -//! observed while running the workflow, not part of the document an operator -//! edits and commits. -//! -//! One file per workflow, not one per note. Notes are only ever read as a whole -//! set — a brief wants all of them or none — and a directory per workflow would -//! reproduce the unindexed scan that already makes run history expensive. - -mod persistence; -mod prune; - -pub use persistence::{append, list, supersede}; - -use crate::workflows::types::NoteId; - -/// How many notes one workflow keeps. -/// -/// Generous, because a note is a sentence rather than a graph, and a workflow -/// that has failed a hundred times has a hundred things worth remembering. The -/// cap exists so an automated pass writing on every failure cannot grow a file -/// without bound. -pub const MAX_NOTES: usize = 100; - -/// Tie-breaker for notes written inside the same millisecond. -/// -/// Process-wide for the same reason revisions use one: it only has to increase, -/// and a per-file count read off disk could be raced into reuse. -static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -/// Mint a note id that sorts chronologically. -/// -/// Same three-part scheme as a revision id: a zero-padded stamp so a lexical -/// sort is a chronological one, a monotonic counter because a pass writes -/// several notes inside one millisecond, and a random token because two -/// processes can pick the same counter. -pub fn mint_id(recorded_at: u64) -> NoteId { - format!( - "{recorded_at:013}-{:012}-{}", - SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - uuid::Uuid::new_v4() - ) -} - -#[cfg(test)] -mod tests; diff --git a/src/sdk/src/workflows/store/file/journal/persistence.rs b/src/sdk/src/workflows/store/file/journal/persistence.rs deleted file mode 100644 index 5538b94a8..000000000 --- a/src/sdk/src/workflows/store/file/journal/persistence.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Locks, recovers, reads, and atomically writes one workflow's journal. - -use std::path::{Path, PathBuf}; - -use fs2::FileExt; - -use crate::workflows::types::{WorkflowError, WorkflowNote}; - -use super::super::paths::{safe_component, write_atomic}; -use super::prune::prune; - -/// Where one workflow's journal lives. -fn path_for(journal_dir: &Path, workflow_id: &str) -> Result { - Ok(journal_dir.join(format!("{}.json", safe_component(workflow_id)?))) -} - -/// Every note for `workflow_id`, newest first, superseded ones included. -/// -/// A journal this host cannot parse yields an empty list with a warning rather -/// than an error. The alternative is that one bad file makes a workflow -/// unreadable everywhere its notes are shown, which is a worse failure than -/// forgetting what it learned — and run history already behaves this way. -pub fn list(journal_dir: &Path, workflow_id: &str) -> Result, WorkflowError> { - with_write_lock(journal_dir, workflow_id, || { - let mut notes = read_all(journal_dir, workflow_id)?; - notes.sort_by(|a, b| b.id.cmp(&a.id)); - Ok(notes) - }) -} - -/// Append `note`, then prune to [`super::MAX_NOTES`]. -/// -/// # Errors -/// -/// Fails when the workflow id is not a usable filename, or when the file cannot -/// be written. A note that could not be recorded is a real failure: the callers -/// that append are the ones claiming the host now knows something. -pub fn append(journal_dir: &Path, note: &WorkflowNote) -> Result<(), WorkflowError> { - with_write_lock(journal_dir, ¬e.workflow_id, || { - let mut notes = read_all(journal_dir, ¬e.workflow_id)?; - notes.push(note.clone()); - prune(&mut notes); - write(journal_dir, ¬e.workflow_id, ¬es) - }) -} - -/// Mark `id` as replaced by `by`, returning whether a current note changed. -/// -/// Silently does nothing when the note is not there. Supersession is a tidying -/// action taken after the fact, and a caller naming a note that has already -/// been pruned away has nothing left to fix. -pub fn supersede( - journal_dir: &Path, - workflow_id: &str, - id: &str, - by: &str, -) -> Result { - with_write_lock(journal_dir, workflow_id, || { - let mut notes = read_all(journal_dir, workflow_id)?; - let mut changed = false; - for note in notes.iter_mut() { - if note.id == id && note.superseded_by.is_none() { - note.superseded_by = Some(by.to_string()); - changed = true; - } - } - if !changed { - return Ok(false); - } - write(journal_dir, workflow_id, ¬es)?; - Ok(true) - }) -} - -/// Serialize journal reads and writes across stores and processes. -/// -/// Reads participate because recovering a corrupt file renames it. Without the -/// same lock a reader could quarantine the valid replacement a writer had just -/// installed after the reader captured the old corrupt bytes. -fn with_write_lock( - journal_dir: &Path, - workflow_id: &str, - write_operation: impl FnOnce() -> Result, -) -> Result { - std::fs::create_dir_all(journal_dir).map_err(|source| WorkflowError::Io { - path: journal_dir.to_path_buf(), - source, - })?; - let lock_path = journal_dir.join(format!("{}.lock", safe_component(workflow_id)?)); - let lock = std::fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .open(&lock_path) - .map_err(|source| WorkflowError::Io { - path: lock_path.clone(), - source, - })?; - lock.lock_exclusive().map_err(|source| WorkflowError::Io { - path: lock_path.clone(), - source, - })?; - let result = write_operation(); - if let Err(source) = lock.unlock() { - tracing::warn!(path = %lock_path.display(), "failed to release journal lock: {source}"); - } - result -} - -/// Read the file, treating absence and corruption alike as "nothing learned". -fn read_all(journal_dir: &Path, workflow_id: &str) -> Result, WorkflowError> { - let path = path_for(journal_dir, workflow_id)?; - let body = match std::fs::read(&path) { - Ok(body) => body, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(source) => return Err(WorkflowError::Io { path, source }), - }; - match serde_json::from_slice::>(&body) { - Ok(notes) => Ok(notes), - Err(err) => { - // Kept, not overwritten. Reading past a corrupt journal is a - // deliberate kindness; appending on top of it would destroy - // whatever an operator might still have recovered by hand, which - // is a different and much less forgivable thing to do. - let quarantine = path.with_extension(format!("json.corrupt.{}", uuid::Uuid::new_v4())); - let _ = std::fs::rename(&path, &quarantine); - tracing::warn!( - workflow = %workflow_id, - path = %path.display(), - kept = %quarantine.display(), - "workflow journal is unreadable; moved aside and starting a new one: {err}" - ); - Ok(Vec::new()) - } - } -} - -/// Write the whole journal back. -fn write( - journal_dir: &Path, - workflow_id: &str, - notes: &[WorkflowNote], -) -> Result<(), WorkflowError> { - let body = serde_json::to_vec_pretty(notes) - .map_err(|err| WorkflowError::Malformed(err.to_string()))?; - write_atomic(&path_for(journal_dir, workflow_id)?, &body) -} diff --git a/src/sdk/src/workflows/store/file/journal/prune.rs b/src/sdk/src/workflows/store/file/journal/prune.rs deleted file mode 100644 index bbfb089f7..000000000 --- a/src/sdk/src/workflows/store/file/journal/prune.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Prunes old notes without breaking supersession chains. - -use crate::workflows::types::WorkflowNote; - -use super::MAX_NOTES; - -/// Drop the oldest notes past [`MAX_NOTES`]. -/// -/// Pinned notes are protected. Supersession chains are pruned as whole groups: -/// a recent replacement and its predecessor survive together, while an old -/// chain can eventually leave together without a dangling `superseded_by`. -pub(super) fn prune(notes: &mut Vec) { - if notes.len() <= MAX_NOTES { - return; - } - - let by_id: std::collections::HashMap<&str, usize> = notes - .iter() - .enumerate() - .map(|(index, note)| (note.id.as_str(), index)) - .collect(); - let mut parents: Vec = (0..notes.len()).collect(); - for (index, note) in notes.iter().enumerate() { - if let Some(replacement) = note.superseded_by.as_deref().and_then(|id| by_id.get(id)) { - join_groups(&mut parents, index, *replacement); - } - } - - let mut groups: std::collections::HashMap> = std::collections::HashMap::new(); - for index in 0..notes.len() { - let root = group_root(&mut parents, index); - groups.entry(root).or_default().push(index); - } - let mut droppable: Vec> = groups - .into_values() - .filter(|group| group.iter().all(|index| !notes[*index].pinned)) - .collect(); - droppable.sort_by(|a, b| { - let oldest = |group: &[usize]| { - group - .iter() - .map(|index| notes[*index].id.as_str()) - .min() - .unwrap_or_default() - }; - oldest(a).cmp(oldest(b)) - }); - - let needed = notes.len() - MAX_NOTES; - let mut removed = 0; - let mut doomed = std::collections::HashSet::new(); - for group in droppable { - if removed >= needed { - break; - } - removed += group.len(); - doomed.extend(group); - } - let mut index = 0; - notes.retain(|_| { - let keep = !doomed.contains(&index); - index += 1; - keep - }); -} - -/// Find a supersession group's root while compressing the traversed path. -fn group_root(parents: &mut [usize], index: usize) -> usize { - if parents[index] != index { - let parent = parents[index]; - parents[index] = group_root(parents, parent); - } - parents[index] -} - -/// Join two notes into one indivisible supersession group. -fn join_groups(parents: &mut [usize], left: usize, right: usize) { - let left = group_root(parents, left); - let right = group_root(parents, right); - if left != right { - parents[right] = left; - } -} diff --git a/src/sdk/src/workflows/store/file/journal/tests.rs b/src/sdk/src/workflows/store/file/journal/tests.rs deleted file mode 100644 index f162e9ffc..000000000 --- a/src/sdk/src/workflows/store/file/journal/tests.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! Tests for the on-disk journal. -//! -//! The behaviour worth pinning here is what happens when things go wrong: a -//! journal that cannot be parsed, an id that is not a filename, a cap reached -//! by automation. The happy path is a JSON array; the failure paths are where -//! a workflow either keeps working or stops. - -use std::path::Path; - -use super::*; -use crate::workflows::types::{NoteKind, NoteSource, WorkflowNote}; - -fn note(workflow_id: &str, recorded_at: u64, text: &str) -> WorkflowNote { - WorkflowNote { - id: mint_id(recorded_at), - workflow_id: workflow_id.to_string(), - kind: NoteKind::Observation, - text: text.to_string(), - recorded_at, - source: NoteSource::System, - run_ids: Vec::new(), - superseded_by: None, - pinned: false, - } -} - -fn dir() -> tempfile::TempDir { - tempfile::tempdir().expect("a temp dir") -} - -#[test] -fn notes_come_back_newest_first() { - let home = dir(); - for (at, text) in [(1, "first"), (2, "second"), (3, "third")] { - append(home.path(), ¬e("sweep", at, text)).expect("append"); - } - - let listed = list(home.path(), "sweep").expect("list"); - - let texts: Vec<&str> = listed.iter().map(|n| n.text.as_str()).collect(); - assert_eq!(texts, ["third", "second", "first"]); -} - -#[test] -fn a_workflow_with_no_journal_has_no_notes_rather_than_an_error() { - let home = dir(); - assert!(list(home.path(), "never-written") - .expect("a missing journal is the normal state") - .is_empty()); -} - -#[test] -fn journals_are_kept_apart_by_workflow() { - let home = dir(); - append(home.path(), ¬e("sweep", 1, "about sweep")).expect("append"); - append(home.path(), ¬e("deploy", 1, "about deploy")).expect("append"); - - assert_eq!(list(home.path(), "sweep").expect("list").len(), 1); - assert_eq!( - list(home.path(), "deploy").expect("list")[0].text, - "about deploy" - ); -} - -#[test] -fn superseding_marks_the_note_without_removing_it() { - let home = dir(); - let first = note("sweep", 1, "the timeout is too short"); - append(home.path(), &first).expect("append"); - let second = note("sweep", 2, "the timeout was never the problem"); - append(home.path(), &second).expect("append"); - - supersede(home.path(), "sweep", &first.id, &second.id).expect("supersede"); - - let listed = list(home.path(), "sweep").expect("list"); - assert_eq!(listed.len(), 2, "history keeps the superseded note"); - let superseded = listed - .iter() - .find(|n| n.id == first.id) - .expect("the superseded note is still listed"); - assert_eq!( - superseded.superseded_by.as_deref(), - Some(second.id.as_str()) - ); - assert!( - !superseded.is_current(), - "a superseded note must stay out of briefs" - ); -} - -#[test] -fn superseding_a_note_that_is_not_there_is_not_a_failure() { - let home = dir(); - append(home.path(), ¬e("sweep", 1, "something")).expect("append"); - - // A caller naming a note that has already been pruned has nothing left to - // fix, and failing here would turn tidying into an error path. - assert!( - !supersede(home.path(), "sweep", "no-such-note", "whatever") - .expect("supersede is forgiving"), - "a missing predecessor was not superseded" - ); -} - -#[test] -fn a_workflow_id_that_is_not_a_filename_is_refused() { - let home = dir(); - let escaping = note("../../etc/passwd", 1, "nope"); - - assert!( - append(home.path(), &escaping).is_err(), - "an id that escapes the journal directory must not be written" - ); - assert!(list(home.path(), "../../etc/passwd").is_err()); -} - -#[test] -fn an_unreadable_journal_reads_as_empty_rather_than_failing() { - let home = dir(); - append(home.path(), ¬e("sweep", 1, "something")).expect("append"); - std::fs::write(home.path().join("sweep.json"), b"{ this is not json").expect("corrupt it"); - - // One bad file must not make the workflow unreadable everywhere its notes - // are shown — the same bargain run history already makes. - assert!(list(home.path(), "sweep") - .expect("a corrupt journal is not an error") - .is_empty()); -} - -#[test] -fn repeated_corruption_preserves_each_quarantined_journal() { - let home = dir(); - for body in [b"{ first corruption".as_slice(), b"{ second corruption"] { - std::fs::write(home.path().join("sweep.json"), body).expect("corrupt it"); - assert!(list(home.path(), "sweep") - .expect("a corrupt journal is not an error") - .is_empty()); - } - - let quarantined: Vec<_> = std::fs::read_dir(home.path()) - .expect("journal directory") - .filter_map(Result::ok) - .filter(|entry| { - entry - .file_name() - .to_string_lossy() - .starts_with("sweep.json.corrupt.") - }) - .collect(); - assert_eq!(quarantined.len(), 2); -} - -#[test] -fn the_journal_is_capped_and_drops_the_oldest_first() { - let home = dir(); - for at in 0..(MAX_NOTES as u64 + 5) { - append(home.path(), ¬e("sweep", at, &format!("note {at}"))).expect("append"); - } - - let listed = list(home.path(), "sweep").expect("list"); - - assert_eq!(listed.len(), MAX_NOTES); - assert_eq!( - listed.last().expect("a note").text, - "note 5", - "the five oldest went, not the five newest" - ); -} - -#[test] -fn pinned_notes_survive_the_cap() { - let home = dir(); - let mut pinned = note("sweep", 0, "what the operator said"); - pinned.pinned = true; - append(home.path(), &pinned).expect("append"); - for at in 1..(MAX_NOTES as u64 + 20) { - append(home.path(), ¬e("sweep", at, &format!("note {at}"))).expect("append"); - } - - let listed = list(home.path(), "sweep").expect("list"); - - assert_eq!(listed.len(), MAX_NOTES); - assert!( - listed.iter().any(|n| n.id == pinned.id), - "automation writing a hundred observations must not evict a person's note" - ); -} - -#[test] -fn an_old_supersession_chain_is_evicted_together_at_the_cap() { - let home = dir(); - let first = note("sweep", 0, "obsolete"); - let replacement = note("sweep", 1, "current"); - append(home.path(), &first).expect("append predecessor"); - append(home.path(), &replacement).expect("append replacement"); - supersede(home.path(), "sweep", &first.id, &replacement.id).expect("supersede"); - for at in 2..(MAX_NOTES as u64 + 20) { - append(home.path(), ¬e("sweep", at, &format!("note {at}"))).expect("append"); - } - - let listed = list(home.path(), "sweep").expect("list"); - assert_eq!(listed.len(), MAX_NOTES); - assert!(!listed.iter().any(|note| note.id == first.id)); - assert!(!listed.iter().any(|note| note.id == replacement.id)); - assert!(listed.iter().all(|note| { - note.superseded_by - .as_ref() - .is_none_or(|id| listed.iter().any(|replacement| &replacement.id == id)) - })); -} - -#[test] -fn a_recent_replacement_survives_with_its_superseded_predecessor() { - let home = dir(); - for at in 0..MAX_NOTES as u64 { - append(home.path(), ¬e("sweep", at, &format!("note {at}"))).expect("append"); - } - let first = note("sweep", MAX_NOTES as u64, "obsolete"); - let replacement = note("sweep", MAX_NOTES as u64 + 1, "current"); - append(home.path(), &first).expect("append predecessor"); - append(home.path(), &replacement).expect("append replacement"); - supersede(home.path(), "sweep", &first.id, &replacement.id).expect("supersede"); - - let listed = list(home.path(), "sweep").expect("list"); - assert_eq!(listed.len(), MAX_NOTES); - assert!(listed.iter().any(|note| note.id == first.id)); - assert!(listed.iter().any(|note| note.id == replacement.id)); -} - -#[test] -fn concurrent_appenders_do_not_overwrite_each_other() { - let home = dir(); - let journal_dir = std::sync::Arc::new(home.path().to_path_buf()); - let gate = std::sync::Arc::new(std::sync::Barrier::new(16)); - let writers: Vec<_> = (0..16) - .map(|at| { - let journal_dir = journal_dir.clone(); - let gate = gate.clone(); - std::thread::spawn(move || { - gate.wait(); - append(&journal_dir, ¬e("sweep", at, &format!("note {at}"))).expect("append"); - }) - }) - .collect(); - for writer in writers { - writer.join().expect("writer completed"); - } - - assert_eq!(list(&journal_dir, "sweep").expect("list").len(), 16); -} - -#[test] -fn ids_minted_in_the_same_millisecond_still_sort_in_order() { - // A pass writes several notes at once; without the counter their order - // would fall through to a random token and the listing would be arbitrary. - let ids: Vec = (0..8).map(|_| mint_id(1_700_000_000_000)).collect(); - let mut sorted = ids.clone(); - sorted.sort(); - - assert_eq!(ids, sorted); -} - -#[test] -fn a_note_round_trips_every_field_through_disk() { - let home = dir(); - let written = WorkflowNote { - id: mint_id(7), - workflow_id: "sweep".into(), - kind: NoteKind::Constraint, - text: "the deploy step must never run before tests".into(), - recorded_at: 7, - source: NoteSource::Agent { - model: Some("claude-opus-5".into()), - }, - run_ids: vec!["run-1".into(), "run-2".into()], - superseded_by: None, - pinned: true, - }; - append(home.path(), &written).expect("append"); - - let read_back = list(home.path(), "sweep").expect("list").remove(0); - - assert_eq!(read_back, written); -} - -/// The journal directory is created on demand, like every other store path. -#[test] -fn appending_creates_the_directory() { - let home = dir(); - let nested = home.path().join("state").join("workflows").join("journal"); - assert!(!Path::new(&nested).exists()); - - append(&nested, ¬e("sweep", 1, "first")).expect("append"); - - assert_eq!(list(&nested, "sweep").expect("list").len(), 1); -} diff --git a/src/sdk/src/workflows/store/file/mod.rs b/src/sdk/src/workflows/store/file/mod.rs deleted file mode 100644 index b56e5a5aa..000000000 --- a/src/sdk/src/workflows/store/file/mod.rs +++ /dev/null @@ -1,708 +0,0 @@ -//! JSON workflow documents under the Medulla home, one graph per file. -//! -//! The directory layering, the forgiving read, and the atomic write all match -//! how agent templates are already kept ([`crate::agents`]) — an operator who -//! has learned one has learned the other. The format is JSON rather than the -//! TOML used for templates because a node's `config` is free-form JSON that the -//! engine hands to jq expressions; round-tripping it through TOML would change -//! what the author wrote. -//! -//! Reading never fails as a whole. A missing directory is the normal state, and -//! a malformed document costs only itself — an operator hand-editing a catalog -//! should lose the file they broke, not the nine that are fine. What went wrong -//! travels back in [`LoadReport::errors`]. -//! -//! The work is split by responsibility: [`dirs`] decides where to look, -//! [`document`] turns bytes into a record and back, [`paths`] guards the -//! identifier-to-filename boundary, and [`revisions`] keeps the superseded -//! copies that make an edit undoable. This module is the store itself. - -mod dirs; -mod document; -mod journal; -mod paths; -mod proposals; -mod revisions; - -pub use dirs::workflow_dirs; -pub use document::{new_run_record, parse_workflow, validate_graph}; -pub use journal::{mint_id as mint_note_id, MAX_NOTES}; -pub use proposals::mint_id as mint_proposal_id; -pub use revisions::MAX_REVISIONS; - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; - -use fs2::FileExt; -use sha2::{Digest, Sha256}; - -use crate::home::medulla_home; -use crate::workflows::types::{ - RunRecord, WorkflowError, WorkflowNote, WorkflowProposal, WorkflowRecord, WorkflowRevision, - WorkflowSummary, -}; - -use dirs::catalog_identity; -pub(super) use dirs::definition_state_dir; -use document::{read_workflow, to_document}; -pub(crate) use paths::write_atomic; -use paths::{is_json, stage_atomic}; -// Re-exported within the crate rather than merely imported: the identifier -// guard is the one piece of this module worth asserting on from outside it. -pub(crate) use paths::safe_component; - -use super::{ProposalDecisionGuard, WorkflowStore}; - -/// The host-state directory this environment and working directory resolve to. -/// -/// Everything a host records *about* workflows rather than as part of them — -/// runs, journal notes, proposals, copilot transcripts — hangs off this one -/// path, scoped to the workspace so two checkouts of the same repository do not -/// read each other's history. Exposed rather than left inside -/// [`FileWorkflowStore::discover`] because the transcript store -/// ([`crate::workflows::copilot::Transcripts`]) has to land in the same place, -/// and a second copy of this derivation is a second thing to keep in step. -pub fn workspace_state_dir(env: &HashMap, cwd: &Path) -> PathBuf { - workspace_state_dir_under(&medulla_home(env), cwd) -} - -/// The same path, for a caller that already knows the Medulla home. -/// -/// The TUI does: it resolved the home once at startup and holds it, and -/// re-deriving it from the process environment would ignore a `--home` the -/// operator passed — and, in tests, would write a fixture's conversations into -/// the developer's own state directory. -pub fn workspace_state_dir_under(home: &Path, cwd: &Path) -> PathBuf { - scoped_state_dir(&home.join("state").join("workflows"), cwd) -} - -/// `state_dir` narrowed to one workspace, by a digest of its canonical path. -fn scoped_state_dir(state_dir: &Path, workspace: &Path) -> PathBuf { - state_dir.join("scopes").join(workspace_scope(workspace)) -} - -/// The directory-name digest that identifies one workspace. -/// -/// Canonical rather than literal so `.` and a symlinked checkout resolve to the -/// same scope; truncated to sixteen hex characters because this is a directory -/// name a person occasionally has to read, and collision here would need a -/// deliberate preimage attack on a path nobody else chooses. -/// -/// Shared in-crate because the generated skills are scoped the same way and by -/// the same rule — a second copy of this derivation is a second thing that can -/// drift. -pub(crate) fn workspace_scope(workspace: &Path) -> String { - let identity = std::fs::canonicalize(workspace).unwrap_or_else(|_| absolute_path(workspace)); - let digest = Sha256::digest(identity.to_string_lossy().as_bytes()); - format!("{digest:x}")[..16].to_string() -} - -/// A file-backed proposal decision claim released when dropped. -struct FileProposalDecisionGuard { - file: std::fs::File, - path: PathBuf, -} - -impl ProposalDecisionGuard for FileProposalDecisionGuard {} - -impl Drop for FileProposalDecisionGuard { - fn drop(&mut self) { - if let Err(source) = FileExt::unlock(&self.file) { - tracing::warn!(path = %self.path.display(), "failed to release proposal decision lock: {source}"); - } - } -} - -/// What one read of the workflow directories found. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct LoadReport { - /// Workflows in load order, later directories having replaced earlier ones - /// of the same id. - pub workflows: Vec, - /// Directories that existed and were read, in precedence order. - pub dirs: Vec, - /// One message per document that could not be read, parsed, or validated. - pub errors: Vec, -} - -/// A workflow store backed by JSON files in the layered workflow directories. -#[derive(Debug, Clone)] -pub struct FileWorkflowStore { - /// Definition directories, lowest precedence first. - dirs: Vec, - /// Where run records are written. Runs are host state, not an authored - /// artifact, so they live under the state directory rather than beside the - /// definitions an operator edits. - runs_dir: PathBuf, - /// Where per-workflow notes are written. - /// - /// Beside the runs rather than beside the definitions for the same reason: - /// a journal is what this host observed while running the workflow, not - /// part of the document an operator edits and commits. - journal_dir: PathBuf, - /// Where proposed graph changes are written, awaiting an operator. - proposals_dir: PathBuf, - /// Where superseded workflow definitions are kept for undo. - revisions_dir: PathBuf, - /// Where cross-process definition locks live. - /// - /// Locks are runtime coordination, so they must not appear among authored - /// files an operator may sync between machines. - definition_locks_dir: PathBuf, - /// Stable identity for in-process decisions and evolution claims. - /// - /// Derived from the persistent proposal directory rather than this - /// object's address because daemon tasks construct independent store - /// instances over the same on-disk state. - decision_scope: String, - /// Serializes `save`/`delete` against each other on *this store instance*. - /// - /// Both are read-modify-write: read what a save would supersede or what a - /// delete would remove, capture that as a revision, then write. Two - /// concurrent writers for the same id — a copilot autosave racing a manual - /// TUI edit, both holding a `clone()` of this store — could otherwise - /// interleave those steps and either lose one edit's revision snapshot or - /// have one silently overwrite the other's write with a stale read. `Arc` - /// so every clone of this store shares the one lock rather than each - /// getting its own and serializing nothing. - /// - /// Separate store instances and processes additionally synchronize through - /// the per-workflow file lock acquired by every definition writer. - write_lock: Arc>, -} - -impl FileWorkflowStore { - /// A store over explicit directories. Mostly for tests; production callers - /// want [`FileWorkflowStore::discover`]. - pub fn new(dirs: Vec, runs_dir: PathBuf) -> Self { - // The journal is derived from the runs directory rather than taken as a - // parameter, so every existing caller of this constructor keeps working - // and still gets a working journal. `with_state` is the explicit form. - let journal_dir = runs_dir - .parent() - .map(|state| state.join("journal")) - .unwrap_or_else(|| PathBuf::from("journal")); - let proposals_dir = runs_dir - .parent() - .map(|state| state.join("proposals")) - .unwrap_or_else(|| PathBuf::from("proposals")); - let definition_root = catalog_identity(&dirs) - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("state/workflows"); - let definition_state = definition_state_dir(&definition_root, &dirs); - let decision_scope = file_store_scope(&proposals_dir); - Self { - dirs, - runs_dir, - journal_dir, - proposals_dir, - revisions_dir: definition_state.join("revisions"), - definition_locks_dir: definition_state.join("locks"), - decision_scope, - write_lock: Arc::new(Mutex::new(())), - } - } - - /// A store whose host state lives under one directory. - /// - /// The explicit form of [`FileWorkflowStore::new`], for callers that know - /// where state belongs rather than only where runs go. - pub fn with_state(dirs: Vec, state_dir: &Path) -> Self { - Self::with_state_roots(dirs, state_dir, state_dir) - } - - /// Build with independently selected run and shared definition state roots. - fn with_state_roots(dirs: Vec, run_state: &Path, definition_state: &Path) -> Self { - let proposals_dir = run_state.join("proposals"); - let definition_state = definition_state_dir(definition_state, &dirs); - Self { - dirs, - runs_dir: run_state.join("runs"), - journal_dir: run_state.join("journal"), - revisions_dir: definition_state.join("revisions"), - definition_locks_dir: definition_state.join("locks"), - decision_scope: file_store_scope(&proposals_dir), - proposals_dir, - write_lock: Arc::new(Mutex::new(())), - } - } - - /// A store whose host state is isolated to one workspace. - pub fn with_workspace_state(dirs: Vec, state_dir: &Path, workspace: &Path) -> Self { - Self::with_state_roots(dirs, &scoped_state_dir(state_dir, workspace), state_dir) - } - - /// A store over the conventional locations for this environment and working - /// directory. - pub fn discover(env: &HashMap, cwd: &Path) -> Self { - let state_dir = medulla_home(env).join("state").join("workflows"); - Self::with_workspace_state(workflow_dirs(env, cwd), &state_dir, cwd) - } - - /// The definition directories, lowest precedence first. - pub fn dirs(&self) -> &[PathBuf] { - &self.dirs - } - - /// The directory new definitions are written to: the highest-precedence one, - /// which production discovery resolves to `/workflows`. - /// - /// Project-local workflows remain a readable lower-precedence layer, but - /// generated user data belongs beside Medulla's config and state rather - /// than appearing as an untracked repository artifact. - pub fn write_dir(&self) -> &Path { - self.dirs - .last() - .map(PathBuf::as_path) - .unwrap_or_else(|| Path::new(".")) - } - - /// Read every `*.json` in every directory, later directories overriding - /// earlier ones by workflow id. - /// - /// Files within one directory are read in sorted order so the catalog is - /// stable across platforms. Never fails: a missing directory yields nothing - /// and a bad document yields an entry in [`LoadReport::errors`]. - pub fn load(&self) -> LoadReport { - let mut report = LoadReport::default(); - for dir in &self.dirs { - let entries = match std::fs::read_dir(dir) { - Ok(entries) => entries, - // Not existing is the normal state, not a failure worth reporting. - Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, - Err(err) => { - report.errors.push(format!("{}: {err}", dir.display())); - continue; - } - }; - report.dirs.push(dir.clone()); - - let mut paths: Vec = entries - .filter_map(|entry| entry.ok().map(|e| e.path())) - .filter(|path| is_json(path)) - .collect(); - paths.sort(); - - for path in paths { - match read_workflow(&path) { - Ok(record) => upsert(&mut report.workflows, record), - Err(err) => report.errors.push(err), - } - } - } - report - } - - /// The path a workflow with `id` is written to. - fn definition_path(&self, id: &str) -> Result { - Ok(self - .write_dir() - .join(format!("{}.json", safe_component(id)?))) - } - - /// The version a save to `path` is about to supersede, if there is one. - /// - /// Two cases, and the cheap one is the common one. When the write directory - /// already holds this workflow, that file *is* what a reader resolves to — - /// the write directory is the highest-precedence one — so parsing it alone - /// is exactly right and costs one read. - /// - /// Only when it does not is a full load needed: the workflow is coming from - /// a lower-precedence directory and this save will shadow it. Snapshotting - /// the shadowed version is what lets an operator undo a project-local edit - /// back to what their home directory had. - fn superseded_by( - &self, - path: &Path, - id: &str, - ) -> Result, WorkflowError> { - if path.exists() { - // A file that no longer parses is not a version worth keeping, and - // refusing the save over it would strand the operator with a broken - // definition they cannot overwrite. - return Ok(read_workflow(path).ok()); - } - self.get(id) - } - - /// The path a run record is written to. - fn run_path(&self, run_id: &str) -> Result { - Ok(self - .runs_dir - .join(format!("{}.json", safe_component(run_id)?))) - } - - /// Run one workflow definition mutation while holding its filesystem lock. - fn with_definition_lock( - &self, - workflow_id: &str, - operation: impl FnOnce() -> Result, - ) -> Result { - std::fs::create_dir_all(&self.definition_locks_dir).map_err(|source| { - WorkflowError::Io { - path: self.definition_locks_dir.clone(), - source, - } - })?; - let lock_path = self - .definition_locks_dir - .join(format!(".{}.lock", safe_component(workflow_id)?)); - let file_lock = std::fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .open(&lock_path) - .map_err(|source| WorkflowError::Io { - path: lock_path.clone(), - source, - })?; - file_lock - .lock_exclusive() - .map_err(|source| WorkflowError::Io { - path: lock_path.clone(), - source, - })?; - let result = operation(); - if let Err(source) = FileExt::unlock(&file_lock) { - tracing::warn!(path = %lock_path.display(), "failed to release workflow lock: {source}"); - } - result - } - - /// Atomically save a workflow when the selected part of its current record - /// still matches the caller's observation. - fn save_if_current_matches( - &self, - record: &WorkflowRecord, - expected_fingerprint: &str, - fingerprint: impl FnOnce(&WorkflowRecord) -> String, - ) -> Result { - let _guard = self - .write_lock - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - self.with_definition_lock(&record.id, || { - let Some(current) = self.get(&record.id)? else { - return Ok(false); - }; - if fingerprint(¤t) != expected_fingerprint { - return Ok(false); - } - let path = self.definition_path(&record.id)?; - validate_graph(&record.id, &record.graph)?; - let document = to_document(record)?; - let staged = stage_atomic(&path, &document)?; - let revision = revisions::capture(&self.revisions_dir, ¤t)?; - if let Err(error) = staged.commit() { - revisions::rollback_capture(&revision); - return Err(error); - } - revisions::commit_capture(&revision)?; - Ok(true) - }) - } -} - -/// Make a stable best-effort absolute identity when a path does not yet exist. -fn absolute_path(path: &Path) -> PathBuf { - if path.is_absolute() { - return path.to_path_buf(); - } - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(path) -} - -/// A stable process-local key for every store instance over `proposals_dir`. -fn file_store_scope(proposals_dir: &Path) -> String { - format!("file:{}", absolute_path(proposals_dir).to_string_lossy()) -} - -impl WorkflowStore for FileWorkflowStore { - fn proposal_decision_scope(&self) -> String { - self.decision_scope.clone() - } - - fn list(&self) -> Result, WorkflowError> { - Ok(self - .load() - .workflows - .iter() - .map(WorkflowRecord::summary) - .collect()) - } - - fn get(&self, id: &str) -> Result, WorkflowError> { - Ok(self.load().workflows.into_iter().find(|w| w.id == id)) - } - - fn save(&self, record: &WorkflowRecord) -> Result<(), WorkflowError> { - // Held across the whole read-modify-write below — see `write_lock`'s - // doc comment for what a concurrent `save`/`delete` on this store - // would otherwise interleave. - let _guard = self.write_lock.lock().unwrap_or_else(|poison| { - // A prior panic mid-write is exactly the case a lock exists to - // survive: the on-disk state is whatever it was left in, but that - // is what a torn write already risks and `write_atomic`'s rename - // makes recoverable — poisoning must not turn one bad write into - // every future save failing too. - poison.into_inner() - }); - self.with_definition_lock(&record.id, || { - // The id decides a filename, so it is checked before anything else: - // a document's own `id` overrides what the caller asked for, and a - // document may have been written by an agent. - let path = self.definition_path(&record.id)?; - // Validate before writing so a listing can be trusted to be runnable. - validate_graph(&record.id, &record.graph)?; - let document = to_document(record)?; - // Snapshot what is about to be replaced, before replacing it. Doing it - // here rather than at each call site is what makes every authoring - // surface undoable without any of them having to opt in. - if let Some(superseded) = self.superseded_by(&path, &record.id)? { - let staged = stage_atomic(&path, &document)?; - let revision = revisions::capture(&self.revisions_dir, &superseded)?; - if let Err(error) = staged.commit() { - revisions::rollback_capture(&revision); - return Err(error); - } - revisions::commit_capture(&revision)?; - return Ok(()); - } - write_atomic(&path, &document) - }) - } - - fn save_if_fingerprint( - &self, - record: &WorkflowRecord, - expected_fingerprint: &str, - ) -> Result { - self.save_if_current_matches(record, expected_fingerprint, |current| { - crate::workflows::fingerprint(¤t.graph) - }) - } - - fn save_if_record_fingerprint( - &self, - record: &WorkflowRecord, - expected_fingerprint: &str, - ) -> Result { - self.save_if_current_matches( - record, - expected_fingerprint, - crate::workflows::record_fingerprint, - ) - } - - fn delete(&self, id: &str) -> Result<(), WorkflowError> { - // See `save`'s matching guard and `write_lock`'s doc comment: this is - // the same read (`load`/`get`), snapshot, write shape. - let _guard = self - .write_lock - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - self.with_definition_lock(id, || { - let existing = self - .load() - .workflows - .into_iter() - .find(|w| w.id == id) - .ok_or_else(|| WorkflowError::NotFound(id.to_string()))?; - let default_path = self.definition_path(id)?; - let path = existing.source_path.clone().unwrap_or(default_path); - if path.parent() != Some(self.write_dir()) { - return Err(WorkflowError::ReadOnlyDefinition { - id: id.to_string(), - path, - }); - } - // Snapshot before removing. A delete is the one edit that leaves - // nothing to diff against afterwards, so without this it is the one - // edit that cannot be undone. - let revision = revisions::capture(&self.revisions_dir, &existing)?; - if let Err(source) = std::fs::remove_file(&path) { - revisions::rollback_capture(&revision); - return Err(WorkflowError::Io { path, source }); - } - revisions::commit_capture(&revision) - }) - } - - fn record_run(&self, run: &RunRecord) -> Result<(), WorkflowError> { - let path = self.run_path(&run.id)?; - let body = serde_json::to_vec_pretty(run) - .map_err(|err| WorkflowError::Malformed(err.to_string()))?; - write_atomic(&path, &body) - } - - fn get_run(&self, run_id: &str) -> Result, WorkflowError> { - let path = self.run_path(run_id)?; - let body = match std::fs::read(&path) { - Ok(body) => body, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(source) => return Err(WorkflowError::Io { path, source }), - }; - serde_json::from_slice(&body) - .map(Some) - .map_err(|err| WorkflowError::Malformed(format!("{}: {err}", path.display()))) - } - - fn list_runs(&self, workflow_id: &str) -> Result, WorkflowError> { - let entries = match std::fs::read_dir(&self.runs_dir) { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(source) => { - return Err(WorkflowError::Io { - path: self.runs_dir.clone(), - source, - }) - } - }; - - let mut runs: Vec = entries - .filter_map(|entry| entry.ok().map(|e| e.path())) - .filter(|path| is_json(path)) - // A run record this host cannot parse is skipped rather than - // failing the listing: history is diagnostic, and one corrupt file - // should not hide the rest of it. - .filter_map(|path| std::fs::read(&path).ok()) - .filter_map(|body| serde_json::from_slice::(&body).ok()) - .filter(|run| run.workflow_id == workflow_id) - .collect(); - runs.sort_by_key(|run| std::cmp::Reverse(run.started_at)); - Ok(runs) - } - - fn list_revisions(&self, workflow_id: &str) -> Result, WorkflowError> { - // Releases before the source/state split kept undo snapshots beside - // definitions. Merge that history with new workspace-scoped snapshots - // so the first post-upgrade edit does not hide the older entries. - revisions::list_merged( - &self.revisions_dir, - &self.write_dir().join(".revisions"), - workflow_id, - ) - } - - fn revision( - &self, - workflow_id: &str, - revision_id: &str, - ) -> Result, WorkflowError> { - match revisions::read(&self.revisions_dir, workflow_id, revision_id)? { - some @ Some(_) => Ok(some), - None => revisions::read( - &self.write_dir().join(".revisions"), - workflow_id, - revision_id, - ), - } - } - - fn list_notes(&self, workflow_id: &str) -> Result, WorkflowError> { - journal::list(&self.journal_dir, workflow_id) - } - - fn append_note(&self, note: &WorkflowNote) -> Result<(), WorkflowError> { - // Under the same lock as `save`/`delete`: appending is a - // read-modify-write of one file, so two passes writing at once would - // otherwise lose whichever note lost the race. - // - // Poison-tolerant for the same reason `save` is, and it matters more - // here: this runs on the failure path, where the caller has documented - // it as best effort. Panicking on a poisoned lock would unwind out of a - // run that already completed. - let _guard = self.write_lock.lock().unwrap_or_else(|p| p.into_inner()); - journal::append(&self.journal_dir, note) - } - - fn supersede_note( - &self, - workflow_id: &str, - note_id: &str, - by: &str, - ) -> Result { - let _guard = self.write_lock.lock().unwrap_or_else(|p| p.into_inner()); - journal::supersede(&self.journal_dir, workflow_id, note_id, by) - } - - fn save_proposal(&self, proposal: &WorkflowProposal) -> Result<(), WorkflowError> { - // Every proposal transition (verification, rejection, acceptance, and - // supersession) funnels through this method. Serialize those writes on - // the shared store lock so clones cannot concurrently replace the same - // proposal document. - let _guard = self.write_lock.lock().unwrap_or_else(|p| p.into_inner()); - proposals::save(&self.proposals_dir, proposal) - } - - fn save_proposal_if_fingerprint( - &self, - proposal: &WorkflowProposal, - expected_fingerprint: &str, - ) -> Result { - let _guard = self - .write_lock - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - self.with_definition_lock(&proposal.workflow_id, || { - let Some(current) = self.get(&proposal.workflow_id)? else { - return Ok(false); - }; - if crate::workflows::fingerprint(¤t.graph) != expected_fingerprint { - return Ok(false); - } - proposals::save(&self.proposals_dir, proposal)?; - Ok(true) - }) - } - - fn get_proposal(&self, id: &str) -> Result, WorkflowError> { - proposals::read(&self.proposals_dir, id) - } - - fn list_proposals(&self, workflow_id: &str) -> Result, WorkflowError> { - proposals::list_for(&self.proposals_dir, workflow_id) - } - - fn lock_proposal_decision( - &self, - workflow_id: &str, - ) -> Result, WorkflowError> { - std::fs::create_dir_all(&self.proposals_dir).map_err(|source| WorkflowError::Io { - path: self.proposals_dir.clone(), - source, - })?; - let path = self.proposals_dir.join(format!( - ".workflow-{}.decision.lock", - safe_component(workflow_id)? - )); - let file = std::fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .open(&path) - .map_err(|source| WorkflowError::Io { - path: path.clone(), - source, - })?; - file.lock_exclusive().map_err(|source| WorkflowError::Io { - path: path.clone(), - source, - })?; - Ok(Box::new(FileProposalDecisionGuard { file, path })) - } -} - -/// Add `record` to `workflows`, replacing any entry with the same id in place so -/// a project-local override keeps the position of what it overrides. -fn upsert(workflows: &mut Vec, record: WorkflowRecord) { - match workflows.iter_mut().find(|w| w.id == record.id) { - Some(existing) => *existing = record, - None => workflows.push(record), - } -} diff --git a/src/sdk/src/workflows/store/file/paths.rs b/src/sdk/src/workflows/store/file/paths.rs deleted file mode 100644 index 7e3d7f9fe..000000000 --- a/src/sdk/src/workflows/store/file/paths.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Turning identifiers into filenames, and writing files without tearing. -//! -//! Everything here guards the boundary between a name this host was *given* and -//! a path it will *act on*. Workflow ids, run ids, and revision ids all arrive -//! from somewhere less trusted than this process — a document an agent wrote, a -//! task frame from a peer — and all three become filenames. - -use std::path::{Path, PathBuf}; - -use crate::workflows::types::WorkflowError; - -/// Suffix appended while writing, then renamed over the target. Matches the -/// idiom already used for trust state, so a half-written file is never -/// observable — and never mistaken for a definition, since the resulting -/// extension is not `json`. -const TMP_SUFFIX: &str = ".medulla-tmp"; - -/// An identifier's use as a single filename component, or an error. -/// -/// Workflow ids and run ids both become filenames, and both are attacker-shaped -/// input: a workflow document's `id` overrides whatever the caller asked for, a -/// document may be written by an agent, and a run id can arrive on a task frame -/// from a peer. Without this, an id of `../../authorized_keys` would let a save -/// write outside the workflow directory with the daemon's privileges. -/// -/// The rule is deliberately strict rather than sanitizing: an id that is not -/// already a safe component is rejected, not silently rewritten into a -/// different one. Rewriting would let two distinct ids collapse onto one file. -pub fn safe_component(id: &str) -> Result<&str, WorkflowError> { - let trimmed = id.trim(); - if trimmed.is_empty() { - return Err(WorkflowError::Malformed( - "identifier must not be empty".to_string(), - )); - } - if trimmed == "." || trimmed == ".." { - return Err(WorkflowError::Malformed(format!( - "identifier '{trimmed}' is not a usable filename" - ))); - } - // Both separators, on every platform: a document written on one machine is - // read on another, and `a\..\b` must not become traversal on Windows just - // because it was authored on unix. - if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') { - return Err(WorkflowError::Malformed(format!( - "identifier '{trimmed}' must not contain a path separator" - ))); - } - // Catches drive-relative and other platform spellings the checks above miss - // by asking the platform itself whether this is one plain component. - let path = Path::new(trimmed); - let mut components = path.components(); - match (components.next(), components.next()) { - (Some(std::path::Component::Normal(_)), None) => Ok(trimmed), - _ => Err(WorkflowError::Malformed(format!( - "identifier '{trimmed}' must be a single path component" - ))), - } -} - -/// Whether a path is a file this store reads. -pub fn is_json(path: &Path) -> bool { - path.is_file() - && path - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("json")) - .unwrap_or(false) -} - -/// Write `body` to `path` through a temporary file in the same directory, so a -/// reader never observes a half-written document. -pub fn write_atomic(path: &Path, body: &[u8]) -> Result<(), WorkflowError> { - stage_atomic(path, body)?.commit() -} - -/// A complete temporary write waiting to be renamed over its destination. -pub struct StagedWrite { - tmp: PathBuf, - path: PathBuf, -} - -impl StagedWrite { - /// Publish the staged bytes atomically. - pub fn commit(self) -> Result<(), WorkflowError> { - std::fs::rename(&self.tmp, &self.path).map_err(|source| WorkflowError::Io { - path: self.path.clone(), - source, - }) - } -} - -impl Drop for StagedWrite { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.tmp); - } -} - -/// Write all bytes into the target directory without replacing the target yet. -pub fn stage_atomic(path: &Path, body: &[u8]) -> Result { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|source| WorkflowError::Io { - path: parent.to_path_buf(), - source, - })?; - } - // Appended rather than substituted for the extension, so an id containing a - // dot cannot collide with a different workflow's temporary file — and - // carrying a unique token, so two writers racing on the *same* id cannot - // scribble over each other's scratch file before either rename lands. - let mut tmp_name = path.as_os_str().to_os_string(); - tmp_name.push(format!("{TMP_SUFFIX}.{}", uuid::Uuid::new_v4())); - let tmp = PathBuf::from(tmp_name); - std::fs::write(&tmp, body).map_err(|source| WorkflowError::Io { - path: tmp.clone(), - source, - })?; - Ok(StagedWrite { - tmp, - path: path.to_path_buf(), - }) -} diff --git a/src/sdk/src/workflows/store/file/proposals/mod.rs b/src/sdk/src/workflows/store/file/proposals/mod.rs deleted file mode 100644 index ce7b8b75c..000000000 --- a/src/sdk/src/workflows/store/file/proposals/mod.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Pending graph changes on disk. -//! -//! One file per proposal, under the state directory beside runs and the -//! journal. Unlike notes, a proposal is read individually as often as in a set -//! — an operator accepts *this* one — so a file each keeps a decision from -//! rewriting every other proposal's record. -//! -//! Listing scans the directory, the same shape run history has. That is -//! acceptable here in a way it is not there: an evolution pass supersedes its -//! own undecided proposal rather than adding to a pile, so the directory stays -//! small by construction. - -use std::path::{Path, PathBuf}; - -use crate::workflows::types::{ProposalId, WorkflowError, WorkflowProposal}; - -use super::paths::{is_json, safe_component, write_atomic}; - -/// Tie-breaker for proposals minted inside the same millisecond. -static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -/// Mint a proposal id that sorts chronologically. -pub fn mint_id(created_at: u64) -> ProposalId { - format!( - "{created_at:013}-{:012}-{}", - SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - uuid::Uuid::new_v4() - ) -} - -/// Where one proposal lives. -fn path_for(proposals_dir: &Path, id: &str) -> Result { - Ok(proposals_dir.join(format!("{}.json", safe_component(id)?))) -} - -/// Write a proposal, replacing any earlier state for the same id. -/// -/// Every state change — verified, accepted, rejected, made stale — goes through -/// here, so a proposal's file is always its current state rather than a log to -/// replay. -pub fn save(proposals_dir: &Path, proposal: &WorkflowProposal) -> Result<(), WorkflowError> { - let body = serde_json::to_vec_pretty(proposal) - .map_err(|err| WorkflowError::Malformed(err.to_string()))?; - write_atomic(&path_for(proposals_dir, &proposal.id)?, &body) -} - -/// One proposal by id, or `None` when there is no such file. -pub fn read(proposals_dir: &Path, id: &str) -> Result, WorkflowError> { - let path = path_for(proposals_dir, id)?; - let body = match std::fs::read(&path) { - Ok(body) => body, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(source) => return Err(WorkflowError::Io { path, source }), - }; - serde_json::from_slice(&body) - .map(Some) - .map_err(|err| WorkflowError::Malformed(format!("{}: {err}", path.display()))) -} - -/// Every proposal for one workflow, newest first. -/// -/// A file this host cannot parse is skipped rather than failing the listing, so -/// one bad proposal does not hide the rest — the same bargain run history and -/// the journal already make. -pub fn list_for( - proposals_dir: &Path, - workflow_id: &str, -) -> Result, WorkflowError> { - let entries = match std::fs::read_dir(proposals_dir) { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(source) => { - return Err(WorkflowError::Io { - path: proposals_dir.to_path_buf(), - source, - }) - } - }; - let mut proposals: Vec = entries - .filter_map(|entry| entry.ok().map(|e| e.path())) - .filter(|path| is_json(path)) - .filter_map(|path| match std::fs::read(&path) { - Ok(body) => match serde_json::from_slice::(&body) { - Ok(proposal) => Some(proposal), - Err(err) => { - tracing::warn!(path = %path.display(), "skipping unreadable proposal: {err}"); - None - } - }, - Err(err) => { - tracing::warn!(path = %path.display(), "skipping unreadable proposal: {err}"); - None - } - }) - .filter(|proposal| proposal.workflow_id == workflow_id) - .collect(); - // Ids lead with a zero-padded stamp, so this is chronological. - proposals.sort_by(|a, b| b.id.cmp(&a.id)); - Ok(proposals) -} - -#[cfg(test)] -mod tests; diff --git a/src/sdk/src/workflows/store/file/proposals/tests.rs b/src/sdk/src/workflows/store/file/proposals/tests.rs deleted file mode 100644 index cb536f584..000000000 --- a/src/sdk/src/workflows/store/file/proposals/tests.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Tests for proposals on disk. -//! -//! A proposal is the one artifact in this feature that can change a saved -//! graph, so what is asserted here is mostly about *not* doing that by -//! accident: a proposal that is not applicable, a listing that cannot leak -//! another workflow's proposals, a file that survives a decision. - -use super::*; -use crate::workflows::types::{ProposalStatus, ProposalVerification}; -use serde_json::json; - -fn proposal(workflow_id: &str, created_at: u64) -> WorkflowProposal { - WorkflowProposal { - id: mint_id(created_at), - workflow_id: workflow_id.to_string(), - created_at, - rationale: "the timeout is too short for a cold cache".into(), - ops: json!([{ "op": "update_node_config", "id": "build", "config": { "timeout": 600 } }]), - evidence_runs: vec!["run-1".into()], - note_ids: Vec::new(), - base_fingerprint: "abc123".into(), - verification: None, - status: ProposalStatus::Pending, - decided_at: None, - decision_reason: None, - } -} - -fn dir() -> tempfile::TempDir { - tempfile::tempdir().expect("a temp dir") -} - -#[test] -fn a_proposal_round_trips_through_disk() { - let home = dir(); - let written = proposal("sweep", 1); - save(home.path(), &written).expect("save"); - - let read_back = read(home.path(), &written.id) - .expect("read") - .expect("the proposal is there"); - - assert_eq!(read_back, written); -} - -#[test] -fn an_unknown_proposal_is_none_rather_than_an_error() { - let home = dir(); - assert!(read(home.path(), "no-such-proposal") - .expect("read") - .is_none()); -} - -#[test] -fn listing_is_scoped_to_one_workflow() { - let home = dir(); - save(home.path(), &proposal("sweep", 1)).expect("save"); - save(home.path(), &proposal("sweep", 2)).expect("save"); - save(home.path(), &proposal("deploy", 3)).expect("save"); - - assert_eq!(list_for(home.path(), "sweep").expect("list").len(), 2); - assert_eq!(list_for(home.path(), "deploy").expect("list").len(), 1); - assert!(list_for(home.path(), "unrelated").expect("list").is_empty()); -} - -#[test] -fn proposals_come_back_newest_first() { - let home = dir(); - let first = proposal("sweep", 1); - let second = proposal("sweep", 2); - save(home.path(), &first).expect("save"); - save(home.path(), &second).expect("save"); - - let listed = list_for(home.path(), "sweep").expect("list"); - - assert_eq!(listed[0].id, second.id); - assert_eq!(listed[1].id, first.id); -} - -#[test] -fn a_decision_replaces_the_file_rather_than_adding_one() { - let home = dir(); - let mut written = proposal("sweep", 1); - save(home.path(), &written).expect("save"); - - written.status = ProposalStatus::Rejected; - written.decided_at = Some(9); - written.decision_reason = Some("the cache is the real problem".into()); - save(home.path(), &written).expect("save the decision"); - - let listed = list_for(home.path(), "sweep").expect("list"); - assert_eq!( - listed.len(), - 1, - "a decision is a state change, not a new row" - ); - assert_eq!(listed[0].status, ProposalStatus::Rejected); - assert_eq!( - listed[0].decision_reason.as_deref(), - Some("the cache is the real problem") - ); -} - -#[test] -fn only_a_verified_pending_proposal_is_applicable() { - let mut subject = proposal("sweep", 1); - assert!( - !subject.is_applicable(), - "an unverified proposal must not be offered" - ); - - subject.verification = Some(ProposalVerification { - ok: false, - verified_at: 2, - messages: vec!["node 'build' does not exist".into()], - diagnosis: None, - }); - assert!( - !subject.is_applicable(), - "a proposal that failed its check is evidence, not an offer" - ); - - subject.verification = Some(ProposalVerification { - ok: true, - verified_at: 3, - messages: Vec::new(), - diagnosis: None, - }); - assert!(subject.is_applicable()); - - subject.status = ProposalStatus::Accepted; - assert!( - !subject.is_applicable(), - "a proposal cannot be applied twice" - ); -} - -#[test] -fn an_unreadable_proposal_is_skipped_rather_than_failing_the_listing() { - let home = dir(); - save(home.path(), &proposal("sweep", 1)).expect("save"); - std::fs::write(home.path().join("broken.json"), b"not json at all").expect("write junk"); - - let listed = list_for(home.path(), "sweep").expect("one bad file is not a failure"); - - assert_eq!(listed.len(), 1); -} - -#[test] -fn a_proposal_id_that_is_not_a_filename_is_refused() { - let home = dir(); - let mut escaping = proposal("sweep", 1); - escaping.id = "../../escape".into(); - - assert!(save(home.path(), &escaping).is_err()); - assert!(read(home.path(), "../../escape").is_err()); -} - -#[test] -fn ops_survive_as_the_json_they_arrived_as() { - // The reason `ops` is a raw `Value`: a stored proposal has to stay readable - // even if the engine's op enum changes shape under it. - let home = dir(); - let mut written = proposal("sweep", 1); - written.ops = json!([{ "op": "some_future_op", "wholly": { "unknown": ["shape"] } }]); - save(home.path(), &written).expect("save"); - - let read_back = read(home.path(), &written.id) - .expect("read") - .expect("the proposal is there"); - - assert_eq!(read_back.ops, written.ops); -} - -#[test] -fn fingerprints_distinguish_graphs_and_agree_with_themselves() { - use crate::workflows::types::fingerprint; - - let graph: tinyflows::model::WorkflowGraph = serde_json::from_value(json!({ - "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], - "edges": [] - })) - .expect("the fixture graph should parse"); - let changed: tinyflows::model::WorkflowGraph = serde_json::from_value(json!({ - "nodes": [{ "id": "t", "kind": "trigger", "name": "start again" }], - "edges": [] - })) - .expect("the fixture graph should parse"); - - assert_eq!(fingerprint(&graph), fingerprint(&graph)); - assert_ne!( - fingerprint(&graph), - fingerprint(&changed), - "a graph that moved must not look unchanged to an accept" - ); -} diff --git a/src/sdk/src/workflows/store/file/revisions.rs b/src/sdk/src/workflows/store/file/revisions.rs deleted file mode 100644 index e88660228..000000000 --- a/src/sdk/src/workflows/store/file/revisions.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Superseded copies of a workflow, so an edit can be taken back. -//! -//! The copilot writes to the store directly — that is the design, and it is why -//! every authoring surface shares one path — but it left an operator with no way -//! to disagree with an edit after the fact. A harness turn that misread the -//! instruction rewrote the graph and the previous one was gone. -//! -//! So [`capture`] runs inside [`super::FileWorkflowStore::save`] and -//! [`super::FileWorkflowStore::delete`], snapshotting what is about to be -//! replaced. Being in the store rather than at the call sites is the point: -//! the copilot, the `medulla workflow` subcommand, and the MCP tools all become -//! undoable without any of them knowing revisions exist. -//! -//! Snapshots live in the workflow state directory. They are host history rather -//! than authored source, so syncing the definitions never pulls along undo -//! state. They are capped at [`MAX_REVISIONS`] per workflow: history is for -//! taking back a mistake that was just made, not an archive. - -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use crate::workflows::types::{WorkflowError, WorkflowRecord, WorkflowRevision}; - -use super::paths::{is_json, safe_component, write_atomic}; - -/// How many superseded copies of one workflow are kept. -/// -/// Matches the sibling `openhuman` host's cap. Past this, an operator is not -/// undoing an edit they just watched happen — they want the version control the -/// project is already in. -pub const MAX_REVISIONS: usize = 20; - -/// Tie-breaker for snapshots taken inside the same millisecond. -/// -/// Process-wide rather than per-workflow: it only has to be increasing, and one -/// counter cannot be raced into reuse the way a per-directory count read off the -/// filesystem could. -static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -/// A snapshot as it is stored: the whole record, plus when it stopped being -/// current. -/// -/// The record is embedded rather than flattened so a revision keeps loading if -/// the record shape gains a field. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct StoredRevision { - /// Epoch-millisecond stamp of when this copy was *superseded*. - superseded_at: u64, - /// The workflow as it was. - record: WorkflowRecord, -} - -/// Where one workflow's snapshots live. -fn dir_for(revisions_dir: &Path, workflow_id: &str) -> Result { - Ok(revisions_dir.join(safe_component(workflow_id)?)) -} - -/// Snapshot `record` as a superseded version, then prune to [`MAX_REVISIONS`]. -/// -/// # Errors -/// -/// Fails when the id is not a usable filename, or when the snapshot cannot be -/// written — the caller is about to overwrite the only copy, so a history it -/// could not record is a failure rather than something to log past. -pub fn capture(write_dir: &Path, record: &WorkflowRecord) -> Result { - let dir = dir_for(write_dir, &record.id)?; - let superseded_at = now_ms(); - // Three parts, and each earns its place. The zero-padded stamp leads so a - // lexical sort is a chronological one. A monotonic counter follows it - // because several saves land inside one millisecond — a burst of copilot - // edits does — and without it the sort would fall through to the random - // token, making both the listing order and *which* revision the cap drops - // arbitrary. The random token is last, and only for uniqueness: two - // processes can pick the same counter. - let revision_id = format!( - "{superseded_at:013}-{:012}-{}", - SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - uuid::Uuid::new_v4() - ); - let stored = StoredRevision { - superseded_at, - record: WorkflowRecord { - // The path a record was read from is where it lived, not part of - // what it was; carrying it into a snapshot would make a rollback - // claim to have come from a file that holds something else. - source_path: None, - ..record.clone() - }, - }; - let body = serde_json::to_vec_pretty(&stored) - .map_err(|err| WorkflowError::Malformed(err.to_string()))?; - let path = dir.join(format!("{revision_id}.json")); - write_atomic(&path, &body)?; - Ok(path) -} - -/// Commit a captured snapshot after its matching source mutation succeeds. -pub fn commit_capture(path: &Path) -> Result<(), WorkflowError> { - prune(path.parent().unwrap_or_else(|| Path::new("."))) -} - -/// Remove a snapshot whose matching source mutation failed. -pub fn rollback_capture(path: &Path) { - let _ = std::fs::remove_file(path); -} - -/// Every snapshot of `workflow_id`, newest first. -/// -/// A snapshot this host cannot parse is skipped rather than failing the listing, -/// matching how run history already behaves: one corrupt file should not hide -/// the rest of it. -pub fn list(write_dir: &Path, workflow_id: &str) -> Result, WorkflowError> { - let dir = dir_for(write_dir, workflow_id)?; - let mut revisions: Vec = snapshot_paths(&dir)? - .into_iter() - .filter_map(|path| load(&path).ok().flatten()) - .collect(); - revisions.sort_by(|a, b| b.id.cmp(&a.id)); - Ok(revisions) -} - -/// Merge snapshots from the current state directory and a legacy directory. -/// -/// Revision identifiers are globally unique in normal operation. Deduplicating -/// them also makes a partially migrated history harmless. -pub(super) fn list_merged( - current_dir: &Path, - legacy_dir: &Path, - workflow_id: &str, -) -> Result, WorkflowError> { - let mut revisions = list(current_dir, workflow_id)?; - revisions.extend(list(legacy_dir, workflow_id)?); - revisions.sort_by(|a, b| b.id.cmp(&a.id)); - revisions.dedup_by(|a, b| a.id == b.id); - revisions.truncate(MAX_REVISIONS); - Ok(revisions) -} - -/// One snapshot by id, scoped to its workflow. -/// -/// Scoped deliberately: a revision id is enough to name a file, and letting one -/// workflow's rollback reach another's history would be a way to write a graph -/// an operator never had. -pub fn read( - write_dir: &Path, - workflow_id: &str, - revision_id: &str, -) -> Result, WorkflowError> { - let path = - dir_for(write_dir, workflow_id)?.join(format!("{}.json", safe_component(revision_id)?)); - load(&path) -} - -/// Read one snapshot file, treating absence as `None`. -fn load(path: &Path) -> Result, WorkflowError> { - let body = match std::fs::read(path) { - Ok(body) => body, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(source) => { - return Err(WorkflowError::Io { - path: path.to_path_buf(), - source, - }) - } - }; - let stored: StoredRevision = serde_json::from_slice(&body) - .map_err(|err| WorkflowError::Malformed(format!("{}: {err}", path.display())))?; - let id = path - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or_default() - .to_string(); - Ok(Some(WorkflowRevision { - id, - superseded_at: stored.superseded_at, - record: stored.record, - })) -} - -/// Every snapshot file in `dir`, sorted oldest first. A missing directory is the -/// normal state for a workflow that has never been edited. -fn snapshot_paths(dir: &Path) -> Result, WorkflowError> { - let entries = match std::fs::read_dir(dir) { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(source) => { - return Err(WorkflowError::Io { - path: dir.to_path_buf(), - source, - }) - } - }; - let mut paths: Vec = entries - .filter_map(|entry| entry.ok().map(|e| e.path())) - .filter(|path| is_json(path)) - .collect(); - // Filenames lead with a zero-padded stamp, so this is chronological. - paths.sort(); - Ok(paths) -} - -/// Drop the oldest snapshots past [`MAX_REVISIONS`]. -/// -/// A snapshot that cannot be removed is not an error: the cap is housekeeping, -/// and failing the *save* that triggered it would be a worse outcome than one -/// extra file on disk. -fn prune(dir: &Path) -> Result<(), WorkflowError> { - let paths = snapshot_paths(dir)?; - let excess = paths.len().saturating_sub(MAX_REVISIONS); - for path in paths.into_iter().take(excess) { - let _ = std::fs::remove_file(path); - } - Ok(()) -} - -/// Epoch milliseconds, saturating at zero if the clock is before the epoch. -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or_default() -} - -#[cfg(test)] -#[path = "revisions_tests.rs"] -mod tests; diff --git a/src/sdk/src/workflows/store/file/revisions_tests.rs b/src/sdk/src/workflows/store/file/revisions_tests.rs deleted file mode 100644 index a6bc104ab..000000000 --- a/src/sdk/src/workflows/store/file/revisions_tests.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Tests for snapshot capture, ordering, pruning, and scoping. - -use serde_json::json; - -use super::*; -use crate::workflows::types::WorkflowRecord; - -/// A record whose graph validates, named so successive versions are tellable -/// apart by their description. -fn record(id: &str, description: &str) -> WorkflowRecord { - WorkflowRecord { - id: id.to_string(), - name: "Greet".into(), - description: description.to_string(), - enabled: true, - defaults: Default::default(), - graph: serde_json::from_value(json!({ - "name": "Greet", - "nodes": [ - { "id": "t", "kind": "trigger", "name": "start", - "config": { "trigger_kind": "manual" } }, - ], - "edges": [], - })) - .expect("graph parses"), - source_path: None, - } -} - -#[test] -fn a_captured_snapshot_can_be_listed_and_read_back() { - let root = tempfile::tempdir().expect("tempdir"); - - capture(root.path(), &record("greet", "first")).expect("capture"); - - let listed = list(root.path(), "greet").expect("list"); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].record.description, "first"); - assert!(listed[0].superseded_at > 0); - - let one = read(root.path(), "greet", &listed[0].id) - .expect("read") - .expect("present"); - assert_eq!(one.record.description, "first"); -} - -#[test] -fn snapshots_are_listed_newest_first() { - let root = tempfile::tempdir().expect("tempdir"); - - for description in ["first", "second", "third"] { - capture(root.path(), &record("greet", description)).expect("capture"); - } - - let listed = list(root.path(), "greet").expect("list"); - let descriptions: Vec<&str> = listed - .iter() - .map(|r| r.record.description.as_str()) - .collect(); - assert_eq!(descriptions, vec!["third", "second", "first"]); -} - -#[test] -fn two_snapshots_taken_in_the_same_millisecond_are_both_kept() { - let root = tempfile::tempdir().expect("tempdir"); - - // No sleep between them on purpose: the stamp alone is not unique enough to - // name a file, so a second save inside one millisecond used to overwrite the - // first rather than adding to the history. - capture(root.path(), &record("greet", "a")).expect("capture"); - capture(root.path(), &record("greet", "b")).expect("capture"); - - assert_eq!(list(root.path(), "greet").expect("list").len(), 2); -} - -#[test] -fn history_is_capped_and_the_oldest_go_first() { - let root = tempfile::tempdir().expect("tempdir"); - - for n in 0..MAX_REVISIONS + 5 { - let captured = capture(root.path(), &record("greet", &format!("v{n}"))).expect("capture"); - commit_capture(&captured).expect("commit capture"); - } - - let listed = list(root.path(), "greet").expect("list"); - assert_eq!(listed.len(), MAX_REVISIONS); - assert_eq!( - listed[0].record.description, - format!("v{}", MAX_REVISIONS + 4) - ); - // The five oldest were dropped, so the tail starts at v5 rather than v0. - assert_eq!(listed[MAX_REVISIONS - 1].record.description, "v5"); -} - -#[test] -fn one_workflow_cannot_read_another_workflows_history() { - let root = tempfile::tempdir().expect("tempdir"); - capture(root.path(), &record("greet", "secret")).expect("capture"); - let listed = list(root.path(), "greet").expect("list"); - - // The id names a real file — just not one that belongs to this workflow. - // Letting it through would be a way to write a graph the operator never had. - let cross = read(root.path(), "other", &listed[0].id).expect("read"); - - assert!(cross.is_none()); -} - -#[test] -fn a_workflow_with_no_history_lists_nothing_rather_than_failing() { - let root = tempfile::tempdir().expect("tempdir"); - - assert!(list(root.path(), "never-edited").expect("list").is_empty()); - assert!(read(root.path(), "never-edited", "whatever") - .expect("read") - .is_none()); -} - -#[test] -fn a_snapshot_forgets_where_the_record_was_read_from() { - let root = tempfile::tempdir().expect("tempdir"); - let mut original = record("greet", "first"); - original.source_path = Some("/somewhere/greet.json".into()); - - capture(root.path(), &original).expect("capture"); - - // Carrying the path would make a rollback claim to have come from a file - // that by then holds something else. - let listed = list(root.path(), "greet").expect("list"); - assert_eq!(listed[0].record.source_path, None); -} - -#[test] -fn current_and_legacy_histories_are_merged_newest_first() { - let root = tempfile::tempdir().expect("tempdir"); - let current = root.path().join("current"); - let legacy = root.path().join("legacy"); - capture(&legacy, &record("greet", "legacy")).expect("legacy capture"); - capture(¤t, &record("greet", "current")).expect("current capture"); - - let listed = list_merged(¤t, &legacy, "greet").expect("merged history"); - - assert_eq!(listed.len(), 2); - assert_eq!(listed[0].record.description, "current"); - assert_eq!(listed[1].record.description, "legacy"); -} - -#[test] -fn an_id_that_would_escape_the_history_directory_is_refused() { - let root = tempfile::tempdir().expect("tempdir"); - - assert!(capture(root.path(), &record("../escape", "x")).is_err()); - assert!(list(root.path(), "../escape").is_err()); - assert!(read(root.path(), "greet", "../../escape").is_err()); -} diff --git a/src/sdk/src/workflows/store/mod.rs b/src/sdk/src/workflows/store/mod.rs index 98251abb3..e465d6916 100644 --- a/src/sdk/src/workflows/store/mod.rs +++ b/src/sdk/src/workflows/store/mod.rs @@ -1,338 +1,127 @@ -//! Where workflows and their run records live. +//! Where workflows and their run records live — Medulla's half of it. //! -//! The engine has exactly one seam for "where does a graph come from" — -//! [`tinyflows::caps::WorkflowResolver`] — and it only covers resolving a -//! `sub_workflow` node's id. Everything else a host needs (listing, saving, -//! deleting, recording runs) has no contract upstream, so this module defines -//! one: [`WorkflowStore`]. +//! The store itself is [`tinyflows::store`] now: the [`WorkflowStore`] trait, +//! the JSON file-backed implementation, the revision snapshots, the journal, the +//! proposal locks, and the record types they move around. None of that was ever +//! about Medulla — a workflow document is the engine's own graph plus +//! bookkeeping, and the sibling hosts that embed the engine need exactly the +//! same bookkeeping. //! -//! The trait exists so the backing store is a decision, not a fact of the -//! codebase. [`FileWorkflowStore`] — JSON documents under the Medulla home, -//! optionally layered over repository-provided defaults — is the only -//! implementation today, but a remote catalog or a database is a new impl -//! rather than a refactor. - -mod file; +//! What is left here is the part that *is* about Medulla, and it is two things: +//! +//! - **Where the catalog lives.** [`workflow_dirs`] and [`workspace_state_dir`] +//! fix the engine's `home`/`project_dir` parameters to Medulla's account +//! directory and its `.medulla` convention. +//! - **What a `defaults` block may say, and what an edit may contain.** The +//! engine treats `defaults.harness` and an `agent` node's `harness` as opaque +//! strings, because which harnesses exist is this host's vocabulary. +//! [`MedullaPolicy`](crate::workflows::gates::MedullaPolicy) is the rule that +//! makes a document naming a harness Medulla does not have fail at load, and +//! an edit introducing one fail before it is written. +//! +//! Everything else is re-exported, so a call site in this crate still writes +//! `crate::workflows::store::…` and does not have to know which crate the item +//! ended up in. -#[cfg(test)] -mod concurrency_tests; #[cfg(test)] mod tests; -pub use file::{ - mint_note_id, mint_proposal_id, new_run_record, parse_workflow, validate_graph, workflow_dirs, - workspace_state_dir, workspace_state_dir_under, FileWorkflowStore, LoadReport, MAX_NOTES, - MAX_REVISIONS, -}; -// The filename guard and the atomic write are how *every* file-backed piece of -// workflow state lands on disk. The copilot transcripts are one such piece and -// live outside this module, so both are visible in-crate rather than each -// keeping a second copy that could disagree about what a safe name is. -pub(crate) use file::{safe_component, workspace_scope, write_atomic}; - -use crate::workflows::types::{ - RunId, RunRecord, WorkflowId, WorkflowNote, WorkflowProposal, WorkflowRecord, WorkflowRevision, - WorkflowSummary, -}; -use crate::workflows::WorkflowError; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; -/// An exclusive claim over proposal decisions for one workflow. -/// -/// The value has no operations: holding it is the operation, and dropping it -/// releases the backing store's claim. -pub trait ProposalDecisionGuard: Send {} +use tinyflows::store::WorkflowDefaults; -/// Default guard for stores that need no external transaction primitive. -struct NoopProposalDecisionGuard; +pub use tinyflows::store::types::{ + bounded_evidence, bounded_within, fingerprint, record_fingerprint, +}; +pub use tinyflows::store::{ + current_notes, mint_note_id, mint_proposal_id, new_run_record, parse_workflow, require, + require_proposal, require_run, rollback, safe_component, undo_last, validate_graph, + workspace_scope, workspace_state_dir_under, write_atomic, FileWorkflowStore, LoadReport, + ProposalDecisionGuard, WorkflowStore, MAX_NOTES, MAX_REVISIONS, +}; -impl ProposalDecisionGuard for NoopProposalDecisionGuard {} +use crate::home::medulla_home; +use crate::workflows::gates::MedullaPolicy; -/// Persistence for workflow definitions and their run history. +/// The per-checkout directory Medulla keeps its own data in. /// -/// Implementations are shared across threads and may be called from async -/// contexts, so they must be `Send + Sync`; the methods are synchronous because -/// every backing store in view is either local files or an in-process database, -/// and a blocking read there is cheaper than the machinery to avoid it. Callers -/// on an async runtime should wrap these in `spawn_blocking`, as the TUI already -/// does for its task repository. -pub trait WorkflowStore: Send + Sync { - /// Identity used to scope in-process proposal decision guards. - /// - /// Workflow and proposal ids are only unique within a store. Including the - /// store identity prevents two independent catalogs with the same ids from - /// blocking each other. - fn proposal_decision_scope(&self) -> String { - format!("{self:p}") - } - - /// Every known workflow, in a stable display order. - fn list(&self) -> Result, WorkflowError>; - - /// One workflow by id, or `None` when the store has no such record. - fn get(&self, id: &str) -> Result, WorkflowError>; - - /// Write `record`, replacing any existing workflow with the same id. - /// - /// Implementations validate before writing: a store never persists a graph - /// the engine would refuse to compile, so a listing can be trusted to be - /// runnable. - fn save(&self, record: &WorkflowRecord) -> Result<(), WorkflowError>; - - /// Save only when the current graph still has `expected_fingerprint`. - /// - /// File-backed stores override this atomically. The default preserves the - /// contract for lightweight test stores that do not expose transactions. - fn save_if_fingerprint( - &self, - record: &WorkflowRecord, - expected_fingerprint: &str, - ) -> Result { - save_if_current_matches(self, record, expected_fingerprint, |current| { - crate::workflows::fingerprint(¤t.graph) - }) - } - - /// Save only when the entire current record still has `expected_fingerprint`. - /// - /// Definition edits use this stronger comparison so a graph write cannot - /// silently restore stale defaults or metadata. File-backed stores override - /// it atomically; the default supports lightweight test stores. - fn save_if_record_fingerprint( - &self, - record: &WorkflowRecord, - expected_fingerprint: &str, - ) -> Result { - save_if_current_matches( - self, - record, - expected_fingerprint, - crate::workflows::record_fingerprint, - ) - } - - /// Remove a workflow. Removing one that does not exist is an error, so a - /// caller cannot mistake a typo for a successful delete. - fn delete(&self, id: &str) -> Result<(), WorkflowError>; - - /// Write a run record, replacing any earlier state for the same run id. - fn record_run(&self, run: &RunRecord) -> Result<(), WorkflowError>; - - /// One run by id. - fn get_run(&self, run_id: &str) -> Result, WorkflowError>; - - /// Every recorded run for a workflow, newest first. - fn list_runs(&self, workflow_id: &str) -> Result, WorkflowError>; +/// A repository's `.medulla/workflows` is read as a source of +/// repository-provided defaults; it is never written to. +pub const PROJECT_DIR: &str = ".medulla"; - /// Every superseded copy of a workflow, newest first. - /// - /// A workflow that has never been written over has no revisions, which is - /// an empty listing rather than an error. - fn list_revisions(&self, workflow_id: &str) -> Result, WorkflowError>; - - /// One superseded copy, by id, scoped to the workflow it belongs to. - /// - /// Scoped rather than global so a rollback cannot reach another workflow's - /// history — that would be a way to write a graph the operator never had. - fn revision( - &self, - workflow_id: &str, - revision_id: &str, - ) -> Result, WorkflowError>; - - /// Every note recorded about a workflow, newest first, including notes a - /// later one superseded. - /// - /// Defaulted so a store that keeps no journal — a read-only catalogue, a - /// test stand-in — is not obliged to invent one. The asymmetry with - /// [`WorkflowStore::append_note`] is deliberate: reporting "nothing - /// learned" is honest, whereas silently discarding something the host - /// claims to have learned is not. - fn list_notes(&self, workflow_id: &str) -> Result, WorkflowError> { - let _ = workflow_id; - Ok(Vec::new()) - } - - /// Record a note. - fn append_note(&self, note: &WorkflowNote) -> Result<(), WorkflowError> { - let _ = note; - Err(WorkflowError::Engine( - "this workflow store does not keep notes".to_string(), - )) - } - - /// Mark a note as replaced by a later one, returning whether it changed. - /// - /// The superseded note stays listed; it simply stops being briefed. - fn supersede_note( - &self, - workflow_id: &str, - note_id: &str, - by: &str, - ) -> Result { - let _ = (workflow_id, note_id, by); - Err(WorkflowError::Engine( - "this workflow store does not keep notes".to_string(), - )) - } - - /// Write a proposal, replacing any earlier state for the same id. - /// - /// Every transition goes through here — verified, accepted, rejected, made - /// stale — so a proposal's stored form is its current state rather than a - /// log to replay. - fn save_proposal(&self, proposal: &WorkflowProposal) -> Result<(), WorkflowError> { - let _ = proposal; - Err(WorkflowError::Engine( - "this workflow store does not keep proposals".to_string(), - )) - } - - /// Save a proposal only while its workflow still has `expected_fingerprint`. - /// - /// File-backed stores override this atomically with definition writes. The - /// default preserves the contract for lightweight test stores that do not - /// expose transactions. - fn save_proposal_if_fingerprint( - &self, - proposal: &WorkflowProposal, - expected_fingerprint: &str, - ) -> Result { - let Some(current) = self.get(&proposal.workflow_id)? else { - return Ok(false); - }; - if crate::workflows::fingerprint(¤t.graph) != expected_fingerprint { - return Ok(false); - } - self.save_proposal(proposal)?; - Ok(true) - } - - /// One proposal by id. - fn get_proposal(&self, id: &str) -> Result, WorkflowError> { - let _ = id; - Ok(None) - } - - /// Every proposal for a workflow, newest first, decided ones included. - fn list_proposals(&self, workflow_id: &str) -> Result, WorkflowError> { - let _ = workflow_id; - Ok(Vec::new()) - } - - /// Claim exclusive decision access for every proposal on one workflow. - /// - /// File stores override this with a cross-process lock held across reading - /// the proposal, applying or rejecting it, and persisting the outcome. - fn lock_proposal_decision( - &self, - workflow_id: &str, - ) -> Result, WorkflowError> { - let _ = workflow_id; - Ok(Box::new(NoopProposalDecisionGuard)) - } -} - -/// Implement the non-transactional compare-and-save fallback with a chosen -/// fingerprint scope. -fn save_if_current_matches( - store: &S, - record: &WorkflowRecord, - expected_fingerprint: &str, - fingerprint: F, -) -> Result -where - S: WorkflowStore + ?Sized, - F: FnOnce(&WorkflowRecord) -> String, -{ - let Some(current) = store.get(&record.id)? else { - return Ok(false); - }; - if fingerprint(¤t) != expected_fingerprint { - return Ok(false); - } - store.save(record)?; - Ok(true) -} - -/// Fetch a proposal by id, turning absence into an error. -pub fn require_proposal( - store: &dyn WorkflowStore, - id: &str, -) -> Result { - store - .get_proposal(id)? - .ok_or_else(|| WorkflowError::Malformed(format!("no proposal with id '{id}'"))) +/// The workflow directories, lowest precedence first: project-local +/// `/.medulla/workflows`, then user-global `/workflows`. +/// +/// The two are always distinct directories. They used to be able to collapse +/// into one — under `MEDULLA_DEV=1` the home *was* `./.medulla`, and reading it +/// twice made every workflow shadow itself — but the home is now the account +/// directory one level inside the root (`./.medulla/`), which no +/// project store can name. +pub fn workflow_dirs(env: &HashMap, cwd: &Path) -> Vec { + tinyflows::store::workflow_dirs(&medulla_home(env), cwd, PROJECT_DIR) } -/// A workflow's current notes — what a brief should be built from. +/// The host-state directory this environment and working directory resolve to. /// -/// Superseded notes are deliberately excluded: they are history worth showing -/// an operator, but asking a model to reason from a claim already known to be -/// wrong is worse than telling it nothing. -pub fn current_notes( - store: &dyn WorkflowStore, - workflow_id: &str, -) -> Result, WorkflowError> { - Ok(store - .list_notes(workflow_id)? - .into_iter() - .filter(WorkflowNote::is_current) - .collect()) +/// Everything Medulla records *about* workflows rather than as part of them — +/// runs, journal notes, proposals, copilot transcripts — hangs off this one +/// path, scoped to the workspace so two checkouts of the same repository do not +/// read each other's history. +pub fn workspace_state_dir(env: &HashMap, cwd: &Path) -> PathBuf { + workspace_state_dir_under(&medulla_home(env), cwd) } -/// Restore `workflow_id` to the state held by `revision_id`. +/// A `defaults` block as the preference layer the dispatch path understands. /// -/// Goes through [`WorkflowStore::save`], so the graph being replaced is itself -/// snapshotted first: a rollback is undoable by the same key that performed it. +/// Lives here rather than on [`WorkflowDefaults`] because +/// [`HarnessPreference`](crate::flow_engine::HarnessPreference) is Medulla's +/// type and the record is the engine's. /// /// # Errors /// -/// Fails when the workflow or the revision is unknown, or when the restored -/// graph no longer validates — which can happen if a sub-workflow it referenced -/// has since been deleted. -pub fn rollback( - store: &dyn WorkflowStore, - workflow_id: &str, - revision_id: &str, -) -> Result { - let revision = store.revision(workflow_id, revision_id)?.ok_or_else(|| { - WorkflowError::Malformed(format!( - "workflow '{workflow_id}' has no revision '{revision_id}'" - )) - })?; - store.save(&revision.record)?; - Ok(revision.record) -} - -/// Restore `workflow_id` to the state before its most recent edit. -/// -/// What the operator's undo key calls. Returns `None` when there is no history -/// to go back to, which the caller reports rather than treating as a failure — -/// a workflow that has never been edited is a normal thing to press undo on. -pub fn undo_last( - store: &dyn WorkflowStore, - workflow_id: &str, -) -> Result, WorkflowError> { - let Some(newest) = store.list_revisions(workflow_id)?.into_iter().next() else { - return Ok(None); +/// Returns a sentence when `harness` names something that cannot be a harness. +pub fn preference( + defaults: &WorkflowDefaults, +) -> Result { + let harness = match defaults.harness.as_deref().map(str::trim) { + Some(name) if !name.is_empty() => Some(crate::flow_engine::HarnessSelector::parse(name)?), + _ => None, }; - let restored = rollback(store, workflow_id, &newest.id)?; - Ok(Some((newest, restored))) + Ok(crate::flow_engine::HarnessPreference { + harness, + model: defaults + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(str::to_string), + }) } -/// Fetch a workflow by id, turning "no such workflow" into an error. +/// A file-backed store over the conventional Medulla locations, judging every +/// document and every edit by [`MedullaPolicy`]. /// -/// The common case at a command boundary, where absence is a failure to report -/// rather than a state to branch on. -pub fn require(store: &dyn WorkflowStore, id: &str) -> Result { - store - .get(id)? - .ok_or_else(|| WorkflowError::NotFound(WorkflowId::from(id))) +/// The one constructor production code should use: a store built any other way +/// carries the engine's own policy, and would both load a document naming a +/// harness this host cannot reach and let an edit introduce one. +pub fn discover(env: &HashMap, cwd: &Path) -> FileWorkflowStore { + let state_dir = medulla_home(env).join("state").join("workflows"); + with_medulla_policy(FileWorkflowStore::with_workspace_state( + workflow_dirs(env, cwd), + &state_dir, + cwd, + )) } -/// Fetch a run by id, turning absence into an error. -pub fn require_run(store: &dyn WorkflowStore, run_id: &str) -> Result { - store - .get_run(run_id)? - .ok_or_else(|| WorkflowError::RunNotFound(RunId::from(run_id))) +/// Attach [`MedullaPolicy`] to a store built over explicit directories. +/// +/// For the callers that choose their own layout — tests, and the harnesses that +/// run a workflow out of a scratch directory. Without it such a store silently +/// holds a laxer policy than the discovered one, so an edit that production +/// would refuse passes. +#[must_use] +pub fn with_medulla_policy(store: FileWorkflowStore) -> FileWorkflowStore { + store.with_policy(Arc::new(MedullaPolicy)) } diff --git a/src/sdk/src/workflows/store/file/dirs_tests.rs b/src/sdk/src/workflows/store/tests.rs similarity index 50% rename from src/sdk/src/workflows/store/file/dirs_tests.rs rename to src/sdk/src/workflows/store/tests.rs index 135ccd5e9..545a31620 100644 --- a/src/sdk/src/workflows/store/file/dirs_tests.rs +++ b/src/sdk/src/workflows/store/tests.rs @@ -1,20 +1,24 @@ -//! Unit tests for workflow directory layering: which directories hold -//! workflows, in what precedence, and how the account-scoped home relates to a -//! repository's own checked-in store. +//! Tests for Medulla's half of the store: where the catalog lands, and what a +//! `defaults` block is allowed to say. +//! +//! The store's own behaviour — layered reads, atomic writes, revisions, the +//! journal, proposal fingerprints — is tested in `tinyflows::store`, next to the +//! code. What is checked here is only what this crate contributes: the home +//! layout, and the harness rule the engine cannot judge on its own. use std::collections::HashMap; use std::path::{Path, PathBuf}; -use super::super::{parse_workflow, FileWorkflowStore}; -use super::workflow_dirs; +use serde_json::json; + +use super::*; use crate::workflows::WorkflowStore; -/// The smallest document that validates: one trigger, one transform, one edge. +/// The smallest document that validates. fn valid_document(id: &str) -> String { - serde_json::json!({ + json!({ "id": id, "name": "Greet", - "description": "says hello", "nodes": [ { "id": "t", "kind": "trigger", "name": "start", "config": { "trigger_kind": "manual" } }, @@ -31,6 +35,7 @@ fn valid_document(id: &str) -> String { #[test] fn the_directories_are_project_then_home_with_home_as_the_write_layer() { let env = HashMap::from([("MEDULLA_HOME".to_string(), "/somewhere/home".to_string())]); + let dirs = workflow_dirs(&env, Path::new("/repo")); assert_eq!( @@ -44,6 +49,24 @@ fn the_directories_are_project_then_home_with_home_as_the_write_layer() { ); } +#[test] +fn a_dev_home_sits_below_the_projects_own_workflow_directory() { + // Under MEDULLA_DEV the root is ./.medulla, but the home is the account + // directory inside it — so the repository's checked-in `.medulla/workflows` + // and the dev write layer are two real directories, not one seen twice. + let env = HashMap::from([("MEDULLA_DEV".to_string(), "1".to_string())]); + + let dirs = workflow_dirs(&env, Path::new(".")); + + assert_eq!( + dirs, + vec![ + PathBuf::from("./.medulla/workflows"), + PathBuf::from(".medulla/local/workflows"), + ] + ); +} + #[test] fn a_discovered_store_saves_new_definitions_under_medulla_home() { let root = tempfile::tempdir().unwrap(); @@ -54,7 +77,7 @@ fn a_discovered_store_saves_new_definitions_under_medulla_home() { medulla_root.to_string_lossy().into_owned(), )]); let home = crate::home::medulla_home(&env); - let store = FileWorkflowStore::discover(&env, &project); + let store = discover(&env, &project); let record = parse_workflow(&valid_document("home-save"), "home-save").unwrap(); store.save(&record).unwrap(); @@ -64,18 +87,47 @@ fn a_discovered_store_saves_new_definitions_under_medulla_home() { } #[test] -fn a_dev_home_sits_below_the_projects_own_workflow_directory() { - // Under MEDULLA_DEV the root is ./.medulla, but the home is the account - // directory inside it — so the repository's checked-in `.medulla/workflows` - // and the dev write layer are two real directories, not one seen twice. - let env = HashMap::from([("MEDULLA_DEV".to_string(), "1".to_string())]); - let dirs = workflow_dirs(&env, Path::new(".")); +fn a_discovered_store_refuses_a_defaults_block_naming_something_that_cannot_be_a_harness() { + // The reason `MedullaPolicy` exists. The engine treats `defaults.harness` + // as an opaque string, so without this policy the document would load and + // the workflow would quietly run on the host default instead. + let root = tempfile::tempdir().unwrap(); + let env = HashMap::from([( + "MEDULLA_HOME".to_string(), + root.path().join("home").to_string_lossy().into_owned(), + )]); + let store = discover(&env, &root.path().join("project")); + let document = json!({ + "id": "nightly", + "defaults": { "harness": "claude code" }, + "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], + "edges": [] + }) + .to_string(); + let dir = crate::home::medulla_home(&env).join("workflows"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("nightly.json"), document).unwrap(); - assert_eq!( - dirs, - vec![ - PathBuf::from("./.medulla/workflows"), - PathBuf::from(".medulla/local/workflows"), - ] - ); + // Reported rather than silently skipped: an operator whose catalog stopped + // listing a workflow needs to be told which file and why. + let report = store.load(); + + assert!(report.workflows.is_empty(), "{:?}", report.workflows); + let joined = report.errors.join("; "); + assert!(joined.contains("defaults"), "{joined}"); + assert!(joined.contains("custom harness id"), "{joined}"); +} + +#[test] +fn a_defaults_block_naming_a_known_harness_becomes_a_preference() { + let defaults = WorkflowDefaults { + harness: Some("codex".into()), + model: Some(" gpt-5-codex ".into()), + }; + + let preference = preference(&defaults).expect("a known harness"); + + assert!(preference.harness.is_some()); + // Trimmed, because an operator's stray space is not part of the model name. + assert_eq!(preference.model.as_deref(), Some("gpt-5-codex")); } diff --git a/src/sdk/src/workflows/store/tests/discovery.rs b/src/sdk/src/workflows/store/tests/discovery.rs deleted file mode 100644 index 6c51786de..000000000 --- a/src/sdk/src/workflows/store/tests/discovery.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Layered directory loading: a home definition overrides a project default in -//! place, one malformed document costs only itself, and a missing directory is -//! not an error. - -use super::*; - -#[test] -fn a_home_workflow_overrides_a_project_default_of_the_same_id_in_place() { - let root = tempfile::tempdir().unwrap(); - let home = root.path().join("home"); - let project = root.path().join("project"); - - write(&project.join("first.json"), &valid_document("first")); - write(&project.join("shared.json"), &valid_document("shared")); - let overridden = valid_document("shared").replace("\"Greet\"", "\"Personal greet\""); - write(&home.join("shared.json"), &overridden); - - let store = FileWorkflowStore::new(vec![project, home], root.path().join("runs")); - let report = store.load(); - - assert!(report.errors.is_empty(), "unexpected: {:?}", report.errors); - let ids: Vec<&str> = report.workflows.iter().map(|w| w.id.as_str()).collect(); - assert_eq!( - ids, - vec!["first", "shared"], - "an override should keep the position of what it overrides" - ); - assert_eq!(report.workflows[1].name, "Personal greet"); -} - -#[test] -fn one_malformed_document_costs_only_itself() { - let root = tempfile::tempdir().unwrap(); - let dir = root.path().join("workflows"); - write(&dir.join("good.json"), &valid_document("good")); - write(&dir.join("broken.json"), "{ not json"); - - let store = store_in(root.path()); - let report = store.load(); - - assert_eq!( - report.workflows.len(), - 1, - "the good document should survive" - ); - assert_eq!(report.errors.len(), 1); - assert!( - report.errors[0].contains("broken.json"), - "the error should name the file: {:?}", - report.errors - ); -} - -#[test] -fn a_missing_directory_is_not_an_error() { - let root = tempfile::tempdir().unwrap(); - let report = store_in(root.path()).load(); - - assert!(report.workflows.is_empty()); - assert!(report.errors.is_empty(), "unexpected: {:?}", report.errors); - assert!(report.dirs.is_empty(), "nothing was read"); -} diff --git a/src/sdk/src/workflows/store/tests/evolution_scope.rs b/src/sdk/src/workflows/store/tests/evolution_scope.rs deleted file mode 100644 index 9b46665ba..000000000 --- a/src/sdk/src/workflows/store/tests/evolution_scope.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Evolution state stays scoped per workspace and per state directory, never -//! shared across stores that should not see each other's claims or proposals. - -use std::collections::HashMap; - -use serde_json::json; - -use super::*; - -#[test] -fn discovered_stores_isolate_evolution_state_by_workspace() { - let root = tempfile::tempdir().unwrap(); - let env = HashMap::from([( - "MEDULLA_HOME".to_string(), - root.path().join("home").to_string_lossy().to_string(), - )]); - let repo_a = root.path().join("repo-a"); - let repo_b = root.path().join("repo-b"); - let store_a = std::sync::Arc::new(FileWorkflowStore::discover(&env, &repo_a)); - let store_b = std::sync::Arc::new(FileWorkflowStore::discover(&env, &repo_b)); - let record = parse_workflow(&valid_document("deploy"), "deploy").unwrap(); - store_a.save(&record).unwrap(); - store_b.save(&record).unwrap(); - - crate::workflows::ops::add_note( - &(store_a.clone() as std::sync::Arc), - "deploy", - "observation", - "repo A only", - Vec::new(), - NoteSource::System, - Vec::new(), - ) - .unwrap(); - - assert_eq!(store_a.list_notes("deploy").unwrap().len(), 1); - assert!(store_b.list_notes("deploy").unwrap().is_empty()); -} - -#[test] -fn independent_instances_over_the_same_state_share_evolution_claims() { - let root = tempfile::tempdir().unwrap(); - let state = root.path().join("state"); - let dirs = vec![root.path().join("workflows")]; - let first = FileWorkflowStore::with_state(dirs.clone(), &state); - let second = FileWorkflowStore::with_state(dirs, &state); - let first_scope = first.proposal_decision_scope(); - let second_scope = second.proposal_decision_scope(); - - assert_eq!(first_scope, second_scope); - let _guard = - EvolveGuard::claim(&first_scope, "deploy").expect("the first store instance claims it"); - assert!( - EvolveGuard::claim(&second_scope, "deploy").is_none(), - "another instance over the same state must not start a duplicate review" - ); -} - -#[test] -fn a_proposal_is_not_published_after_its_base_graph_moves() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut record = parse_workflow(&valid_document("deploy"), "deploy").unwrap(); - store.save(&record).unwrap(); - let base_fingerprint = crate::workflows::fingerprint(&record.graph); - let proposal = WorkflowProposal { - id: "proposal".into(), - workflow_id: "deploy".into(), - created_at: 1, - rationale: "change the worker".into(), - ops: json!([]), - evidence_runs: Vec::new(), - note_ids: Vec::new(), - base_fingerprint: base_fingerprint.clone(), - verification: None, - status: ProposalStatus::Pending, - decided_at: None, - decision_reason: None, - }; - record.graph.nodes[1].name = "Changed concurrently".into(); - store.save(&record).unwrap(); - - assert!(!store - .save_proposal_if_fingerprint(&proposal, &base_fingerprint) - .unwrap()); - assert!(store.get_proposal(&proposal.id).unwrap().is_none()); -} diff --git a/src/sdk/src/workflows/store/tests/history.rs b/src/sdk/src/workflows/store/tests/history.rs deleted file mode 100644 index 0a13487c8..000000000 --- a/src/sdk/src/workflows/store/tests/history.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! Revision snapshotting, undo, and rollback: saving over a workflow -//! snapshots what it replaced, undo restores (and is itself undoable), a named -//! rollback restores exactly that revision, and history never leaks into the -//! ordinary workflow listing. - -use super::*; - -#[test] -fn a_workflow_saved_for_the_first_time_has_no_history_to_go_back_to() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - - store - .save(&parse_workflow(&valid_document("greet"), "greet").unwrap()) - .unwrap(); - - // Nothing was replaced, so nothing was superseded. - assert!(store.list_revisions("greet").unwrap().is_empty()); - assert!(undo_last(&store, "greet").unwrap().is_none()); -} - -#[test] -fn saving_over_a_workflow_snapshots_the_version_it_replaced() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); - store.save(&record).unwrap(); - - record.description = "rewritten by the copilot".into(); - store.save(&record).unwrap(); - - let history = store.list_revisions("greet").unwrap(); - assert_eq!(history.len(), 1); - assert_eq!(history[0].record.description, "says hello"); -} - -#[test] -fn legacy_and_new_revisions_are_listed_together_after_an_edit() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); - record.description = "legacy version".into(); - store.save(&record).unwrap(); - record.description = "current at upgrade".into(); - store.save(&record).unwrap(); - - let legacy_dir = root.path().join("workflows/.revisions/greet"); - std::fs::create_dir_all(legacy_dir.parent().unwrap()).unwrap(); - std::fs::rename( - super::super::file::definition_state_dir( - &root.path().join("state/workflows"), - &[root.path().join("workflows")], - ) - .join("revisions/greet"), - &legacy_dir, - ) - .unwrap(); - - record.description = "post-upgrade edit".into(); - store.save(&record).unwrap(); - - let history = store.list_revisions("greet").unwrap(); - let descriptions: Vec<_> = history - .iter() - .map(|revision| revision.record.description.as_str()) - .collect(); - assert_eq!(descriptions, ["current at upgrade", "legacy version"]); - - let legacy = history - .iter() - .find(|revision| revision.record.description == "legacy version") - .expect("legacy revision remains addressable"); - let restored = rollback(&store, "greet", &legacy.id).expect("legacy rollback"); - assert_eq!(restored.description, "legacy version"); - assert_eq!( - store.get("greet").unwrap().unwrap().description, - "legacy version" - ); -} - -#[test] -fn workspace_scoped_stores_share_definition_revision_history() { - let root = tempfile::tempdir().unwrap(); - let definitions = vec![root.path().join("workflows")]; - let state = root.path().join("state/workflows"); - let first = FileWorkflowStore::with_workspace_state( - definitions.clone(), - &state, - &root.path().join("workspace-a"), - ); - let second = FileWorkflowStore::with_workspace_state( - definitions, - &state, - &root.path().join("workspace-b"), - ); - let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); - first.save(&record).unwrap(); - record.description = "edited from workspace a".into(); - first.save(&record).unwrap(); - - let history = second.list_revisions("greet").unwrap(); - assert_eq!(history.len(), 1); - assert_eq!(history[0].record.description, "says hello"); -} - -#[test] -fn sibling_definition_catalogs_do_not_share_revision_history() { - let root = tempfile::tempdir().unwrap(); - let first = FileWorkflowStore::new( - vec![root.path().join("catalog-a")], - root.path().join("runs-a"), - ); - let second = FileWorkflowStore::new( - vec![root.path().join("catalog-b")], - root.path().join("runs-b"), - ); - - let mut first_record = parse_workflow(&valid_document("greet"), "greet").unwrap(); - first.save(&first_record).unwrap(); - first_record.description = "catalog a edit".into(); - first.save(&first_record).unwrap(); - - let mut second_record = parse_workflow(&valid_document("greet"), "greet").unwrap(); - second_record.description = "catalog b original".into(); - second.save(&second_record).unwrap(); - second_record.description = "catalog b edit".into(); - second.save(&second_record).unwrap(); - - let first_history = first.list_revisions("greet").unwrap(); - let second_history = second.list_revisions("greet").unwrap(); - assert_eq!(first_history.len(), 1); - assert_eq!(first_history[0].record.description, "says hello"); - assert_eq!(second_history.len(), 1); - assert_eq!(second_history[0].record.description, "catalog b original"); -} - -#[test] -fn undo_restores_the_previous_version_and_is_itself_undoable() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); - store.save(&record).unwrap(); - record.description = "rewritten by the copilot".into(); - store.save(&record).unwrap(); - - let (revision, restored) = undo_last(&store, "greet").unwrap().expect("history"); - - assert_eq!(restored.description, "says hello"); - assert_eq!( - store.get("greet").unwrap().unwrap().description, - "says hello" - ); - // The rollback went through `save`, so the version it replaced was - // snapshotted too: pressing undo twice returns to where you started. - let history = store.list_revisions("greet").unwrap(); - assert_eq!(history.len(), 2); - assert_eq!(history[0].record.description, "rewritten by the copilot"); - assert_ne!(history[0].id, revision.id); -} - -#[test] -fn rolling_back_to_a_named_revision_restores_exactly_that_one() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); - for description in ["first", "second", "third"] { - record.description = description.into(); - store.save(&record).unwrap(); - } - - // Three saves, but the first replaced nothing — so history holds the two - // versions that were superseded, newest first, and the last entry is the - // oldest of those. - let history = store.list_revisions("greet").unwrap(); - assert_eq!(history.len(), 2); - let oldest = history.last().expect("history"); - let restored = rollback(&store, "greet", &oldest.id).unwrap(); - - assert_eq!(restored.description, "first"); -} - -#[test] -fn rolling_back_to_a_revision_that_does_not_exist_is_an_error() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - store - .save(&parse_workflow(&valid_document("greet"), "greet").unwrap()) - .unwrap(); - - let err = rollback(&store, "greet", "no-such-revision").expect_err("must refuse"); - - assert!(matches!(err, WorkflowError::Malformed(_)), "got {err:?}"); -} - -#[test] -fn deleting_a_workflow_leaves_it_recoverable() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - store - .save(&parse_workflow(&valid_document("greet"), "greet").unwrap()) - .unwrap(); - - store.delete("greet").unwrap(); - - // A delete has nothing left to diff against, which is exactly why it is - // snapshotted: without this it is the one edit that cannot be taken back. - assert!(store.get("greet").unwrap().is_none()); - let history = store.list_revisions("greet").unwrap(); - assert_eq!(history.len(), 1); - rollback(&store, "greet", &history[0].id).unwrap(); - assert!(store.get("greet").unwrap().is_some()); -} - -#[test] -fn history_does_not_show_up_in_the_workflow_listing() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); - store.save(&record).unwrap(); - record.description = "again".into(); - store.save(&record).unwrap(); - - // Snapshots sit outside the definition directory, so a load must never - // mistake a past version for a current workflow. - assert_eq!(store.list().unwrap().len(), 1); - assert!(store.load().errors.is_empty()); -} - -#[test] -fn shadowing_a_project_default_with_a_home_workflow_snapshots_what_it_shadowed() { - let root = tempfile::tempdir().unwrap(); - let home = root.path().join("home"); - let project = root.path().join("project"); - write(&project.join("greet.json"), &valid_document("greet")); - let store = FileWorkflowStore::new(vec![project, home], root.path().join("runs")); - - // The first home-level save writes a file that did not exist, so nothing - // in the write directory is overwritten — but the operator *does* see the - // graph change, because the home copy now shadows the project default. - let mut record = require(&store, "greet").unwrap(); - record.description = "edited in this project".into(); - store.save(&record).unwrap(); - - let history = store.list_revisions("greet").unwrap(); - assert_eq!(history.len(), 1, "the shadowed version must be recoverable"); - assert_eq!(history[0].record.description, "says hello"); - rollback(&store, "greet", &history[0].id).unwrap(); - assert_eq!( - store.get("greet").unwrap().unwrap().description, - "says hello" - ); -} diff --git a/src/sdk/src/workflows/store/tests/mod.rs b/src/sdk/src/workflows/store/tests/mod.rs deleted file mode 100644 index b208ca67a..000000000 --- a/src/sdk/src/workflows/store/tests/mod.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Unit tests for workflow directory layering, document parsing, and the -//! file-backed store's read/write/delete, run-history, and undo behaviour. -//! -//! The mechanics of snapshot files — ordering, the cap, scoping — are tested -//! next to them in `file/revisions_tests.rs`. What is tested here is the part -//! that matters to a caller: that saving captures history at all, and that -//! rolling back lands where the operator expects. -//! -//! Split by theme rather than kept as one file, to stay under this -//! repository's 500-line-per-file ceiling: [`evolution_scope`] (per-workspace -//! isolation of evolution state), [`parsing`] (document parsing and the -//! `defaults` block), [`discovery`] (layered directory loading), -//! [`persistence`] (save/delete round-tripping), [`runs`] (run-record -//! listing), [`path_guards`] (escaping-id refusal), and [`history`] -//! (revision snapshotting, undo, rollback). Shared fixtures live here and -//! reach every submodule through `super::*`, the same pattern -//! `mcp::tests` uses. - -mod discovery; -mod evolution_scope; -mod history; -mod parsing; -mod path_guards; -mod persistence; -mod runs; - -use std::path::Path; - -use serde_json::json; - -pub(super) use super::file::{new_run_record, parse_workflow, validate_graph}; -pub(super) use super::{ - require, require_run, rollback, undo_last, FileWorkflowStore, WorkflowStore, -}; -pub(super) use crate::workflows::evolve::EvolveGuard; -pub(super) use crate::workflows::types::{ - ProposalStatus, RunStatus, WorkflowError, WorkflowProposal, WorkflowRecord, -}; -pub(super) use crate::workflows::NoteSource; - -/// A store rooted in a temporary directory, with definitions and runs kept -/// apart the way the discovered layout keeps them. -pub(super) fn store_in(root: &Path) -> FileWorkflowStore { - FileWorkflowStore::new(vec![root.join("workflows")], root.join("runs")) -} - -/// The smallest document that validates: one trigger, one transform, one edge. -pub(super) fn valid_document(id: &str) -> String { - json!({ - "id": id, - "name": "Greet", - "description": "says hello", - "nodes": [ - { "id": "t", "kind": "trigger", "name": "start", - "config": { "trigger_kind": "manual" } }, - { "id": "greet", "kind": "transform", "name": "greet", - "config": { "set": { "greeting": "=.item.name" } } } - ], - "edges": [ - { "from_node": "t", "from_port": "main", "to_node": "greet", "to_port": "main" } - ] - }) - .to_string() -} - -pub(super) fn write(path: &Path, body: &str) { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, body).unwrap(); -} diff --git a/src/sdk/src/workflows/store/tests/parsing.rs b/src/sdk/src/workflows/store/tests/parsing.rs deleted file mode 100644 index 91641c41c..000000000 --- a/src/sdk/src/workflows/store/tests/parsing.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Document parsing: id fallback, schema migration, the `defaults` block, and -//! structural validation reporting every failure rather than only the first. - -use serde_json::json; - -use super::*; - -#[test] -fn parsing_names_a_workflow_by_its_filename_when_the_document_omits_an_id() { - let document = json!({ - "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], - "edges": [] - }) - .to_string(); - - let record = parse_workflow(&document, "nightly-sweep").expect("parses"); - - assert_eq!(record.id, "nightly-sweep"); - // Name falls back to the id so a listing is never blank. - assert_eq!(record.name, "nightly-sweep"); - assert!(record.enabled, "workflows are enabled unless opted out"); -} - -#[test] -fn parsing_migrates_a_document_saved_without_a_schema_version() { - // A document predating the field must keep loading; the engine's migration - // runs before deserialization, not after. - let document = json!({ - "id": "old", - "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], - "edges": [] - }) - .to_string(); - - let record = parse_workflow(&document, "old").expect("parses"); - - assert_eq!( - record.graph.schema_version, - tinyflows::model::CURRENT_SCHEMA_VERSION - ); -} - -#[test] -fn parsing_reads_the_defaults_block() { - let document = json!({ - "id": "nightly", - "defaults": { "harness": "codex", "model": "gpt-5-codex" }, - "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], - "edges": [] - }) - .to_string(); - - let record = parse_workflow(&document, "nightly").expect("parses"); - - assert_eq!(record.defaults.harness.as_deref(), Some("codex")); - assert_eq!(record.defaults.model.as_deref(), Some("gpt-5-codex")); -} - -#[test] -fn parsing_refuses_a_defaults_block_naming_something_that_cannot_be_a_harness() { - // Refused on the way in rather than at run time: a workflow that meant to - // change where its work runs and quietly ran it on the host default is the - // failure this check exists to prevent. - let document = json!({ - "id": "nightly", - "defaults": { "harness": "claude code" }, - "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], - "edges": [] - }) - .to_string(); - - let err = parse_workflow(&document, "nightly").expect_err("refused"); - - assert!(err.contains("defaults"), "{err}"); - assert!(err.contains("custom harness id"), "{err}"); -} - -#[test] -fn a_document_without_defaults_stays_without_them() { - let record = parse_workflow(&valid_document("plain"), "plain").expect("parses"); - assert!(record.defaults.is_empty()); -} - -#[test] -fn parsing_rejects_a_document_that_is_not_an_object() { - let err = parse_workflow("[]", "list").expect_err("an array is not a workflow"); - assert!(err.contains("object"), "unhelpful message: {err}"); -} - -#[test] -fn validation_reports_every_failure_not_only_the_first() { - // A graph with no trigger *and* an edge to a node that does not exist. An - // author — often an agent editing over a tool call — should learn both in - // one round-trip. - let graph = serde_json::from_value(json!({ - "nodes": [{ "id": "a", "kind": "transform", "name": "a" }], - "edges": [{ "from_node": "a", "to_node": "ghost" }] - })) - .unwrap(); - - let err = validate_graph("broken", &graph).expect_err("invalid"); - let WorkflowError::Invalid { messages, .. } = err else { - panic!("expected Invalid, got {err:?}"); - }; - - assert!( - messages.len() >= 2, - "expected every failure, got {messages:?}" - ); - assert!( - messages.iter().any(|m| m.contains("missing_trigger")), - "missing trigger not reported: {messages:?}" - ); -} diff --git a/src/sdk/src/workflows/store/tests/path_guards.rs b/src/sdk/src/workflows/store/tests/path_guards.rs deleted file mode 100644 index 81d41323a..000000000 --- a/src/sdk/src/workflows/store/tests/path_guards.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! An id that would escape the workflow or run directory is refused, whether -//! it names a workflow, a save target, or a run record — none of these ids are -//! any more trusted than input from a peer. - -use super::*; - -#[test] -fn an_id_that_would_escape_the_workflow_directory_is_refused() { - // A document's own `id` overrides whatever the caller asked for, and a - // document may have been written by an agent — so this is the guard that - // stops a save from writing outside the store with the daemon's rights. - for hostile in [ - "../escape", - "../../etc/authorized_keys", - "sub/dir", - "back\\slash", - "..", - ".", - " ", - "/absolute", - ] { - assert!( - super::super::file::safe_component(hostile).is_err(), - "{hostile:?} should be refused" - ); - } - // Ordinary ids, including ones with dots, still work. - for ordinary in ["sweep", "nightly-sweep", "a.b", "review_and_fix"] { - assert!( - super::super::file::safe_component(ordinary).is_ok(), - "{ordinary:?} should be allowed" - ); - } -} - -#[test] -fn saving_a_workflow_whose_id_escapes_writes_nothing() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut record = parse_workflow(&valid_document("ok"), "ok").unwrap(); - record.id = "../escaped".into(); - - let err = store.save(&record).expect_err("must refuse"); - - assert!(matches!(err, WorkflowError::Malformed(_)), "got {err:?}"); - assert!( - !root.path().join("escaped.json").exists(), - "nothing may be written outside the workflow directory" - ); -} - -#[test] -fn a_run_id_that_escapes_is_refused_too() { - // Run ids arrive on task frames from peers, so they are no more trusted - // than a workflow id. - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - - let err = store - .record_run(&new_run_record("../escaped", "alpha", 1)) - .expect_err("must refuse"); - - assert!(matches!(err, WorkflowError::Malformed(_)), "got {err:?}"); - assert!(!root.path().join("escaped.json").exists()); -} diff --git a/src/sdk/src/workflows/store/tests/persistence.rs b/src/sdk/src/workflows/store/tests/persistence.rs deleted file mode 100644 index da6c68ac7..000000000 --- a/src/sdk/src/workflows/store/tests/persistence.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Save/delete round-tripping: the graph and host fields survive a save, the -//! `defaults` block round-trips (or is omitted entirely when empty), an -//! uncompilable graph is refused, and deletes remove the file actually read. - -use serde_json::json; - -use super::*; - -#[test] -fn saving_then_loading_round_trips_the_host_fields_and_the_graph() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut record = parse_workflow(&valid_document("round"), "round").unwrap(); - record.description = "edited".into(); - record.enabled = false; - - store.save(&record).expect("saves"); - let loaded = require(&store, "round").expect("found"); - - assert_eq!(loaded.description, "edited"); - assert!(!loaded.enabled, "enabled must survive the round trip"); - assert_eq!(loaded.graph, record.graph); - assert_eq!(loaded.trigger_kind().as_deref(), Some("manual")); -} - -#[test] -fn authored_directory_contains_only_workflow_sources_after_edits() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut record = parse_workflow(&valid_document("clean"), "clean").unwrap(); - - store.save(&record).expect("initial save"); - record.description = "second version".into(); - store.save(&record).expect("edit"); - - let source_entries: Vec<_> = std::fs::read_dir(root.path().join("workflows")) - .expect("source directory") - .map(|entry| entry.expect("entry").file_name()) - .collect(); - assert_eq!(source_entries, vec![std::ffi::OsString::from("clean.json")]); - let definition_state = super::super::file::definition_state_dir( - &root.path().join("state/workflows"), - &[root.path().join("workflows")], - ); - assert!(definition_state.join("revisions/clean").is_dir()); - assert!(definition_state.join("locks/.clean.lock").is_file()); -} - -#[test] -fn explicit_state_root_owns_definition_history_and_locks() { - let root = tempfile::tempdir().unwrap(); - let catalog_parent = tempfile::tempdir().unwrap(); - let definitions = catalog_parent.path().join("authored"); - let state = root.path().join("host-state"); - let store = FileWorkflowStore::with_state(vec![definitions.clone()], &state); - let mut record = parse_workflow(&valid_document("placed"), "placed").unwrap(); - store.save(&record).unwrap(); - record.description = "edited".into(); - store.save(&record).unwrap(); - - let definition_state = - super::super::file::definition_state_dir(&state, std::slice::from_ref(&definitions)); - assert!(definition_state.join("revisions/placed").is_dir()); - assert!(definition_state.join("locks/.placed.lock").is_file()); - assert!(!catalog_parent.path().join("state").exists()); -} - -#[test] -fn saving_round_trips_the_defaults_block() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut record = parse_workflow(&valid_document("pinned"), "pinned").unwrap(); - record.defaults.harness = Some("codex".into()); - record.defaults.model = Some("gpt-5-codex".into()); - - store.save(&record).expect("saves"); - let loaded = require(&store, "pinned").expect("found"); - - assert_eq!(loaded.defaults, record.defaults); -} - -#[test] -fn a_workflow_stating_no_preference_writes_no_defaults_block() { - // A document an operator opens should not grow a block of nulls to say - // nothing. - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let record = parse_workflow(&valid_document("plain"), "plain").unwrap(); - - store.save(&record).expect("saves"); - let path = require(&store, "plain") - .unwrap() - .source_path - .expect("on disk"); - let written: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - - assert!(written.get("defaults").is_none(), "{written}"); -} - -#[test] -fn saving_refuses_a_graph_the_engine_would_not_compile() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let record = WorkflowRecord { - id: "no-trigger".into(), - name: "no trigger".into(), - description: String::new(), - enabled: true, - defaults: Default::default(), - graph: serde_json::from_value(json!({ "nodes": [], "edges": [] })).unwrap(), - source_path: None, - }; - - let err = store.save(&record).expect_err("must not persist"); - - assert!(matches!(err, WorkflowError::Invalid { .. }), "got {err:?}"); - assert!( - store.list().unwrap().is_empty(), - "an invalid graph must not reach the catalog" - ); -} - -#[test] -fn deleting_removes_the_file_the_workflow_was_actually_read_from() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let record = parse_workflow(&valid_document("gone"), "gone").unwrap(); - store.save(&record).unwrap(); - - store.delete("gone").expect("deletes"); - - assert!(store.get("gone").unwrap().is_none()); - let err = store - .delete("gone") - .expect_err("deleting twice is an error"); - assert!(matches!(err, WorkflowError::NotFound(_)), "got {err:?}"); -} - -#[test] -fn deleting_a_repository_default_never_modifies_the_checkout() { - let root = tempfile::tempdir().unwrap(); - let repository_dir = root.path().join("repo/.medulla/workflows"); - let home_dir = root.path().join("home/workflows"); - let repository_file = repository_dir.join("shared.json"); - write(&repository_file, &valid_document("shared")); - let store = FileWorkflowStore::new( - vec![repository_dir, home_dir], - root.path().join("state/runs"), - ); - - let err = store - .delete("shared") - .expect_err("repository defaults are read-only"); - - assert!( - matches!(err, WorkflowError::ReadOnlyDefinition { .. }), - "got {err:?}" - ); - assert!( - repository_file.exists(), - "the checkout must remain untouched" - ); - assert!(store.get("shared").unwrap().is_some()); -} - -#[test] -fn deleting_a_home_definition_uses_its_actual_filename() { - let root = tempfile::tempdir().unwrap(); - let home_dir = root.path().join("home/workflows"); - let alias_file = home_dir.join("alias.json"); - write(&alias_file, &valid_document("shared")); - let store = FileWorkflowStore::new(vec![home_dir], root.path().join("state/runs")); - - store - .delete("shared") - .expect("home definitions are writable"); - - assert!(!alias_file.exists()); - assert!(store.get("shared").unwrap().is_none()); -} - -#[test] -fn an_id_containing_a_dot_does_not_collide_on_its_temporary_file() { - // The temp name is appended, not substituted for the extension, so - // `a.b.json` and `a.json` cannot fight over one scratch path. - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - store - .save(&parse_workflow(&valid_document("a.b"), "a.b").unwrap()) - .unwrap(); - store - .save(&parse_workflow(&valid_document("a"), "a").unwrap()) - .unwrap(); - - // Load order is the sorted filename order, so `a.b.json` precedes `a.json`. - let ids: Vec = store.list().unwrap().into_iter().map(|s| s.id).collect(); - assert_eq!(ids, vec!["a.b", "a"], "both should survive"); -} diff --git a/src/sdk/src/workflows/store/tests/runs.rs b/src/sdk/src/workflows/store/tests/runs.rs deleted file mode 100644 index cc914fe86..000000000 --- a/src/sdk/src/workflows/store/tests/runs.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Run-record listing: newest first, scoped to their workflow, and a run that -//! was never recorded is a distinct error rather than a silent `None`. - -use super::*; - -#[test] -fn runs_are_listed_newest_first_and_scoped_to_their_workflow() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - - store - .record_run(&new_run_record("r1", "alpha", 100)) - .unwrap(); - store - .record_run(&new_run_record("r2", "alpha", 300)) - .unwrap(); - store - .record_run(&new_run_record("r3", "beta", 200)) - .unwrap(); - - let alpha = store.list_runs("alpha").unwrap(); - let ids: Vec<&str> = alpha.iter().map(|r| r.id.as_str()).collect(); - - assert_eq!(ids, vec!["r2", "r1"], "newest first"); - assert_eq!(store.list_runs("beta").unwrap().len(), 1); - assert_eq!(store.list_runs("unknown").unwrap().len(), 0); -} - -#[test] -fn a_run_record_survives_being_rewritten_as_it_settles() { - let root = tempfile::tempdir().unwrap(); - let store = store_in(root.path()); - let mut run = new_run_record("r1", "alpha", 100); - store.record_run(&run).unwrap(); - - run.status = RunStatus::PendingApproval; - run.pending_approvals = vec!["review".into()]; - store.record_run(&run).unwrap(); - - let loaded = require_run(&store, "r1").expect("found"); - assert_eq!(loaded.status, RunStatus::PendingApproval); - assert_eq!(loaded.pending_approvals, vec!["review".to_string()]); - assert!(!loaded.status.is_settled(), "an approval gate is resumable"); -} - -#[test] -fn asking_for_a_run_that_was_never_recorded_is_an_error_not_a_silent_none() { - let root = tempfile::tempdir().unwrap(); - let err = require_run(&store_in(root.path()), "ghost").expect_err("no such run"); - assert!(matches!(err, WorkflowError::RunNotFound(_)), "got {err:?}"); -} diff --git a/src/sdk/src/workflows/types/error.rs b/src/sdk/src/workflows/types/error.rs deleted file mode 100644 index 45361eef5..000000000 --- a/src/sdk/src/workflows/types/error.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! The failure vocabulary every workflow surface reports through. -//! -//! Kept deliberately wide rather than collapsed into one string: the CLI, the -//! MCP server, and the TUI all branch on these, and an operator's next step -//! differs for each. - -use std::path::PathBuf; - -use super::run::RunId; -use super::workflow::WorkflowId; - -/// What can go wrong reading, writing, or running a workflow. -#[derive(Debug, thiserror::Error)] -pub enum WorkflowError { - /// No workflow with that id is known to the store. - #[error("no workflow with id '{0}'")] - NotFound(WorkflowId), - - /// No run with that id is known to the store. - #[error("no run with id '{0}'")] - RunNotFound(RunId), - - /// The graph did not pass the engine's validation. Carries every failure, - /// not just the first, so one round-trip tells an author everything. - #[error("workflow '{id}' is invalid: {}", .messages.join("; "))] - Invalid { - /// The workflow that failed validation. - id: WorkflowId, - /// One message per validation failure. - messages: Vec, - }, - - /// A document could not be read or parsed. - #[error("{0}")] - Malformed(String), - - /// A visible definition belongs to a read-only, lower-precedence layer. - #[error( - "workflow '{id}' comes from repository default {path}; save it to your Medulla home before deleting it" - )] - ReadOnlyDefinition { - /// The workflow the operator tried to delete. - id: WorkflowId, - /// The lower-precedence definition that remains untouched. - path: PathBuf, - }, - - /// The filesystem refused an operation. - #[error("{path}: {source}")] - Io { - /// The path being operated on. - path: PathBuf, - /// The underlying failure. - #[source] - source: std::io::Error, - }, - - /// The engine refused to compile or run the graph. - #[error("{0}")] - Engine(String), - - /// A dispatch to a harness ran out of time before it replied. - /// - /// Kept apart from the three below because the operator's next step differs - /// for each: a timeout is worth retrying, an abort was deliberate, a harness - /// error wants reading, and an unreachable harness wants configuring. - #[error("the harness did not respond in time")] - DispatchTimeout, - - /// A dispatch was aborted before it replied. - #[error("the turn was aborted")] - DispatchAborted, - - /// The harness ran and reported a failure of its own. - #[error("harness: {0}")] - Harness(String), - - /// The dispatch never reached a harness — no transport, no worker, or the - /// waiter went away. - #[error("could not reach a harness: {0}")] - Unreachable(String), -} - -impl From for WorkflowError { - /// Preserve the shape of a dispatch failure rather than flattening it. - /// - /// The hub already distinguishes these four; collapsing them into one - /// string made a missing harness and a deliberate abort read identically at - /// every call site above. - fn from(err: crate::hub::RunError) -> Self { - use crate::hub::RunError; - match err { - RunError::Timeout => Self::DispatchTimeout, - RunError::Aborted => Self::DispatchAborted, - RunError::Worker(message) => Self::Harness(message), - // A failed node's transcript is the step's own account — the - // workflow error that surfaces the failure carries only the message. - RunError::WorkerWithTranscript { message, .. } => Self::Harness(message), - RunError::Busy(message) => Self::Unreachable(message), - // Same shape as backpressure from a workflow's point of view: the - // harness exists and is fine, it simply cannot be reached for this - // run. A workflow has no operator to hand anything back to. - RunError::Held(message) => Self::Unreachable(message), - RunError::Transport(message) => Self::Unreachable(message), - } - } -} diff --git a/src/sdk/src/workflows/types/mod.rs b/src/sdk/src/workflows/types/mod.rs deleted file mode 100644 index 21f4c310d..000000000 --- a/src/sdk/src/workflows/types/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! The data model for stored workflows, their runs, and what a host learns -//! about them. -//! -//! A *workflow* is a [`tinyflows::model::WorkflowGraph`] — the engine's own -//! portable JSON shape — plus the bookkeeping this host needs to find it, list -//! it, and say where it came from. The graph itself is deliberately not -//! re-modelled here: it is the contract shared with the engine and with the -//! sibling hosts that embed it, and a parallel Medulla-side copy would only -//! drift. -//! -//! Runs are recorded rather than merely streamed, so a workflow that paused for -//! approval or died with the process can be found again by id. -//! -//! The submodules split the model by lifetime rather than by shape, because -//! that is what decides where each type is stored: -//! -//! - [`workflow`] — the versioned document an operator edits. -//! - [`run`] — one execution's durable record, written once and never revised. -//! - [`note`] — what the host has learned about a workflow across runs. -//! - [`proposal`] — a graph change suggested but not yet made. -//! - [`error`] — the failure vocabulary every surface reports through. - -mod error; -mod note; -mod proposal; -mod run; -mod workflow; - -#[cfg(test)] -mod tests; - -pub use error::WorkflowError; -pub use note::{NoteId, NoteKind, NoteSource, WorkflowNote}; -pub use proposal::{ - fingerprint, ProposalId, ProposalStatus, ProposalVerification, WorkflowProposal, -}; -pub(crate) use run::{bounded_evidence, bounded_within}; -pub use run::{RunId, RunOrigin, RunRecord, RunStatus, RunStep}; -pub use workflow::{ - record_fingerprint, WorkflowDefaults, WorkflowId, WorkflowRecord, WorkflowRevision, - WorkflowSummary, -}; diff --git a/src/sdk/src/workflows/types/note.rs b/src/sdk/src/workflows/types/note.rs deleted file mode 100644 index 85798d375..000000000 --- a/src/sdk/src/workflows/types/note.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! What a host has learned about a workflow. -//! -//! A workflow could previously say what it *is* and what one run *did*, but -//! nothing carried across runs. Every diagnosis started from zero, so the same -//! cause was re-derived every time it recurred, and a conclusion an operator -//! reached last week was gone by the time it mattered again. -//! -//! A note is one durable claim about a workflow, and the journal is the set of -//! them. Notes are deliberately *not* part of the workflow document: they churn -//! on every failure, and the document is versioned through a twenty-entry -//! revision ring that an operator's real edit history has to fit into. - -use serde::{Deserialize, Serialize}; - -use super::run::RunId; -use super::workflow::WorkflowId; - -/// A note's identifier, unique within its workflow's journal. -pub type NoteId = String; - -/// What a note claims. -/// -/// Separated because they age differently, and the brief that reads them -/// weights them differently: an observation is evidence about one moment, a -/// constraint is a rule the next proposal has to obey. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum NoteKind { - /// Something that happened, stated without explanation. The safest kind to - /// write from automation, because it makes no claim about cause. - Observation, - /// A proposed cause, not yet confirmed. Worth recording precisely because a - /// later run either supports it or does not. - Hypothesis, - /// A rule about this workflow that any future change must respect. - Constraint, - /// A change that was made and what it was meant to fix. - Fix, - /// A change that was considered and turned down, with the reason. - /// - /// The kind that makes the loop converge rather than merely terminate: - /// without it, an idea an operator has already rejected is proposed again - /// the next time the same evidence turns up. - Rejection, -} - -/// Who wrote a note. -/// -/// An agent's claim and an operator's instruction are not the same kind of -/// thing, and a brief that presented them identically would let a model's own -/// guess outweigh what a human actually said. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", tag = "kind")] -pub enum NoteSource { - /// Written by a model during an evolution pass. - Agent { - /// The model that wrote it, when known. - #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, - }, - /// Written by a person. - Operator, - /// Written by the host itself, from a run record — no model involved. - /// - /// The kind that is always safe to write: it needs no dispatch, so it - /// survives a missing harness, a timed-out turn, and a reply that was pure - /// prose. - System, -} - -/// One durable claim about a workflow. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WorkflowNote { - /// This note's id. Leads with a zero-padded timestamp, so a lexical sort is - /// a chronological one — the same scheme workflow revisions use. - pub id: NoteId, - /// The workflow this note is about. - pub workflow_id: WorkflowId, - /// What the note claims. - pub kind: NoteKind, - /// The claim itself, in whoever's words wrote it. - pub text: String, - /// Epoch-millisecond stamp of when it was written. - pub recorded_at: u64, - /// Who wrote it. - pub source: NoteSource, - /// The runs this note is evidence from. - /// - /// Provenance rather than decoration: a note whose evidence is one flaky - /// run should not be read the same way as one drawn from five. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub run_ids: Vec, - /// The note that replaced this one, when a later note did. - /// - /// Superseded notes stay listed — an operator reading history wants to see - /// what was believed and when — but are kept out of briefs, so a model is - /// not asked to reason from a claim already known to be wrong. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub superseded_by: Option, - /// Whether this note is exempt from pruning. - /// - /// An operator's own words are pinned by default: automation writing a - /// hundred observations must not be able to evict what a person said. - #[serde(default)] - pub pinned: bool, -} - -impl WorkflowNote { - /// Whether this note should appear in a brief. - /// - /// Superseded notes are history, not context. - pub fn is_current(&self) -> bool { - self.superseded_by.is_none() - } -} diff --git a/src/sdk/src/workflows/types/proposal.rs b/src/sdk/src/workflows/types/proposal.rs deleted file mode 100644 index f3110e883..000000000 --- a/src/sdk/src/workflows/types/proposal.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! A graph change an agent suggests but does not make. -//! -//! The whole point of the type. An evolution pass reads a workflow's history -//! and often concludes something should change — but a model that edits a saved -//! graph on its own reasoning is a model that can quietly break a workflow -//! nobody was watching. So a pass produces a *proposal*: a checked, dry-run -//! patch that sits on disk until an operator accepts it. -//! -//! Proposals are host state like runs and notes, not part of the versioned -//! document, because most of them never become one. - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use super::note::NoteId; -use super::run::RunId; -use super::workflow::WorkflowId; -use crate::workflows::run::diagnose::Diagnosis; - -/// A proposal's identifier. -pub type ProposalId = String; - -/// Where a proposal stands. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProposalStatus { - /// Waiting on an operator. - Pending, - /// Applied to the saved graph. - Accepted, - /// Turned down. The reason becomes a note, so a later pass does not propose - /// it again. - Rejected, - /// The graph moved on before anyone decided. - /// - /// Kept as its own state rather than folded into "rejected": nobody - /// disagreed with this proposal, it simply cannot be applied to a graph it - /// was not computed against. - Stale, -} - -/// What checking a proposal found. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProposalVerification { - /// Whether the proposal applies cleanly and simulates without new problems. - pub ok: bool, - /// Epoch-millisecond stamp of the check. - pub verified_at: u64, - /// Why it did not pass: op errors, engine validation, or gate failures. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub messages: Vec, - /// What a dry run of the patched graph reported. Absent when the patch - /// could not be applied far enough to simulate. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub diagnosis: Option, -} - -/// A change to a workflow, argued for and checked but not made. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WorkflowProposal { - /// This proposal's id. - pub id: ProposalId, - /// The workflow it would change. - pub workflow_id: WorkflowId, - /// Epoch-millisecond stamp of when it was made. - pub created_at: u64, - /// The argument for it, in the proposer's own words. What an operator reads - /// before deciding. - pub rationale: String, - /// The engine's patch language, as raw JSON. - /// - /// Deliberately not a typed `Vec`: the ops surface only ever - /// *deserializes* that type, and keeping the proposal as the JSON it - /// arrived as means a stored proposal stays readable across engine - /// versions rather than becoming unloadable when the op enum changes. - pub ops: Value, - /// The runs that motivated it. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub evidence_runs: Vec, - /// The notes it was reasoned from. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub note_ids: Vec, - /// A fingerprint of the graph these ops were computed against. - /// - /// Checked again at accept time. Ops are positional edits to a specific - /// graph, so applying them to one that has since changed is not a merge — - /// it is a silent, arbitrary rewrite. A mismatch makes the proposal - /// [`ProposalStatus::Stale`] rather than applying it anyway. - pub base_fingerprint: String, - /// What checking it found. `None` before it has been checked. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verification: Option, - /// Where it stands. - pub status: ProposalStatus, - /// Epoch-millisecond stamp of the decision, when one was made. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub decided_at: Option, - /// Why it was turned down, when it was. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub decision_reason: Option, -} - -impl WorkflowProposal { - /// Whether this proposal is still awaiting a decision. - pub fn is_pending(&self) -> bool { - self.status == ProposalStatus::Pending - } - - /// Whether an operator could apply this proposal as it stands. - /// - /// Both halves matter: a proposal that failed verification is kept on disk - /// as evidence for the next pass, but it is not something to offer. - pub fn is_applicable(&self) -> bool { - self.is_pending() && self.verification.as_ref().is_some_and(|check| check.ok) - } -} - -/// Fingerprint a graph, for detecting that it moved under a proposal. -/// -/// SHA-256 of the graph's canonical JSON. Serialization is stable for a given -/// engine version, which is all this needs: it is a same-process, same-build -/// equality check, not a durable content address. -pub fn fingerprint(graph: &tinyflows::model::WorkflowGraph) -> String { - use sha2::{Digest, Sha256}; - let canonical = serde_json::to_vec(graph).unwrap_or_default(); - format!("{:x}", Sha256::digest(&canonical)) -} diff --git a/src/sdk/src/workflows/types/run.rs b/src/sdk/src/workflows/types/run.rs deleted file mode 100644 index 928080d82..000000000 --- a/src/sdk/src/workflows/types/run.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! One execution's durable record. -//! -//! Unlike a [`super::WorkflowRecord`], a run is written once and never revised, -//! so it needs no snapshot ring. It is the only durable evidence of what the -//! engine actually did, which is why every field here is additive: readers of -//! run files written by an older build must keep working. - -use serde::{Deserialize, Serialize}; - -use super::workflow::WorkflowId; -use crate::workflows::run::diagnose::Diagnosis; - -/// Maximum serialized bytes retained for one step input or output. -pub(crate) const MAX_EVIDENCE_BYTES: usize = 64 * 1024; - -/// Keep small evidence intact and summarize values that would bloat history. -/// -/// Execution and diagnosis retain the engine's full in-memory value. Only the -/// durable inspection copy is bounded, so one response cannot make every -/// future history listing read an arbitrarily large file. -pub(crate) fn bounded_evidence(value: &serde_json::Value) -> serde_json::Value { - bounded_within(value, MAX_EVIDENCE_BYTES) -} - -/// Keep small values intact and summarize ones larger than `max_bytes`. -/// -/// The same bounding as [`bounded_evidence`] against a caller-chosen budget. -/// The durable record uses a generous one because it is written once; a reply -/// projected for a model uses a much smaller one, because a hundred of them -/// land in the same context window. -/// -/// The wrapper shape is deliberately identical at every budget, so a reader -/// that knows how to unpack a truncated run file already knows how to unpack a -/// truncated reply. -pub(crate) fn bounded_within(value: &serde_json::Value, max_bytes: usize) -> serde_json::Value { - let serialized = serde_json::to_string(value).unwrap_or_else(|_| value.to_string()); - if serialized.len() <= max_bytes { - return value.clone(); - } - // The preview is itself embedded in JSON, so reserve half the budget for - // escaping plus the wrapper metadata. Quotes and backslashes can nearly - // double when serialized a second time. - let preview_budget = (max_bytes / 2).saturating_sub(256); - let end = serialized - .char_indices() - .map(|(index, _)| index) - .take_while(|index| *index <= preview_budget) - .last() - .unwrap_or(0); - let bounded = serde_json::json!({ - "_medullaTruncated": true, - "originalBytes": serialized.len(), - "preview": &serialized[..end], - }); - debug_assert!(serde_json::to_vec(&bounded) - .map(|body| body.len() <= max_bytes.max(512)) - .unwrap_or(false)); - bounded -} - -/// One run's identifier. Doubles as the engine checkpointer's `thread_id`, which -/// is what makes a paused run resumable across process restarts. -pub type RunId = String; - -/// Bytes of one declared input value kept on the durable record. -/// -/// Much smaller than [`MAX_EVIDENCE_BYTES`]: an input is a knob a caller turned, -/// and every surface that shows a run shows all of them at once. A repository -/// name, a branch, a PR number, a paragraph of instruction all fit; a pasted -/// transcript is summarized rather than carried into every future listing. -pub(crate) const MAX_INPUT_BYTES: usize = 4 * 1024; - -/// Who asked for a run, and from where. -/// -/// Recorded because a run record on its own cannot say why it exists. The -/// question an operator asks in front of a rail full of runs is "which of these -/// did the session I am sitting in start", and answering it needs the session's -/// own correlation key on the record — nothing about the workflow, the graph, or -/// the steps can supply it after the fact. -/// -/// Every field is optional except `kind`, because the callers differ in how much -/// they know about themselves: a `medulla workflow run` on a terminal knows it -/// is a CLI and nothing else, while a harness session knows the key its tool -/// grant was minted under. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RunOrigin { - /// What kind of caller started the run — see [`RunOrigin::SESSION`] and its - /// siblings. - /// - /// A free string rather than an enum: a build that learns a new door must - /// still be readable by one that does not, and an unknown kind displayed - /// verbatim is strictly better than a record that fails to parse. - pub kind: String, - /// The harness session this run was started from, when one was. - /// - /// The MCP grant key the session's tool server was launched under - /// (`pty-`), which is the only identifier shared by the harness - /// process and the host that spawned it. This is what nests a run under its - /// session in the Agents rail. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session: Option, - /// What to call the caller on screen, when it has a name worth showing. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, - /// The directory the run was started in. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace: Option, -} - -impl RunOrigin { - /// A run started by a harness session through the workflow MCP tools. - pub const SESSION: &'static str = "session"; - /// A run started from a terminal by `medulla workflow run`. - pub const CLI: &'static str = "cli"; - /// A run started from the operator's own Workflows pane. - pub const OPERATOR: &'static str = "operator"; - - /// An origin naming the harness session `session` started the run. - pub fn session(session: impl Into) -> Self { - Self { - kind: Self::SESSION.to_string(), - session: Some(session.into()), - ..Self::default() - } - } - - /// An origin of `kind` with nothing else known about it. - pub fn of_kind(kind: impl Into) -> Self { - Self { - kind: kind.into(), - ..Self::default() - } - } - - /// Record the directory the run started in. - pub fn in_workspace(mut self, workspace: impl Into) -> Self { - let workspace = workspace.into(); - self.workspace = (!workspace.trim().is_empty()).then_some(workspace); - self - } - - /// Record a display name for the caller. - pub fn labelled(mut self, label: impl Into) -> Self { - let label = label.into(); - self.label = (!label.trim().is_empty()).then_some(label); - self - } -} - -/// Where a run got to. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RunStatus { - /// Started and not yet settled. - Running, - /// Parked on one or more approval gates; resumable. - PendingApproval, - /// Finished successfully. - Succeeded, - /// Finished with an error. - Failed, - /// Cancelled by an operator or an abort frame. - Cancelled, - /// The process went away mid-run. Reconciled from `Running` on drop, so a - /// crashed run is never left claiming to be live. - Interrupted, -} - -impl RunStatus { - /// Whether this status is terminal — no resume or cancel applies. - pub fn is_settled(&self) -> bool { - !matches!(self, Self::Running | Self::PendingApproval) - } -} - -/// One node's execution within a run, recorded as the engine reports it. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RunStep { - /// The node this step ran. - pub node_id: String, - /// The engine's step status, lowercased. - pub status: String, - /// Wall-clock duration in milliseconds. - pub duration_ms: u128, - /// The resolved input this activation received. - /// - /// Currently recorded for agent nodes as their full prompt. Absent on - /// other node kinds and on records written before input evidence existed. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input: Option, - /// The items emitted by this activation, retained for run inspection. - /// - /// Absent on records written before step results were persisted and null - /// when the engine failed before producing an output. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output: Option, - /// Expressions that resolved to null, which are usually a wiring mistake - /// rather than an intended value. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub diagnostics: Vec, - /// What the harness said while it ran this step, in order. - /// - /// Recorded for `agent` nodes only, and only for steps run through a - /// dispatch that collects one. Absent on every other node kind and on - /// records written before transcripts existed — additive, like every other - /// field here, so an older build still reads these files. - /// - /// This is the answer to the question [`input`](Self::input) and - /// [`output`](Self::output) cannot reach: those say what the step was asked - /// and what it returned, while a step that returned something surprising is - /// explained by what happened in between. See - /// [`crate::harness_transcript`] for the bounds it is kept under. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub transcript: Vec, -} - -/// A durable record of one workflow run. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RunRecord { - /// This run's id, and the checkpointer thread id that can resume it. - pub id: RunId, - /// The workflow that ran. - pub workflow_id: WorkflowId, - /// Where the run got to. - pub status: RunStatus, - /// Epoch-millisecond start stamp. - pub started_at: u64, - /// Epoch-millisecond settle stamp, absent while running. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub finished_at: Option, - /// The values supplied for the workflow's *declared* inputs, by name. - /// - /// The single most useful thing about a run that the graph cannot supply: - /// two runs of one workflow differ only in what was passed to them, so a - /// history that omitted this listed the same sentence over and over. Bounded - /// per value ([`MAX_INPUT_BYTES`]) rather than whole, because every surface - /// that lists runs shows all of a run's inputs at once. - /// - /// Empty on a workflow that declares no inputs, and on records written - /// before this field existed — the two are indistinguishable, which is - /// acceptable: neither has anything to show. - #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] - pub inputs: serde_json::Map, - /// The free-form trigger payload the run was started with. - /// - /// Separate from [`inputs`](Self::inputs) because they are separate - /// arguments: `inputs` names the workflow's declared parameters, while this - /// is whatever the trigger handed the graph. Absent when it was empty, and - /// on records written before this field existed. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trigger: Option, - /// Who started this run, and from where. - /// - /// Absent on records written before this field existed, and on a run whose - /// caller could not say anything about itself. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub origin: Option, - /// Steps in completion order. - #[serde(default)] - pub steps: Vec, - /// Node ids currently awaiting approval. Non-empty exactly when the status - /// is [`RunStatus::PendingApproval`], and the set a resume must name. - #[serde(default)] - pub pending_approvals: Vec, - /// Failure message, when the run failed. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, - /// One line saying what this run did, written when it settled. - /// - /// The observer builds this to narrate the run live; keeping it means a - /// reader after the fact — an operator scanning history, an agent reviewing - /// what a workflow has been doing — gets the same sentence rather than - /// re-deriving a worse one from the steps. - /// - /// Absent on records written before this field existed, and on runs that - /// never settled through the engine. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub summary: Option, - /// What was wrong with the run beyond whether it failed. - /// - /// Null bindings, errors an `on_error` policy swallowed, and nodes that - /// never executed. Previously produced only for *dry* runs, which meant the - /// runs that actually mattered were the ones with no diagnosis at all. - /// - /// Absent on records written before this field existed. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub diagnosis: Option, -} - -impl RunRecord { - /// Record what this run was started with. - /// - /// Values are bounded individually rather than as one blob, so a single - /// oversized argument does not summarize away the six small ones beside it — - /// which are usually the ones that identify the run. - pub fn with_inputs( - mut self, - inputs: &serde_json::Map, - trigger: &serde_json::Value, - ) -> Self { - self.inputs = inputs - .iter() - .map(|(name, value)| (name.clone(), bounded_within(value, MAX_INPUT_BYTES))) - .collect(); - // An empty trigger is what almost every caller passes, and recording - // `{}` on every run would put a meaningless row on every run view. - self.trigger = match trigger { - serde_json::Value::Null => None, - serde_json::Value::Object(map) if map.is_empty() => None, - value => Some(bounded_within(value, MAX_INPUT_BYTES)), - }; - self - } - - /// Record who asked for this run. - pub fn with_origin(mut self, origin: Option) -> Self { - self.origin = origin; - self - } - - /// How long the run took, once it has settled. - pub fn duration_ms(&self) -> Option { - self.finished_at - .map(|finished| finished.saturating_sub(self.started_at)) - } - - /// How many recorded steps ended in an engine-reported failure. - pub fn failed_steps(&self) -> usize { - self.steps - .iter() - .filter(|step| { - matches!( - step.status.trim().to_ascii_lowercase().as_str(), - "failed" | "error" - ) - }) - .count() - } -} diff --git a/src/sdk/src/workflows/types/tests.rs b/src/sdk/src/workflows/types/tests.rs deleted file mode 100644 index 4e1e84b45..000000000 --- a/src/sdk/src/workflows/types/tests.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Unit tests for the workflow data model. -//! -//! These lean on literal JSON rather than round-tripping Rust values wherever a -//! wire shape is the actual contract: run records and workflow documents are -//! read back from disk by builds other than the one that wrote them, so the -//! spelling of a field is the thing worth asserting. - -use serde_json::json; - -use super::*; - -/// A minimal single-node graph, enough to build a record around. -fn graph() -> tinyflows::model::WorkflowGraph { - serde_json::from_value(json!({ - "nodes": [{ - "id": "start", - "kind": "trigger", - "name": "start", - "config": { "trigger_kind": "manual" } - }], - "edges": [] - })) - .expect("the fixture graph should parse") -} - -fn record() -> WorkflowRecord { - WorkflowRecord { - id: "demo".into(), - name: "Demo".into(), - description: "A demo workflow".into(), - enabled: true, - defaults: Default::default(), - graph: graph(), - source_path: None, - } -} - -#[test] -fn summary_counts_nodes_and_reads_the_trigger_kind() { - let summary = record().summary(); - - assert_eq!(summary.id, "demo"); - assert_eq!(summary.node_count, 1); - assert_eq!(summary.trigger_kind.as_deref(), Some("manual")); -} - -#[test] -fn a_document_without_enabled_defaults_to_enabled() { - let parsed: WorkflowRecord = serde_json::from_value(json!({ - "id": "demo", - "name": "Demo", - "graph": graph(), - })) - .expect("a document may omit `enabled` and `description`"); - - assert!(parsed.enabled); - assert_eq!(parsed.description, ""); -} - -#[test] -fn run_status_settles_everything_but_running_and_pending() { - assert!(!RunStatus::Running.is_settled()); - assert!(!RunStatus::PendingApproval.is_settled()); - for status in [ - RunStatus::Succeeded, - RunStatus::Failed, - RunStatus::Cancelled, - RunStatus::Interrupted, - ] { - assert!(status.is_settled(), "{status:?} should be settled"); - } -} - -#[test] -fn run_records_use_camel_case_on_the_wire() { - let wire = serde_json::to_value(RunRecord { - id: "run-1".into(), - workflow_id: "demo".into(), - status: RunStatus::Succeeded, - started_at: 1, - finished_at: Some(2), - steps: vec![RunStep { - node_id: "start".into(), - status: "ok".into(), - duration_ms: 3, - input: Some(json!("inspect this")), - output: None, - diagnostics: Vec::new(), - transcript: Vec::new(), - }], - pending_approvals: Vec::new(), - error: None, - inputs: Default::default(), - trigger: None, - origin: None, - summary: None, - diagnosis: None, - }) - .expect("a run record should serialize"); - - assert!(wire.get("workflowId").is_some()); - assert!(wire.get("startedAt").is_some()); - assert_eq!(wire["status"], json!("succeeded")); - assert!( - wire.get("error").is_none(), - "an absent error should not be written" - ); - assert!(wire["steps"][0].get("nodeId").is_some()); - assert_eq!(wire["steps"][0]["input"], json!("inspect this")); -} - -#[test] -fn a_run_file_written_before_evidence_existed_still_parses() { - // A literal, not a round trip: run records are read back by builds other - // than the one that wrote them, and every one of these files is already on - // operators' disks. If `summary` or `diagnosis` ever stops defaulting, this - // is the test that says so rather than a support ticket. - let parsed: RunRecord = serde_json::from_value(json!({ - "id": "run-old", - "workflowId": "demo", - "status": "failed", - "startedAt": 1, - "finishedAt": 2, - "steps": [{ "nodeId": "start", "status": "error", "durationMs": 3 }], - "pendingApprovals": [], - "error": "boom" - })) - .expect("a run record from before the evidence fields must still load"); - - assert_eq!(parsed.status, RunStatus::Failed); - assert!(parsed.steps[0].input.is_none()); - assert!(parsed.summary.is_none()); - assert!(parsed.diagnosis.is_none()); -} - -#[test] -fn durable_step_evidence_is_bounded_without_changing_small_values() { - let small = json!({ "answer": "still structured" }); - assert_eq!(bounded_evidence(&small), small); - - let large = json!({ "body": "x".repeat(run::MAX_EVIDENCE_BYTES * 2) }); - let bounded = bounded_evidence(&large); - assert_eq!(bounded["_medullaTruncated"], true); - assert!(bounded["originalBytes"].as_u64().unwrap() > run::MAX_EVIDENCE_BYTES as u64); - assert!( - serde_json::to_vec(&bounded).unwrap().len() <= run::MAX_EVIDENCE_BYTES, - "the persisted summary itself must remain bounded" - ); - - let escaping = json!({ "body": "\\\"".repeat(run::MAX_EVIDENCE_BYTES) }); - assert!( - serde_json::to_vec(&bounded_evidence(&escaping)) - .unwrap() - .len() - <= run::MAX_EVIDENCE_BYTES - ); -} - -#[test] -fn run_evidence_is_omitted_from_the_wire_when_absent() { - // The other half of the compatibility bargain: a record with no evidence - // must not start writing null keys into files an older build reads. - let wire = serde_json::to_value(RunRecord { - id: "run-1".into(), - workflow_id: "demo".into(), - status: RunStatus::Running, - started_at: 1, - finished_at: None, - steps: Vec::new(), - pending_approvals: Vec::new(), - error: None, - inputs: Default::default(), - trigger: None, - origin: None, - summary: None, - diagnosis: None, - }) - .expect("a run record should serialize"); - - assert!(wire.get("summary").is_none()); - assert!(wire.get("diagnosis").is_none()); - assert!(wire.get("inputs").is_none()); - assert!(wire.get("trigger").is_none()); - assert!(wire.get("origin").is_none()); -} - -#[test] -fn what_a_run_was_started_with_survives_the_wire() { - let record = RunRecord { - id: "run-1".into(), - workflow_id: "demo".into(), - status: RunStatus::Running, - started_at: 1, - finished_at: None, - steps: Vec::new(), - pending_approvals: Vec::new(), - error: None, - inputs: Default::default(), - trigger: None, - origin: None, - summary: None, - diagnosis: None, - } - .with_inputs( - &json!({ "repo": "acme/api" }).as_object().cloned().unwrap(), - &json!({ "event": "push" }), - ) - .with_origin(Some(RunOrigin::session("pty-1").in_workspace("/tmp/work"))); - - let wire = serde_json::to_value(&record).expect("a run record should serialize"); - assert_eq!(wire["inputs"]["repo"], json!("acme/api")); - assert_eq!(wire["trigger"]["event"], json!("push")); - assert_eq!(wire["origin"]["kind"], json!("session")); - assert_eq!(wire["origin"]["session"], json!("pty-1")); - assert_eq!(wire["origin"]["workspace"], json!("/tmp/work")); - - let back: RunRecord = serde_json::from_value(wire).expect("and parse back"); - assert_eq!(back, record); -} - -#[test] -fn an_oversized_input_is_summarized_rather_than_carried_whole() { - let record = crate::workflows::new_run_record("run-1", "demo", 1).with_inputs( - &json!({ "body": "x".repeat(run::MAX_INPUT_BYTES * 3) }) - .as_object() - .cloned() - .unwrap(), - &json!({}), - ); - assert_eq!(record.inputs["body"]["_medullaTruncated"], json!(true)); - assert!( - serde_json::to_vec(&record.inputs["body"]).unwrap().len() <= run::MAX_INPUT_BYTES, - "one oversized input must not bloat every listing that shows it" - ); -} - -#[test] -fn an_empty_trigger_is_not_recorded_as_a_value() { - let record = crate::workflows::new_run_record("run-1", "demo", 1) - .with_inputs(&Default::default(), &json!({})); - assert!(record.trigger.is_none()); -} diff --git a/src/sdk/src/workflows/types/workflow.rs b/src/sdk/src/workflows/types/workflow.rs deleted file mode 100644 index 4c23acf38..000000000 --- a/src/sdk/src/workflows/types/workflow.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! The stored workflow document and its listing and history views. -//! -//! These are the versioned half of the model: every write to a -//! [`WorkflowRecord`] snapshots the superseded copy as a [`WorkflowRevision`], -//! which is what makes an edit an operator disagrees with reversible. - -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; -use tinyflows::model::{WorkflowGraph, WorkflowInput}; - -/// A workflow's stable identifier: the `id` in its document, defaulting to the -/// filename stem when the document omits one. -pub type WorkflowId = String; - -/// A stored workflow: the engine graph plus where this host found it. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct WorkflowRecord { - /// The workflow's stable id. - pub id: WorkflowId, - /// Display name; falls back to the id when the document omits one. - pub name: String, - /// Operator-facing description of what the workflow does. - #[serde(default)] - pub description: String, - /// Whether the workflow may be run. A disabled workflow still lists and - /// validates, so an operator can repair one without it firing. - #[serde(default = "default_enabled")] - pub enabled: bool, - /// What every `agent` node in this workflow runs on unless it says - /// otherwise. - #[serde(default, skip_serializing_if = "WorkflowDefaults::is_empty")] - pub defaults: WorkflowDefaults, - /// The engine graph. - pub graph: WorkflowGraph, - /// The file this record was read from, when it came from disk. `None` for a - /// graph built in memory (an agent's draft, an import not yet saved). - #[serde(skip_serializing_if = "Option::is_none", default)] - pub source_path: Option, -} - -/// Workflows are enabled unless a document says otherwise. -fn default_enabled() -> bool { - true -} - -/// A workflow's standing choice of harness and model. -/// -/// The middle layer between an `agent` node's own `config` and the host's -/// `workflows` config, and the one an author reaches for most: "this whole plan -/// runs on Codex" is a property of the plan, not of every node in it and not of -/// the machine that happens to run it. -/// -/// Stored as free-form strings rather than parsed types because a workflow may -/// legitimately name a custom harness preset only some hosts expose. The -/// meaning of these strings — and the refusal of one that cannot be a harness — -/// lives in [`crate::flow_engine::harness_choice`]. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WorkflowDefaults { - /// The harness every `agent` node runs on unless it names its own: a - /// built-in CLI (`claude`, `codex`, `opencode`) or a custom preset id. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub harness: Option, - /// The model hint sent with every dispatch this workflow makes. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, -} - -impl WorkflowDefaults { - /// Whether this workflow states no preference at all. - /// - /// Kept so an unset block is omitted from the document entirely: a file an - /// operator opens should not grow two null fields per workflow to say - /// nothing. - pub fn is_empty(&self) -> bool { - self.harness.is_none() && self.model.is_none() - } - - /// This block as a preference layer the dispatch path understands. - /// - /// # Errors - /// - /// Returns a sentence when `harness` names something that cannot be a - /// harness. - pub fn preference(&self) -> Result { - let harness = match self.harness.as_deref().map(str::trim) { - Some(name) if !name.is_empty() => { - Some(crate::flow_engine::HarnessSelector::parse(name)?) - } - _ => None, - }; - Ok(crate::flow_engine::HarnessPreference { - harness, - model: self - .model - .as_deref() - .map(str::trim) - .filter(|model| !model.is_empty()) - .map(str::to_string), - }) - } -} - -impl WorkflowRecord { - /// The listing view of this record. - pub fn summary(&self) -> WorkflowSummary { - WorkflowSummary { - id: self.id.clone(), - name: self.name.clone(), - description: self.description.clone(), - enabled: self.enabled, - node_count: self.graph.nodes.len(), - trigger_kind: self.trigger_kind(), - inputs: self.inputs().to_vec(), - } - } - - /// The workflow's declared inputs — what a caller must supply to run it. - /// - /// Lives on the engine graph, so this is a shorthand rather than a second - /// copy. Empty for a workflow that takes none. - pub fn inputs(&self) -> &[WorkflowInput] { - &self.graph.inputs - } - - /// The graph's trigger kind, as a lowercase string. - /// - /// Read out of the trigger node's free-form config rather than a typed - /// field, because that is where the engine keeps it. `None` when the graph - /// has no single trigger — which validation will also report, so this stays - /// quiet rather than duplicating the error. - pub fn trigger_kind(&self) -> Option { - let trigger = self.graph.trigger()?; - trigger - .config - .get("trigger_kind") - .and_then(|value| value.as_str()) - .map(str::to_string) - } -} - -/// Fingerprint every persisted field in a workflow record. -/// -/// The source path is a property of the store read, not part of the workflow -/// document, so it is deliberately excluded. Definition compare-and-swap -/// writes use this fingerprint because a graph-only comparison would miss -/// concurrent changes to defaults or other workflow metadata. -pub fn record_fingerprint(record: &WorkflowRecord) -> String { - use sha2::{Digest, Sha256}; - - let mut persisted = record.clone(); - persisted.source_path = None; - let canonical = serde_json::to_vec(&persisted).unwrap_or_default(); - format!("{:x}", Sha256::digest(&canonical)) -} - -/// A workflow reduced to what a list needs — the shape advertised to the -/// orchestrator and rendered in the TUI, so neither has to hold whole graphs. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WorkflowSummary { - /// The workflow's stable id. - pub id: WorkflowId, - /// Display name. - pub name: String, - /// Operator-facing description. - pub description: String, - /// Whether the workflow may be run. - pub enabled: bool, - /// How many nodes the graph has. - pub node_count: usize, - /// The trigger kind, when the graph declares exactly one trigger. - #[serde(skip_serializing_if = "Option::is_none")] - pub trigger_kind: Option, - /// The workflow's declared inputs — what a caller must supply to run it. - /// - /// Carried on the *listing* view deliberately: the TUI has to know whether - /// to prompt before it runs the selected workflow, and the orchestrator has - /// to know what to collect before it asks. Both would otherwise need a - /// second fetch of the whole graph just to answer "does this take - /// arguments?". Omitted from the wire for a workflow that takes none. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub inputs: Vec, -} - -/// A copy of a workflow from before it was last written over. -/// -/// Kept so an operator can disagree with an edit after the fact. That matters -/// most for the copilot, which writes to the store directly and would otherwise -/// leave a misread instruction as the only surviving version of a graph. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WorkflowRevision { - /// This snapshot's id, unique within its workflow. Sorts chronologically. - pub id: String, - /// Epoch-millisecond stamp of when this copy stopped being current. - /// - /// When it was *superseded*, not when it was authored — a revision is - /// named by the edit that replaced it, which is what an operator scanning - /// history is looking for. - pub superseded_at: u64, - /// The workflow as it was. - pub record: WorkflowRecord, -} diff --git a/src/sdk/tests/feature_workflow_dispatch.rs b/src/sdk/tests/feature_workflow_dispatch.rs index 9b495db49..c660664b3 100644 --- a/src/sdk/tests/feature_workflow_dispatch.rs +++ b/src/sdk/tests/feature_workflow_dispatch.rs @@ -221,27 +221,43 @@ fn installed_fingerprint(home: &std::path::Path, id: &str) -> String { medulla::workflows::record_fingerprint(&record) } -/// Drain the peer's inbox until a frame of `kind` shows up. -async fn wait_for(peer: &LocalBridge, kind: TaskFrameKind) -> TaskFrame { - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - let mut seen: Vec = Vec::new(); - loop { - for message in peer.drain_inbox(50).await { - if let Some(frame) = decode_task_frame(&message.text) { - seen.push(frame); +/// The frames drained from a peer so far. +/// +/// The buffer has to outlive one wait, because `drain_inbox` is destructive: a +/// frame pulled off the queue while waiting for a *different* kind is gone. A +/// helper that kept its matches in a local vector therefore discarded them, and +/// that is not hypothetical — an `Ack` and the `Reply` that follows it commonly +/// land in the same drain window, so waiting for the Ack consumed the Reply and +/// the next wait timed out having seen nothing. +#[derive(Default)] +struct Inbox { + seen: Vec, +} + +impl Inbox { + /// Drain the peer's inbox until a frame of `kind` shows up. + async fn wait_for(&mut self, peer: &LocalBridge, kind: TaskFrameKind) -> TaskFrame { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + for message in peer.drain_inbox(50).await { + if let Some(frame) = decode_task_frame(&message.text) { + self.seen.push(frame); + } } + let found = self.seen.iter().position(|frame| frame.kind == kind); + if let Some(index) = found { + return self.seen.remove(index); + } + assert!( + tokio::time::Instant::now() < deadline, + "no {kind:?} frame within the deadline; saw {:?}", + self.seen + .iter() + .map(|f| (f.kind, f.text.clone())) + .collect::>() + ); + tokio::time::sleep(Duration::from_millis(5)).await; } - if let Some(frame) = seen.iter().find(|frame| frame.kind == kind) { - return frame.clone(); - } - assert!( - tokio::time::Instant::now() < deadline, - "no {kind:?} frame within the deadline; saw {:?}", - seen.iter() - .map(|f| (f.kind, f.text.clone())) - .collect::>() - ); - tokio::time::sleep(Duration::from_millis(5)).await; } } @@ -251,6 +267,7 @@ async fn a_frame_naming_a_workflow_runs_the_whole_graph_on_the_worker() { install_workflow(home.path(), "two-step"); let prompts = Arc::new(Mutex::new(Vec::new())); let (_host, peer) = worker(home.path(), recording_executor(prompts.clone())); + let mut inbox = Inbox::default(); let fingerprint = installed_fingerprint(home.path(), "two-step"); peer.send( @@ -260,11 +277,11 @@ async fn a_frame_naming_a_workflow_runs_the_whole_graph_on_the_worker() { .await .unwrap(); - let ack = wait_for(&peer, TaskFrameKind::Ack).await; + let ack = inbox.wait_for(&peer, TaskFrameKind::Ack).await; assert_eq!(ack.task_id, "w1"); assert_eq!(ack.text, "workflow accepted"); - let reply = wait_for(&peer, TaskFrameKind::Reply).await; + let reply = inbox.wait_for(&peer, TaskFrameKind::Reply).await; assert_eq!(reply.correlation_id.as_deref(), Some("corr-w1")); assert!( reply.text.contains("completed 2 steps"), @@ -287,6 +304,7 @@ async fn a_frame_supplies_the_selected_workflows_declared_inputs() { install_parameterized_workflow(home.path(), "parameterized"); let prompts = Arc::new(Mutex::new(Vec::new())); let (_host, peer) = worker(home.path(), recording_executor(prompts.clone())); + let mut inbox = Inbox::default(); let inputs = json!({ "repo": "acme/api" }).as_object().unwrap().clone(); let fingerprint = installed_fingerprint(home.path(), "parameterized"); @@ -303,7 +321,7 @@ async fn a_frame_supplies_the_selected_workflows_declared_inputs() { .await .unwrap(); - let reply = wait_for(&peer, TaskFrameKind::Reply).await; + let reply = inbox.wait_for(&peer, TaskFrameKind::Reply).await; assert!(reply.text.contains("completed 1 step"), "{}", reply.text); assert_eq!(prompts.lock().unwrap().as_slice(), ["acme/api"]); } @@ -325,6 +343,7 @@ async fn a_worker_refuses_a_workflow_changed_after_capability_discovery() { let prompts = Arc::new(Mutex::new(Vec::new())); let (_host, peer) = worker(home.path(), recording_executor(prompts.clone())); + let mut inbox = Inbox::default(); peer.send( "host", &frame("w-stale", "", Some("two-step"), Some(&selected_fingerprint)), @@ -332,7 +351,7 @@ async fn a_worker_refuses_a_workflow_changed_after_capability_discovery() { .await .unwrap(); - let error = wait_for(&peer, TaskFrameKind::Error).await; + let error = inbox.wait_for(&peer, TaskFrameKind::Error).await; assert!( error.text.contains("changed after it was selected"), "{error:?}" @@ -348,6 +367,7 @@ async fn the_reply_carries_the_run_as_a_work_snapshot_the_orchestrator_can_rende home.path(), recording_executor(Arc::new(Mutex::new(Vec::new()))), ); + let mut inbox = Inbox::default(); let fingerprint = installed_fingerprint(home.path(), "two-step"); peer.send( @@ -356,7 +376,7 @@ async fn the_reply_carries_the_run_as_a_work_snapshot_the_orchestrator_can_rende ) .await .unwrap(); - let reply = wait_for(&peer, TaskFrameKind::Reply).await; + let reply = inbox.wait_for(&peer, TaskFrameKind::Reply).await; // The same attachment an ordinary task's reply carries, so the existing // master-terminal rendering shows a workflow with no new code. @@ -377,6 +397,7 @@ async fn naming_a_workflow_the_worker_does_not_have_says_what_it_does_have() { home.path(), recording_executor(Arc::new(Mutex::new(Vec::new()))), ); + let mut inbox = Inbox::default(); let fingerprint = installed_fingerprint(home.path(), "two-step"); peer.send( @@ -386,7 +407,7 @@ async fn naming_a_workflow_the_worker_does_not_have_says_what_it_does_have() { .await .unwrap(); - let error = wait_for(&peer, TaskFrameKind::Error).await; + let error = inbox.wait_for(&peer, TaskFrameKind::Error).await; assert!( error.text.contains("nonexistent") && error.text.contains("two-step"), "an orchestrator should be able to correct itself from this: {}", @@ -404,6 +425,7 @@ async fn a_worker_advertises_the_workflows_it_has_installed() { home.path(), recording_executor(Arc::new(Mutex::new(Vec::new()))), ); + let mut inbox = Inbox::default(); peer.send( "host", @@ -429,7 +451,9 @@ async fn a_worker_advertises_the_workflows_it_has_installed() { .await .unwrap(); - let result = wait_for(&peer, TaskFrameKind::CapabilitiesResult).await; + let result = inbox + .wait_for(&peer, TaskFrameKind::CapabilitiesResult) + .await; let capabilities = parse_agent_capabilities(&result.text).expect("a capabilities payload"); // This is how the orchestrator learns a worker can do more than take an @@ -461,11 +485,12 @@ async fn an_ordinary_instruction_still_goes_straight_to_a_harness() { let home = tempfile::tempdir().unwrap(); let prompts = Arc::new(Mutex::new(Vec::new())); let (_host, peer) = worker(home.path(), recording_executor(prompts.clone())); + let mut inbox = Inbox::default(); peer.send("host", &frame("t1", "just do this", None, None)) .await .unwrap(); - let reply = wait_for(&peer, TaskFrameKind::Reply).await; + let reply = inbox.wait_for(&peer, TaskFrameKind::Reply).await; assert_eq!(reply.text, "ran: just do this"); assert_eq!(prompts.lock().unwrap().clone(), vec!["just do this"]); diff --git a/src/tui/src/startup_skills.rs b/src/tui/src/startup_skills.rs index 3f1898493..675f344ef 100644 --- a/src/tui/src/startup_skills.rs +++ b/src/tui/src/startup_skills.rs @@ -18,7 +18,8 @@ use anyhow::{bail, Context}; use medulla::workflows::skills::{ self, FileAction, InstallOptions, RegistrationOptions, SkillScope, SkillTarget, }; -use medulla::workflows::{FileWorkflowStore, WorkflowRecord}; +use medulla::workflows::store; +use medulla::workflows::WorkflowRecord; /// Long enough for a cold CLI start, bounded so integration never wedges boot. const CLAUDE_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(10); @@ -90,7 +91,7 @@ fn reconcile_with( } let mut report = StartupSkillsReport::default(); - let loaded = FileWorkflowStore::discover(env, cwd).load(); + let loaded = store::discover(env, cwd).load(); let workflows: Vec<_> = loaded .workflows .iter() diff --git a/src/tui/src/ui/app/render/workflows/node_preview/types.rs b/src/tui/src/ui/app/render/workflows/node_preview/types.rs index ce85c608b..036a28b17 100644 --- a/src/tui/src/ui/app/render/workflows/node_preview/types.rs +++ b/src/tui/src/ui/app/render/workflows/node_preview/types.rs @@ -59,7 +59,7 @@ impl AgentDefaults { // A `defaults` block the store would have refused cannot be // displayed as a choice, so an unreadable one is shown as no choice // at all rather than as the host's. - workflow: defaults.preference().unwrap_or_default(), + workflow: medulla::workflows::defaults_preference(defaults).unwrap_or_default(), host: HarnessPreference { harness: config .default_provider diff --git a/vendor/openhuman b/vendor/openhuman index baaf89490..5698b03b3 160000 --- a/vendor/openhuman +++ b/vendor/openhuman @@ -1 +1 @@ -Subproject commit baaf89490da42a4c0ac4be6574adec08fe094e77 +Subproject commit 5698b03b371f9d865c3aad817820cbefffcb37c1