diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..527f9565 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +[env] +# Downloads the official prebuilt libduckdb for the build target instead of +# compiling DuckDB's C++ amalgamation from source, which otherwise costs +# 10+ minutes and several GB of disk on every clean build. Requires the +# duckdb crate's "bundled" feature to stay off (see src-tauri/Cargo.toml) -- +# libduckdb-sys only reads this var on the non-bundled build path. +DUCKDB_DOWNLOAD_LIB = "1" diff --git a/Cargo.lock b/Cargo.lock index c2350eeb..db99d0d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,20 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -146,6 +160,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -161,6 +184,169 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arrow" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.0", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "arrow-select" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -212,7 +398,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -243,7 +429,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -269,7 +455,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -315,6 +501,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -791,6 +986,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "comfy-table" +version = "7.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -987,6 +1193,28 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "parking_lot", + "rustix 0.38.44", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1120,6 +1348,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1303,6 +1542,23 @@ dependencies = [ "dtoa", ] +[[package]] +name = "duckdb" +version = "1.10505.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970e05eedd3f55c435194d9104f90a9b4a79a80d6e73251bc9ff43e178130c4e" +dependencies = [ + "arrow", + "cast", + "comfy-table", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.10.0", + "libduckdb-sys", + "num-integer", + "strum", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1546,6 +1802,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2366,7 +2623,7 @@ dependencies = [ "itoa", "libc", "memmap2", - "rustix", + "rustix 1.1.4", "smallvec", "thiserror 2.0.18", ] @@ -2543,7 +2800,7 @@ dependencies = [ "gix-command", "gix-config-value", "parking_lot", - "rustix", + "rustix 1.1.4", "thiserror 2.0.18", ] @@ -2990,6 +3247,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -3008,7 +3266,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", ] [[package]] @@ -3043,6 +3301,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "hashlink" version = "0.11.0" @@ -3631,7 +3898,7 @@ dependencies = [ "rayon", "ref-cast", "regex", - "rustix", + "rustix 1.1.4", "same-file", "serde", "smallvec", @@ -3797,6 +4064,63 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -3827,6 +4151,22 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libduckdb-sys" +version = "1.10505.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb514dab5e271e849235c1cb98bd65a2ae107fbd619a6740219319c54a71d95" +dependencies = [ + "flate2", + "pkg-config", + "serde", + "serde_json", + "tar", + "ureq", + "vcpkg", + "zip", +] + [[package]] name = "libloading" version = "0.7.4" @@ -3847,6 +4187,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -3870,6 +4216,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4275,12 +4627,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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-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.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4288,6 +4668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4953,7 +5334,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5531,6 +5912,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -5579,7 +5974,7 @@ dependencies = [ "bitflags 2.11.1", "fallible-iterator", "fallible-streaming-iterator", - "hashlink", + "hashlink 0.11.0", "libsqlite3-sys", "smallvec", "sqlite-wasm-rs", @@ -5627,6 +6022,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5636,16 +6044,57 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -5891,6 +6340,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial2" version = "0.2.36" @@ -6213,6 +6675,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6346,6 +6829,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -6715,7 +7209,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -7154,6 +7648,7 @@ version = "0.1.3" dependencies = [ "chrono", "criterion", + "duckdb", "futures", "gix", "ignore", @@ -7173,6 +7668,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "serde_yaml", "sha2 0.11.0", "tauri", "tauri-build", @@ -7315,12 +7811,58 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -7364,6 +7906,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -7655,6 +8203,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -7896,6 +8453,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -8357,6 +8923,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "yoke" version = "0.8.2" @@ -8402,7 +8978,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tracing", @@ -8482,6 +9058,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -8515,6 +9097,20 @@ dependencies = [ "syn 2.0.117", ] +[[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", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.3" @@ -8527,6 +9123,18 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/crates/treq-napi/src/dispatch.rs b/crates/treq-napi/src/dispatch.rs index 90fb2b7f..2f6bc2f6 100644 --- a/crates/treq-napi/src/dispatch.rs +++ b/crates/treq-napi/src/dispatch.rs @@ -930,6 +930,132 @@ pub fn dispatch(command: &str, args: Value) -> Result { serde_json::to_value(result).map_err(|e| e.to_string()) } + "list_workflows" => { + let repo_path = get_str(&args, "repoPath")?; + let result = treq_lib::core::list_workflows_sync(&repo_path)?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "run_workflow_job" => { + let repo_path = get_str(&args, "repoPath")?; + let filename = get_str(&args, "filename")?; + let job_id = get_str(&args, "jobId")?; + let workspace_id = get_i64(&args, "workspaceId")?; + let workspace_path = get_str(&args, "workspacePath")?; + let result = treq_lib::core::run_workflow_job_sync( + &repo_path, + &filename, + &job_id, + workspace_id, + &workspace_path, + )?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "run_workflow" => { + let repo_path = get_str(&args, "repoPath")?; + let filename = get_str(&args, "filename")?; + let workspace_id = get_i64(&args, "workspaceId")?; + let workspace_path = get_str(&args, "workspacePath")?; + let result = treq_lib::core::run_workflow_sync( + &repo_path, + &filename, + workspace_id, + &workspace_path, + )?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "is_repo_trusted" => { + let repo_path = get_str(&args, "repoPath")?; + let trusted = treq_lib::local_db::is_repo_trusted(&repo_path); + Ok(serde_json::Value::Bool(trusted)) + } + + "trust_repo" => { + let repo_path = get_str(&args, "repoPath")?; + treq_lib::local_db::trust_repo(&repo_path)?; + Ok(serde_json::Value::Null) + } + + "list_workflow_runs" => { + let repo_path = get_str(&args, "repoPath")?; + let workspace_id = get_i64(&args, "workspaceId")?; + let filename = get_str(&args, "filename")?; + let limit = opt_i64(&args, "limit").unwrap_or(20); + let result = treq_lib::core::list_workflow_runs_sync( + &repo_path, + workspace_id, + &filename, + limit, + )?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "get_run_logs" => { + let repo_path = get_str(&args, "repoPath")?; + let run_id = get_i64(&args, "runId")?; + let job_id = get_str(&args, "jobId")?; + let query = treq_lib::core::checks_logs::LogQuery { + levels: opt_str_vec(&args, "levels"), + search: opt_str(&args, "search"), + step_index: opt_i64(&args, "stepIndex"), + limit: opt_i64(&args, "limit"), + offset: opt_i64(&args, "offset"), + }; + let result = treq_lib::core::get_run_logs_sync(&repo_path, run_id, &job_id, &query)?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "get_repo_logs" => { + let repo_path = get_str(&args, "repoPath")?; + let query = treq_lib::core::checks_logs::LogQuery { + levels: opt_str_vec(&args, "levels"), + search: opt_str(&args, "search"), + step_index: None, + limit: opt_i64(&args, "limit"), + offset: opt_i64(&args, "offset"), + }; + let result = treq_lib::core::checks_logs::query_repo_logs(&repo_path, &query)?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "get_log_timeseries" => { + let repo_path = get_str(&args, "repoPath")?; + let query = treq_lib::core::checks_logs::LogQuery { + levels: opt_str_vec(&args, "levels"), + search: opt_str(&args, "search"), + step_index: None, + limit: None, + offset: None, + }; + let bucket_seconds = opt_i64(&args, "bucketSeconds").unwrap_or(1); + let result = treq_lib::core::checks_logs::query_log_timeseries( + &repo_path, + &query, + bucket_seconds, + )?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "run_logs_sql" => { + let repo_path = get_str(&args, "repoPath")?; + let sql = get_str(&args, "sql")?; + let max_rows = opt_i64(&args, "maxRows").unwrap_or(500); + let result = treq_lib::core::checks_logs::run_logs_sql(&repo_path, &sql, max_rows)?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "export_run_logs" => { + let repo_path = get_str(&args, "repoPath")?; + let run_id = get_i64(&args, "runId")?; + let job_id = get_str(&args, "jobId")?; + let dest_path = get_str(&args, "destPath")?; + let result = + treq_lib::core::export_run_logs_sync(&repo_path, run_id, &job_id, &dest_path)?; + Ok(serde_json::Value::String(result)) + } + // ── Tauri-runtime-only: silent no-ops ───────────────────────────── "pty_create_session" | "pty_session_exists" @@ -1110,6 +1236,18 @@ fn opt_i64(args: &Value, key: &str) -> Option { args.get(key).and_then(|v| v.as_i64()) } +fn opt_str(args: &Value, key: &str) -> Option { + args.get(key).and_then(|v| v.as_str()).map(String::from) +} + +fn opt_str_vec(args: &Value, key: &str) -> Option> { + args.get(key).and_then(|v| v.as_array()).map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) +} + fn opt_str_to_value(s: Option) -> Value { match s { Some(v) => Value::String(v), diff --git a/package-lock.json b/package-lock.json index 139c89fc..1812fc67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^1.1.1", + "echarts": "^6.1.0", "lucide-react": "^0.562.0", "monaco-editor": "^0.45.0", "prism-themes": "^1.9.0", @@ -8482,6 +8483,22 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -15460,6 +15477,21 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index aac1a485..01445e4e 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^1.1.1", + "echarts": "^6.1.0", "lucide-react": "^0.562.0", "monaco-editor": "^0.45.0", "prism-themes": "^1.9.0", diff --git a/scripts/screenshot/specs/checks-logs-browser.spec.tsx b/scripts/screenshot/specs/checks-logs-browser.spec.tsx new file mode 100644 index 00000000..eb7b1b02 --- /dev/null +++ b/scripts/screenshot/specs/checks-logs-browser.spec.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; +import { it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { createTestRepo, openRepo, writeRepoFile } from "../../../test/utils"; +import { render, screen, waitFor, within } from "../../../test/test-utils"; +import { Dashboard } from "../../../src/components/Dashboard"; +import { createWorkspace, trustRepo } from "../../../src/lib/api"; +import { captureDocument } from "../capture"; + +// Emits info, warning and error lines so the level colouring is visible, and +// two steps so the step filter has something to switch between. +const LOGGING_WORKFLOW = ` +name: Pull request checks +on: + workflow_dispatch: {} +jobs: + build: + name: Build Job + steps: + - name: Compile + run: "echo 'Compiling treq v0.1.3'; echo 'warning: unused variable x'" + - name: Test + run: "echo 'running 2 tests'; echo 'error: assertion failed' 1>&2; exit 1" +`; + +it("captures the checks run history and the logs browser", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await createWorkspace(repoPath, "feat/logs"); + await writeRepoFile(repoPath, ".treq/workflows/ci.yaml", LOGGING_WORKFLOW); + await trustRepo(repoPath); + + const user = userEvent.setup(); + render(); + + const sidebar = document.querySelector( + `.${CSS.escape("group/sidebar")}`, + ) as HTMLElement; + await waitFor(() => { + if (within(sidebar).queryAllByText("feat/logs").length === 0) { + throw new Error("workspace not in sidebar yet"); + } + }); + await user.click(within(sidebar).getAllByText("feat/logs")[0]); + await user.click(await screen.findByRole("tab", { name: /^Checks/ })); + await screen.findByRole("tab", { name: /^Checks/, selected: true }); + await screen.findByText("Build Job"); + + // Run once, then again, so the history shows two distinct run items. + await user.click(await screen.findByRole("button", { name: /Run Build Job/i })); + await screen.findByTestId("run-history-item"); + await user.click(await screen.findByRole("button", { name: /Run Build Job/i })); + await waitFor(async () => { + const items = await screen.findAllByTestId("run-history-item"); + if (items.length !== 2) throw new Error(`expected 2 runs, got ${items.length}`); + }); + + await captureDocument(document, { + name: "checks-logs-01-run-history", + expectations: [ + 'A "Run history" section is visible at the bottom of the workflow card listing two separate run entries, each with a "#" id and a timestamp.', + "Both run entries show a red X status icon, because the job's second step exits with an error.", + 'Each run entry has a "build" button with a document icon for opening that run\'s logs.', + 'The "Build Job" step rows above show a green checkmark on "Compile" and a red X on "Test".', + ], + }); + + // Open the newest run's logs. + await user.click((await screen.findAllByRole("button", { name: /^Logs/ }))[0]); + const browser = await screen.findByTestId("logs-browser"); + await screen.findByText("Compiling treq v0.1.3"); + + await captureDocument(document, { + name: "checks-logs-02-browser", + expectations: [ + "A logs viewer is open, replacing the checks list, with a Back button and an Export button in its header.", + 'A header row above the log lines labels "Timestamp", "Level" and "Message" columns, and each log line below aligns under those same tightly-fit columns with no gap on either side of the timestamp or level text.', + 'The "Level" column shows "WARN" in amber and "ERROR" in red, matching the colour of the message text on those same lines; plain "INFO" lines are the default text colour.', + ], + }); + + // Filter down to error lines only via the level multi-select. + await user.click(within(browser).getByTestId("log-level-filter")); + await user.click(await screen.findByRole("menuitemcheckbox", { name: /error/i })); + await user.keyboard("{Escape}"); + await waitFor(() => { + const lines = document.querySelectorAll('[data-testid="log-line"]'); + if (lines.length !== 1) throw new Error(`expected 1 line, got ${lines.length}`); + }); + + await captureDocument(document, { + name: "checks-logs-03-error-filter", + expectations: [ + 'Exactly one log line is shown — the red "error: assertion failed" line.', + 'The level filter button reads "error" rather than "All levels".', + 'The info and warning lines from the previous screenshot are gone.', + ], + }); +}, 90000); diff --git a/scripts/screenshot/specs/checks-tab.spec.tsx b/scripts/screenshot/specs/checks-tab.spec.tsx new file mode 100644 index 00000000..9fac74ae --- /dev/null +++ b/scripts/screenshot/specs/checks-tab.spec.tsx @@ -0,0 +1,118 @@ +import * as React from "react"; +import { it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { createTestRepo, openRepo, writeRepoFile } from "../../../test/utils"; +import { render, screen, waitFor, within } from "../../../test/test-utils"; +import { Dashboard } from "../../../src/components/Dashboard"; +import { createWorkspace } from "../../../src/lib/api"; +import { captureDocument } from "../capture"; + +// One workflow with a passing job and a failing job, so a single "Run All" +// shows both the green-checkmark and the red-X / fail-fast rendering. +const MIXED_WORKFLOW = ` +name: Pull request checks +on: + workflow_dispatch: {} +jobs: + greet: + name: Greet Job + steps: + - name: Say hello + run: echo hello + - name: Say world + run: echo world + verify: + name: Verify Job + steps: + - name: Failing check + run: exit 1 + - name: Never runs + run: echo skipped +`; + +it("captures the Checks tab trust gate and step pass/fail results", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + // Incidental background state: the Checks tab needs a workspace to render + // under, but workspace creation is not the behavior being verified here. + await createWorkspace(repoPath, "feat/checks"); + await writeRepoFile(repoPath, ".treq/workflows/ci.yaml", MIXED_WORKFLOW); + + const user = userEvent.setup(); + render(); + + // Navigate to the workspace, then to the Checks tab. + const sidebar = document.querySelector( + `.${CSS.escape("group/sidebar")}`, + ) as HTMLElement; + await waitFor(() => { + if (within(sidebar).queryAllByText("feat/checks").length === 0) { + throw new Error("workspace not in sidebar yet"); + } + }); + await user.click(within(sidebar).getAllByText("feat/checks")[0]); + + await user.click(await screen.findByRole("tab", { name: /^Checks/ })); + await screen.findByRole("tab", { name: /^Checks/, selected: true }); + + // Untrusted: the workflow is listed but execution is gated. + await screen.findByText("Pull request checks"); + await screen.findByText("Greet Job"); + await screen.findByText("Verify Job"); + const trustButton = await screen.findByRole("button", { + name: /Trust Repository/i, + }); + await captureDocument(document, { + name: "checks-tab-01-untrusted", + expectations: [ + 'An amber/yellow warning banner is visible reading "Trust this repository to enable running workflow checks." with a "Trust Repository" button on its right.', + 'The workflow card below shows the title "Pull request checks" with the filename "ci.yaml" underneath, and lists "Greet Job" and "Verify Job".', + 'All run buttons ("Run All", "Run Greet Job", "Run Verify Job") appear visually disabled/greyed out.', + "Every step name has a grey neutral dot icon beside it — no green checkmarks and no red X icons anywhere.", + ], + }); + + // Trusting the repo removes the gate and enables execution. + await user.click(trustButton); + await waitFor(() => { + if (screen.queryByRole("button", { name: /Trust Repository/i })) { + throw new Error("trust banner still present"); + } + }); + await captureDocument(document, { + name: "checks-tab-02-trusted", + expectations: [ + "The amber trust banner is completely gone from the top of the panel.", + 'The run buttons ("Run All", "Run Greet Job", "Run Verify Job") now appear enabled — normal contrast, not greyed out.', + "Step names still show grey neutral dot icons, since nothing has been run yet.", + ], + }); + + // Run every job in the workflow. + await user.click(await screen.findByRole("button", { name: /Run All/i })); + await waitFor( + () => { + const passIcons = document.querySelectorAll( + '[data-testid="step-result-pass"]', + ); + const failIcons = document.querySelectorAll( + '[data-testid="step-result-fail"]', + ); + if (passIcons.length !== 2 || failIcons.length !== 1) { + throw new Error( + `expected 2 pass / 1 fail, got ${passIcons.length} / ${failIcons.length}`, + ); + } + }, + { timeout: 20000 }, + ); + await captureDocument(document, { + name: "checks-tab-03-after-run-all", + expectations: [ + 'Under "Greet Job", both "Say hello" and "Say world" have green checkmark icons.', + 'Under "Verify Job", "Failing check" has a red X icon.', + '"Never runs" (the step after the failing one) still shows a grey neutral dot, NOT a green check or red X — it was skipped by fail-fast.', + "There are exactly 2 green checkmarks and exactly 1 red X in the whole panel.", + ], + }); +}, 90000); diff --git a/scripts/screenshot/specs/logs-otel-chart-agent.spec.tsx b/scripts/screenshot/specs/logs-otel-chart-agent.spec.tsx new file mode 100644 index 00000000..a0394a1e --- /dev/null +++ b/scripts/screenshot/specs/logs-otel-chart-agent.spec.tsx @@ -0,0 +1,121 @@ +import * as React from "react"; +import { it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { createTestRepo, openRepo, writeRepoFile } from "../../../test/utils"; +import { render, screen, waitFor, within } from "../../../test/test-utils"; +import { Dashboard } from "../../../src/components/Dashboard"; +import { createWorkspace, trustRepo } from "../../../src/lib/api"; +import { captureDocument } from "../capture"; + +const LOGGING_WORKFLOW = ` +name: Pull request checks +on: + workflow_dispatch: {} +jobs: + build: + name: Build Job + steps: + - name: Compile + run: "echo 'Compiling treq v0.1.3'; echo 'warning: unused variable x'" + - name: Test + run: "echo 'running 2 tests'; echo 'error: assertion failed' 1>&2" +`; + +it("captures the OTel logs tab, chart, selection and templates menu", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await createWorkspace(repoPath, "feat/logs"); + await writeRepoFile(repoPath, ".treq/workflows/ci.yaml", LOGGING_WORKFLOW); + await trustRepo(repoPath); + + const user = userEvent.setup(); + render(); + + const sidebar = document.querySelector( + `.${CSS.escape("group/sidebar")}`, + ) as HTMLElement; + await waitFor(() => { + if (within(sidebar).queryAllByText("feat/logs").length === 0) { + throw new Error("workspace not in sidebar yet"); + } + }); + await user.click(within(sidebar).getAllByText("feat/logs")[0]); + await user.click(await screen.findByRole("tab", { name: /^Checks/ })); + await user.click(await screen.findByRole("button", { name: /Run Build Job/i })); + await screen.findByTestId("run-history-item"); + + await user.click(within(sidebar).getAllByText(/^branch-/)[0]); + await user.click(await screen.findByRole("tab", { name: /^Logs/ })); + await screen.findByText("Compiling treq v0.1.3"); + await screen.findByTestId("logs-timeseries-chart"); + + await captureDocument(document, { + name: "logs-otel-01-browse-with-chart", + expectations: [ + 'The header reads "Checks logs" with the subtitle "OpenTelemetry records · .treq/runs/**/*.jsonl".', + 'The view toggle offers "Browse" and "Logs Explorer" (not "SQL Explorer"), with Browse selected.', + "A chart area is rendered between the filter row and the log lines, with a legend showing Info, Warn and Error.", + "The chart draws stacked bars with a numeric y-axis and time labels along the x-axis.", + "The bars stack in severity order with Info blue at the bottom, Warn amber above it and Error red on top.", + "The legend sits in its own band above the plot area and does not overlap any bar.", + "The x-axis has a tick for every time bucket in the range, including quiet ones with no bars.", + "Time runs oldest-on-the-left to newest-on-the-right along the x-axis.", + "Ignore the chart's narrow overall width: jsdom reports a zero-width container, so ECharts falls back to a 100px canvas here instead of filling the panel.", + 'Below the chart a toolbar shows "Multi-select", "Select all" and a disabled "Send to agent" button.', + 'A header row below the toolbar labels "Timestamp", "Run", "Job", "Level" and "Message" columns; log lines beneath align a timestamp, a "#" run id, a blue job id and the message under those tightly-fit columns with the message column taking up most of the row width.', + ], + }); + + // Drag across the first two lines to select a range. + const lines = await screen.findAllByTestId("repo-log-line"); + await user.pointer([ + { target: lines[0], keys: "[MouseLeft>]" }, + { target: lines[1] }, + { keys: "[/MouseLeft]" }, + ]); + await waitFor(() => { + if (!document.querySelector('[data-testid="selection-count"]')) { + throw new Error("no selection yet"); + } + }); + + await captureDocument(document, { + name: "logs-otel-02-drag-selection", + expectations: [ + "The first two log lines are highlighted with a tinted background, marking them selected.", + 'The toolbar reads "2 selected" and offers a "Clear" button.', + 'The "Send to agent" button is now enabled rather than greyed out.', + "Log lines after the first two are not highlighted.", + ], + }); + + // Switch to the explorer and open the templates dropdown. + await user.click(await screen.findByRole("button", { name: /Logs Explorer/i })); + await user.click(await screen.findByTestId("template-menu")); + await screen.findByText("Newest records across every run"); + + await captureDocument(document, { + name: "logs-otel-03-templates-menu", + expectations: [ + "An open dropdown lists exactly five query templates.", + 'Each template shows a bold label above a smaller grey description, e.g. "Errors by job" above "Which jobs produce the most ERROR records".', + 'The five labels are "Recent lines", "Errors by job", "Severity by run", "Slowest steps" and "Trace overview".', + "Ignore where the dropdown sits on the page: jsdom reports zero-sized boxes so Radix cannot anchor it to the trigger in this harness.", + ], + }); + + await user.keyboard("{Escape}"); + const explorer = await screen.findByTestId("logs-sql-explorer"); + await user.click(within(explorer).getByRole("button", { name: /Run query/i })); + await screen.findByTestId("sql-results"); + + await captureDocument(document, { + name: "logs-otel-04-explorer-results", + expectations: [ + 'The SQL editor shows a query selecting timestamp, severityText, attributes.job_id and body.message from the logs view.', + 'A result grid below has columns "timestamp", "severityText", "job_id" and "message".', + 'The severityText column contains OpenTelemetry severity names such as "INFO", "WARN" or "ERROR" — not lowercase "info"/"error".', + 'A "Send results to agent" button appears next to "Run query" now that a result set exists.', + ], + }); +}, 90000); diff --git a/scripts/screenshot/specs/logs-tab-sql-explorer.spec.tsx b/scripts/screenshot/specs/logs-tab-sql-explorer.spec.tsx new file mode 100644 index 00000000..ea579e5b --- /dev/null +++ b/scripts/screenshot/specs/logs-tab-sql-explorer.spec.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import { it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { createTestRepo, openRepo, writeRepoFile } from "../../../test/utils"; +import { render, screen, waitFor, within } from "../../../test/test-utils"; +import { Dashboard } from "../../../src/components/Dashboard"; +import { createWorkspace, trustRepo } from "../../../src/lib/api"; +import { captureDocument } from "../capture"; + +const LOGGING_WORKFLOW = ` +name: Pull request checks +on: + workflow_dispatch: {} +jobs: + build: + name: Build Job + steps: + - name: Compile + run: "echo 'Compiling treq v0.1.3'; echo 'warning: unused variable x'" + - name: Test + run: "echo 'running 2 tests'; echo 'error: assertion failed' 1>&2" +`; + +it("captures the home repo Logs tab and the level multi-select", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await createWorkspace(repoPath, "feat/logs"); + await writeRepoFile(repoPath, ".treq/workflows/ci.yaml", LOGGING_WORKFLOW); + await trustRepo(repoPath); + + const user = userEvent.setup(); + render(); + + // Seed log rows by running the check inside the workspace. + const sidebar = document.querySelector( + `.${CSS.escape("group/sidebar")}`, + ) as HTMLElement; + await waitFor(() => { + if (within(sidebar).queryAllByText("feat/logs").length === 0) { + throw new Error("workspace not in sidebar yet"); + } + }); + await user.click(within(sidebar).getAllByText("feat/logs")[0]); + await user.click(await screen.findByRole("tab", { name: /^Checks/ })); + await user.click(await screen.findByRole("button", { name: /Run Build Job/i })); + await screen.findByTestId("run-history-item"); + + // Back to the home repo, where the Logs tab lives. + await user.click(within(sidebar).getAllByText(/^branch-/)[0]); + await user.click(await screen.findByRole("tab", { name: /^Logs/ })); + await screen.findByRole("tab", { name: /^Logs/, selected: true }); + await screen.findByText("Compiling treq v0.1.3"); + + await captureDocument(document, { + name: "logs-tab-01-browse", + expectations: [ + 'A "Checks logs" data-source header is visible, subtitled "OpenTelemetry records · .treq/runs/**/*.jsonl".', + 'The header has "Browse" and "Logs Explorer" toggle buttons, with "Browse" currently selected.', + 'Log lines are listed in a monospace font, each with a timestamp, a "#" column, a blue "build" job-id column, and the message.', + 'The warning line is amber and the error line is red; plain lines are the default colour.', + 'A level filter button reading "All levels" sits above the log lines next to a search box.', + ], + }); + + // Open the multi-select and tick two levels. + // Radix positions its popper from measured element boxes, which jsdom always + // reports as 0x0, so the menu rasterises at the top-left corner here. Its + // placement is a harness artifact and is not something these shots can check. + await user.click(await screen.findByTestId("log-level-filter")); + await screen.findByRole("menuitemcheckbox", { name: /warning/i }); + + await captureDocument(document, { + name: "logs-tab-02-level-multiselect", + expectations: [ + 'An open dropdown lists "info", "warning" and "error" as checkbox items.', + "None of the checkboxes are ticked yet, matching the \"All levels\" button label.", + "Ignore where the dropdown sits on the page: jsdom reports zero-sized boxes so Radix cannot anchor it to the trigger in this harness.", + ], + }); + + await user.click(await screen.findByRole("menuitemcheckbox", { name: /warning/i })); + await user.click(await screen.findByRole("menuitemcheckbox", { name: /error/i })); + await waitFor(() => { + const lines = document.querySelectorAll('[data-testid="repo-log-line"]'); + if (lines.length !== 2) throw new Error(`expected 2 lines, got ${lines.length}`); + }); + await user.keyboard("{Escape}"); + + await captureDocument(document, { + name: "logs-tab-03-two-levels-selected", + expectations: [ + 'The level filter button now reads "2 levels" instead of "All levels".', + "Exactly two log lines remain: the amber warning line and the red error line.", + 'The plain info lines ("Compiling treq v0.1.3", "running 2 tests") are gone.', + ], + }); + + // Switch to the SQL explorer and run an aggregate query. + await user.click(await screen.findByRole("button", { name: /Logs Explorer/i })); + const explorer = await screen.findByTestId("logs-sql-explorer"); + await user.click(await screen.findByTestId("template-menu")); + await user.click(await screen.findByRole("menuitem", { name: /Errors by job/i })); + await user.click(within(explorer).getByRole("button", { name: /Run query/i })); + await screen.findByTestId("sql-results"); + + await captureDocument(document, { + name: "logs-tab-04-sql-explorer", + expectations: [ + "A SQL editor shows a multi-line GROUP BY query against the logs view, in a monospace font.", + 'A "Templates" dropdown button sits above the editor.', + 'A result grid is rendered below with column headers "job_id" and "errors", and a row containing "build".', + 'A row count line such as "1 row" appears above the grid.', + ], + }); +}, 90000); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e14712f3..0aaab163 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ tauri-plugin-deep-link = "2.4.7" tauri-plugin-cli = "2.4.1" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" rusqlite = { version = "0.39", features = ["bundled"] } portable-pty = "0.9" chrono = { version = "0.4", features = ["serde"] } @@ -49,6 +50,7 @@ opentelemetry = { version = "0.32.0", features = ["logs"] } opentelemetry_sdk = { version = "0.32.0", features = ["logs"] } opentelemetry-appender-tracing = "0.32.0" opentelemetry-stdout = { version = "0.32.0", features = ["logs"] } +duckdb = "1.10505.0" [dev-dependencies] mockall = "0.14.0" diff --git a/src-tauri/src/commands/checks.rs b/src-tauri/src/commands/checks.rs new file mode 100644 index 00000000..3d09d2c6 --- /dev/null +++ b/src-tauri/src/commands/checks.rs @@ -0,0 +1,176 @@ +use crate::core::checks_logs::{LogBucket, LogQuery, LogRecordView, SqlResult}; +use crate::core::{JobResult, RunSummary, WorkflowInfo}; + +#[tauri::command] +pub async fn list_workflows(repo_path: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || crate::core::list_workflows_sync(&repo_path)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn run_workflow_job( + repo_path: String, + filename: String, + job_id: String, + workspace_id: i64, + workspace_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + crate::core::run_workflow_job_sync( + &repo_path, + &filename, + &job_id, + workspace_id, + &workspace_path, + ) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn run_workflow( + repo_path: String, + filename: String, + workspace_id: i64, + workspace_path: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + crate::core::run_workflow_sync(&repo_path, &filename, workspace_id, &workspace_path) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn is_repo_trusted(repo_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || Ok(crate::local_db::is_repo_trusted(&repo_path))) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn trust_repo(repo_path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || crate::local_db::trust_repo(&repo_path)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn list_workflow_runs( + repo_path: String, + workspace_id: i64, + filename: String, + limit: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + crate::core::list_workflow_runs_sync( + &repo_path, + workspace_id, + &filename, + limit.unwrap_or(20), + ) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn get_run_logs( + repo_path: String, + run_id: i64, + job_id: String, + levels: Option>, + search: Option, + step_index: Option, + limit: Option, + offset: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let query = LogQuery { + levels, + search, + step_index, + limit, + offset, + }; + crate::core::get_run_logs_sync(&repo_path, run_id, &job_id, &query) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn get_repo_logs( + repo_path: String, + levels: Option>, + search: Option, + limit: Option, + offset: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let query = LogQuery { + levels, + search, + step_index: None, + limit, + offset, + }; + crate::core::checks_logs::query_repo_logs(&repo_path, &query) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn get_log_timeseries( + repo_path: String, + levels: Option>, + search: Option, + bucket_seconds: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let query = LogQuery { + levels, + search, + step_index: None, + limit: None, + offset: None, + }; + crate::core::checks_logs::query_log_timeseries( + &repo_path, + &query, + bucket_seconds.unwrap_or(1), + ) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn run_logs_sql( + repo_path: String, + sql: String, + max_rows: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + crate::core::checks_logs::run_logs_sql(&repo_path, &sql, max_rows.unwrap_or(500)) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn export_run_logs( + repo_path: String, + run_id: i64, + job_id: String, + dest_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + crate::core::export_run_logs_sync(&repo_path, run_id, &job_id, &dest_path) + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a8feb6f3..c3b4ceaf 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ // Command modules pub mod binary; +pub mod checks; pub mod commits; pub mod file_view; pub mod file_watcher; @@ -13,6 +14,7 @@ pub mod workspace; // Re-export all commands for convenient access pub use binary::*; +pub use checks::*; pub use commits::*; pub use file_view::*; pub use file_watcher::*; diff --git a/src-tauri/src/core/checks.rs b/src-tauri/src/core/checks.rs new file mode 100644 index 00000000..47b24c57 --- /dev/null +++ b/src-tauri/src/core/checks.rs @@ -0,0 +1,851 @@ +use crate::core::checks_logs::{ + infer_level, job_log_relative_path, make_log_line, now_timestamp, strip_ansi, LogWriter, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::BufRead; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const STEP_TIMEOUT_SECS: u64 = 60; +const MAX_CONCURRENT_JOBS: usize = 4; + +// ── Internal YAML structs ──────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct WorkflowFile { + name: String, + jobs: HashMap, +} + +#[derive(Deserialize)] +struct JobDef { + name: Option, + steps: Vec, +} + +#[derive(Deserialize)] +struct StepDef { + name: String, + run: String, + #[serde(rename = "working-directory")] + working_directory: Option, + env: Option>, +} + +// ── Public API types ───────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorkflowInfo { + pub filename: String, + pub name: String, + pub jobs: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JobInfo { + pub id: String, + pub name: String, + pub steps: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct StepInfo { + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct StepResult { + pub name: String, + pub success: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JobResult { + pub job_id: String, + pub steps: Vec, + pub success: bool, +} + +// ── Validation helpers ─────────────────────────────────────────────────────── + +fn validate_filename(filename: &str) -> Result<(), String> { + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Err(format!( + "Invalid workflow filename '{}': must not contain path separators or '..'", + filename + )); + } + if !filename.ends_with(".yaml") && !filename.ends_with(".yml") { + return Err(format!( + "Invalid workflow filename '{}': must have .yaml or .yml extension", + filename + )); + } + Ok(()) +} + +fn validate_working_directory(wd: &str) -> Result<(), String> { + if Path::new(wd).is_absolute() { + return Err(format!( + "working-directory must be a relative path, got: '{}'", + wd + )); + } + if wd.split('/').any(|c| c == "..") || wd.split('\\').any(|c| c == "..") { + return Err(format!( + "working-directory must not traverse parent directories: '{}'", + wd + )); + } + Ok(()) +} + +// ── Public functions ───────────────────────────────────────────────────────── + +pub fn list_workflows_sync(repo_path: &str) -> Result, String> { + let workflows_dir = Path::new(repo_path).join(".treq").join("workflows"); + if !workflows_dir.exists() { + return Ok(vec![]); + } + + let mut entries: Vec<_> = std::fs::read_dir(&workflows_dir) + .map_err(|e| format!("Failed to read workflows dir: {}", e))? + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.ends_with(".yaml") || name.ends_with(".yml") + }) + .collect(); + + entries.sort_by_key(|e| e.file_name()); + + let canonical_dir = workflows_dir + .canonicalize() + .map_err(|e| format!("Failed to access workflows directory: {}", e))?; + + let mut workflows = Vec::new(); + for entry in entries { + let filename = entry.file_name().to_string_lossy().to_string(); + + // Verify each file stays within the workflows directory. + let canonical_file = match entry.path().canonicalize() { + Ok(p) => p, + Err(_) => continue, + }; + if !canonical_file.starts_with(&canonical_dir) { + continue; + } + + let content = match std::fs::read_to_string(entry.path()) { + Ok(c) => c, + Err(_) => continue, + }; + + let wf: WorkflowFile = match serde_yaml::from_str(&content) { + Ok(w) => w, + Err(_) => continue, // skip invalid YAML files + }; + + let mut jobs: Vec = wf + .jobs + .into_iter() + .map(|(id, def)| JobInfo { + id: id.clone(), + name: def.name.unwrap_or_else(|| id), + steps: def + .steps + .into_iter() + .map(|s| StepInfo { name: s.name }) + .collect(), + }) + .collect(); + jobs.sort_by(|a, b| a.id.cmp(&b.id)); + + workflows.push(WorkflowInfo { + filename, + name: wf.name, + jobs, + }); + } + + Ok(workflows) +} + +/// Runs a single job as its own run (one row in the run history). +pub fn run_workflow_job_sync( + repo_path: &str, + filename: &str, + job_id: &str, + workspace_id: i64, + workspace_path: &str, +) -> Result { + let run_id = crate::local_db::create_workflow_run(repo_path, workspace_id, filename)?; + let result = run_job_in_run(repo_path, filename, job_id, workspace_path, run_id, 0); + let status = match &result { + Ok(r) if r.success => "passed", + Ok(_) => "failed", + Err(_) => "failed", + }; + crate::local_db::finish_workflow_run(repo_path, run_id, status)?; + result +} + +/// Executes one job's steps inside an already-open run. +fn run_job_in_run( + repo_path: &str, + filename: &str, + job_id: &str, + workspace_path: &str, + run_id: i64, + position: i64, +) -> Result { + if !crate::local_db::is_repo_trusted(repo_path) { + return Err( + "repository_not_trusted: Trust this repository before running checks".to_string(), + ); + } + + validate_filename(filename)?; + + let workflows_dir = Path::new(repo_path).join(".treq").join("workflows"); + let file_path = workflows_dir.join(filename); + + // Verify file stays inside the workflows directory after path normalization. + let canonical_dir = workflows_dir + .canonicalize() + .map_err(|e| format!("Failed to access workflows directory: {}", e))?; + let canonical_file = file_path + .canonicalize() + .map_err(|_| format!("Workflow file not found: '{}'", filename))?; + if !canonical_file.starts_with(&canonical_dir) { + return Err(format!( + "Workflow file '{}' is outside the workflows directory", + filename + )); + } + + let content = std::fs::read_to_string(&file_path) + .map_err(|e| format!("Failed to read '{}': {}", filename, e))?; + let wf: WorkflowFile = serde_yaml::from_str(&content) + .map_err(|e| format!("Failed to parse '{}': {}", filename, e))?; + + let job_def = wf + .jobs + .into_iter() + .find(|(id, _)| id == job_id) + .map(|(_, def)| def) + .ok_or_else(|| format!("Job '{}' not found in '{}'", job_id, filename))?; + + let base_dir = if Path::new(workspace_path).is_dir() { + workspace_path.to_string() + } else { + repo_path.to_string() + }; + + let extended_path = crate::binary_paths::get_extended_path(); + let mut step_results = Vec::new(); + + let started_at = Utc::now().to_rfc3339(); + let writer = LogWriter::create(repo_path, run_id, job_id)?; + let log_path = job_log_relative_path(run_id, job_id); + + for (step_index, step) in job_def.steps.iter().enumerate() { + let cwd = if let Some(wd) = &step.working_directory { + validate_working_directory(wd)?; + Path::new(&base_dir).join(wd).to_string_lossy().to_string() + } else { + base_dir.clone() + }; + + let mut cmd = Command::new("sh"); + cmd.args(["-c", &step.run]) + .current_dir(&cwd) + .env("PATH", &extended_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if let Some(env_vars) = &step.env { + for (k, v) in env_vars { + cmd.env(k, v); + } + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to start step '{}': {}", step.name, e))?; + + // Separate threads per pipe; one reader would deadlock on large output. + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let readers: Vec<_> = [ + ("stdout", stdout.map(EitherPipe::Out)), + ("stderr", stderr.map(EitherPipe::Err)), + ] + .into_iter() + .filter_map(|(stream_name, pipe)| pipe.map(|p| (stream_name, p))) + .map(|(stream_name, pipe)| { + let writer = writer.clone(); + let step_name = step.name.clone(); + let job_id_for_logs = job_id.to_string(); + std::thread::spawn(move || { + let reader: Box = match pipe { + EitherPipe::Out(o) => Box::new(o), + EitherPipe::Err(e) => Box::new(e), + }; + let buf = std::io::BufReader::new(reader); + for line in buf.lines() { + let Ok(raw) = line else { break }; + let message = strip_ansi(&raw); + let entry = make_log_line( + now_timestamp(), + run_id, + &job_id_for_logs, + step_index as i64, + &step_name, + stream_name, + infer_level(&message), + &message, + ); + let _ = writer.write_line(&entry); + } + }) + }) + .collect(); + + let timeout = Duration::from_secs(STEP_TIMEOUT_SECS); + let start = Instant::now(); + let timed_out; + let exit_status = loop { + match child.try_wait() { + Ok(Some(status)) => { + timed_out = false; + break Some(status); + } + Ok(None) => { + if start.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + timed_out = true; + break None; + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(e) => return Err(format!("Failed waiting for step '{}': {}", step.name, e)), + } + }; + + // Killing the child closes the pipes, so the readers finish on their own. + for handle in readers { + let _ = handle.join(); + } + + if timed_out { + let _ = writer.write_line(&make_log_line( + now_timestamp(), + run_id, + job_id, + step_index as i64, + &step.name, + "stderr", + "error", + &format!( + "Step timed out after {} seconds and was terminated.", + STEP_TIMEOUT_SECS + ), + )); + let _ = writer.flush(); + step_results.push(StepResult { + name: step.name.clone(), + success: false, + }); + break; + } + + let success = exit_status.map(|s| s.success()).unwrap_or(false); + step_results.push(StepResult { + name: step.name.clone(), + success, + }); + + if !success { + break; + } + } + + writer.flush()?; + + let overall_success = step_results.iter().all(|s| s.success); + let result = JobResult { + job_id: job_id.to_string(), + steps: step_results, + success: overall_success, + }; + + store_job_result(repo_path, run_id, position, &started_at, &log_path, &result)?; + + Ok(result) +} + +/// Lets the two pipe types share one reader thread body. +enum EitherPipe { + Out(std::process::ChildStdout), + Err(std::process::ChildStderr), +} + +pub fn run_workflow_sync( + repo_path: &str, + filename: &str, + workspace_id: i64, + workspace_path: &str, +) -> Result, String> { + let workflows = list_workflows_sync(repo_path)?; + let wf = workflows + .into_iter() + .find(|w| w.filename == filename) + .ok_or_else(|| format!("Workflow '{}' not found", filename))?; + + // All jobs share one run row so history shows one item per invocation. + let job_ids: Vec = wf.jobs.into_iter().map(|j| j.id).collect(); + let total = job_ids.len(); + let run_id = crate::local_db::create_workflow_run(repo_path, workspace_id, filename)?; + + let rp = repo_path.to_string(); + let fn_ = filename.to_string(); + let wp = workspace_path.to_string(); + + let (result_tx, result_rx) = std::sync::mpsc::channel::>(); + let mut job_iter = job_ids.into_iter().enumerate(); + let mut in_flight = 0usize; + let mut results = Vec::with_capacity(total); + let mut failure: Option = None; + + loop { + // Refill the slot pool up to MAX_CONCURRENT_JOBS. + while in_flight < MAX_CONCURRENT_JOBS { + match job_iter.next() { + Some((position, job_id)) => { + let rp = rp.clone(); + let fn_ = fn_.clone(); + let wp = wp.clone(); + let tx = result_tx.clone(); + std::thread::spawn(move || { + let r = run_job_in_run(&rp, &fn_, &job_id, &wp, run_id, position as i64); + tx.send(r).ok(); + }); + in_flight += 1; + } + None => break, + } + } + + if in_flight == 0 { + break; + } + + match result_rx.recv() { + Ok(Ok(r)) => { + results.push(r); + in_flight -= 1; + } + // Keep draining so one bad job doesn't abort unrelated jobs. + Ok(Err(e)) => { + failure.get_or_insert(e); + in_flight -= 1; + } + Err(_) => { + failure.get_or_insert_with(|| "Job thread disconnected unexpectedly".to_string()); + in_flight -= 1; + } + } + } + + let status = if failure.is_some() || results.iter().any(|r| !r.success) { + "failed" + } else { + "passed" + }; + crate::local_db::finish_workflow_run(repo_path, run_id, status)?; + + if let Some(err) = failure { + return Err(err); + } + + // Restore workflow job order; completion order is nondeterministic. + results.sort_by(|a, b| a.job_id.cmp(&b.job_id)); + Ok(results) +} + +fn store_job_result( + repo_path: &str, + run_id: i64, + position: i64, + started_at: &str, + log_path: &str, + result: &JobResult, +) -> Result<(), String> { + let steps_json = serde_json::to_string(&result.steps) + .map_err(|e| format!("Failed to serialize steps: {}", e))?; + crate::local_db::add_workflow_job_result( + repo_path, + run_id, + &result.job_id, + position, + if result.success { "passed" } else { "failed" }, + started_at, + &Utc::now().to_rfc3339(), + &steps_json, + Some(log_path), + )?; + Ok(()) +} + +// ── Run history and logs ───────────────────────────────────────────────────── + +/// A past invocation, with its per-job outcomes, for the run-history list. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RunSummary { + pub id: i64, + pub filename: String, + pub status: String, + pub started_at: String, + pub completed_at: Option, + pub jobs: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RunJobSummary { + pub job_id: String, + pub status: String, + pub steps: Vec, + pub has_logs: bool, +} + +pub fn list_workflow_runs_sync( + repo_path: &str, + workspace_id: i64, + filename: &str, + limit: i64, +) -> Result, String> { + let runs = crate::local_db::list_workflow_runs(repo_path, workspace_id, filename, limit)?; + let mut summaries = Vec::with_capacity(runs.len()); + for run in runs { + let job_rows = crate::local_db::list_workflow_job_results(repo_path, run.id)?; + let jobs = job_rows + .into_iter() + .map(|j| RunJobSummary { + job_id: j.job_id, + status: j.status, + steps: serde_json::from_str(&j.steps_json).unwrap_or_default(), + has_logs: j.log_path.is_some(), + }) + .collect(); + summaries.push(RunSummary { + id: run.id, + filename: run.filename, + status: run.status, + started_at: run.started_at, + completed_at: run.completed_at, + jobs, + }); + } + Ok(summaries) +} + +/// Resolve a job's log file to an absolute path, refusing anything that escapes +/// the repo's `.treq/runs` directory. +fn resolve_log_path(repo_path: &str, run_id: i64, job_id: &str) -> Result { + let relative = crate::local_db::get_job_log_path(repo_path, run_id, job_id)? + .ok_or_else(|| format!("No logs recorded for job '{}' in run {}", job_id, run_id))?; + let absolute = Path::new(repo_path).join(&relative); + + let runs_root = Path::new(repo_path).join(".treq").join("runs"); + let canonical_root = runs_root + .canonicalize() + .map_err(|e| format!("Failed to access runs directory: {}", e))?; + let canonical_file = absolute + .canonicalize() + .map_err(|_| format!("Log file missing for job '{}'", job_id))?; + if !canonical_file.starts_with(&canonical_root) { + return Err(format!( + "Log path for job '{}' is outside .treq/runs", + job_id + )); + } + Ok(canonical_file.to_string_lossy().to_string()) +} + +pub fn get_run_logs_sync( + repo_path: &str, + run_id: i64, + job_id: &str, + query: &crate::core::checks_logs::LogQuery, +) -> Result, String> { + let path = resolve_log_path(repo_path, run_id, job_id)?; + crate::core::checks_logs::query_logs(&path, query) +} + +pub fn export_run_logs_sync( + repo_path: &str, + run_id: i64, + job_id: &str, + dest_path: &str, +) -> Result { + let path = resolve_log_path(repo_path, run_id, job_id)?; + crate::core::checks_logs::export_logs(&path, dest_path) +} + +// ── Unit tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn make_workflow(jobs_yaml: &str) -> String { + format!( + "name: Test Workflow\non:\n workflow_dispatch: {{}}\njobs:\n{}", + jobs_yaml + ) + } + + fn write_workflow(dir: &TempDir, filename: &str, content: &str) -> String { + let workflows_dir = dir.path().join(".treq").join("workflows"); + fs::create_dir_all(&workflows_dir).unwrap(); + fs::write(workflows_dir.join(filename), content).unwrap(); + dir.path().to_string_lossy().to_string() + } + + fn setup_trusted_repo(dir: &TempDir, filename: &str, content: &str) -> String { + let repo = write_workflow(dir, filename, content); + crate::local_db::init_local_db(&repo).unwrap(); + crate::local_db::trust_repo(&repo).unwrap(); + repo + } + + #[test] + fn test_list_workflows_empty_when_no_dir() { + let dir = TempDir::new().unwrap(); + let result = list_workflows_sync(&dir.path().to_string_lossy()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_list_workflows_parses_yaml() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " greet:\n name: Greet Job\n steps:\n - name: Say hi\n run: echo hi\n", + ); + let repo = write_workflow(&dir, "ci.yaml", &content); + let result = list_workflows_sync(&repo).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "Test Workflow"); + } + + #[test] + fn test_list_workflows_skips_non_yaml() { + let dir = TempDir::new().unwrap(); + let workflows_dir = dir.path().join(".treq").join("workflows"); + fs::create_dir_all(&workflows_dir).unwrap(); + fs::write(workflows_dir.join("readme.txt"), "not yaml").unwrap(); + let result = list_workflows_sync(&dir.path().to_string_lossy()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_list_workflows_skips_invalid_yaml() { + let dir = TempDir::new().unwrap(); + let workflows_dir = dir.path().join(".treq").join("workflows"); + fs::create_dir_all(&workflows_dir).unwrap(); + fs::write( + workflows_dir.join("broken.yaml"), + "this: is: not: valid: yaml: :::", + ) + .unwrap(); + // valid workflow alongside the broken one + let content = make_workflow(" j:\n steps:\n - name: s\n run: echo x\n"); + fs::write(workflows_dir.join("valid.yaml"), &content).unwrap(); + let result = list_workflows_sync(&dir.path().to_string_lossy()).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].filename, "valid.yaml"); + } + + #[test] + fn test_list_workflows_sorted_by_filename() { + let dir = TempDir::new().unwrap(); + let content = make_workflow(" j:\n steps:\n - name: s\n run: echo x\n"); + let repo = write_workflow(&dir, "b.yaml", &content); + write_workflow(&dir, "a.yaml", &content); + let result = list_workflows_sync(&repo).unwrap(); + assert_eq!(result.len(), 2); + assert!(result[0].filename < result[1].filename); + } + + #[test] + fn test_run_job_requires_trust() { + let dir = TempDir::new().unwrap(); + let content = + make_workflow(" greet:\n steps:\n - name: Say hi\n run: echo hi\n"); + let repo = write_workflow(&dir, "ci.yaml", &content); + crate::local_db::init_local_db(&repo).unwrap(); + // NOT trusting the repo + let err = run_workflow_job_sync(&repo, "ci.yaml", "greet", 0, &repo).unwrap_err(); + assert!(err.contains("repository_not_trusted")); + } + + #[test] + fn test_run_job_rejects_path_traversal_filename() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().to_string_lossy().to_string(); + crate::local_db::init_local_db(&repo).unwrap(); + crate::local_db::trust_repo(&repo).unwrap(); + let err = run_workflow_job_sync(&repo, "../secret.yaml", "job", 0, &repo).unwrap_err(); + assert!(err.contains("Invalid workflow filename")); + } + + #[test] + fn test_run_job_success() { + let dir = TempDir::new().unwrap(); + let content = + make_workflow(" greet:\n steps:\n - name: Say hi\n run: echo hi\n"); + let repo = setup_trusted_repo(&dir, "ci.yaml", &content); + let result = run_workflow_job_sync(&repo, "ci.yaml", "greet", 0, &repo).unwrap(); + assert!(result.success); + assert!(!result.steps.is_empty()); + } + + #[test] + fn test_run_job_stops_at_first_failure() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " check:\n steps:\n - name: Fail\n run: exit 1\n - name: Skip\n run: echo skip\n", + ); + let repo = setup_trusted_repo(&dir, "ci.yaml", &content); + let result = run_workflow_job_sync(&repo, "ci.yaml", "check", 0, &repo).unwrap(); + assert!(!result.success); + assert_eq!(result.steps.len(), 1); + } + + #[test] + fn test_run_job_unknown_job_returns_error() { + let dir = TempDir::new().unwrap(); + let content = + make_workflow(" greet:\n steps:\n - name: hi\n run: echo hi\n"); + let repo = setup_trusted_repo(&dir, "ci.yaml", &content); + let err = run_workflow_job_sync(&repo, "ci.yaml", "nonexistent", 0, &repo).unwrap_err(); + assert!(err.contains("nonexistent")); + } + + #[test] + fn test_run_job_env_vars() { + let dir = TempDir::new().unwrap(); + let content = "name: Env Test\non:\n workflow_dispatch: {}\njobs:\n check:\n steps:\n - name: Check env\n run: test \"$MY_VAR\" = \"hello\"\n env:\n MY_VAR: hello\n"; + let repo = setup_trusted_repo(&dir, "env.yaml", content); + let result = run_workflow_job_sync(&repo, "env.yaml", "check", 0, &repo).unwrap(); + assert!(result.success); + } + + #[test] + fn test_run_workflow_runs_all_jobs() { + let dir = TempDir::new().unwrap(); + let content = "name: Multi\non:\n workflow_dispatch: {}\njobs:\n job1:\n steps:\n - name: s1\n run: echo a\n job2:\n steps:\n - name: s2\n run: echo b\n"; + let repo = setup_trusted_repo(&dir, "multi.yaml", content); + let results = run_workflow_sync(&repo, "multi.yaml", 0, &repo).unwrap(); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|r| r.success)); + } + + #[test] + fn test_run_job_captures_stdout_into_logs() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " greet:\n steps:\n - name: Say hi\n run: echo hello-from-step\n", + ); + let repo = setup_trusted_repo(&dir, "ci.yaml", &content); + run_workflow_job_sync(&repo, "ci.yaml", "greet", 0, &repo).unwrap(); + + let runs = list_workflow_runs_sync(&repo, 0, "ci.yaml", 10).unwrap(); + let logs = get_run_logs_sync(&repo, runs[0].id, "greet", &Default::default()).unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].body, "hello-from-step"); + } + + #[test] + fn test_run_job_captures_stderr_and_marks_error_level() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " boom:\n steps:\n - name: Fail loudly\n run: \"echo 'error: something broke' 1>&2; exit 1\"\n", + ); + let repo = setup_trusted_repo(&dir, "ci.yaml", &content); + run_workflow_job_sync(&repo, "ci.yaml", "boom", 0, &repo).unwrap(); + + let runs = list_workflow_runs_sync(&repo, 0, "ci.yaml", 10).unwrap(); + let logs = get_run_logs_sync(&repo, runs[0].id, "boom", &Default::default()).unwrap(); + assert_eq!(logs[0].stream, "stderr"); + assert_eq!(logs[0].severity_text, "ERROR"); + } + + #[test] + fn test_rerunning_creates_a_separate_run() { + let dir = TempDir::new().unwrap(); + let content = + make_workflow(" greet:\n steps:\n - name: Say hi\n run: echo hi\n"); + let repo = setup_trusted_repo(&dir, "ci.yaml", &content); + run_workflow_job_sync(&repo, "ci.yaml", "greet", 0, &repo).unwrap(); + run_workflow_job_sync(&repo, "ci.yaml", "greet", 0, &repo).unwrap(); + + let runs = list_workflow_runs_sync(&repo, 0, "ci.yaml", 10).unwrap(); + assert_eq!(runs.len(), 2); + // Newest first, and the older run's logs stay reachable. + assert!(runs[0].id > runs[1].id); + } + + #[test] + fn test_run_workflow_groups_all_jobs_into_one_run() { + let dir = TempDir::new().unwrap(); + let content = "name: Multi\non:\n workflow_dispatch: {}\njobs:\n job1:\n steps:\n - name: s1\n run: echo a\n job2:\n steps:\n - name: s2\n run: echo b\n"; + let repo = setup_trusted_repo(&dir, "multi.yaml", content); + run_workflow_sync(&repo, "multi.yaml", 0, &repo).unwrap(); + + let runs = list_workflow_runs_sync(&repo, 0, "multi.yaml", 10).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].jobs.len(), 2); + } + + #[test] + fn test_export_run_logs_writes_file() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " greet:\n steps:\n - name: Say hi\n run: echo exported-line\n", + ); + let repo = setup_trusted_repo(&dir, "ci.yaml", &content); + run_workflow_job_sync(&repo, "ci.yaml", "greet", 0, &repo).unwrap(); + + let runs = list_workflow_runs_sync(&repo, 0, "ci.yaml", 10).unwrap(); + let dest = dir.path().join("out.log").to_string_lossy().to_string(); + export_run_logs_sync(&repo, runs[0].id, "greet", &dest).unwrap(); + let contents = fs::read_to_string(&dest).unwrap(); + assert!(contents.contains("exported-line")); + } + + #[test] + fn test_validate_filename_rejects_path_separators() { + assert!(validate_filename("sub/dir/ci.yaml").is_err()); + assert!(validate_filename("../escape.yaml").is_err()); + assert!(validate_filename("ci.yaml").is_ok()); + assert!(validate_filename("ci.yml").is_ok()); + } + + #[test] + fn test_validate_working_directory_rejects_absolute_and_traversal() { + assert!(validate_working_directory("/absolute/path").is_err()); + assert!(validate_working_directory("../../etc").is_err()); + assert!(validate_working_directory("sub/dir").is_ok()); + assert!(validate_working_directory("frontend").is_ok()); + } +} diff --git a/src-tauri/src/core/checks_logs.rs b/src-tauri/src/core/checks_logs.rs new file mode 100644 index 00000000..ebf0f737 --- /dev/null +++ b/src-tauri/src/core/checks_logs.rs @@ -0,0 +1,1195 @@ +//! Log collection and querying for workflow check runs. +//! +//! Each job in a run streams its step output to a newline-delimited JSON file +//! under `.treq/runs/{run_id}/{job_id}.jsonl`. Records follow the OpenTelemetry +//! log data model, so a run's logs can be read by OTel-aware tooling without +//! translation, and the DuckDB views expose the standard field names. +//! +//! Reads go through DuckDB's `read_json_auto`, which lets the logs browser +//! filter and paginate without loading a whole run into memory. + +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +/// Describes the entity that generated the log, per OTel resource conventions. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Resource { + #[serde(rename = "service.name")] + pub service_name: String, + #[serde(rename = "service.version")] + pub service_version: String, +} + +impl Default for Resource { + fn default() -> Self { + Self { + service_name: "treq".to_string(), + service_version: env!("CARGO_PKG_VERSION").to_string(), + } + } +} + +/// The scope that emitted the log. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct InstrumentationScope { + pub name: String, + pub version: String, +} + +impl Default for InstrumentationScope { + fn default() -> Self { + Self { + name: "treq.checks".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + } + } +} + +/// Structured log body. OTel allows any value; a map keeps room to grow +/// without turning `body` into a bare string later. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Body { + pub message: String, +} + +/// Everything specific to treq lives here rather than as top-level fields, so +/// the record stays exactly the OTel log data model. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Attributes { + pub run_id: i64, + pub job_id: String, + pub step_index: i64, + pub step_name: String, + /// OTel's convention for which standard stream a line came from. + #[serde(rename = "log.iostream")] + pub log_iostream: String, +} + +/// One captured output line as an OpenTelemetry LogRecord. +/// +/// The field set is exactly the OTel log data model — no treq-specific columns +/// at the top level. `traceId`/`spanId` are derived from the run and job so a +/// run reads as a trace and each job as a span within it. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LogLine { + /// RFC3339 UTC with nanosecond precision: fixed width, so it sorts + /// correctly as text and still parses as a DuckDB TIMESTAMP. + pub timestamp: String, + pub observed_timestamp: String, + pub trace_id: String, + pub span_id: String, + /// W3C trace flags; bit 0 is "sampled". + pub trace_flags: u8, + pub severity_text: String, + pub severity_number: u8, + pub body: Body, + pub resource: Resource, + pub instrumentation_scope: InstrumentationScope, + pub attributes: Attributes, + /// Identifies the class of event these records represent. + pub event_name: String, +} + +/// Every record we emit is a line of step output. +const EVENT_NAME: &str = "check.step.output"; + +/// Marks records as sampled, the only trace-flag bit that applies here. +const TRACE_FLAGS_SAMPLED: u8 = 1; + +// ── Timestamps ─────────────────────────────────────────────────────────────── + +/// Current time as RFC3339 UTC with nanosecond precision. +pub fn now_timestamp() -> String { + format_timestamp( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0), + ) +} + +/// Render epoch nanoseconds as fixed-width RFC3339, so text ordering matches +/// chronological ordering and DuckDB still infers a TIMESTAMP. +pub fn format_timestamp(unix_nano: u64) -> String { + let secs = (unix_nano / 1_000_000_000) as i64; + let nanos = (unix_nano % 1_000_000_000) as u32; + chrono::DateTime::from_timestamp(secs, nanos) + .unwrap_or_default() + .format("%Y-%m-%dT%H:%M:%S%.9fZ") + .to_string() +} + +// ── OTel severity ──────────────────────────────────────────────────────────── + +/// UI-facing level names mapped to their OTel severity text. +pub fn severity_text_for_level(level: &str) -> &'static str { + match level { + "error" => "ERROR", + "warning" => "WARN", + _ => "INFO", + } +} + +/// OTel severity numbers: INFO=9, WARN=13, ERROR=17. +pub fn severity_number_for_text(severity_text: &str) -> u8 { + match severity_text { + "ERROR" => 17, + "WARN" => 13, + _ => 9, + } +} + +/// A run becomes a trace; ids are derived so they are stable and reproducible +/// rather than random, which keeps re-reads of a log file consistent. +pub fn trace_id_for_run(run_id: i64) -> String { + format!("{:032x}", run_id as u128) +} + +/// Each job becomes a span within its run's trace. +pub fn span_id_for_job(run_id: i64, job_id: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in job_id.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x1000_0000_01b3); + } + hash ^= run_id as u64; + format!("{:016x}", hash) +} + +/// Build an OTel record for one captured line. +#[allow(clippy::too_many_arguments)] +pub fn make_log_line( + timestamp: String, + run_id: i64, + job_id: &str, + step_index: i64, + step_name: &str, + stream: &str, + level: &str, + message: &str, +) -> LogLine { + let severity_text = severity_text_for_level(level); + LogLine { + observed_timestamp: timestamp.clone(), + timestamp, + trace_id: trace_id_for_run(run_id), + span_id: span_id_for_job(run_id, job_id), + trace_flags: TRACE_FLAGS_SAMPLED, + severity_text: severity_text.to_string(), + severity_number: severity_number_for_text(severity_text), + body: Body { + message: message.to_string(), + }, + resource: Resource::default(), + instrumentation_scope: InstrumentationScope::default(), + attributes: Attributes { + run_id, + job_id: job_id.to_string(), + step_index, + step_name: step_name.to_string(), + log_iostream: stream.to_string(), + }, + event_name: EVENT_NAME.to_string(), + } +} + +/// Filters accepted by the logs browser. +/// +/// `levels` is a set: an empty or absent list means "no level filter", matching +/// the multi-select showing nothing ticked. +#[derive(Debug, Deserialize, Default, Clone)] +pub struct LogQuery { + pub levels: Option>, + pub search: Option, + pub step_index: Option, + pub limit: Option, + pub offset: Option, +} + +/// Flattened projection of a stored record, for the UI only. +/// +/// This is the shape the browser renders; it is not what the `logs` view +/// exposes, which stays exactly the OTel field set. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct LogRecordView { + pub timestamp: String, + pub severity_number: u8, + pub severity_text: String, + pub body: String, + pub trace_id: String, + pub span_id: String, + pub run_id: i64, + pub job_id: String, + pub step_index: i64, + pub step_name: String, + pub stream: String, +} + +/// Result of an ad-hoc SQL query in the explorer. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SqlResult { + pub columns: Vec, + /// Every cell rendered as text so any column type survives the boundary. + pub rows: Vec>>, + pub row_count: usize, +} + +const DEFAULT_LIMIT: i64 = 2000; + +// ── Paths ──────────────────────────────────────────────────────────────────── + +/// Directory holding one run's per-job log files. +pub fn run_log_dir(repo_path: &str, run_id: i64) -> PathBuf { + Path::new(repo_path) + .join(".treq") + .join("runs") + .join(run_id.to_string()) +} + +/// Path of a single job's log file, relative to the repo root. +pub fn job_log_relative_path(run_id: i64, job_id: &str) -> String { + format!(".treq/runs/{}/{}.jsonl", run_id, sanitize_job_id(job_id)) +} + +/// Job IDs come from user-authored YAML, so keep them to a safe filename charset. +fn sanitize_job_id(job_id: &str) -> String { + job_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +// ── Level inference ────────────────────────────────────────────────────────── + +/// Classify a line by content. +/// +/// Deliberately not keyed on the stream: plenty of tools (cargo, npm) write +/// ordinary progress to stderr, so treating stderr as an error would paint +/// most of a healthy run red. +pub fn infer_level(message: &str) -> &'static str { + let lower = message.to_lowercase(); + + let error_markers = [ + "error", + "error:", + "fatal", + "panic", + "failed", + "failure", + "exception", + "traceback", + ]; + let warn_markers = ["warning", "warn:", "deprecated"]; + + if error_markers.iter().any(|m| lower.contains(m)) { + return "error"; + } + if warn_markers.iter().any(|m| lower.contains(m)) { + return "warning"; + } + "info" +} + +/// Remove ANSI SGR/CSI escape sequences so stored text renders cleanly. +pub fn strip_ansi(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + // Consume "[ ... " of a CSI sequence. + if chars.peek() == Some(&'[') { + chars.next(); + for c2 in chars.by_ref() { + if c2.is_ascii_alphabetic() { + break; + } + } + } + continue; + } + out.push(c); + } + out +} + +// ── Writing ────────────────────────────────────────────────────────────────── + +/// Append-only JSONL sink shared by a job's stdout and stderr reader threads. +#[derive(Clone)] +pub struct LogWriter { + inner: Arc>>, +} + +impl LogWriter { + pub fn create(repo_path: &str, run_id: i64, job_id: &str) -> Result { + let dir = run_log_dir(repo_path, run_id); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create log directory: {}", e))?; + let path = dir.join(format!("{}.jsonl", sanitize_job_id(job_id))); + let file = std::fs::File::create(&path) + .map_err(|e| format!("Failed to create log file: {}", e))?; + Ok(Self { + inner: Arc::new(Mutex::new(std::io::BufWriter::new(file))), + }) + } + + pub fn write_line(&self, line: &LogLine) -> Result<(), String> { + let json = serde_json::to_string(line) + .map_err(|e| format!("Failed to serialize log line: {}", e))?; + let mut guard = self + .inner + .lock() + .map_err(|_| "Log writer mutex poisoned".to_string())?; + writeln!(guard, "{}", json).map_err(|e| format!("Failed to write log line: {}", e)) + } + + pub fn flush(&self) -> Result<(), String> { + let mut guard = self + .inner + .lock() + .map_err(|_| "Log writer mutex poisoned".to_string())?; + guard + .flush() + .map_err(|e| format!("Failed to flush log file: {}", e)) + } +} +// ── Querying (DuckDB) ──────────────────────────────────────────────────────── + +/// Escape a path for embedding in a DuckDB single-quoted string literal. +fn sql_quote(value: &str) -> String { + value.replace('\'', "''") +} + +/// Projection feeding the UI's flat row struct. +/// +/// This is internal plumbing, not the `logs` view: the view exposes only the +/// OTel field set, so treq-specific values are read out of `attributes` here. +const UI_PROJECTION: &str = "SELECT + CAST(timestamp AS VARCHAR) AS timestamp, + CAST(severityNumber AS INTEGER) AS severity_number, + CAST(severityText AS VARCHAR) AS severity_text, + CAST(body.message AS VARCHAR) AS body, + CAST(traceId AS VARCHAR) AS trace_id, + CAST(spanId AS VARCHAR) AS span_id, + CAST(attributes.run_id AS BIGINT) AS run_id, + CAST(attributes.job_id AS VARCHAR) AS job_id, + CAST(attributes.step_index AS BIGINT) AS step_index, + CAST(attributes.step_name AS VARCHAR) AS step_name, + CAST(attributes['log.iostream'] AS VARCHAR) AS stream"; + +/// Shared WHERE builder for the single-job and cross-run readers. +/// +/// `levels` carries UI names (info/warning/error); they are translated to OTel +/// severity text here so the stored records stay standard. +fn build_where_clause(query: &LogQuery) -> String { + let mut conditions: Vec = Vec::new(); + + if let Some(levels) = query.levels.as_ref().filter(|l| !l.is_empty()) { + let list = levels + .iter() + .map(|l| format!("'{}'", sql_quote(severity_text_for_level(l)))) + .collect::>() + .join(", "); + conditions.push(format!("severityText IN ({})", list)); + } + if let Some(search) = query.search.as_ref().filter(|s| !s.is_empty()) { + conditions.push(format!( + "lower(body.message) LIKE '%{}%'", + sql_quote(&search.to_lowercase()) + )); + } + if let Some(step_index) = query.step_index { + conditions.push(format!("attributes.step_index = {}", step_index)); + } + + if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + } +} + +/// Map a projected row onto the flat view struct. +fn row_to_record(row: &duckdb::Row<'_>) -> duckdb::Result { + Ok(LogRecordView { + timestamp: row.get(0)?, + severity_number: row.get::<_, i32>(1)? as u8, + severity_text: row.get(2)?, + body: row.get(3)?, + trace_id: row.get(4)?, + span_id: row.get(5)?, + run_id: row.get(6)?, + job_id: row.get(7)?, + step_index: row.get(8)?, + step_name: row.get(9)?, + stream: row.get(10)?, + }) +} + +/// Read a job's log file, applying the browser's filters. +/// +/// Returns an empty vec when the file is missing or empty — a job that produced +/// no output is a normal state, not an error. +pub fn query_logs(absolute_log_path: &str, query: &LogQuery) -> Result, String> { + let path = Path::new(absolute_log_path); + match std::fs::metadata(path) { + Ok(meta) if meta.len() == 0 => return Ok(vec![]), + Ok(_) => {} + Err(_) => return Ok(vec![]), + } + + let conn = duckdb::Connection::open_in_memory() + .map_err(|e| format!("Failed to open DuckDB connection: {}", e))?; + + let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, 100_000); + let offset = query.offset.unwrap_or(0).max(0); + let sql = format!( + "SELECT * FROM ({} FROM read_json_auto('{}', format='newline_delimited') {}) AS records + ORDER BY timestamp, step_index LIMIT {} OFFSET {}", + UI_PROJECTION, + sql_quote(absolute_log_path), + build_where_clause(query), + limit, + offset + ); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare log query: {}", e))?; + let rows = stmt + .query_map([], row_to_record) + .map_err(|e| format!("Failed to query logs: {}", e))?; + + rows.collect::>>() + .map_err(|e| format!("Failed to read log rows: {}", e)) +} + +// ── Repo-wide data source ──────────────────────────────────────────────────── + +/// Glob covering every job log in the repo. +fn runs_glob(repo_path: &str) -> String { + Path::new(repo_path) + .join(".treq") + .join("runs") + .join("*") + .join("*.jsonl") + .to_string_lossy() + .to_string() +} + +/// True when at least one log file exists, so callers can skip DuckDB entirely. +fn has_any_logs(repo_path: &str) -> bool { + let runs_dir = Path::new(repo_path).join(".treq").join("runs"); + let Ok(entries) = std::fs::read_dir(&runs_dir) else { + return false; + }; + entries.filter_map(|e| e.ok()).any(|run_dir| { + std::fs::read_dir(run_dir.path()) + .map(|mut files| { + files.any(|f| { + f.ok() + .map(|f| f.file_name().to_string_lossy().ends_with(".jsonl")) + .unwrap_or(false) + }) + }) + .unwrap_or(false) + }) +} + +/// SQL defining the `logs` view. +/// +/// Deliberately a passthrough: the view exposes exactly the OpenTelemetry log +/// fields as stored, with no treq-specific columns bolted on. Anything +/// treq-specific is reachable through `attributes`. +fn logs_view_sql(repo_path: &str) -> String { + format!( + "CREATE OR REPLACE VIEW logs AS + SELECT timestamp, observedTimestamp, traceId, spanId, traceFlags, + severityText, severityNumber, body, resource, + instrumentationScope, attributes, eventName + FROM read_json_auto('{}', format='newline_delimited')", + sql_quote(&runs_glob(repo_path)) + ) +} + +/// Open an in-memory DuckDB with the `logs` view registered. +fn connect_with_logs_view(repo_path: &str) -> Result { + let conn = duckdb::Connection::open_in_memory() + .map_err(|e| format!("Failed to open DuckDB connection: {}", e))?; + conn.execute_batch(&logs_view_sql(repo_path)) + .map_err(|e| format!("Failed to register logs view: {}", e))?; + Ok(conn) +} + +/// Browse log records across every run in the repo. +pub fn query_repo_logs(repo_path: &str, query: &LogQuery) -> Result, String> { + if !has_any_logs(repo_path) { + return Ok(vec![]); + } + let conn = connect_with_logs_view(repo_path)?; + + let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, 100_000); + let offset = query.offset.unwrap_or(0).max(0); + let sql = format!( + "SELECT * FROM ({} FROM logs {}) AS records + ORDER BY run_id DESC, timestamp, step_index LIMIT {} OFFSET {}", + UI_PROJECTION, + build_where_clause(query), + limit, + offset + ); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare repo log query: {}", e))?; + let rows = stmt + .query_map([], row_to_record) + .map_err(|e| format!("Failed to query repo logs: {}", e))?; + + rows.collect::>>() + .map_err(|e| format!("Failed to read repo log rows: {}", e)) +} + +// ── Timeseries ─────────────────────────────────────────────────────────────── + +/// One chart bucket: a time slot and how many records of each severity landed +/// in it. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct LogBucket { + pub bucket: String, + pub severity_text: String, + pub count: i64, +} + +/// Count records per time bucket and severity, for the chart above the feed. +/// +/// The bucket width is caller-supplied because the useful resolution depends on +/// how much history is on screen. +pub fn query_log_timeseries( + repo_path: &str, + query: &LogQuery, + bucket_seconds: i64, +) -> Result, String> { + if !has_any_logs(repo_path) { + return Ok(vec![]); + } + let conn = connect_with_logs_view(repo_path)?; + let width = bucket_seconds.clamp(1, 86_400); + + // Truncate (not round) to the bucket: CAST to BIGINT rounds to the + // nearest integer in DuckDB, which would push a .5s+ timestamp into the + // next bucket. floor() truncates toward the earlier bucket instead. + let sql = format!( + "SELECT strftime( + to_timestamp(floor(epoch(CAST(timestamp AS TIMESTAMP)) / {w}) * {w}), + '%Y-%m-%dT%H:%M:%SZ') AS bucket, + severityText AS severity_text, + count(*) AS n + FROM logs {} + GROUP BY bucket, severity_text + ORDER BY bucket, severity_text", + build_where_clause(query), + w = width + ); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare timeseries query: {}", e))?; + let rows = stmt + .query_map([], |row| { + Ok(LogBucket { + bucket: row.get(0)?, + severity_text: row.get(1)?, + count: row.get(2)?, + }) + }) + .map_err(|e| format!("Failed to query timeseries: {}", e))?; + + rows.collect::>>() + .map_err(|e| format!("Failed to read timeseries rows: {}", e)) +} + +// ── Logs explorer ──────────────────────────────────────────────────────────── + +/// Statement kinds the explorer will run. Everything else is rejected so a +/// stray COPY/ATTACH/INSTALL can't write files or pull in extensions. +const ALLOWED_SQL_PREFIXES: [&str; 6] = + ["select", "with", "describe", "show", "explain", "summarize"]; + +/// Reject anything that isn't a single read-only statement. +fn validate_sql(sql: &str) -> Result<(), String> { + let trimmed = sql.trim().trim_end_matches(';').trim(); + if trimmed.is_empty() { + return Err("Query is empty".to_string()); + } + // One statement only: a second one could smuggle in a write. + if trimmed.contains(';') { + return Err("Only a single statement can be run at a time".to_string()); + } + + let lower = trimmed.to_lowercase(); + if !ALLOWED_SQL_PREFIXES + .iter() + .any(|p| lower.starts_with(p) && lower[p.len()..].starts_with(char::is_whitespace)) + { + return Err( + "Only read-only queries are allowed (SELECT, WITH, DESCRIBE, SHOW, EXPLAIN, SUMMARIZE)" + .to_string(), + ); + } + + // These can still appear mid-statement, e.g. inside a CTE body. + let blocked = [ + "attach ", "copy ", "install ", "load ", "export ", "import ", "create ", "insert ", + "update ", "delete ", "drop ", "alter ", + ]; + if let Some(word) = blocked.iter().find(|w| lower.contains(*w)) { + return Err(format!( + "Statement contains a disallowed keyword: {}", + word.trim() + )); + } + Ok(()) +} + +/// Run an ad-hoc read-only query against the `logs` view. +pub fn run_logs_sql(repo_path: &str, sql: &str, max_rows: i64) -> Result { + validate_sql(sql)?; + + if !has_any_logs(repo_path) { + return Err( + "No check logs recorded yet — run a workflow check to populate the logs table." + .to_string(), + ); + } + + let conn = connect_with_logs_view(repo_path)?; + let capped = format!( + "SELECT * FROM ({}) AS explorer_query LIMIT {}", + sql.trim().trim_end_matches(';'), + max_rows.clamp(1, 10_000) + ); + + let mut stmt = conn + .prepare(&capped) + .map_err(|e| format!("Query error: {}", e))?; + let mut rows = stmt.query([]).map_err(|e| format!("Query error: {}", e))?; + + let mut columns: Vec = Vec::new(); + let mut out_rows: Vec>> = Vec::new(); + + while let Some(row) = rows.next().map_err(|e| format!("Query error: {}", e))? { + if columns.is_empty() { + columns = row + .as_ref() + .column_names() + .into_iter() + .map(String::from) + .collect(); + } + let mut cells = Vec::with_capacity(columns.len()); + for idx in 0..columns.len() { + // Everything is stringified; the grid renders text regardless of type. + let value: Option = row + .get::<_, Option>(idx) + .or_else(|_| { + row.get::<_, Option>(idx) + .map(|v| v.map(|n| n.to_string())) + }) + .or_else(|_| { + row.get::<_, Option>(idx) + .map(|v| v.map(|n| n.to_string())) + }) + .or_else(|_| { + row.get::<_, Option>(idx) + .map(|v| v.map(|b| b.to_string())) + }) + .unwrap_or(None); + cells.push(value); + } + out_rows.push(cells); + } + + // A zero-row result still needs its header, which the loop above never saw. + if columns.is_empty() { + columns = stmt.column_names().into_iter().map(String::from).collect(); + } + + let row_count = out_rows.len(); + Ok(SqlResult { + columns, + rows: out_rows, + row_count, + }) +} + +/// Render a job's logs as a plain text file suitable for sharing. +pub fn export_logs(absolute_log_path: &str, dest_path: &str) -> Result { + let lines = query_logs( + absolute_log_path, + &LogQuery { + limit: Some(100_000), + ..Default::default() + }, + )?; + + let mut out = String::new(); + for line in &lines { + out.push_str(&format!( + "{} [{}] {}\n", + line.timestamp, line.severity_text, line.body + )); + } + + if let Some(parent) = Path::new(dest_path).parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create export directory: {}", e))?; + } + std::fs::write(dest_path, out).map_err(|e| format!("Failed to write export file: {}", e))?; + Ok(dest_path.to_string()) +} + +// ── Unit tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + /// Fixed epoch base so bucketing assertions stay deterministic. + const BASE_NANOS: u64 = 1_785_000_000_000_000_000; + + fn sample_line(step_index: i64, level: &str, message: &str) -> LogLine { + sample_line_at( + BASE_NANOS + (step_index as u64) * 1_000_000_000, + step_index, + level, + message, + ) + } + + fn sample_line_at(nanos: u64, step_index: i64, level: &str, message: &str) -> LogLine { + make_log_line( + format_timestamp(nanos), + 1, + "job", + step_index, + &format!("step {}", step_index), + "stdout", + level, + message, + ) + } + + fn write_log(dir: &TempDir, lines: &[LogLine]) -> String { + let writer = LogWriter::create(&dir.path().to_string_lossy(), 1, "job").unwrap(); + for line in lines { + writer.write_line(line).unwrap(); + } + writer.flush().unwrap(); + dir.path() + .join(".treq/runs/1/job.jsonl") + .to_string_lossy() + .to_string() + } + + #[test] + fn test_infer_level_classifies_errors_and_warnings() { + assert_eq!(infer_level("error: build failed"), "error"); + assert_eq!(infer_level("warning: unused variable"), "warning"); + assert_eq!(infer_level("Compiling treq v0.1.3"), "info"); + } + + #[test] + fn test_strip_ansi_removes_color_codes() { + assert_eq!(strip_ansi("\u{1b}[31mred\u{1b}[0m text"), "red text"); + assert_eq!(strip_ansi("plain text"), "plain text"); + } + + #[test] + fn test_sanitize_job_id_replaces_path_characters() { + assert_eq!(sanitize_job_id("../escape"), "___escape"); + assert_eq!(sanitize_job_id("build-job_1"), "build-job_1"); + } + + #[test] + fn test_query_logs_returns_empty_for_missing_file() { + let result = query_logs("/nonexistent/path/logs.jsonl", &LogQuery::default()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_query_logs_reads_written_lines() { + let dir = TempDir::new().unwrap(); + let path = write_log( + &dir, + &[ + sample_line(0, "info", "hello"), + sample_line(1, "error", "boom"), + ], + ); + let result = query_logs(&path, &LogQuery::default()).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].body, "hello"); + } + + #[test] + fn test_query_logs_filters_by_level() { + let dir = TempDir::new().unwrap(); + let path = write_log( + &dir, + &[ + sample_line(0, "info", "hello"), + sample_line(1, "error", "boom"), + ], + ); + let result = query_logs( + &path, + &LogQuery { + levels: Some(vec!["error".to_string()]), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].body, "boom"); + } + + #[test] + fn test_query_logs_filters_by_search_and_step() { + let dir = TempDir::new().unwrap(); + let path = write_log( + &dir, + &[ + sample_line(0, "info", "compiling crate"), + sample_line(1, "info", "linking binary"), + ], + ); + let by_search = query_logs( + &path, + &LogQuery { + search: Some("LINKING".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(by_search.len(), 1); + + let by_step = query_logs( + &path, + &LogQuery { + step_index: Some(0), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(by_step.len(), 1); + } + + #[test] + fn test_query_logs_filters_by_multiple_levels() { + let dir = TempDir::new().unwrap(); + let path = write_log( + &dir, + &[ + sample_line(0, "info", "hello"), + sample_line(1, "warning", "careful"), + sample_line(2, "error", "boom"), + ], + ); + let result = query_logs( + &path, + &LogQuery { + levels: Some(vec!["warning".to_string(), "error".to_string()]), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(result.len(), 2); + assert!(result.iter().all(|l| l.severity_text != "INFO")); + } + + #[test] + fn test_empty_levels_list_does_not_filter() { + let dir = TempDir::new().unwrap(); + let path = write_log( + &dir, + &[ + sample_line(0, "info", "hello"), + sample_line(1, "error", "boom"), + ], + ); + let result = query_logs( + &path, + &LogQuery { + levels: Some(vec![]), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_query_repo_logs_spans_runs_with_ids() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().to_string_lossy().to_string(); + for run_id in [1i64, 2i64] { + let writer = LogWriter::create(&repo, run_id, "build").unwrap(); + writer + .write_line(&make_log_line( + format_timestamp(BASE_NANOS), + run_id, + "build", + 0, + "step 0", + "stdout", + "info", + &format!("run {}", run_id), + )) + .unwrap(); + writer.flush().unwrap(); + } + + let result = query_repo_logs(&repo, &LogQuery::default()).unwrap(); + assert_eq!(result.len(), 2); + // Newest run first, and run/job identity is recovered from the path. + assert_eq!(result[0].run_id, 2); + assert_eq!(result[0].job_id, "build"); + } + + #[test] + fn test_query_repo_logs_empty_without_runs() { + let dir = TempDir::new().unwrap(); + let result = query_repo_logs(&dir.path().to_string_lossy(), &LogQuery::default()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_validate_sql_rejects_writes_and_multiple_statements() { + assert!(validate_sql("SELECT * FROM logs").is_ok()); + assert!(validate_sql("WITH x AS (SELECT 1) SELECT * FROM x").is_ok()); + assert!(validate_sql("DROP TABLE logs").is_err()); + assert!(validate_sql("SELECT 1; DROP TABLE logs").is_err()); + assert!(validate_sql("COPY logs TO '/tmp/out.csv'").is_err()); + assert!(validate_sql("").is_err()); + } + + #[test] + fn test_run_logs_sql_returns_columns_and_rows() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().to_string_lossy().to_string(); + let writer = LogWriter::create(&repo, 1, "build").unwrap(); + writer.write_line(&sample_line(0, "error", "boom")).unwrap(); + writer.write_line(&sample_line(1, "info", "fine")).unwrap(); + writer.flush().unwrap(); + + let result = run_logs_sql( + &repo, + "SELECT severityText, count(*) AS n FROM logs GROUP BY severityText ORDER BY severityText", + 100, + ) + .unwrap(); + assert_eq!(result.columns, vec!["severityText", "n"]); + assert_eq!(result.row_count, 2); + } + + #[test] + fn test_run_logs_sql_rejects_disallowed_statement() { + let dir = TempDir::new().unwrap(); + let err = run_logs_sql(&dir.path().to_string_lossy(), "DELETE FROM logs", 100).unwrap_err(); + assert!(err.contains("read-only")); + } + + #[test] + fn test_records_are_written_in_otel_shape() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().to_string_lossy().to_string(); + let writer = LogWriter::create(&repo, 7, "build").unwrap(); + writer + .write_line(&make_log_line( + format_timestamp(BASE_NANOS), + 7, + "build", + 2, + "Compile", + "stderr", + "error", + "boom", + )) + .unwrap(); + writer.flush().unwrap(); + + let raw = std::fs::read_to_string(dir.path().join(".treq/runs/7/build.jsonl")).unwrap(); + let json: serde_json::Value = serde_json::from_str(raw.trim()).unwrap(); + + // Exactly the OTel log data model, camelCased, and nothing else. + let mut keys: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(|k| k.as_str()) + .collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "attributes", + "body", + "eventName", + "instrumentationScope", + "observedTimestamp", + "resource", + "severityNumber", + "severityText", + "spanId", + "timestamp", + "traceFlags", + "traceId", + ] + ); + + assert_eq!(json["severityText"], "ERROR"); + assert_eq!(json["severityNumber"], 17); + assert_eq!(json["body"]["message"], "boom"); + assert_eq!(json["eventName"], "check.step.output"); + assert_eq!(json["traceFlags"], 1); + assert_eq!(json["resource"]["service.name"], "treq"); + assert_eq!(json["instrumentationScope"]["name"], "treq.checks"); + assert_eq!(json["attributes"]["run_id"], 7); + assert_eq!(json["attributes"]["job_id"], "build"); + assert_eq!(json["attributes"]["step_index"], 2); + assert_eq!(json["attributes"]["log.iostream"], "stderr"); + assert_eq!(json["traceId"].as_str().unwrap().len(), 32); + assert_eq!(json["spanId"].as_str().unwrap().len(), 16); + assert_eq!(json["timestamp"], json["observedTimestamp"]); + assert!(json["timestamp"].as_str().unwrap().ends_with("Z")); + } + + #[test] + fn test_severity_mapping_covers_all_levels() { + assert_eq!(severity_text_for_level("info"), "INFO"); + assert_eq!(severity_text_for_level("warning"), "WARN"); + assert_eq!(severity_text_for_level("error"), "ERROR"); + assert_eq!(severity_number_for_text("INFO"), 9); + assert_eq!(severity_number_for_text("WARN"), 13); + assert_eq!(severity_number_for_text("ERROR"), 17); + } + + #[test] + fn test_trace_id_is_per_run_and_span_id_per_job() { + assert_eq!(trace_id_for_run(1), trace_id_for_run(1)); + assert_ne!(trace_id_for_run(1), trace_id_for_run(2)); + assert_ne!(span_id_for_job(1, "build"), span_id_for_job(1, "test")); + } + + #[test] + fn test_query_log_timeseries_buckets_by_severity() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().to_string_lossy().to_string(); + let writer = LogWriter::create(&repo, 1, "build").unwrap(); + // Two errors in the first second, one info in the next. + writer + .write_line(&sample_line_at(BASE_NANOS, 0, "error", "a")) + .unwrap(); + writer + .write_line(&sample_line_at(BASE_NANOS + 1, 0, "error", "b")) + .unwrap(); + writer + .write_line(&sample_line_at(BASE_NANOS + 2_000_000_000, 1, "info", "c")) + .unwrap(); + writer.flush().unwrap(); + + let buckets = query_log_timeseries(&repo, &LogQuery::default(), 1).unwrap(); + let errors: i64 = buckets + .iter() + .filter(|b| b.severity_text == "ERROR") + .map(|b| b.count) + .sum(); + let infos: i64 = buckets + .iter() + .filter(|b| b.severity_text == "INFO") + .map(|b| b.count) + .sum(); + assert_eq!(errors, 2); + assert_eq!(infos, 1); + // The two errors share a bucket, the info lands in a later one. + assert!(buckets.len() >= 2); + } + + #[test] + fn test_timeseries_truncates_fractional_seconds_not_rounds() { + // Both lines fall in the same integer second, but one is past the + // half-second mark. A rounding bucket (rather than a floor) would push + // it into the next second and split what should be one bucket in two. + let dir = TempDir::new().unwrap(); + let repo = dir.path().to_string_lossy().to_string(); + let writer = LogWriter::create(&repo, 1, "build").unwrap(); + writer + .write_line(&sample_line_at(BASE_NANOS + 100_000_000, 0, "info", "a")) + .unwrap(); + writer + .write_line(&sample_line_at(BASE_NANOS + 700_000_000, 0, "info", "b")) + .unwrap(); + writer.flush().unwrap(); + + let buckets = query_log_timeseries(&repo, &LogQuery::default(), 1).unwrap(); + assert_eq!(buckets.len(), 1); + assert_eq!(buckets[0].count, 2); + } + + #[test] + fn test_timeseries_empty_without_runs() { + let dir = TempDir::new().unwrap(); + let buckets = + query_log_timeseries(&dir.path().to_string_lossy(), &LogQuery::default(), 1).unwrap(); + assert!(buckets.is_empty()); + } + + #[test] + fn test_logs_view_exposes_otel_columns() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().to_string_lossy().to_string(); + let writer = LogWriter::create(&repo, 1, "build").unwrap(); + writer.write_line(&sample_line(0, "info", "hello")).unwrap(); + writer.flush().unwrap(); + + let result = run_logs_sql( + &repo, + "SELECT timestamp, severityText, body.message AS message, attributes.run_id AS run_id FROM logs", + 10, + ) + .unwrap(); + assert_eq!( + result.columns, + vec!["timestamp", "severityText", "message", "run_id"] + ); + assert_eq!(result.row_count, 1); + } + + #[test] + fn test_export_logs_writes_plain_text() { + let dir = TempDir::new().unwrap(); + let path = write_log( + &dir, + &[ + sample_line(0, "info", "hello"), + sample_line(1, "error", "boom"), + ], + ); + let dest = dir.path().join("out.log").to_string_lossy().to_string(); + export_logs(&path, &dest).unwrap(); + let contents = std::fs::read_to_string(&dest).unwrap(); + assert!(contents.contains("[ERROR] boom")); + assert_eq!(contents.lines().count(), 2); + } +} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index a0f62307..e1a9d364 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,10 +1,14 @@ pub mod app; pub mod changes; +pub mod checks; +pub mod checks_logs; pub mod commits; pub mod repo; pub mod workspaces; pub use app::*; pub use changes::*; +pub use checks::*; +pub use checks_logs::*; pub use commits::*; pub use repo::*; use std::path::{Path, PathBuf}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca6acddb..041491d9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -523,6 +523,17 @@ pub fn run() { commands::gh_set_pr_draft, commands::gh_create_pr, commands::gh_list_pr_review_threads, + commands::list_workflows, + commands::run_workflow_job, + commands::run_workflow, + commands::is_repo_trusted, + commands::trust_repo, + commands::list_workflow_runs, + commands::get_run_logs, + commands::export_run_logs, + commands::get_repo_logs, + commands::run_logs_sql, + commands::get_log_timeseries, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/local_db.rs b/src-tauri/src/local_db.rs index baf44659..059c0f17 100644 --- a/src-tauri/src/local_db.rs +++ b/src-tauri/src/local_db.rs @@ -346,6 +346,71 @@ pub fn init_local_db(repo_path: &str) -> Result { ) .map_err(|e| format!("Failed to create instance_registry heartbeat index: {}", e))?; + // Migration: replace the old per-job workflow_runs shape with per-invocation. + let has_legacy_workflow_runs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('workflow_runs') WHERE name = 'job_id'", + [], + |row| row.get(0), + ) + .unwrap_or(0); + if has_legacy_workflow_runs > 0 { + conn.execute("DROP TABLE workflow_runs", []) + .map_err(|e| format!("Failed to drop legacy workflow_runs table: {}", e))?; + } + + conn.execute( + "CREATE TABLE IF NOT EXISTS workflow_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER, + filename TEXT NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT + )", + [], + ) + .map_err(|e| format!("Failed to create workflow_runs table: {}", e))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_workflow_runs_lookup + ON workflow_runs(workspace_id, filename, id DESC)", + [], + ) + .map_err(|e| format!("Failed to create workflow_runs index: {}", e))?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS workflow_job_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + job_id TEXT NOT NULL, + position INTEGER NOT NULL, + status TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + steps_json TEXT NOT NULL, + log_path TEXT + )", + [], + ) + .map_err(|e| format!("Failed to create workflow_job_results table: {}", e))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_workflow_job_results_run + ON workflow_job_results(run_id, position)", + [], + ) + .map_err(|e| format!("Failed to create workflow_job_results index: {}", e))?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS repo_trust ( + id INTEGER PRIMARY KEY, + trusted_at TEXT NOT NULL + )", + [], + ) + .map_err(|e| format!("Failed to create repo_trust table: {}", e))?; + // Migration: rename pending_reviews columns from old schema to new schema. let has_old_columns: Result = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('pending_reviews') WHERE name IN ('comments_json', 'overall_comment', 'viewed_files_json')", @@ -2353,3 +2418,186 @@ mod tests { } } } + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorkflowRunRecord { + pub id: i64, + pub filename: String, + pub status: String, + pub started_at: String, + pub completed_at: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorkflowJobRecord { + pub id: i64, + pub run_id: i64, + pub job_id: String, + pub position: i64, + pub status: String, + pub started_at: Option, + pub completed_at: Option, + pub steps_json: String, + pub log_path: Option, +} + +/// Opens a new run row in the `running` state; jobs are attached as they finish. +pub fn create_workflow_run( + repo_path: &str, + workspace_id: i64, + filename: &str, +) -> Result { + let conn = get_connection(repo_path)?; + let started_at = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO workflow_runs (workspace_id, filename, status, started_at) + VALUES (?1, ?2, 'running', ?3)", + params![workspace_id, filename, started_at], + ) + .map_err(|e| format!("Failed to insert workflow_run: {}", e))?; + Ok(conn.last_insert_rowid()) +} + +pub fn add_workflow_job_result( + repo_path: &str, + run_id: i64, + job_id: &str, + position: i64, + status: &str, + started_at: &str, + completed_at: &str, + steps_json: &str, + log_path: Option<&str>, +) -> Result { + let conn = get_connection(repo_path)?; + conn.execute( + "INSERT INTO workflow_job_results + (run_id, job_id, position, status, started_at, completed_at, steps_json, log_path) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + run_id, + job_id, + position, + status, + started_at, + completed_at, + steps_json, + log_path + ], + ) + .map_err(|e| format!("Failed to insert workflow_job_result: {}", e))?; + Ok(conn.last_insert_rowid()) +} + +pub fn finish_workflow_run(repo_path: &str, run_id: i64, status: &str) -> Result<(), String> { + let conn = get_connection(repo_path)?; + let completed_at = Utc::now().to_rfc3339(); + conn.execute( + "UPDATE workflow_runs SET status = ?1, completed_at = ?2 WHERE id = ?3", + params![status, completed_at, run_id], + ) + .map_err(|e| format!("Failed to finish workflow_run: {}", e))?; + Ok(()) +} + +pub fn list_workflow_runs( + repo_path: &str, + workspace_id: i64, + filename: &str, + limit: i64, +) -> Result, String> { + let conn = get_connection(repo_path)?; + let mut stmt = conn + .prepare( + "SELECT id, filename, status, started_at, completed_at + FROM workflow_runs + WHERE workspace_id = ?1 AND filename = ?2 + ORDER BY id DESC LIMIT ?3", + ) + .map_err(|e| format!("Failed to prepare workflow_runs query: {}", e))?; + let rows = stmt + .query_map(params![workspace_id, filename, limit], |row| { + Ok(WorkflowRunRecord { + id: row.get(0)?, + filename: row.get(1)?, + status: row.get(2)?, + started_at: row.get(3)?, + completed_at: row.get(4)?, + }) + }) + .map_err(|e| format!("Failed to query workflow_runs: {}", e))?; + rows.collect::>>() + .map_err(|e| format!("Failed to read workflow_runs: {}", e)) +} + +pub fn list_workflow_job_results( + repo_path: &str, + run_id: i64, +) -> Result, String> { + let conn = get_connection(repo_path)?; + let mut stmt = conn + .prepare( + "SELECT id, run_id, job_id, position, status, started_at, completed_at, + steps_json, log_path + FROM workflow_job_results + WHERE run_id = ?1 + ORDER BY position ASC", + ) + .map_err(|e| format!("Failed to prepare job results query: {}", e))?; + let rows = stmt + .query_map(params![run_id], |row| { + Ok(WorkflowJobRecord { + id: row.get(0)?, + run_id: row.get(1)?, + job_id: row.get(2)?, + position: row.get(3)?, + status: row.get(4)?, + started_at: row.get(5)?, + completed_at: row.get(6)?, + steps_json: row.get(7)?, + log_path: row.get(8)?, + }) + }) + .map_err(|e| format!("Failed to query job results: {}", e))?; + rows.collect::>>() + .map_err(|e| format!("Failed to read job results: {}", e)) +} + +pub fn get_job_log_path( + repo_path: &str, + run_id: i64, + job_id: &str, +) -> Result, String> { + let conn = get_connection(repo_path)?; + conn.query_row( + "SELECT log_path FROM workflow_job_results WHERE run_id = ?1 AND job_id = ?2", + params![run_id, job_id], + |row| row.get::<_, Option>(0), + ) + .optional() + .map(|opt| opt.flatten()) + .map_err(|e| format!("Failed to query job log path: {}", e)) +} + +pub fn is_repo_trusted(repo_path: &str) -> bool { + get_connection(repo_path) + .and_then(|conn| { + conn.query_row("SELECT COUNT(*) FROM repo_trust", [], |row| { + row.get::<_, i64>(0) + }) + .map_err(|e| format!("Failed to query repo_trust: {}", e)) + }) + .map(|count| count > 0) + .unwrap_or(false) +} + +pub fn trust_repo(repo_path: &str) -> Result<(), String> { + let conn = get_connection(repo_path)?; + let trusted_at = Utc::now().to_rfc3339(); + conn.execute( + "INSERT OR IGNORE INTO repo_trust (id, trusted_at) VALUES (1, ?1)", + params![trusted_at], + ) + .map_err(|e| format!("Failed to trust repo: {}", e))?; + Ok(()) +} diff --git a/src-tauri/tests/checks_test.rs b/src-tauri/tests/checks_test.rs new file mode 100644 index 00000000..8ecad151 --- /dev/null +++ b/src-tauri/tests/checks_test.rs @@ -0,0 +1,89 @@ +mod e2e_test_helpers; + +use e2e_test_helpers::{TestRepo, FAILING_WORKFLOW, PASSING_WORKFLOW}; +use treq_lib::core; + +#[test] +fn test_list_workflows_empty_for_fresh_repo() { + let repo = TestRepo::new().expect("Failed to create test repo"); + let result = core::list_workflows_sync(&repo.repo_path).expect("Failed to list workflows"); + assert!(result.is_empty()); +} + +#[test] +fn test_list_workflows_sees_yaml_file() { + let repo = TestRepo::new().expect("Failed to create test repo"); + repo.write_workflow("ci.yaml", PASSING_WORKFLOW) + .expect("Failed to write workflow"); + let result = core::list_workflows_sync(&repo.repo_path).expect("Failed to list workflows"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "Passing CI"); +} + +#[test] +fn test_list_workflows_multiple_files_sorted() { + let repo = TestRepo::new().expect("Failed to create test repo"); + repo.write_workflow("b.yaml", PASSING_WORKFLOW) + .expect("Failed to write b.yaml"); + repo.write_workflow("a.yaml", FAILING_WORKFLOW) + .expect("Failed to write a.yaml"); + let result = core::list_workflows_sync(&repo.repo_path).expect("Failed to list workflows"); + assert_eq!(result.len(), 2); + assert!(result[0].filename < result[1].filename); +} + +#[test] +fn test_run_workflow_job_success() { + let repo = TestRepo::new().expect("Failed to create test repo"); + repo.write_workflow("ci.yaml", PASSING_WORKFLOW) + .expect("Failed to write workflow"); + treq_lib::local_db::trust_repo(&repo.repo_path).expect("Failed to trust repo"); + let result = + core::run_workflow_job_sync(&repo.repo_path, "ci.yaml", "greet", 0, &repo.repo_path) + .expect("Failed to run job"); + assert!(result.success); + assert!(!result.steps.is_empty()); +} + +#[test] +fn test_run_workflow_job_stops_at_first_failure() { + let repo = TestRepo::new().expect("Failed to create test repo"); + repo.write_workflow("ci.yaml", FAILING_WORKFLOW) + .expect("Failed to write workflow"); + treq_lib::local_db::trust_repo(&repo.repo_path).expect("Failed to trust repo"); + let result = + core::run_workflow_job_sync(&repo.repo_path, "ci.yaml", "check", 0, &repo.repo_path) + .expect("Failed to run job"); + assert!(!result.success); + assert_eq!(result.steps.len(), 1); +} + +#[test] +fn test_run_workflow_job_unknown_job_error() { + let repo = TestRepo::new().expect("Failed to create test repo"); + repo.write_workflow("ci.yaml", PASSING_WORKFLOW) + .expect("Failed to write workflow"); + treq_lib::local_db::trust_repo(&repo.repo_path).expect("Failed to trust repo"); + let err = core::run_workflow_job_sync( + &repo.repo_path, + "ci.yaml", + "nonexistent", + 0, + &repo.repo_path, + ) + .unwrap_err(); + assert!(err.contains("nonexistent")); +} + +#[test] +fn test_run_workflow_runs_all_jobs() { + let repo = TestRepo::new().expect("Failed to create test repo"); + let content = "name: Multi Job\non:\n workflow_dispatch: {}\njobs:\n job1:\n steps:\n - name: step1\n run: echo a\n job2:\n steps:\n - name: step2\n run: echo b\n"; + repo.write_workflow("multi.yaml", content) + .expect("Failed to write workflow"); + treq_lib::local_db::trust_repo(&repo.repo_path).expect("Failed to trust repo"); + let results = core::run_workflow_sync(&repo.repo_path, "multi.yaml", 0, &repo.repo_path) + .expect("Failed to run workflow"); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|r| r.success)); +} diff --git a/src-tauri/tests/e2e_test_helpers.rs b/src-tauri/tests/e2e_test_helpers.rs index dea5613c..88918abc 100644 --- a/src-tauri/tests/e2e_test_helpers.rs +++ b/src-tauri/tests/e2e_test_helpers.rs @@ -5,6 +5,36 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use tempfile::TempDir; +#[allow(dead_code)] +pub const PASSING_WORKFLOW: &str = " +name: Passing CI +on: + workflow_dispatch: {} +jobs: + greet: + name: Greet Job + steps: + - name: Say hello + run: echo hello + - name: Say world + run: echo world +"; + +#[allow(dead_code)] +pub const FAILING_WORKFLOW: &str = " +name: Failing CI +on: + workflow_dispatch: {} +jobs: + check: + name: Check Job + steps: + - name: Fail here + run: exit 1 + - name: Never runs + run: echo skipped +"; + fn random_default_branch_name() -> String { static COUNTER: AtomicU64 = AtomicU64::new(0); let seq = COUNTER.fetch_add(1, Ordering::Relaxed); @@ -255,6 +285,11 @@ impl TestRepo { Ok(file_path) } + /// Write a YAML workflow file to `.treq/workflows/{filename}` in the repo. + pub fn write_workflow(&self, filename: &str, content: &str) -> Result { + self.create_file(&format!(".treq/workflows/{}", filename), content) + } + /// Write or append file content at an absolute path. fn write_file_at_path(file_path: PathBuf, content: &str, append: bool) -> Result<(), String> { if let Some(parent) = file_path.parent() { diff --git a/src/components/ChecksTab.tsx b/src/components/ChecksTab.tsx new file mode 100644 index 00000000..0e20bc1d --- /dev/null +++ b/src/components/ChecksTab.tsx @@ -0,0 +1,413 @@ +import { useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CheckCircle2, + CircleDot, + FileText, + Loader2, + Play, + ShieldCheck, + XCircle, +} from "lucide-react"; +import { Button } from "./ui/button"; +import { + isRepoTrusted, + listWorkflowRuns, + listWorkflows, + runWorkflow, + runWorkflowJob, + trustRepo, +} from "../lib/api"; +import type { JobResult, RunSummary, WorkflowInfo } from "../lib/api-types"; +import { LogsBrowser } from "./LogsBrowser"; + +interface Props { + repoPath: string; + workspaceId: number; + workspacePath: string; + onSendToAgent?: (prompt: string) => void; +} + +interface LogTarget { + runId: number; + jobId: string; + stepIndex?: number; +} + +function formatRunTime(ts: string): string { + const parsed = new Date(ts); + if (Number.isNaN(parsed.getTime())) return ts; + return parsed.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function RunStatusIcon({ status }: { status: string }) { + if (status === "passed") { + return ( + + ); + } + if (status === "failed") { + return ( + + ); + } + return ( + + ); +} + +export function ChecksTab({ + repoPath, + workspaceId, + workspacePath, + onSendToAgent, +}: Props) { + const queryClient = useQueryClient(); + const [runningJobs, setRunningJobs] = useState>(new Set()); + const [runningWorkflows, setRunningWorkflows] = useState>( + new Set(), + ); + const [jobResults, setJobResults] = useState>({}); + const [logTarget, setLogTarget] = useState(null); + + // Results and any open log view belong to the workspace they were run in. + useEffect(() => { + setJobResults({}); + setLogTarget(null); + }, [workspaceId]); + + const { data: isTrusted, isLoading: trustLoading } = useQuery({ + queryKey: ["repo-trusted", repoPath], + queryFn: () => isRepoTrusted(repoPath), + }); + + const { data: workflows = [], isLoading: workflowsLoading } = useQuery({ + queryKey: ["workflows", repoPath], + queryFn: () => listWorkflows(repoPath), + }); + + const jobKey = (filename: string, jobId: string) => `${filename}:${jobId}`; + + function invalidateRuns(filename: string) { + queryClient.invalidateQueries({ + queryKey: ["workflow-runs", repoPath, workspaceId, filename], + }); + } + + async function handleTrustRepo() { + await trustRepo(repoPath); + queryClient.invalidateQueries({ queryKey: ["repo-trusted", repoPath] }); + } + + async function handleRunJob(wf: WorkflowInfo, jobId: string) { + const key = jobKey(wf.filename, jobId); + setRunningJobs((prev) => new Set(prev).add(key)); + try { + const result = await runWorkflowJob( + repoPath, + wf.filename, + jobId, + workspaceId, + workspacePath, + ); + setJobResults((prev) => ({ ...prev, [key]: result })); + } finally { + setRunningJobs((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + invalidateRuns(wf.filename); + } + } + + async function handleRunWorkflow(wf: WorkflowInfo) { + setRunningWorkflows((prev) => new Set(prev).add(wf.filename)); + try { + const results = await runWorkflow( + repoPath, + wf.filename, + workspaceId, + workspacePath, + ); + const updates: Record = {}; + for (const result of results) { + updates[jobKey(wf.filename, result.job_id)] = result; + } + setJobResults((prev) => ({ ...prev, ...updates })); + } finally { + setRunningWorkflows((prev) => { + const next = new Set(prev); + next.delete(wf.filename); + return next; + }); + invalidateRuns(wf.filename); + } + } + + if (logTarget) { + return ( + setLogTarget(null)} + onSendToAgent={onSendToAgent} + /> + ); + } + + if (trustLoading || workflowsLoading) { + return ( +
+ + Loading… +
+ ); + } + + if (workflows.length === 0) { + return ( +
+ No workflows found. Add YAML files to{" "} + .treq/workflows/ to get started. +
+ ); + } + + return ( +
+ {!isTrusted && ( +
+
+ + + Trust this repository to enable running workflow checks. + +
+ +
+ )} + + {workflows.map((wf) => ( + + ))} +
+ ); +} + +interface CardProps { + workflow: WorkflowInfo; + repoPath: string; + workspaceId: number; + isTrusted: boolean; + isRunningWorkflow: boolean; + runningJobs: Set; + jobResults: Record; + jobKey: (filename: string, jobId: string) => string; + onRunWorkflow: (wf: WorkflowInfo) => void; + onRunJob: (wf: WorkflowInfo, jobId: string) => void; + onOpenLogs: (target: LogTarget) => void; +} + +function WorkflowCard({ + workflow: wf, + repoPath, + workspaceId, + isTrusted, + isRunningWorkflow, + runningJobs, + jobResults, + jobKey, + onRunWorkflow, + onRunJob, + onOpenLogs, +}: CardProps) { + const { data: runs = [] } = useQuery({ + queryKey: ["workflow-runs", repoPath, workspaceId, wf.filename], + queryFn: () => listWorkflowRuns(repoPath, workspaceId, wf.filename), + }); + + const [latestRun]: (RunSummary | undefined)[] = runs; + + return ( +
+
+
+
{wf.name}
+
+ {wf.filename} +
+
+ +
+ +
+ {wf.jobs.map((job) => { + const key = jobKey(wf.filename, job.id); + const isRunning = runningJobs.has(key); + const result = jobResults[key]; + const runJob = latestRun?.jobs.find((j) => j.job_id === job.id); + + return ( +
+
+ {job.name} +
+ {latestRun && runJob?.has_logs && ( + + )} + +
+
+ +
+ {job.steps.map((step, idx) => { + const stepResult = result?.steps[idx] ?? runJob?.steps[idx]; + const canOpenStepLogs = !!(latestRun && runJob?.has_logs); + return ( + + ); + })} +
+
+ ); + })} +
+ + {runs.length > 0 && ( +
+
+ Run history +
+
+ {runs.map((run) => ( +
+
+ + #{run.id} + + {formatRunTime(run.started_at)} + +
+
+ {run.jobs + .filter((j) => j.has_logs) + .map((j) => ( + + ))} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/components/EChart.tsx b/src/components/EChart.tsx new file mode 100644 index 00000000..613c0fe9 --- /dev/null +++ b/src/components/EChart.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef } from "react"; +import * as echarts from "echarts"; +import type { EChartsOption } from "echarts"; + +interface Props { + option: EChartsOption; + /** CSS height; the chart fills its container's width. */ + height?: number | string; + className?: string; + "data-testid"?: string; + /** Forwarded to ECharts init; pass "dark" to follow a dark UI. */ + theme?: string; + onEvents?: Record void>; +} + +/** + * Thin React wrapper around Apache ECharts. + * + * ECharts owns a canvas imperatively, so the instance lives in a ref across + * renders and only the option object is pushed on update. Charts are disposed + * on unmount to avoid leaking the canvas and its resize listener. + */ +export function EChart({ + option, + height = 160, + className, + theme, + onEvents, + "data-testid": testId, +}: Props) { + const containerRef = useRef(null); + const chartRef = useRef(null); + + // jsdom has no layout or canvas, so charts render as an empty box in tests. + useEffect(() => { + const element = containerRef.current; + if (!element) return; + + const chart = echarts.init(element, theme, { renderer: "svg" }); + chartRef.current = chart; + + const handleResize = () => chart.resize(); + window.addEventListener("resize", handleResize); + + // The container is often sized by flexbox after mount, so observe it too. + const observer = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(handleResize) + : null; + observer?.observe(element); + + return () => { + observer?.disconnect(); + window.removeEventListener("resize", handleResize); + chart.dispose(); + chartRef.current = null; + }; + }, [theme]); + + useEffect(() => { + // notMerge keeps a shrinking series list from leaving stale series behind. + chartRef.current?.setOption(option, { notMerge: true }); + }, [option]); + + useEffect(() => { + const chart = chartRef.current; + if (!chart || !onEvents) return; + for (const [name, handler] of Object.entries(onEvents)) { + chart.on(name, handler); + } + return () => { + for (const name of Object.keys(onEvents)) { + chart.off(name); + } + }; + }, [onEvents]); + + return ( +
+ ); +} diff --git a/src/components/LogFeed.tsx b/src/components/LogFeed.tsx new file mode 100644 index 00000000..c0121795 --- /dev/null +++ b/src/components/LogFeed.tsx @@ -0,0 +1,200 @@ +import { Bot, CheckSquare, Square } from "lucide-react"; +import { Button } from "./ui/button"; +import type { LogRecordView } from "../lib/api-types"; +import { cn } from "../lib/utils"; +import { useLineSelection } from "../hooks/useLineSelection"; + +/** Info stays uncolored so warnings and errors are what draw the eye. */ +export function severityClass(severityText: string): string { + if (severityText === "ERROR") return "text-red-600 dark:text-red-400"; + if (severityText === "WARN") return "text-amber-600 dark:text-amber-400"; + return "text-foreground"; +} + +export function formatTimestamp(timestamp: string): string { + const parsed = new Date(timestamp); + if (Number.isNaN(parsed.getTime())) return timestamp; + return parsed.toISOString().slice(11, 23); +} + +/** Timestamps render as fixed HH:MM:SS.sss (12 chars), so the column can be sized exactly to fit. */ +const TIMESTAMP_COL_CLASS = "w-[12ch] shrink-0 whitespace-nowrap"; +/** Longest severity name (e.g. "ERROR") plus a hair of padding. */ +const LEVEL_COL_CLASS = "w-[6ch] shrink-0 whitespace-nowrap"; + +export interface PrefixColumn { + header: string; + /** Fixed-width class shared between the header cell and each row's cell. */ + className: string; + render: (record: LogRecordView) => React.ReactNode; +} + +interface Props { + records: LogRecordView[]; + /** Extra fixed-width columns shown before the message; e.g. run/job ids. */ + prefixColumns?: PrefixColumn[]; + testId: string; + lineTestId: string; + emptyMessage: React.ReactNode; + /** Called with the chosen records when the user sends them to an agent. */ + onSendToAgent: (records: LogRecordView[]) => void; +} + +/** + * Selectable log feed shared by the run and repo-wide browsers. + * + * Dragging across lines selects a range; multi-select mode turns clicks into + * individual toggles so non-adjacent lines can be gathered. + */ +export function LogFeed({ + records, + prefixColumns = [], + testId, + lineTestId, + emptyMessage, + onSendToAgent, +}: Props) { + const { + selected, + multiSelect, + onLineMouseDown, + onLineMouseEnter, + clear, + toggleMultiSelect, + selectAll, + } = useLineSelection(records.length); + + function handleSend() { + const chosen = Array.from(selected) + .sort((a, b) => a - b) + .map((index) => records[index]) + .filter(Boolean); + if (chosen.length > 0) onSendToAgent(chosen); + } + + if (records.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+
+ + + {selected.size > 0 && ( + <> + + {selected.size} selected + + + + )} +
+ +
+ +
+ Timestamp + {prefixColumns.map((col) => ( + + {col.header} + + ))} + Level + Message +
+ +
+ {records.map((record, index) => ( + + ))} +
+
+ ); +} diff --git a/src/components/LogLevelFilter.tsx b/src/components/LogLevelFilter.tsx new file mode 100644 index 00000000..d3161f52 --- /dev/null +++ b/src/components/LogLevelFilter.tsx @@ -0,0 +1,66 @@ +import { ChevronDown } from "lucide-react"; +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; + +export const LOG_LEVELS = ["info", "warning", "error"] as const; + +interface Props { + /** Selected levels; empty means no filter (all levels shown). */ + value: string[]; + onChange: (levels: string[]) => void; +} + +/** Multi-select for log levels. Nothing ticked means "All levels". */ +export function LogLevelFilter({ value, onChange }: Props) { + function toggle(level: string) { + onChange( + value.includes(level) + ? value.filter((l) => l !== level) + : [...value, level], + ); + } + + const label = + value.length === 0 + ? "All levels" + : value.length === 1 + ? value[0] + : `${value.length} levels`; + + return ( + + + + + + {LOG_LEVELS.map((level) => ( + event.preventDefault()} + onCheckedChange={() => toggle(level)} + className="capitalize" + > + {level} + + ))} + + + ); +} diff --git a/src/components/LogsBrowser.tsx b/src/components/LogsBrowser.tsx new file mode 100644 index 00000000..c9384eac --- /dev/null +++ b/src/components/LogsBrowser.tsx @@ -0,0 +1,169 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowLeft, Download, Loader2 } from "lucide-react"; +import { Button } from "./ui/button"; +import { exportRunLogs, getRunLogs } from "../lib/api"; +import type { LogBucket, LogRecordView } from "../lib/api-types"; +import { LogLevelFilter } from "./LogLevelFilter"; +import { LogFeed } from "./LogFeed"; +import { LogsTimeseriesChart } from "./LogsTimeseriesChart"; +import { buildLogLinesPrompt } from "../lib/logs-prompt"; + +interface Props { + repoPath: string; + runId: number; + jobId: string; + /** Scopes the initial view to a single step when opened from a step row. */ + initialStepIndex?: number; + onBack: () => void; + onSendToAgent?: (prompt: string) => void; +} + +export function LogsBrowser({ + repoPath, + runId, + jobId, + initialStepIndex, + onBack, + onSendToAgent, +}: Props) { + const [levels, setLevels] = useState([]); + const [search, setSearch] = useState(""); + const [stepIndex, setStepIndex] = useState( + initialStepIndex, + ); + const [exportedTo, setExportedTo] = useState(null); + + const { data: records = [], isLoading } = useQuery({ + queryKey: ["run-logs", repoPath, runId, jobId, levels, search, stepIndex], + queryFn: () => + getRunLogs(repoPath, runId, jobId, { + levels: levels.length > 0 ? levels : undefined, + search: search || undefined, + stepIndex, + }), + }); + + // Step names for the step filter, in first-seen order. + const steps = useMemo(() => { + const seen = new Map(); + for (const record of records) { + if (!seen.has(record.step_index)) { + seen.set(record.step_index, record.step_name); + } + } + return [...seen.entries()].sort((a, b) => a[0] - b[0]); + }, [records]); + + // This job's records are already loaded, so bucket them here rather than + // paying for a second round trip. + const buckets = useMemo(() => { + const counts = new Map(); + for (const record of records) { + const bucket = `${record.timestamp.slice(0, 19)}Z`; + const key = `${bucket}|${record.severity_text}`; + const existing = counts.get(key); + if (existing) { + existing.count += 1; + } else { + counts.set(key, { + bucket, + severity_text: record.severity_text, + count: 1, + }); + } + } + return [...counts.values()]; + }, [records]); + + async function handleExport() { + const dest = `${repoPath}/.treq/runs/${runId}/${jobId}.log`; + setExportedTo(await exportRunLogs(repoPath, runId, jobId, dest)); + } + + function handleSendToAgent(chosen: LogRecordView[]) { + onSendToAgent?.( + buildLogLinesPrompt(chosen, `job "${jobId}" of check run #${runId}`), + ); + } + + return ( +
+
+
+ +
+
{jobId}
+
Run #{runId}
+
+
+ +
+ +
+ + + {steps.length > 1 && ( + + )} + + setSearch(e.target.value)} + /> +
+ + {exportedTo && ( +
+ Exported to {exportedTo} +
+ )} + + {records.length > 0 && ( +
+ +
+ )} + + {isLoading ? ( +
+ + Loading logs… +
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/LogsSqlExplorer.tsx b/src/components/LogsSqlExplorer.tsx new file mode 100644 index 00000000..1122ee5f --- /dev/null +++ b/src/components/LogsSqlExplorer.tsx @@ -0,0 +1,234 @@ +import { useState } from "react"; +import { + Bot, + ChevronDown, + FileCode2, + Loader2, + Play, + TriangleAlert, +} from "lucide-react"; +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { runLogsSql } from "../lib/api"; +import type { SqlResult } from "../lib/api-types"; +import { buildSqlResultPrompt } from "../lib/logs-prompt"; + +interface Props { + repoPath: string; + onSendToAgent?: (prompt: string) => void; +} + +const DEFAULT_QUERY = `SELECT timestamp, severityText, attributes.job_id AS job_id, + body.message AS message +FROM logs +ORDER BY timestamp DESC +LIMIT 50`; + +const TEMPLATES: { label: string; description: string; sql: string }[] = [ + { + label: "Recent lines", + description: "Newest records across every run", + sql: DEFAULT_QUERY, + }, + { + label: "Errors by job", + description: "Which jobs produce the most ERROR records", + sql: `SELECT attributes.job_id AS job_id, count(*) AS errors +FROM logs +WHERE severityText = 'ERROR' +GROUP BY job_id +ORDER BY errors DESC`, + }, + { + label: "Severity by run", + description: "Record counts per run, split by severity", + sql: `SELECT attributes.run_id AS run_id, severityText, count(*) AS records +FROM logs +GROUP BY run_id, severityText +ORDER BY run_id DESC, severityText`, + }, + { + label: "Slowest steps", + description: "Wall-clock span of each step, longest first", + sql: `SELECT attributes.job_id AS job_id, attributes.step_name AS step_name, + datediff('millisecond', min(CAST(timestamp AS TIMESTAMP)), + max(CAST(timestamp AS TIMESTAMP))) AS duration_ms +FROM logs +GROUP BY job_id, step_name +ORDER BY duration_ms DESC`, + }, + { + label: "Trace overview", + description: "One row per trace and span, with severity counts", + sql: `SELECT traceId, spanId, attributes.job_id AS job_id, + count(*) AS records, + count(*) FILTER (WHERE severityText = 'ERROR') AS errors +FROM logs +GROUP BY traceId, spanId, job_id +ORDER BY errors DESC, records DESC`, + }, +]; + +/** + * Ad-hoc SQL over the OpenTelemetry `logs` view. The backend only accepts + * read-only statements, so this is a browser rather than a general SQL console. + */ +export function LogsSqlExplorer({ repoPath, onSendToAgent }: Props) { + const [sql, setSql] = useState(DEFAULT_QUERY); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [running, setRunning] = useState(false); + + async function execute() { + setRunning(true); + setError(null); + try { + setResult(await runLogsSql(repoPath, sql)); + } catch (e) { + setResult(null); + setError(e instanceof Error ? e.message : String(e)); + } finally { + setRunning(false); + } + } + + return ( +
+
+ + + + + + {TEMPLATES.map((template) => ( + setSql(template.sql)} + className="flex flex-col items-start gap-0.5 py-2" + > + {template.label} + + {template.description} + + + ))} + + + + Read-only queries against the logs{" "} + view. + +
+ +
+