diff --git a/Cargo.lock b/Cargo.lock index 0375fdb..e71d760 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,20 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -89,12 +103,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "arc-swap" -version = "1.9.2" +name = "arbitrary" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" dependencies = [ - "rustversion", + "derive_arbitrary", ] [[package]] @@ -144,6 +158,21 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -165,6 +194,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bs58" version = "0.5.1" @@ -181,7 +225,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", - "regex-automata", "serde_core", ] @@ -192,10 +235,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "byteorder" -version = "1.5.0" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytes" @@ -324,6 +367,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.4" @@ -356,15 +408,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "clru" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "cmake" version = "0.1.58" @@ -519,6 +562,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "darling" version = "0.20.11" @@ -588,20 +642,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "data-encoding" version = "2.11.1" @@ -648,6 +688,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -717,6 +768,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.1", + "block2", + "libc", "objc2", ] @@ -758,6 +811,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -824,13 +886,14 @@ dependencies = [ ] [[package]] -name = "faster-hex" -version = "0.10.0" +name = "fancy-regex" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" dependencies = [ - "heapless", - "serde", + "bit-set", + "regex-automata", + "regex-syntax", ] [[package]] @@ -839,16 +902,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -862,21 +915,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", - "libz-sys", "miniz_oxide", + "zlib-rs", ] [[package]] -name = "fnv" -version = "1.0.7" +name = "fluent-uri" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] [[package]] -name = "foldhash" -version = "0.1.5" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" @@ -893,6 +951,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -960,774 +1028,43 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "gix" -version = "0.77.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d8284d86a2f5c0987fbf7219a128815cc04af5a18f5fd7eec6a76d83c2b78cc" -dependencies = [ - "gix-actor", - "gix-attributes", - "gix-command", - "gix-commitgraph", - "gix-config", - "gix-date", - "gix-diff", - "gix-dir", - "gix-discover", - "gix-features", - "gix-filter", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-hashtable", - "gix-ignore", - "gix-index", - "gix-lock", - "gix-object", - "gix-odb", - "gix-pack", - "gix-path", - "gix-pathspec", - "gix-protocol", - "gix-ref", - "gix-refspec", - "gix-revision", - "gix-revwalk", - "gix-sec", - "gix-shallow", - "gix-status", - "gix-submodule", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-url", - "gix-utils", - "gix-validate", - "gix-worktree", - "smallvec", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-actor" -version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c345528d405eab51d20f505f5fe1a4680973953694e0292c6bbe97827daa55c4" -dependencies = [ - "bstr", - "gix-date", - "gix-utils", - "itoa", - "thiserror 2.0.20", - "winnow", -] - -[[package]] -name = "gix-attributes" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dabf8a50f1558c3a55d978440c7c4f22f87ac897bef03b4edbc96f6115966" -dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-quote", - "gix-trace", - "kstring", - "smallvec", - "thiserror 2.0.20", - "unicode-bom", -] - -[[package]] -name = "gix-bitmap" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d982fc7ef0608e669851d0d2a6141dae74c60d5a27e8daa451f2a4857bbf41e2" -dependencies = [ - "thiserror 2.0.20", -] - -[[package]] -name = "gix-chunk" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c356b3825677cb6ff579551bb8311a81821e184453cbd105e2fc5311b288eeb" -dependencies = [ - "thiserror 2.0.20", -] - -[[package]] -name = "gix-command" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f9c425730a654835351e6da8c3c69ba1804f8b8d4e96d027254151138d5c64" -dependencies = [ - "bstr", - "gix-path", - "gix-quote", - "gix-trace", - "shell-words", -] - -[[package]] -name = "gix-commitgraph" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efdcba8048045baf15225daf949d597c3e6183d130245e22a7fbd27084abe63a" -dependencies = [ - "bstr", - "gix-chunk", - "gix-hash", - "memmap2", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-config" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58e2ff8eef96b71f2c5e260f02ca0475caff374027c5cc5a29bda69fac67404" -dependencies = [ - "bstr", - "gix-config-value", - "gix-features", - "gix-glob", - "gix-path", - "gix-ref", - "gix-sec", - "memchr", - "smallvec", - "thiserror 2.0.20", - "unicode-bom", - "winnow", -] - -[[package]] -name = "gix-config-value" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2409cffa4fe8b303847d5b6ba8df9da9ba65d302fc5ee474ea0cac5afde79840" -dependencies = [ - "bitflags 2.13.1", - "bstr", - "gix-path", - "libc", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-date" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4a31bab8159e233094fa70d2e5fd3ec6f19e593f67e6ae01281daa48f8d8e7" -dependencies = [ - "bstr", - "itoa", - "jiff", - "smallvec", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-diff" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3506936e63ce14cd54b5f28ed06c8e43b92ef9f41c2238cc0bc271a9259b4e90" -dependencies = [ - "bstr", - "gix-attributes", - "gix-command", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-worktree", - "imara-diff", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-dir" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709d9fad32d2eb8b0129850874246569e801b6d5877e0c41356c23e9e2501e06" -dependencies = [ - "bstr", - "gix-discover", - "gix-fs", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-trace", - "gix-utils", - "gix-worktree", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-discover" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ce096dc132533802a09d6fd5d4008858f2038341dfe2e69e0d0239edb359de" -dependencies = [ - "bstr", - "dunce", - "gix-fs", - "gix-hash", - "gix-path", - "gix-ref", - "gix-sec", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-features" -version = "0.45.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56aad357ae016449434705033df644ac6253dfcf1281aad3af3af9e907560d1" -dependencies = [ - "crc32fast", - "gix-path", - "gix-trace", - "gix-utils", - "libc", - "once_cell", - "prodash", - "thiserror 2.0.20", - "walkdir", - "zlib-rs", -] - -[[package]] -name = "gix-filter" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10c02464962482570c1f94ad451a608c4391514f803e8074662d02c5629a25dc" -dependencies = [ - "bstr", - "encoding_rs", - "gix-attributes", - "gix-command", - "gix-hash", - "gix-object", - "gix-packetline", - "gix-path", - "gix-quote", - "gix-trace", - "gix-utils", - "smallvec", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-fs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "785b9c499e46bc78d7b81c148c21b3fca18655379ee729a856ed19ce50d359ec" -dependencies = [ - "bstr", - "fastrand", - "gix-features", - "gix-path", - "gix-utils", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-glob" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8546300aee4c65c5862c22a3e321124a69b654a61a8b60de546a9284812b7e2" -dependencies = [ - "bitflags 2.13.1", - "bstr", - "gix-features", - "gix-path", -] - -[[package]] -name = "gix-hash" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e153930f42ccdab8a3306b1027cd524879f6a8996cd0c474d18b0e56cae7714d" -dependencies = [ - "faster-hex", - "gix-features", - "sha1-checked", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-hashtable" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "222f7428636020bef272a87ed833ea48bf5fb3193f99852ae16fbb5a602bd2f0" -dependencies = [ - "gix-hash", - "hashbrown 0.16.1", - "parking_lot", -] - -[[package]] -name = "gix-ignore" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa727fdf54fd9fb53fa3fbb1a5c17172d3073e8e336bf155f3cac3e25b81b21" -dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-trace", - "unicode-bom", -] - -[[package]] -name = "gix-index" -version = "0.45.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea6d3e9e11647ba49f441dea0782494cc6d2875ff43fa4ad9094e6957f42051" -dependencies = [ - "bitflags 2.13.1", - "bstr", - "filetime", - "fnv", - "gix-bitmap", - "gix-features", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-traverse", - "gix-utils", - "gix-validate", - "hashbrown 0.16.1", - "itoa", - "libc", - "memmap2", - "rustix", - "smallvec", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-lock" -version = "20.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115268ae5e3b3b7bc7fc77260eecee05acca458e45318ca45d35467fa81a3ac5" -dependencies = [ - "gix-tempfile", - "gix-utils", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-object" -version = "0.54.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "363d6a879c52e4890180e0ffa7d8c9a364fd0b7e807caa368e860b80e8d0bc81" -dependencies = [ - "bstr", - "gix-actor", - "gix-date", - "gix-features", - "gix-hash", - "gix-hashtable", - "gix-path", - "gix-utils", - "gix-validate", - "itoa", - "smallvec", - "thiserror 2.0.20", - "winnow", -] - -[[package]] -name = "gix-odb" -version = "0.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165a907df369a12ed4330faf8baf7ae597aadb08cfacb4ed8649f93d90bcc0c5" -dependencies = [ - "arc-swap", - "gix-date", - "gix-features", - "gix-fs", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-pack", - "gix-path", - "gix-quote", - "parking_lot", - "tempfile", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-pack" -version = "0.64.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b04a73d5ab07ea0faae55e2c0ae6f24e36e365ac8ce140394dee3a2c89cd4366" -dependencies = [ - "clru", - "gix-chunk", - "gix-features", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-path", - "memmap2", - "smallvec", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-packetline" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fad0ffb982a289888087a165d3e849cbac724f2aa5431236b050dd2cb9c7de31" -dependencies = [ - "bstr", - "faster-hex", - "gix-trace", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-path" -version = "0.10.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cb06c3e4f8eed6e24fd915fa93145e28a511f4ea0e768bae16673e05ed3f366" -dependencies = [ - "bstr", - "gix-trace", - "gix-validate", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-pathspec" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9e0c881933c37a7ef45288d6c5779c4a7b3ad240b4c37657e1d9829eb90085" -dependencies = [ - "bitflags 2.13.1", - "bstr", - "gix-attributes", - "gix-config-value", - "gix-glob", - "gix-path", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-protocol" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02c5dfd068789442c5709e702ef42d851765f2c09a11bf0a13749d24363f4d07" -dependencies = [ - "bstr", - "gix-date", - "gix-features", - "gix-hash", - "gix-ref", - "gix-shallow", - "gix-transport", - "gix-utils", - "maybe-async", - "thiserror 2.0.20", - "winnow", -] - -[[package]] -name = "gix-quote" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96fc2ff2ec8cc0c92807f02eab1f00eb02619fc2810d13dc42679492fcc36757" -dependencies = [ - "bstr", - "gix-utils", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-ref" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccb33aa97006e37e9e83fde233569a66b02ed16fd4b0406cdf35834b06cf8a63" -dependencies = [ - "gix-actor", - "gix-features", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-path", - "gix-tempfile", - "gix-utils", - "gix-validate", - "memmap2", - "thiserror 2.0.20", - "winnow", -] - -[[package]] -name = "gix-refspec" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbba6ae5389f4021f73a2d62a4195aace7db1e8bb684b25521d3d685f57da02" -dependencies = [ - "bstr", - "gix-glob", - "gix-hash", - "gix-revision", - "gix-validate", - "smallvec", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-revision" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91898c83b18c635696f7355d171cfa74a52f38022ff89581f567768935ebc4c8" -dependencies = [ - "bstr", - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-object", - "gix-revwalk", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-revwalk" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d063699278485016863d0d2bb0db7609fd2e8ba9a89379717bf06fd96949eb2" -dependencies = [ - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-hashtable", - "gix-object", - "smallvec", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-sec" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea9962ed6d9114f7f100efe038752f41283c225bb507a2888903ac593dffa6be" -dependencies = [ - "bitflags 2.13.1", - "gix-path", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "gix-shallow" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c1c467fb9f7ec1d33613c2ea5482de514bcb84b8222a793cdc4c71955832356" -dependencies = [ - "bstr", - "gix-hash", - "gix-lock", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-status" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0d94c685a831c679ca5454c22f350e8c233f50dcf377ca00d858bcba9696d2" -dependencies = [ - "bstr", - "filetime", - "gix-diff", - "gix-dir", - "gix-features", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-worktree", - "portable-atomic", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-submodule" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efee2a61198413d80de10028aa507344537827d776ade781760130721bec2419" -dependencies = [ - "bstr", - "gix-config", - "gix-path", - "gix-pathspec", - "gix-refspec", - "gix-url", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-tempfile" -version = "20.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad89218e74850f42d364ed3877c7291f0474c8533502df91bb877ecc5cb0dd40" -dependencies = [ - "dashmap", - "gix-fs", - "libc", - "parking_lot", - "tempfile", -] - -[[package]] -name = "gix-trace" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" - -[[package]] -name = "gix-transport" -version = "0.52.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4d4ed02a2ebe771a26111896ecda0b98b58ed35e1d9c0ccf07251c1abb4918d" -dependencies = [ - "bstr", - "gix-command", - "gix-features", - "gix-packetline", - "gix-quote", - "gix-sec", - "gix-url", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-traverse" -version = "0.51.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d052b83d1d1744be95ac6448ac02f95f370a8f6720e466be9ce57146e39f5280" -dependencies = [ - "bitflags 2.13.1", - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", - "smallvec", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-url" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff1996dfb9430b3699d89224c674169c1ae355eacc52bf30a03c0b8bffe73d9" -dependencies = [ - "bstr", - "gix-features", - "gix-path", - "percent-encoding", - "thiserror 2.0.20", -] - -[[package]] -name = "gix-utils" -version = "0.3.5" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "bstr", - "fastrand", - "getrandom 0.4.3", - "unicode-normalization", + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", ] [[package]] -name = "gix-validate" -version = "0.10.1" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1e63a5b516e970a594f870ed4571a8fdcb8a344e7bd407a20db8bd61dbfde4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "bstr", - "thiserror 2.0.20", + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] -name = "gix-worktree" -version = "0.46.0" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfb7ce8cdbfe06117d335d1ad329351468d20331e0aafd108ceb647c1326aca" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "bstr", - "gix-attributes", - "gix-features", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-validate", + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1768,61 +1105,21 @@ dependencies = [ "tracing", ] -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "hash32", - "stable_deref_trait", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] @@ -2087,15 +1384,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "imara-diff" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "indexmap" version = "1.9.3" @@ -2274,12 +1562,56 @@ dependencies = [ ] [[package]] -name = "kstring" -version = "2.0.4" +name = "jsonschema" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum 0.28.0", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.58.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b609e7ca5ea38f093c20a4a102335b247221c9643b7a6bc3510f196f99499a9e" +checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96" dependencies = [ - "static_assertions", + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", ] [[package]] @@ -2297,17 +1629,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libz-sys" -version = "1.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2347,17 +1668,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "maybe-async" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "memchr" version = "2.8.3" @@ -2365,13 +1675,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "memmap2" -version = "0.9.11" +name = "micromap" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" -dependencies = [ - "libc", -] +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" [[package]] name = "mime" @@ -2406,12 +1713,93 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2502,6 +1890,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2591,15 +1985,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prodash" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6efc566849d3d9d737c5cb06cc50e48950ebe3d3f9d70631490fff3a07b139" -dependencies = [ - "parking_lot", -] - [[package]] name = "quinn" version = "0.11.11" @@ -2772,6 +2157,23 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "referencing" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -3217,16 +2619,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha1-checked" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" -dependencies = [ - "digest", - "sha1", -] - [[package]] name = "sha2" version = "0.10.9" @@ -3238,12 +2630,6 @@ dependencies = [ "digest", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "2.0.1" @@ -3306,12 +2692,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" @@ -3324,7 +2704,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -3339,6 +2728,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3414,16 +2815,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -3677,29 +3068,31 @@ dependencies = [ "clap", "cliclack", "console", - "flate2", - "gix", + "ctrlc", "ignore", + "jsonschema", + "libc", "rstest", "serde", "serde_json", "sha2", "supports-hyperlinks", - "tar", "tempfile", "thiserror 2.0.20", "toml", "toml_edit", "tracel-client", + "tracel-job", "url", "walkdir", "webbrowser", + "zip", ] [[package]] name = "tracel-client" version = "0.21.0" -source = "git+https://github.com/tracel-ai/burn-central-client?rev=5a618779e8b9288f51415da6af4f385194b6e950#5a618779e8b9288f51415da6af4f385194b6e950" +source = "git+https://github.com/tracel-ai/burn-central-client?rev=8b909baa569effcd4bf336a1256e23be25036f01#8b909baa569effcd4bf336a1256e23be25036f01" dependencies = [ "directories", "reqwest", @@ -3707,13 +3100,26 @@ dependencies = [ "serde", "serde_json", "serde_with", - "strum", + "strum 0.27.2", "thiserror 2.0.20", "tracing", "tungstenite", "uuid", ] +[[package]] +name = "tracel-job" +version = "0.11.0" +source = "git+https://github.com/tracel-ai/tracel?rev=84d5b9a3f6c9c1fee2c3401073a50c56bdc536b4#84d5b9a3f6c9c1fee2c3401073a50c56bdc536b4" +dependencies = [ + "clap", + "clap_complete", + "referencing", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "tracel-xtask" version = "5.0.0" @@ -3724,7 +3130,7 @@ dependencies = [ "clap", "log", "regex", - "strum", + "strum 0.27.2", "tracel-xtask-macros", "tracel-xtask-utils", "ureq", @@ -3755,7 +3161,7 @@ dependencies = [ "rand 0.9.5", "regex", "serde_json", - "strum", + "strum 0.27.2", ] [[package]] @@ -3828,10 +3234,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "unicode-bom" -version = "2.0.3" +name = "unicode-general-category" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" @@ -3845,15 +3251,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-width" version = "0.2.2" @@ -3957,10 +3354,14 @@ dependencies = [ ] [[package]] -name = "vcpkg" -version = "0.2.15" +name = "uuid-simd" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] [[package]] name = "version_check" @@ -3968,6 +3369,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -4306,7 +3713,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" name = "xtask" version = "0.5.1" dependencies = [ - "strum", + "strum 0.27.2", "tracel-xtask", "tracing", ] @@ -4428,11 +3835,24 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zlib-rs" -version = "0.5.5" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 94b58c0..be58a01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ edition = "2024" version = "0.5.1" readme = "README.md" license = "MIT OR Apache-2.0" -rust-version = "1.87.0" +rust-version = "1.97.0" authors = ["Tracel Technologies Inc."] repository = "https://github.com/tracel-ai/tracel-cli" keywords = ["tracel", "burn", "cli"] @@ -21,7 +21,7 @@ categories = ["command-line-interface", "development-tools"] anyhow = "1.0.104" toml = "0.8.23" toml_edit = "0.22.27" -clap = { version = "4.6.4", features = ["derive", "cargo"] } +clap = { version = "4.6.4", features = ["derive", "cargo", "string"] } derive-new = { version = "0.7.0", default-features = false } tracing = "0.1.44" serde = { version = "1.0.229", default-features = false, features = ["derive"] } @@ -35,22 +35,21 @@ supports-hyperlinks = "3.2.0" webbrowser = "1.2.4" url = "2.5.8" cargo_metadata = { version = "0.20.0", features = ["builder"] } -flate2 = { version = "1.1.9", default-features = false, features = ["zlib"] } -tar = { version = "0.4.46", default-features = false } walkdir = "2" +zip = { version = "6.0.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } ignore = "0.4.30" -gix = { version = "0.77.0", default-features = false, features = [ - "dirwalk", - "status", -] } chrono = "0.4.45" strum = { version = "0.27.2", features = ["derive"] } +jsonschema = { version = "0.58.6", default-features = false } tempfile = "3.27.0" +ctrlc = { version = "3.5.2", features = ["termination"] } +libc = "0.2.189" rstest = { version = "0.27.0", default-features = false } ## External Crate -tracel-client = { git = "https://github.com/tracel-ai/burn-central-client", rev = "5a618779e8b9288f51415da6af4f385194b6e950" } +tracel-client = { git = "https://github.com/tracel-ai/burn-central-client", rev = "8b909baa569effcd4bf336a1256e23be25036f01" } +tracel-job = { git = "https://github.com/tracel-ai/tracel", rev = "84d5b9a3f6c9c1fee2c3401073a50c56bdc536b4" } ## Crate tracel-cli = { version = "0.5.0", path = "crates/tracel-cli" } diff --git a/README.md b/README.md index d83a5f8..862d0e3 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ The Tracel CLI (`tracel`) works with the and run training on compute providers. Together with the [Tracel SDK](https://github.com/tracel-ai/tracel) in your project, it lets you: -- run your project locally and browse its experiments, metrics and logs; +- run your program's jobs, on your machine or on compute providers, and browse + their experiments, metrics and logs; - package your code for compute providers; - push, pull and promote models, and download artifacts and datasets. @@ -35,7 +36,7 @@ cd tracel-cli cargo install --path crates/tracel-cli ``` -You need Rust 1.87 or later, an account on the +You need Rust 1.97 or later, an account on the [Tracel Console](https://console.tracel.ai/), and the Tracel SDK in your Burn project. @@ -44,7 +45,8 @@ project. ```bash tracel login # approve the login in your browser tracel init # link this workspace to a Console project -tracel train # run it locally with `cargo run` +tracel run --list # list the jobs your program defines +tracel run train # run one, recording it on the Console tracel experiments list ``` @@ -94,12 +96,11 @@ Tracel Console, creating the project if it doesn't exist. The link is saved in ```bash tracel init -tracel init --owner my-org --name mnist --yes --allow-dirty +tracel init --owner my-org --name mnist --yes tracel unlink ``` -`init` needs a git commit, and offers to commit your changes. `--force` links a -workspace that is already linked to another project. +`--force` links a workspace that is already linked to another project. ### `tracel project` @@ -112,31 +113,46 @@ tracel project list tracel project list my-org ``` -### `tracel train` +### `tracel run` -Run the workspace locally with `cargo run`. Arguments after `--` go to your -program, and its output and exit code are the command's. +Run a job your program defines, on this machine or on a compute provider. Its +input starts from the job's example, or from the input an experiment recorded +with `--like`; `-c` merges JSON files onto it, and the job's own flags or `--set` +change single values. `tracel run --help` lists the job's flags. The run +is recorded on the Tracel Console when the workspace is linked and you're logged +in, and on this machine otherwise. Ctrl-C stops the job. + +With `--remote`, the workspace is packaged as `tracel package` does and the job +is queued on a compute provider group once you confirm, since a job may incur +costs. `--follow` then shows its logs until it finishes. Without a job, +`tracel run` is `cargo run --release`. ```bash -tracel train -- --epochs 10 +tracel run --list +tracel run train --help +tracel run train --epochs 5 --optimizer.lr 0.01 +tracel run train --set epochs=5 -c base.json --like 42 +tracel run train --offline +tracel run train --remote gpu --mode binary --yes --follow +tracel run -- --epochs 10 ``` ### `tracel package` Upload a code version of the workspace, the code compute providers run: a -compiled binary, or the source to build there. A code version is the current -commit, so `package` offers to commit your changes first. +compiled binary, or the source to build there. Packaging the same source again +gives the same code version. ```bash tracel package tracel package --mode binary --target x86_64-unknown-linux-gnu --json -tracel package --mode source --allow-dirty +tracel package --mode source ``` ### `tracel experiments` Browse the project's experiments by their number, along with their metrics and -logs. `exp` is short for `experiments`. +logs, and compare two of them. `exp` is short for `experiments`. ```bash tracel experiments list --sort created_at,desc --limit 10 @@ -144,6 +160,7 @@ tracel exp get latest tracel exp metrics 42 --metric loss tracel exp logs 42 --level warn --level error tracel exp logs 42 --follow +tracel exp compare 41 42 --metric loss ``` ### `tracel models` @@ -181,6 +198,19 @@ tracel datasets get images tracel datasets versions images ``` +### `tracel jobs` + +Browse the project's jobs on compute providers, read and follow their logs, +cancel them, and wait for them to finish. + +```bash +tracel jobs list +tracel jobs get 12 +tracel jobs logs 12 --follow +tracel jobs cancel 12 +tracel jobs wait 12 --timeout 3600 +``` + ## Development ```bash diff --git a/crates/tracel-cli/Cargo.toml b/crates/tracel-cli/Cargo.toml index a2722ca..d75b3c4 100644 --- a/crates/tracel-cli/Cargo.toml +++ b/crates/tracel-cli/Cargo.toml @@ -17,6 +17,7 @@ path = "src/bin/tracel.rs" [dependencies] tracel-client.workspace = true +tracel-job = { workspace = true, features = ["cli"] } anyhow.workspace = true clap.workspace = true console.workspace = true @@ -31,13 +32,16 @@ thiserror.workspace = true toml_edit.workspace = true url.workspace = true toml.workspace = true -gix.workspace = true cargo_metadata.workspace = true ignore.workspace = true -flate2.workspace = true -tar.workspace = true walkdir.workspace = true +zip.workspace = true +jsonschema.workspace = true +tempfile.workspace = true +ctrlc.workspace = true + +[target.'cfg(unix)'.dependencies] +libc.workspace = true [dev-dependencies] rstest.workspace = true -tempfile.workspace = true diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index bcaac22..6f63b96 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -1,7 +1,7 @@ use std::ffi::OsString; use std::io::IsTerminal; -use clap::{CommandFactory, Parser, Subcommand}; +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; use tracel_client::console::Env; use crate::commands; @@ -63,19 +63,34 @@ pub struct CliArgs { impl CliArgs { /// Parse the arguments `tracel` was started with. + /// + /// `tracel run ` also takes the job's own flags: each long flag `run` doesn't take + /// is kept in `RunArgs::job_flags`, to be read once the job is described. pub fn try_parse_args(args: I) -> Result where I: IntoIterator, - T: Into + Clone, + T: Into, { - Self::try_parse_from(args) + let args: Vec = args.into_iter().map(Into::into).collect(); + let mut matches = + commands::run::with_job_flags(Self::command(), &args).try_get_matches_from(&args)?; + let job_flags = matches + .subcommand_matches("run") + .map(commands::run::job_flags) + .unwrap_or_default(); + let mut args = Self::from_arg_matches_mut(&mut matches) + .map_err(|error| error.format(&mut Self::command()))?; + if let Some(Commands::Run(run)) = &mut args.command { + run.job_flags = job_flags; + } + Ok(args) } } #[derive(Subcommand, Debug)] pub enum Commands { - /// Run the workspace locally with `cargo run` - Train(commands::training::TrainingArgs), + /// Run a job locally or on a compute provider + Run(commands::run::RunArgs), /// Upload a code version of the workspace Package(commands::package::PackageArgs), /// Log in to the Tracel Console @@ -84,9 +99,11 @@ pub enum Commands { Logout, /// Show your login, or print an access token Auth(commands::auth::AuthArgs), - /// Browse experiments, their metrics and logs + /// Browse and compare experiments, their metrics and logs #[command(visible_alias = "exp")] Experiments(commands::experiments::ExperimentsArgs), + /// Browse, follow and cancel jobs + Jobs(commands::jobs::JobsArgs), /// Link the workspace to a Tracel Console project Init(commands::init::InitArgs), /// Unlink the workspace from its project @@ -107,12 +124,13 @@ impl Commands { /// How the command draws on a terminal. fn layout(&self) -> Layout { match self { - Self::Train(_) => Layout::Bare, + Self::Run(args) => args.layout(), Self::Auth(args) => args.layout(), Self::Package(_) => Layout::flow("Package a code version"), Self::Login(args) => args.layout(), Self::Logout => Layout::flow("Log out of the Tracel Console"), Self::Experiments(args) => args.layout(), + Self::Jobs(args) => args.layout(), Self::Init(_) => Layout::flow("Link this workspace to a Tracel Console project"), Self::Unlink(_) => Layout::flow("Unlink this workspace from its project"), Self::Me | Self::Project(_) | Self::Datasets(_) => Layout::Read, @@ -127,6 +145,8 @@ impl Commands { self, Self::Package(_) | Self::Experiments(_) + | Self::Jobs(_) + | Self::Run(_) | Self::Models(_) | Self::Artifacts(_) | Self::Datasets(_) @@ -134,10 +154,16 @@ impl Commands { } /// Choose what `auto` means: human on a terminal and JSON otherwise, except for - /// `auth token` and `train`, whose stdout is plain text for another program. + /// `auth token` and a `run` that hands over to `cargo run`, whose stdout is plain + /// text for another program. fn auto_format(&self, stdout_is_terminal: bool) -> Format { match self { - Self::Train(_) + Self::Run(commands::run::RunArgs { + job: None, + list: false, + remote: None, + .. + }) | Self::Auth(commands::auth::AuthArgs { command: commands::auth::AuthCommands::Token, }) => Format::Human, @@ -175,17 +201,34 @@ pub fn cli_main() { auto, ); - if let Some(directory) = &args.working_directory { - if let Err(error) = std::env::set_current_dir(directory) { - let error = CliError::new( - ErrorKind::Usage, - format!( - "Can't change to directory '{}': {error}", - directory.display() - ), - ); - fail_early(&error.into(), format.unwrap_or(auto)); + if let Some(directory) = &args.working_directory + && let Err(error) = std::env::set_current_dir(directory) + { + let error = CliError::new( + ErrorKind::Usage, + format!( + "Can't change to directory '{}': {error}", + directory.display() + ), + ); + fail_early(&error.into(), format.unwrap_or(auto)); + } + + // `run --help` is clap's text whatever the format or TRACEL_ENV say, so it comes + // before the environment is resolved. + if let Some(Commands::Run(run)) = &args.command + && run.help + { + let (output, terminal) = + ui::channels(Format::Human, args.no_input, Layout::Bare, Place::default()); + match commands::run::help(run, CliArgs::command(), &terminal) { + // A reader that closes early (`tracel run --help | head`) is not an error. + Ok(mut help) => { + let _ = help.print_long_help(); + } + Err(error) => fail(&error, &output, &terminal), } + return; } let Some(command) = args.command else { @@ -325,7 +368,7 @@ fn usage_format( fn handle_command(command: Commands, context: CliContext) -> anyhow::Result { match command { - Commands::Train(run_args) => commands::training::handle_command(run_args, context), + Commands::Run(run_args) => commands::run::handle_command(run_args, context), Commands::Package(package_args) => commands::package::handle_command(package_args, context), Commands::Login(login_args) => commands::login::handle_command(login_args, context), Commands::Logout => commands::logout::handle_command(context), @@ -333,6 +376,7 @@ fn handle_command(command: Commands, context: CliContext) -> anyhow::Result { commands::experiments::handle_command(experiments_args, context) } + Commands::Jobs(jobs_args) => commands::jobs::handle_command(jobs_args, context), Commands::Init(init_args) => commands::init::handle_command(init_args, context), Commands::Unlink(unlink_args) => commands::unlink::handle_command(unlink_args, context), Commands::Me => commands::me::handle_command(context), @@ -363,7 +407,7 @@ mod tests { #[rstest] #[case::auth_token(&["auth", "token"])] - #[case::train(&["train", "--", "--epochs", "1"])] + #[case::program(&["run", "--", "--epochs", "1"])] fn given_a_command_whose_stdout_is_its_own_when_piped_then_its_output_stays_text( #[case] args: &[&str], ) { @@ -372,9 +416,13 @@ mod tests { assert_eq!(format, Format::Human); } - #[test] - fn given_another_command_when_stdout_is_piped_then_its_output_is_json() { - let format = command(&["me"]).auto_format(false); + #[rstest] + #[case::me(&["me"])] + #[case::named_job(&["run", "train"])] + #[case::job_list(&["run", "--list"])] + #[case::remote_run(&["run", "--remote", "gpu"])] + fn given_another_command_when_stdout_is_piped_then_its_output_is_json(#[case] args: &[&str]) { + let format = command(args).auto_format(false); assert_eq!(format, Format::Json); } @@ -419,7 +467,7 @@ mod tests { #[test] fn given_json_after_a_double_dash_when_arguments_do_not_parse_then_it_is_not_an_output_flag() { - let format = usage_format(arguments(&["train", "--", "--json"]), None, true); + let format = usage_format(arguments(&["run", "--", "--json"]), None, true); assert_eq!(format, Format::Human); } diff --git a/crates/tracel-cli/src/commands/artifacts.rs b/crates/tracel-cli/src/commands/artifacts.rs index 2b2b334..38f899b 100644 --- a/crates/tracel-cli/src/commands/artifacts.rs +++ b/crates/tracel-cli/src/commands/artifacts.rs @@ -136,16 +136,15 @@ impl Render for ArtifactListResponse { pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Result { let project = resolve_namespace_project(&context)?.project; - if let ArtifactsCommands::Download(args) = &args.command { - if let Some(directory) = &args.directory { - if directory.as_os_str().is_empty() || (directory.exists() && !directory.is_dir()) { - return Err(CliError::new( - ErrorKind::Usage, - format!("'{}' isn't a directory.", directory.display()), - ) - .into()); - } - } + if let ArtifactsCommands::Download(args) = &args.command + && let Some(directory) = &args.directory + && (directory.as_os_str().is_empty() || (directory.exists() && !directory.is_dir())) + { + return Err(CliError::new( + ErrorKind::Usage, + format!("'{}' isn't a directory.", directory.display()), + ) + .into()); } let client = get_client_and_login_if_needed(&context)?; let namespace = &project.namespace; diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index e3ddf60..41390c0 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -1,8 +1,11 @@ +mod compare; + use std::io::{self, Write}; use std::time::Duration; use clap::{Args, Subcommand, ValueEnum}; use serde::Serialize; +use tracel_client::ClientError; use tracel_client::console::Client; use tracel_client::console::experiment::request::{ ExperimentLogQueryRequest, ListExperimentsQuery, LogLevelRequest, MetadataFilterRequest, @@ -52,6 +55,8 @@ pub enum ExperimentsCommands { Metrics(MetricsArgs), /// Show an experiment's logs Logs(LogsArgs), + /// Compare two experiments' config, attributes and metrics + Compare(CompareArgs), } #[derive(Args, Debug)] @@ -73,7 +78,7 @@ pub enum ExperimentSelector { Latest, } -fn parse_experiment(value: &str) -> Result { +pub fn parse_experiment(value: &str) -> Result { if value == "latest" { return Ok(ExperimentSelector::Latest); } @@ -177,6 +182,31 @@ pub struct LogsArgs { pub follow: bool, } +#[derive(Args, Debug)] +pub struct CompareArgs { + /// Experiment number to compare against + #[arg(value_parser = clap::value_parser!(i32).range(1..))] + pub base: i32, + /// Experiment number to compare + #[arg(value_parser = clap::value_parser!(i32).range(1..))] + pub other: i32, + /// Compare this metric; repeat for more + #[arg(long = "metric", value_name = "NAME", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub metrics: Vec, +} + +impl CompareArgs { + fn check(&self) -> Result<(), CliError> { + if self.base == self.other { + return Err(CliError::new( + ErrorKind::Usage, + format!("Experiment {} can't be compared with itself.", self.base), + )); + } + Ok(()) + } +} + impl LogsArgs { fn into_request(self) -> ExperimentLogQueryRequest { let metadata_filters = self @@ -347,6 +377,33 @@ impl Render for ExperimentLogQueryResponse { } } +/// Find the experiment `selector` names in `project`. +pub fn get_experiment( + client: &Client, + project: &TracelProject, + selector: &ExperimentSelector, +) -> anyhow::Result { + match *selector { + ExperimentSelector::Number(num) => client + .get_experiment(&project.namespace, &project.name, num) + .map_err(|error| missing_experiment(error, project, num)), + ExperimentSelector::Latest => client + .get_project_latest_experiment(&project.namespace, &project.name)? + .ok_or_else(|| { + CliError::new(ErrorKind::NotFound, "No experiments in this project yet.").into() + }), + } +} + +fn missing_experiment(error: ClientError, project: &TracelProject, num: i32) -> anyhow::Error { + map_resource_error( + error, + &project.namespace, + &project.name, + Resource::Experiment(num), + ) +} + /// Name an experiment status in a word: "stopping" for the server's `cancel_requested`. fn status_name(status: &str) -> &str { match status { @@ -428,8 +485,11 @@ fn follow_logs( } pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Result { - let client = get_client_and_login_if_needed(&context)?; + if let ExperimentsCommands::Compare(args) = &args.command { + args.check()?; + } let project = resolve_namespace_project(&context)?.project; + let client = get_client_and_login_if_needed(&context)?; let (namespace, name) = (&project.namespace, &project.name); let missing = |num| move |error| map_resource_error(error, namespace, name, Resource::Experiment(num)); @@ -446,17 +506,7 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res )? .into()), ExperimentsCommands::Get(args) => { - let experiment = match args.experiment { - ExperimentSelector::Number(num) => client - .get_experiment(namespace, name, num) - .map_err(missing(num))?, - ExperimentSelector::Latest => client - .get_project_latest_experiment(namespace, name)? - .ok_or_else(|| { - CliError::new(ErrorKind::NotFound, "No experiments in this project yet.") - })?, - }; - Ok(experiment.into()) + Ok(get_experiment(&client, &project, &args.experiment)?.into()) } ExperimentsCommands::Metrics(args) => { let Some(metric) = args.metric else { @@ -502,6 +552,9 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res .map_err(missing(num))? .into()) } + ExperimentsCommands::Compare(args) => { + Ok(compare::compare_experiments(&client, &project, args)?.into()) + } } } diff --git a/crates/tracel-cli/src/commands/experiments/compare.rs b/crates/tracel-cli/src/commands/experiments/compare.rs new file mode 100644 index 0000000..32674cc --- /dev/null +++ b/crates/tracel-cli/src/commands/experiments/compare.rs @@ -0,0 +1,410 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::io::{self, Write}; + +use serde::Serialize; +use serde_json::Value; +use tracel_client::console::Client; +use tracel_client::console::experiment::request::MetricSummaryQuery; +use tracel_client::console::experiment::response::MetricSummaryGroupResponse; + +use super::{CompareArgs, missing_experiment}; +use crate::tools::tracel_config::TracelProject; +use crate::ui::{Human, Render, Table, counted, number}; + +/// A JSON value that differs between two experiments. A side is `None` where the path does +/// not exist, and is then left out of the JSON output. +#[derive(Debug, PartialEq, Serialize)] +pub struct Difference { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub base: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub other: Option, +} + +/// A metric group's summary value in both experiments, with `delta = other - base`. +#[derive(Debug, PartialEq, Serialize)] +pub struct MetricComparison { + pub metric: String, + pub group: String, + pub base: Option, + pub other: Option, + pub delta: Option, +} + +#[derive(Serialize)] +pub struct Comparison { + pub base: i32, + pub other: i32, + pub config: Vec, + pub attributes: Vec, + pub metrics: Vec, +} + +pub fn compare_experiments( + client: &Client, + project: &TracelProject, + args: CompareArgs, +) -> anyhow::Result { + let (namespace, name) = (&project.namespace, &project.name); + let get_experiment = |num| { + client + .get_experiment(namespace, name, num) + .map_err(|error| missing_experiment(error, project, num)) + }; + let base = get_experiment(args.base)?; + let other = get_experiment(args.other)?; + + let metrics = if args.metrics.is_empty() { + let metric_names = |num| { + client + .get_metric_metadata(namespace, name, num) + .map(|metadata| metadata.metric_types) + .map_err(|error| missing_experiment(error, project, num)) + }; + defined_metrics(metric_names(args.base)?, metric_names(args.other)?) + } else { + requested_metrics(args.metrics) + }; + let summary = |num, metric: &str| { + client + .get_metric_summary( + namespace, + name, + num, + MetricSummaryQuery { + metric: metric.into(), + }, + ) + .map(|summary| summary.map(|summary| summary.groups).unwrap_or_default()) + .map_err(|error| missing_experiment(error, project, num)) + }; + let mut metric_comparisons = Vec::new(); + for metric in &metrics { + metric_comparisons.extend(compare_metric( + metric, + &summary(args.base, metric)?, + &summary(args.other, metric)?, + )); + } + + let attributes = + |attributes: HashMap| Value::Object(attributes.into_iter().collect()); + Ok(Comparison { + base: args.base, + other: args.other, + config: diff_json(&base.config, &other.config), + attributes: diff_json(&attributes(base.attributes), &attributes(other.attributes)), + metrics: metric_comparisons, + }) +} + +/// List every path where `base` and `other` differ, depth first with object keys sorted. +/// Objects and arrays are compared member by member; any other change, including a change +/// of type, is one difference holding both values. +fn diff_json(base: &Value, other: &Value) -> Vec { + let mut differences = Vec::new(); + diff_at(String::new(), Some(base), Some(other), &mut differences); + differences +} + +fn diff_at( + path: String, + base: Option<&Value>, + other: Option<&Value>, + differences: &mut Vec, +) { + match (base, other) { + (Some(Value::Object(base)), Some(Value::Object(other))) => { + let keys: BTreeSet<&String> = base.keys().chain(other.keys()).collect(); + for key in keys { + diff_at( + key_path(&path, key), + base.get(key), + other.get(key), + differences, + ); + } + } + (Some(Value::Array(base)), Some(Value::Array(other))) => { + for index in 0..base.len().max(other.len()) { + diff_at( + format!("{path}[{index}]"), + base.get(index), + other.get(index), + differences, + ); + } + } + _ if base != other => differences.push(Difference { + path, + base: base.cloned(), + other: other.cloned(), + }), + _ => {} + } +} + +/// Write the path of `key` under `parent`: `parent.key`, or `parent["key"]` when the key is +/// empty or holds `.`, `[` or `]`. +fn key_path(parent: &str, key: &str) -> String { + if key.is_empty() || key.contains(['.', '[', ']']) { + format!("{parent}[{}]", Value::from(key)) + } else if parent.is_empty() { + key.into() + } else { + format!("{parent}.{key}") + } +} + +/// List every metric either experiment defines, sorted. +fn defined_metrics(base: Vec, other: Vec) -> Vec { + base.into_iter() + .chain(other) + .collect::>() + .into_iter() + .collect() +} + +/// Keep the requested metrics in order, without repeats. +fn requested_metrics(mut metrics: Vec) -> Vec { + let mut seen = HashSet::new(); + metrics.retain(|metric| seen.insert(metric.clone())); + metrics +} + +/// Pair two summaries of `metric` by group, sorted by group. A group summarized by one +/// experiment only has no value for the other and no delta. +fn compare_metric( + metric: &str, + base: &[MetricSummaryGroupResponse], + other: &[MetricSummaryGroupResponse], +) -> Vec { + let (base, other) = (group_values(base), group_values(other)); + base.keys() + .chain(other.keys()) + .copied() + .collect::>() + .into_iter() + .map(|group| { + let base = base.get(group).copied(); + let other = other.get(group).copied(); + MetricComparison { + metric: metric.into(), + group: group.into(), + base, + other, + delta: base.zip(other).map(|(base, other)| other - base), + } + }) + .collect() +} + +fn group_values(groups: &[MetricSummaryGroupResponse]) -> BTreeMap<&str, f64> { + groups + .iter() + .map(|group| (group.group.as_str(), group.optimal_value)) + .collect() +} + +impl Render for Comparison { + /// Write each part under its heading. + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { + for (title, empty, differences) in [ + ("Config differences", "No config differences.", &self.config), + ( + "Attribute differences", + "No attribute differences.", + &self.attributes, + ), + ] { + out.heading(title)?; + differences_table(differences).empty(empty).write(out)?; + writeln!(out)?; + } + out.heading("Metric summaries")?; + self.metric_table() + .empty("No metric summaries to compare.") + .write(out) + } + + fn summary(&self) -> Option { + let differences = (self.config.len() + self.attributes.len()) as u64; + Some(format!( + "{} between experiments {} and {}.", + counted(differences, ["difference", "differences"]), + self.base, + self.other + )) + } +} + +impl Comparison { + fn metric_table(&self) -> Table<5> { + let value = |value: Option| value.map(number).unwrap_or_default(); + Table::new(["METRIC", "GROUP", "BASE", "OTHER", "DELTA"]).rows(self.metrics.iter().map( + |metric| { + [ + metric.metric.clone(), + metric.group.clone(), + value(metric.base), + value(metric.other), + metric + .delta + .map(|delta| { + let sign = if delta > 0.0 { "+" } else { "" }; + format!("{sign}{}", number(delta)) + }) + .unwrap_or_default(), + ] + }, + )) + } +} + +fn differences_table(differences: &[Difference]) -> Table<3> { + let value = |value: Option<&Value>| value.map(Value::to_string).unwrap_or_default(); + Table::new(["PATH", "BASE", "OTHER"]) + .shrink("BASE") + .shrink("OTHER") + .rows(differences.iter().map(|difference| { + [ + difference.path.clone(), + value(difference.base.as_ref()), + value(difference.other.as_ref()), + ] + })) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn difference(path: &str, base: Option, other: Option) -> Difference { + Difference { + path: path.into(), + base, + other, + } + } + + fn summary(group: &str, optimal_value: f64) -> MetricSummaryGroupResponse { + MetricSummaryGroupResponse { + group: group.into(), + optimal_value, + epoch: 1, + } + } + + #[test] + fn given_equal_configs_when_comparing_them_then_there_are_no_differences() { + let config = json!({"optimizer": {"lr": 0.01, "betas": [0.9, 0.999]}, "seed": 1}); + + let differences = diff_json(&config, &config.clone()); + + assert_eq!(differences, []); + } + + #[test] + fn given_a_changed_nested_field_when_comparing_configs_then_its_dotted_path_has_both_values() { + let base = json!({"optimizer": {"name": "adam", "lr": 0.01}}); + let other = json!({"optimizer": {"name": "adam", "lr": 0.001}}); + + let differences = diff_json(&base, &other); + + assert_eq!( + differences, + [difference( + "optimizer.lr", + Some(json!(0.01)), + Some(json!(0.001)) + )] + ); + } + + #[test] + fn given_a_field_only_one_config_has_when_comparing_them_then_the_other_side_is_missing() { + let base = json!({"seed": 1, "old": true}); + let other = json!({"seed": 1, "new": null}); + + let differences = diff_json(&base, &other); + + assert_eq!( + differences, + [ + difference("new", None, Some(json!(null))), + difference("old", Some(json!(true)), None), + ] + ); + } + + #[test] + fn given_arrays_when_comparing_configs_then_items_compare_by_index() { + let base = json!({"layers": [64, 32, 16]}); + let other = json!({"layers": [64, 8]}); + + let differences = diff_json(&base, &other); + + assert_eq!( + differences, + [ + difference("layers[1]", Some(json!(32)), Some(json!(8))), + difference("layers[2]", Some(json!(16)), None), + ] + ); + } + + #[test] + fn given_a_field_whose_type_changed_when_comparing_configs_then_it_is_one_difference() { + let base = json!({"schedule": "cosine"}); + let other = json!({"schedule": {"kind": "cosine"}}); + + let differences = diff_json(&base, &other); + + assert_eq!( + differences, + [difference( + "schedule", + Some(json!("cosine")), + Some(json!({"kind": "cosine"})) + )] + ); + } + + #[test] + fn given_a_key_a_dotted_path_would_misread_when_comparing_configs_then_it_is_quoted() { + let base = json!({"outer": {"a.b": 1}}); + let other = json!({"outer": {"a.b": 2}}); + + let differences = diff_json(&base, &other); + + assert_eq!(differences[0].path, r#"outer["a.b"]"#); + } + + #[test] + fn given_a_group_both_experiments_summarize_when_comparing_a_metric_then_the_delta_is_other_minus_base() + { + let comparisons = + compare_metric("loss", &[summary("valid", 0.75)], &[summary("valid", 0.5)]); + + assert_eq!(comparisons[0].delta, Some(-0.25)); + } + + #[test] + fn given_a_group_one_experiment_summarizes_when_comparing_a_metric_then_there_is_no_delta() { + let comparisons = compare_metric("loss", &[summary("train", 0.5)], &[]); + + assert_eq!( + comparisons, + [MetricComparison { + metric: "loss".into(), + group: "train".into(), + base: Some(0.5), + other: None, + delta: None, + }] + ); + } +} diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 24e8232..8c200f9 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -1,7 +1,6 @@ use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::{can_initialize_project, require_cargo_workspace, require_linked_project}; -use crate::tools::git; use crate::tools::project_context::ProjectContext; use crate::tools::tracel_config::TracelProject; use crate::ui::{Declined, Outcome, Terminal, Unanswered}; @@ -29,12 +28,6 @@ pub struct InitArgs { /// Link an existing project without asking #[arg(long, short = 'y')] pub yes: bool, - /// Commit all current changes first - #[arg(long, conflicts_with = "allow_dirty")] - pub commit: bool, - /// Link with uncommitted changes - #[arg(long)] - pub allow_dirty: bool, } /// The project the workspace is linked to. `url` is null when it already was. @@ -95,10 +88,6 @@ fn link(args: InitArgs, context: &CliContext, client: &Client) -> anyhow::Result let workspace_info = require_cargo_workspace()?; let terminal = context.terminal(); - ensure_git_repo_initialized(&workspace_info.get_ws_root(), terminal)?; - ensure_git_repo_clean(terminal, args.commit, args.allow_dirty)?; - git::get_first_commit_hash().context("Can't read the first commit")?; - let namespace = choose_namespace( &user.username, &user.namespace, @@ -130,11 +119,12 @@ fn link(args: InitArgs, context: &CliContext, client: &Client) -> anyhow::Result let url = context.web_console().join(&namespace.project_path(&name))?; let shown = format!("{}/{}", project.namespace, project.name); let link = terminal.format_url(&url); - terminal.outro(&if created { + let outcome = if created { format!("Created and linked {shown} at {link}.") } else { format!("Linked to {shown} at {link}.") - }); + }; + terminal.next_steps(&outcome, &["tracel run --list".to_string()]); Ok(ProjectLinked { namespace: project.namespace, @@ -320,102 +310,6 @@ fn create_project( }) } -fn ensure_git_repo_initialized( - ws_root: &std::path::Path, - terminal: &Terminal, -) -> anyhow::Result<()> { - if !git::is_repo_initialized() { - git::init_repo(ws_root)?; - terminal.step("Created a git repository"); - } - Ok(()) -} - -/// Make sure the workspace has a commit and no uncommitted changes, committing when -/// `commit` says so or the user agrees, unless `allow_dirty`. -fn ensure_git_repo_clean( - terminal: &Terminal, - commit: bool, - allow_dirty: bool, -) -> anyhow::Result<()> { - let has_commit = git::get_last_commit_hash().is_ok(); - let dirty = match git::is_repo_dirty() { - Ok(dirty) => dirty, - Err(_) if !has_commit => true, - Err(error) => return Err(error).context("Can't read the git status"), - }; - if !dirty && has_commit { - return Ok(()); - } - if commit { - return commit_all(terminal); - } - if allow_dirty && has_commit { - return Ok(()); - } - if allow_dirty { - return Err(no_commit() - .with_hint("Pass --commit to make the first one.") - .into()); - } - if !terminal.is_interactive() { - return Err(commit_required().into()); - } - terminal.warning(if has_commit { - "The workspace has uncommitted changes." - } else { - "The workspace has no commit yet." - }); - if !terminal.confirm( - "Commit all files?", - false, - Unanswered::new("Linking needs a commit.", "Pass --commit or --allow-dirty."), - )? { - return Err(Declined::new(NOTHING_LINKED).into()); - } - commit_all(terminal) -} - -fn no_commit() -> CliError { - CliError::new(ErrorKind::Usage, "The workspace has no commit yet.") -} - -fn commit_required() -> CliError { - CliError::new(ErrorKind::Usage, "The workspace has uncommitted changes.") - .with_hint("Commit them, or pass --commit or --allow-dirty.") -} - -/// Commit everything in the working tree, making the first commit if there is none. -pub fn commit_all(terminal: &Terminal) -> anyhow::Result<()> { - let commit_message = "Automatic commit by Tracel Console CLI"; - let status = std::process::Command::new("git") - .args(["add", "--all"]) - .stdin(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .status() - .context("Can't run `git add --all`")?; - if !status.success() { - anyhow::bail!("`git add --all` failed."); - } - let mut command = std::process::Command::new("git"); - command.args(["commit", "-m", commit_message]); - if git::get_last_commit_hash().is_err() { - command.arg("--allow-empty"); - } - let status = command - .stdin(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .status() - .context("Can't run `git commit`")?; - if !status.success() { - anyhow::bail!("`git commit` failed."); - } - terminal.step("Committed all files"); - Ok(()) -} - #[cfg(test)] mod tests { use rstest::rstest; diff --git a/crates/tracel-cli/src/commands/jobs.rs b/crates/tracel-cli/src/commands/jobs.rs new file mode 100644 index 0000000..b2e96d5 --- /dev/null +++ b/crates/tracel-cli/src/commands/jobs.rs @@ -0,0 +1,525 @@ +use std::io::{self, Write}; +use std::time::{Duration, Instant}; + +use clap::{Args, Subcommand}; +use serde::Serialize; +use tracel_client::ClientError; +use tracel_client::console::job::response::{ + ExecutionContextResponse, JobLogResponse, JobResponse, MoneyResponse, ProjectJobsResponse, +}; + +use crate::commands::login::get_client_and_login_if_needed; +use crate::context::CliContext; +use crate::error::{CliError, ErrorKind}; +use crate::helpers::{ + FinalStatus, final_status, follow_logs, job_invocation, missing_job, poll_job, read_job, + require_completed, resolve_namespace_project, +}; +use crate::tools::tracel_config::TracelProject; +use crate::ui::{ + Declined, Details, Human, Layout, Outcome, Render, Table, Unanswered, elapsed, list_summary, + short_digest, when, +}; + +#[derive(Args, Debug)] +pub struct JobsArgs { + #[command(subcommand)] + pub command: JobsCommands, +} + +impl JobsArgs { + pub fn layout(&self) -> Layout { + match &self.command { + JobsCommands::Logs(args) if args.follow => { + Layout::flow(format!("Follow the logs of job {}", args.num)) + } + JobsCommands::Logs(_) => Layout::Text, + JobsCommands::Cancel(args) => Layout::flow(format!("Cancel job {}", args.num)), + JobsCommands::Wait(args) => Layout::flow(format!("Wait for job {}", args.num)), + _ => Layout::Read, + } + } +} + +#[derive(Subcommand, Debug)] +pub enum JobsCommands { + /// List the project's jobs, newest first + List, + /// Show a job + Get(GetArgs), + /// Show a job's logs + Logs(LogsArgs), + /// Cancel a job + Cancel(CancelArgs), + /// Wait for a job to finish + Wait(WaitArgs), +} + +#[derive(Args, Debug)] +pub struct GetArgs { + /// Job number + #[arg(value_parser = clap::value_parser!(i32).range(1..))] + pub num: i32, +} + +#[derive(Args, Debug)] +pub struct LogsArgs { + /// Job number + #[arg(value_parser = clap::value_parser!(i32).range(1..))] + pub num: i32, + /// Read from this byte of the logs + #[arg(long, value_name = "BYTE")] + pub start: Option, + /// Keep reading until it finishes + #[arg(long)] + pub follow: bool, +} + +#[derive(Args, Debug)] +pub struct CancelArgs { + /// Job number + #[arg(value_parser = clap::value_parser!(i32).range(1..))] + pub num: i32, + /// Cancel without asking + #[arg(long, short = 'y')] + pub yes: bool, +} + +#[derive(Args, Debug)] +pub struct WaitArgs { + /// Job number + #[arg(value_parser = clap::value_parser!(i32).range(1..))] + pub num: i32, + /// Stop waiting after this many seconds + #[arg(long, value_name = "SECONDS")] + pub timeout: Option, + /// Check the job every this many seconds + #[arg(long, value_name = "SECONDS", default_value_t = 5, value_parser = clap::value_parser!(u64).range(1..))] + pub interval: u64, +} + +/// Name what a job with `job_command` runs: the job, for one `tracel run --remote` +/// queued, or else the command. +fn command(job_command: &str) -> String { + match job_invocation(job_command) { + Some((name, _)) => name, + None => job_command.to_string(), + } +} + +impl Render for ProjectJobsResponse { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { + Table::new([ + "JOB", + "STATUS", + "COMMAND", + "CREATED AT", + "STARTED AT", + "COMPLETED AT", + ]) + .shrink("COMMAND") + .rows(self.jobs.iter().map(|job| { + [ + job.job_num.to_string(), + job.status.clone(), + command(&job.job_command), + when(&job.created_at), + job.started_at.as_deref().map(when).unwrap_or_default(), + job.completed_at.as_deref().map(when).unwrap_or_default(), + ] + })) + .write(out) + } + + fn summary(&self) -> Option { + Some(list_summary( + self.jobs.len(), + self.jobs.len() as u64, + ["job", "jobs"], + "No jobs in this project yet.", + )) + } +} + +impl Render for JobResponse { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { + let details = Details::new() + .field("Command", command(&self.job_command)) + .field("Code version", short_digest(&self.job_project_version)); + let details = match &self.execution_context { + Some(ExecutionContextResponse::Managed { + compute_provider_name, + memory_gb, + cpu_cores, + num_gpus, + lock_price_per_hour, + final_cost, + estimated_cost, + }) => details + .field( + "Compute provider", + format!("{compute_provider_name} (managed)"), + ) + .field("CPU cores", cpu_cores) + .field("Memory", format!("{memory_gb} GB")) + .optional("GPUs", *num_gpus) + .field("Price per hour", money(lock_price_per_hour)) + .optional("Estimated cost", estimated_cost.as_ref().map(money)) + .optional("Final cost", final_cost.as_ref().map(money)), + Some(ExecutionContextResponse::SelfManaged { + compute_provider_name, + }) => details.field( + "Compute provider", + format!("{compute_provider_name} (self-managed)"), + ), + None => details, + }; + let details = details.optional("Ran", ran(self)); + match job_invocation(&self.job_command) { + Some((_, input)) => details.section("Input", &input), + None => details, + } + .write(out) + } + + fn summary(&self) -> Option { + Some(state(self)) + } +} + +/// Say when `job` started and for how long it ran, or has run so far. +fn ran(job: &JobResponse) -> Option { + let started = job.started_at.as_deref()?; + let completed = job.completed_at.as_deref(); + let elapsed = elapsed(started, completed)?; + let so_far = if completed.is_some() { "" } else { " so far" }; + Some(format!("{}, {elapsed}{so_far}", when(started))) +} + +/// Say how `job` is going, or how it ended. +fn state(job: &JobResponse) -> String { + let num = job.job_num; + match job.status.as_str() { + "completed" => format!("Job {num} completed."), + "failed" => format!("Job {num} failed."), + "cancelled" => format!("Job {num} was cancelled."), + "pending_cancellation" => format!("Job {num} is stopping."), + status => format!("Job {num} is {status}."), + } +} + +fn money(money: &MoneyResponse) -> String { + format!("{} {}", money.amount, money.currency) +} + +/// A page of job `num`'s logs, closed by the command that reads the next one when there +/// is more. +#[derive(Serialize)] +#[serde(transparent)] +struct LogPage { + #[serde(skip)] + num: i32, + page: JobLogResponse, +} + +impl Render for LogPage { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { + let logs = &self.page.logs; + out.write_all(logs.as_bytes())?; + if !logs.is_empty() && !logs.ends_with('\n') { + writeln!(out)?; + } + Ok(()) + } + + fn summary(&self) -> Option { + self.page.has_more.then(|| { + format!( + "More: `tracel jobs logs {} --start {}`.", + self.num, self.page.end + ) + }) + } +} + +fn cancel_error(error: ClientError, project: &TracelProject, num: i32) -> anyhow::Error { + if error.is_conflict() { + return CliError::new( + ErrorKind::Conflict, + format!("Job {num} is no longer running, so it can't be cancelled."), + ) + .into(); + } + missing_job(error, project, num) +} + +pub fn handle_command(args: JobsArgs, context: CliContext) -> anyhow::Result { + let project = resolve_namespace_project(&context)?.project; + let client = || get_client_and_login_if_needed(&context); + match args.command { + JobsCommands::List => Ok(client()? + .list_jobs(&project.namespace, &project.name)? + .into()), + JobsCommands::Get(args) => Ok(read_job(&client()?, &project, args.num)?.into()), + JobsCommands::Logs(args) if args.follow => { + let start = args.start.unwrap_or(0); + let job = follow_logs(&client()?, context.output(), &project, args.num, start)?; + context.terminal().outro(&state(&job)); + Ok(Outcome::streamed()) + } + JobsCommands::Logs(args) => { + let page = client()? + .get_job_logs(&project.namespace, &project.name, args.num, args.start) + .map_err(|error| missing_job(error, &project, args.num))?; + Ok(LogPage { + num: args.num, + page, + } + .into()) + } + JobsCommands::Cancel(args) => Ok(Outcome::data(cancel(args, &context, &project)?)), + JobsCommands::Wait(args) => Ok(Outcome::data(wait(args, &context, &project)?)), + } +} + +/// Cancel job `num` once the user agrees, and return it as read afterwards. +fn cancel( + args: CancelArgs, + context: &CliContext, + project: &TracelProject, +) -> anyhow::Result { + let (terminal, num) = (context.terminal(), args.num); + if !args.yes { + let confirmed = terminal.confirm( + "Cancel it? A running job is stopped.", + false, + Unanswered::new( + format!("Cancelling job {num} needs confirmation."), + "Pass --yes to cancel without asking.", + ), + )?; + if !confirmed { + return Err(Declined::new(format!("Job {num} was not cancelled.")).into()); + } + } + let client = get_client_and_login_if_needed(context)?; + client + .cancel_job(&project.namespace, &project.name, num) + .map_err(|error| cancel_error(error, project, num))?; + let job = read_job(&client, project, num)?; + terminal.outro(&match final_status(&job.status) { + Some(FinalStatus::Cancelled) => format!("Cancelled job {num}."), + _ => format!("Job {num} is stopping."), + }); + Ok(job) +} + +/// Wait for job `num` to finish, and return it once it completed. +fn wait( + args: WaitArgs, + context: &CliContext, + project: &TracelProject, +) -> anyhow::Result { + let (terminal, num) = (context.terminal(), args.num); + let client = get_client_and_login_if_needed(context)?; + let deadline = args + .timeout + .map(|timeout| Instant::now() + Duration::from_secs(timeout)); + let spinner = terminal.spinner(format!("Waiting for job {num}")); + let mut status = String::new(); + let job = poll_job( + &client, + project, + num, + Duration::from_secs(args.interval), + deadline, + |job| { + if job.status != status { + status.clone_from(&job.status); + spinner.set_message(format!("Waiting for job {num}, which is {status}")); + } + Ok(()) + }, + ); + spinner.clear(); + let job = job?; + waited(&job, args.timeout)?; + terminal.outro(&state(&job)); + Ok(job) +} + +/// Decide what waiting for a job comes to, with `job` as last read: it passes when the job +/// completed, fails with `JOB_FAILED` when it failed or was cancelled, and with `TIMEOUT` +/// when it is still going after `timeout` seconds. +fn waited(job: &JobResponse, timeout: Option) -> Result<(), CliError> { + let num = job.job_num; + if final_status(&job.status).is_none() { + return Err(CliError::new( + ErrorKind::Timeout, + format!( + "Job {num} is still {} after {} seconds.", + job.status, + timeout.unwrap_or_default() + ), + ) + .with_hint(format!("Wait for it again with `tracel jobs wait {num}`."))); + } + require_completed(job) + .map_err(|error| error.with_hint(format!("Read its logs with `tracel jobs logs {num}`."))) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use tracel_client::console::job::response::JobProjectResponse; + + use super::*; + use crate::error::ErrorReport; + use crate::test_support::plain; + + fn job(status: &str) -> JobResponse { + JobResponse { + job_num: 14, + compute_provider_id: None, + job_project: JobProjectResponse { + project_name: "demo".into(), + namespace_name: "test".into(), + }, + job_project_version: "67c31f69845e9a1e".into(), + job_command: "train".into(), + status: status.into(), + status_message: String::new(), + execution_context: None, + created_at: "2026-10-13T15:00:00Z".into(), + started_at: Some("2026-10-13T15:02:00Z".into()), + completed_at: Some("2026-10-13T15:03:05Z".into()), + } + } + + fn log_page(logs: &str, has_more: bool) -> LogPage { + LogPage { + num: 12, + page: JobLogResponse { + logs: logs.into(), + start: 0, + end: logs.len() as u64, + total_size: 4096, + has_more, + }, + } + } + + #[test] + fn given_a_completed_job_when_waiting_for_it_then_the_wait_passes() { + let waited = waited(&job("completed"), None); + + assert!(waited.is_ok()); + } + + #[rstest] + #[case::failed("failed")] + #[case::cancelled("cancelled")] + fn given_a_job_that_did_not_complete_when_waiting_for_it_then_the_wait_fails_saying_how_it_ended( + #[case] status: &str, + ) { + let error = waited(&job(status), None).unwrap_err(); + + assert_eq!(error.kind, ErrorKind::JobFailed); + assert!(error.to_string().contains(status), "{error}"); + } + + #[test] + fn given_a_failed_job_when_waiting_for_it_then_the_hint_points_at_its_logs() { + let error = anyhow::Error::new(waited(&job("failed"), None).unwrap_err()); + + let hint = ErrorReport::new(&error).hint.unwrap(); + + assert!(hint.contains("tracel jobs logs 14"), "{hint}"); + } + + #[test] + fn given_a_job_still_running_when_the_wait_times_out_then_it_is_a_timeout() { + let error = waited(&job("running"), Some(60)).unwrap_err(); + + assert_eq!(error.kind, ErrorKind::Timeout); + } + + #[test] + fn given_a_job_tracel_run_queued_by_name_when_showing_its_command_then_it_is_the_job_name() { + let shown = command(r#"train '{"epochs":2}'"#); + + assert_eq!(shown, "train"); + } + + #[test] + fn given_another_command_when_showing_it_then_it_is_shown_as_it_is() { + let shown = command("train --epochs 2"); + + assert_eq!(shown, "train --epochs 2"); + } + + #[test] + fn given_a_finished_job_when_saying_how_long_it_ran_then_it_is_from_its_start_to_its_end() { + let ran = ran(&job("completed")); + + assert_eq!( + ran, + Some(format!("{}, 1 min 5 s", when("2026-10-13T15:02:00Z"))) + ); + } + + #[test] + fn given_a_running_job_when_saying_how_long_it_ran_then_it_is_so_far() { + let running = JobResponse { + completed_at: None, + ..job("running") + }; + + let ran = ran(&running).unwrap(); + + assert!(ran.ends_with(" so far"), "{ran}"); + } + + #[test] + fn given_a_job_that_has_not_started_when_saying_how_long_it_ran_then_there_is_nothing_to_say() { + let queued = JobResponse { + started_at: None, + completed_at: None, + ..job("queued") + }; + + let ran = ran(&queued); + + assert_eq!(ran, None); + } + + #[test] + fn given_logs_that_end_mid_line_when_writing_a_page_then_the_line_is_ended() { + let page = log_page("epoch 1\nepoch 2", false); + + let text = plain(&page); + + assert_eq!(text, "epoch 1\nepoch 2\n"); + } + + #[test] + fn given_a_page_with_more_logs_when_summing_it_up_then_it_says_how_to_read_on() { + let page = log_page("epoch 1\n", true); + + let summary = page.summary().unwrap(); + + assert!( + summary.contains("tracel jobs logs 12 --start 8"), + "{summary}" + ); + } + + #[test] + fn given_the_last_page_when_summing_it_up_then_there_is_no_summary() { + let page = log_page("epoch 1\n", false); + + let summary = page.summary(); + + assert_eq!(summary, None); + } +} diff --git a/crates/tracel-cli/src/commands/mod.rs b/crates/tracel-cli/src/commands/mod.rs index 08c4f12..cef0a5b 100644 --- a/crates/tracel-cli/src/commands/mod.rs +++ b/crates/tracel-cli/src/commands/mod.rs @@ -3,11 +3,12 @@ pub mod auth; pub mod datasets; pub mod experiments; pub mod init; +pub mod jobs; pub mod login; pub mod logout; pub mod me; pub mod models; pub mod package; pub mod project; -pub mod training; +pub mod run; pub mod unlink; diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index 7425549..53c344a 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -1,488 +1,85 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; +mod code_version; -use anyhow::Context; use clap::{Args, ValueEnum}; use serde::Serialize; -use sha2::{Digest, Sha256}; -use tracel_client::console::Client; -use tracel_client::console::project::request::{ - PublishArtifactRequest, PublishBinaryRequest, PublishProjectVersionRequest, - PublishSourceRequest, -}; -use crate::commands::init::commit_all; +pub use code_version::build_package; + use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; -use crate::helpers::{ - require_cargo_workspace, resolve_namespace_project, validate_project_exists_on_server, -}; -use crate::tools::build_driver::{self, BuildDriver}; -use crate::tools::packager::{PackageEvent, package_workspace}; -use crate::tools::project_context::ProjectContext; -use crate::tools::{cargo, git, target}; -use crate::ui::{Outcome, Unanswered, short_digest}; +use crate::helpers::{require_workspace_project, validate_project_exists_on_server}; +use crate::tools::target; +use crate::ui::{Outcome, Terminal, short_digest}; -#[derive(Args, Debug)] +#[derive(Args, Clone, Debug)] pub struct PackageArgs { - /// Package with uncommitted changes - #[arg(long, action)] - pub allow_dirty: bool, /// Package a compiled binary or the source #[arg(long, value_enum)] pub mode: Option, /// Build for this target triple; repeat for more #[arg(long = "target", value_name = "TRIPLE")] pub targets: Vec, - /// Upload this binary when several are built + /// Binary to build, when there are several #[arg(long, value_name = "NAME")] pub bin: Option, /// Install missing Rust targets without asking #[arg(long)] pub install_targets: bool, - /// Commit all current changes before packaging - #[arg(long, conflicts_with = "allow_dirty")] - pub commit: bool, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ValueEnum)] #[serde(rename_all = "lowercase")] pub enum Mode { Binary, Source, } -/// A code version ready to upload: its publish request, each file by the key of its -/// upload URL, and the targets it was built for. -struct PreparedCodeVersion { - request: PublishArtifactRequest, - uploads: Vec<(String, PathBuf)>, - targets: Vec, -} - -/// The code version `package` made. `uploaded` is false when it already existed. -#[derive(Serialize)] -struct Packaged { - namespace: String, - project: String, - digest: String, - version_id: String, - mode: Mode, - targets: Vec, - uploaded: bool, -} - -pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result { - if args.mode.is_none() && !context.terminal().is_interactive() { - return Err(CliError::new(ErrorKind::Usage, MODE_NEEDED) - .with_hint(MODE_HINT) - .into()); - } - if args.mode == Some(Mode::Source) && !args.targets.is_empty() { - return Err(CliError::new( - ErrorKind::Usage, - "--target is only valid with --mode binary.", - ) - .into()); - } - for triple in &args.targets { - target::parse_target(triple)?; - } - - let workspace_info = require_cargo_workspace()?; - let resolved = resolve_namespace_project(&context)?; - let client = get_client_and_login_if_needed(&context)?; - validate_project_exists_on_server(&resolved.project, &resolved.source, &client)?; - let project = ProjectContext { - workspace_info, - project: resolved.project, - }; +const MODE_NEEDED: &str = "Packaging needs a mode."; +const MODE_HINT: &str = "Pass --mode binary or --mode source."; - let has_commit = git::get_last_commit_hash().is_ok(); - if !has_commit && !args.commit && !context.terminal().is_interactive() { - return Err( - CliError::new(ErrorKind::Usage, "The workspace has no commit yet.") - .with_hint("Commit your changes, or pass --commit.") - .into(), - ); - } - if args.commit { - if !has_commit || git::is_repo_dirty()? { - commit_all(context.terminal())?; - } - } else if git::is_repo_dirty()? && !args.allow_dirty { - let dirty = "The workspace has uncommitted changes."; - if !context.terminal().is_interactive() { - return Err(CliError::new(ErrorKind::Usage, dirty) - .with_hint("Commit them, or pass --commit or --allow-dirty.") +impl PackageArgs { + /// Reject flags that can't work before doing anything: a missing `--mode` without + /// prompts, an unknown `--target`, or `--target` with `--mode source`. + pub fn check(&self, terminal: &Terminal) -> anyhow::Result<()> { + if self.mode.is_none() && !terminal.is_interactive() { + return Err(CliError::new(ErrorKind::Usage, MODE_NEEDED) + .with_hint(MODE_HINT) .into()); } - context.terminal().warning(dirty); - if context.terminal().confirm( - "Commit them first?", - true, - Unanswered::new( - "Packaging needs a commit.", - "Pass --commit or --allow-dirty.", - ), - )? { - commit_all(context.terminal())?; + if let Some(mode) = self.mode { + check_targets_allowed(mode, &self.targets)?; } - } - - let digest = git::get_last_commit_hash().context("Can't read the current commit")?; - if git::is_repo_dirty()? { - context.terminal().warning(&format!( - "Uncommitted changes are not part of code version {}.", - short_digest(&digest) - )); - } - - let mode = match args.mode { - Some(mode) => mode, - None => context.terminal().select( - "Mode", - &[ - ( - Mode::Binary, - "Binary (more secure)", - "ship a compiled binary; your source is not uploaded", - ), - ( - Mode::Source, - "Source (more portable)", - "upload source; it is built on the compute provider", - ), - ], - None, - Unanswered::new(MODE_NEEDED, MODE_HINT), - )?, - }; - if mode == Mode::Source && !args.targets.is_empty() { - return Err(CliError::new( - ErrorKind::Usage, - "--target is only valid with --mode binary.", - ) - .into()); - } - - let code_version = match mode { - Mode::Source => prepare_source(&context, &project)?, - Mode::Binary => prepare_binaries(&context, &project, &args)?, - }; - - Ok(Outcome::data(upload( - &context, - &client, - &project, - &digest, - mode, - code_version, - )?)) -} - -fn prepare_source( - context: &CliContext, - project: &ProjectContext, -) -> anyhow::Result { - let spinner = context.terminal().spinner("Packaging the workspace"); - let reporter = spinner.clone(); - let result = package_workspace( - project.get_workspace_name(), - Arc::new(move |event: PackageEvent| reporter.set_message(event.message)), - ) - .context("Can't package the workspace")?; - spinner.clear(); - - Ok(PreparedCodeVersion { - request: PublishArtifactRequest::Source { - source: PublishSourceRequest { - checksum: result.checksum, - size: result.size, - }, - }, - uploads: vec![("source.zip".to_string(), result.path)], - targets: Vec::new(), - }) -} - -fn prepare_binaries( - context: &CliContext, - project: &ProjectContext, - args: &PackageArgs, -) -> anyhow::Result { - let host = target::host_target()?; - let installed = target::installed_targets(); - - let selected = target::select_targets(context.terminal(), &args.targets, host, &installed)?; - - // Offer to install the chosen cross targets whose standard library is missing. - let missing: Vec<&str> = selected - .iter() - .filter(|&&(os, arch)| (os, arch) != host) - .map(|&(os, arch)| target::target_triple(os, arch)) - .filter(|triple| !installed.contains(*triple)) - .collect(); - target::install_missing_target(context.terminal(), missing, args.install_targets)?; - - let root = project.get_workspace_root(); - let drivers = build_driver::detect(); - let mut binaries = Vec::new(); - let mut uploads = Vec::new(); - - for &(os, arch) in &selected { - let triple = target::target_triple(os, arch); - let is_host = (os, arch) == host; - let driver = if is_host { - BuildDriver::Cargo - } else { - build_driver::choose(host, (os, arch), &drivers) - }; - - let linker = if is_host { - context.terminal().warning(&format!( - "The binary runs only on {} compute providers.", - target::pretty_name(os, arch) - )); - None - } else { - build_driver::cross_preflight(context.terminal(), root, host, (os, arch), driver)? - }; - - let path = build_release_binary( - context, - (!is_host).then_some(triple), - driver, - linker, - args.bin.as_deref(), - )?; - let (checksum, size) = sha256_and_size(&path)?; - binaries.push(PublishBinaryRequest { - os, - architecture: arch, - checksum, - size, - }); - uploads.push((triple.to_string(), path)); - } - - Ok(PreparedCodeVersion { - request: PublishArtifactRequest::Binaries { binaries }, - uploads, - targets: selected - .iter() - .map(|&(os, arch)| target::target_triple(os, arch).to_string()) - .collect(), - }) -} - -/// Build the release binary with `driver`, for `target` when cross-building, and return -/// its path, asking which one when the build makes several. -fn build_release_binary( - context: &CliContext, - target: Option<&str>, - driver: BuildDriver, - linker: Option<&str>, - bin: Option<&str>, -) -> anyhow::Result { - let cmd_label = match target { - Some(triple) => format!("{} --release --target {triple}", driver.label()), - None => format!("{} --release", driver.label()), - }; - let mut command = cargo::command(); - for arg in driver.subcommand_args() { - command.arg(arg); - } - command.arg("--release"); - if let Some(triple) = target { - command.arg("--target").arg(triple); - if let Some(linker) = linker { - command - .arg("--config") - .arg(format!("target.{triple}.linker=\"{linker}\"")); - } - } - let building = match target { - Some(triple) => format!("Building for {triple}"), - None => "Building the binary".to_string(), - }; - let spinner = context.terminal().spinner(&building); - let built = cargo::build_quietly(command, &cmd_label, |status| { - spinner.set_message(format!("{building}: {status}")); - }); - spinner.clear(); - let built = built?; - if !built.succeeded() { - built.show_output(); - } - let executables = built.executables()?; - - let names: Vec<&str> = executables.iter().map(|(name, _)| name.as_str()).collect(); - if let Some(bin) = bin { - return executables - .iter() - .find(|(name, _)| name == bin) - .map(|(_, path)| path.clone()) - .ok_or_else(|| { - CliError::new( - ErrorKind::Usage, - format!("No binary '{bin}'. Binaries: {}.", names.join(", ")), - ) - .into() - }); - } - match executables.len() { - 0 => anyhow::bail!("The build made no binary."), - 1 => Ok(executables[0].1.clone()), - _ => { - let items: Vec<(PathBuf, String, &str)> = executables - .iter() - .map(|(_, path)| { - ( - path.clone(), - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()), - "", - ) - }) - .collect(); - context.terminal().select( - "Binary", - &items, - None, - Unanswered::new( - format!("Several binaries were built: {}.", names.join(", ")), - "Pass --bin .", - ), - ) + for triple in &self.targets { + target::parse_target(triple)?; } + Ok(()) } } -const MODE_NEEDED: &str = "Packaging needs a mode."; -const MODE_HINT: &str = "Pass --mode binary or --mode source."; +pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result { + args.check(context.terminal())?; -fn sha256_and_size(path: &Path) -> anyhow::Result<(String, u64)> { - let bytes = std::fs::read(path).with_context(|| format!("Can't read '{}'", path.display()))?; - let checksum = format!("{:x}", Sha256::digest(&bytes)); - Ok((checksum, bytes.len() as u64)) + let (project, source) = require_workspace_project(&context)?; + let client = get_client_and_login_if_needed(&context)?; + validate_project_exists_on_server(project.get_project(), &source, &client)?; + + let version = build_package(&context, &project, &args)?.publish(&context, &client, &project)?; + let digest = short_digest(&version.digest); + context.terminal().outro(&if version.uploaded { + format!("Uploaded code version {digest}.") + } else { + format!("Code version {digest} is already uploaded.") + }); + Ok(Outcome::data(version)) } -fn upload( - context: &CliContext, - client: &Client, - project: &ProjectContext, - digest: &str, - mode: Mode, - prepared: PreparedCodeVersion, -) -> anyhow::Result { - let linked = project.get_project(); - - let response = client - .publish_project_version_urls( - &linked.namespace, - &linked.name, - PublishProjectVersionRequest { - digest: digest.to_string(), - artifact: prepared.request, - }, - ) - .with_context(|| { - format!( - "Can't start the upload to {}/{}", - linked.namespace, linked.name - ) - })?; - - let packaged = |uploaded| Packaged { - namespace: linked.namespace.clone(), - project: linked.name.clone(), - digest: digest.to_string(), - version_id: response.id.clone(), - mode, - targets: prepared.targets.clone(), - uploaded, - }; - let uploads = match &response.urls { - Some(urls) => requested(prepared.uploads, urls), - None => Vec::new(), - }; - if uploads.is_empty() { - context.terminal().outro(&format!( - "Code version {} is already uploaded.", - short_digest(digest) +fn check_targets_allowed(mode: Mode, targets: &[String]) -> Result<(), CliError> { + if mode == Mode::Source && !targets.is_empty() { + return Err(CliError::new( + ErrorKind::Usage, + "--target is only valid with --mode binary.", )); - return Ok(packaged(false)); - } - - let spinner = context.terminal().spinner("Uploading the code version"); - for (key, url, path) in uploads { - let bytes = - std::fs::read(&path).with_context(|| format!("Can't read '{}'", path.display()))?; - client - .upload_bytes_to_url(url, bytes) - .with_context(|| format!("Can't upload '{key}'"))?; - } - spinner.clear(); - - client - .complete_project_version_upload(&linked.namespace, &linked.name, &response.id) - .with_context(|| { - format!( - "Can't finish the upload to {}/{}", - linked.namespace, linked.name - ) - })?; - - context.terminal().outro(&format!( - "Uploaded code version {}.", - short_digest(&response.digest) - )); - Ok(packaged(true)) -} - -/// The files of a package the Tracel Console asked for, each with its upload URL. A file -/// the code version already has gets no URL and is left out. -fn requested( - uploads: Vec<(String, PathBuf)>, - urls: &HashMap, -) -> Vec<(String, &str, PathBuf)> { - uploads - .into_iter() - .filter_map(|(key, path)| urls.get(&key).map(|url| (key, url.as_str(), path))) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_the_files_the_console_asks_for_are_uploaded() { - let uploads = vec![ - ( - "x86_64-unknown-linux-gnu".to_string(), - PathBuf::from("linux"), - ), - ("aarch64-apple-darwin".to_string(), PathBuf::from("macos")), - ]; - let urls = HashMap::from([( - "aarch64-apple-darwin".to_string(), - "https://storage/macos".to_string(), - )]); - - let requested = requested(uploads, &urls); - - assert_eq!( - requested, - [( - "aarch64-apple-darwin".to_string(), - "https://storage/macos", - PathBuf::from("macos") - )] - ); } + Ok(()) } diff --git a/crates/tracel-cli/src/commands/package/code_version.rs b/crates/tracel-cli/src/commands/package/code_version.rs new file mode 100644 index 0000000..43f99d8 --- /dev/null +++ b/crates/tracel-cli/src/commands/package/code_version.rs @@ -0,0 +1,352 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use serde::Serialize; +use tracel_client::console::Client; +use tracel_client::console::project::request::{ + PublishArtifactRequest, PublishBinaryRequest, PublishProjectVersionRequest, + PublishSourceRequest, +}; + +use super::{MODE_HINT, MODE_NEEDED, Mode, PackageArgs, check_targets_allowed}; +use crate::context::CliContext; +use crate::tools::build_driver::{self, BuildDriver}; +use crate::tools::cargo::{self, Binary}; +use crate::tools::fs::file_sha256_and_size; +use crate::tools::packager::{self, PackageEvent}; +use crate::tools::project_context::ProjectContext; +use crate::tools::target; +use crate::ui::Unanswered; + +/// A code version ready to upload: the digest of its content, its publish request, each +/// file by the key of its upload URL, and the targets it was built for. +pub struct BuiltPackage { + pub mode: Mode, + pub digest: String, + targets: Vec, + request: PublishArtifactRequest, + uploads: Vec<(String, PathBuf)>, +} + +/// A code version of the project, as `tracel package` reports it. +#[derive(Serialize)] +pub struct PackagedCodeVersion { + pub namespace: String, + pub project: String, + pub digest: String, + pub version_id: String, + pub mode: Mode, + pub targets: Vec, + /// False when the same content was already packaged. + pub uploaded: bool, +} + +/// Build the workspace in the mode from `args`, asking for one when it is missing. +pub fn build_package( + context: &CliContext, + project: &ProjectContext, + args: &PackageArgs, +) -> anyhow::Result { + let mode = match args.mode { + Some(mode) => mode, + None => context.terminal().select( + "Mode", + &[ + ( + Mode::Binary, + "Binary (more secure)", + "ship a compiled binary; your source is not uploaded", + ), + ( + Mode::Source, + "Source (more portable)", + "upload source; it is built on the compute provider", + ), + ], + None, + Unanswered::new(MODE_NEEDED, MODE_HINT), + )?, + }; + check_targets_allowed(mode, &args.targets)?; + + match mode { + Mode::Source => build_source_package(context, project), + Mode::Binary => build_binary_package(context, project, args), + } +} + +fn build_source_package( + context: &CliContext, + project: &ProjectContext, +) -> anyhow::Result { + let spinner = context.terminal().spinner("Packaging the workspace"); + let reporter = spinner.clone(); + let result = packager::package_workspace( + &project.workspace_info, + Arc::new(move |event: PackageEvent| reporter.set_message(event.message)), + ) + .context("Can't package the workspace")?; + spinner.clear(); + + Ok(BuiltPackage { + mode: Mode::Source, + digest: result.digest, + targets: Vec::new(), + request: PublishArtifactRequest::Source { + source: PublishSourceRequest { + checksum: result.checksum, + size: result.size, + }, + }, + uploads: vec![("source.zip".to_string(), result.path)], + }) +} + +fn build_binary_package( + context: &CliContext, + project: &ProjectContext, + args: &PackageArgs, +) -> anyhow::Result { + let host = target::host_target()?; + let installed = target::installed_targets(); + + let selected = target::select_targets(context.terminal(), &args.targets, host, &installed)?; + + // Offer to install the chosen cross targets whose standard library is missing. + let missing: Vec<&str> = selected + .iter() + .filter(|&&(os, arch)| (os, arch) != host) + .map(|&(os, arch)| target::target_triple(os, arch)) + .filter(|triple| !installed.contains(*triple)) + .collect(); + target::install_missing_target(context.terminal(), missing, args.install_targets)?; + + // Only the binaries are uploaded, but the code version is the source they are built from. + let digest = packager::workspace_digest(&project.workspace_info)?; + + let root = project.get_workspace_root(); + let drivers = build_driver::detect(); + let bin = args + .bin + .as_deref() + .map(|name| cargo::find_binary(&cargo::binaries(&project.workspace_info.metadata), name)) + .transpose()?; + let mut binaries = Vec::new(); + let mut uploads = Vec::new(); + + for &(os, arch) in &selected { + let triple = target::target_triple(os, arch); + let is_host = (os, arch) == host; + let driver = if is_host { + BuildDriver::Cargo + } else { + build_driver::choose(host, (os, arch), &drivers) + }; + + let linker = if is_host { + context.terminal().warning(&format!( + "Only {} compute providers can run this build.", + target::pretty_name(os, arch) + )); + None + } else { + build_driver::cross_preflight(context.terminal(), root, host, (os, arch), driver)? + }; + + let path = build_release_binary( + context, + (!is_host).then_some(triple), + driver, + linker, + bin.as_ref(), + )?; + let (checksum, size) = file_sha256_and_size(&path)?; + binaries.push(PublishBinaryRequest { + os, + architecture: arch, + checksum, + size, + }); + uploads.push((triple.to_string(), path)); + } + + Ok(BuiltPackage { + mode: Mode::Binary, + digest, + targets: selected + .iter() + .map(|&(os, arch)| target::target_triple(os, arch).to_string()) + .collect(), + request: PublishArtifactRequest::Binaries { binaries }, + uploads, + }) +} + +/// Build the release binary with `driver`, for `target` when cross-building, and return +/// its path. A `bin` is the only binary built, as `tracel run` builds it, so the build +/// `tracel run` made for this machine is reused; otherwise the user picks one when the +/// build makes several. +fn build_release_binary( + context: &CliContext, + target: Option<&str>, + driver: BuildDriver, + linker: Option<&str>, + bin: Option<&Binary>, +) -> anyhow::Result { + let (command, label) = cargo::release_build(driver, target, linker, bin); + let building = match target { + Some(triple) => format!("Building for {triple}"), + None => "Building the binary".to_string(), + }; + let executables = cargo::build(context.terminal(), command, &label, &building)?; + + if let Some(bin) = bin { + return executables + .into_iter() + .find(|(name, _)| *name == bin.name) + .map(|(_, path)| path) + .with_context(|| format!("`{label}` made no binary '{}'", bin.name)); + } + let names: Vec<&str> = executables.iter().map(|(name, _)| name.as_str()).collect(); + match executables.len() { + 0 => anyhow::bail!("The build made no binary."), + 1 => Ok(executables[0].1.clone()), + _ => { + let items: Vec<(PathBuf, String, &str)> = executables + .iter() + .map(|(_, path)| { + ( + path.clone(), + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + "", + ) + }) + .collect(); + context.terminal().select( + "Binary", + &items, + None, + Unanswered::new( + format!("Several binaries were built: {}.", names.join(", ")), + "Pass --bin .", + ), + ) + } + } +} + +impl BuiltPackage { + /// Upload the package as a code version of the project: the files the code version + /// with this digest doesn't have yet, or nothing when it has them all. + pub fn publish( + self, + context: &CliContext, + client: &Client, + project: &ProjectContext, + ) -> anyhow::Result { + let linked = project.get_project(); + + let response = client + .publish_project_version_urls( + &linked.namespace, + &linked.name, + PublishProjectVersionRequest { + digest: self.digest.clone(), + artifact: self.request, + }, + ) + .with_context(|| { + format!( + "Can't start the upload to {}/{}", + linked.namespace, linked.name + ) + })?; + + let mut version = PackagedCodeVersion { + namespace: linked.namespace.clone(), + project: linked.name.clone(), + digest: self.digest, + version_id: response.id, + mode: self.mode, + targets: self.targets, + uploaded: false, + }; + let Some(urls) = response.urls else { + return Ok(version); + }; + let uploads = requested(self.uploads, &urls); + if uploads.is_empty() { + return Ok(version); + } + + let spinner = context.terminal().spinner("Uploading the code version"); + for (key, url, path) in uploads { + let bytes = + std::fs::read(&path).with_context(|| format!("Can't read '{}'", path.display()))?; + client + .upload_bytes_to_url(url, bytes) + .with_context(|| format!("Can't upload '{key}'"))?; + } + spinner.clear(); + + client + .complete_project_version_upload(&linked.namespace, &linked.name, &version.version_id) + .with_context(|| { + format!( + "Can't finish the upload to {}/{}", + linked.namespace, linked.name + ) + })?; + + version.uploaded = true; + Ok(version) + } +} + +/// The files of a package the Tracel Console asked for, each with its upload URL. A file +/// the code version already has gets no URL and is left out. +fn requested( + uploads: Vec<(String, PathBuf)>, + urls: &HashMap, +) -> Vec<(String, &str, PathBuf)> { + uploads + .into_iter() + .filter_map(|(key, path)| urls.get(&key).map(|url| (key, url.as_str(), path))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_upload_urls_for_some_files_when_choosing_what_to_upload_then_only_those_files_are_uploaded() + { + let uploads = vec![ + ( + "x86_64-unknown-linux-gnu".to_string(), + PathBuf::from("linux"), + ), + ("aarch64-apple-darwin".to_string(), PathBuf::from("macos")), + ]; + let urls = HashMap::from([( + "aarch64-apple-darwin".to_string(), + "https://storage/macos".to_string(), + )]); + + let requested = requested(uploads, &urls); + + assert_eq!( + requested, + [( + "aarch64-apple-darwin".to_string(), + "https://storage/macos", + PathBuf::from("macos") + )] + ); + } +} diff --git a/crates/tracel-cli/src/commands/run.rs b/crates/tracel-cli/src/commands/run.rs new file mode 100644 index 0000000..31e7477 --- /dev/null +++ b/crates/tracel-cli/src/commands/run.rs @@ -0,0 +1,405 @@ +mod describe; +mod input; +mod job_flags; +mod launch; +mod remote; + +use std::io; +use std::path::PathBuf; + +use anyhow::Context; +use clap::{ArgAction, ArgGroup, Args, Command}; +use serde::Serialize; +use serde_json::Value; +use tracel_client::console::Env; +use tracel_job::{DefinitionsFile, JobDefinition}; + +pub use job_flags::{job_flags, with_job_flags}; + +use crate::commands::experiments::{ExperimentSelector, get_experiment, parse_experiment}; +use crate::commands::login::get_client_and_login_if_needed; +use crate::commands::package::PackageArgs; +use crate::context::CliContext; +use crate::environment; +use crate::error::{CliError, ErrorKind, classify}; +use crate::helpers::{job_command, require_cargo_workspace, resolve_namespace_project}; +use crate::tools::cargo; +use crate::tools::tracel_config::TracelProject; +use crate::ui::{Human, Layout, Outcome, Render, Table, Terminal, list_summary}; + +use describe::{describe, find_job}; +use input::Assignment; +use launch::{Launch, Target, exit_error, launch, select_target}; +use remote::RemoteRun; + +#[derive(Args, Debug)] +#[command(group( + ArgGroup::new("packaging") + .args(["mode", "targets", "install_targets"]) + .multiple(true) + .requires("remote") +))] +#[command(group(ArgGroup::new("selection").args(["job", "list", "remote"]).multiple(true)))] +#[command(mut_arg("bin", |arg| arg.requires("selection")))] +#[command(disable_help_flag = true)] +pub struct RunArgs { + /// Job to run, then its own flags + #[arg(value_name = "JOB", conflicts_with = "forwarded")] + pub job: Option, + /// The job's own flags, as arguments of the job's command line, such as `--epochs=5`. + #[arg(skip)] + pub job_flags: Vec, + /// List the jobs your program defines + #[arg(long, conflicts_with_all = ["job", "remote", "forwarded"])] + pub list: bool, + /// Merge this JSON file onto the input; repeat for more + #[arg(short = 'c', long = "config", value_name = "FILE", requires = "job")] + pub configs: Vec, + /// Set an input value, such as optimizer.lr=0.01; repeat for more + #[arg( + long = "set", + value_name = "PATH=VALUE", + value_parser = input::parse_assignment, + requires = "job" + )] + pub assignments: Vec, + /// Start from the input this experiment recorded + #[arg(long, value_name = "NUM|latest", value_parser = parse_experiment, requires = "job")] + pub like: Option, + /// Record the run on this machine + #[arg(long, requires = "job", conflicts_with = "remote")] + pub offline: bool, + /// Queue the job on this compute provider group + #[arg(long, value_name = "GROUP")] + pub remote: Option, + #[command(flatten)] + pub package: PackageArgs, + /// Queue without asking + #[arg(long, short = 'y', requires = "remote")] + pub yes: bool, + /// Package it without uploading or queuing anything + #[arg(long, requires = "remote", conflicts_with_all = ["follow", "yes"])] + pub dry_run: bool, + /// Follow its logs until it finishes + #[arg(long, requires = "remote")] + pub follow: bool, + /// Arguments for the program, without a job + #[arg(last = true, value_name = "ARGS")] + pub forwarded: Vec, + /// Print help, with a job's own flags + #[arg(short, long, action = ArgAction::SetTrue)] + pub help: bool, +} + +impl RunArgs { + /// Choose how `run` shows on a terminal. A run of the workspace without a job draws + /// nothing: its output is the program's. + pub fn layout(&self) -> Layout { + match (&self.job, &self.remote) { + _ if self.list => Layout::Read, + (Some(job), Some(group)) => Layout::flow(format!("Run job '{job}' on {group}")), + (Some(job), None) => Layout::flow(format!("Run job '{job}'")), + (None, Some(group)) => Layout::flow(format!("Run the workspace on {group}")), + (None, None) => Layout::Bare, + } + } + + /// Make the run on `group` of a job, or of the workspace, whose program runs with + /// `command`. A `bin` found for the job is the one packaged. + fn remote_run<'a>( + &self, + group: &'a str, + command: String, + bin: Option, + ) -> RemoteRun<'a> { + RemoteRun { + group, + command, + packaging: PackageArgs { + bin: bin.or_else(|| self.package.bin.clone()), + ..self.package.clone() + }, + yes: self.yes, + dry_run: self.dry_run, + follow: self.follow, + } + } +} + +pub fn handle_command(args: RunArgs, context: CliContext) -> anyhow::Result { + if let Some(group) = &args.remote { + let asks = !args.yes && !args.dry_run; + remote::check(group, &args.package, asks, context.terminal())?; + } + if args.list { + let workspace = require_cargo_workspace()?; + let bin = args.package.bin.as_deref(); + return Ok(describe(context.terminal(), &workspace, bin)? + .definitions + .into()); + } + match (&args.job, &args.remote) { + (Some(job), _) => run_job(job, &args, &context), + (None, Some(group)) => { + let run = args.remote_run(group, job_command(&args.forwarded), None); + remote::queue(run, &context) + } + (None, None) => { + run_locally(&args.forwarded, &context)?; + Ok(Outcome::streamed()) + } + } +} + +impl Render for DefinitionsFile { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { + Table::new(["NAME", "DESCRIPTION"]) + .shrink("DESCRIPTION") + .rows(self.jobs.iter().map(|job| { + [ + job.name.clone(), + job.description.clone().unwrap_or_default(), + ] + })) + .write(out) + } + + fn summary(&self) -> Option { + Some(list_summary( + self.jobs.len(), + self.jobs.len() as u64, + ["job", "jobs"], + "The program defines no jobs.", + )) + } +} + +/// The data of a local job run. +#[derive(Serialize)] +struct JobRun { + job: String, + input: Value, + target: Target, + exit_code: Option, +} + +/// Run `job` of the workspace binary with its input, on this machine or with --remote. +fn run_job(job: &str, args: &RunArgs, context: &CliContext) -> anyhow::Result { + let terminal = context.terminal(); + let workspace = require_cargo_workspace()?; + let described = describe(terminal, &workspace, args.package.bin.as_deref())?; + let definition = find_job(&described.definitions, job)?; + let start = match &args.like { + Some(experiment) => recorded_input(context, experiment)?, + None => definition.input_example.clone().unwrap_or(Value::Null), + }; + let input = resolve_input(definition, start, args, terminal)?; + let input_json = serde_json::to_string(&input)?; + + if let Some(group) = &args.remote { + let command = job_command(&[job.to_string(), input_json]); + let run = args.remote_run(group, command, Some(described.binary.name)); + return remote::queue(run, context); + } + + let project = linked_project(context)?; + let logged_in = !args.offline && project.is_some() && context.has_credentials()?; + let (target, note) = select_target(args.offline, project.is_some(), logged_in); + if let Some(note) = note { + terminal.info(note); + } + let mut env = sdk_environment(&context.environment(), project.as_ref()); + env.push(("TRACEL_TARGET", target.as_str().to_string())); + let exit = launch(Launch { + program: &described.program, + job, + input: &input_json, + env, + stdout: context.output().child_stdout(), + })?; + terminal.program_ended(exit.stopped); + if let Some(error) = exit_error(job, &exit) { + return Err(error.into()); + } + terminal.outro(&format!("Job '{job}' completed.")); + Ok(Outcome::data(JobRun { + job: job.to_string(), + input, + target, + exit_code: exit.status.code(), + })) +} + +/// Resolve the input job `definition` runs with from `start`, with the configs, job +/// flags and assignments of `args`, and check it against the job's schema. An input the +/// schema rejects stops the run; a schema that can't be used is only warned about. +fn resolve_input( + definition: &JobDefinition, + start: Value, + args: &RunArgs, + terminal: &Terminal, +) -> anyhow::Result { + let configs = args + .configs + .iter() + .map(|path| input::read_config(path)) + .collect::, _>>()?; + let input = input::resolve( + definition, + start, + &configs, + &args.job_flags, + &args.assignments, + )?; + let job = &definition.name; + if let Some(schema) = &definition.input_schema { + match input::violations(schema, &input) { + Ok(violations) if violations.is_empty() => {} + Ok(violations) => return Err(input::invalid_input(job, &violations).into()), + Err(error) => terminal.warning(&format!( + "Can't check the input of job '{job}' against its schema: {error}" + )), + } + } + Ok(input) +} + +/// Build the command whose help `tracel run --help` prints: after a job, with the job's +/// own flags. +pub fn help(args: &RunArgs, tracel: Command, terminal: &Terminal) -> anyhow::Result { + let tracel = match &args.job { + Some(job) => { + let workspace = require_cargo_workspace()?; + let described = describe(terminal, &workspace, args.package.bin.as_deref())?; + job_flags::job_help(tracel, find_job(&described.definitions, job)?) + } + None => tracel, + }; + Ok(job_flags::run_command(&tracel)) +} + +/// Find the project from --project, the environment or `tracel.toml`, or `None` when +/// none is set. +fn linked_project(context: &CliContext) -> anyhow::Result> { + match resolve_namespace_project(context) { + Ok(resolved) => Ok(Some(resolved.project)), + Err(error) if classify(&error) == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +/// Read the input the experiment `selector` names recorded. +fn recorded_input(context: &CliContext, selector: &ExperimentSelector) -> anyhow::Result { + let project = resolve_namespace_project(context)?.project; + let client = get_client_and_login_if_needed(context)?; + let experiment = get_experiment(&client, &project, selector)?; + Ok(recorded(experiment.experiment_num, experiment.config)?) +} + +/// Take the input experiment `num` recorded as `config`, which is `null` when it +/// recorded none. +fn recorded(num: i32, config: Value) -> Result { + if config.is_null() { + return Err(CliError::new( + ErrorKind::NotFound, + format!("Experiment {num} recorded no input."), + ) + .with_hint("Leave out --like to start from the job's example input.")); + } + Ok(config) +} + +/// Run `cargo run --release` with the arguments given after `--`: its output and exit +/// code are the command's. +fn run_locally(forwarded: &[String], context: &CliContext) -> anyhow::Result<()> { + let mut cmd = cargo::command(); + cmd.arg("run").arg("--release"); + cmd.envs(sdk_environment(&context.environment(), context.project())); + if !forwarded.is_empty() { + cmd.arg("--"); + cmd.args(forwarded); + } + + hand_over(cmd) +} + +/// List the variables that name the Tracel Console and the project to the SDK. +fn sdk_environment(env: &Env, project: Option<&TracelProject>) -> Vec<(&'static str, String)> { + let mut variables = vec![("TRACEL_ENV", environment::sdk_value(env))]; + if let Some(project) = project { + variables.push(("TRACEL_NAMESPACE", project.namespace.clone())); + variables.push(("TRACEL_PROJECT", project.name.clone())); + } + variables +} + +/// Replace this process with `cmd`. `cargo run` does the same with the program it +/// builds, so a signal sent to `tracel` reaches the program instead of orphaning it. +#[cfg(unix)] +fn hand_over(mut cmd: std::process::Command) -> anyhow::Result<()> { + use std::os::unix::process::CommandExt; + + Err(cmd.exec()).context("Can't run `cargo run`") +} + +/// Windows can't replace a process, so wait for the child and exit with its code. +#[cfg(not(unix))] +fn hand_over(mut cmd: std::process::Command) -> anyhow::Result<()> { + let status = cmd.status().context("Can't run `cargo run`")?; + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::cli::Commands; + use crate::error::classify; + use crate::test_support::{job, parse, quiet_terminal}; + + fn run_args(args: &[&str]) -> RunArgs { + match parse(args).unwrap().command { + Some(Commands::Run(args)) => args, + other => panic!("{args:?} parse as {other:?}"), + } + } + + #[test] + fn given_an_input_its_schema_rejects_when_resolving_it_then_the_run_stops() { + let schema = json!({"type": "object", "properties": {"epochs": {"minimum": 0}}}); + let train = job("train", Some(schema), Some(json!({"epochs": 3}))); + let args = run_args(&["run", "train", "--set", "epochs=-1"]); + + let error = + resolve_input(&train, json!({"epochs": 3}), &args, &quiet_terminal()).unwrap_err(); + + assert_eq!(classify(&error), ErrorKind::Usage); + } + + #[test] + fn given_a_schema_that_cannot_check_inputs_when_resolving_the_input_then_it_is_used_unchecked() + { + let train = job( + "train", + Some(json!({"type": 5})), + Some(json!({"epochs": 3})), + ); + let args = run_args(&["run", "train"]); + + let input = resolve_input(&train, json!({"epochs": 3}), &args, &quiet_terminal()); + + assert_eq!(input.unwrap(), json!({"epochs": 3})); + } + + #[test] + fn given_an_experiment_that_recorded_no_input_when_starting_like_it_then_it_is_not_found() { + let error = recorded(7, Value::Null).unwrap_err(); + + assert_eq!(error.kind, ErrorKind::NotFound); + } +} diff --git a/crates/tracel-cli/src/commands/run/describe.rs b/crates/tracel-cli/src/commands/run/describe.rs new file mode 100644 index 0000000..4770d44 --- /dev/null +++ b/crates/tracel-cli/src/commands/run/describe.rs @@ -0,0 +1,463 @@ +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::Context; +use cargo_metadata::{DependencyKind, Metadata}; +use serde::Deserialize; +use serde_json::json; +use tracel_job::{DefinitionsFile, JobDefinition, PROTOCOL, ParseError, TRACEL_DESCRIBE}; + +use super::launch::wait_until; +use crate::error::{CliError, ErrorKind}; +use crate::tools::build_driver::BuildDriver; +use crate::tools::cargo::{self, Binary}; +use crate::tools::fs::file_sha256_and_size; +use crate::tools::workspace::WorkspaceInfo; +use crate::ui::Terminal; + +/// The crate a package depends on to define jobs. +const SDK_CRATE: &str = "tracel"; +/// How long a program has to write its job definitions. +const DESCRIBE_TIMEOUT: Duration = Duration::from_secs(10); +/// The hint for definitions this CLI can't read. +const SAME_PROTOCOL: &str = "Use a tracel CLI and a tracel crate that support the same protocol."; + +/// Find the job named `name` among `definitions`. +pub fn find_job<'a>( + definitions: &'a DefinitionsFile, + name: &str, +) -> Result<&'a JobDefinition, CliError> { + if let Some(job) = definitions.jobs.iter().find(|job| job.name == name) { + return Ok(job); + } + let names: Vec<&str> = definitions + .jobs + .iter() + .map(|job| job.name.as_str()) + .collect(); + let message = match names.as_slice() { + [] => format!("No job '{name}'. The program defines no jobs."), + names => format!("No job '{name}'. Jobs: {}.", names.join(", ")), + }; + Err(CliError::new(ErrorKind::Usage, message)) +} + +/// Say why the definitions the program `name` wrote can't be read. +fn unreadable_definitions(name: &str, error: ParseError) -> CliError { + let message = match error { + ParseError::Protocol { found } => format!( + "`{name}` lists its jobs with runner protocol {found}, but this CLI reads protocol {PROTOCOL}." + ), + ParseError::Invalid(error) => { + format!("`{name}` listed its jobs in a form this CLI can't read: {error}") + } + }; + CliError::new(ErrorKind::Internal, message).with_hint(SAME_PROTOCOL) +} + +/// A workspace package, as finding the jobs sees it. +#[derive(Clone, Debug)] +pub struct PackageBinaries { + pub package: String, + /// Whether the package depends on the `tracel` crate. + pub uses_sdk: bool, + pub bins: Vec, + pub default_run: Option, +} + +pub fn package_binaries(metadata: &Metadata) -> Vec { + metadata + .workspace_packages() + .into_iter() + .map(|package| PackageBinaries { + package: package.name.to_string(), + uses_sdk: package.dependencies.iter().any(|dependency| { + dependency.name == SDK_CRATE && dependency.kind == DependencyKind::Normal + }), + bins: package + .targets + .iter() + .filter(|target| target.is_bin()) + .map(|target| target.name.clone()) + .collect(), + default_run: package.default_run.clone(), + }) + .collect() +} + +/// Choose the binary whose jobs `tracel run` runs: the one of the packages that depend on +/// the `tracel` crate, or `bin` among them. As with `cargo run`, a package's `default-run` +/// picks one of its binaries. +pub fn choose_binary(packages: &[PackageBinaries], bin: Option<&str>) -> Result { + let candidates: Vec = packages + .iter() + .filter(|package| package.uses_sdk) + .flat_map(|package| { + package.bins.iter().map(|name| Binary { + package: package.package.clone(), + name: name.clone(), + }) + }) + .collect(); + if candidates.is_empty() { + let message = if packages.iter().any(|package| package.uses_sdk) { + "No package that depends on the `tracel` crate has a binary, so there are no jobs to run." + } else { + "No package in this workspace depends on the `tracel` crate, so there are no jobs to run." + }; + return Err(CliError::new(ErrorKind::Usage, message) + .with_hint("Run the program with `tracel run -- ` instead.")); + } + if let Some(bin) = bin { + return cargo::find_binary(&candidates, bin); + } + if let [binary] = candidates.as_slice() { + return Ok(binary.clone()); + } + let package = &candidates[0].package; + if candidates.iter().all(|binary| &binary.package == package) { + let default_run = packages + .iter() + .find(|candidate| &candidate.package == package) + .and_then(|package| package.default_run.as_deref()); + if let Some(binary) = candidates + .iter() + .find(|binary| Some(binary.name.as_str()) == default_run) + { + return Ok(binary.clone()); + } + } + let names: Vec<&str> = candidates + .iter() + .map(|binary| binary.name.as_str()) + .collect(); + Err(CliError::new( + ErrorKind::Usage, + format!( + "Several binaries depend on the `tracel` crate: {}.", + names.join(", ") + ), + ) + .with_hint("Pass --bin to choose one.")) +} + +/// Read the cached definitions in `contents` when they were written for the binary with +/// `sha256`. +pub fn cached_definitions(contents: &str, sha256: &str) -> Option { + #[derive(Deserialize)] + struct Cache { + sha256: String, + definitions: DefinitionsFile, + } + let cache: Cache = serde_json::from_str(contents).ok()?; + (cache.sha256 == sha256 && cache.definitions.protocol == PROTOCOL).then_some(cache.definitions) +} + +fn write_cache(path: &Path, sha256: &str, definitions: &DefinitionsFile) -> anyhow::Result<()> { + if let Some(directory) = path.parent() { + std::fs::create_dir_all(directory)?; + } + let contents = serde_json::to_vec_pretty(&json!({ + "sha256": sha256, + "definitions": definitions, + }))?; + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + std::fs::write(&tmp, contents)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +/// The jobs of a workspace binary, and where it is built. +pub struct Described { + pub binary: Binary, + pub program: PathBuf, + pub definitions: DefinitionsFile, +} + +/// Build the binary whose jobs `tracel run` runs, in release, and read its job definitions. +/// +/// The definitions are cached in `target/tracel/jobs.json` under the SHA-256 of the binary, +/// so the binary only runs again once it changes. +pub fn describe( + terminal: &Terminal, + workspace: &WorkspaceInfo, + bin: Option<&str>, +) -> anyhow::Result { + let binary = choose_binary(&package_binaries(&workspace.metadata), bin)?; + let program = build(terminal, &binary)?; + let (sha256, _) = file_sha256_and_size(&program)?; + let cache = workspace + .metadata + .target_directory + .as_std_path() + .join("tracel") + .join("jobs.json"); + let cached = std::fs::read_to_string(&cache) + .ok() + .and_then(|contents| cached_definitions(&contents, &sha256)); + let definitions = match cached { + Some(definitions) => definitions, + None => { + let definitions = run_describe(&program, &binary.name)?; + if let Err(error) = write_cache(&cache, &sha256, &definitions) { + terminal.warning(&format!( + "Can't cache the job definitions in {}: {error:#}", + cache.display() + )); + } + definitions + } + }; + Ok(Described { + binary, + program, + definitions, + }) +} + +fn build(terminal: &Terminal, binary: &Binary) -> anyhow::Result { + let (command, label) = cargo::release_build(BuildDriver::Cargo, None, None, Some(binary)); + let building = format!("Building {}", binary.name); + cargo::build(terminal, command, &label, &building)? + .into_iter() + .find(|(name, _)| *name == binary.name) + .map(|(_, path)| path) + .with_context(|| format!("`{label}` made no binary '{}'", binary.name)) +} + +/// Run `program` with `TRACEL_DESCRIBE` set, and read the definitions it writes. +fn run_describe(program: &Path, name: &str) -> anyhow::Result { + let directory = tempfile::tempdir().context("Can't create a temporary directory")?; + let path = directory.path().join("jobs.json"); + let mut child = Command::new(program) + .env(TRACEL_DESCRIBE, &path) + // Describing creates no experiment, so it needs no Console settings. + .env("TRACEL_TARGET", "offline") + .stdin(Stdio::null()) + .stdout(Stdio::from(std::io::stderr())) + .spawn() + .with_context(|| format!("Can't run {}", program.display()))?; + + let started = Instant::now(); + let Some(status) = wait_until(&mut child, || started.elapsed() >= DESCRIBE_TIMEOUT)? else { + let _ = child.kill(); + let _ = child.wait(); + return Err(CliError::new( + ErrorKind::Timeout, + format!( + "`{name}` didn't list its jobs within {} seconds.", + DESCRIBE_TIMEOUT.as_secs() + ), + ) + .with_hint( + "To list its jobs, the program must reach `Jobs::run` quickly; move slow setup into its jobs.", + ) + .into()); + }; + if !status.success() { + let ended = match status.code() { + Some(code) => format!("exited with code {code}"), + None => "was ended by a signal".to_string(), + }; + return Err(CliError::new( + ErrorKind::Internal, + format!("`{name}` {ended} instead of listing its jobs."), + ) + .into()); + } + read_definitions(&path, name) +} + +/// Read the definitions the program `name` wrote to `path`, when they follow the +/// protocol this CLI reads. +fn read_definitions(path: &Path, name: &str) -> anyhow::Result { + let json = match std::fs::read(path) { + Ok(json) => json, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(CliError::new( + ErrorKind::Usage, + format!("`{name}` didn't list its jobs."), + ) + .with_hint( + "Define its jobs with `tracel::runtime::Jobs`, or run it with `tracel run -- `.", + ) + .into()); + } + Err(error) => { + return Err(error).with_context(|| format!("Can't read {}", path.display())); + } + }; + DefinitionsFile::from_json(&json).map_err(|error| unreadable_definitions(name, error).into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::classify; + use crate::test_support::job; + + fn package(name: &str, uses_sdk: bool, bins: &[&str]) -> PackageBinaries { + PackageBinaries { + package: name.to_string(), + uses_sdk, + bins: bins.iter().map(|bin| bin.to_string()).collect(), + default_run: None, + } + } + + fn binary(package: &str, name: &str) -> Binary { + Binary { + package: package.to_string(), + name: name.to_string(), + } + } + + fn definitions(names: &[&str]) -> DefinitionsFile { + DefinitionsFile::new(names.iter().map(|name| job(name, None, None)).collect()) + } + + fn cache(definitions: &DefinitionsFile) -> String { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("jobs.json"); + write_cache(&path, "abc", definitions).unwrap(); + std::fs::read_to_string(path).unwrap() + } + + #[test] + fn given_a_job_the_program_defines_when_finding_it_then_it_is_found() { + let jobs = definitions(&["train", "evaluate"]); + + let found = find_job(&jobs, "evaluate").unwrap(); + + assert_eq!(found.name, "evaluate"); + } + + #[test] + fn given_an_unknown_job_when_finding_it_then_the_error_lists_the_jobs() { + let jobs = definitions(&["train", "evaluate"]); + + let error = find_job(&jobs, "serve").unwrap_err(); + + assert_eq!(error.kind, ErrorKind::Usage); + assert!(error.to_string().contains("train, evaluate"), "{error}"); + } + + #[test] + fn given_a_program_without_jobs_when_finding_one_then_the_error_says_so() { + let jobs = definitions(&[]); + + let error = find_job(&jobs, "serve").unwrap_err(); + + assert!(error.to_string().contains("no jobs"), "{error}"); + } + + #[test] + fn given_a_program_that_wrote_no_definitions_when_reading_them_then_it_did_not_list_its_jobs() { + let directory = tempfile::tempdir().unwrap(); + + let error = read_definitions(&directory.path().join("jobs.json"), "trainer").unwrap_err(); + + assert_eq!(classify(&error), ErrorKind::Usage); + assert!(error.to_string().contains("`trainer`"), "{error}"); + } + + #[test] + fn given_one_binary_using_the_sdk_when_choosing_the_binary_then_it_is_chosen() { + let packages = [ + package("tools", false, &["fmt-data"]), + package("trainer", true, &["train"]), + ]; + + let chosen = choose_binary(&packages, None).unwrap(); + + assert_eq!(chosen, binary("trainer", "train")); + } + + #[test] + fn given_bin_when_choosing_the_binary_then_it_picks_among_those_using_the_sdk() { + let packages = [ + package("trainer", true, &["train"]), + package("server", true, &["serve"]), + ]; + + let chosen = choose_binary(&packages, Some("serve")).unwrap(); + + assert_eq!(chosen, binary("server", "serve")); + } + + #[test] + fn given_default_run_in_the_only_package_using_the_sdk_when_choosing_the_binary_then_it_decides() + { + let trainer = PackageBinaries { + default_run: Some("evaluate".to_string()), + ..package("trainer", true, &["train", "evaluate"]) + }; + + let chosen = choose_binary(&[trainer], None).unwrap(); + + assert_eq!(chosen, binary("trainer", "evaluate")); + } + + #[test] + fn given_several_binaries_using_the_sdk_when_choosing_the_binary_then_the_error_lists_them() { + let packages = [ + package("trainer", true, &["train"]), + package("server", true, &["serve"]), + ]; + + let error = choose_binary(&packages, None).unwrap_err(); + + assert!(error.to_string().contains("train, serve"), "{error}"); + } + + #[test] + fn given_no_package_using_the_sdk_when_choosing_the_binary_then_there_are_no_jobs_to_run() { + let packages = [package("tools", false, &["fmt-data"])]; + + let error = choose_binary(&packages, None).unwrap_err(); + + assert!(error.to_string().contains("in this workspace"), "{error}"); + } + + #[test] + fn given_a_package_using_the_sdk_without_a_binary_when_choosing_the_binary_then_there_are_no_jobs_to_run() + { + let packages = [package("model", true, &[])]; + + let error = choose_binary(&packages, None).unwrap_err(); + + assert!(error.to_string().contains("has a binary"), "{error}"); + } + + #[test] + fn given_cached_definitions_of_the_same_binary_when_reading_the_cache_then_they_are_used() { + let contents = cache(&definitions(&["train"])); + + let cached = cached_definitions(&contents, "abc"); + + assert_eq!(cached, Some(definitions(&["train"]))); + } + + #[test] + fn given_cached_definitions_of_another_binary_when_reading_the_cache_then_they_are_not_used() { + let contents = cache(&definitions(&["train"])); + + let cached = cached_definitions(&contents, "def"); + + assert_eq!(cached, None); + } + + #[test] + fn given_cached_definitions_of_another_protocol_when_reading_the_cache_then_they_are_not_used() + { + let contents = + cache(&definitions(&["train"])).replace(r#""protocol": 1"#, r#""protocol": 2"#); + + let cached = cached_definitions(&contents, "abc"); + + assert_eq!(cached, None); + } +} diff --git a/crates/tracel-cli/src/commands/run/input.rs b/crates/tracel-cli/src/commands/run/input.rs new file mode 100644 index 0000000..37c8c48 --- /dev/null +++ b/crates/tracel-cli/src/commands/run/input.rs @@ -0,0 +1,539 @@ +use std::path::Path; + +use serde_json::{Map, Value}; +use tracel_job::JobDefinition; + +use super::job_flags; +use crate::error::{CliError, ErrorKind}; + +/// One step of a `--set` path: an object key, or an array index written `[i]`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Segment { + Key(String), + Index(usize), +} + +/// A `--set =`: the value is JSON, or a string when it isn't JSON. +#[derive(Clone, Debug, PartialEq)] +pub struct Assignment { + pub path: Vec, + pub value: Value, +} + +/// Parse a `--set` argument. +pub fn parse_assignment(argument: &str) -> Result { + let (path, value) = argument + .split_once('=') + .ok_or("expected =, such as optimizer.lr=0.01")?; + Ok(Assignment { + path: parse_path(path)?, + value: serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string())), + }) +} + +/// Parse a dotted path with `[i]` array indices, such as `layers[0].size`. +fn parse_path(path: &str) -> Result, String> { + let mut segments = Vec::new(); + let mut rest = path; + loop { + if !rest.starts_with('[') { + let end = rest.find(['.', '[', ']']).unwrap_or(rest.len()); + if end == 0 { + return Err(format!("empty key in path '{path}'")); + } + segments.push(Segment::Key(rest[..end].to_string())); + rest = &rest[end..]; + } + while let Some(after) = rest.strip_prefix('[') { + let end = after + .find(']') + .ok_or_else(|| format!("missing ']' in path '{path}'"))?; + let index = after[..end] + .parse() + .map_err(|_| format!("'[{}]' isn't an array index", &after[..end]))?; + segments.push(Segment::Index(index)); + rest = &after[end + 1..]; + } + if rest.is_empty() { + return Ok(segments); + } + rest = rest + .strip_prefix('.') + .ok_or_else(|| format!("unexpected '{rest}' in path '{path}'"))?; + } +} + +/// Write a path as `--set` takes it; the whole input is `(root)`. +pub fn display_path(segments: &[Segment]) -> String { + if segments.is_empty() { + return "(root)".to_string(); + } + let mut path = String::new(); + for segment in segments { + match segment { + Segment::Key(key) if path.is_empty() => path.push_str(key), + Segment::Key(key) => { + path.push('.'); + path.push_str(key); + } + Segment::Index(index) => path.push_str(&format!("[{index}]")), + } + } + path +} + +fn type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "an array", + Value::Object(_) => "an object", + } +} + +impl Assignment { + /// Set the value at the path in `input`, creating missing objects and arrays on the + /// way. An index may be one past the end of an array, which appends. + pub fn apply(&self, input: &mut Value) -> Result<(), CliError> { + let mut current = input; + for (depth, segment) in self.path.iter().enumerate() { + let failed = |reason: String| { + CliError::new( + ErrorKind::Usage, + format!( + "Can't set {}: {reason}.", + display_path(&self.path[..=depth]) + ), + ) + }; + let parent = || display_path(&self.path[..depth]); + current = match segment { + Segment::Key(key) => { + if current.is_null() { + *current = Value::Object(Map::new()); + } + match current { + Value::Object(map) => map.entry(key.clone()).or_insert(Value::Null), + other => { + return Err(failed(format!( + "{} is {}, not an object", + parent(), + type_name(other) + ))); + } + } + } + Segment::Index(index) => { + if current.is_null() { + *current = Value::Array(Vec::new()); + } + match current { + Value::Array(items) => { + if *index == items.len() { + items.push(Value::Null); + } + let length = items.len(); + items + .get_mut(*index) + .ok_or_else(|| failed(format!("{} has {length} items", parent())))? + } + other => { + return Err(failed(format!( + "{} is {}, not an array", + parent(), + type_name(other) + ))); + } + } + } + }; + } + *current = self.value.clone(); + Ok(()) + } +} + +/// Apply `patch` to `target` with JSON merge patch (RFC 7386): an object patch replaces +/// the fields it names and removes the ones it sets to `null`; any other patch replaces +/// the target. +pub fn merge_patch(target: &mut Value, patch: &Value) { + let Value::Object(fields) = patch else { + *target = patch.clone(); + return; + }; + if !target.is_object() { + *target = Value::Object(Map::new()); + } + let Some(map) = target.as_object_mut() else { + return; + }; + for (key, value) in fields { + if value.is_null() { + map.remove(key); + } else { + merge_patch(map.entry(key.clone()).or_insert(Value::Null), value); + } + } +} + +/// Read a `-c` file as JSON. +pub fn read_config(path: &Path) -> Result { + let contents = std::fs::read_to_string(path).map_err(|error| { + CliError::new( + ErrorKind::Usage, + format!("Can't read config file '{}': {error}", path.display()), + ) + })?; + serde_json::from_str(&contents).map_err(|error| { + CliError::new( + ErrorKind::Usage, + format!("Config file '{}' isn't JSON: {error}", path.display()), + ) + }) +} + +/// Resolve the input the job `definition` runs with, each step winning over the ones +/// before: `start`, then each config merge-patched onto it in order, then the job's flags +/// `job_flags` set as the job's command line sets them, then each assignment. +pub fn resolve( + definition: &JobDefinition, + start: Value, + configs: &[Value], + job_flags: &[String], + assignments: &[Assignment], +) -> Result { + let mut input = start; + for config in configs { + merge_patch(&mut input, config); + } + let mut input = job_flags::apply(definition, input, job_flags)?; + for assignment in assignments { + assignment.apply(&mut input)?; + } + Ok(input) +} + +/// A place where an input breaks its schema. +#[derive(Debug, PartialEq, Eq)] +pub struct Violation { + pub path: String, + pub message: String, +} + +/// Find where `input` breaks `schema`, or why `schema` can't be used. References outside +/// the schema are not fetched. +pub fn violations(schema: &Value, input: &Value) -> Result, String> { + let validator = jsonschema::validator_for(schema).map_err(|error| error.to_string())?; + Ok(validator + .iter_errors(input) + .map(|error| { + let path: Vec = error + .instance_path() + .iter() + .map(|segment| match segment { + jsonschema::paths::LocationSegment::Property(key) => { + Segment::Key(key.into_owned()) + } + jsonschema::paths::LocationSegment::Index(index) => Segment::Index(index), + }) + .collect(); + Violation { + path: display_path(&path), + message: error.to_string(), + } + }) + .collect()) +} + +/// Build the error for an input that breaks the schema of `job`. +pub fn invalid_input(job: &str, violations: &[Violation]) -> CliError { + let details = violations + .iter() + .map(|violation| format!("{}: {}", violation.path, violation.message)) + .collect::>() + .join("; "); + CliError::new( + ErrorKind::Usage, + format!("The input for job '{job}' is invalid: {details}."), + ) + .with_hint(format!("See its flags with `tracel run {job} --help`.")) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serde_json::json; + + use super::*; + use crate::test_support::job; + + fn key(key: &str) -> Segment { + Segment::Key(key.to_string()) + } + + fn set(input: &mut Value, argument: &str) -> Result<(), CliError> { + parse_assignment(argument).unwrap().apply(input) + } + + fn train() -> JobDefinition { + let schema = json!({ + "type": "object", + "properties": {"epochs": {"type": "integer"}} + }); + job("train", Some(schema), Some(json!({"epochs": 10}))) + } + + fn schema() -> Value { + json!({ + "type": "object", + "properties": { + "epochs": {"type": "integer", "minimum": 0}, + "layers": {"type": "array", "items": {"type": "integer"}} + }, + "required": ["epochs"] + }) + } + + #[test] + fn given_a_dotted_path_with_indices_when_parsing_an_assignment_then_each_key_and_index_is_a_step() + { + let assignment = parse_assignment("layers[0].sizes[2]=1").unwrap(); + + assert_eq!( + assignment.path, + [ + key("layers"), + Segment::Index(0), + key("sizes"), + Segment::Index(2) + ] + ); + } + + #[rstest] + #[case::empty_key(".a=1", "empty key")] + #[case::unclosed_index("a[=1", "missing ']'")] + #[case::not_an_index("a[x]=1", "'[x]'")] + #[case::text_after_an_index("a[0]b=1", "unexpected 'b'")] + fn given_a_malformed_path_when_parsing_an_assignment_then_it_says_what_is_wrong( + #[case] argument: &str, + #[case] reason: &str, + ) { + let error = parse_assignment(argument).unwrap_err(); + + assert!(error.contains(reason), "{error}"); + } + + #[test] + fn given_no_equals_sign_when_parsing_an_assignment_then_it_shows_the_expected_form() { + let error = parse_assignment("epochs").unwrap_err(); + + assert!(error.contains("="), "{error}"); + } + + #[rstest] + #[case::number("epochs=5", json!(5))] + #[case::boolean("shuffle=true", json!(true))] + #[case::array("layers=[1, 2]", json!([1, 2]))] + #[case::quoted_string(r#"name="x""#, json!("x"))] + fn given_a_json_value_when_parsing_an_assignment_then_it_is_that_value( + #[case] argument: &str, + #[case] expected: Value, + ) { + let assignment = parse_assignment(argument).unwrap(); + + assert_eq!(assignment.value, expected); + } + + #[rstest] + #[case::word("name=x", "x")] + #[case::empty("name=", "")] + #[case::with_an_equals_sign("expression=a=b", "a=b")] + fn given_text_that_is_not_json_when_parsing_an_assignment_then_it_is_a_string( + #[case] argument: &str, + #[case] expected: &str, + ) { + let assignment = parse_assignment(argument).unwrap(); + + assert_eq!(assignment.value, json!(expected)); + } + + #[test] + fn given_an_existing_field_when_setting_it_then_its_value_is_replaced() { + let mut input = json!({"optimizer": {"lr": 0.001, "decay": 0.1}}); + + set(&mut input, "optimizer.lr=0.01").unwrap(); + + assert_eq!(input, json!({"optimizer": {"lr": 0.01, "decay": 0.1}})); + } + + #[test] + fn given_missing_objects_on_the_path_when_setting_a_field_then_they_are_created() { + let mut input = json!({"epochs": 10}); + + set(&mut input, "optimizer.schedule.warmup=2").unwrap(); + + assert_eq!( + input, + json!({"epochs": 10, "optimizer": {"schedule": {"warmup": 2}}}) + ); + } + + #[test] + fn given_an_index_one_past_the_end_when_setting_it_then_the_value_is_appended() { + let mut input = json!({"layers": [8]}); + + set(&mut input, "layers[1]=16").unwrap(); + + assert_eq!(input, json!({"layers": [8, 16]})); + } + + #[rstest] + #[case::key_of_a_number("epochs.max=3", "not an object")] + #[case::index_of_a_number("epochs[0]=3", "not an array")] + #[case::index_past_the_end("layers[3]=1", "has 1 items")] + fn given_a_path_the_input_cannot_hold_when_setting_it_then_it_is_a_usage_error_saying_why( + #[case] argument: &str, + #[case] reason: &str, + ) { + let mut input = json!({"epochs": 10, "layers": [8]}); + + let error = set(&mut input, argument).unwrap_err(); + + assert_eq!(error.kind, ErrorKind::Usage); + assert!(error.to_string().contains(reason), "{error}"); + } + + #[test] + fn given_an_object_patch_when_merging_it_then_it_replaces_only_the_fields_it_names() { + let mut input = json!({"epochs": 10, "optimizer": {"lr": 0.001, "decay": 0.1}}); + + merge_patch(&mut input, &json!({"optimizer": {"lr": 0.01}})); + + assert_eq!( + input, + json!({"epochs": 10, "optimizer": {"lr": 0.01, "decay": 0.1}}) + ); + } + + #[test] + fn given_a_null_field_in_the_patch_when_merging_it_then_the_field_is_removed() { + let mut input = json!({"epochs": 10, "tag": "baseline"}); + + merge_patch(&mut input, &json!({"tag": null})); + + assert_eq!(input, json!({"epochs": 10})); + } + + #[test] + fn given_a_patch_that_is_not_an_object_when_merging_it_then_it_replaces_the_input() { + let mut input = json!({"layers": [8, 16]}); + + merge_patch(&mut input, &json!([32])); + + assert_eq!(input, json!([32])); + } + + #[test] + fn given_a_config_when_resolving_the_input_then_it_wins_over_the_example() { + let input = resolve( + &train(), + json!({"epochs": 10}), + &[json!({"epochs": 20})], + &[], + &[], + ); + + assert_eq!(input.unwrap(), json!({"epochs": 20})); + } + + #[test] + fn given_a_job_flag_when_resolving_the_input_then_it_wins_over_the_config() { + let flags = ["--epochs=30".to_string()]; + + let input = resolve( + &train(), + json!({"epochs": 10}), + &[json!({"epochs": 20})], + &flags, + &[], + ); + + assert_eq!(input.unwrap(), json!({"epochs": 30})); + } + + #[test] + fn given_an_assignment_when_resolving_the_input_then_it_wins_over_the_job_flags() { + let flags = ["--epochs=30".to_string()]; + let assignments = [parse_assignment("epochs=40").unwrap()]; + + let input = resolve(&train(), json!({"epochs": 10}), &[], &flags, &assignments); + + assert_eq!(input.unwrap(), json!({"epochs": 40})); + } + + #[test] + fn given_a_config_file_that_is_not_json_when_reading_it_then_it_is_a_usage_error() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("base.json"); + std::fs::write(&path, "epochs = 2").unwrap(); + + let error = read_config(&path).unwrap_err(); + + assert_eq!(error.kind, ErrorKind::Usage); + assert!( + error.to_string().contains(&*path.to_string_lossy()), + "{error}" + ); + } + + #[test] + fn given_an_input_that_follows_the_schema_when_checking_it_then_there_are_no_violations() { + let input = json!({"epochs": 3, "layers": [1, 2]}); + + let violations = violations(&schema(), &input).unwrap(); + + assert_eq!(violations, []); + } + + #[test] + fn given_an_input_that_breaks_the_schema_when_checking_it_then_each_violation_names_its_path() { + let input = json!({"epochs": -1, "layers": [1, "two"]}); + + let violations = violations(&schema(), &input).unwrap(); + + let paths: Vec<&str> = violations + .iter() + .map(|violation| violation.path.as_str()) + .collect(); + assert_eq!(paths, ["epochs", "layers[1]"]); + } + + #[test] + fn given_a_schema_with_a_remote_reference_when_checking_an_input_then_it_is_not_fetched() { + let schema = json!({"$ref": "https://example.com/schema.json"}); + + let checked = violations(&schema, &json!({})); + + assert!(checked.is_err()); + } + + #[test] + fn given_violations_when_reporting_an_invalid_input_then_the_message_lists_them() { + let violations = [Violation { + path: "epochs".into(), + message: "-1 is less than the minimum of 0".into(), + }]; + + let error = invalid_input("train", &violations); + + assert!( + error + .to_string() + .contains("epochs: -1 is less than the minimum of 0"), + "{error}" + ); + } +} diff --git a/crates/tracel-cli/src/commands/run/job_flags.rs b/crates/tracel-cli/src/commands/run/job_flags.rs new file mode 100644 index 0000000..11b0ef3 --- /dev/null +++ b/crates/tracel-cli/src/commands/run/job_flags.rs @@ -0,0 +1,313 @@ +//! The job's own flags on `tracel run `, such as `--epochs 5`: those of the job's +//! command line, which [`job_command`] builds from the job's definition. Only a job whose +//! input has a JSON schema has flags. +//! +//! `tracel run` reads its command line before it knows the job's definition, so each long +//! flag it doesn't take itself is taken as one of the job's, and read with the job's +//! command line once the job is described. `tracel run` keeps its own flags, so a job +//! field whose flag has the name of one of them is set with `--set` instead. + +use std::ffi::OsString; + +use clap::builder::Resettable; +use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; +use serde_json::Value; +use tracel_job::JobDefinition; +use tracel_job::cli::{job_command, job_input}; + +use crate::error::{CliError, ErrorKind}; + +/// The help heading of the job's flags in `tracel run --help`. +const HEADING: &str = "Job options"; + +/// Make `tracel run` take, as flags of its job, the long flags among `args` that it +/// doesn't take itself. +pub fn with_job_flags(tracel: Command, args: &[OsString]) -> Command { + let taken = run_flags(&tracel); + let mut names: Vec<&str> = Vec::new(); + let flags = args + .iter() + .skip(1) + .take_while(|arg| *arg != "--") + .filter_map(|arg| arg.to_str()?.strip_prefix("--")) + .map(flag_name); + for name in flags { + if !name.is_empty() + && !name.starts_with('-') + && !taken.iter().any(|taken| taken == name) + && !names.contains(&name) + { + names.push(name); + } + } + tracel.mut_subcommand("run", |run| run.args(names.into_iter().map(job_flag))) +} + +/// Read the job's flags in `matches`, those [`with_job_flags`] added, as arguments of the +/// job's command line. +pub fn job_flags(matches: &ArgMatches) -> Vec { + let mut arguments = Vec::new(); + for flag in matches.ids().map(|id| id.as_str()) { + if !flag.starts_with("--") { + continue; + } + for mut values in matches + .get_occurrences::(flag) + .into_iter() + .flatten() + { + arguments.push(match values.next() { + Some(value) => format!("{flag}={value}"), + None => flag.to_string(), + }); + } + } + arguments +} + +/// Set the job's flags `flags` on `input`, as the job's command line ` +/// ` sets them: each flag sets one field, an object it gives is laid over the +/// field's, and `null` is kept. A job whose input has no schema has no flags. +pub fn apply( + definition: &JobDefinition, + input: Value, + flags: &[String], +) -> Result { + let Some(flag) = flags.first() else { + return Ok(input); + }; + let job = &definition.name; + if definition.input_schema.is_none() { + let flag = flag_name(flag); + return Err(CliError::new( + ErrorKind::Usage, + format!("Job '{job}' has no flag {flag}: its input has no JSON schema."), + ) + .with_hint( + "Set its input with `--set =`, or give the job's input a JSON schema with `Job::with_schema`.", + )); + } + let arguments = [job.clone(), input.to_string()] + .into_iter() + .chain(flags.iter().cloned()); + let matches = job_command(definition) + .disable_help_flag(true) + .try_get_matches_from(arguments) + .map_err(|error| { + CliError::new(ErrorKind::Usage, message(&error)).with_hint(format!( + "List the flags of job '{job}' with `tracel run {job} --help`." + )) + })?; + job_input(&matches, |path| std::fs::read_to_string(path)) + .map_err(|error| CliError::new(ErrorKind::Usage, error.to_string())) +} + +/// Make `tracel run` that of the job `definition`, for `tracel run --help`: with the +/// job's flags, but those `tracel run` takes itself. +pub fn job_help(tracel: Command, definition: &JobDefinition) -> Command { + let taken = run_flags(&tracel); + let flags: Vec = job_command(definition) + .get_arguments() + .filter_map(|arg| { + let long = arg.get_long()?; + (!taken.iter().any(|taken| taken == long)).then(|| { + arg.clone() + .id(format!("--{long}")) + .required_unless_present(Resettable::Reset) + .help_heading(HEADING) + }) + }) + .collect(); + let usage = format!("tracel run {} [OPTIONS]", definition.name); + tracel.mut_subcommand("run", |run| { + let about = match &definition.description { + Some(description) => Resettable::Value(description.into()), + None => Resettable::Reset, + }; + run.about(about) + .override_usage(usage) + .mut_arg("job", |arg| arg.hide(true)) + .mut_arg("list", |arg| arg.hide(true)) + .mut_arg("forwarded", |arg| arg.hide(true)) + .args(flags) + }) +} + +/// Find the `tracel run` command of `tracel`, built, so it has its help flag and the +/// global flags. +pub fn run_command(tracel: &Command) -> Command { + let mut tracel = tracel.clone(); + tracel.build(); + tracel + .find_subcommand("run") + .expect("`tracel` has a `run` command") + .clone() +} + +/// List the long flags `tracel run` takes, with their aliases. +fn run_flags(tracel: &Command) -> Vec { + run_command(tracel) + .get_arguments() + .flat_map(|arg| { + arg.get_long() + .into_iter() + .chain(arg.get_all_aliases().unwrap_or_default()) + .map(str::to_string) + .collect::>() + }) + .collect() +} + +/// Take the name of a long flag written `name` or `name=value`. +fn flag_name(flag: &str) -> &str { + flag.split_once('=').map_or(flag, |(name, _)| name) +} + +/// Make the argument of `--`, one of the job's flags: given no value, or one, which +/// may be a negative number, as the job's flags take. It needs a job, which `--list` +/// can't be given. +fn job_flag(name: &str) -> Arg { + Arg::new(format!("--{name}")) + .long(name.to_string()) + .value_name("VALUE") + .num_args(0..=1) + .action(ArgAction::Append) + .value_parser(value_parser!(String)) + .allow_negative_numbers(true) + .requires("job") + .conflicts_with("list") + .hide(true) +} + +/// Write the message of clap's `error` without its usage: its lines, trimmed, joined by +/// `; `. +fn message(error: &clap::Error) -> String { + let rendered = error.to_string(); + let message = rendered.split("\nUsage:").next().unwrap_or_default().trim(); + let message = message.strip_prefix("error: ").unwrap_or(message); + message + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join("; ") +} + +#[cfg(test)] +mod tests { + use clap::CommandFactory; + use rstest::rstest; + use serde_json::json; + + use super::*; + use crate::cli::{CliArgs, Commands}; + use crate::error::ErrorReport; + use crate::test_support::{job, parse}; + + fn train() -> JobDefinition { + let schema = json!({ + "type": "object", + "properties": { + "epochs": {"type": "integer"}, + "shuffle": {"type": "boolean"}, + "tag": {"type": "string"}, + "optimizer": { + "type": "object", + "properties": {"lr": {"type": "number"}} + } + } + }); + job("train", Some(schema), Some(json!({"epochs": 10}))) + } + + fn job_flags_of(args: &[&str]) -> Vec { + match parse(args).unwrap().command { + Some(Commands::Run(run)) => run.job_flags, + other => panic!("{args:?} parse as {other:?}"), + } + } + + #[test] + fn given_job_flags_when_clap_checks_tracel_run_then_it_is_valid() { + let args: Vec = ["tracel", "run", "train", "--epochs", "5"] + .map(OsString::from) + .into(); + + let tracel = with_job_flags(CliArgs::command(), &args); + + tracel.debug_assert(); + } + + #[test] + fn given_a_jobs_help_when_clap_checks_it_then_it_is_valid() { + let mut help = job_help(CliArgs::command(), &train()); + + help.build(); + + help.debug_assert(); + } + + #[test] + fn given_flags_tracel_run_does_not_take_when_parsing_it_then_they_are_the_jobs() { + let flags = job_flags_of(&[ + "run", + "train", + "--epochs", + "5", + "--offline", + "--optimizer.lr", + "-0.5", + ]); + + assert_eq!(flags, ["--epochs=5", "--optimizer.lr=-0.5"]); + } + + #[test] + fn given_a_job_flag_without_a_value_when_parsing_tracel_run_then_it_is_kept_bare() { + let flags = job_flags_of(&["run", "train", "--shuffle"]); + + assert_eq!(flags, ["--shuffle"]); + } + + #[test] + fn given_job_flags_when_applying_them_then_each_sets_its_field_with_its_type() { + let input = json!({"epochs": 10, "optimizer": {"lr": 0.001, "decay": 0.1}}); + let flags = [ + "--epochs=5".to_string(), + "--shuffle".to_string(), + "--optimizer.lr=0.01".to_string(), + ]; + + let input = apply(&train(), input, &flags).unwrap(); + + assert_eq!( + input, + json!({"epochs": 5, "shuffle": true, "optimizer": {"lr": 0.01, "decay": 0.1}}) + ); + } + + #[rstest] + #[case::wrong_type("--epochs=ten")] + #[case::unknown_flag("--epoch=5")] + #[case::missing_value("--tag")] + fn given_a_flag_the_job_cannot_take_when_applying_it_then_the_hint_points_at_the_jobs_help( + #[case] flag: &str, + ) { + let error = + anyhow::Error::from(apply(&train(), json!({}), &[flag.to_string()]).unwrap_err()); + + let hint = ErrorReport::new(&error).hint.unwrap(); + + assert!(hint.contains("tracel run train --help"), "{hint}"); + } + + #[test] + fn given_a_job_without_an_input_schema_when_applying_a_flag_then_it_has_no_flags() { + let evaluate = job("evaluate", None, Some(json!({"split": "test"}))); + + let error = apply(&evaluate, json!({}), &["--split=valid".to_string()]).unwrap_err(); + + assert_eq!(error.kind, ErrorKind::Usage); + assert!(error.to_string().contains("--split"), "{error}"); + } +} diff --git a/crates/tracel-cli/src/commands/run/launch.rs b/crates/tracel-cli/src/commands/run/launch.rs new file mode 100644 index 0000000..f3e3a00 --- /dev/null +++ b/crates/tracel-cli/src/commands/run/launch.rs @@ -0,0 +1,325 @@ +use std::io; +use std::path::Path; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use anyhow::Context; +use serde::Serialize; +use tracel_job::{ExitCode, TRACEL_DESCRIBE}; + +use crate::error::{CliError, ErrorKind}; + +/// How often a running program and the signals are checked. +const TICK: Duration = Duration::from_millis(50); + +/// Where a job run records its experiments. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Target { + Console, + Offline, +} + +impl Target { + /// Name the target as `TRACEL_TARGET` does. + pub fn as_str(self) -> &'static str { + match self { + Self::Console => "console", + Self::Offline => "offline", + } + } +} + +/// Choose where to record a run: the Tracel Console when a project is linked and a +/// credential is available, unless `offline` is asked for. Also says why the run is +/// offline when that wasn't asked for. +pub fn select_target( + offline: bool, + linked: bool, + logged_in: bool, +) -> (Target, Option<&'static str>) { + if offline { + return (Target::Offline, None); + } + if !linked { + return ( + Target::Offline, + Some("Recording offline: no project is linked. Link one with `tracel init`."), + ); + } + if !logged_in { + return ( + Target::Offline, + Some( + "Recording offline: you aren't logged in. Log in with `tracel login`, or set TRACEL_API_KEY.", + ), + ); + } + (Target::Console, None) +} + +/// A job to run with the program built from the workspace. +pub struct Launch<'a> { + pub program: &'a Path, + pub job: &'a str, + pub input: &'a str, + pub env: Vec<(&'static str, String)>, + /// Where the program writes its stdout. + pub stdout: Stdio, +} + +/// How the program ended, and whether `tracel` stopped it. +#[derive(Clone, Copy, Debug)] +pub struct Exit { + pub status: ExitStatus, + pub stopped: bool, +} + +/// Run ` ` and wait for it. +/// +/// The program runs in a process group of its own with stdin closed, so a Ctrl-C in the +/// terminal reaches only `tracel`. The first SIGINT, SIGTERM or SIGHUP `tracel` gets kills +/// the program and what it started at once. On Windows, the console also delivers Ctrl-C +/// to the program. +pub fn launch(launch: Launch) -> anyhow::Result { + let signalled = Arc::new(AtomicBool::new(false)); + { + let signalled = Arc::clone(&signalled); + ctrlc::set_handler(move || signalled.store(true, Ordering::SeqCst)) + .context("Can't handle termination signals")?; + } + + let mut command = Command::new(launch.program); + command + .arg(launch.job) + .arg(launch.input) + .env_remove(TRACEL_DESCRIBE) + .envs(launch.env) + .stdin(Stdio::null()) + .stdout(launch.stdout); + let mut child = own_process_group(&mut command) + .spawn() + .with_context(|| format!("Can't run {}", launch.program.display()))?; + + if let Some(status) = wait_until(&mut child, || signalled.load(Ordering::SeqCst))? { + return Ok(Exit { + status, + stopped: false, + }); + } + kill_job(&mut child).context("Can't stop the job")?; + Ok(Exit { + status: child.wait()?, + stopped: true, + }) +} + +/// Wait for `child` to exit, checking every tick until `give_up` says to stop waiting. +/// Returns its status, or `None` when waiting was given up. +pub fn wait_until( + child: &mut Child, + mut give_up: impl FnMut() -> bool, +) -> io::Result> { + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + if give_up() { + return Ok(None); + } + std::thread::sleep(TICK); + } +} + +/// Make the program `command` runs the leader of a process group of its own, which +/// [`kill_job`] kills. On Windows, it is left as is. +fn own_process_group(command: &mut Command) -> &mut Command { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + command +} + +/// Kill the program `child` runs and the processes it started: on Unix with SIGKILL to +/// the process group it leads, or to the program alone when that fails; on Windows the +/// program. +fn kill_job(child: &mut Child) -> io::Result<()> { + #[cfg(unix)] + if let Ok(pid) = libc::pid_t::try_from(child.id()) { + // SAFETY: `killpg` has no memory effects; `pid` leads the group of a child not yet + // waited for. + if unsafe { libc::killpg(pid, libc::SIGKILL) } == 0 { + return Ok(()); + } + } + child.kill() +} + +/// Build the error a run of `job` that ended with `exit` fails with, unless the job +/// completed: `JOB_FAILED` when the job failed or was stopped, and `USAGE` when the +/// program rejected it. +pub fn exit_error(job: &str, exit: &Exit) -> Option { + if exit.stopped { + return Some(CliError::new( + ErrorKind::JobFailed, + format!("Stopped job '{job}'."), + )); + } + let code = exit.status.code(); + let how = match (code.and_then(ExitCode::from_code), code) { + (Some(ExitCode::Completed), _) => return None, + (Some(ExitCode::Rejected), _) => { + return Some(CliError::new( + ErrorKind::Usage, + format!("The program rejected job '{job}' or its input (exit code 2)."), + )); + } + (_, Some(code)) => format!("exit code {code}"), + (_, None) => match signal(exit.status) { + Some(signal) => format!("ended by signal {signal}"), + None => "ended without an exit code".to_string(), + }, + }; + Some(CliError::new( + ErrorKind::JobFailed, + format!("Job '{job}' failed ({how})."), + )) +} + +fn signal(status: ExitStatus) -> Option { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + status.signal() + } + #[cfg(not(unix))] + { + let _ = status; + None + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[cfg(unix)] + fn read_to_end_within( + mut out: impl std::io::Read + Send + 'static, + timeout: Duration, + ) -> Option { + let (sender, text) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut rest = String::new(); + let _ = out.read_to_string(&mut rest); + let _ = sender.send(rest); + }); + text.recv_timeout(timeout).ok() + } + + #[cfg(unix)] + fn exit(script: &str, stopped: bool) -> Exit { + let status = Command::new("sh").args(["-c", script]).status().unwrap(); + Exit { status, stopped } + } + + #[test] + fn given_a_linked_project_and_a_login_when_selecting_where_to_record_then_it_is_the_console() { + let selected = select_target(false, true, true); + + assert_eq!(selected, (Target::Console, None)); + } + + #[test] + fn given_offline_when_selecting_where_to_record_then_it_is_offline_without_a_note() { + let selected = select_target(true, true, true); + + assert_eq!(selected, (Target::Offline, None)); + } + + #[rstest] + #[case::no_linked_project( + false, + true, + "Recording offline: no project is linked. Link one with `tracel init`." + )] + #[case::no_login( + true, + false, + "Recording offline: you aren't logged in. Log in with `tracel login`, or set TRACEL_API_KEY." + )] + fn given_no_way_to_reach_the_console_when_selecting_where_to_record_then_it_is_offline_with_a_note( + #[case] linked: bool, + #[case] logged_in: bool, + #[case] note: &str, + ) { + let selected = select_target(false, linked, logged_in); + + assert_eq!(selected, (Target::Offline, Some(note))); + } + + #[cfg(unix)] + #[test] + fn given_a_program_that_exits_with_0_when_the_run_ends_then_it_did_not_fail() { + let error = exit_error("mnist", &exit("exit 0", false)); + + assert!(error.is_none()); + } + + #[cfg(unix)] + #[rstest] + #[case::exit_code_1("exit 1", "exit code 1")] + #[case::exit_code_3("exit 3", "exit code 3")] + #[case::signal("kill -9 $$", "signal 9")] + fn given_a_program_that_failed_when_the_run_ends_then_the_job_failed_saying_how( + #[case] script: &str, + #[case] how: &str, + ) { + let error = exit_error("mnist", &exit(script, false)).unwrap(); + + assert_eq!(error.kind, ErrorKind::JobFailed); + assert!(error.to_string().contains(how), "{error}"); + } + + #[cfg(unix)] + #[test] + fn given_a_program_that_exits_with_2_when_the_run_ends_then_it_rejected_the_job() { + let error = exit_error("mnist", &exit("exit 2", false)).unwrap(); + + assert_eq!(error.kind, ErrorKind::Usage); + } + + #[cfg(unix)] + #[test] + fn given_a_stopped_job_when_the_run_ends_then_it_failed_as_stopped() { + let error = exit_error("mnist", &exit("exit 0", true)).unwrap(); + + assert_eq!(error.kind, ErrorKind::JobFailed); + assert!(error.to_string().contains("Stopped"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn given_a_job_that_started_a_process_when_killing_it_then_that_process_ends_too() { + use std::io::{BufRead, BufReader}; + + let mut command = Command::new("sh"); + command + .args(["-c", "(echo ready; sleep 30; echo survived) & wait"]) + .stdout(Stdio::piped()); + let mut job = own_process_group(&mut command).spawn().unwrap(); + let mut stdout = BufReader::new(job.stdout.take().unwrap()); + stdout.read_line(&mut String::new()).unwrap(); + + kill_job(&mut job).unwrap(); + + let rest = read_to_end_within(stdout, Duration::from_secs(10)); + assert_eq!(rest.as_deref(), Some("")); + } +} diff --git a/crates/tracel-cli/src/commands/run/remote.rs b/crates/tracel-cli/src/commands/run/remote.rs new file mode 100644 index 0000000..798698e --- /dev/null +++ b/crates/tracel-cli/src/commands/run/remote.rs @@ -0,0 +1,178 @@ +use anyhow::Context; +use serde::Serialize; + +use crate::commands::login::get_client_and_login_if_needed; +use crate::commands::package::{Mode, PackageArgs, build_package}; +use crate::context::CliContext; +use crate::error::{CliError, ErrorKind}; +use crate::helpers::{follow_job, require_workspace_project, validate_project_exists_on_server}; +use crate::ui::{Declined, Human, Outcome, Render, Terminal, Unanswered, short_digest}; + +/// A job `tracel run --remote` queues on a compute provider group. +pub struct RemoteRun<'a> { + pub group: &'a str, + /// The command the job runs the program with. + pub command: String, + pub packaging: PackageArgs, + /// Queue it without asking. + pub yes: bool, + /// Package it and say what would be queued, without uploading or queuing anything. + pub dry_run: bool, + /// Follow its logs until it finishes. + pub follow: bool, +} + +/// A queued job: the data of `run --remote`, and its first NDJSON item with `--follow`. +#[derive(Serialize)] +struct QueuedJob { + job_num: Option, + job_id: String, + group: String, + digest: String, + version_id: String, + command: String, + uploaded: bool, +} + +/// The first event of `--follow`. As JSON, the queued job gains `"type": "queued"`. +#[derive(Serialize)] +#[serde(tag = "type", rename = "queued")] +struct QueuedEvent<'a> { + #[serde(flatten)] + job: &'a QueuedJob, +} + +impl Render for QueuedEvent<'_> { + fn render(&self, _: &mut Human<'_>) -> std::io::Result<()> { + Ok(()) + } +} + +/// The job `--dry-run` would queue. +#[derive(Serialize)] +struct DryRun { + namespace: String, + project: String, + group: String, + mode: Mode, + digest: String, + command: String, +} + +/// Say what queueing a job on `group` needs when the user can't confirm it. +fn queueing(group: &str) -> Unanswered { + Unanswered::new( + format!("Queueing a job on '{group}' needs confirmation."), + "Pass --yes to queue it without asking.", + ) +} + +/// Fail before any slow work when a run on `group` needs answers the user can't give: +/// a packaging mode, or, when it `asks`, whether to queue the job. +pub fn check( + group: &str, + packaging: &PackageArgs, + asks: bool, + terminal: &Terminal, +) -> anyhow::Result<()> { + packaging.check(terminal)?; + if asks { + terminal.require_confirmation(queueing(group))?; + } + Ok(()) +} + +/// Package the workspace as `tracel package` does, then queue it as a job, once the user +/// agrees, since a job may incur costs. +pub fn queue(run: RemoteRun, context: &CliContext) -> anyhow::Result { + let terminal = context.terminal(); + let group = run.group; + let (project, source) = require_workspace_project(context)?; + let linked = project.get_project(); + + if run.dry_run { + let package = build_package(context, &project, &run.packaging)?; + terminal.info(&format!( + "Would queue it on {group} with code version {}.", + short_digest(&package.digest) + )); + terminal.outro("Dry run: nothing was uploaded or queued."); + return Ok(Outcome::data(DryRun { + namespace: linked.namespace.clone(), + project: linked.name.clone(), + group: group.to_string(), + mode: package.mode, + digest: package.digest, + command: run.command, + })); + } + + let client = get_client_and_login_if_needed(context)?; + validate_project_exists_on_server(linked, &source, &client)?; + let package = build_package(context, &project, &run.packaging)?; + let digest = short_digest(&package.digest).to_string(); + if !run.yes { + let confirmed = terminal.confirm( + &format!("Queue it on {group} with code version {digest}? It may incur costs."), + false, + queueing(group), + )?; + if !confirmed { + return Err(Declined::new("No job was queued.").into()); + } + } + + let version = package.publish(context, &client, &project)?; + let queued = client + .queue_job( + &linked.namespace, + &linked.name, + group, + &version.digest, + &run.command, + ) + .with_context(|| format!("Can't queue a job on '{group}'"))?; + let job = QueuedJob { + job_num: queued.job_num, + job_id: queued.job_id, + group: group.to_string(), + digest: version.digest, + version_id: version.version_id, + command: run.command, + uploaded: version.uploaded, + }; + if run.follow { + context.output().event(&QueuedEvent { job: &job })?; + } + + let Some(num) = job.job_num else { + let queued = format!("Queued a job with code version {digest}."); + if run.follow { + terminal.success(&queued); + return Err(CliError::new( + ErrorKind::Internal, + "The Tracel Console didn't send the job's number, so it can't be followed.", + ) + .with_hint("Find it with `tracel jobs list`, then follow it with `tracel jobs logs --follow`.") + .into()); + } + terminal.next_steps(&queued, &["tracel jobs list".to_string()]); + return Ok(Outcome::data(job)); + }; + + let queued = format!("Queued job {num} with code version {digest}."); + if run.follow { + terminal.success(&queued); + follow_job(&client, context.output(), linked, num)?; + terminal.outro(&format!("Job {num} completed.")); + return Ok(Outcome::streamed()); + } + terminal.next_steps( + &queued, + &[ + format!("tracel jobs logs {num} --follow"), + format!("tracel jobs wait {num}"), + ], + ); + Ok(Outcome::data(job)) +} diff --git a/crates/tracel-cli/src/commands/training.rs b/crates/tracel-cli/src/commands/training.rs deleted file mode 100644 index 0a013c8..0000000 --- a/crates/tracel-cli/src/commands/training.rs +++ /dev/null @@ -1,60 +0,0 @@ -use anyhow::Context; -use clap::Parser; - -use crate::{context::CliContext, environment, tools::cargo, ui::Outcome}; - -#[derive(Parser, Debug, Default)] -pub struct TrainingArgs { - /// Arguments for the program, after -- - #[arg( - value_name = "ARGS", - trailing_var_arg = true, - allow_hyphen_values = true - )] - forwarded: Vec, -} - -pub fn handle_command(args: TrainingArgs, context: CliContext) -> anyhow::Result { - run_cargo(&args.forwarded, context)?; - Ok(Outcome::streamed()) -} - -/// Run `cargo run` in this directory with `forwarded` after `--`. The program gets -/// tracel's stdio, and its exit code is tracel's. -fn run_cargo(forwarded: &[String], context: CliContext) -> anyhow::Result<()> { - let mut cmd = cargo::command(); - cmd.arg("run"); - - cmd.env("TRACEL_ENV", environment::sdk_value(&context.environment())); - if let Some(project) = context.project() { - cmd.env("TRACEL_NAMESPACE", &project.namespace); - cmd.env("TRACEL_PROJECT", &project.name); - } - - if !forwarded.is_empty() { - cmd.arg("--"); - cmd.args(forwarded); - } - - hand_over(cmd) -} - -/// Replace this process with `cmd`. `cargo run` does the same with the program it -/// builds, so a signal sent to `tracel` reaches the program instead of orphaning it. -#[cfg(unix)] -fn hand_over(mut cmd: std::process::Command) -> anyhow::Result<()> { - use std::os::unix::process::CommandExt; - - Err(cmd.exec()).context("Can't run `cargo run`") -} - -/// Windows cannot replace a process, so wait for the child and mirror its exit code. -#[cfg(not(unix))] -fn hand_over(mut cmd: std::process::Command) -> anyhow::Result<()> { - let status = cmd.status().context("Can't run `cargo run`")?; - if !status.success() { - std::process::exit(status.code().unwrap_or(1)); - } - - Ok(()) -} diff --git a/crates/tracel-cli/src/context.rs b/crates/tracel-cli/src/context.rs index 79f38cb..49e21c8 100644 --- a/crates/tracel-cli/src/context.rs +++ b/crates/tracel-cli/src/context.rs @@ -1,8 +1,11 @@ use std::sync::Arc; +use std::time::SystemTime; use tracel_client::ClientError; use tracel_client::console::auth::DeviceAuthClient; -use tracel_client::console::{AppSession, Client, Env, FileSessionStore, TracelCredentials}; +use tracel_client::console::{ + AppSession, Client, Env, FileSessionStore, StoredAppSession, TracelCredentials, +}; use url::Url; use crate::environment; @@ -63,10 +66,28 @@ impl CliContext { /// Find the credential commands use: `TRACEL_API_KEY` when it is set, else the /// stored login. pub fn credentials(&self) -> Result, ClientError> { + self.credentials_where(|_| true) + } + + /// Whether a credential works without logging in: `TRACEL_API_KEY`, or a stored login + /// that hasn't ended. + pub fn has_credentials(&self) -> Result { + let now = SystemTime::now(); + Ok(self + .credentials_where(|login| !login.has_ended_at(now))? + .is_some()) + } + + /// Find `TRACEL_API_KEY`, else the stored login when `usable` accepts it. + fn credentials_where( + &self, + usable: impl FnOnce(&StoredAppSession) -> bool, + ) -> Result, ClientError> { first_credentials(TracelCredentials::from_env().ok(), || { let app_session = self.app_session()?; Ok(app_session .stored()? + .filter(|login| usable(login)) .map(|_| TracelCredentials::app_session(app_session))) }) } @@ -99,7 +120,8 @@ impl CliContext { &self.terminal } - /// Return stdout, for commands that stream their output while they run. + /// Return stdout, for commands that write to it while they run: streams, or a + /// program they run. pub fn output(&self) -> &Output { &self.output } diff --git a/crates/tracel-cli/src/error.rs b/crates/tracel-cli/src/error.rs index 26d54e2..c655927 100644 --- a/crates/tracel-cli/src/error.rs +++ b/crates/tracel-cli/src/error.rs @@ -15,6 +15,7 @@ pub enum ErrorKind { Conflict, ConfirmationRequired, LimitReached, + JobFailed, Timeout, Unavailable, } @@ -30,6 +31,7 @@ impl ErrorKind { Self::Conflict => 6, Self::ConfirmationRequired => 7, Self::LimitReached => 8, + Self::JobFailed => 9, Self::Timeout => 10, Self::Unavailable => 11, } diff --git a/crates/tracel-cli/src/helpers/download.rs b/crates/tracel-cli/src/helpers/download.rs index 55a63c4..68ae8ad 100644 --- a/crates/tracel-cli/src/helpers/download.rs +++ b/crates/tracel-cli/src/helpers/download.rs @@ -77,14 +77,14 @@ impl HashingWriter { } fn verify(&self, file: &DownloadFile) -> anyhow::Result<()> { - if let Some(size) = file.size_bytes.filter(|size| *size != 0) { - if self.bytes != size { - anyhow::bail!( - "Size mismatch for '{}': expected {size} bytes, received {}.", - file.rel_path, - self.bytes - ); - } + if let Some(size) = file.size_bytes.filter(|size| *size != 0) + && self.bytes != size + { + anyhow::bail!( + "Size mismatch for '{}': expected {size} bytes, received {}.", + file.rel_path, + self.bytes + ); } if let Some(checksum) = file .checksum diff --git a/crates/tracel-cli/src/helpers/jobs.rs b/crates/tracel-cli/src/helpers/jobs.rs new file mode 100644 index 0000000..686ec98 --- /dev/null +++ b/crates/tracel-cli/src/helpers/jobs.rs @@ -0,0 +1,350 @@ +//! Jobs on compute providers: the command a queued job runs the program with, and +//! reading a job until it finishes. + +use std::borrow::Cow; +use std::io::{self, Write}; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use serde_json::Value; +use tracel_client::ClientError; +use tracel_client::console::Client; +use tracel_client::console::job::response::{JobLogResponse, JobResponse}; + +use super::{Resource, map_resource_error}; +use crate::error::{CliError, ErrorKind}; +use crate::tools::tracel_config::TracelProject; +use crate::ui::{Human, Output, Render}; + +/// How often a followed job is read again once its logs are caught up. +const FOLLOW_INTERVAL: Duration = Duration::from_secs(2); + +/// Write the job command that gives the program `arguments` unchanged. +/// +/// The compute provider splits the command into words with shell quoting rules (quotes, +/// backslashes, and `#` comments, without expansions), so every argument the split would +/// change is single-quoted. +pub fn job_command(arguments: &[String]) -> String { + arguments + .iter() + .map(|argument| quote(argument)) + .collect::>() + .join(" ") +} + +fn quote(argument: &str) -> Cow<'_, str> { + let unchanged = !argument.is_empty() + && !argument.starts_with('#') + && !argument.contains([' ', '\t', '\n', '\'', '"', '\\']); + if unchanged { + return Cow::Borrowed(argument); + } + Cow::Owned(format!("'{}'", argument.replace('\'', r"'\''"))) +} + +/// Read the job and input of a command `tracel run --remote` queued, as +/// [`job_command`] writes it; `None` for any other command. +pub fn job_invocation(command: &str) -> Option<(String, Value)> { + let [job, input] = <[String; 2]>::try_from(job_arguments(command)?).ok()?; + let input: Value = serde_json::from_str(&input).ok()?; + let named = !job.is_empty() && !job.starts_with('-'); + (named && input.is_object()).then_some((job, input)) +} + +/// Split a command [`job_command`] wrote into its arguments; `None` when it quotes in a +/// way [`job_command`] doesn't. +fn job_arguments(command: &str) -> Option> { + let mut arguments = Vec::new(); + let mut argument = None::; + let mut chars = command.chars(); + while let Some(c) = chars.next() { + match c { + ' ' => arguments.extend(argument.take()), + '\'' => { + let argument = argument.get_or_insert_default(); + loop { + match chars.next()? { + '\'' => break, + c => argument.push(c), + } + } + } + '\\' => argument.get_or_insert_default().push(chars.next()?), + '"' | '\t' | '\n' => return None, + c => argument.get_or_insert_default().push(c), + } + } + arguments.extend(argument); + Some(arguments) +} + +/// How a job ended, from its status, or `None` while the status can still change. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FinalStatus { + Completed, + Failed, + Cancelled, +} + +pub fn final_status(status: &str) -> Option { + match status { + "completed" => Some(FinalStatus::Completed), + "failed" => Some(FinalStatus::Failed), + "cancelled" => Some(FinalStatus::Cancelled), + _ => None, + } +} + +/// Fail with `JOB_FAILED` unless `job`, whose status is final, completed. +pub fn require_completed(job: &JobResponse) -> Result<(), CliError> { + let num = job.job_num; + let message = match final_status(&job.status) { + Some(FinalStatus::Completed) | None => return Ok(()), + Some(FinalStatus::Failed) => format!("Job {num} failed."), + Some(FinalStatus::Cancelled) => format!("Job {num} was cancelled."), + }; + Err(CliError::new(ErrorKind::JobFailed, message)) +} + +/// Turn the Tracel Console's "not found" for job `num` into an error that names it. +pub fn missing_job(error: ClientError, project: &TracelProject, num: i32) -> anyhow::Error { + map_resource_error(error, &project.namespace, &project.name, Resource::Job(num)) +} + +pub fn read_job(client: &Client, project: &TracelProject, num: i32) -> anyhow::Result { + client + .get_job(&project.namespace, &project.name, num) + .map_err(|error| missing_job(error, project, num)) +} + +/// Read job `num` every `interval` until its status is final, or `deadline` passes, and +/// return it as last read. `each` gets every read. +pub fn poll_job( + client: &Client, + project: &TracelProject, + num: i32, + interval: Duration, + deadline: Option, + mut each: impl FnMut(&JobResponse) -> anyhow::Result<()>, +) -> anyhow::Result { + loop { + let job = read_job(client, project, num)?; + each(&job)?; + if final_status(&job.status).is_some() { + return Ok(job); + } + let pause = match deadline { + Some(deadline) => match deadline.saturating_duration_since(Instant::now()) { + left if left.is_zero() => return Ok(job), + left => interval.min(left), + }, + None => interval, + }; + std::thread::sleep(pause); + } +} + +/// An event of following a job's logs. As JSON, a page of logs gains `"type": "log"`. +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum LogEvent<'a> { + Log(&'a JobLogResponse), + End { + status: &'a str, + /// Whether the logs so far end inside a line, which the text then ends. + #[serde(skip)] + mid_line: bool, + }, +} + +impl Render for LogEvent<'_> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { + match self { + Self::Log(page) => out.write_all(page.logs.as_bytes()), + Self::End { mid_line: true, .. } => writeln!(out), + Self::End { .. } => Ok(()), + } + } +} + +/// Find where the next read of the logs starts after `page`, read from `start`, and +/// whether more is already there to read. +fn next_read(start: u64, page: &JobLogResponse) -> anyhow::Result<(u64, bool)> { + let next = page.end.max(start); + if page.has_more && next == start { + anyhow::bail!("The Tracel Console has more logs to send, but not from where."); + } + Ok((next, page.has_more)) +} + +/// Write job `num`'s logs from byte `start` until the job has finished, and return it as +/// last read. The job is read before its logs, so the logs read after a final status are +/// complete. +pub fn follow_logs( + client: &Client, + output: &Output, + project: &TracelProject, + num: i32, + start: u64, +) -> anyhow::Result { + let mut start = start; + let mut mid_line = false; + let job = poll_job(client, project, num, FOLLOW_INTERVAL, None, |_| { + loop { + let page = client + .get_job_logs(&project.namespace, &project.name, num, Some(start)) + .map_err(|error| missing_job(error, project, num))?; + if !page.logs.is_empty() { + output.event(&LogEvent::Log(&page))?; + mid_line = !page.logs.ends_with('\n'); + } + let more; + (start, more) = next_read(start, &page)?; + if !more { + return Ok(()); + } + } + })?; + output.event(&LogEvent::End { + status: &job.status, + mid_line, + })?; + Ok(job) +} + +/// Follow job `num`'s logs from the start until it finishes, and fail unless it +/// completed. +pub fn follow_job( + client: &Client, + output: &Output, + project: &TracelProject, + num: i32, +) -> anyhow::Result { + let job = follow_logs(client, output, project, num, 0)?; + require_completed(&job)?; + Ok(job) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serde_json::json; + + use super::*; + use crate::test_support::plain; + + fn command(arguments: &[&str]) -> String { + let arguments: Vec = arguments + .iter() + .map(|argument| argument.to_string()) + .collect(); + job_command(&arguments) + } + + fn page(end: u64, has_more: bool) -> JobLogResponse { + JobLogResponse { + logs: "epoch 1\n".into(), + start: 0, + end, + total_size: 4096, + has_more, + } + } + + #[test] + fn given_plain_arguments_when_writing_a_job_command_then_they_are_joined_unchanged() { + let written = command(&["train", "--epochs", "10", "--lr=0.01", "$HOME", "*.rs"]); + + assert_eq!(written, "train --epochs 10 --lr=0.01 $HOME *.rs"); + } + + #[rstest] + #[case::empty("", "''")] + #[case::space("my run", "'my run'")] + #[case::leading_hash("#tag", "'#tag'")] + #[case::double_quote(r#"say "hi""#, r#"'say "hi"'"#)] + #[case::backslash(r"C:\data", r"'C:\data'")] + #[case::single_quote("it's", r"'it'\''s'")] + fn given_an_argument_the_split_would_change_when_writing_a_job_command_then_it_is_single_quoted( + #[case] argument: &str, + #[case] expected: &str, + ) { + let written = command(&[argument]); + + assert_eq!(written, expected); + } + + #[test] + fn given_a_job_command_when_reading_it_back_then_it_gives_the_same_arguments() { + let arguments = [ + "--name", + "my run", + "", + "it's", + "#tag", + r#"{"label":"a \"b\""}"#, + ]; + let written = command(&arguments); + + let read = job_arguments(&written).unwrap(); + + assert_eq!(read, arguments); + } + + #[test] + fn given_a_job_and_its_input_queued_by_tracel_run_when_reading_the_command_then_both_are_found() + { + let input = json!({"epochs": 2, "optimizer": {"lr": 0.01}}); + let queued = command(&["train", &input.to_string()]); + + let invocation = job_invocation(&queued); + + assert_eq!(invocation, Some(("train".to_string(), input))); + } + + #[rstest] + #[case::flags("train --epochs 10")] + #[case::input_not_an_object("train '[1]'")] + #[case::flag_for_a_job("--epochs '{}'")] + #[case::extra_argument("train '{}' extra")] + fn given_another_command_when_reading_it_as_a_job_and_input_then_there_is_none( + #[case] queued: &str, + ) { + let invocation = job_invocation(queued); + + assert_eq!(invocation, None); + } + + #[test] + fn given_a_page_when_reading_on_then_the_next_read_starts_where_it_ends() { + let next = next_read(0, &page(8, true)).unwrap(); + + assert_eq!(next, (8, true)); + } + + #[test] + fn given_a_page_that_ends_before_the_start_when_reading_on_then_the_start_is_kept() { + let next = next_read(16, &page(8, false)).unwrap(); + + assert_eq!(next, (16, false)); + } + + #[test] + fn given_more_logs_but_no_progress_when_reading_on_then_it_fails() { + let next = next_read(8, &page(8, true)); + + assert!(next.is_err()); + } + + #[test] + fn given_logs_that_end_mid_line_when_the_job_ends_then_the_line_is_ended() { + let end = LogEvent::End { + status: "completed", + mid_line: true, + }; + + let text = plain(&end); + + assert_eq!(text, "\n"); + } +} diff --git a/crates/tracel-cli/src/helpers/mod.rs b/crates/tracel-cli/src/helpers/mod.rs index cbca47d..2de94ac 100644 --- a/crates/tracel-cli/src/helpers/mod.rs +++ b/crates/tracel-cli/src/helpers/mod.rs @@ -1,13 +1,18 @@ mod download; +mod jobs; mod model_upload; mod project; mod resources; pub use download::{DownloadFile, DownloadResult, download_files, validate_rel_path}; +pub use jobs::{ + FinalStatus, final_status, follow_job, follow_logs, job_command, job_invocation, missing_job, + poll_job, read_job, require_completed, +}; pub use model_upload::{build_part_tasks, ensure_model_exists, upload_parts}; pub use project::{ ProjectSource, can_initialize_project, missing_project, parse_project, require_cargo_workspace, - require_linked_project, resolve_namespace_project, resolve_project, + require_linked_project, require_workspace_project, resolve_namespace_project, resolve_project, validate_project_exists_on_server, }; pub use resources::{ diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index 2ca7133..c5b62c6 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -1,7 +1,9 @@ //! Finding the workspace and the project a command works on. +use std::path::Path; + use anyhow::Context; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use crate::{ context::CliContext, @@ -9,7 +11,7 @@ use crate::{ tools::{ cargo::try_locate_manifest, project_context::{ErrorKind, ProjectContext, ProjectContextError}, - tracel_config::TracelProject, + tracel_config::{TracelProject, TracelToml}, workspace::WorkspaceInfo, }, }; @@ -71,13 +73,15 @@ pub fn require_linked_project() -> anyhow::Result { } pub fn parse_project(value: &str) -> Result { - if let Some((namespace, project)) = value.split_once('/') { - if !namespace.is_empty() && !project.is_empty() && !project.contains('/') { - return Ok(TracelProject { - namespace: namespace.to_owned(), - name: project.to_owned(), - }); - } + if let Some((namespace, project)) = value.split_once('/') + && !namespace.is_empty() + && !project.is_empty() + && !project.contains('/') + { + return Ok(TracelProject { + namespace: namespace.to_owned(), + name: project.to_owned(), + }); } Err(CliError::new( @@ -112,14 +116,6 @@ pub struct ResolvedProject { pub source: ProjectSource, } -#[derive(Deserialize, Default)] -struct TracelTomlConfig { - #[serde(alias = "owner")] - namespace: Option, - #[serde(alias = "name")] - project: Option, -} - fn project_not_found() -> CliError { CliError::new(CliErrorKind::NotFound, "No project here.") .with_hint("Run `tracel init`, or pass --project /.") @@ -160,36 +156,31 @@ pub fn resolve_project(flag: Option<&TracelProject>) -> anyhow::Result contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(TracelTomlConfig::default()); - } - Err(error) => { - return Err(CliError::new( - CliErrorKind::Usage, - format!("Can't read '{}': {error}", path.display()), - ) - .into()); - } - }; - toml::from_str(&contents).map_err(|error| { - CliError::new( - CliErrorKind::Usage, - format!("Can't parse '{}': {error}", path.display()), - ) - .into() - }) + Ok(read_tracel_toml(workspace_root)?) }, ) } +/// Read the `tracel.toml` of `workspace_root`, empty when there is none. +fn read_tracel_toml(workspace_root: &Path) -> Result { + TracelToml::load(workspace_root) + .map(Option::unwrap_or_default) + .map_err(|error| { + CliError::new( + CliErrorKind::Usage, + format!( + "Can't read '{}': {error}", + TracelProject::path(workspace_root).display() + ), + ) + }) +} + fn resolve_project_from( flag: Option<&TracelProject>, namespace: Option, project: Option, - load_config: impl FnOnce() -> anyhow::Result, + load_config: impl FnOnce() -> anyhow::Result, ) -> anyhow::Result { if let Some(project) = flag { return Ok(ResolvedProject { @@ -204,7 +195,7 @@ fn resolve_project_from( ProjectSource::TracelToml }; let config = if namespace.is_some() && project.is_some() { - TracelTomlConfig::default() + TracelToml::default() } else { load_config()? }; @@ -226,6 +217,20 @@ pub fn require_cargo_workspace() -> anyhow::Result { ProjectContext::load_workspace_info(&manifest_path).map_err(explain_project_context_error) } +/// Load the Cargo workspace, with the project a command works on and how it was +/// selected. +pub fn require_workspace_project( + context: &CliContext, +) -> anyhow::Result<(ProjectContext, ProjectSource)> { + let workspace_info = require_cargo_workspace()?; + let resolved = resolve_namespace_project(context)?; + let project = ProjectContext { + workspace_info, + project: resolved.project, + }; + Ok((project, resolved.source)) +} + /// Decide whether `tracel init` links: not when the workspace is linked and `force` is /// off. Fails outside a workspace. pub fn can_initialize_project(force: bool) -> anyhow::Result { @@ -255,14 +260,14 @@ mod tests { use super::*; use crate::error::{ErrorReport, classify}; - fn tracel_toml(namespace: Option<&str>, project: Option<&str>) -> TracelTomlConfig { - TracelTomlConfig { + fn tracel_toml(namespace: Option<&str>, project: Option<&str>) -> TracelToml { + TracelToml { namespace: namespace.map(str::to_string), project: project.map(str::to_string), } } - fn unread() -> anyhow::Result { + fn unread() -> anyhow::Result { panic!("tracel.toml was read") } diff --git a/crates/tracel-cli/src/helpers/resources.rs b/crates/tracel-cli/src/helpers/resources.rs index 67d7e98..375cf3d 100644 --- a/crates/tracel-cli/src/helpers/resources.rs +++ b/crates/tracel-cli/src/helpers/resources.rs @@ -12,6 +12,7 @@ pub enum Resource<'a> { ModelAlias { model: &'a str, alias: &'a str }, Experiment(i32), Dataset(&'a str), + Job(i32), } /// Turn the Tracel Console's "not found" for `resource` into an error that names it and @@ -71,6 +72,10 @@ pub fn map_resource_error( format!("No dataset '{dataset}' in {namespace}/{project}."), "List datasets with `tracel datasets list`.".to_string(), ), + (ClientError::NotFoundWithCode(ApiErrorCode::ComputeProviderJob), Resource::Job(job)) => ( + format!("No job {job} in {namespace}/{project}."), + "List jobs with `tracel jobs list`.".to_string(), + ), _ => return error.into(), }; CliError::new(ErrorKind::NotFound, message) @@ -190,6 +195,12 @@ mod tests { "'images'", "tracel datasets list" )] + #[case::job( + ApiErrorCode::ComputeProviderJob, + Resource::Job(12), + "job 12", + "tracel jobs list" + )] fn given_the_console_says_what_is_missing_when_mapping_the_error_then_it_names_it_and_how_to_list_them( #[case] code: ApiErrorCode, #[case] resource: Resource<'static>, diff --git a/crates/tracel-cli/src/test_support.rs b/crates/tracel-cli/src/test_support.rs index 11af988..155330a 100644 --- a/crates/tracel-cli/src/test_support.rs +++ b/crates/tracel-cli/src/test_support.rs @@ -1,5 +1,8 @@ //! Helpers shared by the tests of several modules. +use serde_json::Value; +use tracel_job::JobDefinition; + use crate::cli::{CliArgs, Commands}; use crate::ui::{self, Format, Human, Layout, Place, Render, Terminal}; @@ -25,6 +28,17 @@ pub fn plain(result: &impl Render) -> String { String::from_utf8(text).expect("results are UTF-8") } +/// A job named `name`, with no description, and with an input schema and an example +/// input when given. +pub fn job(name: &str, input_schema: Option, input_example: Option) -> JobDefinition { + JobDefinition { + name: name.to_string(), + description: None, + input_schema, + input_example, + } +} + /// A terminal that never draws or asks: JSON output, and `--no-input`. pub fn quiet_terminal() -> Terminal { ui::channels(Format::Json, true, Layout::Bare, Place::default()).1 diff --git a/crates/tracel-cli/src/tools/cargo.rs b/crates/tracel-cli/src/tools/cargo.rs index 98af800..5e09a0e 100644 --- a/crates/tracel-cli/src/tools/cargo.rs +++ b/crates/tracel-cli/src/tools/cargo.rs @@ -1,10 +1,14 @@ use std::ffi::OsString; use std::io::{self, BufRead, BufReader, Write}; use std::path::PathBuf; -use std::process::Stdio; +use std::process::{Command, Stdio}; use anyhow::Context; -use cargo_metadata::Message; +use cargo_metadata::{Message, Metadata}; + +use crate::error::{CliError, ErrorKind}; +use crate::tools::build_driver::BuildDriver; +use crate::ui::Terminal; /// Find the manifest of the Cargo workspace around the current directory. pub fn try_locate_manifest() -> Option { @@ -20,42 +24,108 @@ pub fn try_locate_manifest() -> Option { } /// Start a cargo command, with the cargo `CARGO` names when it is set. -pub fn command() -> std::process::Command { - std::process::Command::new(std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))) +pub fn command() -> Command { + Command::new(std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))) } -/// A finished cargo build whose own output was kept back. -pub struct QuietBuild { - label: String, - succeeded: bool, - output: Vec, - executables: Vec<(String, PathBuf)>, +/// A binary target of a workspace package. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Binary { + pub package: String, + pub name: String, } -impl QuietBuild { - pub fn succeeded(&self) -> bool { - self.succeeded - } +/// List the binary targets of the workspace's packages. +pub fn binaries(metadata: &Metadata) -> Vec { + metadata + .workspace_packages() + .into_iter() + .flat_map(|package| { + package + .targets + .iter() + .filter(|target| target.is_bin()) + .map(|target| Binary { + package: package.name.to_string(), + name: target.name.clone(), + }) + }) + .collect() +} - /// Print cargo's output on stderr, as cargo would have. - pub fn show_output(&self) { - let _ = io::stderr().write_all(&self.output); +/// Find the binary `--bin ` names among `binaries`, with its package. +pub fn find_binary(binaries: &[Binary], name: &str) -> Result { + if let Some(binary) = binaries.iter().find(|binary| binary.name == name) { + return Ok(binary.clone()); } + let names: Vec<&str> = binaries.iter().map(|binary| binary.name.as_str()).collect(); + Err(CliError::new( + ErrorKind::Usage, + format!("No binary '{name}'. Binaries: {}.", names.join(", ")), + )) +} - /// List the executables the build made as `(target name, path)` pairs, or fail if - /// the build did. - pub fn executables(self) -> anyhow::Result> { - if !self.succeeded { - anyhow::bail!("`{}` failed.", self.label); +/// Make the release build of `bin`, or of every binary, with `driver`, for `target` when +/// cross-building, and the command as messages show it. +pub fn release_build( + driver: BuildDriver, + target: Option<&str>, + linker: Option<&str>, + bin: Option<&Binary>, +) -> (Command, String) { + let mut command = command(); + command.args(driver.subcommand_args()).arg("--release"); + let mut label = format!("{} --release", driver.label()); + if let Some(bin) = bin { + command.args(["--package", &bin.package, "--bin", &bin.name]); + label.push_str(&format!(" --package {} --bin {}", bin.package, bin.name)); + } + if let Some(triple) = target { + command.args(["--target", triple]); + label.push_str(&format!(" --target {triple}")); + if let Some(linker) = linker { + command + .arg("--config") + .arg(format!("target.{triple}.linker=\"{linker}\"")); } - Ok(self.executables) } + (command, label) +} + +/// Run `command`, a cargo build named `label` in errors, under a spinner saying `building` +/// and what cargo compiles, and list the executables it built as `(target name, path)` +/// pairs. The spinner clears once the build is done; cargo's own output shows only when +/// the build fails. +pub fn build( + terminal: &Terminal, + command: Command, + label: &str, + building: &str, +) -> anyhow::Result> { + let spinner = terminal.spinner(building); + let built = build_quietly(command, label, |status| { + spinner.set_message(format!("{building}: {status}")); + }); + spinner.clear(); + let built = built?; + if !built.succeeded { + let _ = io::stderr().write_all(&built.output); + anyhow::bail!("`{label}` failed."); + } + Ok(built.executables) +} + +/// A finished cargo build whose own output was kept back. +struct QuietBuild { + succeeded: bool, + output: Vec, + executables: Vec<(String, PathBuf)>, } /// Run `command`, a cargo build named `label` in errors, keeping cargo's output back. /// Each status line, such as `Compiling trainer v0.1.0`, goes to `status` as it comes. -pub fn build_quietly( - mut command: std::process::Command, +fn build_quietly( + mut command: Command, label: &str, mut status: impl FnMut(&str), ) -> anyhow::Result { @@ -84,7 +154,6 @@ pub fn build_quietly( .join() .map_err(|_| anyhow::anyhow!("Can't read the output of `{label}`."))??; Ok(QuietBuild { - label: label.to_string(), succeeded: child.wait()?.success(), output, executables, @@ -159,4 +228,76 @@ mod tests { assert_eq!(status, None); } + + fn trainer() -> Binary { + Binary { + package: "trainer".to_string(), + name: "train".to_string(), + } + } + + fn args(command: &Command) -> Vec<&str> { + command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect() + } + + #[test] + fn given_a_binary_when_making_its_release_build_then_only_that_binary_is_built() { + let (command, _) = release_build(BuildDriver::Cargo, None, None, Some(&trainer())); + + assert_eq!( + args(&command), + [ + "build", + "--release", + "--package", + "trainer", + "--bin", + "train" + ] + ); + } + + #[test] + fn given_a_cross_target_and_its_linker_when_making_the_release_build_then_the_linker_is_set_for_that_target() + { + let target = "aarch64-unknown-linux-gnu"; + + let (command, _) = release_build( + BuildDriver::Cargo, + Some(target), + Some("aarch64-linux-gnu-gcc"), + None, + ); + + assert_eq!( + args(&command), + [ + "build", + "--release", + "--target", + target, + "--config", + "target.aarch64-unknown-linux-gnu.linker=\"aarch64-linux-gnu-gcc\"" + ] + ); + } + + #[test] + fn given_an_unknown_binary_when_finding_it_then_the_error_lists_the_binaries() { + let binaries = [ + Binary { + package: "tools".to_string(), + name: "fmt-data".to_string(), + }, + trainer(), + ]; + + let error = find_binary(&binaries, "serve").unwrap_err(); + + assert_eq!(error.kind, ErrorKind::Usage); + assert!(error.to_string().contains("fmt-data, train"), "{error}"); + } } diff --git a/crates/tracel-cli/src/tools/fs.rs b/crates/tracel-cli/src/tools/fs.rs index 8591db5..bd2b340 100644 --- a/crates/tracel-cli/src/tools/fs.rs +++ b/crates/tracel-cli/src/tools/fs.rs @@ -47,7 +47,8 @@ pub struct FileMeta { pub checksum: String, } -fn file_sha256_and_size(path: &Path) -> anyhow::Result<(String, u64)> { +/// Hash a file with SHA-256, and measure it. +pub fn file_sha256_and_size(path: &Path) -> anyhow::Result<(String, u64)> { let mut file = std::fs::File::open(path).with_context(|| format!("Can't open '{}'", path.display()))?; @@ -85,6 +86,22 @@ pub fn build_file_specs(files: &BTreeMap) -> anyhow::Result(files: impl IntoIterator) -> String { + let mut lines: Vec<(&str, &str)> = files.into_iter().collect(); + lines.sort_unstable(); + + let mut hasher = Sha256::new(); + for (name, checksum) in lines { + hasher.update(name.as_bytes()); + hasher.update(b":"); + hasher.update(checksum.as_bytes()); + hasher.update(b"\n"); + } + format!("{:x}", hasher.finalize()) +} + #[cfg(test)] mod tests { use std::fs; @@ -128,4 +145,16 @@ mod tests { "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" ); } + + #[test] + fn given_files_in_any_order_when_digesting_their_manifest_then_it_hashes_their_sorted_lines() { + let files = [("b.txt", "2"), ("a.txt", "1")]; + + let digest = manifest_digest(files); + + assert_eq!( + digest, + "13bc76289088ec8bee58cab71493d13ba7ef87dbcf86bf361f84b918cbd0eb2e" + ); + } } diff --git a/crates/tracel-cli/src/tools/git.rs b/crates/tracel-cli/src/tools/git.rs deleted file mode 100644 index 19e0986..0000000 --- a/crates/tracel-cli/src/tools/git.rs +++ /dev/null @@ -1,63 +0,0 @@ -use anyhow::Context; -use gix::Repository; -use gix::revision::walk::Sorting; -use gix::traverse::commit::simple::CommitTimeOrder; -use std::path::Path; - -pub fn is_repo_dirty() -> anyhow::Result { - let repo = gix::discover(".")?; - Ok(repo.is_dirty()?) -} - -/// Read the hash of the HEAD commit, which identifies a code version. -pub fn get_last_commit_hash() -> anyhow::Result { - let repo = gix::discover(".")?; - let last_commit = repo.head()?.peel_to_commit()?.id(); - Ok(last_commit.to_string()) -} - -pub fn get_first_commit_hash() -> anyhow::Result { - let repo = gix::discover(".")?; - - let platform = repo - .rev_walk([repo.head_id()?]) - .first_parent_only() - .sorting(Sorting::ByCommitTime(CommitTimeOrder::OldestFirst)); - let revs = platform.all()?; - - let last_hash = revs.last().context("The repository has no commit")?; - let last_hash = last_hash?.id(); - - Ok(last_hash.to_string()) -} - -pub fn is_repo_initialized() -> bool { - gix::discover(".").is_ok() -} - -pub fn init_repo(dir: &Path) -> anyhow::Result { - if is_repo_initialized() { - anyhow::bail!("The git repository already exists."); - } - - let repo = gix::init(dir)?; - Ok(repo) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_first_commit_is_the_root_of_head() { - let output = std::process::Command::new("git") - .args(["rev-list", "--max-parents=0", "--first-parent", "HEAD"]) - .output() - .expect("git runs"); - let root = String::from_utf8_lossy(&output.stdout); - - let first = get_first_commit_hash().expect("the repository has commits"); - - assert_eq!(first, root.trim()); - } -} diff --git a/crates/tracel-cli/src/tools/linker.rs b/crates/tracel-cli/src/tools/linker.rs index cac7b44..8959a6d 100644 --- a/crates/tracel-cli/src/tools/linker.rs +++ b/crates/tracel-cli/src/tools/linker.rs @@ -81,14 +81,14 @@ fn linker_configured(root: &Path, triple: &str) -> bool { if std::env::var_os(linker_env_var(triple)).is_some() { return true; } - if let Some((_, content)) = read_config(root) { - if let Ok(doc) = content.parse::() { - return doc - .get("target") - .and_then(|t| t.get(triple)) - .and_then(|t| t.get("linker")) - .is_some(); - } + if let Some((_, content)) = read_config(root) + && let Ok(doc) = content.parse::() + { + return doc + .get("target") + .and_then(|t| t.get(triple)) + .and_then(|t| t.get("linker")) + .is_some(); } false } diff --git a/crates/tracel-cli/src/tools/mod.rs b/crates/tracel-cli/src/tools/mod.rs index 1db46ba..94318fc 100644 --- a/crates/tracel-cli/src/tools/mod.rs +++ b/crates/tracel-cli/src/tools/mod.rs @@ -2,7 +2,6 @@ pub mod build_driver; pub mod cargo; pub mod event; pub mod fs; -pub mod git; pub mod linker; pub mod packager; pub mod project_context; diff --git a/crates/tracel-cli/src/tools/packager.rs b/crates/tracel-cli/src/tools/packager.rs index e963919..4a79cc7 100644 --- a/crates/tracel-cli/src/tools/packager.rs +++ b/crates/tracel-cli/src/tools/packager.rs @@ -1,48 +1,57 @@ -//! Packaging the workspace's source as one compressed archive, without what git ignores. +//! Workspace packaging for Tracel Console. +//! +//! Packages an entire workspace as a single zip archive, respecting ignore files, +//! and computes the code version digest from the files a source package holds. use std::{ - fs::File, + collections::BTreeMap, + fs::{File, Metadata}, path::{Path, PathBuf}, sync::Arc, }; use anyhow::Context; -use sha2::Digest as _; -use sha2::Sha256; +use ignore::{WalkBuilder, overrides::OverrideBuilder}; +use zip::{DateTime, ZipWriter, write::SimpleFileOptions}; +use crate::error::{CliError, ErrorKind}; use crate::tools::event::Reporter; +use crate::tools::fs::{file_sha256_and_size, manifest_digest}; +use crate::tools::workspace::WorkspaceInfo; use crate::ui::{counted, size}; -/// A packaged archive: where it is, its SHA-256 checksum and its size. +/// Paths never packaged, whatever the ignore files say: git metadata, `target` at the +/// workspace root, and any `target` directory below it (gitignore syntax). +const EXCLUDED: [&str; 3] = [".git", "/target", "target/"]; + #[derive(Debug)] pub struct ArchiveMetadata { + /// The code version digest of the packaged files (see `source_digest`). + pub digest: String, pub path: PathBuf, + /// SHA-256 of the archive file. pub checksum: String, pub size: u64, } -/// A step of packaging, shown as it happens. pub struct PackageEvent { pub message: String, } type PackageEventReporter = dyn Reporter; -/// Package the workspace as one compressed archive, without what git ignores. +/// Package the entire workspace as a single zip archive, skipping ignored files. pub fn package_workspace( - workspace_name: &str, + workspace: &WorkspaceInfo, event_reporter: Arc, ) -> anyhow::Result { - let metadata = cargo_metadata::MetadataCommand::new().exec()?; - let workspace_root = metadata - .workspace_root - .canonicalize() - .context("Can't find the workspace root")?; + let workspace_root = canonical_root(workspace)?; event_reporter.report_event(PackageEvent { message: "Listing files".to_string(), }); let files = list_workspace_files(&workspace_root)?; + let digest = source_digest(&files)?; event_reporter.report_event(PackageEvent { message: format!( @@ -50,214 +59,428 @@ pub fn package_workspace( counted(files.len() as u64, ["file", "files"]) ), }); - // The archive goes in the target directory, which it leaves out. - let output_dir = metadata + let output_dir = workspace + .metadata .target_directory .as_std_path() .join("tracel") .join("package"); - let archive_path = output_dir.join(workspace_name); - + let archive_path = output_dir.join(format!("{}.zip", workspace.workspace_name)); std::fs::create_dir_all(&output_dir)?; - - let archive_file = File::create(&archive_path)?; - - let uncompressed_size = - create_workspace_archive(&workspace_root, &files, &archive_file, workspace_name)?; + let uncompressed_size = write_zip_archive(&files, File::create(&archive_path)?)?; event_reporter.report_event(PackageEvent { message: format!("Computing the checksum of {}", size(uncompressed_size)), }); - let archive_data = std::fs::read(&archive_path)?; + let (checksum, size) = file_sha256_and_size(&archive_path)?; Ok(ArchiveMetadata { + digest, path: archive_path, - checksum: format!("{:x}", Sha256::digest(&archive_data)), - size: archive_data.len() as u64, + checksum, + size, }) } -/// List the workspace files to package, without what git ignores. -fn list_workspace_files(workspace_root: &Path) -> anyhow::Result> { - let git_repo = discover_gix_repo(workspace_root)?; +/// Compute the code version digest of the workspace without packaging it: the digest of +/// the files a source package holds. Binary packages use it too, so every package of the +/// same source, in either mode and for any target, belongs to one code version. +pub fn workspace_digest(workspace: &WorkspaceInfo) -> anyhow::Result { + source_digest(&list_workspace_files(&canonical_root(workspace)?)?) +} - let mut exclude_builder = ignore::gitignore::GitignoreBuilder::new(workspace_root); +fn canonical_root(workspace: &WorkspaceInfo) -> anyhow::Result { + workspace + .workspace_root + .canonicalize() + .context("Can't find the workspace root") +} - // Without git, leave out hidden files. - if git_repo.is_none() { - exclude_builder.add_line(None, ".*")?; +/// Compute the code version digest of a source package: SHA-256 over the sorted +/// `path:sha256` lines of the packaged files (see [`manifest_digest`]). +fn source_digest(files: &BTreeMap) -> anyhow::Result { + let mut lines = Vec::with_capacity(files.len()); + for (name, file_path) in files { + let (checksum, _) = file_sha256_and_size(file_path)?; + lines.push((name.as_str(), checksum)); } + let lines = lines + .iter() + .map(|(name, checksum)| (*name, checksum.as_str())); + Ok(manifest_digest(lines)) +} - exclude_builder.add_line(None, "target/")?; +/// List the files to package as a map from their `/`-separated path relative to +/// `workspace_root` to their full path. `workspace_root` must be canonical. +/// +/// Ignore files apply with or without a git repository: `.gitignore` and `.ignore` in the +/// workspace and its parent directories, `.git/info/exclude`, and git's global excludes +/// file. [`EXCLUDED`] paths are never packaged. Hidden files are packaged in a git +/// repository unless ignored, and skipped outside one. Links to files are packaged as the +/// files they point to; links to directories are not followed. Fails with a usage error +/// when no file is left. +fn list_workspace_files(workspace_root: &Path) -> anyhow::Result> { + let in_git_repository = workspace_root + .ancestors() + .any(|dir| dir.join(".git").exists()); + + let mut excluded = OverrideBuilder::new(workspace_root); + for glob in EXCLUDED { + excluded.add(&format!("!{glob}"))?; + } + let walk = WalkBuilder::new(workspace_root) + .require_git(false) + .hidden(!in_git_repository) + .overrides(excluded.build()?) + .build(); + + let mut files = BTreeMap::new(); + for entry in walk { + let entry = entry.context("Can't list the workspace files")?; + if !entry.path().is_file() { + continue; + } + let relative_path = entry + .path() + .strip_prefix(workspace_root) + .context("A packaged file is outside the workspace")?; + let name = relative_path + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + files.insert(name, entry.into_path()); + } - let ignore_exclude = exclude_builder.build()?; + if files.is_empty() { + return Err(CliError::new( + ErrorKind::Usage, + format!("No files to package in {}.", workspace_root.display()), + ) + .with_hint("Check the ignore files that apply to the workspace.") + .into()); + } + Ok(files) +} - let filter = |path: &Path, is_dir: bool| { - let Ok(relative_path) = path.strip_prefix(workspace_root) else { - return false; +/// Write `files` to a zip archive in path order, under their workspace-relative names so +/// the workspace root is the archive root, and return their total size. Entries have a +/// fixed timestamp and keep only the executable bit of their permissions, so the same files +/// always make the same archive. +fn write_zip_archive(files: &BTreeMap, dst: File) -> anyhow::Result { + let mut zip = ZipWriter::new(dst); + let mut uncompressed_size = 0; + + for (name, file_path) in files { + let mut file = File::open(file_path)?; + let metadata = file.metadata()?; + let mode = if is_executable(&metadata) { + 0o755 + } else { + 0o644 }; + let options = SimpleFileOptions::default() + .last_modified_time(DateTime::default()) + .unix_permissions(mode) + .large_file(metadata.len() > u64::from(u32::MAX)); - if let Some(first_component) = relative_path.components().next() { - let component_str = first_component.as_os_str().to_string_lossy(); - if component_str == "target" { - return false; - } - } - - !ignore_exclude - .matched_path_or_any_parents(relative_path, is_dir) - .is_ignore() - }; - - if let Some(repo) = git_repo { - list_files_gix(workspace_root, &repo, &filter) - } else { - let mut files = Vec::new(); - list_files_walk(workspace_root, &mut files, &filter)?; - Ok(files) + zip.start_file(name.as_str(), options)?; + uncompressed_size += std::io::copy(&mut file, &mut zip)?; } + + zip.finish()?; + Ok(uncompressed_size) } -/// Write `files` to `dst` as a tar.gz archive, each under `{package_prefix}/` as in -/// cargo's crate archives, and return their total size. -fn create_workspace_archive( - workspace_root: &Path, - files: &[PathBuf], - dst: &File, - package_prefix: &str, -) -> anyhow::Result { - let encoder = flate2::GzBuilder::new().write(dst, flate2::Compression::best()); - - let mut ar = tar::Builder::new(encoder); - let mut uncompressed_size: u64 = 0; - - for file_path in files { - let relative_path = file_path - .strip_prefix(workspace_root) - .context("A packaged file is outside the workspace")?; +#[cfg(unix)] +fn is_executable(metadata: &Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn is_executable(_metadata: &Metadata) -> bool { + false +} - if file_path.is_file() { - let mut file = File::open(file_path)?; - let metadata = file.metadata()?; +#[cfg(test)] +mod tests { + use super::*; - let mut header = tar::Header::new_gnu(); - header.set_metadata_in_mode(&metadata, tar::HeaderMode::Deterministic); - header.set_cksum(); + struct TempWorkspace(tempfile::TempDir); - let prefixed_path = PathBuf::from(package_prefix).join(relative_path); - ar.append_data(&mut header, &prefixed_path, &mut file)?; - uncompressed_size += metadata.len(); + impl TempWorkspace { + fn new() -> Self { + Self(tempfile::tempdir().unwrap()) + } + + fn path(&self) -> &Path { + self.0.path() + } + + fn write(&self, rel_path: &str, contents: &str) { + let path = self.path().join(rel_path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); + } + + fn git_init(&self, rel_path: &str) { + self.write(&format!("{rel_path}/.git/HEAD"), "ref: refs/heads/main\n"); + } + + fn files_under(&self, rel_root: &str) -> Vec { + let root = self.path().join(rel_root).canonicalize().unwrap(); + list_workspace_files(&root).unwrap().into_keys().collect() + } + + fn files(&self) -> Vec { + self.files_under(".") + } + + fn digest(&self) -> String { + let root = self.path().canonicalize().unwrap(); + source_digest(&list_workspace_files(&root).unwrap()).unwrap() + } + + fn archive(&self, output: &Path) -> Vec { + let root = self.path().canonicalize().unwrap(); + let files = list_workspace_files(&root).unwrap(); + write_zip_archive(&files, File::create(output).unwrap()).unwrap(); + std::fs::read(output).unwrap() } } - let encoder = ar.into_inner()?; - encoder.finish()?; + #[test] + fn given_the_same_files_written_in_another_order_when_digesting_then_the_digest_is_the_same() { + let first = TempWorkspace::new(); + first.write("Cargo.toml", "[package]"); + first.write("src/main.rs", "fn main() {}"); + let second = TempWorkspace::new(); + second.write("src/main.rs", "fn main() {}"); + second.write("Cargo.toml", "[package]"); - Ok(uncompressed_size) -} + let (first, second) = (first.digest(), second.digest()); -/// Find the git repository whose work tree holds `root`, if any. -fn discover_gix_repo(root: &Path) -> anyhow::Result> { - let repo = match gix::ThreadSafeRepository::discover(root) { - Ok(repo) => repo.to_thread_local(), - Err(_) => return Ok(None), - }; + assert_eq!(first, second); + } - let repo_root = repo.workdir().ok_or_else(|| { - anyhow::format_err!("The git repository at {} is bare.", repo.path().display()) - })?; + #[test] + fn given_an_edited_file_when_digesting_then_the_digest_changes() { + let workspace = TempWorkspace::new(); + workspace.write("src/main.rs", "fn main() {}"); + let original = workspace.digest(); - let canon_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); - let canon_repo_root = repo_root - .canonicalize() - .unwrap_or_else(|_| repo_root.to_path_buf()); + workspace.write("src/main.rs", "fn main() { println!(); }"); - if canon_root.starts_with(&canon_repo_root) { - Ok(Some(repo)) - } else { - Ok(None) + assert_ne!(workspace.digest(), original); } -} -/// List the files git tracks or doesn't ignore, through nested repositories. -fn list_files_gix( - workspace_root: &Path, - repo: &gix::Repository, - filter: &impl Fn(&Path, bool) -> bool, -) -> anyhow::Result> { - let options = repo - .dirwalk_options()? - .emit_untracked(gix::dir::walk::EmissionMode::Matching) - .emit_ignored(None) - .emit_tracked(true) - .recurse_repositories(false) - .symlinks_to_directories_are_ignored_like_directories(true); - - let index = repo.index_or_empty()?; - let mut files = Vec::new(); - - for entry in repo.dirwalk_iter(index.clone(), None::<&str>, Default::default(), options)? { - let entry = entry?; - - let file_path = workspace_root.join(gix::path::from_bstr(entry.entry.rela_path)); - let is_dir = file_path.is_dir(); - - if filter(&file_path, is_dir) { - if !is_dir { - files.push(file_path); - } else { - match gix::open(&file_path) { - Ok(sub_repo) => { - files.extend(list_files_gix(workspace_root, &sub_repo, filter)?); - } - Err(_) => { - list_files_walk(&file_path, &mut files, filter)?; - } - } - } - } + #[test] + fn given_a_renamed_file_when_digesting_then_the_digest_changes() { + let workspace = TempWorkspace::new(); + workspace.write("src/main.rs", "fn main() {}"); + let original = workspace.digest(); + + std::fs::rename( + workspace.path().join("src/main.rs"), + workspace.path().join("src/lib.rs"), + ) + .unwrap(); + + assert_ne!(workspace.digest(), original); } - Ok(files) -} + #[test] + fn given_a_binary_package_when_digesting_it_then_it_shares_the_source_packages_digest() { + let workspace = TempWorkspace::new(); + workspace.write( + "Cargo.toml", + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ); + workspace.write("src/main.rs", "fn main() {}"); + let metadata = cargo_metadata::MetadataCommand::new() + .manifest_path(workspace.path().join("Cargo.toml")) + .no_deps() + .env("CARGO_TARGET_DIR", workspace.path().join("target")) + .exec() + .unwrap(); + let info = WorkspaceInfo { + workspace_name: "demo".to_string(), + workspace_root: metadata.workspace_root.clone().into_std_path_buf(), + metadata, + }; + let source = package_workspace(&info, Arc::new(())).unwrap(); -/// List the files under `path` that `filter` keeps, without git. -fn list_files_walk( - path: &Path, - files: &mut Vec, - filter: &impl Fn(&Path, bool) -> bool, -) -> anyhow::Result<()> { - if !path.is_dir() { - return Ok(()); + let binary_digest = workspace_digest(&info).unwrap(); + + assert_eq!(binary_digest, source.digest); } - let walker = walkdir::WalkDir::new(path) - .follow_links(false) - .into_iter() - .filter_entry(|e| { - if e.file_type().is_dir() { - filter(e.path(), true) - } else { - true - } - }); - - for entry in walker { - match entry { - Ok(entry) => { - let file_path = entry.path(); - - if file_path.is_file() && filter(file_path, false) { - files.push(file_path.to_path_buf()); - } - } - Err(err) => match err.path() { - Some(path) if !filter(path, path.is_dir()) => {} - Some(path) => files.push(path.to_path_buf()), - None => return Err(err.into()), - }, - } + #[test] + fn given_build_output_when_listing_the_files_to_package_then_it_is_left_out() { + let workspace = TempWorkspace::new(); + workspace.write("src/main.rs", "fn main() {}"); + workspace.write("target/release/trainer", "binary"); + + let files = workspace.files(); + + assert_eq!(files, ["src/main.rs"]); + } + + #[test] + fn given_a_gitignore_outside_a_repository_when_listing_the_files_to_package_then_it_applies() { + let workspace = TempWorkspace::new(); + workspace.write("src/main.rs", "fn main() {}"); + workspace.write("train.log", "epoch 1"); + workspace.write(".gitignore", "*.log\n"); + + let files = workspace.files(); + + assert_eq!(files, ["src/main.rs"]); } - Ok(()) + #[test] + fn given_an_ignore_file_that_reincludes_a_file_when_listing_the_files_to_package_then_only_that_file_is_kept() + { + let workspace = TempWorkspace::new(); + workspace.write("data/.ignore", "*.csv\n!keep.csv\n"); + workspace.write("data/train.csv", "1,2"); + workspace.write("data/keep.csv", "3,4"); + + let files = workspace.files(); + + assert_eq!(files, ["data/keep.csv"]); + } + + #[test] + fn given_a_repository_exclude_file_when_listing_the_files_to_package_then_it_applies() { + let workspace = TempWorkspace::new(); + workspace.git_init("."); + workspace.write(".git/info/exclude", "*.bak\n"); + workspace.write("src/main.rs", "fn main() {}"); + workspace.write("notes.bak", "draft"); + + let files = workspace.files(); + + assert_eq!(files, ["src/main.rs"]); + } + + #[test] + fn given_hidden_files_outside_a_repository_when_listing_the_files_to_package_then_they_are_left_out() + { + let workspace = TempWorkspace::new(); + workspace.write("src/main.rs", "fn main() {}"); + workspace.write(".cargo/config.toml", "[build]"); + + let files = workspace.files(); + + assert_eq!(files, ["src/main.rs"]); + } + + #[test] + fn given_hidden_files_in_a_repository_when_listing_the_files_to_package_then_they_are_packaged() + { + let workspace = TempWorkspace::new(); + workspace.git_init("."); + workspace.write("src/main.rs", "fn main() {}"); + workspace.write(".cargo/config.toml", "[build]"); + + let files = workspace.files(); + + assert_eq!(files, [".cargo/config.toml", "src/main.rs"]); + } + + #[test] + fn given_a_workspace_in_a_repository_subdirectory_when_listing_the_files_to_package_then_only_its_files_are_named_from_its_root() + { + let repository = TempWorkspace::new(); + repository.git_init("."); + repository.write(".gitignore", "*.log\n"); + repository.write("README.md", "outside the workspace"); + repository.write("trainer/Cargo.toml", "[package]"); + repository.write("trainer/train.log", "epoch 1"); + + let files = repository.files_under("trainer"); + + assert_eq!(files, ["Cargo.toml"]); + } + + #[test] + fn given_a_repository_nested_in_the_workspace_when_listing_the_files_to_package_then_its_files_are_packaged() + { + let workspace = TempWorkspace::new(); + workspace.git_init("."); + workspace.git_init("vendor/lib"); + workspace.write("vendor/lib/lib.rs", "pub fn lib() {}"); + + let files = workspace.files(); + + assert_eq!(files, ["vendor/lib/lib.rs"]); + } + + #[test] + fn given_nothing_left_to_package_when_listing_the_files_then_it_is_a_usage_error() { + let workspace = TempWorkspace::new(); + workspace.write(".ignore", "*\n"); + workspace.write("Cargo.toml", "[package]"); + + let error = list_workspace_files(&workspace.path().canonicalize().unwrap()).unwrap_err(); + + assert_eq!(crate::error::classify(&error), ErrorKind::Usage); + } + + #[test] + fn given_the_same_files_written_in_another_order_when_archiving_then_the_archive_is_the_same() { + let output = tempfile::tempdir().unwrap(); + let first = TempWorkspace::new(); + first.write("Cargo.toml", "[package]"); + first.write("src/main.rs", "fn main() {}"); + let second = TempWorkspace::new(); + second.write("src/main.rs", "fn main() {}"); + second.write("Cargo.toml", "[package]"); + + let first = first.archive(&output.path().join("first.zip")); + let second = second.archive(&output.path().join("second.zip")); + + assert_eq!(first, second); + } + + #[test] + fn given_a_workspace_when_archiving_it_then_its_files_are_at_the_archive_root() { + let output = tempfile::tempdir().unwrap(); + let workspace = TempWorkspace::new(); + workspace.write("Cargo.toml", "[package]"); + workspace.write("src/main.rs", "fn main() {}"); + + let bytes = workspace.archive(&output.path().join("source.zip")); + + let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + assert_eq!( + archive.file_names().collect::>(), + ["Cargo.toml", "src/main.rs"] + ); + } + + #[cfg(unix)] + #[test] + fn given_an_executable_script_when_archiving_it_then_it_stays_executable() { + use std::os::unix::fs::PermissionsExt; + + let output = tempfile::tempdir().unwrap(); + let workspace = TempWorkspace::new(); + workspace.write("build.sh", "#!/bin/sh\n"); + std::fs::set_permissions( + workspace.path().join("build.sh"), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + + let bytes = workspace.archive(&output.path().join("source.zip")); + + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + assert_eq!( + archive.by_name("build.sh").unwrap().unix_mode(), + Some(0o100755) + ); + } } diff --git a/crates/tracel-cli/src/tools/project_context.rs b/crates/tracel-cli/src/tools/project_context.rs index b895654..b6522fd 100644 --- a/crates/tracel-cli/src/tools/project_context.rs +++ b/crates/tracel-cli/src/tools/project_context.rs @@ -112,10 +112,6 @@ impl ProjectContext { &self.project } - pub fn get_workspace_name(&self) -> &str { - &self.workspace_info.workspace_name - } - pub fn get_workspace_root(&self) -> &Path { &self.workspace_info.workspace_root } diff --git a/crates/tracel-cli/src/tools/tracel_config.rs b/crates/tracel-cli/src/tools/tracel_config.rs index 6fe18f8..0ae9a98 100644 --- a/crates/tracel-cli/src/tools/tracel_config.rs +++ b/crates/tracel-cli/src/tools/tracel_config.rs @@ -1,15 +1,13 @@ -use serde::{Deserialize, Serialize}; +use serde::Serialize; use std::path::{Path, PathBuf}; use std::{fs, io}; -/// A project on the Tracel Console, as `tracel.toml` at the workspace root names it. -/// The file's keys are `namespace` and `project`, as the SDK reads them, and the older -/// `owner` and `name` still read. -#[derive(Serialize, Deserialize, Debug, Default, Clone)] +/// A project on the Tracel Console, as `tracel.toml` at the workspace root names it with +/// the keys `namespace` and `project`, as the SDK reads them. +#[derive(Serialize, Debug, Clone)] pub struct TracelProject { - #[serde(alias = "owner")] pub namespace: String, - #[serde(rename = "project", alias = "name")] + #[serde(rename = "project")] pub name: String, } @@ -27,19 +25,141 @@ impl TracelProject { fs::write(Self::path(workspace_root), contents) } - /// Load `tracel.toml` from `workspace_root`, or `None` when there is none. + /// Load `tracel.toml` from `workspace_root`, which must hold both keys, or `None` when + /// there is none. pub fn load(workspace_root: &Path) -> io::Result> { - let path = Self::path(workspace_root); - if !path.exists() { + let Some(TracelToml { namespace, project }) = TracelToml::load(workspace_root)? else { return Ok(None); - } - let contents = fs::read_to_string(path)?; - let project = - toml::from_str(&contents).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - Ok(Some(project)) + }; + let missing = |key| invalid_data(format!("missing key `{key}`")); + Ok(Some(Self { + namespace: namespace.ok_or_else(|| missing("namespace"))?, + name: project.ok_or_else(|| missing("project"))?, + })) } pub fn remove(workspace_root: &Path) -> io::Result<()> { fs::remove_file(Self::path(workspace_root)) } } + +/// The keys of a `tracel.toml`. Either may be missing, for the environment to provide. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct TracelToml { + pub namespace: Option, + pub project: Option, +} + +impl TracelToml { + /// Read `workspace_root/tracel.toml`, or `None` if the file does not exist. + pub fn load(workspace_root: &Path) -> io::Result> { + match fs::read_to_string(TracelProject::path(workspace_root)) { + Ok(contents) => Self::parse(&contents).map(Some).map_err(invalid_data), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } + } + + /// Parse the contents of a `tracel.toml`, whose only keys are the strings `namespace` + /// and `project`. + pub fn parse(contents: &str) -> Result { + let table: toml::Table = toml::from_str(contents).map_err(|error| error.to_string())?; + let mut config = Self::default(); + for (key, value) in table { + let slot = match key.as_str() { + "namespace" => &mut config.namespace, + "project" => &mut config.project, + _ => { + return Err(format!( + "unknown key `{key}`; expected keys are `namespace` and `project`" + )); + } + }; + let toml::Value::String(value) = value else { + return Err(format!("`{key}` must be a string")); + }; + *slot = Some(value); + } + Ok(config) + } +} + +fn invalid_data(message: String) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_namespace_and_project_when_parsing_tracel_toml_then_both_are_read() { + let parsed = TracelToml::parse("namespace = 'alice'\nproject = 'demo'").unwrap(); + + assert_eq!( + parsed, + TracelToml { + namespace: Some("alice".into()), + project: Some("demo".into()), + } + ); + } + + #[test] + fn given_only_project_when_parsing_tracel_toml_then_namespace_is_left_to_the_environment() { + let parsed = TracelToml::parse("project = 'demo'").unwrap(); + + assert_eq!(parsed.namespace, None); + } + + #[test] + fn given_an_unknown_key_when_parsing_tracel_toml_then_the_error_names_it() { + let error = TracelToml::parse("owner = 'alice'").unwrap_err(); + + assert!(error.contains("`owner`"), "{error}"); + } + + #[test] + fn given_a_key_that_is_not_a_string_when_parsing_tracel_toml_then_it_is_refused() { + let error = TracelToml::parse("namespace = 1").unwrap_err(); + + assert!(error.contains("`namespace`"), "{error}"); + } + + #[test] + fn given_a_saved_project_when_loading_it_then_it_is_the_same_project() { + let directory = tempfile::tempdir().unwrap(); + let project = TracelProject { + namespace: "alice".into(), + name: "demo".into(), + }; + project.save(directory.path()).unwrap(); + + let loaded = TracelProject::load(directory.path()).unwrap().unwrap(); + + assert_eq!( + (loaded.namespace.as_str(), loaded.name.as_str()), + ("alice", "demo") + ); + } + + #[test] + fn given_no_tracel_toml_when_loading_the_project_then_there_is_none() { + let directory = tempfile::tempdir().unwrap(); + + let loaded = TracelProject::load(directory.path()).unwrap(); + + assert!(loaded.is_none()); + } + + #[test] + fn given_a_tracel_toml_without_namespace_when_loading_the_project_then_it_is_invalid() { + let directory = tempfile::tempdir().unwrap(); + fs::write(TracelProject::path(directory.path()), "project = 'demo'").unwrap(); + + let error = TracelProject::load(directory.path()).unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("`namespace`"), "{error}"); + } +} diff --git a/crates/tracel-cli/src/tools/workspace.rs b/crates/tracel-cli/src/tools/workspace.rs index 207ad43..280f607 100644 --- a/crates/tracel-cli/src/tools/workspace.rs +++ b/crates/tracel-cli/src/tools/workspace.rs @@ -66,10 +66,6 @@ impl WorkspaceInfo { }) } - pub fn get_ws_root(&self) -> PathBuf { - self.metadata.workspace_root.clone().into_std_path_buf() - } - pub fn get_manifest_path(&self) -> PathBuf { self.workspace_root.join(PathBuf::from("Cargo.toml")) } diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs index 81e6e9a..645829d 100644 --- a/crates/tracel-cli/src/ui/human.rs +++ b/crates/tracel-cli/src/ui/human.rs @@ -3,6 +3,7 @@ use std::io::{self, Write}; use serde::Serialize; use serde_json::Value; +use url::Url; /// The narrowest a shrinkable table column gets, ellipsis included. const MIN_COLUMN_WIDTH: usize = 4; @@ -50,6 +51,16 @@ impl Human<'_> { self.styled(text, console::Style::new().dim()) } + /// Show `url` as cliclack shows links when the text is styled, and plain otherwise. + /// Not for table cells, whose widths don't skip a hyperlink's escapes. + pub fn link(&self, url: &Url) -> String { + if self.styled { + terminal_link(url, console::Style::new().force_styling(true)) + } else { + url.to_string() + } + } + /// Write a title for what follows, ending in a colon. pub fn heading(&mut self, text: impl Display) -> io::Result<()> { writeln!(self, "{text}:") @@ -196,7 +207,7 @@ fn line(cells: &[String; N], widths: &[usize; N]) -> String { } /// Labelled values, aligned, and sections that list the fields of a JSON value under a -/// heading. +/// heading. A value that is a web address is a link. #[derive(Default)] pub struct Details { entries: Vec, @@ -349,14 +360,23 @@ fn flatten(prefix: &str, value: &Value, fields: &mut Vec<(String, String)>) { } } -/// Put a value on one line, with a dim "-" for nothing. +/// Put a value on one line, with a dim "-" for nothing and a web address as a link. fn styled_cell(out: &Human<'_>, value: &str) -> String { match cell_text(value) { text if text == "-" => out.dim(text), - text => text, + text => match web_address(&text) { + Some(url) => out.link(&url), + None => text, + }, } } +/// Read `text` as an http or https URL, when it is one written as the URL writes itself. +fn web_address(text: &str) -> Option { + let url = Url::parse(text).ok()?; + (matches!(url.scheme(), "http" | "https") && url.as_str() == text).then_some(url) +} + /// Cut `text` to `width` columns with an ellipsis, when it is wider. fn truncate(text: &str, width: usize) -> String { if console::measure_text_width(text) <= width { @@ -372,6 +392,17 @@ fn cell_text(value: &str) -> String { if text.is_empty() { "-".into() } else { text } } +/// Show `url` in `style`, blue and bold as cliclack shows links, and as a hyperlink +/// (OSC 8) a click opens when the terminal on stderr renders them. +pub fn terminal_link(url: &Url, style: console::Style) -> String { + let text = style.blue().bold().apply_to(url).to_string(); + if supports_hyperlinks::on(supports_hyperlinks::Stream::Stderr) { + format!("\u{1b}]8;;{url}\u{1b}\\{text}\u{1b}]8;;\u{1b}\\") + } else { + text + } +} + #[cfg(test)] mod tests { use rstest::rstest; @@ -511,4 +542,25 @@ mod tests { assert_eq!(shown, expected); } + + #[rstest] + #[case::localhost("http://localhost:9001/")] + #[case::console("https://console.tracel.ai/test/demo")] + fn given_a_web_url_written_as_it_writes_itself_when_reading_it_then_it_is_a_link( + #[case] text: &str, + ) { + let address = web_address(text); + + assert!(address.is_some()); + } + + #[rstest] + #[case::rewritten_host("http://Example.com")] + #[case::other_scheme("ftp://example.com/")] + #[case::word("weights")] + fn given_other_text_when_reading_it_as_a_web_url_then_it_is_not_a_link(#[case] text: &str) { + let address = web_address(text); + + assert!(address.is_none()); + } } diff --git a/crates/tracel-cli/src/ui/layout.rs b/crates/tracel-cli/src/ui/layout.rs index 6646ecf..619659b 100644 --- a/crates/tracel-cli/src/ui/layout.rs +++ b/crates/tracel-cli/src/ui/layout.rs @@ -4,6 +4,9 @@ use std::fmt::{self, Display}; pub enum Layout { /// A command that reads, its result in one box under its summary. Read, + /// A command that reads text to show as it is, such as a job's logs, closed by its + /// summary when it has one. + Text, /// A command that does work, in a session that first says what it does, such as /// "Upload a version of model 'weights'", and where. Flow(String), diff --git a/crates/tracel-cli/src/ui/mod.rs b/crates/tracel-cli/src/ui/mod.rs index b50c29e..b471217 100644 --- a/crates/tracel-cli/src/ui/mod.rs +++ b/crates/tracel-cli/src/ui/mod.rs @@ -20,7 +20,7 @@ pub use human::{Details, Human, Table, counted, list_summary, number, short_dige pub use layout::{Layout, Place}; pub use stderr::{Declined, Terminal, Unanswered}; pub use stdout::{Outcome, Output, Render, StdoutClosed}; -pub use time::{Timestamp, log_time, when}; +pub use time::{Timestamp, elapsed, log_time, when}; /// Open stdout and stderr for one command, drawing on the same screen. A flow's session /// opens now, saying what it does and where. diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index 16f2b82..c6b67c9 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -32,6 +32,8 @@ impl Terminals { pub enum Delivery { /// One box on the terminal, its text fitted to the columns inside the box. Box(Option), + /// Stdout, on the terminal: the text as it is, then its summary on stderr. + Text, /// Stdout, which carries nothing else: an envelope, or plain text ending with the /// summary. Stdout, @@ -97,6 +99,7 @@ impl Screen { columns(&console::Term::stderr()) .map(|columns| columns.saturating_sub(usize::from(BOX_PADDING))), ), + Layout::Text if self.shares_stdout() => Delivery::Text, _ => Delivery::Stdout, } } @@ -180,6 +183,14 @@ impl Screen { } } + /// End a flow's session with the commands to run next, one per line. Nothing when + /// no session is open. + pub fn next_steps(&self, commands: &str) { + if self.session.swap(false, Ordering::Relaxed) { + self.draw(|| cliclack::outro_note("Next steps", commands)); + } + } + /// End the command on its `failure`: cliclack's red closing line, in a session or not. pub fn fail(&self, failure: &str) { self.session.store(false, Ordering::Relaxed); @@ -222,10 +233,10 @@ impl Screen { /// End live widget `id` without leaving a line, unless it has ended. pub fn clear(&self, id: u64) { let mut live = self.lock(); - if live.as_ref().is_some_and(|live| live.id == id) { - if let Some(widget) = live.take() { - widget.bar.clear(); - } + if live.as_ref().is_some_and(|live| live.id == id) + && let Some(widget) = live.take() + { + widget.bar.clear(); } } diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index 4d5a5c2..e835693 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use cliclack::ProgressBar; use super::Format; +use super::human::terminal_link; use super::screen::Screen; use crate::error::{CliError, ErrorKind, ErrorReport}; @@ -127,6 +128,16 @@ impl Terminal { } } + /// Say what the command did when more follows, such as a job it queued before its + /// logs, as a sentence. + pub fn success(&self, message: &str) { + if self.is_styled() { + self.screen.draw(|| cliclack::log::success(message)); + } else if self.is_human() { + self.line(message); + } + } + /// End the command on its outcome: the corner of a flow's session, or a line. pub fn outro(&self, outcome: &str) { if self.screen.session_open() { @@ -136,6 +147,27 @@ impl Terminal { } } + /// End the command on its outcome, then the commands to run next. + pub fn next_steps(&self, outcome: &str, commands: &[String]) { + self.success(outcome); + if self.screen.session_open() { + self.screen.next_steps(&commands.join("\n")); + } else if self.is_human() { + self.line("Next steps:"); + for command in commands { + self.line(format_args!(" {command}")); + } + } + } + + /// End the line a program the command ran left after the `^C` the terminal echoed, + /// when it was interrupted, so what comes next starts a line of its own. + pub fn program_ended(&self, interrupted: bool) { + if interrupted && io::stderr().is_terminal() { + let _ = writeln!(io::stderr()); + } + } + /// Report a failed command: its message and hint here, and its envelope on stdout /// with JSON output. On a terminal the command ends on cliclack's red closing line, /// with the hint under the message. @@ -185,12 +217,7 @@ impl Terminal { if !self.is_styled() { return url.to_string(); } - let text = console::style(url).for_stderr().blue().bold().to_string(); - if supports_hyperlinks::on(supports_hyperlinks::Stream::Stderr) { - hyperlink(url, &text) - } else { - text - } + terminal_link(url, console::Style::new().for_stderr()) } /// Fail with `kind` when the user can't answer a prompt, and otherwise settle the screen @@ -203,13 +230,19 @@ impl Terminal { Ok(()) } + /// Fail as `confirm` does when the user can't answer, so a command can stop before + /// the slow work that comes ahead of its question. + pub fn require_confirmation(&self, unanswered: Unanswered) -> anyhow::Result<()> { + self.require_answer(ErrorKind::ConfirmationRequired, unanswered) + } + pub fn confirm( &self, question: &str, initial: bool, unanswered: Unanswered, ) -> anyhow::Result { - self.require_answer(ErrorKind::ConfirmationRequired, unanswered)?; + self.require_confirmation(unanswered)?; cliclack::confirm(question) .initial_value(initial) .interact() @@ -317,12 +350,6 @@ fn one_line(message: &str) -> String { .join(" ") } -/// `text` as a terminal hyperlink to `url` (OSC 8), which terminals that render them -/// open on a click. -fn hyperlink(url: &url::Url, text: &str) -> String { - format!("\u{1b}]8;;{url}\u{1b}\\{text}\u{1b}]8;;\u{1b}\\") -} - /// A spinner or bar for one step, drawn until it ends. When something else draws /// first, it ends early as a line with its last message. #[derive(Clone)] diff --git a/crates/tracel-cli/src/ui/stdout.rs b/crates/tracel-cli/src/ui/stdout.rs index d0606a7..02732b1 100644 --- a/crates/tracel-cli/src/ui/stdout.rs +++ b/crates/tracel-cli/src/ui/stdout.rs @@ -1,4 +1,5 @@ use std::io::{self, Write}; +use std::process::Stdio; use std::sync::Arc; use serde::Serialize; @@ -116,6 +117,13 @@ impl Output { let body = box_text(report.as_ref(), width)?; self.screen.show_result(summary.as_deref(), &body); } + Delivery::Text => { + let width = self.screen.stdout_width(); + self.write_stdout(|out| report.write_text(&mut Human::plain(out, width)))?; + if summary.is_some() { + self.screen.show_result(summary.as_deref(), ""); + } + } Delivery::Stdout => { let (format, width) = (self.screen.format(), self.screen.stdout_width()); self.write_stdout(|out| { @@ -138,6 +146,17 @@ impl Output { }) } + /// Choose where a program the command runs writes its stdout: here with text output, + /// or stderr with JSON output, which keeps stdout for the envelope. On a terminal the + /// program writes among a session's lines, so the live widget is settled first. + pub fn child_stdout(&self) -> Stdio { + self.screen.settle(); + match self.screen.format() { + Format::Human => Stdio::inherit(), + Format::Json => Stdio::from(io::stderr()), + } + } + /// Write the error envelope of a failed command with JSON output. Text errors go /// to stderr. pub fn error(&self, report: &ErrorReport<'_>) { diff --git a/crates/tracel-cli/src/ui/time.rs b/crates/tracel-cli/src/ui/time.rs index 145cdfe..464a945 100644 --- a/crates/tracel-cli/src/ui/time.rs +++ b/crates/tracel-cli/src/ui/time.rs @@ -31,6 +31,25 @@ pub fn log_time(text: &str) -> String { local(text, &Local, TO_THE_SECOND) } +/// Say how long it was from the RFC 3339 time `start` to `end`, or to now without one, +/// such as "3 min 20 s". None when a time doesn't parse. +pub fn elapsed(start: &str, end: Option<&str>) -> Option { + let start = DateTime::parse_from_rfc3339(start).ok()?; + let end = match end { + Some(end) => DateTime::parse_from_rfc3339(end).ok()?.to_utc(), + None => Utc::now(), + }; + Some(span((end - start.to_utc()).num_seconds().max(0))) +} + +fn span(seconds: i64) -> String { + match seconds { + seconds if seconds < 60 => format!("{seconds} s"), + seconds if seconds < 3600 => format!("{} min {} s", seconds / 60, seconds % 60), + seconds => format!("{} h {} min", seconds / 3600, seconds % 3600 / 60), + } +} + const TO_THE_MINUTE: &str = "%Y-%m-%d %H:%M"; const TO_THE_SECOND: &str = "%Y-%m-%d %H:%M:%S"; @@ -48,6 +67,8 @@ where mod tests { use std::time::{Duration, UNIX_EPOCH}; + use rstest::rstest; + use super::*; #[test] @@ -73,6 +94,28 @@ mod tests { assert_eq!(time, "yesterday"); } + #[rstest] + #[case::seconds("2026-10-13T15:02:45Z", "45 s")] + #[case::minutes("2026-10-13T15:05:20Z", "3 min 20 s")] + #[case::hours("2026-10-13T17:07:59Z", "2 h 5 min")] + #[case::end_before_start("2026-10-13T15:01:00Z", "0 s")] + fn given_a_start_and_an_end_when_saying_how_long_it_took_then_it_reads_in_its_two_largest_units( + #[case] end: &str, + #[case] expected: &str, + ) { + let took = elapsed("2026-10-13T15:02:00Z", Some(end)); + + assert_eq!(took.as_deref(), Some(expected)); + } + + #[test] + fn given_a_start_that_is_not_a_time_when_saying_how_long_it_took_then_there_is_nothing_to_say() + { + let took = elapsed("soon", Some("2026-10-13T15:01:00Z")); + + assert_eq!(took, None); + } + #[test] fn given_a_timestamp_when_writing_it_then_it_is_rfc_3339_in_utc() { let timestamp = Timestamp(UNIX_EPOCH + Duration::from_secs(1_900_000_000)); diff --git a/crates/tracel-cli/template.gitignore b/crates/tracel-cli/template.gitignore deleted file mode 100644 index 0831140..0000000 --- a/crates/tracel-cli/template.gitignore +++ /dev/null @@ -1,104 +0,0 @@ -# This file should only ignore things that are generated during a `x.py` build, -# generated by common IDEs, and optional files controlled by the user that -# affect the build (such as bootstrap.toml). -# In particular, things like `mir_dump` should not be listed here; they are only -# created during manual debugging and many people like to clean up instead of -# having git ignore such leftovers. You can use `.git/info/exclude` to -# configure your local ignore list. - -## File system -.DS_Store -desktop.ini - -## Editor -*.swp -*.swo -Session.vim -.cproject -.idea -*.iml -.vscode -.project -.vim/ -.helix/ -.zed/ -.favorites.json -.settings/ -.vs/ -.dir-locals.el - -## Tool -.valgrindrc -.cargo -# Included because it is part of the test case -!/tests/run-make/thumb-none-qemu/example/.cargo - -## Configuration -/bootstrap.toml -/config.toml -/Makefile -config.mk -config.stamp -no_llvm_build - -## Build -/dl/ -/doc/ -/inst/ -/llvm/ -/mingw-build/ -/build -/build-rust-analyzer/ -/dist/ -/unicode-downloads -/target -/library/target -/src/bootstrap/target -/src/ci/citool/target -/src/tools/x/target -# Created by `x vendor` -/vendor -# Created by default with `src/ci/docker/run.sh` -/obj/ -# Created by nix dev shell / .envrc -src/tools/nix-dev-shell/flake.lock - -## ICE reports -rustc-ice-*.txt - -## Temporary files -*~ -\#* -\#*\# -.#* - -## Tags -tags -tags.* -TAGS -TAGS.* - -## Python -__pycache__/ -*.py[cod] -*$py.class - -## Node -node_modules -package-lock.json -package.json -/src/doc/rustc-dev-guide/mermaid.min.js - -## Rustdoc GUI tests -tests/rustdoc-gui/src/**.lock - -## direnv -/.envrc -/.direnv/ - -## nix -/flake.nix -flake.lock -/default.nix - -# Before adding new lines, see the comment at the top. \ No newline at end of file diff --git a/deny.toml b/deny.toml index db6f428..de48284 100644 --- a/deny.toml +++ b/deny.toml @@ -89,6 +89,7 @@ allow = [ "CC0-1.0", "ISC", "MIT", + "MIT-0", "MPL-2.0", "OpenSSL", "Unicode-DFS-2016", diff --git a/docs/cli.md b/docs/cli.md index 75687e8..f14d28f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -14,9 +14,9 @@ questions, warnings and errors. The format is the first of these that is set: 4. `auto`: human on a terminal, and JSON when stdout goes to a file or another program. -`auth token` and `train` are plain text under `auto` too: `auth token` prints -the bare token, and `train` writes what the program it runs writes. Help and -version output keep clap's own format. +`auth token` and `run -- ` are plain text under `auto` too: `auth token` +prints the bare token, and `run -- ` writes what the program it runs +writes. Help and version output keep clap's own format. On a terminal, a command that reads shows its result in a frame titled with a line that sums it up, the project and the environment when it isn't @@ -48,8 +48,11 @@ and the instructions a login asks you to follow. `tracel experiments logs --follow --json` writes one line of JSON per event instead: each log entry, with `"type":"log"` added, then -`{"type":"end","running":false}` when the experiment has finished. An error -still writes the failure line. +`{"type":"end","running":false}` when the experiment has finished. +`tracel jobs logs --follow --json` does the same with each page of logs, then +`{"type":"end","status":""}`, and `tracel run --remote --follow --json` +starts with the queued job, with `"type":"queued"` added, then writes the job's +log events. An error still writes the failure line. The data of commands that read is the Tracel Console's own response, with `null` for a metric series or summary that doesn't exist. The other commands @@ -72,6 +75,12 @@ return: | `models alias set` | the alias | | `models alias remove` | `model`, `alias`, `removed` | | `artifacts download` | `experiment`, `artifact`, `directory`, `files`, `bytes` | +| `experiments compare` | `base`, `other`, `config` and `attributes` (each difference `path`, `base`, `other`), `metrics` (each `metric`, `group`, `base`, `other`, `delta`) | +| `jobs cancel`, `jobs wait` | the job, as read afterwards | +| `run --list` | `protocol`, `sdk_version`, `jobs` (each `name`, `description`, `input_schema`, `input_example`) | +| `run ` | `job`, `input`, `target` (`"console"` or `"offline"`), `exit_code` | +| `run --remote` | `job_num` (null when the Tracel Console doesn't return it), `job_id`, `group`, `digest`, `version_id`, `command`, `uploaded` | +| `run --remote --dry-run` | `namespace`, `project`, `group`, `mode`, `digest`, `command` | Times are RFC 3339 in UTC. @@ -88,10 +97,11 @@ Times are RFC 3339 in UTC. | `CONFLICT` | 6 | | `CONFIRMATION_REQUIRED` | 7 | | `LIMIT_REACHED` | 8 | +| `JOB_FAILED` | 9 | | `TIMEOUT` | 10 | | `UNAVAILABLE` | 11 | -Exit code 9 is reserved. `train` exits with the code of the program it runs. +`run -- ` exits with the code of the program it runs. ## Questions @@ -109,11 +119,11 @@ A command that works on a project uses the first of these: 1. `--project /`; 2. `TRACEL_NAMESPACE` and `TRACEL_PROJECT`, each of which can also override its half of `tracel.toml`; -3. `tracel.toml` at the root of the Cargo workspace, which `tracel init` writes - with the keys `namespace` and `project`. +3. `tracel.toml` at the root of the Cargo workspace, which `tracel init` writes. + Its only keys are `namespace` and `project`. -With the flag or both variables, `project`, `experiments`, `models`, -`artifacts` and `datasets` work in any directory. `package` and `train` need +With the flag or both variables, `project`, `experiments`, `jobs`, `models`, +`artifacts` and `datasets` work in any directory. `package` and `run` need the Cargo workspace, and `init` and `unlink` change `tracel.toml` whatever selects the project. `-C ` runs a command as if it started in ``. @@ -132,18 +142,26 @@ next `--complete`; a denied or expired login has to start again. ## Packaging -`tracel package` uploads a code version, which the current commit names: +`tracel package` uploads a code version, named by a digest of the workspace's +source: the SHA-256 of `path:sha256` lines sorted by path, one per file a source +package holds. Binary packages use the same digest and upload only the +binaries, so every package of the same source, in either mode and for any +target, belongs to one code version. Only the files the code version doesn't +have yet are uploaded, such as the binary for a new target, and `uploaded` is +false when it already had them all. - `--mode binary` builds a release binary for each `--target `, the - host when there is none and no question can be asked. `--bin ` picks - the binary when the build makes several. On Linux, building for another - architecture needs its linker, and building for another OS needs - `cargo-zigbuild` or, for Windows, `cargo-xwin`. -- `--mode source` uploads the workspace's files, without what git ignores, to - be built where the code runs. + host when there is none and no question can be asked. `--bin ` builds + only that binary; without it, every binary is built, and you choose one when + there are several. On Linux, building for another architecture needs its + linker, and building for another OS needs `cargo-zigbuild` or, for Windows, + `cargo-xwin`. +- `--mode source` uploads the workspace's files, to be built where the code + runs. It leaves out what `.gitignore`, `.ignore`, `.git/info/exclude` and + git's global excludes file match, with or without a git repository, and + always `.git` and `target`. Hidden files are packaged only in a git + repository. Packaging no file fails with `USAGE`. - `--install-targets` installs missing Rust targets with rustup without asking. -- `--commit` commits all changes first, and `--allow-dirty` packages without - them. Uncommitted changes are never part of the code version. ## Downloads @@ -175,3 +193,84 @@ Each file's size and SHA-256 checksum are checked before it is saved. shows them oldest first. `--after ` reads from a sequence number and can't be combined with `--from`, `--to` or `--offset`. `--follow` reads from there until the experiment finishes. +- `experiments compare ` lists where the two experiments' config + and attributes differ, at dotted paths with array indices such as + `optimizer.betas[1]`; a key that is empty or holds `.`, `[` or `]` is written + `["key"]`. A difference leaves out the side where the path doesn't exist. + It then pairs each metric's summary value by group, for each `--metric`, or + else every metric either experiment has, with `delta` being `other - base`. + The Tracel Console doesn't say whether lower or higher is better, so neither + does `compare`. + +## Jobs + +- A job's status is `new`, `queued`, `running`, `pending_cancellation`, + `completed`, `failed` or `cancelled`. +- `jobs logs ` reads one page of the job's log file, and shows it as it + is, then the command that reads the next page when there is more. + `--start ` reads from a byte offset, and a page's `end` is the next + page's `start`. `--follow` reads until the job has finished. +- `jobs cancel ` cancels a new, queued or running job, after asking; + `--yes` skips the question. A running job stays `pending_cancellation` until + its compute provider stops it. +- `jobs wait ` checks the job every `--interval` seconds until it + finishes. It fails with `JOB_FAILED` when the job fails or is cancelled, and + with `TIMEOUT` when `--timeout` seconds pass first. + +## Running jobs + +- Jobs come from the binary of a workspace package that depends on the + `tracel` crate and runs them with `tracel::runtime::Jobs`. `--bin ` + picks one when there are several, unless the package's `default-run` names + it. +- The binary is built with + `cargo build --release --package --bin `, the build + `--remote --mode binary` uploads for this machine, and cargo's output shows + only when the build fails. To list its jobs, it runs with + `TRACEL_DESCRIBE=` and has 10 seconds to write their definitions + there. They're cached in `target/tracel/jobs.json` until the binary + changes. +- `run ` starts from the job's example input, or from the input of the + experiment `--like ` names. Each `-c ` is merged onto it + as a JSON merge patch (RFC 7386), then the job's flags set their fields, then + each `--set =` sets one value at a dotted path with array + indices, such as `layers[0].size`. A value that isn't JSON is a string. An + input that doesn't match the job's schema fails with `USAGE`. +- A job whose input has a JSON schema has flags, one per field, typed by the + schema. A nested field's flag joins the keys with dots and writes `_` as + `-`, as in `--optimizer.weight-decay`. A boolean flag without a value is + `true`, and an array, a map or an optional object takes JSON. A field whose + flag has the name of one of `run`'s own, such as `offline`, is set with + `--set`. +- The job runs as ` `, with stdin closed. With JSON + output, its stdout goes to stderr. +- The run is recorded on the Tracel Console when a project is linked and a + credential is available, and on this machine otherwise or with `--offline`. + The program gets `TRACEL_TARGET` (`console` or `offline`), `TRACEL_ENV`, and + `TRACEL_NAMESPACE` and `TRACEL_PROJECT` when a project is linked. +- The program's exit code says how the job ended: 0 is completed, 2 is a job + or input the program rejected, which fails with `USAGE`, and any other code + or a signal is a failure, which fails with `JOB_FAILED`. +- Ctrl-C, SIGTERM or SIGHUP kills the job and the processes it started, and + fails with `JOB_FAILED`. +- Without a job, `run -- ` is `cargo run --release -- `. + +## Remote runs + +- `run --remote ` packages the workspace with the flags of + `package`, then queues the code version as a job on the compute provider + group, with the resolved input. Without a job, the job command is the + arguments after `--`. They reach the program unchanged: the job command + single-quotes each argument that is empty, starts with `#`, or contains a + space, tab, newline, quote or backslash. +- A job may incur costs, so the command asks before uploading anything, naming + the group and the code version, and `--yes` skips the question. Without a + terminal it needs `--yes`, and fails with `CONFIRMATION_REQUIRED` before + packaging. +- In human output, the command ends on the commands that follow the job's + logs and wait for it. +- `--dry-run` packages and shows the job without logging in, uploading or + queuing. +- `--follow` follows the job's logs, then exits as `jobs wait` does. Without a + job number from the Tracel Console, it fails after queuing, and the job stays + queued.