Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dfde96f81 | ||
|
|
9a67137954 | ||
|
|
1f0ae9f551 | ||
|
|
22dd6fdda7 | ||
|
|
b272858edb | ||
|
|
3f79b283e2 | ||
|
|
9618ef413b | ||
|
|
0ad3b0e539 | ||
|
|
dc9fd4dbd1 | ||
|
|
8e6acc30d5 | ||
|
|
6d41ffc587 | ||
|
|
f51a32569f | ||
|
|
add6845edf | ||
|
|
4ab812d93b | ||
|
|
ff6a749c11 | ||
|
|
f2d97fb17d | ||
|
|
be278f8b1c | ||
|
|
910aa5e071 | ||
|
|
5ede65f454 | ||
|
|
4dc4f80fa3 | ||
|
|
3401d63063 | ||
|
|
be0a9582bb | ||
|
|
86cc412395 | ||
|
|
1afca97c1b | ||
|
|
a00aa9bec8 | ||
|
|
7d99cd6618 | ||
|
|
e075eb7acc | ||
|
|
d6de9735ab | ||
|
|
85d16ebe97 | ||
|
|
96084f7462 | ||
|
|
82c024a854 | ||
|
|
78e402cf14 | ||
|
|
3a6e32d8f3 | ||
|
|
e080d9fc6b | ||
|
|
ea0b498829 | ||
|
|
682007a250 | ||
|
|
7ffdf44135 | ||
|
|
ceb84790bb | ||
|
|
12a03fd3d1 | ||
|
|
059ca8ea24 | ||
|
|
c219b6ec58 | ||
|
|
8bb697a53f | ||
|
|
75e9cadcd5 | ||
|
|
c4e1d7c2b2 | ||
|
|
da39033e81 | ||
|
|
f62ac3f688 | ||
|
|
aaab953d8a | ||
|
|
e0d0860d7b | ||
|
|
dcfc5b9ec0 | ||
|
|
472162d135 | ||
|
|
71b1613d84 | ||
|
|
d93bdba59b | ||
|
|
3b6abd920e | ||
|
|
7f2509ecd2 | ||
|
|
2eebedfea5 | ||
|
|
930961bd85 | ||
|
|
93be92a5fb | ||
|
|
95cae8fd8a | ||
|
|
5c413cf9a3 | ||
|
|
c386030573 | ||
|
|
199028fa2e | ||
|
|
8d77e4565c | ||
|
|
683715cd7a | ||
|
|
2bb8e6f255 | ||
|
|
c3c0ef632a | ||
|
|
5dd835ff24 | ||
|
|
0a24903eb0 | ||
|
|
de8e3703f2 | ||
|
|
b7fc335bf5 | ||
|
|
821622e80d | ||
|
|
8fbc51534d | ||
|
|
3f284cbb9a | ||
|
|
54484ed137 | ||
|
|
9bf5236680 | ||
|
|
4428e8bc01 | ||
|
|
b2e848d124 | ||
|
|
3020a0431c | ||
|
|
6fbe1e2d1d | ||
|
|
9ba9a5048b | ||
|
|
188e7cc9a9 | ||
|
|
1a6a0c628e | ||
|
|
1c08b8e4e9 | ||
|
|
f87ab133f1 | ||
|
|
98615ca5b9 | ||
|
|
5e6d6deeab | ||
|
|
c5253b2ca3 | ||
|
|
a8adfcbf6d | ||
|
|
558908aef2 | ||
|
|
472c597c5e | ||
|
|
97aa75f2da | ||
|
|
dfceb8acac | ||
|
|
c6ab063c21 |
@@ -21,11 +21,11 @@ jobs:
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
- name: Build workspace
|
||||
run: cargo build --release --workspace
|
||||
|
||||
- name: Test
|
||||
run: cargo test
|
||||
- name: Test workspace
|
||||
run: cargo test --workspace
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
- name: Clippy workspace
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
|
||||
@@ -20,10 +20,10 @@ jobs:
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
run: cargo build --release --workspace
|
||||
|
||||
- name: Test
|
||||
run: cargo test
|
||||
run: cargo test --workspace
|
||||
|
||||
release:
|
||||
name: Semantic Release
|
||||
|
||||
@@ -5,3 +5,4 @@ node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
.superpowers/
|
||||
docs/lesson/
|
||||
+120
@@ -1,3 +1,123 @@
|
||||
# [1.15.0](https://github.com/asepharyana/zesdex/compare/v1.14.0...v1.15.0) (2026-07-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cms:** perbaiki serde default hive_mind_node_timeout_ms & toleransi parse gagal di Settings ([6d41ffc](https://github.com/asepharyana/zesdex/commit/6d41ffc587f6f8de5bb4757c7eb8d69077831057))
|
||||
* **cms:** satukan ChatMessage/Role Conversation dengan tipe kanonik zesdex-entities ([b272858](https://github.com/asepharyana/zesdex/commit/b272858edbe36af0b2eca119ff5b132de8dadce9))
|
||||
* **iam:** redirect_uri dinamis + validasi CSRF state di OAuthServiceImpl ([be278f8](https://github.com/asepharyana/zesdex/commit/be278f8b1c2bc1295368e49c38622ef2bc3ed9ee))
|
||||
* **iam:** set permission 0600 pada file token OAuth ([f2d97fb](https://github.com/asepharyana/zesdex/commit/f2d97fb17dcf4f52123da89532d7964ed8f8124c))
|
||||
* **middleware:** jangan percaya header X-Forwarded-For/X-Real-IP secara default di rate limiter ([4dc4f80](https://github.com/asepharyana/zesdex/commit/4dc4f80fa344d894c2ce62f75d239847cd13001a))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cms:** implement RewindBlobRepository for managing binary blobs ([22dd6fd](https://github.com/asepharyana/zesdex/commit/22dd6fdda7d0c8eabfce296bbad53587204c69fa))
|
||||
* **iam:** implementasikan FileSystemSessionLockRepository (sebelumnya belum ada implementasi) ([ff6a749](https://github.com/asepharyana/zesdex/commit/ff6a749c1149d890173922fbff76facef50a93b0))
|
||||
* **iam:** port LoopbackServer OAuth callback listener dari zesdex-backend ([910aa5e](https://github.com/asepharyana/zesdex/commit/910aa5e071911f158609c4dd8985f776d8d9235f))
|
||||
* **iam:** tambahkan CSPRNG (OsRng) untuk token state/PKCE ([5ede65f](https://github.com/asepharyana/zesdex/commit/5ede65f454b08303f588754a208dca0c37d3ce34))
|
||||
|
||||
# [1.14.0](https://github.com/asepharyana/zesdex/compare/v1.13.0...v1.14.0) (2026-07-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **context:** batasi squash_log ke tool bash saja ([7d99cd6](https://github.com/asepharyana/zesdex/commit/7d99cd66187b3fafb2ddeb19d8e8aa7db139df64))
|
||||
* **context:** perbaiki fixture test shaping agar men-drop pesan lama ([8bb697a](https://github.com/asepharyana/zesdex/commit/8bb697a53fe89c9d00f709df33ccbd7944482f07))
|
||||
* **plan:** perbaiki bug entropy gate dan fixture test squash.rs ([ceb8479](https://github.com/asepharyana/zesdex/commit/ceb84790bb741352b2b3416cda4bdbc6b42767c9))
|
||||
* **plan:** perbaiki fixture test array-cutoff squash_json ([7ffdf44](https://github.com/asepharyana/zesdex/commit/7ffdf441355cda0972b849bc9b475d3de8f39843))
|
||||
* **plan:** perbaiki fixture test shaping agar benar-benar men-drop pesan ([3a6e32d](https://github.com/asepharyana/zesdex/commit/3a6e32d8f3f0dd47d2c5ff63ab0c184b0ee4a6bc))
|
||||
* **plan:** perkuat fixture test log agar benar-benar uji squash_log ([682007a](https://github.com/asepharyana/zesdex/commit/682007a2507f209c2378796442d4bc879516191a))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **context:** tambah context::dedup untuk hasil tool yang berulang ([12a03fd](https://github.com/asepharyana/zesdex/commit/12a03fd3d1123c7a88289e44064537a71b9f574b))
|
||||
* **context:** tambah context::shaping (port dari shortsend) ([e080d9f](https://github.com/asepharyana/zesdex/commit/e080d9fc6b7daf2ab37fb242d03a3f64e8629acc))
|
||||
* **context:** tambah context::squash untuk kompresi hasil tool ([ea0b498](https://github.com/asepharyana/zesdex/commit/ea0b4988299894e4588d851ebc91704a9e73bc73))
|
||||
* **context:** tambah context::tokens dengan tiktoken-rs ([c219b6e](https://github.com/asepharyana/zesdex/commit/c219b6ec58ddad4770e491a48bbe1b6e3d0c8884))
|
||||
* **context:** tambah context::window::resolve ([059ca8e](https://github.com/asepharyana/zesdex/commit/059ca8ea246768dbec06ae5336433853e4276cec))
|
||||
* **runtime:** kompres hasil tool lewat squash sebelum masuk context ([96084f7](https://github.com/asepharyana/zesdex/commit/96084f74621636a72c209388580d0ebbb335d198))
|
||||
* **settings:** tambah mode ringkas opsional (concise_output) ([d6de973](https://github.com/asepharyana/zesdex/commit/d6de9735aba7745f5f57a8e7a5e625613c1a2569))
|
||||
|
||||
# [1.13.0](https://github.com/asepharyana/zesdex/compare/v1.12.0...v1.13.0) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct test assertion in dim_false_plain_text_has_no_color ([8d77e45](https://github.com/asepharyana/zesdex/commit/8d77e4565c2e3ca49058022c71e8277bdd790312))
|
||||
* Remove orphaned span_text helper function from markdown test module ([199028f](https://github.com/asepharyana/zesdex/commit/199028fa2eb6056dad2bdf0753554939c153a163))
|
||||
* **state:** cegah panic saat select mention dengan cursor stale ([dcfc5b9](https://github.com/asepharyana/zesdex/commit/dcfc5b9ec0d35f4c66b1648ded00f4f1279e0413))
|
||||
* **state:** jangan bangun mention index di mode attach ([e0d0860](https://github.com/asepharyana/zesdex/commit/e0d0860d7ba0ab6a4c9b9f1562f6a767a6424d99))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Deteksi trigger [@mention](https://github.com/mention) dan Tab-cycle di input handler ([2eebedf](https://github.com/asepharyana/zesdex/commit/2eebedfea56c7d4827f94a2739dbb5fa006e3069))
|
||||
* **ipc:** dukung Ctrl+Y clipboard copy di mode daemon/attach ([472162d](https://github.com/asepharyana/zesdex/commit/472162d135478666913a888a68867e635e50d14a))
|
||||
* Judul dropdown autocomplete mengikuti jenisnya (Commands vs Files) ([7f2509e](https://github.com/asepharyana/zesdex/commit/7f2509ecd2688495545333debee0c81016b28641))
|
||||
* **state:** Alirkan mention_index lewat ToolCtx dan AppStateRest, bangun index di background thread ([93be92a](https://github.com/asepharyana/zesdex/commit/93be92a5fb8644c9020478c49421585ade2d0fd1))
|
||||
* Tambah Ctrl+Y untuk menyalin pesan assistant terakhir ([d93bdba](https://github.com/asepharyana/zesdex/commit/d93bdba59b7dd14b4a76dc91c6b7f70a3c0f721c))
|
||||
* Tambah field pending_clipboard_copy di MiscState ([3b6abd9](https://github.com/asepharyana/zesdex/commit/3b6abd920ea92329ebcf888c3bb50470675c4152))
|
||||
* Tambah helper truncate_diff untuk membatasi panjang diff ([5dd835f](https://github.com/asepharyana/zesdex/commit/5dd835ff244a138bb5f1a8dbfd7cf252c2671e5e))
|
||||
* Tambah MentionIndex, AutocompleteKind, dan deteksi [@mention](https://github.com/mention) di InputState ([95cae8f](https://github.com/asepharyana/zesdex/commit/95cae8fd8a8ce737ba60c3ac8bea77f4c2e14b1a))
|
||||
* Tambah write_osc52 dan salin ke clipboard di mode single-process ([71b1613](https://github.com/asepharyana/zesdex/commit/71b1613d8467cfcb2383b3fce153a25c7883ac3d))
|
||||
* Tambahkan file baru ke mention_index saat tool write membuatnya ([930961b](https://github.com/asepharyana/zesdex/commit/930961bd85701ab8906b8d81095e46d3635a951d))
|
||||
* Tampilkan unified diff pada hasil tool edit ([c3c0ef6](https://github.com/asepharyana/zesdex/commit/c3c0ef632a605d8bf32712049035e4fc814af9d5))
|
||||
* Tampilkan unified diff saat tool write menimpa file yang sudah ada ([2bb8e6f](https://github.com/asepharyana/zesdex/commit/2bb8e6f2555eac01baf2311d41811afad4aa3041))
|
||||
* **view:** Tambah parameter dim dan pewarnaan baris diff di markdown renderer ([683715c](https://github.com/asepharyana/zesdex/commit/683715cd7af77d87a2ae3834360bb6f7a4388bd0))
|
||||
|
||||
# [1.12.0](https://github.com/asepharyana/zesdex/compare/v1.11.0...v1.12.0) (2026-07-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add mouse capture functionality to terminal and enhance markdown rendering with table support ([4428e8b](https://github.com/asepharyana/zesdex/commit/4428e8bc01c196415ac408a42f57305227a79760))
|
||||
* Improve markdown rendering with enhanced line wrapping and indentation for code blocks ([b2e848d](https://github.com/asepharyana/zesdex/commit/b2e848d124e726c4d8b644d473e518398fab1dea))
|
||||
|
||||
# [1.11.0](https://github.com/asepharyana/zesdex/compare/v1.10.0...v1.11.0) (2026-07-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Enhance subagent tool output handling and clarify workflow directives ([6fbe1e2](https://github.com/asepharyana/zesdex/commit/6fbe1e2d1dc790ba2509803b3ab3a848d5b2a63b))
|
||||
* Enhance token usage tracking and improve chat UI with emojis ([188e7cc](https://github.com/asepharyana/zesdex/commit/188e7cc9a9233140a5e4953e3f3ff66682914e42))
|
||||
|
||||
# [1.10.0](https://github.com/asepharyana/zesdex/compare/v1.9.0...v1.10.0) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align format strings in sidebar Usage widget ([98615ca](https://github.com/asepharyana/zesdex/commit/98615ca5b9d896331a5a6d9af91035aca1f5e9d5))
|
||||
* use {:>6}: for aligned colons in sidebar Usage widget ([f87ab13](https://github.com/asepharyana/zesdex/commit/f87ab133f1953633f66e21b9eaf7c4eb41291ccd))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Implement lesson generation feature and update status display ([1c08b8e](https://github.com/asepharyana/zesdex/commit/1c08b8e4e9c3bb1318535a74c9812beb976df315))
|
||||
|
||||
# [1.9.0](https://github.com/asepharyana/zesdex/compare/v1.8.0...v1.9.0) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **workflow:** import Color style for improved agent state rendering ([472c597](https://github.com/asepharyana/zesdex/commit/472c597c5e4ab12808a6bcd1899628bc7ab77186))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **agent:** refine cognitive cycle plan with structured phases for exploration, planning, and execution ([c5253b2](https://github.com/asepharyana/zesdex/commit/c5253b2ca359d4dbed9445e04f1dec1a6bb37e8f))
|
||||
* **subagent:** add progress event handling and formatting for subagent execution ([558908a](https://github.com/asepharyana/zesdex/commit/558908aef216e61a0a108083fbac5e02c31501dc))
|
||||
* **subagent:** emit reasoning text as progress in StepCompleted events ([97aa75f](https://github.com/asepharyana/zesdex/commit/97aa75f2da37aee5fc7a0626fc396988f089fff2))
|
||||
* **subagent:** include tool call arguments in ToolResult events and progress formatting ([a8adfcb](https://github.com/asepharyana/zesdex/commit/a8adfcbf6dc5411e977f22ac6b6ba023f563d7c9))
|
||||
|
||||
# [1.8.0](https://github.com/asepharyana/zesdex/compare/v1.7.0...v1.8.0) (2026-07-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tools:** require reason argument for delete and git_operator tools ([c6ab063](https://github.com/asepharyana/zesdex/commit/c6ab063c211fb858fd0e155883b9c47b345f0f8a))
|
||||
|
||||
# [1.7.0](https://github.com/asepharyana/zesdex/compare/v1.6.0...v1.7.0) (2026-07-14)
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Detailed architecture documentation is in `docs/CODEMAPS/`:
|
||||
- **Error handling** — `anyhow::Result` and `anyhow::bail!` throughout. No custom error types.
|
||||
- **Static strings** — MCP tool descriptions use `Box::leak` + `OnceLock` cache.
|
||||
- **Tools** — `trait Tool { fn name() -> &str, fn run() -> Result<String> }`, 28 impls, gated by `Harness`.
|
||||
- **Shell safety** — `tool/shell_filter/` blocks credential leaks and destructive git commands.
|
||||
- **Shell safety** — `tool/shell_filter/` blocks destructive git commands (`shell_filter::git::check_git_destructive`, called from `tool/shell.rs::Bash::run`). It also contains a `check_credential_read` detector for credential-file reads, but that one is intentionally NOT wired into `Bash::run` today — see the doc comment on `Bash::run` for why.
|
||||
|
||||
### Hive-Mind Orchestration (Machine Intelligence)
|
||||
|
||||
|
||||
Generated
+382
-15
@@ -47,6 +47,18 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -114,12 +126,82 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
@@ -171,6 +253,15 @@ version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -191,11 +282,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.3"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
|
||||
checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@@ -619,6 +711,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -807,6 +900,17 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fast-srgb8"
|
||||
version = "1.0.0"
|
||||
@@ -1084,9 +1188,9 @@ checksum = "43503cc176394dd30a6525f5f36e838339b8b5619be33ed9a7783841580a97b6"
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.18"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
|
||||
checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
@@ -1212,6 +1316,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.13"
|
||||
@@ -1235,6 +1345,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -1433,9 +1544,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.28"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2adf14691c72bcfc1058740436a35bdd3ae9c07d1a941ef00b749e9ea16aefa7"
|
||||
checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
@@ -1598,6 +1709,21 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.12"
|
||||
@@ -1778,6 +1904,12 @@ dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -1915,6 +2047,26 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nucleo-matcher"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[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-conv"
|
||||
version = "0.2.2"
|
||||
@@ -1932,6 +2084,15 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -2096,12 +2257,33 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -2453,6 +2635,9 @@ name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
@@ -2592,9 +2777,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.0"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -2604,9 +2789,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.15"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
|
||||
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -3008,6 +3193,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -3129,9 +3325,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
@@ -3149,6 +3345,27 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6505efef05804732ed8a3f2d4f279429eb485bd69d5b0cc6b19cc02005cda16"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3362,7 +3579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -3502,6 +3719,21 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken-rs"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bstr",
|
||||
"fancy-regex 0.17.0",
|
||||
"lazy_static",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.53"
|
||||
@@ -3643,6 +3875,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3656,6 +3889,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
@@ -3681,6 +3915,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -4435,8 +4670,8 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex"
|
||||
version = "1.7.0"
|
||||
name = "zesdex-backend"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -4453,6 +4688,7 @@ dependencies = [
|
||||
"infer",
|
||||
"libc",
|
||||
"lsp-types",
|
||||
"nucleo-matcher",
|
||||
"percent-encoding",
|
||||
"pulldown-cmark",
|
||||
"ratatui",
|
||||
@@ -4465,13 +4701,144 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml_ng",
|
||||
"sha2 0.11.0",
|
||||
"similar",
|
||||
"syntect",
|
||||
"tiktoken-rs",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"webbrowser",
|
||||
"zesdex-cms",
|
||||
"zesdex-entities",
|
||||
"zesdex-iam",
|
||||
"zesdex-infra",
|
||||
"zesdex-ipc",
|
||||
"zesdex-middleware",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-cms"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-entities",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-entities"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-iam"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"hex",
|
||||
"libc",
|
||||
"rand_core 0.6.4",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zesdex-entities",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-infra"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"chrono",
|
||||
"jsonwebtoken",
|
||||
"rand_core 0.6.4",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-cms",
|
||||
"zesdex-entities",
|
||||
"zesdex-iam",
|
||||
"zesdex-middleware",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-ipc"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"zesdex-entities",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-middleware"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"zesdex-entities",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-utils"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+52
-33
@@ -1,11 +1,22 @@
|
||||
[package]
|
||||
name = "zesdex"
|
||||
version = "1.7.0"
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/zesdex-entities",
|
||||
"crates/zesdex-utils",
|
||||
"crates/zesdex-ipc",
|
||||
"crates/zesdex-iam",
|
||||
"crates/zesdex-cms",
|
||||
"crates/zesdex-middleware",
|
||||
"crates/zesdex-infra",
|
||||
"crates/zesdex-backend",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.15.0"
|
||||
edition = "2021"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
|
||||
# Treat all warnings as errors, set strict clippy levels
|
||||
[lints.rust]
|
||||
[workspace.lints.rust]
|
||||
unused = "deny"
|
||||
dead_code = "deny"
|
||||
unreachable_code = "deny"
|
||||
@@ -17,46 +28,54 @@ deprecated = "deny"
|
||||
trivial_casts = "deny"
|
||||
trivial_numeric_casts = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
[workspace.lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -2 }
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.30.2"
|
||||
crossterm = "0.29"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
|
||||
reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] }
|
||||
dom_smoothie = "0.18.0"
|
||||
fast_html2md = "0.0.62"
|
||||
scraper = "0.27.0"
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml_ng = "0.10"
|
||||
anyhow = "1"
|
||||
include_dir = "0.7"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "v5"] }
|
||||
dirs = "6"
|
||||
futures-util = "0.3"
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] }
|
||||
ratatui = "0.30.2"
|
||||
crossterm = "0.29"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
ignore = "0.4"
|
||||
regex = "1"
|
||||
globset = "0.4"
|
||||
infer = "0.19"
|
||||
thiserror = "1"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.11"
|
||||
hex = "0.4"
|
||||
libc = "0.2"
|
||||
dirs = "6"
|
||||
regex = "1"
|
||||
globset = "0.4"
|
||||
ignore = "0.4"
|
||||
nucleo-matcher = "0.3"
|
||||
futures-util = "0.3"
|
||||
rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
||||
tracing = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
webbrowser = "1"
|
||||
lsp-types = "0.97"
|
||||
tiktoken-rs = "0.12"
|
||||
similar = "3"
|
||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
infer = "0.19"
|
||||
webbrowser = "1"
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
dom_smoothie = "0.18.0"
|
||||
fast_html2md = "0.0.62"
|
||||
scraper = "0.27.0"
|
||||
include_dir = "0.7"
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "limit"] }
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = "9"
|
||||
|
||||
[[bin]]
|
||||
name = "zesdex"
|
||||
path = "src/main.rs"
|
||||
zesdex-entities = { path = "crates/zesdex-entities" }
|
||||
zesdex-utils = { path = "crates/zesdex-utils" }
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Zesdex — Multi-stage Docker build
|
||||
# ===================================
|
||||
# Stage 1: Build with Rust toolchain
|
||||
FROM rust:1.85-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libsqlite3-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Build with release profile (treats warnings as errors via lints)
|
||||
RUN cargo build --release -p zesdex-backend --bin zesdex
|
||||
|
||||
# Stage 2: Minimal runtime image
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libsqlite3-0 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/zesdex /usr/local/bin/zesdex
|
||||
|
||||
ENV ZESDEX_DATA_DIR=/data
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/zesdex"]
|
||||
@@ -0,0 +1,66 @@
|
||||
[package]
|
||||
name = "zesdex-backend"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# Workspace crates
|
||||
zesdex-entities = { path = "../zesdex-entities" }
|
||||
zesdex-utils = { path = "../zesdex-utils" }
|
||||
zesdex-ipc = { path = "../zesdex-ipc" }
|
||||
zesdex-iam = { path = "../zesdex-iam" }
|
||||
zesdex-cms = { path = "../zesdex-cms" }
|
||||
zesdex-middleware = { path = "../zesdex-middleware" }
|
||||
zesdex-infra = { path = "../zesdex-infra" }
|
||||
|
||||
# External deps
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml_ng.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
reqwest.workspace = true
|
||||
ratatui.workspace = true
|
||||
crossterm.workspace = true
|
||||
rusqlite.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
libc.workspace = true
|
||||
dirs.workspace = true
|
||||
regex.workspace = true
|
||||
globset.workspace = true
|
||||
ignore.workspace = true
|
||||
nucleo-matcher.workspace = true
|
||||
futures-util.workspace = true
|
||||
rmcp.workspace = true
|
||||
lsp-types.workspace = true
|
||||
tiktoken-rs.workspace = true
|
||||
similar.workspace = true
|
||||
syntect.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
infer.workspace = true
|
||||
webbrowser.workspace = true
|
||||
url.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
dom_smoothie.workspace = true
|
||||
fast_html2md.workspace = true
|
||||
scraper.workspace = true
|
||||
include_dir.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "zesdex"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "seed"
|
||||
path = "src/bin/seed.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "migrate"
|
||||
path = "src/bin/migrate.rs"
|
||||
@@ -8,25 +8,27 @@ For complex multi-step tasks that would benefit from parallel analysis or
|
||||
independent verification, use workflow_run to orchestrate sub-agents.
|
||||
|
||||
Core tools:
|
||||
- read(path) — Read file contents. Use when you need to inspect code.
|
||||
- grep(pattern, path?) — Search for a pattern in files.
|
||||
- glob(pattern) — List files matching a glob pattern.
|
||||
- write(path, content, reason) — Write content to a file. Reason is required.
|
||||
- edit(path, old, new, replace_all?, reason) — Replace text in a file. Reason is required.
|
||||
- delete(path) — Delete a file or empty directory.
|
||||
- bash(command) — Run a shell command. Use for builds, tests, git ops.
|
||||
- read(path, limit?) — Read file contents. Use when you need to inspect code.
|
||||
- grep(pattern, path) — Search for a pattern in files.
|
||||
- glob(pattern, path) — List files matching a glob pattern in a directory.
|
||||
- write(path, content, reason) — Write content to a file. Reason is required (>= 8 chars).
|
||||
- edit(path, old, new, replace_all?, reason) — Replace text in a file. Reason is required (>= 8 chars).
|
||||
- delete(path, reason) — Delete a file or empty directory. Reason is required (>= 8 chars).
|
||||
- bash(command, description?, timeout?, run_in_background?) — Run a shell command.
|
||||
- bash_output(job_id) — Poll output of a background bash job.
|
||||
- bash_kill(job_id) — Kill a background bash job.
|
||||
- cd(path) — Change working directory.
|
||||
- dir_list(path) — List directory contents.
|
||||
- dir_cache_update() — Refresh the directory cache.
|
||||
- dir_cache_update(path) — Refresh the directory cache for a path.
|
||||
- pong(message?) — Simple connectivity check. Echoes back the message.
|
||||
|
||||
Git tools:
|
||||
- git_operator(args, confirm_destructive?) — Run git commands. Some destructive
|
||||
operations (force-push, reset --hard, branch -D) require confirm_destructive=true.
|
||||
- git_worktree(args) — Manage git worktrees.
|
||||
- git_cred(operation) — Manage git credentials.
|
||||
- git_operator(operation, args, reason) — Run git commands (e.g. add, commit, status,
|
||||
diff, log). Reason explaining the operation is required (>= 8 chars). Destructive
|
||||
operations (force-push, reset --hard, branch -D) are blocked by the shell filter.
|
||||
- git_worktree(name, base_ref) — Manage git worktrees: create a new worktree
|
||||
with a given name and base ref (branch or commit).
|
||||
- git_cred(operation) — Manage git credentials (store, get, or erase).
|
||||
|
||||
|
||||
Memory & Planning:
|
||||
@@ -86,5 +88,6 @@ Language Server Protocol (LSP) tools:
|
||||
LSP auto-provisioning runs at startup for Rust (rust-analyzer), TypeScript
|
||||
(typescript-language-server), Go (gopls), and Java (jdtls).
|
||||
|
||||
Each write/edit call MUST include a non-empty reason argument explaining
|
||||
why the change is being made. This is enforced deterministically.
|
||||
Each write/edit/delete/git_operator call MUST include a non-empty reason
|
||||
argument (>= 8 chars) explaining why the operation is being made. This is
|
||||
enforced deterministically.
|
||||
@@ -1,4 +1,9 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Global registry of running background bash jobs, and control operations
|
||||
//! (output polling, kill) exposed to the rest of the app.
|
||||
//!
|
||||
@@ -10,7 +15,6 @@
|
||||
//! Why: a single static map (rather than storing jobs in `AppStateRest`)
|
||||
//! lets background jobs outlive the borrow of any particular state mutation
|
||||
//! and be looked up by id from tool calls issued at arbitrary points.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
@@ -44,7 +48,11 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
||||
while let Some(line) = job.try_read_line() {
|
||||
lines.push(line);
|
||||
}
|
||||
if lines.is_empty() { None } else { Some(lines) }
|
||||
if lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(lines)
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate a running background bash job and remove it from the registry.
|
||||
@@ -58,7 +66,9 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
||||
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
|
||||
/// with that id exists.
|
||||
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
||||
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {e}"))?;
|
||||
let mut map = bash_jobs_map()
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("lock error: {e}"))?;
|
||||
let job = map.remove(id);
|
||||
match job {
|
||||
Some(job) => {
|
||||
@@ -9,11 +9,10 @@
|
||||
//! Why: running bash commands on a detached thread with a channel (rather
|
||||
//! than synchronously) lets the TUI stay responsive while long-running
|
||||
//! shell commands execute in the background.
|
||||
|
||||
use std::io::BufRead;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::io::BufRead;
|
||||
|
||||
/// Maximum number of output lines buffered in memory per background job.
|
||||
/// Beyond this limit, old output is dropped to prevent OOM (CWE-770).
|
||||
@@ -59,7 +58,9 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
// Spawn a named thread for easier debugging. If Builder::spawn fails
|
||||
// (e.g. OS resource limit), fall back to unnameable thread::spawn.
|
||||
let thread_name = format!("bgbash-{}", &thread_id[..8.min(thread_id.len())]);
|
||||
if thread::Builder::new().name(thread_name).spawn({
|
||||
if thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.spawn({
|
||||
// Clone everything the closure captures so we can also pass it
|
||||
// to the fallback thread without moving.
|
||||
let cmd = cmd.clone();
|
||||
@@ -67,9 +68,13 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
let pid_tx = pid_tx.clone();
|
||||
let id_for_log = id_for_log.clone();
|
||||
move || spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log)
|
||||
}).is_err()
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log);
|
||||
tracing::warn!(
|
||||
"[bgbash:{}] failed to spawn named thread, using unnamed fallback",
|
||||
id_for_log
|
||||
);
|
||||
thread::spawn(move || {
|
||||
spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log);
|
||||
});
|
||||
@@ -139,7 +144,8 @@ fn spawn_bash_thread_body(
|
||||
if output_tx.try_send(line).is_err() {
|
||||
tracing::debug!(
|
||||
"[bgbash:{}] output buffer full ({} lines), discarding remaining output",
|
||||
id_for_log, MAX_OUTPUT_LINES,
|
||||
id_for_log,
|
||||
MAX_OUTPUT_LINES,
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Background bash: run shell commands off the main thread, poll their
|
||||
//! output non-blockingly, and terminate them on demand.
|
||||
|
||||
pub mod control;
|
||||
pub mod job;
|
||||
@@ -0,0 +1,467 @@
|
||||
//! Tool-call gating: decides whether a risky tool call is allowed to run
|
||||
//! before it executes. Implements hooks-style pre-checks for write/edit/delete
|
||||
//! and bash tools so the agent cannot silently introduce stubs, denial
|
||||
//! patterns, assumption language, or destructive commands.
|
||||
|
||||
pub mod patterns;
|
||||
|
||||
use patterns::*;
|
||||
|
||||
/// Outcome of gating a tool call: whether it's allowed to run.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Verdict {
|
||||
Allow,
|
||||
Block(String),
|
||||
}
|
||||
|
||||
/// Gatekeeper that decides whether a tool call may proceed before execution.
|
||||
pub struct Guard;
|
||||
|
||||
impl Guard {
|
||||
/// Decide whether a tool call is allowed to execute.
|
||||
///
|
||||
/// Flow: ALL tools are gated (not just risky ones), closing the bypass
|
||||
/// for MCP tools (which are never in the risky list). Delegates to
|
||||
/// smaller helper methods for each concern: path traversal, output
|
||||
/// path validation, content scanning, bash safety, and reason checks.
|
||||
///
|
||||
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
|
||||
pub fn gate_tool_call(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
workspace_roots: &[&std::path::Path],
|
||||
) -> Verdict {
|
||||
let is_risky = crate::tool::tool_is_risky(tool_name);
|
||||
let is_mcp = tool_name.starts_with("mcp__");
|
||||
|
||||
// Universal checks applied to EVERY tool.
|
||||
if let Some(v) = Self::check_path_traversal(args, workspace_roots) {
|
||||
return v;
|
||||
}
|
||||
if let Some(v) = Self::check_output_path(tool_name, args, workspace_roots) {
|
||||
return v;
|
||||
}
|
||||
|
||||
// Non-risky, non-MCP tools pass after universal checks.
|
||||
if !is_risky && !is_mcp {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
|
||||
// File-mutating tools: require a meaningful reason.
|
||||
if matches!(tool_name, "write" | "edit" | "delete") {
|
||||
if let Err(msg) = Self::validate_reason(tool_name, args) {
|
||||
return Verdict::Block(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// write / edit content scanning for stub/denial/assumption patterns.
|
||||
if let Some(v) = Self::check_content_safety(tool_name, args) {
|
||||
return v;
|
||||
}
|
||||
|
||||
// Bash-specific destructive / exfiltration checks.
|
||||
if let Some(v) = Self::check_bash_safety(args) {
|
||||
return v;
|
||||
}
|
||||
|
||||
// git_operator: require a non-trivial reason.
|
||||
if tool_name == "git_operator" && !Self::has_valid_reason(args, MIN_REASON_LEN) {
|
||||
if args.get("reason").and_then(|v| v.as_str()).is_some() {
|
||||
return Verdict::Block(format!(
|
||||
"git_operator requires a non-trivial 'reason' \
|
||||
(>= {MIN_REASON_LEN} chars) explaining the operation"
|
||||
));
|
||||
}
|
||||
return Verdict::Block(
|
||||
"git_operator requires a 'reason' argument explaining the operation".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// MCP tools: require a reason when they take meaningful arguments.
|
||||
if is_mcp {
|
||||
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
|
||||
if reason.trim().len() < MIN_REASON_LEN {
|
||||
return Verdict::Block(format!(
|
||||
"MCP tool '{tool_name}' requires a non-trivial 'reason' \
|
||||
(>= {MIN_REASON_LEN} chars) explaining why it is needed"
|
||||
));
|
||||
}
|
||||
} else if args.as_object().is_some_and(|m| !m.is_empty()) {
|
||||
return Verdict::Block(format!(
|
||||
"MCP tool '{tool_name}' requires a 'reason' argument \
|
||||
explaining the operation"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Verdict::Allow
|
||||
}
|
||||
|
||||
/// Check for path traversal in the `path` argument and verify it stays
|
||||
/// within workspace roots.
|
||||
///
|
||||
/// Flow: reject any path containing `..` → if workspace roots are set,
|
||||
/// reject absolute paths outside every root.
|
||||
///
|
||||
/// Return: `Some(Verdict::Block)` on violation, `None` if the check
|
||||
/// passes or the tool has no `path` argument.
|
||||
fn check_path_traversal(
|
||||
args: &serde_json::Value,
|
||||
workspace_roots: &[&std::path::Path],
|
||||
) -> Option<Verdict> {
|
||||
let path = args.get("path")?.as_str()?;
|
||||
if path.contains("..") {
|
||||
return Some(Verdict::Block(
|
||||
"path traversal detected in 'path' argument".to_string(),
|
||||
));
|
||||
}
|
||||
if !workspace_roots.is_empty() {
|
||||
let abs_check = std::path::PathBuf::from(path);
|
||||
if abs_check.is_absolute() && !workspace_roots.iter().any(|r| abs_check.starts_with(r))
|
||||
{
|
||||
return Some(Verdict::Block(format!(
|
||||
"absolute path '{path}' is outside all workspace roots"
|
||||
)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Verify that a tool's output path (if any) stays within workspace roots.
|
||||
///
|
||||
/// Flow: if `find_output_path` yields a path, reject it unless it's
|
||||
/// under `/tmp`, already absolute, or within a workspace root.
|
||||
///
|
||||
/// Return: `Some(Verdict::Block)` on violation, `None` otherwise.
|
||||
fn check_output_path(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
workspace_roots: &[&std::path::Path],
|
||||
) -> Option<Verdict> {
|
||||
let out_path = Self::find_output_path(tool_name, args)?;
|
||||
if !workspace_roots.is_empty() && !out_path.starts_with("/tmp") && !out_path.is_absolute() {
|
||||
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
|
||||
if !allowed {
|
||||
return Some(Verdict::Block(format!(
|
||||
"output path '{}' is outside all workspace roots",
|
||||
out_path.display(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check write/edit content for stub, denial, and assumption patterns.
|
||||
///
|
||||
/// Return: `Some(Verdict::Block)` with a description of the first
|
||||
/// matched pattern, `None` if the content is clean or not applicable.
|
||||
fn check_content_safety(tool_name: &str, args: &serde_json::Value) -> Option<Verdict> {
|
||||
if !matches!(tool_name, "write" | "edit") {
|
||||
return None;
|
||||
}
|
||||
let content = Self::extract_content(tool_name, args)?;
|
||||
for (patterns, msg_prefix) in [
|
||||
(&STUB_PATTERNS, "stub/placeholder"),
|
||||
(&DENIAL_PATTERNS, "denial/punt"),
|
||||
(&ASSUMPTION_PATTERNS, "assumption"),
|
||||
] {
|
||||
if let Some(pat) = Self::first_match(&content, patterns) {
|
||||
let msg = match msg_prefix {
|
||||
"stub/placeholder" => format!(
|
||||
"content contains stub/placeholder pattern '{pat}'; \
|
||||
production code must be fully implemented — \
|
||||
replace the stub with a real implementation"
|
||||
),
|
||||
"denial/punt" => format!(
|
||||
"content contains denial/punt pattern '{pat}'; \
|
||||
implement the change properly instead of skipping"
|
||||
),
|
||||
_ => format!(
|
||||
"content contains assumption pattern '{pat}'; \
|
||||
verify against data/tests instead of guessing"
|
||||
),
|
||||
};
|
||||
return Some(Verdict::Block(msg));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check bash commands for path traversal, exfiltration, sensitive
|
||||
/// path reads, destructive patterns, and stub language.
|
||||
///
|
||||
/// Flow: extract the `command` argument → check each category in
|
||||
/// sequence, returning the first violation found.
|
||||
///
|
||||
/// Return: `Some(Verdict::Block)` on any violation, `None` if the
|
||||
/// tool is not bash or the command is safe.
|
||||
fn check_bash_safety(args: &serde_json::Value) -> Option<Verdict> {
|
||||
let cmd = args.get("command")?.as_str()?;
|
||||
if cmd.contains("..") {
|
||||
return Some(Verdict::Block(
|
||||
"path traversal detected in bash command".to_string(),
|
||||
));
|
||||
}
|
||||
for pat in EXFIL_PATTERNS {
|
||||
if cmd.contains(pat) {
|
||||
return Some(Verdict::Block(format!(
|
||||
"potential data-exfiltration command blocked (matched '{pat}')"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for pat in SENSITIVE_PATH_PATTERNS {
|
||||
if cmd.contains(pat) {
|
||||
return Some(Verdict::Block(format!(
|
||||
"refused to read/write sensitive path '{pat}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let dangerous_patterns = [
|
||||
"rm -rf /",
|
||||
"rm -rf --no-preserve-root",
|
||||
"rm -rf ~",
|
||||
"rm -fr /",
|
||||
"mkfs.",
|
||||
"dd if=",
|
||||
":(){",
|
||||
"> /dev/sda",
|
||||
"chmod -R 000 /",
|
||||
"shutdown ",
|
||||
"poweroff ",
|
||||
"reboot ",
|
||||
"halt ",
|
||||
];
|
||||
for pat in &dangerous_patterns {
|
||||
if cmd.contains(pat) {
|
||||
return Some(Verdict::Block(format!(
|
||||
"destructive command pattern blocked: {pat}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(pat) = Self::first_match(cmd, STUB_PATTERNS) {
|
||||
return Some(Verdict::Block(format!(
|
||||
"bash command contains stub pattern '{pat}'"
|
||||
)));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check whether the given `args` contain a non-trivial `reason`
|
||||
/// argument meeting the minimum length requirement.
|
||||
fn has_valid_reason(args: &serde_json::Value, min_len: usize) -> bool {
|
||||
args.get("reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|r| r.trim().len() >= min_len)
|
||||
}
|
||||
|
||||
/// Validate the `reason` argument for a mutating tool.
|
||||
///
|
||||
/// Flow: require the field to exist and be a non-empty string ≥
|
||||
/// `MIN_REASON_LEN` chars after trimming.
|
||||
///
|
||||
/// Why: hook-style gates force the agent to articulate the *why* of
|
||||
/// every change, which both deters lazy writes and produces a useful
|
||||
/// audit trail in the edit log.
|
||||
fn validate_reason(tool_name: &str, args: &serde_json::Value) -> Result<(), String> {
|
||||
let reason = match args.get("reason") {
|
||||
None => {
|
||||
return Err(format!(
|
||||
"{tool_name} requires a non-empty 'reason' argument \
|
||||
explaining why the change is being made"
|
||||
));
|
||||
}
|
||||
Some(v) => match v.as_str() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(format!("{tool_name} 'reason' must be a string"));
|
||||
}
|
||||
},
|
||||
};
|
||||
let trimmed = reason.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{tool_name} 'reason' must not be empty"));
|
||||
}
|
||||
if trimmed.len() < MIN_REASON_LEN {
|
||||
return Err(format!(
|
||||
"{tool_name} 'reason' must be at least {MIN_REASON_LEN} chars \
|
||||
(got {}) — explain WHY, not just WHAT",
|
||||
trimmed.len()
|
||||
));
|
||||
}
|
||||
// Reject generic non-answers
|
||||
let lower = trimmed.to_lowercase();
|
||||
let non_answers = [
|
||||
"fix",
|
||||
"update",
|
||||
"change",
|
||||
"edit",
|
||||
"modify",
|
||||
"implement",
|
||||
"add",
|
||||
"remove",
|
||||
"delete",
|
||||
"make it work",
|
||||
"make work",
|
||||
"test",
|
||||
"wip",
|
||||
"tbd",
|
||||
];
|
||||
if non_answers.iter().any(|n| lower == *n) {
|
||||
return Err(format!(
|
||||
"{tool_name} 'reason' '{trimmed}' is too generic — \
|
||||
describe what changes and why (e.g. 'switch to Result<T> for \
|
||||
safer error propagation per user request')"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the textual content of a write/edit call, if any.
|
||||
fn extract_content(tool_name: &str, args: &serde_json::Value) -> Option<String> {
|
||||
match tool_name {
|
||||
"write" => args
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
"edit" => {
|
||||
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
|
||||
Some(format!("{old}\n{new}"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the first pattern (case-insensitive substring) that matches
|
||||
/// `text`, or `None` if no pattern matched.
|
||||
fn first_match(text: &str, patterns: &'static [&'static str]) -> Option<&'static str> {
|
||||
let lower = text.to_lowercase();
|
||||
let iter: std::slice::Iter<'static, &'static str> = patterns.iter();
|
||||
iter.copied().find(|p| lower.contains(&p.to_lowercase()))
|
||||
}
|
||||
|
||||
/// Extract a candidate output path from a tool call, if one exists.
|
||||
fn find_output_path(tool_name: &str, args: &serde_json::Value) -> Option<std::path::PathBuf> {
|
||||
match tool_name {
|
||||
"write" | "edit" | "delete" | "read" => args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(std::path::PathBuf::from),
|
||||
"bash" => {
|
||||
let cmd = args.get("command").and_then(|v| v.as_str())?;
|
||||
let lower = cmd.to_lowercase();
|
||||
for prefix in &["cp ", "mv ", "install ", "ln -s ", "cat >", "cat >>"] {
|
||||
if let Some(rest) = lower.strip_prefix(prefix) {
|
||||
if let Some(target) = rest.split_whitespace().last() {
|
||||
if !target.starts_with('-') {
|
||||
return Some(std::path::PathBuf::from(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Guard {
|
||||
fn default() -> Self {
|
||||
Guard
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn parse_verdict(text: &str) -> Option<Verdict> {
|
||||
let trimmed = text.trim();
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if let Some(verdict) = v.get("verdict").and_then(|v| v.as_str()) {
|
||||
return match verdict.to_lowercase().as_str() {
|
||||
"allow" => Some(Verdict::Allow),
|
||||
"block" => Some(Verdict::Block(
|
||||
v.get("reason")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("blocked")
|
||||
.to_string(),
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
for line in trimmed.lines() {
|
||||
let l = line.trim().to_lowercase();
|
||||
if l.starts_with("verdict: allow") {
|
||||
return Some(Verdict::Allow);
|
||||
}
|
||||
if l.starts_with("verdict: block") {
|
||||
let reason = line
|
||||
.split_once(':')
|
||||
.map_or("blocked", |x| x.1)
|
||||
.trim()
|
||||
.to_string();
|
||||
return Some(Verdict::Block(reason));
|
||||
}
|
||||
}
|
||||
if trimmed.to_lowercase().contains("allow") {
|
||||
return Some(Verdict::Allow);
|
||||
}
|
||||
if trimmed.to_lowercase().contains("block") {
|
||||
return Some(Verdict::Block("blocked by classifier".to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate_tool_non_risky_always_allows() {
|
||||
let roots: &[&std::path::Path] = &[];
|
||||
let result = Guard::gate_tool_call("read", &json!({"path": "test.txt"}), roots);
|
||||
assert_eq!(result, Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_json_allow() {
|
||||
let v = parse_verdict(r#"{"verdict": "allow"}"#);
|
||||
assert_eq!(v, Some(Verdict::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_json_block() {
|
||||
let v = parse_verdict(r#"{"verdict": "block", "reason": "dangerous operation"}"#);
|
||||
assert_eq!(v, Some(Verdict::Block("dangerous operation".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_text_allow() {
|
||||
let v = parse_verdict("Verdict: Allow");
|
||||
assert_eq!(v, Some(Verdict::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_text_block() {
|
||||
let v = parse_verdict("Verdict: Block - this operation is not allowed");
|
||||
assert!(matches!(v, Some(Verdict::Block(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_fallback_allow() {
|
||||
let v = parse_verdict("I think we should allow this operation");
|
||||
assert_eq!(v, Some(Verdict::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_fallback_block() {
|
||||
let v = parse_verdict("This request should be blocked");
|
||||
assert!(matches!(v, Some(Verdict::Block(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_unparseable() {
|
||||
let v = parse_verdict("completely unrelated text with no keywords");
|
||||
assert_eq!(v, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Pattern constants for tool-call content safety gating.
|
||||
//!
|
||||
//! These are shared between the main agent's `Guard` and the subagent
|
||||
//! engine's `gate_subagent_tool_call` — extracted here so both can
|
||||
//! reference the same canonical list without duplication.
|
||||
|
||||
/// Stub / placeholder / denial / assumption patterns that should never reach
|
||||
/// a file in real code. Detected in write/edit content and bash heredocs.
|
||||
pub const STUB_PATTERNS: &[&str] = &[
|
||||
"todo!()",
|
||||
"todo!(",
|
||||
"unimplemented!()",
|
||||
"unimplemented!(",
|
||||
"todo_macro",
|
||||
"FIXME",
|
||||
"fixme:",
|
||||
"XXX:",
|
||||
"PLACEHOLDER",
|
||||
"REPLACE_ME",
|
||||
"stub_value",
|
||||
"stub_function",
|
||||
"fake_response",
|
||||
"fake_data",
|
||||
"not implemented",
|
||||
"not yet implemented",
|
||||
"to be implemented",
|
||||
"to be done",
|
||||
];
|
||||
|
||||
/// Language patterns indicating the AI is denying responsibility or
|
||||
/// punting the work ("I'll skip this", "for now just", etc).
|
||||
pub const DENIAL_PATTERNS: &[&str] = &[
|
||||
"// skip",
|
||||
"// skipping",
|
||||
"// skipping for now",
|
||||
"// for now just",
|
||||
"// punt",
|
||||
"// punted",
|
||||
"// hack:",
|
||||
"// hacky",
|
||||
"// hack workaround",
|
||||
"// workaround:",
|
||||
"// cba",
|
||||
"// later",
|
||||
"// do later",
|
||||
"// ignore for now",
|
||||
"// disable",
|
||||
"// disabled",
|
||||
"// bypass",
|
||||
"// quick fix",
|
||||
"// temp fix",
|
||||
"// temporary fix",
|
||||
"// temp:",
|
||||
"// temporary:",
|
||||
"// noop",
|
||||
];
|
||||
|
||||
/// Assumption-language patterns: words/phrases that indicate the code is
|
||||
/// reasoning based on guesswork rather than data.
|
||||
pub const ASSUMPTION_PATTERNS: &[&str] = &[
|
||||
"// assume",
|
||||
"// assuming",
|
||||
"// probably",
|
||||
"// maybe",
|
||||
"// might",
|
||||
"// should work",
|
||||
"// hopefully",
|
||||
"// guess",
|
||||
"// i think",
|
||||
"// should be fine",
|
||||
"// should be",
|
||||
"// likely",
|
||||
"// ought to",
|
||||
];
|
||||
|
||||
/// Network-exfiltration and credential-disclosure patterns for bash.
|
||||
pub const EXFIL_PATTERNS: &[&str] = &[
|
||||
"curl ",
|
||||
"wget ",
|
||||
"nc -e ",
|
||||
"ncat ",
|
||||
"/dev/tcp/",
|
||||
"base64 -d |",
|
||||
"base64 --decode |",
|
||||
"openssl s_client",
|
||||
"ssh -R ",
|
||||
"scp /",
|
||||
"rsync /",
|
||||
];
|
||||
|
||||
/// Substrings of well-known credential / secret files that bash must not read.
|
||||
pub const SENSITIVE_PATH_PATTERNS: &[&str] = &[
|
||||
".ssh/id_rsa",
|
||||
".ssh/id_ed25519",
|
||||
".ssh/authorized_keys",
|
||||
".aws/credentials",
|
||||
".aws/config",
|
||||
".netrc",
|
||||
".pypirc",
|
||||
".npmrc",
|
||||
".kube/config",
|
||||
".docker/config.json",
|
||||
".gnupg/",
|
||||
"/etc/shadow",
|
||||
"/etc/passwd",
|
||||
"/proc/self/environ",
|
||||
];
|
||||
|
||||
/// Minimum character length of a `reason` argument to be considered meaningful.
|
||||
pub const MIN_REASON_LEN: usize = 8;
|
||||
@@ -47,13 +47,20 @@ impl LspClient {
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{command}': {e}"))?;
|
||||
|
||||
let stdin = child.stdin.take()
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
|
||||
let stdout = BufReader::new(child.stdout.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?);
|
||||
let stdout = BufReader::new(
|
||||
child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?,
|
||||
);
|
||||
|
||||
let mut client = LspClient {
|
||||
stdin,
|
||||
@@ -106,7 +113,11 @@ impl LspClient {
|
||||
}
|
||||
});
|
||||
|
||||
let result = client.call_with_timeout("initialize", &init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?;
|
||||
let result = client.call_with_timeout(
|
||||
"initialize",
|
||||
&init_params,
|
||||
Duration::from_millis(LSP_INIT_TIMEOUT_MS),
|
||||
)?;
|
||||
client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
|
||||
|
||||
client.notify("initialized", &json!({}))?;
|
||||
@@ -122,7 +133,12 @@ impl LspClient {
|
||||
self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
fn call_with_timeout(&mut self, method: &str, params: &Value, timeout: Duration) -> anyhow::Result<Value> {
|
||||
fn call_with_timeout(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: &Value,
|
||||
timeout: Duration,
|
||||
) -> anyhow::Result<Value> {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
let req = json!({
|
||||
@@ -148,11 +164,14 @@ impl LspClient {
|
||||
let body = serde_json::to_string(msg)
|
||||
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?;
|
||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||
self.stdin.write_all(header.as_bytes())
|
||||
self.stdin
|
||||
.write_all(header.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {e}"))?;
|
||||
self.stdin.write_all(body.as_bytes())
|
||||
self.stdin
|
||||
.write_all(body.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {e}"))?;
|
||||
self.stdin.flush()
|
||||
self.stdin
|
||||
.flush()
|
||||
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -166,8 +185,14 @@ impl LspClient {
|
||||
let frame = self.read_frame()?;
|
||||
if frame.get("id") == Some(&json!(expected_id)) {
|
||||
if let Some(err) = frame.get("error") {
|
||||
let code = err.get("code").and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error");
|
||||
let code = err
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(0);
|
||||
let msg = err
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
anyhow::bail!("LSP error {code}: {msg}");
|
||||
}
|
||||
return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
|
||||
@@ -205,8 +230,9 @@ impl LspClient {
|
||||
// Cap Content-Length at 64 MiB to prevent OOM from a
|
||||
// malicious or misconfigured LSP server (CWE-400).
|
||||
const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024;
|
||||
let length: usize = len_str.trim().parse::<usize>()
|
||||
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
|
||||
let length: usize = len_str.trim().parse::<usize>().map_err(|e| {
|
||||
anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e)
|
||||
})?;
|
||||
if length > MAX_CONTENT_LENGTH {
|
||||
anyhow::bail!(
|
||||
"Content-Length {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes",
|
||||
@@ -220,7 +246,8 @@ impl LspClient {
|
||||
.ok_or_else(|| anyhow::anyhow!("missing Content-Length header in LSP response"))?;
|
||||
|
||||
let mut body = vec![0u8; length];
|
||||
self.stdout.read_exact(&mut body)
|
||||
self.stdout
|
||||
.read_exact(&mut body)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({length} bytes): {e}"))?;
|
||||
|
||||
let json_str = String::from_utf8(body)
|
||||
@@ -230,20 +257,30 @@ impl LspClient {
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {e}"))
|
||||
}
|
||||
|
||||
pub fn did_open(&mut self, uri: &str, language_id: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didOpen", &json!({
|
||||
pub fn did_open(
|
||||
&mut self,
|
||||
uri: &str,
|
||||
language_id: &str,
|
||||
version: i32,
|
||||
text: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
self.notify(
|
||||
"textDocument/didOpen",
|
||||
&json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"languageId": language_id,
|
||||
"version": version,
|
||||
"text": text
|
||||
}
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didChange", &json!({
|
||||
self.notify(
|
||||
"textDocument/didChange",
|
||||
&json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"version": version
|
||||
@@ -251,53 +288,67 @@ impl LspClient {
|
||||
"contentChanges": [{
|
||||
"text": text
|
||||
}]
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didClose", &json!({
|
||||
self.notify(
|
||||
"textDocument/didClose",
|
||||
&json!({
|
||||
"textDocument": {
|
||||
"uri": uri
|
||||
}
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/hover", &json!({
|
||||
self.call(
|
||||
"textDocument/hover",
|
||||
&json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/completion", &json!({
|
||||
self.call(
|
||||
"textDocument/completion",
|
||||
&json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/definition", &json!({
|
||||
pub fn goto_definition(
|
||||
&mut self,
|
||||
uri: &str,
|
||||
line: u32,
|
||||
character: u32,
|
||||
) -> anyhow::Result<Value> {
|
||||
self.call(
|
||||
"textDocument/definition",
|
||||
&json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/references", &json!({
|
||||
self.call(
|
||||
"textDocument/references",
|
||||
&json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character },
|
||||
"context": {
|
||||
"includeDeclaration": true
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/documentSymbol", &json!({
|
||||
"textDocument": { "uri": uri }
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn collect_diagnostics(
|
||||
@@ -313,64 +364,14 @@ impl LspClient {
|
||||
);
|
||||
self.did_close(uri)?;
|
||||
match result {
|
||||
Ok(params) => Ok(params.get("diagnostics").cloned().unwrap_or_else(|| json!([]))),
|
||||
Ok(params) => Ok(params
|
||||
.get("diagnostics")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([]))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Health-check the LSP server.
|
||||
///
|
||||
/// Sends a `textDocument/documentSymbol` request on a dummy URI with a
|
||||
/// 2-second timeout. Returns `true` if the server responds at all —
|
||||
/// including with an error response such as "file not found", which
|
||||
/// still proves the process is up and the JSON-RPC channel is live.
|
||||
/// Returns `false` on timeout, EOF, or any read/write error.
|
||||
///
|
||||
/// Flow: build request → `send_frame` → poll frames until id matches
|
||||
/// (alive) or deadline/read error fires (dead).
|
||||
#[allow(dead_code)]
|
||||
pub fn is_alive(&mut self) -> bool {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "textDocument/documentSymbol",
|
||||
"params": {
|
||||
"textDocument": { "uri": "file:///__zesdex_lsp_health_check__.txt" }
|
||||
}
|
||||
});
|
||||
if self.send_frame(&req).is_err() {
|
||||
return false;
|
||||
}
|
||||
let timeout = Duration::from_secs(2);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if Instant::now() > deadline {
|
||||
return false;
|
||||
}
|
||||
match self.read_frame() {
|
||||
Ok(frame) => {
|
||||
if frame.get("id") == Some(&json!(id)) {
|
||||
return true;
|
||||
}
|
||||
// Skip unrelated notifications/responses on the same channel.
|
||||
}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the LSP `exit` notification to request graceful shutdown.
|
||||
///
|
||||
/// Per the LSP spec, `exit` is a notification — the server is expected
|
||||
/// to terminate after receiving it without sending a response. We do
|
||||
/// not block on any reply.
|
||||
#[allow(dead_code)]
|
||||
pub fn exit(&mut self) -> anyhow::Result<()> {
|
||||
self.notify("exit", &json!({}))
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self) {
|
||||
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
|
||||
let _ = self.notify("exit", &json!({}));
|
||||
@@ -13,12 +13,6 @@ pub use client::{path_to_lsp_uri, LspClient};
|
||||
/// to issue LSP requests from threads or async tasks.
|
||||
#[derive(Clone)]
|
||||
pub struct LspServer {
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
#[allow(dead_code)]
|
||||
pub command: String,
|
||||
#[allow(dead_code)]
|
||||
pub args: Vec<String>,
|
||||
pub language_id: String,
|
||||
pub client: Arc<Mutex<LspClient>>,
|
||||
}
|
||||
@@ -38,12 +32,12 @@ pub struct OpenDoc {
|
||||
///
|
||||
/// Flow: caller calls `connect*` -> client spawned -> entry pushed to
|
||||
/// `servers` -> `extension_registry` is populated by `register_extensions`.
|
||||
/// File edits route through `find_server_for_path` / `find_server_for_extension`
|
||||
/// and are dispatched as `didOpen` / `didChange` notifications.
|
||||
/// File edits route through `extension_registry` and are dispatched as
|
||||
/// `didOpen` / `didChange` notifications.
|
||||
#[derive(Clone)]
|
||||
pub struct LspManager {
|
||||
pub servers: Vec<LspServer>,
|
||||
/// Maps file extension (".rs", ".ts", ...) -> server name.
|
||||
/// Maps file extension (".rs", ".ts", ...) -> language id.
|
||||
pub extension_registry: HashMap<String, String>,
|
||||
/// Maps document URI -> tracked open document state.
|
||||
pub open_files: HashMap<String, OpenDoc>,
|
||||
@@ -59,112 +53,73 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn an LSP server and register it under `name`.
|
||||
/// Spawn an LSP server and register it under `language_id`.
|
||||
///
|
||||
/// Fails if a server with the same name is already connected.
|
||||
/// Fails if a server with the same `language_id` is already connected.
|
||||
pub fn connect(
|
||||
&mut self,
|
||||
name: &str,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
language_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.servers.iter().any(|s| s.name == name) {
|
||||
anyhow::bail!("LSP server '{name}' is already connected");
|
||||
if self.servers.iter().any(|s| s.language_id == language_id) {
|
||||
anyhow::bail!("LSP server for language '{language_id}' is already connected");
|
||||
}
|
||||
let client = LspClient::spawn(command, args)?;
|
||||
self.servers.push(LspServer {
|
||||
name: name.to_string(),
|
||||
command: command.to_string(),
|
||||
args: args.to_vec(),
|
||||
language_id: language_id.to_string(),
|
||||
client: Arc::new(Mutex::new(client)),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a connected server by name and return a reference to its entry.
|
||||
#[allow(dead_code)]
|
||||
pub fn find_server(&self, name: &str) -> Option<&LspServer> {
|
||||
self.servers.iter().find(|s| s.name == name)
|
||||
}
|
||||
|
||||
/// Return a clone of the `Arc<Mutex<LspClient>>` for a connected server.
|
||||
///
|
||||
/// Cloning the `Arc` lets callers issue requests without holding a
|
||||
/// borrow on the manager.
|
||||
pub fn get_client(&self, name: &str) -> Option<Arc<Mutex<LspClient>>> {
|
||||
self.servers.iter().find(|s| s.name == name).map(|s| s.client.clone())
|
||||
pub fn get_client(&self, language_id: &str) -> Option<Arc<Mutex<LspClient>>> {
|
||||
self.servers
|
||||
.iter()
|
||||
.find(|s| s.language_id == language_id)
|
||||
.map(|s| s.client.clone())
|
||||
}
|
||||
|
||||
/// Shut down and remove a server by name. Returns true if it existed.
|
||||
pub fn disconnect(&mut self, name: &str) -> bool {
|
||||
if let Some(server) = self.servers.iter().find(|s| s.name == name) {
|
||||
/// Shut down and remove a server by language. Returns true if it existed.
|
||||
pub fn disconnect(&mut self, language_id: &str) -> bool {
|
||||
if let Some(server) = self.servers.iter().find(|s| s.language_id == language_id) {
|
||||
if let Ok(mut client) = server.client.lock() {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
let len = self.servers.len();
|
||||
self.servers.retain(|s| s.name != name);
|
||||
self.servers.retain(|s| s.language_id != language_id);
|
||||
self.servers.len() < len
|
||||
}
|
||||
|
||||
/// Return the language id (e.g. "rust") registered for `name`.
|
||||
pub fn get_language_id(&self, name: &str) -> Option<String> {
|
||||
self.servers.iter().find(|s| s.name == name).map(|s| s.language_id.clone())
|
||||
}
|
||||
|
||||
/// Resolve an extension (".rs", ".ts", ...) to its server's client.
|
||||
///
|
||||
/// Flow: lookup `extension_registry` -> resolve server name -> clone client.
|
||||
/// Returns `None` if no server has been registered for `ext`.
|
||||
#[allow(dead_code)]
|
||||
pub fn find_server_for_extension(&self, ext: &str) -> Option<Arc<Mutex<LspClient>>> {
|
||||
self.extension_registry
|
||||
.get(ext)
|
||||
.and_then(|name| self.get_client(name))
|
||||
}
|
||||
|
||||
/// Resolve a file path to its server's client by extension.
|
||||
///
|
||||
/// Flow: extract the extension from `path` -> delegate to
|
||||
/// `find_server_for_extension`. Files without an extension or with
|
||||
/// an unmapped extension return `None`.
|
||||
#[allow(dead_code)]
|
||||
pub fn find_server_for_path(&self, path: &Path) -> Option<Arc<Mutex<LspClient>>> {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| format!(".{s}"))
|
||||
.and_then(|ext| self.find_server_for_extension(&ext))
|
||||
/// Return the language id (e.g. "rust") registered for `language_id`.
|
||||
pub fn get_language_id(&self, language_id: &str) -> Option<String> {
|
||||
self.servers
|
||||
.iter()
|
||||
.find(|s| s.language_id == language_id)
|
||||
.map(|s| s.language_id.clone())
|
||||
}
|
||||
|
||||
/// Register a set of file extensions for an already-connected server.
|
||||
///
|
||||
/// Flow: for each `ext`, write `server_name` into `extension_registry`.
|
||||
/// Re-registration overwrites the previous target. Unknown server
|
||||
/// names are accepted at this layer — caller must ensure `server_name`
|
||||
/// is connected or will be connected later.
|
||||
pub fn register_extensions(&mut self, server_name: &str, extensions: &[&str]) {
|
||||
/// Flow: for each `ext`, write `language_id` into `extension_registry`.
|
||||
/// Re-registration overwrites the previous target. Unknown language IDs
|
||||
/// are accepted at this layer — caller must ensure a server for
|
||||
/// `language_id` is connected or will be connected later.
|
||||
pub fn register_extensions(&mut self, language_id: &str, extensions: &[&str]) {
|
||||
for ext in extensions {
|
||||
self.extension_registry.insert(ext.to_string(), server_name.to_string());
|
||||
self.extension_registry
|
||||
.insert(ext.to_string(), language_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the registered server name for a given language id.
|
||||
///
|
||||
/// Flow: scan `servers` for the first entry whose `language_id` matches.
|
||||
/// Used when callers have a language hint rather than a file path.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_server_name(&self, language: &str) -> Option<String> {
|
||||
self.servers
|
||||
.iter()
|
||||
.find(|s| s.language_id == language)
|
||||
.map(|s| s.name.clone())
|
||||
}
|
||||
|
||||
/// Notify the relevant LSP server that a file's contents have changed.
|
||||
///
|
||||
/// Flow: resolve server by extension -> read file contents ->
|
||||
/// Flow: resolve language by extension from the registry -> read file contents ->
|
||||
/// either send `didOpen` (first time) or `didChange` (already tracked)
|
||||
/// -> update `open_files` with the new version.
|
||||
///
|
||||
@@ -172,13 +127,20 @@ impl LspManager {
|
||||
/// error) are logged with `tracing::warn!` rather than propagated,
|
||||
/// so a stale notification cannot abort the calling flow.
|
||||
pub fn did_change_file(&mut self, path: &Path) {
|
||||
let Some(ext) = path.extension().and_then(|e| e.to_str()).map(|s| format!(".{s}")) else {
|
||||
let Some(ext) = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| format!(".{s}"))
|
||||
else {
|
||||
tracing::warn!("did_change_file: path has no extension: {:?}", path);
|
||||
return;
|
||||
};
|
||||
|
||||
let server_name = if let Some(name) = self.extension_registry.get(&ext) { name.clone() } else {
|
||||
tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
|
||||
let Some(language_id) = self.extension_registry.get(&ext).cloned() else {
|
||||
tracing::warn!(
|
||||
"did_change_file: no LSP server registered for extension '{}'",
|
||||
ext
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -192,12 +154,8 @@ impl LspManager {
|
||||
}
|
||||
};
|
||||
|
||||
let language_id = self
|
||||
.get_language_id(&server_name)
|
||||
.unwrap_or_else(|| "plaintext".to_string());
|
||||
|
||||
let Some(client) = self.get_client(&server_name) else {
|
||||
tracing::warn!("did_change_file: server '{}' has no client", server_name);
|
||||
let Some(client) = self.get_client(&language_id) else {
|
||||
tracing::warn!("did_change_file: no client for language '{}'", language_id);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -210,7 +168,11 @@ impl LspManager {
|
||||
let mut client = match client.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e);
|
||||
tracing::warn!(
|
||||
"did_change_file: client mutex poisoned for '{}': {}",
|
||||
language_id,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -224,7 +186,7 @@ impl LspManager {
|
||||
if let Err(e) = send_result {
|
||||
tracing::warn!(
|
||||
"did_change_file: failed to notify '{}' for {}: {}",
|
||||
server_name,
|
||||
language_id,
|
||||
uri,
|
||||
e
|
||||
);
|
||||
@@ -238,24 +200,6 @@ impl LspManager {
|
||||
version: next_version,
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/// Record that `server_name` has an open document at `uri`.
|
||||
///
|
||||
/// Flow: insert/overwrite the `OpenDoc` entry in `open_files`.
|
||||
/// Does not contact the LSP server — pure local bookkeeping.
|
||||
#[allow(dead_code)]
|
||||
pub fn track_open_doc(&mut self, server_name: &str, uri: &str, language: &str, version: i32) {
|
||||
// server_name retained for future routing extensions; not stored today.
|
||||
let _ = server_name;
|
||||
self.open_files.insert(
|
||||
uri.to_string(),
|
||||
OpenDoc {
|
||||
language: language.to_string(),
|
||||
version,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Shut down every connected server and clear the server list.
|
||||
@@ -272,21 +216,20 @@ impl LspManager {
|
||||
self.servers.clear();
|
||||
}
|
||||
|
||||
/// Snapshot the connected servers as `(name, language_id, has_open_docs)` triples.
|
||||
/// Snapshot the connected servers as `(language_id, has_open_docs)` pairs.
|
||||
///
|
||||
/// `has_open_docs` is true if any tracked `OpenDoc` was registered
|
||||
/// against this server's clients. Useful for status displays.
|
||||
pub fn list_servers(&self) -> Vec<(String, String, bool)> {
|
||||
pub fn list_servers(&self) -> Vec<(String, bool)> {
|
||||
self.servers
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let name = s.name.clone();
|
||||
let lang = s.language_id.clone();
|
||||
let has_open = self
|
||||
.open_files
|
||||
.values()
|
||||
.any(|d| d.language == s.language_id);
|
||||
(name, lang, has_open)
|
||||
(lang, has_open)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -294,18 +237,17 @@ impl LspManager {
|
||||
/// Connect an LSP server and register its default extensions in one call.
|
||||
///
|
||||
/// Flow: invoke `connect` -> on success, register `extensions` against
|
||||
/// `name` in `extension_registry`. If `connect` fails, the registries
|
||||
/// `language_id` in `extension_registry`. If `connect` fails, the registries
|
||||
/// are left untouched and the error is propagated.
|
||||
pub fn connect_with_extensions(
|
||||
&mut self,
|
||||
name: &str,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
language_id: &str,
|
||||
extensions: &[&str],
|
||||
) -> anyhow::Result<()> {
|
||||
self.connect(name, command, args, language_id)?;
|
||||
self.register_extensions(name, extensions);
|
||||
self.connect(command, args, language_id)?;
|
||||
self.register_extensions(language_id, extensions);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -315,4 +257,3 @@ impl Default for LspManager {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Static language server definitions and core types.
|
||||
//!
|
||||
//! Defines the set of supported LSP servers, their install tiers, and the
|
||||
//! result/enum types used across the provisioner.
|
||||
|
||||
/// Optional progress callback type (non-owning, caller ensures liveness
|
||||
/// for the duration of the provisioning call).
|
||||
/// Intended to be hooked up to a UI toast / status-bar mechanism.
|
||||
pub type ProgressFn<'a> = Option<&'a dyn Fn(&str)>;
|
||||
|
||||
/// Result of attempting to make a single language server available.
|
||||
///
|
||||
/// The caller should switch on this variant: `AlreadyAvailable` and
|
||||
/// Installed both mean the binary can be launched; Failed means we
|
||||
/// gave up and the user needs to install manually (see `manual_instructions`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProvisionResult {
|
||||
/// Binary was already on PATH — no install was needed.
|
||||
AlreadyAvailable {
|
||||
server_name: String,
|
||||
language: String,
|
||||
binary_path: String,
|
||||
},
|
||||
/// Provisioner successfully installed the binary during this run.
|
||||
Installed {
|
||||
server_name: String,
|
||||
language: String,
|
||||
binary_path: String,
|
||||
},
|
||||
/// Every install tier failed. Tells the user how to install by hand.
|
||||
Failed {
|
||||
language: String,
|
||||
server_name: String,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Sentinel command names used by `provision_single` to detect "download"
|
||||
/// tiers (which are dispatched to `download_*` helpers rather than
|
||||
/// `run_command`). Kept as constants so `supported_servers` stays readable.
|
||||
pub(super) const DOWNLOAD_RUST_BIN: &str = "__download_rust_analyzer__";
|
||||
pub(super) const DOWNLOAD_JDTLS: &str = "__download_jdtls__";
|
||||
|
||||
/// Static description of a single language server: how to detect it,
|
||||
/// what file extensions it handles, and how to install it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LanguageServerDef {
|
||||
/// Human-readable server name (e.g. "rust-analyzer").
|
||||
pub name: String,
|
||||
/// LSP language identifier (e.g. "rust").
|
||||
pub language: String,
|
||||
/// File extensions this server handles (with leading dot).
|
||||
pub extensions: Vec<String>,
|
||||
/// Candidate binary names — the provisioner accepts whichever appears on PATH.
|
||||
pub binary_names: Vec<String>,
|
||||
/// Install strategies, tried in order until one succeeds.
|
||||
pub install_tiers: Vec<InstallTier>,
|
||||
}
|
||||
|
||||
/// A single install attempt: a command (plus args) gated by a prerequisite.
|
||||
///
|
||||
/// `requires` lists binaries that must already be on PATH for this tier
|
||||
/// to be considered. If any required binary is missing, the tier is
|
||||
/// skipped (not attempted) so we don't produce misleading failures
|
||||
/// like "rustup: command not found" when the real fix was to install
|
||||
/// rustup first.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstallTier {
|
||||
/// Short human-readable label, e.g. "rustup component".
|
||||
pub label: String,
|
||||
/// Binaries that must be available before this tier is attempted.
|
||||
pub requires: Vec<String>,
|
||||
/// Command to run.
|
||||
pub command: String,
|
||||
/// Arguments to pass to the command.
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Return the static set of supported language servers.
|
||||
///
|
||||
/// The order is significant: it determines provisioning order and
|
||||
/// the order results appear in `provision_all_with_progress()`. Tier 1 paths are
|
||||
/// the canonical/idiomatic install for each ecosystem; later tiers
|
||||
/// are fallbacks for hosts that lack the primary tooling.
|
||||
///
|
||||
/// Why hard-coded rather than loaded from settings: the set is small,
|
||||
/// changes rarely, and bundling it lets the provisioner run before any
|
||||
/// user config has been read (e.g. on first launch).
|
||||
pub fn supported_servers() -> Vec<LanguageServerDef> {
|
||||
vec![
|
||||
LanguageServerDef {
|
||||
name: "rust-analyzer".to_string(),
|
||||
language: "rust".to_string(),
|
||||
extensions: vec![".rs".to_string()],
|
||||
binary_names: vec!["rust-analyzer".to_string()],
|
||||
install_tiers: vec![
|
||||
InstallTier {
|
||||
label: "rustup component".to_string(),
|
||||
requires: vec!["rustup".to_string()],
|
||||
command: "rustup".to_string(),
|
||||
args: vec![
|
||||
"component".to_string(),
|
||||
"add".to_string(),
|
||||
"rust-analyzer".to_string(),
|
||||
],
|
||||
},
|
||||
InstallTier {
|
||||
label: "pacman".to_string(),
|
||||
requires: vec!["pacman".to_string()],
|
||||
command: "pacman".to_string(),
|
||||
args: vec![
|
||||
"-S".to_string(),
|
||||
"--noconfirm".to_string(),
|
||||
"--needed".to_string(),
|
||||
"rust-analyzer".to_string(),
|
||||
],
|
||||
},
|
||||
InstallTier {
|
||||
label: "brew".to_string(),
|
||||
requires: vec!["brew".to_string()],
|
||||
command: "brew".to_string(),
|
||||
args: vec!["install".to_string(), "rust-analyzer".to_string()],
|
||||
},
|
||||
InstallTier {
|
||||
label: "cargo install".to_string(),
|
||||
requires: vec!["cargo".to_string()],
|
||||
command: "cargo".to_string(),
|
||||
args: vec![
|
||||
"install".to_string(),
|
||||
"--locked".to_string(),
|
||||
"rust-analyzer".to_string(),
|
||||
],
|
||||
},
|
||||
InstallTier {
|
||||
label: "download prebuilt".to_string(),
|
||||
requires: vec!["curl".to_string(), "tar".to_string()],
|
||||
command: DOWNLOAD_RUST_BIN.to_string(),
|
||||
args: vec![],
|
||||
},
|
||||
],
|
||||
},
|
||||
LanguageServerDef {
|
||||
name: "typescript-language-server".to_string(),
|
||||
language: "typescript".to_string(),
|
||||
extensions: vec![
|
||||
".ts".to_string(),
|
||||
".tsx".to_string(),
|
||||
".js".to_string(),
|
||||
".jsx".to_string(),
|
||||
],
|
||||
binary_names: vec!["typescript-language-server".to_string()],
|
||||
install_tiers: vec![InstallTier {
|
||||
label: "npm global".to_string(),
|
||||
requires: vec!["npm".to_string()],
|
||||
command: "npm".to_string(),
|
||||
args: vec![
|
||||
"install".to_string(),
|
||||
"-g".to_string(),
|
||||
"typescript".to_string(),
|
||||
"typescript-language-server".to_string(),
|
||||
],
|
||||
}],
|
||||
},
|
||||
LanguageServerDef {
|
||||
name: "gopls".to_string(),
|
||||
language: "go".to_string(),
|
||||
extensions: vec![".go".to_string()],
|
||||
binary_names: vec!["gopls".to_string()],
|
||||
install_tiers: vec![InstallTier {
|
||||
label: "go install".to_string(),
|
||||
requires: vec!["go".to_string()],
|
||||
command: "go".to_string(),
|
||||
args: vec![
|
||||
"install".to_string(),
|
||||
"golang.org/x/tools/gopls@latest".to_string(),
|
||||
],
|
||||
}],
|
||||
},
|
||||
LanguageServerDef {
|
||||
name: "jdtls".to_string(),
|
||||
language: "java".to_string(),
|
||||
extensions: vec![".java".to_string()],
|
||||
binary_names: vec![
|
||||
"jdtls".to_string(),
|
||||
"eclipse-jdt-ls".to_string(),
|
||||
"jdtls-launcher".to_string(),
|
||||
],
|
||||
install_tiers: vec![
|
||||
InstallTier {
|
||||
label: "pacman".to_string(),
|
||||
requires: vec!["java".to_string(), "pacman".to_string()],
|
||||
command: "pacman".to_string(),
|
||||
args: vec![
|
||||
"-S".to_string(),
|
||||
"--noconfirm".to_string(),
|
||||
"--needed".to_string(),
|
||||
"eclipse-jdt-ls".to_string(),
|
||||
],
|
||||
},
|
||||
InstallTier {
|
||||
label: "apt".to_string(),
|
||||
requires: vec!["java".to_string(), "apt".to_string()],
|
||||
command: "sudo".to_string(),
|
||||
args: vec![
|
||||
"apt".to_string(),
|
||||
"install".to_string(),
|
||||
"-y".to_string(),
|
||||
"eclipse-jdt-ls".to_string(),
|
||||
],
|
||||
},
|
||||
InstallTier {
|
||||
label: "brew".to_string(),
|
||||
requires: vec!["java".to_string(), "brew".to_string()],
|
||||
command: "brew".to_string(),
|
||||
args: vec!["install".to_string(), "jdtls".to_string()],
|
||||
},
|
||||
InstallTier {
|
||||
label: "download from eclipse".to_string(),
|
||||
requires: vec!["java".to_string(), "curl".to_string(), "tar".to_string()],
|
||||
command: DOWNLOAD_JDTLS.to_string(),
|
||||
args: vec![],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Environment discovery: finding binaries on PATH and detecting available
|
||||
//! toolchains / package managers on the host system.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// Rust toolchain availability on the host PATH.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RustToolchain {
|
||||
pub has_rustup: bool,
|
||||
pub has_cargo: bool,
|
||||
}
|
||||
|
||||
/// Web / scripting language toolchain availability.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebToolchain {
|
||||
pub has_npm: bool,
|
||||
pub has_go: bool,
|
||||
pub has_java: bool,
|
||||
}
|
||||
|
||||
/// General-purpose platform utilities.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformUtils {
|
||||
pub has_curl: bool,
|
||||
pub has_tar: bool,
|
||||
}
|
||||
|
||||
/// Pacman and Brew package managers (Arch / macOS).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PacmanBrew {
|
||||
pub has_pacman: bool,
|
||||
pub has_brew: bool,
|
||||
}
|
||||
|
||||
/// Apt and DNF package managers (Debian / Fedora).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AptDnf {
|
||||
pub has_apt: bool,
|
||||
pub has_dnf: bool,
|
||||
}
|
||||
|
||||
/// Snapshot of the host environment used to decide which install tiers are viable.
|
||||
///
|
||||
/// Populated by `detect_env()` once per `provision_all_with_progress()` call so we
|
||||
/// don't re-shell out for every server. `is_linux` / `is_macos` are
|
||||
/// computed at startup (compile time would also work, but keeping the
|
||||
/// shape uniform with the rest of the struct makes the call sites tidy).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnvInfo {
|
||||
pub rust: RustToolchain,
|
||||
pub web: WebToolchain,
|
||||
pub platform: PlatformUtils,
|
||||
pub pacman_brew: PacmanBrew,
|
||||
pub apt_dnf: AptDnf,
|
||||
pub is_linux: bool,
|
||||
pub is_macos: bool,
|
||||
}
|
||||
|
||||
/// Check whether `binary` exists on PATH by shelling out to `which`.
|
||||
///
|
||||
/// Flow: `Command::new("which").arg(binary).output()` → on Unix
|
||||
/// `which` returns exit 0 + stdout path when found, non-zero
|
||||
/// otherwise. We return the first stdout line as the `PathBuf`.
|
||||
///
|
||||
/// Returns None if `which` itself is missing, fails to spawn, or the
|
||||
/// binary is not on PATH. We deliberately don't cache this — it's only
|
||||
/// called during provisioning and the results feed into install-tier
|
||||
/// gating, which is already cheap.
|
||||
pub fn which(binary: &str) -> Option<PathBuf> {
|
||||
let output = Command::new("which").arg(binary).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let first = stdout.lines().next()?.trim();
|
||||
if first.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(first))
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the host environment: which toolchains and package managers
|
||||
/// are available, and what OS we're on.
|
||||
///
|
||||
/// Flow: shell out to `which` for each tool in parallel (sequentially,
|
||||
/// actually — the calls are fast and the ordering doesn't matter)
|
||||
/// → set `EnvInfo` flags. Linux/macOS are detected via cfg at
|
||||
/// compile time since `which` won't tell us.
|
||||
///
|
||||
/// Edge case: `which` may not exist on Windows; we guard with cfg so
|
||||
/// this only ever runs on Unix-like targets.
|
||||
pub fn detect_env() -> EnvInfo {
|
||||
EnvInfo {
|
||||
rust: RustToolchain {
|
||||
has_rustup: which("rustup").is_some(),
|
||||
has_cargo: which("cargo").is_some(),
|
||||
},
|
||||
web: WebToolchain {
|
||||
has_npm: which("npm").is_some(),
|
||||
has_go: which("go").is_some(),
|
||||
has_java: which("java").is_some(),
|
||||
},
|
||||
platform: PlatformUtils {
|
||||
has_curl: which("curl").is_some(),
|
||||
has_tar: which("tar").is_some(),
|
||||
},
|
||||
pacman_brew: PacmanBrew {
|
||||
has_pacman: which("pacman").is_some(),
|
||||
has_brew: which("brew").is_some(),
|
||||
},
|
||||
apt_dnf: AptDnf {
|
||||
has_apt: which("apt").is_some() || which("apt-get").is_some(),
|
||||
has_dnf: which("dnf").is_some(),
|
||||
},
|
||||
is_linux: cfg!(target_os = "linux"),
|
||||
is_macos: cfg!(target_os = "macos"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Download and install helpers for LSP servers not available via
|
||||
//! system package managers.
|
||||
//!
|
||||
//! Each helper downloads a prebuilt binary (or archive) and places it
|
||||
//! under `~/.local/share/zesdex/lsp/<server-name>/`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use super::config::{ProgressFn, DOWNLOAD_JDTLS, DOWNLOAD_RUST_BIN};
|
||||
use super::discovery::EnvInfo;
|
||||
use super::manager::run_command;
|
||||
|
||||
/// Resolve the directory where downloaded LSP binaries are stored.
|
||||
fn lsp_install_dir(server: &str) -> Result<PathBuf, String> {
|
||||
let base = dirs::data_dir()
|
||||
.ok_or_else(|| "cannot find data directory via dirs crate".to_string())?
|
||||
.join("zesdex")
|
||||
.join("lsp")
|
||||
.join(server);
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
/// Check whether `def` was previously installed via the download tier
|
||||
/// (binary/launcher lives under `~/.local/share/zesdex/lsp/<name>/`).
|
||||
/// Returns the path to the binary if found.
|
||||
pub(super) fn previous_download_install(def: &super::config::LanguageServerDef) -> Option<PathBuf> {
|
||||
let base = lsp_install_dir(&def.name).ok()?;
|
||||
let candidates: &[&str] = match def.name.as_str() {
|
||||
"rust-analyzer" => &["rust-analyzer"],
|
||||
"jdtls" => &["bin/jdtls", "jdtls-launcher.sh", "jdtls"],
|
||||
"typescript-language-server" => &["bin/typescript-language-server"],
|
||||
"gopls" => &["bin/gopls"],
|
||||
_ => return None,
|
||||
};
|
||||
for sub in candidates {
|
||||
let p = base.join(sub);
|
||||
if p.exists() {
|
||||
// Skip directory entries that exist but are the base dir itself.
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Download a file from `url` to `dest` using curl.
|
||||
fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
|
||||
let path_str = dest.to_str().ok_or("invalid dest path")?.to_string();
|
||||
info!(url = url, dest = %path_str, "downloading");
|
||||
let args = [
|
||||
"-fsSL",
|
||||
"--connect-timeout",
|
||||
"15",
|
||||
"--max-time",
|
||||
&max_secs.to_string(),
|
||||
"-o",
|
||||
&path_str,
|
||||
url,
|
||||
];
|
||||
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {e}"))?;
|
||||
if !ok {
|
||||
return Err(format!("download failed: {}", out.trim()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download rust-analyzer from GitHub releases and install into
|
||||
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
|
||||
fn install_rust_analyzer_binary(
|
||||
env: &EnvInfo,
|
||||
progress: ProgressFn<'_>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let base = lsp_install_dir("rust-analyzer")?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
|
||||
|
||||
let url = if env.is_linux {
|
||||
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz"
|
||||
} else if env.is_macos {
|
||||
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-aarch64-apple-darwin.gz"
|
||||
} else {
|
||||
return Err("no prebuilt binary for this OS".to_string());
|
||||
};
|
||||
|
||||
let gz = base.join("rust-analyzer.gz");
|
||||
let target = base.join("rust-analyzer");
|
||||
|
||||
if let Some(cb) = progress {
|
||||
cb("Rust: downloading prebuilt binary...");
|
||||
}
|
||||
download_url(url, &gz, 120)?;
|
||||
if let Some(cb) = progress {
|
||||
cb("Rust: decompressing...");
|
||||
}
|
||||
let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()])
|
||||
.map_err(|e| format!("gunzip spawn: {e}"))?;
|
||||
if !ok {
|
||||
return Err(format!("gunzip: {}", out.trim()));
|
||||
}
|
||||
|
||||
if !target.exists() {
|
||||
return Err("binary missing after decompression".to_string());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("chmod: {e}"))?;
|
||||
}
|
||||
if let Some(cb) = progress {
|
||||
cb("Rust: installed ✓");
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// Download Eclipse JDT-LS from the official snapshot server, extract it,
|
||||
/// and create a launcher script at `bin/jdtls`.
|
||||
fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
|
||||
let base = lsp_install_dir("jdtls")?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
|
||||
|
||||
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
|
||||
let tarball = base.join("jdtls.tar.gz");
|
||||
if let Some(cb) = progress {
|
||||
cb("Java: downloading JDT-LS (~150MB)...");
|
||||
}
|
||||
download_url(url, &tarball, 300)?;
|
||||
if let Some(cb) = progress {
|
||||
cb("Java: extracting...");
|
||||
}
|
||||
|
||||
let (ok, out) = run_command(
|
||||
"tar",
|
||||
&[
|
||||
"-xzf",
|
||||
tarball.to_str().unwrap_or(""),
|
||||
"-C",
|
||||
base.to_str().unwrap_or("."),
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("tar spawn: {e}"))?;
|
||||
if !ok {
|
||||
return Err(format!("tar: {}", out.trim()));
|
||||
}
|
||||
let _ = std::fs::remove_file(&tarball);
|
||||
|
||||
if !base.join("plugins").exists() {
|
||||
return Err("extracted archive missing plugins/ directory".to_string());
|
||||
}
|
||||
|
||||
let bin_dir = base.join("bin");
|
||||
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {e}"))?;
|
||||
let launcher = bin_dir.join("jdtls");
|
||||
|
||||
let script = r#"#!/usr/bin/env bash
|
||||
set -e
|
||||
JDTLS_HOME="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
LAUNCHER=$(ls "${JDTLS_HOME}/plugins/org.eclipse.equinox.launcher_"*.jar 2>/dev/null | head -n1)
|
||||
CONFIG=$(ls -d "${JDTLS_HOME}"/config_* 2>/dev/null | head -n1)
|
||||
WORKSPACE="${JDTLS_HOME}/workspace"
|
||||
mkdir -p "${WORKSPACE}"
|
||||
exec java \
|
||||
-Declipse.application=org.eclipse.jdt.ls.core.id1 \
|
||||
-Dosgi.bundles.defaultStartLevel=5 \
|
||||
-Declipse.product=org.eclipse.jdt.ls.core.product \
|
||||
-Dlog.level=WARN -noverify -Xmx1G \
|
||||
-jar "${LAUNCHER}" -configuration "${CONFIG}" -data "${WORKSPACE}" \
|
||||
--add-modules=ALL-SYSTEM \
|
||||
--add-opens java.base/java.util=ALL-UNNAMED \
|
||||
--add-opens java.base/java.lang=ALL-UNNAMED \
|
||||
"$@"
|
||||
"#;
|
||||
std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("chmod launcher: {e}"))?;
|
||||
}
|
||||
if let Some(cb) = progress {
|
||||
cb("Java: JDT-LS installed ✓");
|
||||
}
|
||||
Ok(launcher)
|
||||
}
|
||||
|
||||
/// Dispatch a sentinel download tier to the correct helper.
|
||||
pub(super) fn run_download_tier(
|
||||
name: &str,
|
||||
env: &EnvInfo,
|
||||
progress: ProgressFn<'_>,
|
||||
) -> Result<PathBuf, String> {
|
||||
match name {
|
||||
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
|
||||
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
|
||||
other => Err(format!("unknown download tier '{other}'")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Provisioning orchestration: running install commands, iterating over
|
||||
//! supported servers, and connecting provisioned servers to the LspManager.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::config::{self, LanguageServerDef, ProgressFn, ProvisionResult};
|
||||
use super::discovery::{self, EnvInfo};
|
||||
use super::install;
|
||||
use crate::app::lsp::LspManager;
|
||||
|
||||
/// Spawn `cmd` with `args`, capture stdout, wait up to 120s, return
|
||||
/// (success, stdout).
|
||||
///
|
||||
/// Flow: build Command with piped stdout/err → spawn → poll in 50ms
|
||||
/// loops with `child.try_wait()` until the command finishes or
|
||||
/// 120s elapses (in which case we kill the child).
|
||||
/// Merging stderr into stdout keeps callers simple — install
|
||||
/// commands tend to emit errors to stderr, and we want to surface
|
||||
/// those.
|
||||
///
|
||||
/// Why a custom timeout: `std::process::Command` has no built-in timeout,
|
||||
/// and we'd rather kill a hung `apt` than block the TUI indefinitely.
|
||||
pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> {
|
||||
let mut command = Command::new(cmd);
|
||||
command.args(args);
|
||||
command.stdout(Stdio::piped());
|
||||
command.stderr(Stdio::piped());
|
||||
|
||||
let mut child = command.spawn()?;
|
||||
let stdout_handle = child.stdout.take();
|
||||
let stderr_handle = child.stderr.take();
|
||||
|
||||
let stdout_thread = stdout_handle.map(|s| {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(s), &mut buf);
|
||||
buf
|
||||
})
|
||||
});
|
||||
let stderr_thread = stderr_handle.map(|s| {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(s), &mut buf);
|
||||
buf
|
||||
})
|
||||
});
|
||||
|
||||
let timeout = Duration::from_mins(3);
|
||||
let start = Instant::now();
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break Ok(status);
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
};
|
||||
|
||||
let stdout = stdout_thread
|
||||
.map(|t| t.join().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let stderr = stderr_thread
|
||||
.map(|t| t.join().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok((true, stdout)),
|
||||
Ok(_) => Ok((false, format!("{stdout}{stderr}"))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn provision_single_with_progress(
|
||||
def: &LanguageServerDef,
|
||||
env: &EnvInfo,
|
||||
progress: ProgressFn<'_>,
|
||||
) -> ProvisionResult {
|
||||
// 1. Check PATH.
|
||||
for bin in &def.binary_names {
|
||||
if let Some(path) = discovery::which(bin) {
|
||||
if let Some(cb) = progress {
|
||||
cb(&format!("{}: already installed (PATH)", def.language));
|
||||
}
|
||||
return ProvisionResult::AlreadyAvailable {
|
||||
server_name: def.name.clone(),
|
||||
language: def.language.clone(),
|
||||
binary_path: path.to_string_lossy().to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check download-install directory (~/.local/share/zesdex/lsp/<name>/...).
|
||||
if let Some(path) = install::previous_download_install(def) {
|
||||
if let Some(cb) = progress {
|
||||
cb(&format!("{}: found previous install", def.language));
|
||||
}
|
||||
return ProvisionResult::AlreadyAvailable {
|
||||
server_name: def.name.clone(),
|
||||
language: def.language.clone(),
|
||||
binary_path: path.to_string_lossy().to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(cb) = progress {
|
||||
cb(&format!("{}: checking install options...", def.language));
|
||||
}
|
||||
|
||||
let mut last_reason = String::from("no install tiers succeeded");
|
||||
|
||||
for tier in &def.install_tiers {
|
||||
// Prerequisite gating
|
||||
let prereqs_met = tier.requires.iter().all(|req| match req.as_str() {
|
||||
"rustup" => env.rust.has_rustup,
|
||||
"npm" => env.web.has_npm,
|
||||
"go" => env.web.has_go,
|
||||
"java" => env.web.has_java,
|
||||
"cargo" => env.rust.has_cargo,
|
||||
"curl" => env.platform.has_curl,
|
||||
"tar" => env.platform.has_tar,
|
||||
"pacman" => env.pacman_brew.has_pacman,
|
||||
"apt" => env.apt_dnf.has_apt,
|
||||
"brew" => env.pacman_brew.has_brew,
|
||||
"dnf" => env.apt_dnf.has_dnf,
|
||||
_ => discovery::which(req).is_some(),
|
||||
});
|
||||
if !prereqs_met {
|
||||
let skip = format!("{}: {} — missing prerequisite", def.language, tier.label);
|
||||
if let Some(cb) = progress {
|
||||
cb(&skip);
|
||||
}
|
||||
last_reason = format!("tier '{}' skipped: missing prerequisite", tier.label);
|
||||
warn!(server = %def.name, tier = %tier.label, "skipped — missing prerequisites");
|
||||
continue;
|
||||
}
|
||||
|
||||
let trying = format!("{}: {}...", def.language, tier.label);
|
||||
if let Some(cb) = progress {
|
||||
cb(&trying);
|
||||
}
|
||||
|
||||
// Download sentinel → helper.
|
||||
if tier.command.starts_with("__download_") && tier.command.ends_with("__") {
|
||||
match install::run_download_tier(&tier.command, env, progress) {
|
||||
Ok(path) => {
|
||||
info!(server = %def.name, tier = %tier.label, binary = %path.display(), "installed");
|
||||
return ProvisionResult::Installed {
|
||||
server_name: def.name.clone(),
|
||||
language: def.language.clone(),
|
||||
binary_path: path.to_string_lossy().to_string(),
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
last_reason = format!("tier '{}' failed: {}", tier.label, e);
|
||||
warn!(server = %def.name, tier = %tier.label, error = %e, "download failed");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal shell-out tier.
|
||||
let arg_refs: Vec<&str> = tier.args.iter().map(std::string::String::as_str).collect();
|
||||
match run_command(&tier.command, &arg_refs) {
|
||||
Ok((true, _)) => {
|
||||
let located = def
|
||||
.binary_names
|
||||
.iter()
|
||||
.find_map(|b| discovery::which(b).map(|p| p.to_string_lossy().to_string()));
|
||||
if let Some(path) = located {
|
||||
if let Some(cb) = progress {
|
||||
cb(&format!("{}: installed ✓", def.language));
|
||||
}
|
||||
info!(server = %def.name, tier = %tier.label, binary = %path, "installed");
|
||||
return ProvisionResult::Installed {
|
||||
server_name: def.name.clone(),
|
||||
language: def.language.clone(),
|
||||
binary_path: path,
|
||||
};
|
||||
}
|
||||
last_reason = format!("tier '{}' exited 0 but binary not on PATH", tier.label);
|
||||
warn!(server = %def.name, tier = %tier.label, "success reported but binary missing");
|
||||
}
|
||||
Ok((false, out)) => {
|
||||
let trimmed = out.trim();
|
||||
let snippet: String = trimmed.chars().take(300).collect();
|
||||
last_reason = format!("tier '{}' failed: {}", tier.label, snippet);
|
||||
warn!(server = %def.name, tier = %tier.label, output = %snippet, "failed");
|
||||
}
|
||||
Err(e) => {
|
||||
last_reason = format!("tier '{}' error: {}", tier.label, e);
|
||||
warn!(server = %def.name, tier = %tier.label, error = %e, "errored");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProvisionResult::Failed {
|
||||
language: def.language.clone(),
|
||||
server_name: def.name.clone(),
|
||||
reason: last_reason,
|
||||
}
|
||||
}
|
||||
|
||||
/// Provision every supported server with progress callbacks with a human-readable status
|
||||
/// string at each stage of each server's install attempt.
|
||||
pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult> {
|
||||
let env = discovery::detect_env();
|
||||
if let Some(cb) = progress {
|
||||
let flags = [
|
||||
("rustup", env.rust.has_rustup),
|
||||
("cargo", env.rust.has_cargo),
|
||||
("npm", env.web.has_npm),
|
||||
("go", env.web.has_go),
|
||||
("java", env.web.has_java),
|
||||
("curl", env.platform.has_curl),
|
||||
("tar", env.platform.has_tar),
|
||||
("pacman", env.pacman_brew.has_pacman),
|
||||
("apt", env.apt_dnf.has_apt),
|
||||
("brew", env.pacman_brew.has_brew),
|
||||
];
|
||||
let avail: String = flags
|
||||
.iter()
|
||||
.filter(|(_, v)| *v)
|
||||
.map(|(k, _)| *k)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
cb(&format!("LSP: environment ready — {avail}"));
|
||||
}
|
||||
config::supported_servers()
|
||||
.iter()
|
||||
.map(|def| provision_single_with_progress(def, &env, progress))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// For every successful provision result, attach the corresponding
|
||||
/// server to the given `LspManager`.
|
||||
///
|
||||
/// Flow: for each result, if it's `AlreadyAvailable` or Installed, look
|
||||
/// up the `LanguageServerDef`, then call `manager.connect()` with
|
||||
/// the binary path and empty args. On connect success, log and
|
||||
/// record the name; on failure, log a warning and skip.
|
||||
/// Returns the names that successfully connected.
|
||||
///
|
||||
/// Why empty args: most LSP servers don't need CLI flags to start;
|
||||
/// the spec for each server lives in the protocol handshake, not the
|
||||
/// argv. If we ever need flags (e.g. --stdio), they'll be a per-server
|
||||
/// constant in `supported_servers()`.
|
||||
pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult]) -> Vec<String> {
|
||||
let defs = config::supported_servers();
|
||||
let mut connected: Vec<String> = Vec::new();
|
||||
|
||||
for result in results {
|
||||
let (name, language, binary) = match result {
|
||||
ProvisionResult::AlreadyAvailable {
|
||||
server_name,
|
||||
language,
|
||||
binary_path,
|
||||
}
|
||||
| ProvisionResult::Installed {
|
||||
server_name,
|
||||
language,
|
||||
binary_path,
|
||||
} => (server_name.clone(), language.clone(), binary_path.clone()),
|
||||
ProvisionResult::Failed { .. } => continue,
|
||||
};
|
||||
|
||||
// Sanity: only connect to servers we know about. Protects against
|
||||
// future ProvisionResult variants sneaking in unknown names.
|
||||
let Some(def) = defs.iter().find(|d| d.name == name) else {
|
||||
warn!(name = %name, "skipping connect: unknown server");
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut guard = match manager.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "LspManager mutex poisoned; skipping connect");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Build extension slice for connect_with_extensions.
|
||||
let ext_refs: Vec<&str> = def
|
||||
.extensions
|
||||
.iter()
|
||||
.map(std::string::String::as_str)
|
||||
.collect();
|
||||
|
||||
match guard.connect_with_extensions(&binary, &[], &language, &ext_refs) {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
name = %name,
|
||||
language = %language,
|
||||
binary = %binary,
|
||||
"connected LSP server"
|
||||
);
|
||||
connected.push(name);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
name = %name,
|
||||
error = %e,
|
||||
"failed to connect LSP server"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connected
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Auto-provisioning engine for LSP language servers.
|
||||
//!
|
||||
//! Flow: `detect_env()` → for each supported server in `supported_servers()`
|
||||
//! → `provision_single()` tries install tiers in order → returns
|
||||
//! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed).
|
||||
//! Caller can then call `auto_connect()` to attach available servers
|
||||
//! to an existing `LspManager`.
|
||||
//!
|
||||
//! Why: opening a project on a fresh machine should not require the user
|
||||
//! to manually hunt down and install 4 different language servers.
|
||||
//! Each tier is a fallback for the previous, so we try the most
|
||||
//! user-friendly path first (rustup component, npm global, etc.) and
|
||||
//! only fall back to package managers or manual download if those fail.
|
||||
|
||||
mod config;
|
||||
mod discovery;
|
||||
mod install;
|
||||
mod manager;
|
||||
|
||||
// -- Re-exports: all public items from the original monolithic provisioner.rs --
|
||||
// These are kept for API compatibility even if not all are consumed internally.
|
||||
|
||||
// Config types and the server definitions
|
||||
#[allow(unused_imports)]
|
||||
pub use config::{InstallTier, LanguageServerDef, ProgressFn, ProvisionResult};
|
||||
#[allow(unused_imports)]
|
||||
pub use config::supported_servers;
|
||||
|
||||
// Environment discovery
|
||||
#[allow(unused_imports)]
|
||||
pub use discovery::{detect_env, AptDnf, EnvInfo, PacmanBrew, PlatformUtils, RustToolchain, WebToolchain};
|
||||
#[allow(unused_imports)]
|
||||
pub use discovery::which;
|
||||
|
||||
// Manager / orchestration
|
||||
#[allow(unused_imports)]
|
||||
pub use manager::{auto_connect, provision_all_with_progress, run_command};
|
||||
|
||||
// -- Internal plumbing for crate::app::lsp::provisioner::* compatibility --
|
||||
// `install` module items are all `pub(super)` and not re-exported.
|
||||
// The old `provision_single_with_progress` was private, so we don't re-export it.
|
||||
@@ -0,0 +1,187 @@
|
||||
//! MCP server connection management: spawning/talking to stdio child
|
||||
//! processes and HTTP endpoints, and adapting their advertised tools to
|
||||
//! the crate's `Tool` trait.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::transport::{call_via_http, call_via_stdio, mcp_static_str, spawn_stdio_child};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP server descriptor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A connected MCP server: its transport, advertised tools, and (for stdio)
|
||||
/// a live handle to the child process.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServer {
|
||||
pub name: String,
|
||||
pub transport: McpTransport,
|
||||
pub tools: Vec<McpToolInfo>,
|
||||
/// Held child-process handle so subsequent tool calls reuse the same
|
||||
/// connection instead of spawning a new child each time. Not serialized
|
||||
/// because the child only lives in this process.
|
||||
#[serde(skip)]
|
||||
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Adapts a single MCP-advertised tool to the crate's `Tool` trait so it can
|
||||
/// be dispatched through the same execution path as built-in tools.
|
||||
pub struct McpToolAdapter {
|
||||
pub tool_name: String,
|
||||
pub server_name: String,
|
||||
pub transport: McpTransport,
|
||||
pub description: String,
|
||||
pub parameters: Value,
|
||||
/// Shared handle to a persistent child process (stdio transport only).
|
||||
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
|
||||
}
|
||||
|
||||
impl crate::tool::Tool for McpToolAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
mcp_static_str(&format!("mcp__{}__{}", self.server_name, self.tool_name))
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
mcp_static_str(&self.description)
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
self.parameters.clone()
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
|
||||
match &self.transport {
|
||||
McpTransport::Stdio {
|
||||
command,
|
||||
args: extra_args,
|
||||
} => call_via_stdio(
|
||||
self.child_handle.as_ref().map(std::convert::AsRef::as_ref),
|
||||
command,
|
||||
extra_args,
|
||||
&self.tool_name,
|
||||
args,
|
||||
),
|
||||
McpTransport::StreamableHttp { url } => call_via_http(url, &self.tool_name, args),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registry of connected MCP servers and their tools for the current session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpManager {
|
||||
pub servers: Vec<McpServer>,
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
/// Create an empty manager with no connected servers.
|
||||
pub fn new() -> Self {
|
||||
McpManager {
|
||||
servers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten all connected servers' tools into a single list of `Tool` trait objects.
|
||||
///
|
||||
/// Flow: for each server, clone its child handle → wrap each of its
|
||||
/// `McpToolInfo` entries in an `McpToolAdapter` sharing that handle.
|
||||
///
|
||||
/// Why: the handle is cloned (Arc) per tool so every adapter for a given
|
||||
/// stdio server reuses the same persistent child process/connection.
|
||||
///
|
||||
/// Return: boxed `Tool` trait objects ready to merge into the harness's tool list.
|
||||
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
|
||||
self.servers
|
||||
.iter()
|
||||
.flat_map(|server| {
|
||||
let handle = server.child_handle.clone();
|
||||
server.tools.iter().map(move |info| {
|
||||
let adapter: Box<dyn crate::tool::Tool> = Box::new(McpToolAdapter {
|
||||
tool_name: info.name.clone(),
|
||||
server_name: server.name.clone(),
|
||||
transport: server.transport.clone(),
|
||||
description: info.description.clone(),
|
||||
parameters: info.input_schema.clone(),
|
||||
child_handle: handle.clone(),
|
||||
});
|
||||
adapter
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Connects to an MCP server via stdio by spawning the child process, running
|
||||
/// the `initialize` handshake, calling `tools/list`, and registering the server
|
||||
/// with its advertised tools in `self.servers`. The child process stays alive
|
||||
/// for subsequent `tools/call` invocations via the stored `McpServer.tools`.
|
||||
pub fn connect_stdio(
|
||||
&mut self,
|
||||
name: &str,
|
||||
command: &str,
|
||||
extra_args: &[String],
|
||||
) -> anyhow::Result<()> {
|
||||
let transport = McpTransport::Stdio {
|
||||
command: command.to_string(),
|
||||
args: extra_args.to_vec(),
|
||||
};
|
||||
|
||||
let mut child = spawn_stdio_child(command, extra_args)?;
|
||||
let result = child.call("tools/list", &json!({}))?;
|
||||
|
||||
let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) {
|
||||
tool_list
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
Some(McpToolInfo {
|
||||
name: t.get("name")?.as_str()?.to_string(),
|
||||
description: t
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
"[mcp] tool {} missing description",
|
||||
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
|
||||
);
|
||||
""
|
||||
})
|
||||
.to_string(),
|
||||
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
"[mcp] tool {} missing inputSchema",
|
||||
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
|
||||
);
|
||||
serde_json::Value::Null
|
||||
}),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let handle = Arc::new(Mutex::new(child));
|
||||
|
||||
self.servers.push(McpServer {
|
||||
name: name.to_string(),
|
||||
transport,
|
||||
tools,
|
||||
child_handle: Some(handle),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub use super::transport::{McpTransport, McpToolInfo, StdioChild};
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Model Context Protocol (MCP) client: connects to external MCP servers
|
||||
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait.
|
||||
|
||||
pub mod manager;
|
||||
pub mod transport;
|
||||
@@ -1,20 +1,27 @@
|
||||
//! MCP server connection management: spawning/talking to stdio child
|
||||
//! processes and HTTP endpoints, and adapting their advertised tools to
|
||||
//! the crate's `Tool` trait.
|
||||
//! MCP transport layer: stdio child process management and HTTP client calls.
|
||||
//! This module handles the low-level protocol details of communicating with
|
||||
//! MCP servers (both spawned subprocesses and remote HTTP endpoints).
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000;
|
||||
const MCP_CALL_TIMEOUT_MS: u64 = 60_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static string cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Global cache for `&'static str` names/descriptions of MCP tools, so we
|
||||
/// never need `Box::leak`. Entries are never removed (small, bounded by the
|
||||
/// number of MCP tools ever registered in a session).
|
||||
fn mcp_static_str(s: &str) -> &'static str {
|
||||
pub(super) fn mcp_static_str(s: &str) -> &'static str {
|
||||
static CACHE: OnceLock<Mutex<Vec<&'static str>>> = OnceLock::new();
|
||||
let mut cache = match CACHE.get_or_init(|| Mutex::new(Vec::new())).lock() {
|
||||
Ok(c) => c,
|
||||
@@ -31,17 +38,16 @@ fn mcp_static_str(s: &str) -> &'static str {
|
||||
leaked
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core transport types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// How an MCP server is reached: a spawned child process talking
|
||||
/// newline-delimited JSON-RPC over stdio, or a remote HTTP endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum McpTransport {
|
||||
Stdio {
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
},
|
||||
StreamableHttp {
|
||||
url: String,
|
||||
},
|
||||
Stdio { command: String, args: Vec<String> },
|
||||
StreamableHttp { url: String },
|
||||
}
|
||||
|
||||
/// A single tool advertised by an MCP server, as returned by `tools/list`.
|
||||
@@ -52,19 +58,9 @@ pub struct McpToolInfo {
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
/// A connected MCP server: its transport, advertised tools, and (for stdio)
|
||||
/// a live handle to the child process.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServer {
|
||||
pub name: String,
|
||||
pub transport: McpTransport,
|
||||
pub tools: Vec<McpToolInfo>,
|
||||
/// Held child-process handle so subsequent tool calls reuse the same
|
||||
/// connection instead of spawning a new child each time. Not serialized
|
||||
/// because the child only lives in this process.
|
||||
#[serde(skip)]
|
||||
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stdio child process handle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Live handle to an MCP server child process communicating over stdio
|
||||
/// via newline-delimited JSON-RPC 2.0.
|
||||
@@ -104,8 +100,8 @@ impl StdioChild {
|
||||
self.stdin.flush()?;
|
||||
|
||||
let mut response_line = String::new();
|
||||
let deadline = std::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms");
|
||||
@@ -136,7 +132,9 @@ impl StdioChild {
|
||||
line_truncated = true;
|
||||
// Consume rest of line to keep stream in sync
|
||||
loop {
|
||||
let buf = self.stdout.fill_buf()
|
||||
let buf = self
|
||||
.stdout
|
||||
.fill_buf()
|
||||
.map_err(|e| anyhow::anyhow!("MCP stdio read error: {e}"))?;
|
||||
if buf.is_empty() {
|
||||
anyhow::bail!("MCP stdio child closed mid-line");
|
||||
@@ -152,9 +150,7 @@ impl StdioChild {
|
||||
response_line.push(byte as char);
|
||||
}
|
||||
if line_truncated {
|
||||
anyhow::bail!(
|
||||
"MCP response line exceeded {MAX_LINE_LENGTH} byte limit",
|
||||
);
|
||||
anyhow::bail!("MCP response line exceeded {MAX_LINE_LENGTH} byte limit");
|
||||
}
|
||||
let trimmed = response_line.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -172,12 +168,20 @@ impl StdioChild {
|
||||
}));
|
||||
}
|
||||
}
|
||||
} // close fn call
|
||||
} // close impl StdioChild
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result<StdioChild> {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawning and connecting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn spawn_stdio_child(
|
||||
command: &str,
|
||||
extra_args: &[String],
|
||||
) -> anyhow::Result<StdioChild> {
|
||||
let parts: Vec<&str> = command.split_whitespace().collect();
|
||||
let (prog, prog_args) = parts.split_first()
|
||||
let (prog, prog_args) = parts
|
||||
.split_first()
|
||||
.ok_or_else(|| anyhow::anyhow!("MCP stdio command is empty"))?;
|
||||
|
||||
let mut cmd = std::process::Command::new(prog);
|
||||
@@ -189,12 +193,17 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
// rather than discarded silently, making connectivity issues debugable.
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{command}': {e}"))?;
|
||||
|
||||
let stdin = child.stdin.take()
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?;
|
||||
let stdout = child.stdout.take()
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to get stdout for MCP server"))?;
|
||||
|
||||
let mut mcp = StdioChild {
|
||||
@@ -203,17 +212,20 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
next_id: 0,
|
||||
};
|
||||
|
||||
let deadline = std::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
|
||||
|
||||
let init_result = mcp.call("initialize", &json!({
|
||||
let init_result = mcp.call(
|
||||
"initialize",
|
||||
&json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "zesdex",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
if std::time::Instant::now() > deadline {
|
||||
anyhow::bail!("MCP initialize timed out");
|
||||
@@ -226,7 +238,11 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
Ok(mcp)
|
||||
}
|
||||
|
||||
fn call_via_stdio(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool-call helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(super) fn call_via_stdio(
|
||||
existing_handle: Option<&Mutex<StdioChild>>,
|
||||
command: &str,
|
||||
extra_args: &[String],
|
||||
@@ -236,26 +252,34 @@ fn call_via_stdio(
|
||||
// Reuse the persistent child handle if available; otherwise spawn a new one.
|
||||
let mut guard;
|
||||
let child: &mut StdioChild = if let Some(mtx) = existing_handle {
|
||||
guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
|
||||
guard = mtx
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
|
||||
&mut guard
|
||||
} else {
|
||||
let mut fresh = spawn_stdio_child(command, extra_args)?;
|
||||
let result = fresh.call("tools/call", &json!({
|
||||
let result = fresh.call(
|
||||
"tools/call",
|
||||
&json!({
|
||||
"name": tool_name,
|
||||
"arguments": tool_args
|
||||
}))?;
|
||||
}),
|
||||
)?;
|
||||
return Ok(extract_text_content(&result));
|
||||
};
|
||||
|
||||
let result = child.call("tools/call", &json!({
|
||||
let result = child.call(
|
||||
"tools/call",
|
||||
&json!({
|
||||
"name": tool_name,
|
||||
"arguments": tool_args
|
||||
}))?;
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(extract_text_content(&result))
|
||||
}
|
||||
|
||||
fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
|
||||
pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
|
||||
.connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS))
|
||||
@@ -289,7 +313,8 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
||||
}
|
||||
});
|
||||
|
||||
let resp = client.post(url)
|
||||
let resp = client
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
@@ -304,7 +329,8 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
||||
anyhow::bail!("MCP HTTP server returned {status}: {text}");
|
||||
}
|
||||
|
||||
let response: Value = resp.json()
|
||||
let response: Value = resp
|
||||
.json()
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {e}"))?;
|
||||
|
||||
if let Some(err) = response.get("error") {
|
||||
@@ -318,16 +344,21 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
||||
Ok(extract_text_content(&result))
|
||||
}
|
||||
|
||||
fn extract_text_content(result: &Value) -> String {
|
||||
pub(super) fn extract_text_content(result: &Value) -> String {
|
||||
if let Some(content) = result.get("content") {
|
||||
if let Some(arr) = content.as_array() {
|
||||
let text: Vec<String> = arr.iter().filter_map(|item| {
|
||||
let text: Vec<String> = arr
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||
item.get("text").and_then(|t| t.as_str()).map(std::string::ToString::to_string)
|
||||
item.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(std::string::ToString::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
if !text.is_empty() {
|
||||
return text.join("\n");
|
||||
}
|
||||
@@ -338,132 +369,3 @@ fn extract_text_content(result: &Value) -> String {
|
||||
result.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Registry of connected MCP servers and their tools for the current session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpManager {
|
||||
pub servers: Vec<McpServer>,
|
||||
}
|
||||
|
||||
/// Adapts a single MCP-advertised tool to the crate's `Tool` trait so it can
|
||||
/// be dispatched through the same execution path as built-in tools.
|
||||
pub struct McpToolAdapter {
|
||||
pub tool_name: String,
|
||||
pub server_name: String,
|
||||
pub transport: McpTransport,
|
||||
pub description: String,
|
||||
pub parameters: Value,
|
||||
/// Shared handle to a persistent child process (stdio transport only).
|
||||
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
|
||||
}
|
||||
|
||||
impl crate::tool::Tool for McpToolAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
mcp_static_str(&format!("mcp__{}__{}", self.server_name, self.tool_name))
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
mcp_static_str(&self.description)
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
self.parameters.clone()
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
|
||||
match &self.transport {
|
||||
McpTransport::Stdio { command, args: extra_args } => {
|
||||
call_via_stdio(self.child_handle.as_ref().map(std::convert::AsRef::as_ref), command, extra_args, &self.tool_name, args)
|
||||
}
|
||||
McpTransport::StreamableHttp { url } => {
|
||||
call_via_http(url, &self.tool_name, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
/// Create an empty manager with no connected servers.
|
||||
pub fn new() -> Self {
|
||||
McpManager {
|
||||
servers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten all connected servers' tools into a single list of `Tool` trait objects.
|
||||
///
|
||||
/// Flow: for each server, clone its child handle → wrap each of its
|
||||
/// `McpToolInfo` entries in an `McpToolAdapter` sharing that handle.
|
||||
///
|
||||
/// Why: the handle is cloned (Arc) per tool so every adapter for a given
|
||||
/// stdio server reuses the same persistent child process/connection.
|
||||
///
|
||||
/// Return: boxed `Tool` trait objects ready to merge into the harness's tool list.
|
||||
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
|
||||
self.servers.iter().flat_map(|server| {
|
||||
let handle = server.child_handle.clone();
|
||||
server.tools.iter().map(move |info| {
|
||||
let adapter: Box<dyn crate::tool::Tool> = Box::new(McpToolAdapter {
|
||||
tool_name: info.name.clone(),
|
||||
server_name: server.name.clone(),
|
||||
transport: server.transport.clone(),
|
||||
description: info.description.clone(),
|
||||
parameters: info.input_schema.clone(),
|
||||
child_handle: handle.clone(),
|
||||
});
|
||||
adapter
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Connects to an MCP server via stdio by spawning the child process, running
|
||||
/// the `initialize` handshake, calling `tools/list`, and registering the server
|
||||
/// with its advertised tools in `self.servers`. The child process stays alive
|
||||
/// for subsequent `tools/call` invocations via the stored `McpServer.tools`.
|
||||
pub fn connect_stdio(&mut self, name: &str, command: &str, extra_args: &[String]) -> anyhow::Result<()> {
|
||||
let transport = McpTransport::Stdio {
|
||||
command: command.to_string(),
|
||||
args: extra_args.to_vec(),
|
||||
};
|
||||
|
||||
let mut child = spawn_stdio_child(command, extra_args)?;
|
||||
let result = child.call("tools/list", &json!({}))?;
|
||||
|
||||
let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) {
|
||||
tool_list.iter().filter_map(|t| {
|
||||
Some(McpToolInfo {
|
||||
name: t.get("name")?.as_str()?.to_string(),
|
||||
description: t.get("description").and_then(|v| v.as_str()).unwrap_or_else(|| {
|
||||
tracing::warn!("[mcp] tool {} missing description", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
||||
""
|
||||
}).to_string(),
|
||||
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
|
||||
tracing::warn!("[mcp] tool {} missing inputSchema", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
||||
serde_json::Value::Null
|
||||
}),
|
||||
})
|
||||
}).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let handle = Arc::new(Mutex::new(child));
|
||||
|
||||
self.servers.push(McpServer {
|
||||
name: name.to_string(),
|
||||
transport,
|
||||
tools,
|
||||
child_handle: Some(handle),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a server by name. Returns `true` if a server was found and removed.
|
||||
#[allow(dead_code)]
|
||||
pub fn disconnect(&mut self, name: &str) -> bool {
|
||||
let len = self.servers.len();
|
||||
self.servers.retain(|s| s.name != name);
|
||||
self.servers.len() < len
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
//! Top-level application module: harness, modes, runtime loop, state,
|
||||
//! Top-level application module: tool gate, modes, runtime loop, state,
|
||||
//! workflows, subagents, review, background bash, MCP integration, and
|
||||
//! native LSP client.
|
||||
pub mod harness;
|
||||
pub mod bgbash;
|
||||
pub mod guard;
|
||||
pub mod lsp;
|
||||
pub mod mcp;
|
||||
pub mod mode;
|
||||
pub mod review;
|
||||
pub mod runtime;
|
||||
pub mod state;
|
||||
pub mod workflow;
|
||||
pub mod subagent;
|
||||
pub mod review;
|
||||
pub mod bgbash;
|
||||
pub mod mcp;
|
||||
pub mod lsp;
|
||||
pub mod workflow;
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Bash mode: handles submitting a shell command from the bash input panel.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Launch a background bash job for the submitted command.
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Editor mode: a minimal in-TUI line editor for viewing/modifying a file,
|
||||
//! with bounded undo history.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
@@ -66,7 +65,9 @@ impl EditorState {
|
||||
self.cursor_line += 1;
|
||||
}
|
||||
self.cursor_col = self.cursor_col.min(
|
||||
self.content.get(self.cursor_line).map_or(0, std::string::String::len),
|
||||
self.content
|
||||
.get(self.cursor_line)
|
||||
.map_or(0, std::string::String::len),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,10 +115,9 @@ impl EditorState {
|
||||
/// the char directly → mark state dirty.
|
||||
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
|
||||
let editor = &mut state.misc.editor;
|
||||
if editor.is_none() {
|
||||
let Some(ed) = editor.as_mut() else {
|
||||
return;
|
||||
}
|
||||
let ed = editor.as_mut().unwrap();
|
||||
};
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'\n' | '\r' => {
|
||||
@@ -1,7 +1,11 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Effort mode: cycles the agent's reasoning effort level, which scales the
|
||||
//! LLM's temperature and `max_tokens` for subsequent turns.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Replace the input buffer with the given text and mark state dirty.
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use zesdex_cms::domain::repository::MemoryRepository;
|
||||
|
||||
/// A unified representation of a lesson item for the interactive TUI overlay.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -33,14 +34,16 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
||||
let scope_str = match p.lesson.scope {
|
||||
crate::app::review::LessonScope::Project => "project",
|
||||
crate::app::review::LessonScope::Global => "global",
|
||||
}.to_string();
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let conf_str = match p.lesson.confidence {
|
||||
crate::app::review::Confidence::Human => "human",
|
||||
crate::app::review::Confidence::Verified => "verified",
|
||||
crate::app::review::Confidence::Unverified => "unverified",
|
||||
crate::app::review::Confidence::Auto => "auto",
|
||||
}.to_string();
|
||||
}
|
||||
.to_string();
|
||||
|
||||
items.push(LearningItem::Pending {
|
||||
name: p.lesson.name,
|
||||
@@ -51,9 +54,15 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
||||
}
|
||||
|
||||
// 2. Load stored memory lessons from long-term memory directory
|
||||
let names = crate::model::memory::Memory::list(&state.memory_dir);
|
||||
let names =
|
||||
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
|
||||
.list(&state.memory_dir)
|
||||
.unwrap_or_default();
|
||||
for name in names {
|
||||
if let Ok(mem) = crate::model::memory::Memory::read(&state.memory_dir, &name) {
|
||||
if let Ok(mem) =
|
||||
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
|
||||
.load(&state.memory_dir, &name)
|
||||
{
|
||||
if mem.kind == "lesson" {
|
||||
items.push(LearningItem::Stored {
|
||||
name: mem.name,
|
||||
@@ -1,5 +1,4 @@
|
||||
//! MCP mode: overlay for connecting to a configured MCP server.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Placeholder entry point for connecting to an MCP server by name.
|
||||
@@ -1,14 +1,13 @@
|
||||
//! TUI mode definitions and per-mode input/action handlers, one submodule
|
||||
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
|
||||
|
||||
pub mod bash;
|
||||
pub mod editor;
|
||||
pub mod effort;
|
||||
pub mod key_input;
|
||||
pub mod mcp;
|
||||
|
||||
pub mod learning;
|
||||
pub mod quit_confirm;
|
||||
pub mod rewind;
|
||||
pub mod settings;
|
||||
pub mod todo;
|
||||
pub mod learning;
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
|
||||
|
||||
use crate::app::runtime::actions::Action;
|
||||
|
||||
/// Translate the user's yes/no answer on the quit-confirm overlay into an action.
|
||||
@@ -1,13 +1,20 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
|
||||
//! session's `SQLite` blob store.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use sha2::Digest;
|
||||
use zesdex_cms::domain::repository::EditLogRepository;
|
||||
|
||||
/// Returns the number of stored pre-edit blobs (snapshots) for this session.
|
||||
pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||
let Ok(conn) = open_session_db(&state.session_dir) else { return 0 };
|
||||
let Ok(conn) = open_session_db(&state.session_dir) else {
|
||||
return 0;
|
||||
};
|
||||
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
|
||||
.ok()
|
||||
.map_or(0, |keys| keys.len())
|
||||
@@ -51,7 +58,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
}
|
||||
|
||||
let blob_key = &keys[index];
|
||||
let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key) {
|
||||
let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key)
|
||||
{
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
@@ -74,8 +82,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
// Look up the path from the edit log — the blob key is the tool_call_id.
|
||||
// The edit log doesn't store the tool_call_id directly, so fall back to the
|
||||
// path from the most recent write/edit entry.
|
||||
let restore_path = find_edit_path(state, blob_key)
|
||||
.unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
|
||||
let restore_path =
|
||||
find_edit_path(state, blob_key).unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
|
||||
|
||||
match std::fs::write(&restore_path, &bytes) {
|
||||
Ok(()) => {
|
||||
@@ -93,8 +101,10 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
}
|
||||
|
||||
// Log the rewind itself as an edit entry
|
||||
let mut el = crate::model::editlog::EditLog::new(&state.session_dir);
|
||||
let entry = crate::model::editlog::EditLogEntry {
|
||||
let repo =
|
||||
zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
|
||||
if let Ok(mut el) = repo.open(&state.session_dir) {
|
||||
let entry = zesdex_cms::domain::edit_log::EditLogEntry {
|
||||
ts: chrono::Utc::now().timestamp_millis(),
|
||||
tool: "rewind".to_string(),
|
||||
path: restore_path.to_string_lossy().to_string(),
|
||||
@@ -104,7 +114,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
origin: crate::app::state::types::Origin::Main.tag(),
|
||||
session_id: state.session_id.clone(),
|
||||
};
|
||||
let _ = el.append(entry);
|
||||
let _ = repo.append(&state.session_dir, &mut el, entry);
|
||||
}
|
||||
|
||||
// Clear the transcript to force a refresh
|
||||
state.transcript_cache.dirty = true;
|
||||
@@ -118,7 +129,13 @@ fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Co
|
||||
}
|
||||
|
||||
fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option<std::path::PathBuf> {
|
||||
let el = crate::model::editlog::EditLog::new(&state.session_dir);
|
||||
let entry = el.entries.iter().rev().find(|e| e.tool == "write" || e.tool == "edit")?;
|
||||
let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
|
||||
.open(&state.session_dir)
|
||||
.unwrap_or_else(|_| zesdex_cms::domain::edit_log::EditLog::new());
|
||||
let entry = el
|
||||
.entries
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|e| e.tool == "write" || e.tool == "edit")?;
|
||||
Some(std::path::PathBuf::from(&entry.path))
|
||||
}
|
||||
@@ -3,8 +3,7 @@
|
||||
//! Flow: exposes small mutation functions (currently just cycling the
|
||||
//! internet access mode) invoked by keybindings while the settings overlay
|
||||
//! is active.
|
||||
|
||||
use crate::model::settings::{Settings, InternetMode};
|
||||
use zesdex_cms::domain::settings::{InternetMode, Settings};
|
||||
|
||||
/// Advance the internet access mode to the next value in the cycle.
|
||||
///
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! Flow: exposes the toggle handler invoked by a keybinding to show/hide
|
||||
//! the todo overlay.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Adaptive quality-review triggering, build/test probing, staleness
|
||||
//! sweeps for stored lessons, and the pending-lesson approval workflow.
|
||||
|
||||
pub mod pending;
|
||||
pub mod probe;
|
||||
pub mod prompt;
|
||||
pub mod staleness;
|
||||
pub mod types;
|
||||
|
||||
pub use pending::{load_pending_lessons, process_pending_lessons, resolve_pending_lesson};
|
||||
pub use staleness::maybe_run_staleness_sweep;
|
||||
pub use types::{Confidence, LessonScope};
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::runtime::TurnEvent;
|
||||
use crate::app::state::types::{Origin, Toast, ToastKind};
|
||||
use crate::app::subagent::context::build_subagent_context;
|
||||
use crate::app::subagent::engine::run_subagent;
|
||||
use crate::app::subagent::event::SubagentEvent;
|
||||
use crate::app::subagent::spawn::{spawn_subagent_with_drain, AgentDefinition};
|
||||
|
||||
/// Decide whether an adaptive quality review should fire for this turn.
|
||||
///
|
||||
/// Flow: only `Origin::Main` turns are eligible → require review enabled
|
||||
/// in settings → fire every 5th edit unconditionally → otherwise, once
|
||||
/// `consecutive_empty_reviews` reaches `adaptive_review_max_skip` (min 2),
|
||||
/// fire on an exponentially growing skip interval (2^n, capped at 2^10)
|
||||
/// to avoid reviewing every single edit once reviews keep coming back empty.
|
||||
///
|
||||
/// Why: balances review usefulness against wasted subagent calls when
|
||||
/// reviews consistently find nothing.
|
||||
///
|
||||
/// Return: `true` if a review should be triggered this turn.
|
||||
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
if origin != Origin::Main {
|
||||
return false;
|
||||
}
|
||||
let Some(runtime) = &state.session_runtime else {
|
||||
return false;
|
||||
};
|
||||
if !state.settings.flags.review_enabled {
|
||||
return false;
|
||||
}
|
||||
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
||||
return true;
|
||||
}
|
||||
let base: u32 = state.settings.adaptive_review_max_skip.max(2);
|
||||
let consecutive = runtime.consecutive_empty_reviews;
|
||||
if consecutive >= base {
|
||||
let skip = 1u32 << (consecutive - base).min(10);
|
||||
if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Spawn a background quality-review subagent for the current session.
|
||||
///
|
||||
/// Flow: build a "quality-reviewer" subagent context → probe build/test
|
||||
/// status via `probe_build_test` to give the reviewer a real pass/fail
|
||||
/// signal → compose a system prompt embedding the probe result and lesson
|
||||
/// tagging instructions → spawn a thread running `run_subagent` → on
|
||||
/// completion, push a `TurnEvent::SystemNote` with the verdict's first
|
||||
/// line (or error) → push an "in progress" toast immediately.
|
||||
///
|
||||
/// Why: runs on a plain OS thread (not tokio) so it doesn't block the
|
||||
/// async event loop; communicates its result back via `turn_events`
|
||||
/// rather than a channel receiver (the `_rx` half is intentionally unused).
|
||||
///
|
||||
/// Return: `Ok(())` once the review has been kicked off; errors only
|
||||
/// propagate from constructing the subagent context, not from the review
|
||||
/// itself (that failure is reported via a `SystemNote` instead).
|
||||
pub fn trigger_review(state: &mut AppStateRest) {
|
||||
state.misc.lesson_running = true;
|
||||
|
||||
if let Some(workspace) = state.workspace_roots.first() {
|
||||
let gitignore_path = workspace.join(".gitignore");
|
||||
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
|
||||
if !content.contains("docs/lesson") {
|
||||
use std::io::Write;
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&gitignore_path)
|
||||
{
|
||||
let prefix = if content.is_empty() || content.ends_with('\n') {
|
||||
""
|
||||
} else {
|
||||
"\n"
|
||||
};
|
||||
let _ = writeln!(file, "{prefix}docs/lesson/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut def = AgentDefinition::new("lesson-generator".to_string(), "reviewer".to_string());
|
||||
// Explicitly allow write_file for docs/lesson
|
||||
def.allowed_tools = Some(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
]);
|
||||
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir.clone_from(&state.session_dir);
|
||||
ctx.workspaces.clone_from(&state.workspace_roots);
|
||||
|
||||
let probe_result = probe::probe_build_test(
|
||||
&state.workspace_roots,
|
||||
state.settings.verify_command.as_deref(),
|
||||
state.settings.verify_timeout_ms,
|
||||
);
|
||||
|
||||
let probe_note = match &probe_result {
|
||||
Some(r) => {
|
||||
if r.passed {
|
||||
format!("Build/test verification passed ({}).", r.command)
|
||||
} else if r.timed_out {
|
||||
format!("Build/test verification timed out ({}).", r.command)
|
||||
} else {
|
||||
format!(
|
||||
"Build/test verification failed ({}). Output: {}",
|
||||
r.command, r.output
|
||||
)
|
||||
}
|
||||
}
|
||||
None => "No build/test probe matched.".to_string(),
|
||||
};
|
||||
|
||||
ctx.system_prompt = prompt::compose_review_prompt(state, &probe_note);
|
||||
|
||||
let turn_events_for_drain = state.turn_events.clone();
|
||||
let (tx, _drain_thread) = spawn_subagent_with_drain(move |event| {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[review] tool call: {}", tool)
|
||||
}
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[review] tool result: {}", tool)
|
||||
}
|
||||
SubagentEvent::StepCompleted { .. } => tracing::trace!("[review] step completed"),
|
||||
SubagentEvent::StepFailed { step, error } => {
|
||||
tracing::warn!("[review] step {} failed: {}", step, error)
|
||||
}
|
||||
SubagentEvent::Progress(_) => {}
|
||||
SubagentEvent::Completed => tracing::debug!("[review] completed"),
|
||||
SubagentEvent::Usage {
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
} => {
|
||||
if let Ok(mut q) = turn_events_for_drain.lock() {
|
||||
q.push_back(TurnEvent::ReviewUsage {
|
||||
tokens_in: *tokens_in,
|
||||
tokens_out: *tokens_out,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let turn_events = state.turn_events.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let result = run_subagent(&ctx, &tx);
|
||||
let message = match result {
|
||||
Ok(verdict) => {
|
||||
let first_line = verdict.lines().next().unwrap_or(&verdict);
|
||||
format!("Lesson created: {first_line}")
|
||||
}
|
||||
Err(e) => format!("Lesson generation failed: {e}"),
|
||||
};
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
kind: "review".to_string(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Info,
|
||||
"Generating lesson...".to_string(),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Pending-lesson approval workflow: queuing lessons that await user
|
||||
//! confirmation, with optional auto-resolve after a grace period.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::types::Lesson;
|
||||
use zesdex_cms::domain::memory::Memory;
|
||||
use zesdex_cms::domain::repository::MemoryRepository;
|
||||
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
||||
|
||||
/// A lesson awaiting confirmation before being committed to memory,
|
||||
/// optionally auto-resolving after a grace period.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingLesson {
|
||||
pub lesson: Lesson,
|
||||
pub created_at: i64,
|
||||
pub auto_resolve: bool,
|
||||
}
|
||||
|
||||
/// Load the session's pending-lessons queue from disk.
|
||||
///
|
||||
/// Return: the parsed list, or an empty `Vec` if the file is missing or
|
||||
/// fails to parse.
|
||||
pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson> {
|
||||
let path = session_dir.join("pending_lessons.json");
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Write the session's pending-lessons queue to disk as pretty JSON.
|
||||
///
|
||||
/// Return: `Ok(())`, or an I/O error from writing the file.
|
||||
pub(crate) fn save_pending_lessons(
|
||||
session_dir: &std::path::Path,
|
||||
pending: &[PendingLesson],
|
||||
) -> std::io::Result<()> {
|
||||
let path = session_dir.join("pending_lessons.json");
|
||||
let data = serde_json::to_string_pretty(pending)?;
|
||||
std::fs::write(&path, data)
|
||||
}
|
||||
|
||||
/// Commit any auto-resolvable pending lessons whose grace period has
|
||||
/// elapsed, and persist the remaining queue.
|
||||
///
|
||||
/// Flow: load pending lessons → partition into those eligible to commit
|
||||
/// (`auto_resolve` and older than the 5s grace window) vs. still pending
|
||||
/// → write eligible lessons as new `Memory` entries with `lifecycle:
|
||||
/// "active"` → save the remaining (unresolved) queue back to disk.
|
||||
///
|
||||
/// Why: the grace window gives the user a brief window to reject an
|
||||
/// auto-resolving lesson via `resolve_pending_lesson` before it commits.
|
||||
///
|
||||
/// Return: the still-pending lessons (post-commit), or an I/O error from
|
||||
/// writing memory files or the queue.
|
||||
pub fn process_pending_lessons(
|
||||
session_dir: &std::path::Path,
|
||||
memory_dir: &std::path::Path,
|
||||
) -> std::io::Result<Vec<PendingLesson>> {
|
||||
let pending = load_pending_lessons(session_dir);
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let grace_window = 5_000;
|
||||
let mut remaining = Vec::new();
|
||||
let mut to_keep = Vec::new();
|
||||
|
||||
for p in &pending {
|
||||
if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window {
|
||||
to_keep.push(p.lesson.clone());
|
||||
} else {
|
||||
remaining.push(p.clone());
|
||||
}
|
||||
}
|
||||
for lesson in &to_keep {
|
||||
let mem = Memory {
|
||||
name: lesson.name.clone(),
|
||||
description: lesson.content.chars().take(80).collect(),
|
||||
content: lesson.content.clone(),
|
||||
kind: "lesson".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
outcome: None,
|
||||
lifecycle: "active".to_string(),
|
||||
scope: Some("project".to_string()),
|
||||
before_snippet: None,
|
||||
after_snippet: None,
|
||||
provenances: vec![],
|
||||
};
|
||||
MarkdownMemoryRepository::new()
|
||||
.save(memory_dir, &mem)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
|
||||
save_pending_lessons(session_dir, &remaining)?;
|
||||
Ok(remaining)
|
||||
}
|
||||
|
||||
/// Manually resolve a single pending lesson by name: commit it to memory
|
||||
/// or discard it.
|
||||
///
|
||||
/// Flow: load the queue → find the lesson matching `lesson_name` →
|
||||
/// if `keep` is true, write it as an active `Memory` entry; either way
|
||||
/// remove it from the queue → save the remaining queue.
|
||||
///
|
||||
/// Why: lets the user (or UI action) override a pending lesson's fate
|
||||
/// before/without waiting for the auto-resolve grace window.
|
||||
///
|
||||
/// Return: `Ok(())`, or an I/O error from writing the memory file or queue.
|
||||
pub fn resolve_pending_lesson(
|
||||
session_dir: &std::path::Path,
|
||||
memory_dir: &std::path::Path,
|
||||
lesson_name: &str,
|
||||
keep: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let pending = load_pending_lessons(session_dir);
|
||||
let mut remaining = Vec::new();
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
for p in pending {
|
||||
if p.lesson.name == lesson_name {
|
||||
if keep {
|
||||
let mem = Memory {
|
||||
name: p.lesson.name.clone(),
|
||||
description: p.lesson.content.chars().take(80).collect(),
|
||||
content: p.lesson.content.clone(),
|
||||
kind: "lesson".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
outcome: None,
|
||||
lifecycle: "active".to_string(),
|
||||
scope: Some("project".to_string()),
|
||||
before_snippet: None,
|
||||
after_snippet: None,
|
||||
provenances: vec![],
|
||||
};
|
||||
MarkdownMemoryRepository::new()
|
||||
.save(memory_dir, &mem)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
} else {
|
||||
remaining.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
save_pending_lessons(session_dir, &remaining)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Build/test probing: running a verification command and capturing its
|
||||
//! pass/fail/timeout outcome for the review subagent.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
/// Outcome of running a build/test probe command against a workspace.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProbeResult {
|
||||
pub command: String,
|
||||
pub passed: bool,
|
||||
pub output: String,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
/// Run a build/test verification command in the first workspace root and
|
||||
/// capture its outcome, to back a review with a real pass/fail signal.
|
||||
///
|
||||
/// Flow: pick the first workspace → resolve the verify command (explicit
|
||||
/// override or auto-detected via `resolve_verify_command`) → spawn it →
|
||||
/// poll `try_wait` in a loop, killing the child if `timeout_ms` elapses →
|
||||
/// capture combined stdout+stderr (truncated) on completion.
|
||||
///
|
||||
/// Why: polling instead of a blocking wait lets the timeout be enforced
|
||||
/// without spawning a watcher thread.
|
||||
///
|
||||
/// Return: `None` if no workspace exists, no command could be resolved,
|
||||
/// or the process failed to spawn/poll; otherwise `Some(ProbeResult)`
|
||||
/// describing pass/fail/timeout and truncated output.
|
||||
pub fn probe_build_test(
|
||||
workspaces: &[std::path::PathBuf],
|
||||
verify_command: Option<&str>,
|
||||
timeout_ms: u64,
|
||||
) -> Option<ProbeResult> {
|
||||
let probe_dir = workspaces.first()?;
|
||||
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
||||
|
||||
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(
|
||||
|| (cmd.clone(), String::new()),
|
||||
|(p, a)| (p.to_string(), a.to_string()),
|
||||
);
|
||||
|
||||
let Ok(mut child) = Command::new(&cmd_prog)
|
||||
.args(cmd_args.split_whitespace())
|
||||
.current_dir(probe_dir)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let timed_out = loop {
|
||||
if start.elapsed().as_millis() as u64 >= timeout_ms {
|
||||
let _ = child.kill();
|
||||
break true;
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let output = child.wait_with_output().ok();
|
||||
let stdout = output
|
||||
.as_ref()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default();
|
||||
let stderr = output
|
||||
.as_ref()
|
||||
.map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string())
|
||||
.unwrap_or_default();
|
||||
let combined = if stderr.is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
format!("{stdout}\n{stderr}")
|
||||
};
|
||||
return Some(ProbeResult {
|
||||
command: cmd.clone(),
|
||||
passed: status.success(),
|
||||
output: truncate_output(&combined, 2048),
|
||||
timed_out: false,
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
};
|
||||
if timed_out {
|
||||
Some(ProbeResult {
|
||||
command: cmd.clone(),
|
||||
passed: false,
|
||||
output: "timed out".to_string(),
|
||||
timed_out: true,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the shell command to build/test a workspace, auto-detecting
|
||||
/// the project type from marker files when no override is given.
|
||||
///
|
||||
/// Flow: use `override_cmd` verbatim if non-empty → otherwise probe for
|
||||
/// language/tool marker files (Cargo.toml, go.mod, package.json, etc.)
|
||||
/// in priority order and return that ecosystem's conventional test/build
|
||||
/// command.
|
||||
///
|
||||
/// Why: covers a broad set of ecosystems so review probing works without
|
||||
/// per-project configuration in the common case.
|
||||
///
|
||||
/// Return: `Some(command)` if a command could be determined, `None` if
|
||||
/// no marker files matched (e.g. plain Python project with no test dir).
|
||||
pub(crate) fn resolve_verify_command(
|
||||
probe_dir: &std::path::Path,
|
||||
override_cmd: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if let Some(cmd) = override_cmd {
|
||||
if !cmd.trim().is_empty() {
|
||||
return Some(cmd.trim().to_string());
|
||||
}
|
||||
}
|
||||
let has_file = |name: &str| probe_dir.join(name).exists();
|
||||
let has_dir = |name: &str| probe_dir.join(name).is_dir();
|
||||
if has_file("Cargo.toml") {
|
||||
if has_dir("src") || has_dir("tests") {
|
||||
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
|
||||
}
|
||||
return Some("cargo build 2>&1".to_string());
|
||||
}
|
||||
if has_file("go.mod") {
|
||||
return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string());
|
||||
}
|
||||
if has_file("package.json") {
|
||||
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
|
||||
let scripts = v.get("scripts")?;
|
||||
if scripts
|
||||
.get("test")
|
||||
.and_then(|s| s.as_str())
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.is_empty())
|
||||
{
|
||||
return Some("npm test 2>&1".to_string());
|
||||
}
|
||||
if scripts
|
||||
.get("build")
|
||||
.and_then(|s| s.as_str())
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.is_empty())
|
||||
{
|
||||
return Some("npm run build 2>&1".to_string());
|
||||
}
|
||||
}
|
||||
return Some("npm test 2>&1".to_string());
|
||||
}
|
||||
if has_file("pyproject.toml")
|
||||
|| has_file("requirements.txt")
|
||||
|| has_file("setup.py")
|
||||
|| has_file("setup.cfg")
|
||||
|| has_file("Pipfile")
|
||||
|| has_file("poetry.lock")
|
||||
{
|
||||
if has_file("pyproject.toml") {
|
||||
let content =
|
||||
std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
|
||||
if content.contains("[tool.pytest") {
|
||||
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
||||
}
|
||||
}
|
||||
if has_dir("tests") || has_dir("test") {
|
||||
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if has_file("Cargo.lock") {
|
||||
return Some("cargo build 2>&1".to_string());
|
||||
}
|
||||
if has_file("Gemfile") || has_file("Rakefile") || has_file("*.gemspec") {
|
||||
return Some("bundle exec rake 2>&1".to_string());
|
||||
}
|
||||
if has_file("Makefile") || has_file("makefile") || has_file("GNUmakefile") {
|
||||
return Some("make test 2>&1 || make build 2>&1".to_string());
|
||||
}
|
||||
if has_file("justfile") || has_file("justfile") {
|
||||
return Some("just test 2>&1 || just build 2>&1".to_string());
|
||||
}
|
||||
if has_file("deno.json") || has_file("deno.jsonc") {
|
||||
return Some("deno test 2>&1".to_string());
|
||||
}
|
||||
if has_file("bun.lock") || has_file("bun.lockb") {
|
||||
return Some("bun test 2>&1".to_string());
|
||||
}
|
||||
if has_file("pnpm-lock.yaml") {
|
||||
return Some("pnpm test 2>&1 || pnpm build 2>&1".to_string());
|
||||
}
|
||||
if has_file("yarn.lock") {
|
||||
return Some("yarn test 2>&1 || yarn build 2>&1".to_string());
|
||||
}
|
||||
if has_file("composer.json") {
|
||||
return Some("composer test 2>&1 || composer run build 2>&1".to_string());
|
||||
}
|
||||
if has_file("build.gradle") || has_file("build.gradle.kts") || has_file("gradlew") {
|
||||
return Some("gradle build 2>&1 && gradle test 2>&1".to_string());
|
||||
}
|
||||
if has_file("pom.xml") || has_file("mvnw") {
|
||||
return Some("mvn test 2>&1".to_string());
|
||||
}
|
||||
if has_file("stack.yaml") || has_file("package.yaml") || has_file("cabal.project") {
|
||||
return Some("cabal test all 2>&1 || stack test 2>&1".to_string());
|
||||
}
|
||||
if has_file("mix.exs") {
|
||||
return Some("mix test 2>&1".to_string());
|
||||
}
|
||||
if has_file("rebar.config") || has_file("rebar.lock") {
|
||||
return Some("rebar3 ct 2>&1 || rebar3 eunit 2>&1".to_string());
|
||||
}
|
||||
if has_file("dune-project") || has_file("jbuild") || has_file("Makefile") {
|
||||
return Some("dune runtest 2>&1".to_string());
|
||||
}
|
||||
if has_file("shard.yml") {
|
||||
return Some("crystal spec 2>&1".to_string());
|
||||
}
|
||||
if has_file("Project.toml") || has_file("JuliaProject.toml") {
|
||||
return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `max` characters, appending a marker if cut.
|
||||
///
|
||||
/// Return: the original string if short enough, otherwise the first `max`
|
||||
/// characters plus `"... (truncated)"`.
|
||||
pub(crate) fn truncate_output(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
let mut t: String = s.chars().take(max).collect();
|
||||
t.push_str("... (truncated)");
|
||||
t
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Review prompt composition: building the system prompt for the
|
||||
//! quality-review subagent, embedding git diff, chat history, and
|
||||
//! build/test probe results.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Number of days without update after which a memory is flagged as stale.
|
||||
pub(crate) const STALE_AFTER_DAYS: i64 = 60;
|
||||
|
||||
/// Compose the system prompt for the quality-review subagent.
|
||||
pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> String {
|
||||
let diff_output = if let Some(workspace) = state.workspace_roots.first() {
|
||||
std::process::Command::new("git")
|
||||
.arg("diff")
|
||||
.arg("HEAD")
|
||||
.current_dir(workspace)
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let history_output = if let Some(rt) = &state.session_runtime {
|
||||
let msgs: Vec<String> = rt
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.role == crate::dto::chat::message::Role::Assistant
|
||||
|| m.role == crate::dto::chat::message::Role::User
|
||||
})
|
||||
.rev()
|
||||
.take(10)
|
||||
.map(|m| format!("{:?}: {}", m.role, m.content.as_deref().unwrap_or("")))
|
||||
.collect();
|
||||
let mut rev_msgs = msgs;
|
||||
rev_msgs.reverse();
|
||||
rev_msgs.join("\n\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let session_dir_disp = state.session_dir.display();
|
||||
format!(
|
||||
"You are a code quality reviewer and lesson generator. Your goal is to review recent code changes.\n\n\
|
||||
Session directory: {session_dir_disp}\n\n\
|
||||
--- Build/Test Probe ---\n{probe_note}\n\n\
|
||||
--- Recent Chat History (Last 10 messages) ---\n{history_output}\n\n\
|
||||
--- Recent Code Diffs (git diff HEAD) ---\n{diff_output}\n\n\
|
||||
INSTRUCTIONS:\n\
|
||||
1. Compare the 'Recent Chat History' (what the AI promised or discussed) with the 'Recent Code Diffs' (what was actually changed).\n\
|
||||
2. Ensure that the AI's promises match the actual code changes.\n\
|
||||
3. Evaluate the code quality in the diff (check for best practices, clean code).\n\
|
||||
4. Write your findings and learning points as a lesson to a file in `docs/lesson/` (e.g., docs/lesson/lesson_01.md).\n\
|
||||
5. Use the `write` tool to save this markdown file.\n\
|
||||
6. Your verdict should briefly summarize what lesson was created.",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Staleness sweep: flagging memory entries as stale when they haven't
|
||||
//! been updated for `STALE_AFTER_DAYS`, rate-limited to once per 10
|
||||
//! minutes.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::{Toast, ToastKind};
|
||||
use super::prompt::STALE_AFTER_DAYS;
|
||||
use zesdex_cms::domain::repository::MemoryRepository;
|
||||
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
||||
|
||||
/// Flag memory entries as stale if they haven't been updated recently.
|
||||
///
|
||||
/// Flow: list all memory files → for each, read it → if `updated_at` is
|
||||
/// older than `STALE_AFTER_DAYS` and it isn't already flagged, set
|
||||
/// `lifecycle = "stale"` and write it back → collect flagged names.
|
||||
///
|
||||
/// Return: names of newly-flagged memories, or an I/O error from
|
||||
/// `mem.write`.
|
||||
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
|
||||
let mut flagged = Vec::new();
|
||||
let names = MarkdownMemoryRepository::new()
|
||||
.list(memory_dir)
|
||||
.unwrap_or_default();
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000;
|
||||
for name in names {
|
||||
if let Ok(mut mem) = MarkdownMemoryRepository::new().load(memory_dir, &name) {
|
||||
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
|
||||
mem.lifecycle = "stale".to_string();
|
||||
MarkdownMemoryRepository::new()
|
||||
.save(memory_dir, &mem)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
flagged.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(flagged)
|
||||
}
|
||||
|
||||
/// Run the staleness sweep at most once every 10 minutes, notifying via toast.
|
||||
///
|
||||
/// Flow: skip if less than 600,000ms since `last_staleness_sweep_ms` →
|
||||
/// otherwise update the timestamp and run `run_staleness_sweep`, pushing
|
||||
/// an info toast listing flagged lessons if any were found.
|
||||
///
|
||||
/// Why: rate-limited so the sweep (a file read/write per memory) doesn't
|
||||
/// run on every event-loop tick.
|
||||
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
|
||||
return;
|
||||
}
|
||||
state.misc.last_staleness_sweep_ms = now;
|
||||
if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) {
|
||||
if !flagged.is_empty() {
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Info,
|
||||
format!(
|
||||
"Staleness sweep: {} lesson(s) flagged as stale: {}",
|
||||
flagged.len(),
|
||||
flagged.join(", ")
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Core data types for lessons: their confidence, lifecycle, scope,
|
||||
//! provenance, and the `Lesson` struct itself.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::app::state::types::Origin;
|
||||
|
||||
/// How much trust a lesson's origin/verification warrants.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum Confidence {
|
||||
Human,
|
||||
Verified,
|
||||
Unverified,
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Where a lesson sits in its life cycle, from freshly written to superseded.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum LessonLifecycle {
|
||||
New,
|
||||
Active,
|
||||
Stale,
|
||||
Contradicted,
|
||||
Superseded,
|
||||
}
|
||||
|
||||
/// Whether a lesson applies to the current project only or globally.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum LessonScope {
|
||||
Project,
|
||||
Global,
|
||||
}
|
||||
|
||||
/// Records who/what produced a lesson and in which session/turn.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Provenance {
|
||||
pub session_turn: String,
|
||||
pub session_id: String,
|
||||
pub reviewer: Origin,
|
||||
}
|
||||
|
||||
/// A single learned fact/pattern surfaced by a review, prior to being
|
||||
/// written to persistent memory.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Lesson {
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
pub confidence: Confidence,
|
||||
pub outcome: Option<String>,
|
||||
pub lifecycle: LessonLifecycle,
|
||||
pub scope: LessonScope,
|
||||
pub contradiction_with: Option<String>,
|
||||
pub provenance: Provenance,
|
||||
}
|
||||
|
||||
impl Default for Lesson {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
content: String::new(),
|
||||
confidence: Confidence::Unverified,
|
||||
outcome: None,
|
||||
lifecycle: LessonLifecycle::New,
|
||||
scope: LessonScope::Project,
|
||||
contradiction_with: None,
|
||||
provenance: Provenance {
|
||||
session_turn: String::new(),
|
||||
session_id: String::new(),
|
||||
reviewer: Origin::Main,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-10
@@ -1,8 +1,8 @@
|
||||
//! Maps parsed `/` slash commands into one or more `Action` variants
|
||||
//! that `apply_action` can process.
|
||||
use crate::controller::command::Command;
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::types::Overlay;
|
||||
use crate::controller::command::Command;
|
||||
|
||||
/// Convert a parsed `Command` into the corresponding sequence of `Action`s.
|
||||
///
|
||||
@@ -21,9 +21,6 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
Command::Quit => {
|
||||
vec![Action::QuitConfirm]
|
||||
}
|
||||
Command::LessonInteractive => {
|
||||
vec![Action::OpenOverlay(Overlay::Learning)]
|
||||
}
|
||||
Command::McpOpen => {
|
||||
vec![Action::OpenOverlay(Overlay::Mcp)]
|
||||
}
|
||||
@@ -63,12 +60,7 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
Command::Compact => {
|
||||
vec![Action::Compact]
|
||||
}
|
||||
Command::WorkflowOpen => {
|
||||
vec![Action::OpenOverlay(Overlay::Workflow)]
|
||||
}
|
||||
Command::WorkflowRun { script } => {
|
||||
vec![Action::RunWorkflow { script }]
|
||||
}
|
||||
|
||||
Command::TodoOpen => {
|
||||
vec![Action::OpenOverlay(Overlay::Todo)]
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Simple action handler functions — one per `Action` variant, called by
|
||||
//! `apply_action` in the root module. Each handler mutates `AppStateRest`
|
||||
//! in place.
|
||||
|
||||
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
|
||||
use crate::app::state::runtime::TurnEvent;
|
||||
use crate::app::state::types::{Overlay, Toast, ToastKind};
|
||||
use crate::dto::chat::message::{ChatMessage, Role};
|
||||
use zesdex_cms::domain::repository::MemoryRepository;
|
||||
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
||||
|
||||
use super::io::save_current_session;
|
||||
use super::memory::refresh_lesson_counters;
|
||||
use super::spawn::spawn_turn;
|
||||
use super::oauth::run_oauth_flow;
|
||||
|
||||
pub(super) fn handle_force_quit(state: &mut AppStateRest) {
|
||||
save_current_session(state);
|
||||
state.shutdown_lsp();
|
||||
state.quit = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_submit_input(state: &mut AppStateRest, text: String) {
|
||||
state.input.submit();
|
||||
let text = text.trim().to_string();
|
||||
if text.is_empty() {
|
||||
state.dirty = true;
|
||||
return;
|
||||
}
|
||||
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.push_message(ChatMessage::user(text));
|
||||
refresh_lesson_counters(&state.memory_dir, rt);
|
||||
} else {
|
||||
let _ = std::fs::create_dir_all(&state.memory_dir);
|
||||
}
|
||||
state.misc.thinking = true;
|
||||
spawn_turn(state);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_delete_char(state: &mut AppStateRest) {
|
||||
state.input.delete_left();
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_delete_char_right(state: &mut AppStateRest) {
|
||||
state.input.delete_right();
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_cursor_left(state: &mut AppStateRest) {
|
||||
state.input.char_left();
|
||||
}
|
||||
|
||||
pub(super) fn handle_cursor_right(state: &mut AppStateRest) {
|
||||
state.input.char_right();
|
||||
}
|
||||
|
||||
pub(super) fn handle_history_up(state: &mut AppStateRest) {
|
||||
state.input.history_up();
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_history_down(state: &mut AppStateRest) {
|
||||
state.input.history_down();
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_scroll_up(state: &mut AppStateRest) {
|
||||
state.scroll.scroll_up(5);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_scroll_down(state: &mut AppStateRest) {
|
||||
state.scroll.scroll_down(5);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) {
|
||||
state.misc.overlay = overlay;
|
||||
if overlay == Overlay::Learning
|
||||
|| overlay == Overlay::Rewind
|
||||
|| overlay == Overlay::ModelSelector
|
||||
{
|
||||
state.misc.selected_index = 0;
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_open_editor(state: &mut AppStateRest, path: String) {
|
||||
let resolved = crate::tool::resolve_path(&state.workspace_roots, &path);
|
||||
match resolved {
|
||||
Ok(abs_path) => {
|
||||
let content = std::fs::read_to_string(&abs_path).unwrap_or_default();
|
||||
let lines: Vec<String> =
|
||||
content.lines().map(std::string::ToString::to_string).collect();
|
||||
let ed = crate::app::mode::editor::EditorState::open(
|
||||
abs_path.to_string_lossy().to_string(),
|
||||
Some(lines),
|
||||
);
|
||||
state.misc.editor = Some(ed);
|
||||
state.misc.overlay = Overlay::Editor;
|
||||
state.push_toast(Toast::new(ToastKind::Info, format!("Editing {path}")));
|
||||
}
|
||||
Err(e) => {
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Error,
|
||||
format!("Failed to open {path}: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) {
|
||||
let extra_args: Vec<String> =
|
||||
command.split_whitespace().map(std::string::ToString::to_string).collect();
|
||||
let cmd = extra_args.first().cloned().unwrap_or_default();
|
||||
let args: Vec<String> = extra_args.into_iter().skip(1).collect();
|
||||
match state.mcp_manager.connect_stdio(&name, &cmd, &args) {
|
||||
Ok(()) => {
|
||||
let tool_count = state
|
||||
.mcp_manager
|
||||
.servers
|
||||
.last()
|
||||
.map_or(0, |s| s.tools.len());
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Success,
|
||||
format!("Connected MCP server '{name}' ({tool_count} tools)"),
|
||||
));
|
||||
state.dirty = true;
|
||||
}
|
||||
Err(e) => {
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Error,
|
||||
format!("MCP connect failed: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_model_list(state: &mut AppStateRest) {
|
||||
state.misc.selected_index = 0;
|
||||
state.misc.overlay = Overlay::ModelSelector;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_close_overlay(state: &mut AppStateRest) {
|
||||
// If the overlay is the Editor, dismiss it properly first
|
||||
if state.misc.overlay == Overlay::Editor {
|
||||
crate::app::mode::editor::handle_editor_dismiss(state);
|
||||
}
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_system_note(state: &mut AppStateRest, message: String) {
|
||||
let toast = Toast::new(ToastKind::Info, message);
|
||||
state.push_toast(toast);
|
||||
}
|
||||
|
||||
pub(super) fn handle_quit_confirm(state: &mut AppStateRest) {
|
||||
state.misc.overlay = Overlay::QuitConfirm;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_resize(state: &mut AppStateRest, w: u16) {
|
||||
state.scroll.set_max_visible(w as usize);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
|
||||
let turn_events = state.turn_events.clone();
|
||||
let provider_clone = provider.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = run_oauth_flow(&provider_clone);
|
||||
let message = match result {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => format!("OAuth login failed: {e}"),
|
||||
};
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
kind: "oauth".to_string(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
});
|
||||
let toast = Toast::new(
|
||||
ToastKind::Info,
|
||||
format!("Opening browser for {provider} login..."),
|
||||
);
|
||||
state.push_toast(toast);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_abort_turn(state: &mut AppStateRest) {
|
||||
state
|
||||
.abort_flag
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Warning,
|
||||
"Aborting generation...".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
pub(super) fn handle_compact(state: &mut AppStateRest) {
|
||||
let max_wire_tokens = state
|
||||
.app_config
|
||||
.model_roles
|
||||
.values()
|
||||
.find(|role| {
|
||||
role.provider == state.settings.provider && role.model == state.settings.model
|
||||
})
|
||||
.and_then(|role| role.context_window)
|
||||
.unwrap_or(state.app_config.default_context_window) as usize;
|
||||
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
let total_chars: usize = rt
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(str::len)
|
||||
.sum();
|
||||
let token_estimate = total_chars / 3;
|
||||
rt.messages =
|
||||
crate::app::runtime::context::shaping::shape_messages(
|
||||
&rt.messages,
|
||||
token_estimate,
|
||||
max_wire_tokens,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Success,
|
||||
"Conversation history compacted.".to_string(),
|
||||
));
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
let _ = crate::app::review::resolve_pending_lesson(
|
||||
&rt.session_dir,
|
||||
&state.memory_dir,
|
||||
&name,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
refresh_lesson_counters(&state.memory_dir, rt);
|
||||
}
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Success,
|
||||
format!("accepted lesson: {name}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
let _ = crate::app::review::resolve_pending_lesson(
|
||||
&rt.session_dir,
|
||||
&state.memory_dir,
|
||||
&name,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
refresh_lesson_counters(&state.memory_dir, rt);
|
||||
}
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Info,
|
||||
format!("rejected lesson: {name}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub(super) fn handle_lesson_delete(state: &mut AppStateRest, name: String) {
|
||||
let _ = MarkdownMemoryRepository::new().delete(&state.memory_dir, &name);
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
refresh_lesson_counters(&state.memory_dir, rt);
|
||||
}
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Info,
|
||||
format!("deleted lesson: {name}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user