From 45caf838cbc789ab618cc6942832a8479f9fa35c Mon Sep 17 00:00:00 2001 From: Enrico Piovesan Date: Sat, 11 Jul 2026 19:22:10 -0600 Subject: [PATCH] Extract traverse-core-rs shared HTTP/SSE crate for GTK and CLI linux-gtk and cli-rust now depend on a workspace crate for send_command, SSE subscribe_events, and server.json discovery, matching the web-react command + event pattern from #43. Co-authored-by: Cursor --- AGENTS.md | 2 +- README.md | 4 +- .../{cli-rust => }/Cargo.lock | 846 +++++++++++++++++- apps/traverse-starter/Cargo.toml | 3 + apps/traverse-starter/cli-rust/Cargo.toml | 5 +- apps/traverse-starter/cli-rust/README.md | 21 +- apps/traverse-starter/cli-rust/src/client.rs | 181 +--- .../cli-rust/src/commands/health.rs | 19 +- .../cli-rust/src/commands/run.rs | 126 ++- apps/traverse-starter/cli-rust/src/lib.rs | 4 +- .../cli-rust/tests/integration_tests.rs | 38 +- apps/traverse-starter/linux-gtk/Cargo.toml | 3 +- apps/traverse-starter/linux-gtk/README.md | 21 +- apps/traverse-starter/linux-gtk/src/client.rs | 198 +--- apps/traverse-starter/linux-gtk/src/lib.rs | 4 +- .../linux-gtk/src/ui/main_window.rs | 138 ++- .../linux-gtk/tests/client_tests.rs | 50 +- .../traverse-core-rs/Cargo.toml | 18 + .../traverse-core-rs/src/client.rs | 221 +++++ .../traverse-core-rs/src/discovery.rs | 53 ++ .../traverse-core-rs/src/lib.rs | 16 + .../traverse-core-rs/src/sse.rs | 100 +++ .../traverse-core-rs/src/state.rs | 88 ++ .../traverse-core-rs/tests/client_tests.rs | 76 ++ .../traverse-core-rs/tests/sse_tests.rs | 41 + scripts/ci/repository_checks.sh | 2 + 26 files changed, 1693 insertions(+), 585 deletions(-) rename apps/traverse-starter/{cli-rust => }/Cargo.lock (69%) create mode 100644 apps/traverse-starter/Cargo.toml create mode 100644 apps/traverse-starter/traverse-core-rs/Cargo.toml create mode 100644 apps/traverse-starter/traverse-core-rs/src/client.rs create mode 100644 apps/traverse-starter/traverse-core-rs/src/discovery.rs create mode 100644 apps/traverse-starter/traverse-core-rs/src/lib.rs create mode 100644 apps/traverse-starter/traverse-core-rs/src/sse.rs create mode 100644 apps/traverse-starter/traverse-core-rs/src/state.rs create mode 100644 apps/traverse-starter/traverse-core-rs/tests/client_tests.rs create mode 100644 apps/traverse-starter/traverse-core-rs/tests/sse_tests.rs diff --git a/AGENTS.md b/AGENTS.md index a5add94..6b3de10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ gh project item-list 2 --owner traverse-framework --format json --limit 300 \ - **Phase 3 embedded runtime** ([#109](https://github.com/traverse-framework/reference-apps/issues/109)–[#118](https://github.com/traverse-framework/reference-apps/issues/118)) — see `docs/embedded-runtime-plan.md`; blocked on a **consumable platform embedder SDK** (Traverse [#553](https://github.com/traverse-framework/Traverse/issues/553) closed via [#578](https://github.com/traverse-framework/Traverse/pull/578) with manifest `execution_mode` only — no web/Swift/etc. embedder package yet). Traverse [#615](https://github.com/traverse-framework/Traverse/pull/615) added wasm32 core build only — not a platform embedder package. - **doc-approval multi-capability showcase** ([#111](https://github.com/traverse-framework/reference-apps/issues/111), [#112](https://github.com/traverse-framework/reference-apps/issues/112)) — blocked on Traverse [#538](https://github.com/traverse-framework/Traverse/issues/538) / [#555](https://github.com/traverse-framework/Traverse/issues/555) (`extract` / `recommend` agents) -Ready: [#110](https://github.com/traverse-framework/reference-apps/issues/110) traverse-starter.pipeline (Traverse [#620](https://github.com/traverse-framework/Traverse/issues/620) closed); [#59](https://github.com/traverse-framework/reference-apps/issues/59)/[#72](https://github.com/traverse-framework/reference-apps/issues/72)/[#73](https://github.com/traverse-framework/reference-apps/issues/73) shared client packages ([#58](https://github.com/traverse-framework/reference-apps/issues/58) in progress). +Ready: [#110](https://github.com/traverse-framework/reference-apps/issues/110) traverse-starter.pipeline (Traverse [#620](https://github.com/traverse-framework/Traverse/issues/620) closed); [#72](https://github.com/traverse-framework/reference-apps/issues/72)/[#73](https://github.com/traverse-framework/reference-apps/issues/73) shared client packages ([#58](https://github.com/traverse-framework/reference-apps/issues/58) Done; [#59](https://github.com/traverse-framework/reference-apps/issues/59) in this PR). Update this section when a PR changes platform status (see PR template checklist). diff --git a/README.md b/README.md index c4fa3d4..237b0f4 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,6 @@ Active blockers on [Project 2](https://github.com/orgs/traverse-framework/projec - **Phase 3 embedded runtime** ([#109](https://github.com/traverse-framework/reference-apps/issues/109)–[#118](https://github.com/traverse-framework/reference-apps/issues/118)) — all platform clients must bundle the WASM runtime host; blocked on a **consumable platform embedder SDK** (Traverse [#553](https://github.com/traverse-framework/Traverse/issues/553) closed via [#578](https://github.com/traverse-framework/Traverse/pull/578) with manifest `execution_mode` only; [#615](https://github.com/traverse-framework/Traverse/pull/615) is wasm32 core build only). HTTP sidecar is dev-only. - **doc-approval multi-capability showcase** ([#111](https://github.com/traverse-framework/reference-apps/issues/111), [#112](https://github.com/traverse-framework/reference-apps/issues/112)) — blocked on Traverse [#538](https://github.com/traverse-framework/Traverse/issues/538) / [#555](https://github.com/traverse-framework/Traverse/issues/555) (`extract` / `recommend` agents). -- **traverse-starter.pipeline showcase** ([#110](https://github.com/traverse-framework/reference-apps/issues/110)) — blocked on Traverse [#620](https://github.com/traverse-framework/Traverse/issues/620) (pipeline execute surface never landed on `main`). +- **traverse-starter.pipeline showcase** ([#110](https://github.com/traverse-framework/reference-apps/issues/110)) — Ready (Traverse [#620](https://github.com/traverse-framework/Traverse/issues/620) closed; pipeline on Traverse `main`). -Ready on Project 2: [#58](https://github.com/traverse-framework/reference-apps/issues/58)/[#59](https://github.com/traverse-framework/reference-apps/issues/59)/[#72](https://github.com/traverse-framework/reference-apps/issues/72)/[#73](https://github.com/traverse-framework/reference-apps/issues/73) — shared HTTP/SSE client packages (Swift + Rust). +Ready on Project 2: [#59](https://github.com/traverse-framework/reference-apps/issues/59)/[#72](https://github.com/traverse-framework/reference-apps/issues/72)/[#73](https://github.com/traverse-framework/reference-apps/issues/73) — shared HTTP/SSE client packages ([#58](https://github.com/traverse-framework/reference-apps/issues/58) Done). diff --git a/apps/traverse-starter/cli-rust/Cargo.lock b/apps/traverse-starter/Cargo.lock similarity index 69% rename from apps/traverse-starter/cli-rust/Cargo.lock rename to apps/traverse-starter/Cargo.lock index 919f080..944a03b 100644 --- a/apps/traverse-starter/cli-rust/Cargo.lock +++ b/apps/traverse-starter/Cargo.lock @@ -129,20 +129,53 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cairo-rs" +version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] [[package]] name = "cc" -version = "1.2.66" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -222,6 +255,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -231,12 +273,51 @@ dependencies = [ "libc", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -254,6 +335,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -284,6 +375,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -300,12 +406,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -324,8 +452,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -333,6 +463,63 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -372,6 +559,197 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "h2" version = "0.4.15" @@ -403,6 +781,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.2" @@ -657,12 +1041,52 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libadwaita" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500135d29c16aabf67baafd3e7741d48e8b8978ca98bac39e589165c8dc78191" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6680988058c2558baf3f548a370e4e78da3bf7f08469daa822ac414842c912db" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + [[package]] name = "litemap" version = "0.8.2" @@ -692,9 +1116,18 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] [[package]] name = "mio" @@ -715,7 +1148,7 @@ checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" dependencies = [ "assert-json-diff", "bytes", - "colored", + "colored 3.1.1", "futures-core", "http", "http-body", @@ -724,7 +1157,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand 0.9.4", + "rand 0.9.5", "regex", "serde_json", "serde_urlencoded", @@ -747,6 +1180,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -759,6 +1202,36 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -794,6 +1267,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.5" @@ -842,6 +1321,15 @@ dependencies = [ "termtree", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -865,7 +1353,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -887,7 +1375,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -930,9 +1418,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -992,11 +1480,22 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" -version = "1.12.4" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -1006,9 +1505,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -1029,7 +1528,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "futures-channel", "futures-core", "futures-util", "http", @@ -1051,12 +1549,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -1081,6 +1581,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.23.41" @@ -1118,9 +1627,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1134,6 +1643,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1177,6 +1692,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1272,19 +1796,58 @@ dependencies = [ "syn", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1310,9 +1873,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -1335,9 +1898,21 @@ dependencies = [ "parking_lot", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -1361,6 +1936,57 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -1425,18 +2051,47 @@ dependencies = [ "once_cell", ] +[[package]] +name = "traverse-core-rs" +version = "0.1.0" +dependencies = [ + "futures-util", + "reqwest", + "serde", + "serde_json", + "tokio", + "wiremock", +] + [[package]] name = "traverse-starter-cli" version = "0.1.0" dependencies = [ "assert_cmd", "clap", - "colored", + "colored 2.2.0", + "futures-util", "mockito", "predicates", - "reqwest", "serde", "serde_json", + "tokio", + "traverse-core-rs", +] + +[[package]] +name = "traverse-starter-gtk" +version = "0.1.0" +dependencies = [ + "directories", + "futures-util", + "gtk4", + "libadwaita", + "serde", + "serde_json", + "tokio", + "traverse-core-rs", + "wiremock", ] [[package]] @@ -1481,6 +2136,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -1569,6 +2230,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -1604,13 +2278,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1619,7 +2302,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1631,34 +2314,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1671,30 +2387,86 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -1732,18 +2504,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", diff --git a/apps/traverse-starter/Cargo.toml b/apps/traverse-starter/Cargo.toml new file mode 100644 index 0000000..3591b01 --- /dev/null +++ b/apps/traverse-starter/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["traverse-core-rs", "linux-gtk", "cli-rust"] diff --git a/apps/traverse-starter/cli-rust/Cargo.toml b/apps/traverse-starter/cli-rust/Cargo.toml index 6381622..4278c91 100644 --- a/apps/traverse-starter/cli-rust/Cargo.toml +++ b/apps/traverse-starter/cli-rust/Cargo.toml @@ -10,9 +10,11 @@ name = "traverse-starter" path = "src/main.rs" [dependencies] +traverse-core-rs = { path = "../traverse-core-rs" } clap = { version = "4", features = ["derive", "env"] } colored = "2" -reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls"] } +futures-util = "0.3" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -20,3 +22,4 @@ serde_json = "1" mockito = "1" assert_cmd = "2" predicates = "3" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/apps/traverse-starter/cli-rust/README.md b/apps/traverse-starter/cli-rust/README.md index 3894e71..3cc4d18 100644 --- a/apps/traverse-starter/cli-rust/README.md +++ b/apps/traverse-starter/cli-rust/README.md @@ -1,6 +1,6 @@ # traverse-starter (Rust CLI) -Terminal client for the `traverse-starter` reference app. Phase 1 uses HTTP polling against the public Traverse HTTP/JSON API (spec 033) — same flow as `web-react` and the native GUI clients. +Terminal client for the `traverse-starter` reference app. Uses the shared `traverse-core-rs` crate for HTTP command dispatch and SSE app-state events (same pattern as `web-react`). ## Prerequisites @@ -15,11 +15,13 @@ cargo run -p traverse-cli -- serve ## Build and install +Prefer the workspace root so the shared crate resolves cleanly: + ```bash -cd apps/traverse-starter/cli-rust -cargo build --release -cargo test -cargo install --path . # installs `traverse-starter` binary to ~/.cargo/bin +cd apps/traverse-starter +cargo build -p traverse-starter-cli --release +cargo test -p traverse-starter-cli -p traverse-core-rs +cargo install --path cli-rust # installs `traverse-starter` binary to ~/.cargo/bin ``` ## Usage @@ -50,11 +52,8 @@ Environment variables: | File | Role | |---|---| -| `client.rs` | Blocking `reqwest` HTTP client | -| `commands/run.rs` | Execute + poll loop | +| `../traverse-core-rs` | Shared HTTP `send_command` + SSE `subscribe_events` | +| `client.rs` | Re-exports `traverse-core-rs` | +| `commands/run.rs` | Submit note + wait for SSE terminal result | | `commands/health.rs` | `/healthz` check | | `output.rs` | Human (colored) and JSON formatters | - -## Phase 2 (not implemented) - -SSE streaming when Traverse ships [#525](https://github.com/traverse-framework/Traverse/issues/525)–[#527](https://github.com/traverse-framework/Traverse/issues/527). Shared client moves to `traverse-core-rs` ([#59](https://github.com/traverse-framework/reference-apps/issues/59)). diff --git a/apps/traverse-starter/cli-rust/src/client.rs b/apps/traverse-starter/cli-rust/src/client.rs index 25516b2..345f4d6 100644 --- a/apps/traverse-starter/cli-rust/src/client.rs +++ b/apps/traverse-starter/cli-rust/src/client.rs @@ -1,177 +1,4 @@ -use serde::Deserialize; -use serde_json::Value; - -#[derive(Debug, Clone, PartialEq, Deserialize, serde::Serialize)] -pub struct TraverseStarterOutput { - pub title: String, - pub tags: Vec, - #[serde(rename = "noteType")] - pub note_type: String, - #[serde(rename = "suggestedNextAction")] - pub suggested_next_action: String, - pub status: String, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, serde::Serialize)] -pub struct TraceEvent { - pub event_type: String, - pub timestamp: String, - pub data: Option, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ExecutionPollResult { - pub execution_id: String, - pub status: String, - pub output: Option, - pub error: Option, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum TraverseClientError { - Http(u16), - Decode, - Request(String), -} - -impl std::fmt::Display for TraverseClientError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Http(code) => write!(f, "HTTP {code}"), - Self::Decode => write!(f, "decode failed"), - Self::Request(msg) => write!(f, "request failed: {msg}"), - } - } -} - -impl std::error::Error for TraverseClientError {} - -pub struct TraverseClient { - http: reqwest::blocking::Client, -} - -impl Default for TraverseClient { - fn default() -> Self { - Self::new() - } -} - -impl TraverseClient { - pub fn new() -> Self { - Self { - http: reqwest::blocking::Client::new(), - } - } - - pub fn with_client(http: reqwest::blocking::Client) -> Self { - Self { http } - } - - pub fn check_health(&self, base_url: &str) -> Result { - let url = format!("{}/healthz", normalize_base_url(base_url)); - let response = self - .http - .get(url) - .send() - .map_err(|e| TraverseClientError::Request(e.to_string()))?; - Ok(response.status().is_success()) - } - - pub fn execute( - &self, - base_url: &str, - workspace_id: &str, - capability: &str, - input: &Value, - ) -> Result { - let url = format!( - "{}/v1/workspaces/{}/execute", - normalize_base_url(base_url), - workspace_id - ); - let body = serde_json::json!({ "capability": capability, "input": input }); - let response = self - .http - .post(url) - .json(&body) - .send() - .map_err(|e| TraverseClientError::Request(e.to_string()))?; - if !response.status().is_success() { - return Err(TraverseClientError::Http(response.status().as_u16())); - } - let payload: ExecuteResponse = response - .json() - .map_err(|_| TraverseClientError::Decode)?; - Ok(payload.execution_id) - } - - pub fn poll_execution( - &self, - base_url: &str, - workspace_id: &str, - execution_id: &str, - ) -> Result { - let url = format!( - "{}/v1/workspaces/{}/executions/{}", - normalize_base_url(base_url), - workspace_id, - execution_id - ); - let response = self - .http - .get(url) - .send() - .map_err(|e| TraverseClientError::Request(e.to_string()))?; - if !response.status().is_success() { - return Err(TraverseClientError::Http(response.status().as_u16())); - } - let payload: ExecutionPollResponse = response - .json() - .map_err(|_| TraverseClientError::Decode)?; - Ok(ExecutionPollResult { - execution_id: execution_id.to_string(), - status: payload.status, - output: payload.output, - error: payload.error, - }) - } - - pub fn fetch_trace( - &self, - base_url: &str, - workspace_id: &str, - execution_id: &str, - ) -> Result, TraverseClientError> { - let url = format!( - "{}/v1/workspaces/{}/traces/{}", - normalize_base_url(base_url), - workspace_id, - execution_id - ); - let response = self - .http - .get(url) - .send() - .map_err(|e| TraverseClientError::Request(e.to_string()))?; - if !response.status().is_success() { - return Err(TraverseClientError::Http(response.status().as_u16())); - } - response.json().map_err(|_| TraverseClientError::Decode) - } -} - -fn normalize_base_url(base_url: &str) -> String { - base_url.trim().trim_end_matches('/').to_string() -} - -#[derive(Debug, Deserialize)] -struct ExecuteResponse { - execution_id: String, -} - -#[derive(Debug, Deserialize)] -struct ExecutionPollResponse { - status: String, - output: Option, - error: Option, -} +//! Re-exports the shared traverse-core-rs client for the CLI shell. +pub use traverse_core_rs::{ + TraceEvent, TraverseClient, TraverseClientError, TraverseStarterOutput, DEFAULT_APP_ID, +}; diff --git a/apps/traverse-starter/cli-rust/src/commands/health.rs b/apps/traverse-starter/cli-rust/src/commands/health.rs index ede1ae1..0f369bd 100644 --- a/apps/traverse-starter/cli-rust/src/commands/health.rs +++ b/apps/traverse-starter/cli-rust/src/commands/health.rs @@ -2,8 +2,21 @@ use crate::client::TraverseClient; use crate::output::print_health; pub fn execute(base_url: &str, json: bool) -> i32 { - let client = TraverseClient::new(); - let online = client.check_health(base_url).unwrap_or(false); + let runtime = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(err) => { + eprintln!("runtime failed: {err}"); + return 1; + } + }; + let online = runtime.block_on(async { + let client = TraverseClient::new(); + client.check_health(base_url).await.unwrap_or(false) + }); print_health(base_url, online, json); - if online { 0 } else { 1 } + if online { + 0 + } else { + 1 + } } diff --git a/apps/traverse-starter/cli-rust/src/commands/run.rs b/apps/traverse-starter/cli-rust/src/commands/run.rs index 18bfbf8..214ac6e 100644 --- a/apps/traverse-starter/cli-rust/src/commands/run.rs +++ b/apps/traverse-starter/cli-rust/src/commands/run.rs @@ -1,59 +1,101 @@ -use std::{thread, time::Duration}; +use futures_util::StreamExt; +use traverse_core_rs::{AppState, DEFAULT_APP_ID}; use crate::client::{TraverseClient, TraverseStarterOutput}; use crate::output::{print_run_result, RunResultJson}; -use crate::CAPABILITY_ID; pub fn execute(base_url: &str, workspace: &str, note: &str, json: bool) -> i32 { - let client = TraverseClient::new(); - let execution_id = match client.execute( - base_url, - workspace, - CAPABILITY_ID, - &serde_json::json!({ "note": note }), - ) { - Ok(id) => id, + let runtime = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, Err(err) => { - eprintln!("execute failed: {err}"); + eprintln!("runtime failed: {err}"); return 1; } }; - loop { - match client.poll_execution(base_url, workspace, &execution_id) { - Ok(result) if result.status == "succeeded" => { - let trace = client - .fetch_trace(base_url, workspace, &execution_id) - .unwrap_or_default(); - let output = result.output.unwrap_or(TraverseStarterOutput { - title: String::new(), - tags: vec![], - note_type: String::new(), - suggested_next_action: String::new(), - status: String::new(), - }); - print_run_result( - &RunResultJson { - execution_id, - output, - trace, - }, - json, - ); - return 0; - } - Ok(result) if result.status == "failed" => { - eprintln!( - "execution failed: {}", - result.error.unwrap_or_else(|| "unknown error".to_string()) - ); + runtime.block_on(async { + let client = TraverseClient::new(); + let accepted = match client.submit_note(base_url, workspace, note).await { + Ok(accepted) => accepted, + Err(err) => { + eprintln!("submit failed: {err}"); return 1; } - Ok(_) => thread::sleep(Duration::from_secs(1)), + }; + + let mut stream = match client + .subscribe_events(base_url, workspace, DEFAULT_APP_ID) + .await + { + Ok(stream) => stream, Err(err) => { - eprintln!("poll failed: {err}"); + eprintln!("subscribe failed: {err}"); return 1; } + }; + + while let Some(item) = stream.next().await { + match item { + Ok(event) if event.event_type == "heartbeat" => continue, + Ok(event) => { + if let Some(sid) = event.session_id.as_deref() { + if sid != accepted.session_id { + continue; + } + } + if event.event_type == "error" + || matches!(event.state.as_ref(), Some(AppState::Error)) + { + eprintln!( + "execution failed: {}", + event + .error_message + .unwrap_or_else(|| "unknown error".to_string()) + ); + return 1; + } + let terminal = matches!(event.state.as_ref(), Some(AppState::Results)) + || event.event_type == "capability_result"; + if !terminal { + continue; + } + let execution_id = event + .execution_id + .or(accepted.execution_id.clone()) + .unwrap_or_default(); + let output = event.output.unwrap_or(TraverseStarterOutput { + title: String::new(), + tags: vec![], + note_type: String::new(), + suggested_next_action: String::new(), + status: String::new(), + }); + let trace = if execution_id.is_empty() { + Vec::new() + } else { + client + .fetch_trace(base_url, workspace, &execution_id) + .await + .unwrap_or_default() + }; + print_run_result( + &RunResultJson { + execution_id, + output, + trace, + }, + json, + ); + return 0; + } + Err(err) => { + eprintln!("event stream failed: {err}"); + return 1; + } + } } - } + + eprintln!("event stream ended before result"); + 1 + }) } diff --git a/apps/traverse-starter/cli-rust/src/lib.rs b/apps/traverse-starter/cli-rust/src/lib.rs index b204b3a..992186c 100644 --- a/apps/traverse-starter/cli-rust/src/lib.rs +++ b/apps/traverse-starter/cli-rust/src/lib.rs @@ -2,6 +2,4 @@ pub mod client; pub mod commands; pub mod output; -pub const CAPABILITY_ID: &str = "traverse-starter.process"; -pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8787"; -pub const DEFAULT_WORKSPACE: &str = "local-default"; +pub use traverse_core_rs::{DEFAULT_APP_ID, DEFAULT_BASE_URL, DEFAULT_WORKSPACE}; diff --git a/apps/traverse-starter/cli-rust/tests/integration_tests.rs b/apps/traverse-starter/cli-rust/tests/integration_tests.rs index c66d48e..d5e9dd4 100644 --- a/apps/traverse-starter/cli-rust/tests/integration_tests.rs +++ b/apps/traverse-starter/cli-rust/tests/integration_tests.rs @@ -1,34 +1,44 @@ use traverse_starter_cli::client::TraverseClient; use traverse_starter_cli::commands; -#[test] -fn check_health_returns_true_on_200() { - let mut server = mockito::Server::new(); +#[tokio::test] +async fn check_health_returns_true_on_200() { + let mut server = mockito::Server::new_async().await; let _mock = server .mock("GET", "/healthz") .with_status(200) - .create(); + .create_async() + .await; let client = TraverseClient::new(); - assert!(client.check_health(&server.url()).unwrap()); + assert!(client.check_health(&server.url()).await.unwrap()); } #[test] fn run_flow_succeeds_against_mock_server() { let mut server = mockito::Server::new(); - let _execute = server - .mock("POST", "/v1/workspaces/local-default/execute") - .with_status(200) - .with_body(r#"{"execution_id":"exec_abc"}"#) + let _submit = server + .mock( + "POST", + "/v1/workspaces/local-default/apps/traverse-starter/commands", + ) + .with_status(202) + .with_header("content-type", "application/json") + .with_body( + r#"{"api_version":"v1","status":"accepted","workspace_id":"local-default","app_id":"traverse-starter","session_id":"sess-1","command":"submit","state":"processing","execution_id":"exec_abc"}"#, + ) .create(); - let _poll = server - .mock("GET", "/v1/workspaces/local-default/executions/exec_abc") - .with_status(200) - .with_body( - r#"{"status":"succeeded","output":{"title":"Title","tags":["tag"],"noteType":"meeting","suggestedNextAction":"follow up","status":"processed"}}"#, + let sse_body = "event: capability_result\ndata: {\"state\":\"results\",\"session_id\":\"sess-1\",\"execution_id\":\"exec_abc\",\"output\":{\"title\":\"Title\",\"tags\":[\"tag\"],\"noteType\":\"meeting\",\"suggestedNextAction\":\"follow up\",\"status\":\"processed\"}}\n\n"; + let _events = server + .mock( + "GET", + "/v1/workspaces/local-default/apps/traverse-starter/events", ) + .with_status(200) + .with_header("content-type", "text/event-stream") + .with_body(sse_body) .create(); let _trace = server diff --git a/apps/traverse-starter/linux-gtk/Cargo.toml b/apps/traverse-starter/linux-gtk/Cargo.toml index 64df04e..30b4208 100644 --- a/apps/traverse-starter/linux-gtk/Cargo.toml +++ b/apps/traverse-starter/linux-gtk/Cargo.toml @@ -13,10 +13,11 @@ name = "traverse-starter-gtk" path = "src/main.rs" [dependencies] +traverse-core-rs = { path = "../traverse-core-rs" } gtk4 = "0.9" libadwaita = { version = "0.7", features = ["v1_5"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +futures-util = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" directories = "5" diff --git a/apps/traverse-starter/linux-gtk/README.md b/apps/traverse-starter/linux-gtk/README.md index d9bfce5..77cb94b 100644 --- a/apps/traverse-starter/linux-gtk/README.md +++ b/apps/traverse-starter/linux-gtk/README.md @@ -1,6 +1,6 @@ # traverse-starter (Linux GTK4) -Native Linux client for the `traverse-starter` reference app. Phase 1 uses HTTP polling against the public Traverse HTTP/JSON API (spec 033) — same flow as `web-react` and the other native clients. +Native Linux client for the `traverse-starter` reference app. Uses the shared `traverse-core-rs` crate for HTTP command dispatch and SSE app-state events (same pattern as `web-react`). ## Prerequisites @@ -31,26 +31,25 @@ Values persist to `~/.config/traverse-starter/settings.json`. ## Build and run +Prefer the workspace root so the shared crate resolves cleanly: + ```bash -cd apps/traverse-starter/linux-gtk -cargo build -cargo test -cargo run +cd apps/traverse-starter +cargo build -p traverse-starter-gtk +cargo test -p traverse-core-rs +cargo run -p traverse-starter-gtk ``` ## Architecture | File | Role | |---|---| -| `client.rs` | `reqwest` HTTP client (execute / poll / trace / healthz) | -| `execution_state.rs` | `Arc>` polling state machine | +| `../traverse-core-rs` | Shared HTTP `send_command` + SSE `subscribe_events` | +| `client.rs` | Re-exports `traverse-core-rs` | +| `execution_state.rs` | Shell UI phase / online status (not runtime transitions) | | `ui/main_window.rs` | `AdwApplicationWindow` — input, output, header health strip | | `ui/preferences.rs` | Runtime URL + workspace dialog | -## Phase 2 (not implemented) - -SSE subscription when Traverse ships [#525](https://github.com/traverse-framework/Traverse/issues/525)–[#527](https://github.com/traverse-framework/Traverse/issues/527). Shared HTTP client moves to `traverse-core-rs` ([#59](https://github.com/traverse-framework/reference-apps/issues/59)). - ## Design language Follow [docs/design-language.md](../../../docs/design-language.md). diff --git a/apps/traverse-starter/linux-gtk/src/client.rs b/apps/traverse-starter/linux-gtk/src/client.rs index d821eb3..6083243 100644 --- a/apps/traverse-starter/linux-gtk/src/client.rs +++ b/apps/traverse-starter/linux-gtk/src/client.rs @@ -1,194 +1,4 @@ -use serde::Deserialize; -use serde_json::Value; - -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct TraverseStarterOutput { - pub title: String, - pub tags: Vec, - #[serde(rename = "noteType")] - pub note_type: String, - #[serde(rename = "suggestedNextAction")] - pub suggested_next_action: String, - pub status: String, -} - -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct TraceEvent { - pub event_type: String, - pub timestamp: String, - pub data: Option, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ExecutionPollResult { - pub execution_id: String, - pub status: String, - pub output: Option, - pub error: Option, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum TraverseClientError { - Http(u16), - Decode, - Request(String), -} - -impl std::fmt::Display for TraverseClientError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Http(code) => write!(f, "HTTP {code}"), - Self::Decode => write!(f, "decode failed"), - Self::Request(msg) => write!(f, "request failed: {msg}"), - } - } -} - -impl std::error::Error for TraverseClientError {} - -pub struct TraverseClient { - http: reqwest::Client, -} - -impl Clone for TraverseClient { - fn clone(&self) -> Self { - Self { - http: self.http.clone(), - } - } -} - -impl Default for TraverseClient { - fn default() -> Self { - Self::new() - } -} - -impl TraverseClient { - pub fn new() -> Self { - Self { - http: reqwest::Client::new(), - } - } - - pub fn with_client(http: reqwest::Client) -> Self { - Self { http } - } - - pub async fn check_health(&self, base_url: &str) -> Result { - let url = format!("{}/healthz", normalize_base_url(base_url)); - let response = self - .http - .get(url) - .send() - .await - .map_err(|e| TraverseClientError::Request(e.to_string()))?; - Ok(response.status().is_success()) - } - - pub async fn execute( - &self, - base_url: &str, - workspace_id: &str, - capability: &str, - input: &serde_json::Value, - ) -> Result { - let url = format!( - "{}/v1/workspaces/{}/execute", - normalize_base_url(base_url), - workspace_id - ); - let body = serde_json::json!({ "capability": capability, "input": input }); - let response = self - .http - .post(url) - .json(&body) - .send() - .await - .map_err(|e| TraverseClientError::Request(e.to_string()))?; - if !response.status().is_success() { - return Err(TraverseClientError::Http(response.status().as_u16())); - } - let payload: ExecuteResponse = response - .json() - .await - .map_err(|_| TraverseClientError::Decode)?; - Ok(payload.execution_id) - } - - pub async fn poll_execution( - &self, - base_url: &str, - workspace_id: &str, - execution_id: &str, - ) -> Result { - let url = format!( - "{}/v1/workspaces/{}/executions/{}", - normalize_base_url(base_url), - workspace_id, - execution_id - ); - let response = self - .http - .get(url) - .send() - .await - .map_err(|e| TraverseClientError::Request(e.to_string()))?; - if !response.status().is_success() { - return Err(TraverseClientError::Http(response.status().as_u16())); - } - let payload: ExecutionPollResponse = response - .json() - .await - .map_err(|_| TraverseClientError::Decode)?; - Ok(ExecutionPollResult { - execution_id: execution_id.to_string(), - status: payload.status, - output: payload.output, - error: payload.error, - }) - } - - pub async fn fetch_trace( - &self, - base_url: &str, - workspace_id: &str, - execution_id: &str, - ) -> Result, TraverseClientError> { - let url = format!( - "{}/v1/workspaces/{}/traces/{}", - normalize_base_url(base_url), - workspace_id, - execution_id - ); - let response = self - .http - .get(url) - .send() - .await - .map_err(|e| TraverseClientError::Request(e.to_string()))?; - if !response.status().is_success() { - return Err(TraverseClientError::Http(response.status().as_u16())); - } - response - .json() - .await - .map_err(|_| TraverseClientError::Decode) - } -} - -fn normalize_base_url(base_url: &str) -> String { - base_url.trim().trim_end_matches('/').to_string() -} - -#[derive(Debug, Deserialize)] -struct ExecuteResponse { - execution_id: String, -} - -#[derive(Debug, Deserialize)] -struct ExecutionPollResponse { - status: String, - output: Option, - error: Option, -} +//! Re-exports the shared traverse-core-rs client for the GTK shell. +pub use traverse_core_rs::{ + TraceEvent, TraverseClient, TraverseClientError, TraverseStarterOutput, DEFAULT_APP_ID, +}; diff --git a/apps/traverse-starter/linux-gtk/src/lib.rs b/apps/traverse-starter/linux-gtk/src/lib.rs index 1e01d53..57114dd 100644 --- a/apps/traverse-starter/linux-gtk/src/lib.rs +++ b/apps/traverse-starter/linux-gtk/src/lib.rs @@ -3,7 +3,5 @@ pub mod execution_state; pub mod settings; pub mod ui; -pub const CAPABILITY_ID: &str = "traverse-starter.process"; -pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8787"; -pub const DEFAULT_WORKSPACE: &str = "local-default"; +pub use traverse_core_rs::{DEFAULT_APP_ID, DEFAULT_BASE_URL, DEFAULT_WORKSPACE}; pub const NOTE_MAX_LENGTH: usize = 2000; diff --git a/apps/traverse-starter/linux-gtk/src/ui/main_window.rs b/apps/traverse-starter/linux-gtk/src/ui/main_window.rs index 478097b..da3298b 100644 --- a/apps/traverse-starter/linux-gtk/src/ui/main_window.rs +++ b/apps/traverse-starter/linux-gtk/src/ui/main_window.rs @@ -1,3 +1,4 @@ +use futures_util::StreamExt; use gtk4::prelude::*; use gtk4::{ Box as GtkBox, Button, Label, Orientation, ScrolledWindow, TextView, TextViewBuffer, @@ -7,12 +8,13 @@ use libadwaita as adw; use std::cell::RefCell; use std::rc::Rc; use std::sync::{Arc, Mutex}; +use traverse_core_rs::AppState; -use crate::client::TraverseClient; +use crate::client::{TraverseClient, TraverseStarterOutput, DEFAULT_APP_ID}; use crate::execution_state::{ExecutionPhase, ExecutionState, RuntimeStatus}; use crate::settings::{load_settings, save_settings, AppSettings}; use crate::ui::preferences::PreferencesDialog; -use crate::{CAPABILITY_ID, NOTE_MAX_LENGTH}; +use crate::NOTE_MAX_LENGTH; pub struct MainWindow { pub window: adw::ApplicationWindow, @@ -136,7 +138,7 @@ impl MainWindow { output_label.remove_css_class("dim-label"); } ExecutionPhase::Polling { execution_id } => { - output_label.set_text(&format!("Polling execution {execution_id}…")); + output_label.set_text(&format!("Waiting for runtime events ({execution_id})…")); output_label.remove_css_class("dim-label"); } ExecutionPhase::Failed { error } => { @@ -219,25 +221,21 @@ impl MainWindow { let state = state.clone(); let refresh_ui = refresh_ui.clone(); async move { - match client - .execute( - &base_url, - &workspace, - CAPABILITY_ID, - &serde_json::json!({ "note": note }), - ) - .await - { - Ok(execution_id) => { + match client.submit_note(&base_url, &workspace, ¬e).await { + Ok(accepted) => { + let label = accepted + .execution_id + .clone() + .unwrap_or_else(|| accepted.session_id.clone()); state.lock().unwrap().phase = - ExecutionPhase::Polling { execution_id: execution_id.clone() }; + ExecutionPhase::Polling { execution_id: label }; refresh_ui(); - poll_until_terminal( + wait_for_result( &client, &state, &base_url, &workspace, - &execution_id, + &accepted.session_id, &refresh_ui, ) .await; @@ -341,50 +339,94 @@ async fn refresh_health( }); } -async fn poll_until_terminal( +async fn wait_for_result( client: &TraverseClient, state: &Arc>, base_url: &str, workspace: &str, - execution_id: &str, + session_id: &str, refresh_ui: &impl Fn(), ) { - loop { - match client - .poll_execution(base_url, workspace, execution_id) - .await - { - Ok(result) if result.status == "succeeded" => { - let trace = client - .fetch_trace(base_url, workspace, execution_id) - .await - .unwrap_or_default(); - let output = result.output.unwrap_or(crate::client::TraverseStarterOutput { - title: String::new(), - tags: vec![], - note_type: String::new(), - suggested_next_action: String::new(), - status: String::new(), - }); - state.lock().unwrap().phase = ExecutionPhase::Succeeded { output, trace }; - refresh_ui(); - return; + let mut stream = match client + .subscribe_events(base_url, workspace, DEFAULT_APP_ID) + .await + { + Ok(stream) => stream, + Err(err) => { + state.lock().unwrap().phase = ExecutionPhase::Failed { + error: err.to_string(), + }; + refresh_ui(); + return; + } + }; + + while let Some(item) = stream.next().await { + match item { + Ok(event) if event.event_type == "heartbeat" => continue, + Ok(event) => { + if let Some(sid) = event.session_id.as_deref() { + if sid != session_id { + continue; + } + } + if event.event_type == "error" + || matches!(event.state.as_ref(), Some(AppState::Error)) + { + state.lock().unwrap().phase = ExecutionPhase::Failed { + error: event + .error_message + .unwrap_or_else(|| "execution failed".to_string()), + }; + refresh_ui(); + return; + } + let terminal = matches!(event.state.as_ref(), Some(AppState::Results)) + || event.event_type == "capability_result"; + if terminal { + if let Some(output) = event.output { + let execution_id = event.execution_id.clone().unwrap_or_default(); + let trace = if execution_id.is_empty() { + Vec::new() + } else { + client + .fetch_trace(base_url, workspace, &execution_id) + .await + .unwrap_or_default() + }; + state.lock().unwrap().phase = + ExecutionPhase::Succeeded { output, trace }; + refresh_ui(); + return; + } + if matches!(event.state.as_ref(), Some(AppState::Results)) { + state.lock().unwrap().phase = ExecutionPhase::Succeeded { + output: TraverseStarterOutput { + title: String::new(), + tags: vec![], + note_type: String::new(), + suggested_next_action: String::new(), + status: String::new(), + }, + trace: Vec::new(), + }; + refresh_ui(); + return; + } + } } - Ok(result) if result.status == "failed" => { + Err(err) => { state.lock().unwrap().phase = ExecutionPhase::Failed { - error: result.error.unwrap_or_else(|| "execution failed".to_string()), + error: err.to_string(), }; refresh_ui(); return; } - Ok(_) => { - glib::timeout_future_seconds(1).await; - } - Err(err) => { - state.lock().unwrap().phase = ExecutionPhase::Failed { error: err.to_string() }; - refresh_ui(); - return; - } } } + + state.lock().unwrap().phase = ExecutionPhase::Failed { + error: "event stream ended before result".to_string(), + }; + refresh_ui(); } diff --git a/apps/traverse-starter/linux-gtk/tests/client_tests.rs b/apps/traverse-starter/linux-gtk/tests/client_tests.rs index 39e996f..df4824d 100644 --- a/apps/traverse-starter/linux-gtk/tests/client_tests.rs +++ b/apps/traverse-starter/linux-gtk/tests/client_tests.rs @@ -1,5 +1,4 @@ use traverse_starter_gtk::client::TraverseClient; -use traverse_starter_gtk::CAPABILITY_ID; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -17,50 +16,27 @@ async fn check_health_returns_true_on_200() { } #[tokio::test] -async fn execute_returns_execution_id() { +async fn submit_note_posts_command() { let server = MockServer::start().await; Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + .and(path("/v1/workspaces/local-default/apps/traverse-starter/commands")) + .respond_with(ResponseTemplate::new(202).set_body_json(serde_json::json!({ + "api_version": "v1", + "status": "accepted", + "workspace_id": "local-default", + "app_id": "traverse-starter", + "session_id": "sess-1", + "command": "submit", + "state": "processing", "execution_id": "exec_abc" }))) .mount(&server) .await; let client = TraverseClient::new(); - let id = client - .execute( - &server.uri(), - "local-default", - CAPABILITY_ID, - &serde_json::json!({ "note": "hello" }), - ) + let accepted = client + .submit_note(&server.uri(), "local-default", "hello") .await .unwrap(); - assert_eq!(id, "exec_abc"); -} - -#[tokio::test] -async fn poll_execution_parses_output() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": "succeeded", - "output": { - "title": "T", - "tags": ["a"], - "noteType": "n", - "suggestedNextAction": "x", - "status": "done" - } - }))) - .mount(&server) - .await; - - let client = TraverseClient::new(); - let result = client - .poll_execution(&server.uri(), "local-default", "exec_abc") - .await - .unwrap(); - assert_eq!(result.status, "succeeded"); - assert_eq!(result.output.unwrap().title, "T"); + assert_eq!(accepted.execution_id.as_deref(), Some("exec_abc")); } diff --git a/apps/traverse-starter/traverse-core-rs/Cargo.toml b/apps/traverse-starter/traverse-core-rs/Cargo.toml new file mode 100644 index 0000000..4e71a0f --- /dev/null +++ b/apps/traverse-starter/traverse-core-rs/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "traverse-core-rs" +version = "0.1.0" +edition = "2021" +description = "Shared Traverse HTTP/SSE client for traverse-starter Rust shells" +license = "MIT" + +[dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +futures-util = "0.3" +tokio = { version = "1", features = ["rt", "macros", "time"] } + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +wiremock = "0.6" +serde_json = "1" diff --git a/apps/traverse-starter/traverse-core-rs/src/client.rs b/apps/traverse-starter/traverse-core-rs/src/client.rs new file mode 100644 index 0000000..7250a86 --- /dev/null +++ b/apps/traverse-starter/traverse-core-rs/src/client.rs @@ -0,0 +1,221 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::sse; +use crate::state::StateEvent; +use crate::DEFAULT_APP_ID; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct TraverseStarterOutput { + pub title: String, + pub tags: Vec, + #[serde(rename = "noteType")] + pub note_type: String, + #[serde(rename = "suggestedNextAction")] + pub suggested_next_action: String, + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct TraceEvent { + pub event_type: String, + pub timestamp: String, + pub data: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CommandAccepted { + pub api_version: String, + pub status: String, + pub workspace_id: String, + pub app_id: String, + pub session_id: String, + pub command: String, + pub state: String, + pub execution_id: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum TraverseClientError { + Http(u16), + Decode, + Request(String), +} + +impl std::fmt::Display for TraverseClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Http(code) => write!(f, "HTTP {code}"), + Self::Decode => write!(f, "decode failed"), + Self::Request(msg) => write!(f, "request failed: {msg}"), + } + } +} + +impl std::error::Error for TraverseClientError {} + +#[derive(Clone)] +pub struct TraverseClient { + http: reqwest::Client, +} + +impl Default for TraverseClient { + fn default() -> Self { + Self::new() + } +} + +impl TraverseClient { + pub fn new() -> Self { + Self { + http: reqwest::Client::new(), + } + } + + pub fn with_client(http: reqwest::Client) -> Self { + Self { http } + } + + pub async fn health_check(&self, base_url: &str) -> Result { + let url = format!("{}/healthz", normalize_base_url(base_url)); + let response = self + .http + .get(url) + .send() + .await + .map_err(|e| TraverseClientError::Request(e.to_string()))?; + Ok(response.status().is_success()) + } + + /// Alias matching older shells. + pub async fn check_health(&self, base_url: &str) -> Result { + self.health_check(base_url).await + } + + pub async fn send_command( + &self, + base_url: &str, + workspace_id: &str, + app_id: &str, + command: &str, + payload: &Value, + session_id: Option<&str>, + ) -> Result { + let url = format!( + "{}/v1/workspaces/{}/apps/{}/commands", + normalize_base_url(base_url), + workspace_id, + app_id + ); + let mut body = json!({ + "command": command, + "payload": payload, + }); + if let Some(session_id) = session_id { + body["session_id"] = Value::String(session_id.to_string()); + } + let response = self + .http + .post(url) + .json(&body) + .send() + .await + .map_err(|e| TraverseClientError::Request(e.to_string()))?; + if !(200..300).contains(&response.status().as_u16()) { + return Err(TraverseClientError::Http(response.status().as_u16())); + } + let payload: CommandAcceptedResponse = response + .json() + .await + .map_err(|_| TraverseClientError::Decode)?; + Ok(CommandAccepted { + api_version: payload.api_version, + status: payload.status, + workspace_id: payload.workspace_id, + app_id: payload.app_id, + session_id: payload.session_id, + command: payload.command, + state: payload.state, + execution_id: payload.execution_id, + }) + } + + pub async fn submit_note( + &self, + base_url: &str, + workspace_id: &str, + note: &str, + ) -> Result { + self.send_command( + base_url, + workspace_id, + DEFAULT_APP_ID, + "submit", + &json!({ "note": note }), + None, + ) + .await + } + + pub async fn fetch_trace( + &self, + base_url: &str, + workspace_id: &str, + execution_id: &str, + ) -> Result, TraverseClientError> { + let url = format!( + "{}/v1/workspaces/{}/traces/{}", + normalize_base_url(base_url), + workspace_id, + execution_id + ); + let response = self + .http + .get(url) + .send() + .await + .map_err(|e| TraverseClientError::Request(e.to_string()))?; + if !response.status().is_success() { + return Err(TraverseClientError::Http(response.status().as_u16())); + } + response + .json() + .await + .map_err(|_| TraverseClientError::Decode) + } + + pub fn app_events_url(base_url: &str, workspace_id: &str, app_id: &str) -> String { + format!( + "{}/v1/workspaces/{}/apps/{}/events", + normalize_base_url(base_url), + workspace_id, + app_id + ) + } + + pub async fn subscribe_events( + &self, + base_url: &str, + workspace_id: &str, + app_id: &str, + ) -> Result>, TraverseClientError> + { + sse::subscribe_events(&self.http, base_url, workspace_id, app_id).await + } +} + +pub fn normalize_base_url(base_url: &str) -> String { + base_url.trim().trim_end_matches('/').to_string() +} + +#[derive(Debug, Deserialize)] +struct CommandAcceptedResponse { + api_version: String, + status: String, + workspace_id: String, + app_id: String, + session_id: String, + command: String, + state: String, + execution_id: Option, +} diff --git a/apps/traverse-starter/traverse-core-rs/src/discovery.rs b/apps/traverse-starter/traverse-core-rs/src/discovery.rs new file mode 100644 index 0000000..41f0fd0 --- /dev/null +++ b/apps/traverse-starter/traverse-core-rs/src/discovery.rs @@ -0,0 +1,53 @@ +use serde::Deserialize; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerInfo { + pub base_url: String, + pub workspace_default: String, +} + +#[derive(Debug, Deserialize)] +struct ServerJson { + base_url: String, + #[serde(default = "default_workspace")] + workspace_default: String, +} + +fn default_workspace() -> String { + "local-default".to_string() +} + +pub struct ServerDiscovery; + +impl ServerDiscovery { + pub fn from_file(path: impl AsRef) -> Option { + let data = fs::read_to_string(path).ok()?; + let parsed: ServerJson = serde_json::from_str(&data).ok()?; + Some(ServerInfo { + base_url: parsed.base_url, + workspace_default: parsed.workspace_default, + }) + } + + /// Walk parents from `start` looking for `.traverse/server.json`. + pub fn discover(start: impl AsRef) -> Option { + let mut current = start.as_ref().to_path_buf(); + for _ in 0..8 { + let candidate = current.join(".traverse").join("server.json"); + if let Some(info) = Self::from_file(&candidate) { + return Some(info); + } + if !current.pop() { + break; + } + } + None + } + + pub fn discover_cwd() -> Option { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + Self::discover(cwd) + } +} diff --git a/apps/traverse-starter/traverse-core-rs/src/lib.rs b/apps/traverse-starter/traverse-core-rs/src/lib.rs new file mode 100644 index 0000000..84b5278 --- /dev/null +++ b/apps/traverse-starter/traverse-core-rs/src/lib.rs @@ -0,0 +1,16 @@ +//! Shared Traverse runtime client for traverse-starter Rust shells. +//! Platform-neutral: no GTK or terminal UI imports. + +mod client; +mod discovery; +mod sse; +mod state; + +pub use client::{CommandAccepted, TraverseClient, TraverseClientError, TraverseStarterOutput, TraceEvent}; +pub use discovery::{ServerDiscovery, ServerInfo}; +pub use sse::subscribe_events; +pub use state::{AppState, StateEvent}; + +pub const DEFAULT_APP_ID: &str = "traverse-starter"; +pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8787"; +pub const DEFAULT_WORKSPACE: &str = "local-default"; diff --git a/apps/traverse-starter/traverse-core-rs/src/sse.rs b/apps/traverse-starter/traverse-core-rs/src/sse.rs new file mode 100644 index 0000000..8e16faa --- /dev/null +++ b/apps/traverse-starter/traverse-core-rs/src/sse.rs @@ -0,0 +1,100 @@ +use futures_util::{stream, StreamExt}; +use futures_util::stream::BoxStream; +use reqwest::Client; + +use crate::client::{TraverseClient, TraverseClientError}; +use crate::state::StateEvent; + +pub async fn subscribe_events( + http: &Client, + base_url: &str, + workspace_id: &str, + app_id: &str, +) -> Result>, TraverseClientError> { + let url = TraverseClient::app_events_url(base_url, workspace_id, app_id); + let response = http + .get(&url) + .header("Accept", "text/event-stream") + .send() + .await + .map_err(|e| TraverseClientError::Request(e.to_string()))?; + if !(200..300).contains(&response.status().as_u16()) { + return Err(TraverseClientError::Http(response.status().as_u16())); + } + + let byte_stream = response.bytes_stream(); + let event_stream = stream::unfold( + SseParser { + byte_stream, + buffer: String::new(), + }, + |mut parser| async move { + loop { + if let Some(event) = parser.pop_event() { + return Some((Ok(event), parser)); + } + match parser.byte_stream.next().await { + Some(Ok(chunk)) => { + parser.buffer.push_str(&String::from_utf8_lossy(&chunk)); + } + Some(Err(err)) => { + return Some(( + Err(TraverseClientError::Request(err.to_string())), + parser, + )); + } + None => return None, + } + } + }, + ); + + Ok(Box::pin(event_stream)) +} + +struct SseParser { + byte_stream: S, + buffer: String, +} + +impl SseParser { + fn pop_event(&mut self) -> Option { + let idx = self.buffer.find("\n\n")?; + let block = self.buffer[..idx].to_string(); + self.buffer = self.buffer[idx + 2..].to_string(); + parse_block(&block) + } +} + +fn parse_block(block: &str) -> Option { + let mut event_type = String::from("message"); + let mut data_lines: Vec = Vec::new(); + for line in block.lines() { + if line.starts_with(':') { + continue; + } + if let Some(rest) = line.strip_prefix("event:") { + event_type = rest.trim().to_string(); + } else if let Some(rest) = line.strip_prefix("data:") { + data_lines.push(rest.trim().to_string()); + } + } + if data_lines.is_empty() && event_type != "heartbeat" { + return None; + } + let data = data_lines.join("\n"); + StateEvent::from_sse(&event_type, &data) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_capability_result_block() { + let block = "event: capability_result\ndata: {\"state\":\"results\",\"session_id\":\"s1\",\"execution_id\":\"e1\",\"output\":{\"title\":\"T\",\"tags\":[],\"noteType\":\"n\",\"suggestedNextAction\":\"x\",\"status\":\"done\"}}"; + let event = parse_block(block).expect("event"); + assert_eq!(event.event_type, "capability_result"); + assert_eq!(event.output.as_ref().map(|o| o.title.as_str()), Some("T")); + } +} diff --git a/apps/traverse-starter/traverse-core-rs/src/state.rs b/apps/traverse-starter/traverse-core-rs/src/state.rs new file mode 100644 index 0000000..3041611 --- /dev/null +++ b/apps/traverse-starter/traverse-core-rs/src/state.rs @@ -0,0 +1,88 @@ +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppState { + Idle, + Processing, + Results, + Error, + Other(String), +} + +impl AppState { + pub fn from_runtime(name: &str) -> Self { + match name { + "idle" => Self::Idle, + "processing" => Self::Processing, + "results" => Self::Results, + "error" => Self::Error, + other => Self::Other(other.to_string()), + } + } + + pub fn as_str(&self) -> &str { + match self { + Self::Idle => "idle", + Self::Processing => "processing", + Self::Results => "results", + Self::Error => "error", + Self::Other(s) => s.as_str(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct StateEvent { + pub event_type: String, + pub state: Option, + pub session_id: Option, + pub execution_id: Option, + pub output: Option, + pub error_message: Option, + pub raw: Value, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EventPayload { + pub state: Option, + pub session_id: Option, + pub execution_id: Option, + pub output: Option, + pub error: Option, +} + +impl StateEvent { + pub fn from_sse(event_type: &str, data: &str) -> Option { + if event_type == "heartbeat" { + return Some(Self { + event_type: event_type.to_string(), + state: None, + session_id: None, + execution_id: None, + output: None, + error_message: None, + raw: Value::Null, + }); + } + let raw: Value = serde_json::from_str(data).ok()?; + let payload: EventPayload = serde_json::from_value(raw.clone()).ok()?; + let error_message = match payload.error { + Some(Value::String(s)) => Some(s), + Some(Value::Object(map)) => map + .get("message") + .and_then(|v| v.as_str()) + .map(str::to_string), + _ => None, + }; + Some(Self { + event_type: event_type.to_string(), + state: payload.state.as_deref().map(AppState::from_runtime), + session_id: payload.session_id, + execution_id: payload.execution_id, + output: payload.output, + error_message, + raw, + }) + } +} diff --git a/apps/traverse-starter/traverse-core-rs/tests/client_tests.rs b/apps/traverse-starter/traverse-core-rs/tests/client_tests.rs new file mode 100644 index 0000000..fe0c21e --- /dev/null +++ b/apps/traverse-starter/traverse-core-rs/tests/client_tests.rs @@ -0,0 +1,76 @@ +use serde_json::json; +use traverse_core_rs::{ServerDiscovery, StateEvent, TraverseClient}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn health_check_ok() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/healthz")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + let client = TraverseClient::new(); + assert!(client.health_check(&server.uri()).await.unwrap()); +} + +#[tokio::test] +async fn send_command_posts_to_apps_commands() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/workspaces/local-default/apps/traverse-starter/commands")) + .respond_with(ResponseTemplate::new(202).set_body_json(json!({ + "api_version": "v1", + "status": "accepted", + "workspace_id": "local-default", + "app_id": "traverse-starter", + "session_id": "sess-1", + "command": "submit", + "state": "processing", + "execution_id": "exec-1" + }))) + .mount(&server) + .await; + + let client = TraverseClient::new(); + let accepted = client + .submit_note(&server.uri(), "local-default", "hello") + .await + .unwrap(); + assert_eq!(accepted.session_id, "sess-1"); + assert_eq!(accepted.state, "processing"); +} + +#[test] +fn server_discovery_reads_json() { + let dir = tempfile_dir(); + let traverse = dir.join(".traverse"); + std::fs::create_dir_all(&traverse).unwrap(); + std::fs::write( + traverse.join("server.json"), + r#"{"base_url":"http://127.0.0.1:9999","workspace_default":"ws-a"}"#, + ) + .unwrap(); + let info = ServerDiscovery::from_file(traverse.join("server.json")).unwrap(); + assert_eq!(info.base_url, "http://127.0.0.1:9999"); + assert_eq!(info.workspace_default, "ws-a"); +} + +#[test] +fn state_event_from_sse() { + let event = StateEvent::from_sse( + "capability_result", + r#"{"state":"results","output":{"title":"T","tags":[],"noteType":"n","suggestedNextAction":"x","status":"done"}}"#, + ) + .unwrap(); + assert_eq!(event.output.unwrap().title, "T"); +} + +fn tempfile_dir() -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("traverse-core-rs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + path +} diff --git a/apps/traverse-starter/traverse-core-rs/tests/sse_tests.rs b/apps/traverse-starter/traverse-core-rs/tests/sse_tests.rs new file mode 100644 index 0000000..332ed00 --- /dev/null +++ b/apps/traverse-starter/traverse-core-rs/tests/sse_tests.rs @@ -0,0 +1,41 @@ +use traverse_core_rs::StateEvent; + +#[test] +fn parses_heartbeat() { + let event = StateEvent::from_sse("heartbeat", "").unwrap(); + assert_eq!(event.event_type, "heartbeat"); + assert!(event.output.is_none()); +} + +#[test] +fn parses_state_changed() { + let event = StateEvent::from_sse( + "state_changed", + r#"{"state":"processing","session_id":"s1"}"#, + ) + .unwrap(); + assert_eq!(event.state.unwrap().as_str(), "processing"); + assert_eq!(event.session_id.as_deref(), Some("s1")); +} + +#[test] +fn parses_capability_result_payload() { + let event = StateEvent::from_sse( + "capability_result", + r#"{"state":"results","session_id":"s1","execution_id":"e1","output":{"title":"Hello","tags":["a"],"noteType":"n","suggestedNextAction":"x","status":"done"}}"#, + ) + .unwrap(); + assert_eq!(event.event_type, "capability_result"); + assert_eq!(event.execution_id.as_deref(), Some("e1")); + assert_eq!(event.output.unwrap().title, "Hello"); +} + +#[test] +fn parses_error_object_message() { + let event = StateEvent::from_sse( + "error", + r#"{"state":"error","error":{"message":"boom"}}"#, + ) + .unwrap(); + assert_eq!(event.error_message.as_deref(), Some("boom")); +} diff --git a/scripts/ci/repository_checks.sh b/scripts/ci/repository_checks.sh index 57f48f8..3cf70f5 100644 --- a/scripts/ci/repository_checks.sh +++ b/scripts/ci/repository_checks.sh @@ -42,6 +42,8 @@ check "apps/traverse-starter/linux-gtk/Cargo.toml" "linux-g check "apps/traverse-starter/linux-gtk/README.md" "linux-gtk README" check "apps/traverse-starter/cli-rust/Cargo.toml" "cli-rust Cargo project" check "apps/traverse-starter/cli-rust/README.md" "cli-rust README" +check "apps/traverse-starter/traverse-core-rs/Cargo.toml" "traverse-core-rs Cargo crate" +check "apps/traverse-starter/Cargo.toml" "traverse-starter Cargo workspace" # doc-approval clients check "apps/doc-approval/web-react/package.json" "doc-approval web-react package"