From dcd0ec4df39ef87c57ed4be2e8650e5e341b74d7 Mon Sep 17 00:00:00 2001 From: allen-munsch-bot Date: Sun, 9 Aug 2026 16:06:33 -0500 Subject: [PATCH] =?UTF-8?q?feat(vigil):=20phase=205=20=E2=80=94=20vigil=20?= =?UTF-8?q?runtime=20with=20/panel=20vigils,=20cross-session=20messaging,?= =?UTF-8?q?=20and=20e2e=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .osv-scanner.toml | 4 + Cargo.lock | 1005 +++++++++++------ Cargo.toml | 3 + docs/proposals/vigil.md | 817 ++++++++++++++ docs/vigils/README.md | 67 ++ docs/vigils/usage.md | 267 +++++ docs/vigils/use-cases.md | 323 ++++++ docs/vigils/vs-loop-mcp.md | 110 ++ src/cli.rs | 67 ++ src/config/mod.rs | 88 ++ src/extras/dirge_paths.rs | 7 + src/extras/mod.rs | 4 + src/extras/vigil/dispatch.rs | 263 +++++ src/extras/vigil/harbinger.rs | 149 +++ src/extras/vigil/mod.rs | 293 +++++ src/extras/vigil/reaper.rs | 337 ++++++ src/extras/vigil/rite.rs | 113 ++ src/extras/vigil/toll.rs | 64 ++ src/extras/vigil/types.rs | 208 ++++ src/extras/vigil/watcher.rs | 140 +++ src/extras/vigil_db.rs | 184 +++ src/main.rs | 322 +++++- src/plugin/loader.rs | 4 + src/plugin/mod.rs | 7 + src/plugin/mod_tests.rs | 56 +- src/plugin/worker.rs | 378 +++++++ src/ui/mod.rs | 158 ++- src/ui/panel_data.rs | 28 + src/ui/renderer.rs | 25 + src/ui/run_handlers/done.rs | 58 + src/ui/slash/cmd/mod.rs | 1 + src/ui/slash/cmd/panel.rs | 8 +- src/ui/slash/cmd/vigil_cmd/add.rs | 132 +++ src/ui/slash/cmd/vigil_cmd/mod.rs | 61 + src/ui/slash/cmd/vigil_cmd/pause.rs | 25 + src/ui/slash/cmd/vigil_cmd/remove.rs | 43 + src/ui/slash/cmd/vigil_cmd/rest.rs | 49 + src/ui/slash/cmd/vigil_cmd/resume.rs | 25 + src/ui/slash/cmd/vigil_cmd/start.rs | 29 + src/ui/slash/cmd/vigil_cmd/status.rs | 58 + src/ui/slash/cmd/vigil_cmd/stop.rs | 29 + src/ui/slash/completion.rs | 2 +- src/ui/slash/mod.rs | 18 + src/ui/tui/panels.rs | 120 ++ src/ui/tui/scene.rs | 50 +- tests/fixtures/vigil/README.md | 193 ++++ tests/fixtures/vigil/echo-airflow.sh | 4 + tests/fixtures/vigil/echo-jenkins.sh | 4 + tests/fixtures/vigil/echo-prefect.sh | 5 + .../vigil/plugins/airflow-poller.janet | 45 + .../vigil/plugins/jenkins-poller.janet | 47 + .../vigil/plugins/prefect-poller.janet | 46 + tests/fixtures/vigil/podman-compose.yml | 57 + tests/fixtures/vigil/run-sanity-checks.sh | 199 ++++ tests/fixtures/vigil/sanity-airflow.json | 12 + .../vigil/sanity-harbinger-commands.json | 29 + .../vigil/sanity-harbinger-template.json | 14 + tests/fixtures/vigil/sanity-jenkins.json | 12 + tests/fixtures/vigil/sanity-prefect.json | 12 + tests/fixtures/vigil/sanity-toll.json | 12 + tests/fixtures/vigil/sanity-watcher.json | 12 + tests/fixtures/vigil/setup-airflow.sh | 92 ++ tests/fixtures/vigil/setup-jenkins.sh | 57 + tests/fixtures/vigil/setup-prefect.sh | 56 + tests/fixtures/vigil/watch-dir/README.txt | 1 + tests/fixtures/vigil/watch-dir/trigger.txt | 0 66 files changed, 6751 insertions(+), 357 deletions(-) create mode 100644 docs/proposals/vigil.md create mode 100644 docs/vigils/README.md create mode 100644 docs/vigils/usage.md create mode 100644 docs/vigils/use-cases.md create mode 100644 docs/vigils/vs-loop-mcp.md create mode 100644 src/extras/vigil/dispatch.rs create mode 100644 src/extras/vigil/harbinger.rs create mode 100644 src/extras/vigil/mod.rs create mode 100644 src/extras/vigil/reaper.rs create mode 100644 src/extras/vigil/rite.rs create mode 100644 src/extras/vigil/toll.rs create mode 100644 src/extras/vigil/types.rs create mode 100644 src/extras/vigil/watcher.rs create mode 100644 src/extras/vigil_db.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/add.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/mod.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/pause.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/remove.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/rest.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/resume.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/start.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/status.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/stop.rs create mode 100644 tests/fixtures/vigil/README.md create mode 100755 tests/fixtures/vigil/echo-airflow.sh create mode 100755 tests/fixtures/vigil/echo-jenkins.sh create mode 100755 tests/fixtures/vigil/echo-prefect.sh create mode 100644 tests/fixtures/vigil/plugins/airflow-poller.janet create mode 100644 tests/fixtures/vigil/plugins/jenkins-poller.janet create mode 100644 tests/fixtures/vigil/plugins/prefect-poller.janet create mode 100644 tests/fixtures/vigil/podman-compose.yml create mode 100755 tests/fixtures/vigil/run-sanity-checks.sh create mode 100644 tests/fixtures/vigil/sanity-airflow.json create mode 100644 tests/fixtures/vigil/sanity-harbinger-commands.json create mode 100644 tests/fixtures/vigil/sanity-harbinger-template.json create mode 100644 tests/fixtures/vigil/sanity-jenkins.json create mode 100644 tests/fixtures/vigil/sanity-prefect.json create mode 100644 tests/fixtures/vigil/sanity-toll.json create mode 100644 tests/fixtures/vigil/sanity-watcher.json create mode 100755 tests/fixtures/vigil/setup-airflow.sh create mode 100755 tests/fixtures/vigil/setup-jenkins.sh create mode 100755 tests/fixtures/vigil/setup-prefect.sh create mode 100644 tests/fixtures/vigil/watch-dir/README.txt create mode 100644 tests/fixtures/vigil/watch-dir/trigger.txt diff --git a/.osv-scanner.toml b/.osv-scanner.toml index 78d3c69b..d5b2ebab 100644 --- a/.osv-scanner.toml +++ b/.osv-scanner.toml @@ -1,3 +1,7 @@ [[IgnoredVulns]] id = "RUSTSEC-2025-0057" reason = "fxhash is a transitive dep of bm25; no maintained fork exists that preserves the required hash32/hash64 API" + +[[IgnoredVulns]] +id = "RUSTSEC-2024-0384" +reason = "instant is a transitive dep of notify-types → notify (dirge's watch dep); unmaintained, no drop-in fork yet — notify maintainers are tracking web-time migration" diff --git a/Cargo.lock b/Cargo.lock index 4dceba03..e1a27bb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aead" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +checksum = "ef60ac202874e574ce7a7158cc8bca7313dd344322482e4fadee288bf4a306b8" dependencies = [ "crypto-common 0.2.2", "inout", @@ -52,7 +52,7 @@ dependencies = [ "blocking", "futures", "futures-concurrency", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "rustix", "schemars 1.2.1", "serde", @@ -70,7 +70,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3abd4080f51e4f24f5042beb7fb7a66ede29a2dc1c2582c329532e1c27264ddc" dependencies = [ "quote", - "syn 3.0.0", + "syn 3.0.3", ] [[package]] @@ -136,8 +136,8 @@ dependencies = [ "nom 8.0.0", "ratatui-core", "simdutf8", - "smallvec 1.15.2", - "thiserror 2.0.19", + "smallvec 1.15.1", + "thiserror 2.0.18", ] [[package]] @@ -225,9 +225,9 @@ checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] name = "arrayvec" -version = "0.7.8" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "as-any" @@ -328,7 +328,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -368,7 +368,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -379,13 +379,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.0", + "syn 2.0.117", ] [[package]] @@ -451,8 +451,8 @@ dependencies = [ "quote", "regex", "rustc-hash 1.1.0", - "shlex 1.3.0", - "syn 2.0.119", + "shlex", + "syn 2.0.117", ] [[package]] @@ -502,9 +502,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", "zeroize", @@ -582,13 +582,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "regex-automata", - "serde_core", + "serde", ] [[package]] @@ -617,9 +617,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cached" @@ -632,7 +632,7 @@ dependencies = [ "cached_proc_macro_types", "hashbrown 0.15.5", "once_cell", - "thiserror 2.0.19", + "thiserror 2.0.18", "web-time", ] @@ -645,7 +645,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -674,12 +674,12 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.67" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -699,15 +699,15 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cipher", @@ -718,9 +718,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.45" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -736,7 +736,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.1", + "block-buffer 0.12.0", "crypto-common 0.2.2", "inout", "zeroize", @@ -755,9 +755,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -765,9 +765,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -784,7 +784,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -934,9 +934,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -953,9 +953,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" @@ -992,7 +992,7 @@ checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ "cpubits", "ctutils", - "getrandom 0.4.3", + "getrandom 0.4.2", "hybrid-array", "num-traits", "rand_core 0.10.1", @@ -1017,7 +1017,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "hybrid-array", "rand_core 0.10.1", ] @@ -1045,9 +1045,9 @@ dependencies = [ [[package]] name = "ctor" -version = "1.0.9" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a394189d59f9befacce833f337f7b1eca5e9a91221bcdd4d28e0114d96e597b3" +checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" dependencies = [ "link-section", "linktime-proc-macro", @@ -1097,7 +1097,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1152,7 +1152,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1165,7 +1165,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1178,7 +1178,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.0", + "syn 3.0.3", ] [[package]] @@ -1189,7 +1189,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1200,7 +1200,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1211,7 +1211,7 @@ checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" dependencies = [ "darling_core 0.24.0", "quote", - "syn 3.0.0", + "syn 3.0.3", ] [[package]] @@ -1220,6 +1220,37 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "delegate" version = "0.13.5" @@ -1228,14 +1259,14 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "der" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ "const-oid", "pem-rfc7468", @@ -1248,6 +1279,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ + "powerfmt", "serde_core", ] @@ -1270,7 +1302,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.119", + "syn 2.0.117", "unicode-xid", ] @@ -1305,7 +1337,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", + "block-buffer 0.12.0", "const-oid", "crypto-common 0.2.2", "ctutils", @@ -1345,6 +1377,7 @@ dependencies = [ "libc", "libkrun-sys", "lsp-types", + "notify", "notify-rust", "nucleo-matcher", "once_cell", @@ -1361,11 +1394,11 @@ dependencies = [ "serde", "serde_json", "sha2", - "smallvec 1.15.2", + "smallvec 1.15.1", "stop-words 0.10.0", "streaming-iterator", "sysinfo", - "thiserror 2.0.19", + "thiserror 2.0.18", "tokio", "tokio-rustls", "toml", @@ -1436,7 +1469,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1562,7 +1595,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1583,7 +1616,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1599,7 +1632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1667,12 +1700,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - [[package]] name = "fastrand" version = "2.4.1" @@ -1695,6 +1722,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1772,9 +1809,9 @@ checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1787,9 +1824,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1805,20 +1842,20 @@ dependencies = [ "futures-core", "futures-lite", "pin-project", - "smallvec 1.15.2", + "smallvec 1.15.1", ] [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1827,9 +1864,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -1846,26 +1883,26 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" @@ -1879,9 +1916,9 @@ dependencies = [ [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1914,7 +1951,7 @@ dependencies = [ "fnv", "itertools 0.14.0", "num-traits", - "rand 0.10.2", + "rand 0.10.1", "rand_pcg", "random_choice", "rayon", @@ -1938,9 +1975,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.4.4" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b" +checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3" dependencies = [ "generic-array 0.14.7", "rustversion", @@ -1983,15 +2020,17 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasip2", + "wasip3", "wasm-bindgen", ] @@ -2012,9 +2051,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ "aho-corasick", "bstr", @@ -2048,9 +2087,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -2106,9 +2145,9 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.11.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ "hashbrown 0.16.1", ] @@ -2172,7 +2211,7 @@ checksum = "d1c97eea9d3e6524d8d2a4643ce0e3135500c4c7ca1d02393a485f871bef69d8" dependencies = [ "html5ever", "tendril", - "thiserror 2.0.19", + "thiserror 2.0.18", "unicode-width", ] @@ -2198,9 +2237,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.1.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", @@ -2208,9 +2247,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", @@ -2227,9 +2266,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "ctutils", "subtle", @@ -2239,9 +2278,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" dependencies = [ "atomic-waker", "bytes", @@ -2253,7 +2292,7 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "smallvec 1.15.2", + "smallvec 1.15.1", "tokio", "want", ] @@ -2310,7 +2349,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -2359,7 +2398,7 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", - "smallvec 1.15.2", + "smallvec 1.15.1", "zerovec", ] @@ -2404,6 +2443,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2417,7 +2462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", - "smallvec 1.15.2", + "smallvec 1.15.1", "utf8_iter", ] @@ -2433,9 +2478,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.30" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7" +checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" dependencies = [ "crossbeam-deque", "globset", @@ -2498,6 +2543,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.2.2" @@ -2518,7 +2583,16 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", ] [[package]] @@ -2529,7 +2603,7 @@ checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" dependencies = [ "num-integer", "num-traits", - "rand 0.10.2", + "rand 0.10.1", "rand_core 0.10.1", ] @@ -2592,7 +2666,7 @@ dependencies = [ "janetrs_version", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2605,6 +2679,59 @@ dependencies = [ "evil-janet", ] +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2617,7 +2744,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.18", "walkdir", "windows-link 0.2.1", ] @@ -2632,7 +2759,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2651,25 +2778,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] [[package]] name = "jsonschema" -version = "0.49.5" +version = "0.49.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8da60094fc1968bbd091e2cfa004415cb7b19e25e592376e66a64aa832bb6039" +checksum = "ce93912abc8220a3fdb768b2c4a826a7a9a4b1599cfb4d760d422eaa1faf88c7" dependencies = [ "ahash", "bytecount", @@ -2698,18 +2826,18 @@ dependencies = [ [[package]] name = "jsonschema-regex" -version = "0.49.5" +version = "0.49.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36539f34ef9e5da418433fbbf7294522d8f930ecf6a540ccd1ec6481c9ff9056" +checksum = "8165657ebed4d32c50f3c250c1986d8428b16fbfeac355222e8fec50aa26eb1d" dependencies = [ "regex-syntax", ] [[package]] name = "jsonschema-value" -version = "0.49.5" +version = "0.49.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bc513dfee68c6fe29498451df95a32951ea227801b476f4a1f236433986c891" +checksum = "b069c5fdda3e9c2242bba49d811d5bbdb488abd8d234ed44a6312fd2891113e1" dependencies = [ "ahash", "bytecount", @@ -2727,7 +2855,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.19", + "thiserror 2.0.18", ] [[package]] @@ -2750,12 +2878,38 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -2764,9 +2918,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libkrun-sys" -version = "0.9.7" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9607d41f54e9d6a01380bd2d9cd2405b9d1d8af1504a55c7b23f77c904d60ac0" +checksum = "d68d3f9149f71292a4e7278b2b71ca12f33fdc3705cdf9a57437c40943e46c2b" dependencies = [ "libc", "num_cpus", @@ -2791,18 +2945,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.38.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -2820,15 +2974,15 @@ dependencies = [ [[package]] name = "link-section" -version = "0.19.0" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e333fe507b738576d6da5bb3f1a7d7a1c80307ed9ef31624c057d844c19c93e9" +checksum = "5ee1a0d6e252afe82e7bc2db42fba60e02ddf3b1accaf8cb21d96e34ba61f3d4" [[package]] name = "linktime-proc-macro" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" +checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" [[package]] name = "linux-raw-sys" @@ -2859,15 +3013,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ "hashbrown 0.17.1", ] @@ -2921,15 +3075,15 @@ dependencies = [ [[package]] name = "md5" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -2970,9 +3124,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -3042,6 +3196,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.1", + "filetime", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -3056,6 +3228,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -3100,9 +3281,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.8" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -3140,10 +3321,11 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.46" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ + "autocfg", "num-integer", "num-traits", ] @@ -3338,9 +3520,9 @@ dependencies = [ "delegate", "futures", "log", - "rand 0.10.2", + "rand 0.10.1", "sha2", - "thiserror 2.0.19", + "thiserror 2.0.18", "tokio", "windows 0.62.2", "windows-strings 0.5.1", @@ -3348,26 +3530,35 @@ dependencies = [ [[package]] name = "palette" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" dependencies = [ "approx", - "fast-srgb8", "libm", "palette_derive", + "palette_math", ] [[package]] name = "palette_derive" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", ] [[package]] @@ -3395,7 +3586,7 @@ dependencies = [ "cfg-if", "libc", "redox_syscall", - "smallvec 1.15.2", + "smallvec 1.15.1", "windows-link 0.2.1", ] @@ -3505,7 +3696,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -3527,12 +3718,11 @@ dependencies = [ [[package]] name = "pkcs5" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +checksum = "279a91971a1d8eb1260a30938eae3be9cb67b472dffecb222fbbbe2fd2dc1453" dependencies = [ "aes", - "aes-gcm", "cbc", "der", "pbkdf2", @@ -3576,9 +3766,9 @@ dependencies = [ [[package]] name = "poly1305" -version = "0.9.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +checksum = "a00baa632505d05512f48a963e16051c54fda9a95cc9acea1a4e3c90991c4a2e" dependencies = [ "cpufeatures 0.3.0", "universal-hash", @@ -3587,9 +3777,9 @@ dependencies = [ [[package]] name = "polyval" -version = "0.7.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" dependencies = [ "cpubits", "cpufeatures 0.3.0", @@ -3598,9 +3788,18 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] [[package]] name = "potential_utf" @@ -3630,7 +3829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -3713,9 +3912,9 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] name = "quote" -version = "1.0.46" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3757,12 +3956,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.3", + "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -3839,7 +4038,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -3923,34 +4122,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.18", ] [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.0", + "syn 2.0.117", ] [[package]] name = "referencing" -version = "0.49.5" +version = "0.49.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05915176d843da9ec5c925e5e2631be9aa61b27430acfdd562e79cae8f559725" +checksum = "f39f4c36ce0f50e96fb740d895f1cad34cfa76c4ab5c36934d6590bcd7029087" dependencies = [ "ahash", "fluent-uri 0.4.1", @@ -3965,9 +4164,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.13.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -3977,9 +4176,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -3988,9 +4187,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -4071,7 +4270,7 @@ dependencies = [ "schemars 1.2.1", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.18", "tokio", "tracing", "tracing-futures", @@ -4103,7 +4302,7 @@ dependencies = [ "schemars 1.2.1", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.18", "tokio", "tracing", "tracing-futures", @@ -4120,7 +4319,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4139,9 +4338,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "094c075f6698deef5a657cf4df6b684dff65157d255978b92b552ec22503f17a" +checksum = "c8dddc5b1924b9a59fba420166160ca2c4663a4e01803e52eda33070f56d63c8" dependencies = [ "base64 0.23.1", "bytes", @@ -4157,7 +4356,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror 2.0.19", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", @@ -4167,15 +4366,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737d947bcfd946fae6a179a4ef6487be6dcf25c930c2393856b820f1386e52a6" +checksum = "6898e24cd16342b59bfa8a53c2c04b9cf62fc8a2cfea57b9c038b09984bfc521" dependencies = [ "darling 0.24.0", "proc-macro2", "quote", "serde_json", - "syn 3.0.0", + "syn 3.0.3", ] [[package]] @@ -4185,21 +4384,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.19", + "thiserror 2.0.18", ] [[package]] name = "rusqlite" -version = "0.40.1" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink 0.12.1", "libsqlite3-sys", - "smallvec 1.15.2", + "smallvec 1.15.1", "sqlite-wasm-rs", ] @@ -4228,8 +4427,8 @@ dependencies = [ "elliptic-curve", "enum_dispatch", "futures", - "generic-array 1.4.4", - "getrandom 0.4.3", + "generic-array 1.4.2", + "getrandom 0.4.2", "ghash", "hex-literal", "hmac", @@ -4249,7 +4448,7 @@ dependencies = [ "pkcs5", "pkcs8", "polyval", - "rand 0.10.2", + "rand 0.10.1", "rand_core 0.10.1", "ring", "russh-cryptovec", @@ -4265,7 +4464,7 @@ dependencies = [ "ssh-encoding", "ssh-key", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.18", "tokio", "typenum", "universal-hash", @@ -4314,9 +4513,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.3" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -4337,14 +4536,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", @@ -4356,9 +4555,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4368,9 +4567,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] @@ -4393,7 +4592,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4415,9 +4614,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -4488,7 +4687,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4591,7 +4790,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4602,7 +4801,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4621,13 +4820,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.21" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.0", + "syn 2.0.117", ] [[package]] @@ -4641,9 +4840,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -4651,6 +4850,7 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -4661,14 +4861,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4762,12 +4962,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - [[package]] name = "signal-hook" version = "0.3.18" @@ -4811,9 +5005,9 @@ dependencies = [ [[package]] name = "simd_cesu8" -version = "1.2.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" dependencies = [ "rustc_version", "simdutf8", @@ -4839,9 +5033,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smallvec" @@ -4854,9 +5048,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4892,9 +5086,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" dependencies = [ "bytes", "futures-util", @@ -5044,7 +5238,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -5055,9 +5249,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.119" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -5066,9 +5260,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.0" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -5092,7 +5286,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -5136,7 +5330,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.18", "windows 0.61.3", "windows-version", ] @@ -5148,19 +5342,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "tendril" -version = "0.5.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" dependencies = [ "new_debug_unreachable", + "utf-8", ] [[package]] @@ -5174,11 +5369,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.18", ] [[package]] @@ -5189,36 +5384,37 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 3.0.0", + "syn 2.0.117", ] [[package]] name = "thread_local" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.53" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", @@ -5230,15 +5426,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -5256,9 +5452,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -5271,9 +5467,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -5287,13 +5483,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -5356,9 +5552,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime", @@ -5390,7 +5586,7 @@ dependencies = [ "indexmap 2.14.0", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.18", ] [[package]] @@ -5457,7 +5653,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -5504,7 +5700,7 @@ dependencies = [ "once_cell", "regex-automata", "sharded-slab", - "smallvec 1.15.2", + "smallvec 1.15.1", "thread_local", "tracing", "tracing-core", @@ -5680,9 +5876,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uds_windows" @@ -5779,6 +5975,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5793,11 +5995,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -5885,18 +6087,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -5907,9 +6118,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -5917,9 +6128,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5927,26 +6138,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -5960,11 +6193,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -5982,9 +6227,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ "phf", "phf_codegen", @@ -5994,9 +6239,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -6012,9 +6257,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.5" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" dependencies = [ "libc", ] @@ -6041,7 +6286,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6149,7 +6394,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -6160,7 +6405,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -6353,19 +6598,107 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wnaf" version = "0.14.0" @@ -6391,14 +6724,14 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink 0.11.1", + "hashlink 0.11.0", ] [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6413,7 +6746,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] @@ -6461,7 +6794,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -6480,22 +6813,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -6515,7 +6848,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] @@ -6555,14 +6888,14 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zvariant" @@ -6587,7 +6920,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "zvariant_utils", ] @@ -6600,6 +6933,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 2.0.117", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index b1dc0a2d..0571fef2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ default = [ 'lsp', 'acp', 'plugin', + 'vigil', 'semantic-ts', 'semantic-python', 'semantic-bash', @@ -125,6 +126,7 @@ no-plugin = [ # The release workflow builds the Windows target with this set. windows-default = ['no-plugin'] loop = [] +vigil = ["dep:notify"] # Run dirge itself as an MCP server (`dirge mcp`) so another agent (e.g. # Claude Code) can delegate implementation tasks to dirge and review them. # Pulls rmcp's server side + stdio transport + the tool macros. @@ -336,6 +338,7 @@ lsp-types = { version = "0.97", optional = true } tree-sitter-elixir = { version = "0.3.5", optional = true } tree-sitter-sequel = { version = "0.3", optional = true } tree-sitter-dafny = { version = "0.1", optional = true } +notify = { version = "7", optional = true, default-features = false, features = ["macos_kqueue"] } rusqlite = { version = "0.40", features = ["bundled"] } # DAP (Debug Adapter Protocol) — optional feature for driving # debuggers (lldb-dap, dlv, debugpy, node) to fix crashes instead diff --git a/docs/proposals/vigil.md b/docs/proposals/vigil.md new file mode 100644 index 00000000..500ebf55 --- /dev/null +++ b/docs/proposals/vigil.md @@ -0,0 +1,817 @@ +# Vigil — Heartbeat & Wakeup Mode + +## Motivation + +`/loop` runs the agent continuously — turn after turn, chained, until a max iteration cap or manual stop. It works for autonomous task execution but has no pause between turns: the agent runs, the loop immediately launches the next iteration, rinse, repeat. + +Two gaps: + +- There is no mechanism to wake the agent up _on a trigger_ (timer, file change, network event), run one turn, then go back to sleep — monitoring, not looping. +- There is no conditional gate — no "check this first, only run the agent if the check says so." + +Vigil fills both. It is a wakeup-and-sleep runtime that monitors triggers, queues events, reaps them on a configurable cadence, runs optional gates, dispatches the agent when gates pass, and returns to monitoring. The agent only runs when it should. + +### Existing `/loop` extensions + +Two flags retrofit onto the existing `--loop` mode: + +- `--loop-oneshot` — run exactly one iteration then stop. Equivalent to `--loop-max 1`. +- `--loop-persist` — save the session to disk after each iteration so a later `--session` resume picks up the accumulated context. `--loop` currently runs entirely in memory with no persistence; `--loop-persist` makes each iteration a saved checkpoint in the same session (cumulative context between iterations). Without `--loop-persist`, each iteration is a fresh turn with no prior-turn history. + +These are independent of vigil. The `--loop-oneshot` and `--loop-persist` flags are gated on `#[cfg(feature = "loop")]` since they extend the existing `--loop` mode. The vigil feature gate only gates the vigil runtime and slash commands. + +## Theme + +"Dirge" is both a funeral lament and a direction (from Latin _dirige_, "direct my path"). Vigil extends that: a vigil is the watch kept over the dead — staying awake, attentive, waiting. It is direction _through_ watchfulness. + +The feature is named **vigil** — the period of watchful attention between agent turns. + +- A watcher configuration is a **vigil** +- The runtime that monitors triggers and dispatches agent turns is the **vigil-keeper** +- When a vigil completes its task it is **laid to rest** (state `resting`) +- One vigil chaining to the next is a **procession** + +## Architecture: Queue & Reaper Model + +Triggers are _producers_, the reaper is the _consumer_. Each vigil has its own bounded mpsc channel. Trigger frequency and dispatch cadence are independently tunable. + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ toll (timer) │ │ watcher (inotify)│ │harbinger (socket)│ +│ tokio::interval │ │ notify crate │ │ TcpListener │ +└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ + │ │ │ + │ event_tx.send() │ event_tx.send() │ event_tx.send() + ▼ ▼ ▼ + ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ + │ mpsc(256) │ │ mpsc(256) │ │ mpsc(256) │ + │ vigil: ci-watch│ │ vigil: review │ │ vigil: webhook │ + └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ + │ │ │ + ▼ ▼ ▼ + ┌──────────────────────────────────────────────────────┐ + │ vigil-keeper │ + │ │ + │ Per-vigil reap intervals (FuturesUnordered): │ + │ ci-watch → reap every 300s │ + │ review-src → reap every 30s │ + │ webhook-9000 → reap every 10s │ + │ │ + │ When a vigil's reap interval ticks: │ + │ 1. drain its channel → Vec │ + │ 2. coalesce() → one CoalescedBatch │ + │ 3. run rite (optional gate) │ + │ 4. if rite passes → spawn observance (agent turn) │ + │ 5. if procession set → inject event into next │ + │ vigil's channel (bypassing its trigger) │ + └──────────────────────────────────────────────────────┘ +``` + +Key: there is no single `select!` loop over steps. Each vigil's reap interval fires independently via `FuturesUnordered`; the vigil-keeper is a `loop` that drives `FuturesUnordered::next()`. + +### Why per-vigil channels? + +**Isolation.** Each vigil has a dedicated `mpsc::channel::(256)`. A shared channel would let one vigil's reap drain another vigil's events — `try_recv()` takes whatever is at the head of the queue, with no way to filter by vigil name. Per-vigil channels eliminate this race: each reap drains only its own events. + +**Decoupling.** Triggers fire at their natural cadence (timer ticks, inotify events, socket connections). The reaper consolidates them on its own schedule. A CI toll that fires every 5 minutes and a file watcher that fires on every save don't compete — each pushes into its own channel, and each reaper drains independently. + +**Coalescing.** 15 `watcher` events for `ci-watch` in one reap window become _one_ observance with batched context: + +``` +{files: ["src/main.rs", "src/lib.rs", "src/tests.rs"], event_count: 15, events: ["modify", "modify", "create"]} +``` + +The agent sees the full picture, not one change at a time. + +**Backpressure.** Producers use `try_send()` — a non-blocking send that returns `Err(TrySendError::Full(event))` when the bounded channel (256) is full. On `Full`, the oldest event in the channel is popped and dropped (a `tracing::warn!` logs the loss), then `try_send` is retried. This gives ring-buffer semantics without a specialized data structure: the channel stays at capacity, fresh events push out stale ones, and memory is bounded. + +### Per-vigil reap interval + +Each vigil has its own `reap_interval_secs` field. Defaults to 30s when unset. A CI toll at 300s, a file-review watcher at 30s, a webhook at 10s — each reaps independently. The vigil-keeper uses `FuturesUnordered` to manage multiple independent intervals. + +### Toll interval vs. reap interval + +For toll (timer) triggers, `interval_secs` on the trigger controls how often an event is _produced_ (pushed into the channel), while `reap_interval_secs` controls how often the reaper _drains_ and dispatches. They are independent: a toll with `interval_secs: 60` and `reap_interval_secs: 300` produces 5 events per reap window, which are coalesced into one observance. + +When both values are equal (e.g., both 300), the timers are in lockstep — one event per reap window, prompt and simple. + +### Trigger types + +- **toll** (timer/poll): Fires on a fixed interval (like a bell tolling). +- **watcher** (inotify/filesystem): Fires when files change in a watched path. +- **harbinger** (socket/network): Fires on an incoming TCP or Unix socket connection. + +### Stages per reap + +- **Queue drain** — pull all events from this vigil's dedicated mpsc channel. +- **Coalesce** — merge N events into one `CoalescedBatch` with aggregated context (deduplicated file list, event counts, per-trigger payloads). +- **Rite** (gate check) — optional shell command and optional git dirty check. If the rite fails, the observance is skipped and the batch is discarded. +- **Observance** (agent turn) — the agent runs one turn against the prompt (template-substituted from the coalesced context). +- **Procession** — if `procession` is set, the vigil-keeper injects an event directly into the next vigil's queue (bypassing its trigger — this is a forced wake), and the next vigil will process it on its own next reap. If procession is not set, return to sleep. + +### Socket dispatch modes + +Socket vigils (harbingers) have a `socket_mode` field on the trigger variant itself: + +- `template` (default): The raw payload substitutes into `{harbinger_data}` in the prompt, then the agent turn runs. +- `commands`: The payload is parsed as JSON `{"command":"","args":{...}}` and dispatched against a pre-registered command map — no agent turn, no LLM cost. + +`socket_mode` only exists on `VigilTrigger::Harbinger`, making the invalid combination (`toll` with `commands`) unrepresentable. + +### Security model for `commands` mode + +`commands` mode replaces the earlier `tool_call` design. The problem with `tool_call` + allowlist: limiting *which tool* is called doesn't limit what the tool *does*. `{"tool":"bash","args":{"command":"rm -rf /"}}` passes a `bash` allowlist. The attack surface lives in the arguments, not the tool name. + +**Named commands.** Every `commands` harbinger MUST specify a non-empty `commands` map — a dictionary of named commands, each defining a fully-specified tool call with optional `{arg_name}` template placeholders. The vigil-keeper rejects at startup any `commands` harbinger with an empty or absent command map. + +The socket payload carries only a command name and a flat args dictionary: + +```json +{"command": "build", "args": {"release": true}} +``` + +The vigil-keeper looks up `"build"` in the command map, substitutes `{release}` in the template, and dispatches the result. The caller never provides a tool name, never constructs raw argument strings — they pick from a menu and fill in pre-declared slots. + +Example: a CI hook with three commands: + +```json +{ + "name": "ci-hook", + "trigger": { + "harbinger": { + "address": "127.0.0.1:9090", + "protocol": "tcp", + "socket_mode": "commands", + "commands": { + "build": { + "tool": "bash", + "args": { + "command": "cargo build{release}", + "description": "build {release}" + } + }, + "test": { + "tool": "bash", + "args": { + "command": "cargo test{test_name}", + "description": "run {test_name}" + } + }, + "lint": { + "tool": "bash", + "args": { + "command": "cargo clippy -- -D warnings" + } + } + } + } + }, + "prompt": "" +} +``` + +Template substitution rules: + +- `{arg_name}` in any string value inside `args` is replaced with the corresponding value from the socket payload's `args` dict. +- Missing optional args are replaced with the empty string: `{release}` → `""` when `release` is absent or `false`, `" --release"` when `true`. +- Unknown args (not present in any template) are ignored with a `tracing::debug!` log — no error, forward-compatible. +- Template substitution is string-level only; it cannot change JSON structure. You can't inject `{"tool": "write"}` via a template value because the tool is fixed in the command definition. +- The `lint` command has no templates — it's fully static. Any `args` in the payload are ignored. + +Socket payloads for the example above: + +```json +{"command": "build", "args": {"release": true}} +→ bash -c "cargo build --release" + +{"command": "test", "args": {"test_name": " -- --nocapture"}} +→ bash -c "cargo test -- --nocapture" + +{"command": "lint"} +→ bash -c "cargo clippy -- -D warnings" + +{"command": "unknown"} +→ rejected with warning log (not in command map) + +{"command": "build", "args": {"release": "; rm -rf /"}} +→ bash -c "cargo build ; rm -rf /" +→ this is still dangerous — arg values are raw strings. See mitigation below. +``` + +**Shell injection in template values.** The caller controls the substituted values, so `{"release": "; rm -rf /"}` does inject into the shell command. This is inherent to string templating. The defense is the same as for any CI system: the commands are defined by the project owner, the socket is loopback-only, and the caller is a trusted local process. For untrusted callers, use static commands (no templates, like `lint` above) so the caller provides nothing but the command name. + +**Binding constraints.** Harbinger vigils with `socket_mode: "commands"` must bind to `127.0.0.1` (loopback-only) or a Unix domain socket with restrictive filesystem permissions. The vigil-keeper rejects at startup any `commands` harbinger bound to a non-loopback address. + +**No auto-confirm.** If a dispatched tool call would require permission elevation (a `harness/confirm` dialog), it is denied. `commands` mode auto-confirms nothing. The tool must be pre-approved by the user's existing permission config. + +**Audit logging.** Every `commands` dispatch is logged at `info!` level with the source address, command name, substituted tool call, and any ignored unknown args. + +## Janet Plugin API + +External engine adapters (Prefect, Airflow, Jenkins, custom webhooks) live in Janet plugins, not in Rust. The Rust core provides the queue and dispatch machinery; Janet plugins hook into it at three lifecycle points and expose four functions for custom control. + +### Lifecycle hooks + +| Hook | When fired | Janet context | Return value | +|---|---|---|---| +| `on-vigil-event` | Event enters the queue (pre-push) | See per-trigger shapes below | Modified context map (merged into the event), or `nil` to pass through unchanged | +| `on-vigil-reap` | Reaper drains for a vigil, pre-rite | `{:vigil "ci-watch" :count 3 :files ["src/a.rs" "src/b.rs"] :trigger :watcher}` | `nil` (fire-and-forget) | +| `on-vigil-observance` | Agent turn completes | `{:vigil "ci-watch" :response "Tests passed." :exit :ok :rite_passed true}` | `nil` (fire-and-forget) | + +**Return value semantics for `on-vigil-event`:** If the hook returns a Janet table, its keys are shallow-merged into the event's context before the event is pushed into the queue. This allows plugins to enrich, transform, or annotate events without replacing the core fields (`:vigil`, `:trigger`, `:timestamp`). If the hook returns `nil`, the event passes through unchanged. + +**Per-trigger `on-vigil-event` context shapes:** + +For a **toll** trigger: + +``` +{:vigil "ci-watch" :trigger :toll :timestamp "2026-08-15T14:00:00Z"} +``` + +For a **watcher** trigger (single event, pre-coalesce): + +``` +{:vigil "review-src" :trigger :watcher :file "src/main.rs" :event "modify" :timestamp "2026-08-15T14:00:01Z"} +``` + +For a **harbinger** trigger: + +``` +{:vigil "automation-hook" :trigger :harbinger :harbinger_data "{\"command\":\"build\",\"args\":{\"release\":true}}" :timestamp "2026-08-15T14:00:02Z"} +``` + +### Janet functions exposed from Rust + +| Function | Signature | Purpose | +|---|---|---| +| `vigil/emit` | `(vigil/emit name context)` | Push an event into a vigil's queue — the core extension point for custom triggers | +| `vigil/list` | `(vigil/list)` → `[{:name "ci-watch" :state :active :trigger :toll} ...]` | Return all vigils | +| `vigil/set-state` | `(vigil/set-state name state)` | Set state: `:active`, `:paused`, `:resting` | +| `vigil/get` | `(vigil/get name)` → `{:name "ci-watch" :state :active :trigger :toll ...}` | Get a single vigil by name, or `nil` if not found | + +`vigil/emit` pushes one event into the named vigil's bounded mpsc channel. It is the sole entry point for external triggers — a Janet plugin that wants to inject events calls `(vigil/emit ...)`. How that plugin decides _when_ to emit (polling an external API, receiving its own webhook, etc.) is the plugin's responsibility. The queue is the contract. + +If the named vigil does not exist, `vigil/emit` returns `:not-found`. Dynamic vigil registration from Janet is a Phase 2 concern exposed via `vigil/register`. + +### Example: Prefect adapter plugin + +A `.janet` file that spawns a polling loop and emits events into the vigil queue. The polling loop is Janet's problem; `vigil/emit` is Rust's contract. + +```janet +# Runs on plugin load. Spawns a background fiber that polls Prefect. +(defn start-prefect-poller [] + (ev/spawn + (fn [] + (loop + (each run (prefect/fetch-failed-runs) + (vigil/emit "prefect-remediate" + {:run_id (run :id) + :flow_name (run :flow_name) + :error (run :error)})) + (ev/sleep 60))))) + +# The vigil-keeper starts toll vigils from its own config. This plugin +# just publishes events — the reaper picks them up on the vigil's reap +# interval. No vigil registration API needed in Phase 1. +``` + +### Example: Jenkins webhook adapter + +A harbinger vigil receives Jenkins build notifications. The Janet `on-vigil-event` hook transforms the Jenkins-specific payload into the standard vigil context: + +```janet +(defn jenkins-transform [ctx] + (let [raw (ctx :harbinger_data) + parsed (json/decode raw)] + # Return a modified context — the vigil-keeper shallow-merges this + # into the event before it enters the queue. + {:build_number (parsed :number) + :job_name (parsed :job) + :console_url (parsed :console_url)})) + +(harness/register-hook "on-vigil-event" "jenkins-transform") +``` + +## Configuration + +Vigils are defined in three places and merged into the database at startup: + +### A) `config.json` — inline `vigils` block + +```json +{ + "vigils": [ + { + "name": "ci-watch", + "trigger": { "toll": { "interval_secs": 300 } }, + "reap_interval_secs": 300, + "rite": { "cmd": "cargo test --quiet 2>&1" }, + "prompt": "Tests failed. Investigate and fix.\n\nTest output:\n{rite_output}", + "procession": "review-changes" + }, + { + "name": "review-changes", + "trigger": { "watcher": { "path": "src/", "events": ["modify"] } }, + "reap_interval_secs": 30, + "prompt": "Code changed after CI failure. Review {files} and assess." + }, + { + "name": "automation-hook", + "trigger": { + "harbinger": { + "address": "127.0.0.1:9090", + "protocol": "tcp", + "socket_mode": "commands", + "commands": { + "build": { + "tool": "bash", + "args": { "command": "cargo build" } + } + } + } + }, + "reap_interval_secs": 10, + "prompt": "" + } + ] +} +``` + +Note: `socket_mode` and `commands` live inside the `harbinger` trigger object, not at the top-level `VigilEntry`. `commands` is required when `socket_mode` is `"commands"` — the vigil-keeper rejects at startup any `commands` harbinger without a non-empty command map. + +### B) `.dirge/vigils/*.json` — filesystem vigils + +One vigil per JSON file, same shape as above. File name is the vigil name. Auto-discovered at startup alongside the config block. Mirrors `.dirge/skills/` and `.dirge/agents/` patterns. + +Example: `.dirge/vigils/ci-watch.json` + +```json +{ + "trigger": { "toll": { "interval_secs": 300 } }, + "reap_interval_secs": 300, + "rite": { "cmd": "cargo test --quiet 2>&1" }, + "prompt": "Tests failed. Investigate and fix.\n\nTest output:\n{rite_output}", + "procession": "lint-check" +} +``` + +### C) Database (runtime state) + +The canonical registry is the `vigils` table in `state.db`. Config and filesystem files are import sources — they seed the DB. `/vigil add` writes directly to DB. State (`active`/`paused`/`resting`) is DB-authoritative and survives restarts. + +### Config structs (Rust) + +```rust +// In Config (src/config/mod.rs) +#[cfg(feature = "vigil")] +pub vigils: Option>, + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum VigilTrigger { + Toll { interval_secs: u64 }, + Watcher { path: String, events: Vec }, + Harbinger { + address: String, + protocol: String, + #[serde(default)] + socket_mode: SocketMode, + /// Required when socket_mode is Commands. The vigil-keeper rejects + /// a Commands harbinger with an empty map at startup. + #[serde(default)] + commands: HashMap, + }, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct VigilRite { + pub cmd: Option, + pub git_dirty: bool, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SocketMode { + #[default] + Template, + Commands, +} + +/// A pre-registered named command for socket_mode: "commands". +/// The caller provides a command name; the vigil-keeper looks up the +/// template and substitutes {arg_name} placeholders from the socket payload. +#[derive(Debug, Clone, Deserialize)] +pub struct VigilCommand { + pub tool: String, + /// String values in args may contain {arg_name} templates. + pub args: serde_json::Map, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct VigilEntry { + pub name: String, + pub trigger: VigilTrigger, + #[serde(default = "default_reap_interval")] + pub reap_interval_secs: u64, + #[serde(default)] + pub rite: VigilRite, + pub prompt: String, + pub procession: Option, +} + +fn default_reap_interval() -> u64 { 30 } +``` + +### VigilRunState (runtime tracking) + +This type is held by the vigil-keeper at runtime and surfaced to the post-turn dispatch so `decide_post_done_action` knows whether a vigil observance just completed: + +```rust +// In src/extras/vigil/types.rs +#[derive(Debug, Clone)] +pub struct VigilRunState { + /// Whether the vigil-keeper is currently running (i.e., an observance + /// just completed and we should return to sleep rather than idle). + pub active: bool, + /// The name of the vigil whose observance just completed. + pub current_vigil: Option, + /// The event queue sender for the vigil-keeper's control channel. + pub ctl_tx: Option>, +} +``` + +### VigilState (UI-layer runtime tracking) + +This type is held by the TUI event loop and surfaced to slash commands via `SlashCtx`. It is separate from `VigilRunState` — `VigilRunState` lives inside the vigil-keeper, while `VigilState` is the UI's view of vigil activity: + +```rust +// In src/extras/vigil/mod.rs +#[derive(Debug, Clone)] +pub struct VigilState { + /// Whether the vigil-keeper is currently active (an observance + /// just completed and we should return to sleep rather than idle). + pub active: bool, + /// If set, the current agent turn is a vigil observance. The post-turn + /// handler reads this to dispatch `on-vigil-observance` with the agent's + /// response text. Cleared after dispatch. + pub pending_observance: Option, +} +``` + +`pending_observance` carries the vigil name and event count from the reaper to the post-turn handler, so the `on-vigil-observance` Janet hook can be dispatched with the agent's response and exit code. + +### SQLite schema (in `.dirge/sessions/state.db`) + +```sql +CREATE TABLE IF NOT EXISTS vigils ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + trigger_type TEXT NOT NULL, -- 'toll' | 'watcher' | 'harbinger' + trigger_config TEXT NOT NULL, -- JSON blob + reap_interval_secs INTEGER DEFAULT 30, + rite_cmd TEXT, + rite_git INTEGER DEFAULT 0, + prompt TEXT NOT NULL, + procession TEXT, -- next vigil name (flat chain) + state TEXT DEFAULT 'active', -- active | paused | resting | error + laid_to_rest_at TEXT, + laid_to_rest_by TEXT, -- session id + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_vigils_state ON vigils(state); +``` + +Note: `socket_mode` and `commands` are stored inside `trigger_config` JSON (part of the harbinger blob), not as top-level columns. + +## Lifecycle + +- **`active`** — Watching, will fire on trigger, reaper picks it up. Default state. +- **`paused`** — Suspended, retains config, reaper skips it. Set via `/vigil pause `. +- **`resting`** — Task complete, soft-closed. The row stays for audit. Set via `/vigil rest ` or agent tool. `/vigil resume ` flips back to `active`. +- **`error`** — Failed to start or rite errored persistently. Set by runtime. + +### Import merge behavior + +At startup, the vigil-keeper: + +1. Opens the `vigils` table (creates if needed) +2. Reads existing vigils into memory +3. Scans `Config::vigils` — for each: update if `name` matches (preserving `state`/`laid_to_rest_at`/`laid_to_rest_by`), insert if new +4. Scans `.dirge/vigils/*.json` — same merge logic +5. Validates each vigil: `commands` harbingers must have a non-empty `commands` map and bind only to loopback/Unix addresses. Invalid vigils are logged at `error!` and skipped. +6. Registers each valid active vigil with the reaper, spawning its trigger producer, mpsc channel, and reap interval + +## CLI + +```bash +# Headless vigil mode — reads vigils from config + .dirge/vigils/ +dirge --vigil + +# Import vigils from a file on startup +dirge --vigil --vigil-config vigils.json + +# Manage vigils +dirge vigil add toll --interval 300 --reap 300 --rite "cargo test --quiet" --prompt "Fix failing tests" +dirge vigil add watcher --path src/ --events modify --reap 30 --prompt "Review changes in {files}" +dirge vigil add harbinger --port 9090 --reap 10 --socket-mode commands --commands '{"build":{"tool":"bash","args":{"command":"cargo build"}}}' +dirge vigil list +dirge vigil remove +dirge vigil pause +dirge vigil resume +dirge vigil rest + +# Extended --loop flags (gated on #[cfg(feature = "loop")], NOT vigil) +dirge --loop --loop-oneshot --loop-prompt "Fix the build" +dirge --loop --loop-persist --loop-prompt "Iterative refactor" +``` + +### CLI struct additions + +```rust +// src/cli.rs + +// --loop-oneshot and --loop-persist are gated on 'loop', not 'vigil': +#[cfg(feature = "loop")] +#[arg(long = "loop-oneshot", help = "Run exactly one loop iteration then stop")] +pub loop_oneshot: bool, + +#[cfg(feature = "loop")] +#[arg(long = "loop-persist", help = "Persist session to disk each iteration")] +pub loop_persist: bool, + +// Vigil flags are gated on 'vigil': +#[cfg(feature = "vigil")] +#[arg(long = "vigil", help = "Run in vigil mode (heartbeat/wakeup)")] +pub vigil_mode: bool, + +#[cfg(feature = "vigil")] +#[arg(long = "vigil-config", help = "JSON file with vigil definitions")] +pub vigil_config: Option, + +#[command(subcommand)] +pub command: Option, + +// New subcommand variant (inside Command enum): +#[cfg(feature = "vigil")] +Vigil { + #[command(subcommand)] + action: VigilAction, +}, + +// VigilAction enum: +#[derive(Debug, Subcommand)] +pub enum VigilAction { + Add { + #[command(subcommand)] + trigger: VigilAddTrigger, + #[arg(long = "reap", default_value = "30")] + reap_interval_secs: u64, + #[arg(long = "rite")] + rite_cmd: Option, + #[arg(long = "prompt")] + prompt: String, + #[arg(long = "procession")] + procession: Option, + }, + List, + Remove { name: String }, + Pause { name: String }, + Resume { name: String }, + Rest { name: String }, +} + +#[derive(Debug, Subcommand)] +pub enum VigilAddTrigger { + Toll { + #[arg(long = "interval")] + interval_secs: u64, + }, + Watcher { + #[arg(long = "path")] + path: String, + #[arg(long = "events")] + events: Vec, + }, + Harbinger { + #[arg(long = "port")] + port: u16, + #[arg(long = "socket-mode", default_value = "template")] + socket_mode: String, + #[arg(long = "commands", help = "JSON map of named commands (required for socket-mode=commands)")] + commands: Option, + }, +} +``` + +## TUI + +``` +/vigil start [name] Start all vigils or a named one +/vigil stop [name] Stop +/vigil pause Suspend a vigil +/vigil resume Resume a paused or resting vigil +/vigil rest Lay a vigil to rest (soft-complete) +/vigil status List all vigils with states +/vigil add toll --interval 60 --reap 60 --prompt "…" [--procession ] +/vigil add watcher --path src/ --events modify --reap 30 --prompt "…" +/vigil add harbinger --port 9090 --reap 10 --prompt "…" [--socket-mode commands --commands '{"build":{...}}'] +/vigil remove +``` + +## Prompt template variables + +Available in the `prompt` field of a vigil entry. After coalescing, batched context is available for files and events: + +- `{name}` — Vigil name +- `{files}` — Changed file paths as comma-separated list (watcher trigger, coalesced) +- `{events}` — Event types as comma-separated list (watcher: create/modify/delete) +- `{event_count}` — Number of events in this reap window +- `{timestamp}` — ISO 8601 time of reap +- `{rite_output}` — stdout+stderr from the rite command +- `{rite_exit_code}` — Exit code from the rite command +- `{harbinger_data}` — Raw payload from socket connection (first connection in reap window) + +## Post-turn behavior + +A new `PostDoneAction::VigilSleep` variant signals that the observance is complete and the vigil-keeper should return to monitoring. No loop, no followup. + +```rust +pub enum PostDoneAction { + Followup(String), + LoopIter, + LoopStop, + VigilSleep, // NEW — gated behind #[cfg(feature = "vigil")] + Idle, +} + +pub fn decide_post_done_action( + followup: Option, + loop_active: bool, + loop_should_stop: bool, + #[cfg(feature = "vigil")] vigil_active: bool, // NEW — cfg-gated parameter +) -> PostDoneAction { + if let Some(text) = followup { + return PostDoneAction::Followup(text); + } + #[cfg(feature = "vigil")] + if vigil_active { + return PostDoneAction::VigilSleep; + } + if !loop_active { + return PostDoneAction::Idle; + } + if loop_should_stop { + PostDoneAction::LoopStop + } else { + PostDoneAction::LoopIter + } +} +``` + +**Why cfg-gate the parameter rather than the whole function:** The function is called unconditionally from `done.rs`. Adding a non-cfg-gated `vigil_active: bool` parameter forces every call site (including the test at `mod_tests.rs:134`) to pass a fourth argument, even when the `vigil` feature is disabled. By cfg-gating the parameter itself, the function has 3 parameters without the feature and 4 with it — call sites use the same `#[cfg]` pattern to match. + +**Call site pattern in `src/ui/run_handlers/done.rs`:** + +```rust +#[cfg(feature = "vigil")] +let vigil_active = vigil_bits + .state + .as_ref() + .is_some_and(|v| v.active); +#[cfg(not(feature = "vigil"))] +let vigil_active = (); // unused — cfg-gated parameter will be absent + +let action = crate::plugin::decide_post_done_action( + followup_for_decision, + loop_active, + loop_should_stop, + #[cfg(feature = "vigil")] + vigil_active, +); +``` + +**Test update in `src/plugin/mod_tests.rs`:** The `test_post_done_action` test (line 134) must be updated with `#[cfg]` branches. Without the `vigil` feature, the test is unchanged (3 parameters). With `vigil`, a new assertion is added for the `VigilSleep` variant: + +```rust +#[test] +fn test_post_done_action() { + let followup = Some("retry".to_string()); + assert_eq!( + decide_post_done_action(followup.clone(), true, false), + PostDoneAction::Followup("retry".into()) + ); + assert_eq!( + decide_post_done_action(followup.clone(), false, false), + PostDoneAction::Followup("retry".into()) + ); + // Loop iteration only when no followup. + assert_eq!( + decide_post_done_action(None, true, false), + PostDoneAction::LoopIter + ); + // Loop stop only when no followup and should_stop. + assert_eq!( + decide_post_done_action(None, true, true), + PostDoneAction::LoopStop + ); + // Idle: nothing to do. + assert_eq!( + decide_post_done_action(None, false, false), + PostDoneAction::Idle + ); + + #[cfg(feature = "vigil")] + { + // VigilSleep: vigil active outranks loop. + assert_eq!( + decide_post_done_action(None, true, false, true), + PostDoneAction::VigilSleep + ); + // Followup still beats vigil. + assert_eq!( + decide_post_done_action(followup.clone(), false, false, true), + PostDoneAction::Followup("retry".into()) + ); + } +} +``` + +## File map + +- `Cargo.toml` — `notify` dep, `vigil` feature gate, feature lists, `check-cfg` +- `src/cli.rs` — `--vigil`, `--vigil-config`, `Vigil` subcommand + `VigilAction` enum; `--loop-oneshot`, `--loop-persist` (gated on `loop`) +- `src/config/mod.rs` — `VigilEntry`, `VigilTrigger` (with `Harbinger { socket_mode, commands }`), `VigilRite`, `SocketMode`, `VigilCommand` types; `Config::vigils` field +- `src/extras/dirge_paths.rs` — `vigils_dir()` → `.dirge/vigils/` +- `src/extras/vigil_db.rs` — `VigilStore` — SQLite CRUD over `state.db`, follows `IssueStore` pattern +- `src/extras/vigil/types.rs` — `VigilConfig`, `VigilPayload`, `VigilEvent` (mpsc message), `TriggerType`, `RiteResult`, `CoalescedBatch`, `VigilRunState` +- `src/extras/vigil/rite.rs` — `run_rite(cfg, batch) -> RiteResult` — shell command + git dirty +- `src/extras/vigil/dispatch.rs` — `build_prompt()`, `dispatch_commands()` (command-map lookup + template substitution), `run_observance()` +- `src/extras/vigil/toll.rs` — `spawn_toll()` — `tokio::time::interval`, pushes `VigilEvent` into its vigil's channel via `try_send` with overflow-drop +- `src/extras/vigil/watcher.rs` — `spawn_watcher()` — `notify` crate, 500ms debounce, pushes into its vigil's channel +- `src/extras/vigil/harbinger.rs` — `spawn_harbinger()` — `TcpListener`/`UnixListener`, 5s read timeout, loopback-only + non-empty command-map enforcement for `commands` mode, pushes into its vigil's channel +- `src/extras/vigil/reaper.rs` — `VigilReaper` — per-vigil reap intervals via `FuturesUnordered`, `drain_queue()`, `coalesce_by_vigil()`, rite check, dispatch, procession +- `src/extras/vigil/mod.rs` — `VigilKeeper` — wires producers (each with own channel) + reaper + dispatch, import merge, startup validation +- `src/plugin/mod.rs` — `PostDoneAction::VigilSleep` variant; extend `decide_post_done_action` with cfg-gated `vigil_active` parameter +- `src/plugin/loader.rs` — hook registration for `on-vigil-event`, `on-vigil-reap`, `on-vigil-observance` +- `src/plugin/worker.rs` — Janet functions: `vigil/emit`, `vigil/list`, `vigil/set-state`, `vigil/get` +- `src/ui/run_handlers/done.rs` — Handle `VigilSleep`: reset `is_running`; cfg-gated `vigil_active` at call site +- `src/ui/slash/cmd/vigil_cmd/mod.rs` — `/vigil` dispatch +- `src/ui/slash/cmd/vigil_cmd/add.rs` — `/vigil add toll|watcher|harbinger` +- `src/ui/slash/cmd/vigil_cmd/` — `start.rs`, `stop.rs`, `status.rs`, `rest.rs`, `pause.rs`, `resume.rs`, `remove.rs` +- `src/ui/slash/mod.rs` — `vigil_state` in `SlashCtx`, dispatch `/vigil` +- `src/ui/mod.rs` — `vigil_rx` arm in main `select!` loop +- `src/main.rs` — `--vigil` entry point, `--loop-oneshot`/`--loop-persist` wiring +- `src/extras/mod.rs` — `pub mod vigil_db;` (ungated, no deps beyond rusqlite) + `#[cfg(feature = "vigil")] pub mod vigil;` +- `specs/vigil.allium` — Formal Allium v3 specification for the vigil feature (Phase 1 deliverables) + +## Implementation plan + +### Phase 1: Foundation + +1. **Cargo.toml** — `notify` dep, `vigil` feature, add to `default` + `no-plugin` + `check-cfg` +2. **`--loop-oneshot` / `--loop-persist`** — `cli.rs` + `main.rs` (gated on `loop`, not `vigil`) +3. **Config types** — `VigilEntry`, `VigilTrigger` (with `Harbinger { socket_mode, commands }`), `VigilRite`, `SocketMode`, `VigilCommand` in `config/mod.rs` +4. **`vigils_dir()`** — add to `dirge_paths.rs` + +### Phase 2: Database + +5. **`vigil_db.rs`** — `VigilStore` with full SQLite CRUD, lifecycle states. Add `pub mod vigil_db;` to `extras/mod.rs` (ungated). +6. **`extras/mod.rs` vigil module** — `#[cfg(feature = "vigil")] pub mod vigil;` + +### Phase 3: Runtime (queue + reaper) + +7. **`vigil/types.rs`** — `VigilConfig`, `VigilPayload`, `VigilEvent` (mpsc message), `CoalescedBatch`, `VigilRunState`, `VigilCtl` +8. **`vigil/rite.rs`** — gate runner +9. **`vigil/dispatch.rs`** — prompt builder, commands dispatch (command-map lookup + template substitution), `run_observance()` +10. **`vigil/toll.rs`** — timer producer, each vigil gets its own `mpsc::channel(256)`, pushes `VigilEvent` via `try_send` with overflow-drop +11. **`vigil/watcher.rs`** — inotify producer with own per-vigil channel, 500ms debounce, pushes via `try_send` +12. **`vigil/harbinger.rs`** — socket producer with own per-vigil channel (template + commands), loopback-only + non-empty command-map enforcement for `commands` mode, pushes via `try_send` +13. **`vigil/reaper.rs`** — `VigilReaper`: per-vigil reap intervals via `FuturesUnordered`, `drain_queue()` from vigil's own channel, `coalesce_by_vigil()`, rite check, spawn observance, procession (inject event into next vigil's channel) +14. **`vigil/mod.rs`** — `VigilKeeper::run()` — wires producers (each with own channel) + reaper + dispatch, import merge from config and filesystem + +### Phase 4: Plugin integration + +15. **Plugin hooks** — `on-vigil-event`, `on-vigil-reap`, `on-vigil-observance` registered in `plugin/loader.rs`, dispatched from the reaper; `on-vigil-event` return value shallow-merged into event context +16. **Janet functions** — `vigil/emit`, `vigil/list`, `vigil/set-state`, `vigil/get` in `plugin/worker.rs` +17. **`PostDoneAction::VigilSleep`** — cfg-gated variant + cfg-gated `decide_post_done_action` parameter in `plugin/mod.rs`; cfg-gated `vigil_active` at call site in `done.rs`; test update in `mod_tests.rs` + +### Phase 5: CLI + TUI + +18. **CLI** — `--vigil` flag + `vigil` subcommand + `VigilAction` enum in `cli.rs` + `main.rs` +19. **`/vigil` slash command** — full subcommand set in `ui/slash/cmd/vigil_cmd/`; register in `slash/mod.rs` and `slash/cmd/mod.rs` +20. **TUI wiring** — `vigil_rx` arm in `ui/mod.rs` event loop + +### Phase 6: Specification + +21. **Allium spec** — Write `specs/vigil.allium` (v3) covering toll timer, watcher inotify, harbinger socket with `commands` dispatch and template substitution security constraints, queue backpressure, per-vigil channel isolation, and rite gating. + +## Risks + +- **Inotify flood**: A formatter touching 50 files fires 50 events. Mitigated by 500ms debounce _before_ the event enters the queue, plus coalescing at reap time so the agent sees one batch, not 50 turns. +- **Harbinger read hang**: Unbounded `read_to_string` on TCP can block. Mitigated by `tokio::time::timeout` (5s default, configurable). +- **Observance blocks reaping**: Agent turns are async. Mitigated by spawning each observance on a detached `tokio::spawn`; the reaper continues looping `FuturesUnordered`. One observance per vigil at a time, gated by `AtomicBool` running flag — a second reap for the same vigil while its observance is still running is skipped. +- **Queue overflow**: Producers use `try_send()`. When a vigil's bounded channel (256) is full, the oldest event is popped and dropped with a `tracing::warn!` log, then the new event is pushed. This bounds memory per vigil and signals tuning pressure: shorten the reap interval or increase the channel bound. +- **Commands dispatch**: `socket_mode: "commands"` is restricted to loopback/Unix sockets only and requires a non-empty `commands` map. Every message is validated against the command map at dispatch time — only pre-registered command names are accepted. Template substitution is string-level and caller-controlled arg values are raw strings; for untrusted callers, use static commands (no templates) so the caller provides nothing but the command name. Permission escalation is blocked (no auto-confirm). +- **Feature creep**: DAG complexity. Mitigated by flat `procession` field — one vigil chains to at most one next vigil. External engines (Prefect, Airflow, Jenkins) plug in via Janet `vigil/emit`, not in Rust — the queue is the extensibility boundary. diff --git a/docs/vigils/README.md b/docs/vigils/README.md new file mode 100644 index 00000000..a04a4c95 --- /dev/null +++ b/docs/vigils/README.md @@ -0,0 +1,67 @@ +# Vigils — Heartbeat, Wakeup & Monitoring Mode + +Vigil is a wakeup-and-sleep runtime for dirge. It monitors triggers, queues +events, reaps them on a configurable cadence, runs optional gate checks, and +dispatches agent turns only when a gate passes — then returns to monitoring. + +Start it with `dirge --vigil`. Control vigils from inside the TUI with `/vigil` +slash commands. + +## What vigils do + +- **Monitor** — timer ticks, filesystem changes, TCP socket connections, or + custom Janet plugin probes +- **Queue** — bounded per-vigil channels (256 events) with ring-buffer + backpressure +- **Reap** — drain into coalesced batches on independent per-vigil intervals +- **Gate** — optional `rite` command (shell check) before firing an agent turn +- **Observe** — one agent turn with a template-substituted prompt, then back to + sleep +- **Chain** — optional `procession` field: after one vigil fires, inject an event + into the next vigil's queue (bypassing its trigger) + +## Why vigils instead of `/loop`? + +`/loop` runs the agent continuously — turn after turn, no pause, no trigger, no +conditional gate. Vigils are the monitoring counterpart: + +| | Loop | Vigil | +|---|---|---| +| Trigger | None (immediate) | Timer, file watch, socket, plugin probe | +| Idle | Busy (always running) | Sleep (wakes only on trigger) | +| Gate | None | Rite command (optional) | +| Chaining | Manual (re-prompt) | Automate (procession) | +| Best for | Long autonomous tasks | Monitoring and alert-driven fix loops | + +## Quick start + +```bash +# Create a vigil config +cat > .dirge/vigils/hello.json << 'EOF' +{ + "name": "hello", + "trigger": { "type": "toll", "interval_secs": 30 }, + "reap_interval_secs": 30, + "prompt": "Vigil fired. Rite output: {rite_output}", + "rite": { "cmd": "echo 'all clear'" } +} +EOF + +# Start in vigil mode +dirge --vigil + +# In the TUI +/vigil status # see all vigils +/vigil pause hello # temporarily stop +/vigil resume hello # restart +/vigil remove hello # remove entirely +``` + +## More docs + +- [Usage](usage.md) — configuration reference, triggers, rites, template + variables, slash commands +- [Use Cases](use-cases.md) — CI monitoring, file-diff review, webhook handlers, + Jenkins/Prefect/Airflow remediation, and custom Janet plugins +- [vs. Loop & MCP](vs-loop-mcp.md) — when to use vigil, loop, or MCP; how they + differ; why you might prefer one over the others diff --git a/docs/vigils/usage.md b/docs/vigils/usage.md new file mode 100644 index 00000000..dce84145 --- /dev/null +++ b/docs/vigils/usage.md @@ -0,0 +1,267 @@ +# Vigil Usage + +Configuration reference, trigger types, rite gates, template variables, slash +commands, and Janet plugin integration. + +## Configuration + +Vigils are defined in two places, merged by name (config wins on collision): + +### A) `config.json` — inline `vigils` block + +```json +{ + "vigils": [ + { + "name": "ci-watch", + "trigger": { "type": "toll", "interval_secs": 300 }, + "reap_interval_secs": 300, + "rite": { "cmd": "cargo test --quiet 2>&1" }, + "prompt": "Tests failed:\n{rite_output}\n\nFix the failures.", + "procession": "review-changes" + } + ] +} +``` + +### B) `.dirge/vigils/*.json` — filesystem vigils + +One file per vigil. Same schema. Filesystem vigils load alongside config vigils; +config entries win on name collision. + +```json +{ + "name": "ci-watch", + "trigger": { "type": "toll", "interval_secs": 300 }, + "reap_interval_secs": 300, + "rite": { "cmd": "cargo test --quiet 2>&1" }, + "prompt": "Tests failed:\n{rite_output}\n\nFix the failures." +} +``` + +### VigilEntry fields + +| Field | Required | Default | Description | +|---|---|---|---| +| `name` | yes | — | Unique vigil name. Used in `/vigil` commands and `vigil/emit`. | +| `trigger` | yes | — | What produces events: toll, watcher, or harbinger. | +| `reap_interval_secs` | no | `30` | How often the reaper drains the queue. | +| `rite` | no | `null` | Optional gate command. If the rite fails, the observance is skipped. | +| `prompt` | no | `""` | Template string sent to the agent for observances. | +| `procession` | no | `null` | Name of the next vigil to chain to after this one fires. | + +## Trigger types + +### Toll (timer) + +Fires on a fixed interval. Good for polling, health checks, periodic scans. + +```json +{ + "trigger": { "type": "toll", "interval_secs": 300 } +} +``` + +`interval_secs` controls how often events are *produced*. `reap_interval_secs` +controls how often they're *consumed*. When both are equal (e.g., both 300), one +event per reap window — simple. When `interval_secs` is shorter than +`reap_interval_secs`, multiple events are coalesced into one batch. + +### Watcher (filesystem) + +Fires when files change in a watched directory. Uses inotify under the hood. + +```json +{ + "trigger": { "type": "watcher", "path": "src/" } +} +``` + +Includes a 500ms debounce to coalesce rapid changes. Events carry the changed +file path and event kind (modify, create, delete). + +### Harbinger (TCP socket) + +Fires on incoming TCP connections. Two socket modes: + +#### Template mode (default) + +The raw socket payload substitutes into `{harbinger_data}` in the prompt. The +agent turn fires with the full payload. + +```json +{ + "trigger": { + "type": "harbinger", + "address": "127.0.0.1:9090", + "protocol": "tcp" + }, + "prompt": "Harbinger received:\n{harbinger_data}\n\nRespond." +} +``` + +Send data with `nc`: + +```bash +echo '{"message":"hello"}' | nc -w1 127.0.0.1 9090 +``` + +#### Commands mode + +The socket payload carries a command name and optional args. The vigil-keeper +looks up the command in a pre-registered map, substitutes `{arg}` placeholders, +and dispatches — no agent turn, no LLM cost. + +```json +{ + "trigger": { + "type": "harbinger", + "address": "127.0.0.1:9091", + "protocol": "tcp", + "socket_mode": "commands", + "commands": { + "build": { + "tool": "bash", + "args": { "command": "cargo build {release_flag}" } + }, + "ping": { + "tool": "bash", + "args": { "command": "echo 'pong'" } + } + } + } +} +``` + +```bash +echo '{"command":"build","args":{"release_flag":"--release"}}' | nc -w1 127.0.0.1 9091 +echo '{"command":"ping"}' | nc -w1 127.0.0.1 9091 +``` + +Security constraints for commands mode: + +- Must bind to `127.0.0.1` (loopback) — rejected at startup otherwise +- The commands map must be non-empty — rejected at startup otherwise +- The caller provides only a command name and flat args — no tool name, no raw + argument strings +- Dispatched tools must pass dirge's existing permission check — nothing is + auto-confirmed + +## Rites (gate checks) + +A rite is an optional shell command that runs before each observance. If the +command exits non-zero, the observance is skipped. + +```json +{ + "rite": { "cmd": "cargo test --quiet 2>&1" } +} +``` + +Use rites to: + +- Only wake the agent when a real problem exists (`curl ... | grep ERROR`) +- Run a cheap pre-check before spending LLM tokens +- Gate on git state, API health, or any shell-testable condition + +## Template variables + +The `prompt` field supports these template variables: + +| Variable | Source | +|---|---| +| `{name}` | Vigil name | +| `{files}` | Comma-separated changed file paths | +| `{events}` | Comma-separated event kinds (toll, watcher, harbinger) | +| `{event_count}` | Number of events in this reap window | +| `{timestamp}` | ISO 8601 reap time | +| `{rite_output}` | stdout+stderr from rite command | +| `{rite_exit_code}` | Exit code from rite command | +| `{harbinger_data}` | Raw socket payload (first connection in the window) | + +Additionally, any `{key}` matching a string field in the merged event context +objects is substituted. This is how custom Janet plugin fields (job names, build +numbers, flow IDs) flow into observance prompts. + +## Processions (chaining) + +When a vigil's `procession` field names another vigil, the vigil-keeper injects +an event into that vigil's queue after the observance completes — bypassing its +trigger. The next vigil processes it on its own next reap. + +```json +[ + { + "name": "ci-watch", + "trigger": { "type": "toll", "interval_secs": 300 }, + "reap_interval_secs": 300, + "rite": { "cmd": "cargo test --quiet 2>&1" }, + "prompt": "Tests failed:\n{rite_output}", + "procession": "review-changes" + }, + { + "name": "review-changes", + "trigger": { "type": "watcher", "path": "src/" }, + "reap_interval_secs": 30, + "prompt": "Code changed after CI failure. Review {files}." + } +] +``` + +## Slash commands + +All commands work from inside the TUI (`dirge --vigil`): + +| Command | Description | +|---|---| +| `/vigil status` | Show all vigils: name, trigger, reap interval, active/paused/stopped | +| `/vigil pause ` | Pause a vigil. Triggers still fire but observances are suppressed. | +| `/vigil resume ` | Resume a paused vigil. | +| `/vigil remove ` | Remove a vigil entirely. | +| `/vigil add` | Add a vigil at runtime (interactive). | +| `/vigil start` | Start all vigils. | +| `/vigil stop` | Stop all vigils. | +| `/vigil rest` | Set a vigil's state to resting (completed its task). | + +## Janet plugin integration + +Janet plugins can push events into any vigil's queue via `(vigil/emit ...)`. The +plugin decides when to emit — polling an external API, receiving a webhook, +reacting to a condition — and the queue is the contract. + +```janet +# Minimal plugin: poll Jenkins and emit failed builds +(defn poll-jenkins [] + (let [json-str (sh-capture "curl -s http://localhost:8080/api/json")] + (each job (filter #(= "FAILURE" (get-in % [:lastBuild :result])) + (parse json-str :jobs)) + (vigil/emit "jenkins-remediate" + {:job (job :name) + :build_number (string (get-in job [:lastBuild :number])) + :url (get-in job [:lastBuild :url]) + :status "FAILURE"})))) +``` + +Three lifecycle hooks are available: + +- `on-vigil-event` — fired as an event enters the queue (return a table to + enrich the context) +- `on-vigil-reap` — fired when the reaper drains a vigil +- `on-vigil-observance` — fired after the agent turn completes + +```janet +(harness/register-hook "on-vigil-event" "my-enrich-fn") +``` + +## Process model + +Startup: `dirge --vigil` loads vigils from `config.json` and `.dirge/vigils/*.json`, +merges them (config wins), creates per-vigil mpsc channels, spawns trigger tasks +(tokio intervals, inotify watchers, TCP listeners), spawns the reaper (per-vigil +`FuturesUnordered`), and enters the TUI loop. + +At runtime: triggers push `VigilEvent` structs into their vigil's channel. The +reaper drains each channel on its own independent interval, coalesces events into +a `CoalescedBatch`, runs the rite if configured, and if the rite passes, wakes +the TUI loop to spawn an agent turn. After the turn, if a `procession` is set, an +event is injected into the next vigil's queue. diff --git a/docs/vigils/use-cases.md b/docs/vigils/use-cases.md new file mode 100644 index 00000000..1f6c79c0 --- /dev/null +++ b/docs/vigils/use-cases.md @@ -0,0 +1,323 @@ +# Vigil Use Cases + +Concrete examples of what vigils can do, with full configurations. + +## CI/test failure monitoring + +The classic case: poll a test suite, only wake the agent when something breaks. + +```json +{ + "name": "ci-watch", + "trigger": { "type": "toll", "interval_secs": 300 }, + "reap_interval_secs": 300, + "rite": { "cmd": "cargo test --quiet 2>&1" }, + "prompt": "Tests failed:\n\n{rite_output}\n\nFix the failing tests. Commands to repro:\n cargo test", + "procession": "review-changes" +} +``` + +The rite runs `cargo test`. If tests pass, the observance is skipped — zero LLM +cost. If tests fail, the agent sees the failure output and attempts a fix. After +the fix, `procession` chains to `review-changes` to review the diff. + +## File-diff review on change + +When files change under `src/`, the watcher collects them, and the agent reviews +the diff. + +```json +{ + "name": "review-changes", + "trigger": { "type": "watcher", "path": "src/" }, + "reap_interval_secs": 30, + "prompt": "Files changed: {files}\n\nReview the changes for correctness, style, and potential bugs." +} +``` + +The 500ms debounce coalesces multiple saves into one observance. The agent sees +all changed files at once. + +## Health check with notification + +Poll an endpoint, gate on the response, wake the agent when degraded. + +```json +{ + "name": "health-watch", + "trigger": { "type": "toll", "interval_secs": 60 }, + "reap_interval_secs": 60, + "rite": { + "cmd": "curl -sf -o /dev/null -w '%{http_code}' http://localhost:3000/health | grep -q 200" + }, + "prompt": "Health check failed for http://localhost:3000/health.\n\nExit code: {rite_exit_code}\nOutput: {rite_output}" +} +``` + +## TCP webhook handler (template mode) + +Accept JSON payloads from any local process, feed them into an agent turn. + +```json +{ + "name": "webhook-handler", + "trigger": { + "type": "harbinger", + "address": "127.0.0.1:9090", + "protocol": "tcp" + }, + "reap_interval_secs": 10, + "prompt": "Webhook received:\n\n{harbinger_data}\n\nProcess this payload." +} +``` + +Send from a git hook, cron job, or script: + +```bash +echo '{"event":"deploy","env":"production","commit":"abc123"}' | nc -w1 127.0.0.1 9090 +``` + +## TCP command dispatcher (commands mode) + +Accept named commands from a local process. No agent turn, no LLM cost — just +dispatch pre-registered tool calls with template arguments. + +```json +{ + "name": "ci-commands", + "trigger": { + "type": "harbinger", + "address": "127.0.0.1:9091", + "protocol": "tcp", + "socket_mode": "commands", + "commands": { + "build": { + "tool": "bash", + "args": { "command": "cargo build {release}", "description": "Build the project" } + }, + "test": { + "tool": "bash", + "args": { "command": "cargo test {filter}", "description": "Run tests" } + }, + "lint": { + "tool": "bash", + "args": { "command": "cargo clippy -- -D warnings" } + } + } + } +} +``` + +```bash +echo '{"command":"build","args":{"release":"--release"}}' | nc -w1 127.0.0.1 9091 +echo '{"command":"test","args":{"filter":"my_crate::"}}' | nc -w1 127.0.0.1 9091 +echo '{"command":"lint"}' | nc -w1 127.0.0.1 9091 +``` + +## Jenkins build remediation + +A Janet plugin polls the Jenkins API for failed builds. When one is found, it +pushes an event into the vigil queue. The agent investigates and proposes a fix. + +Vigil config: + +```json +{ + "name": "jenkins-remediate", + "trigger": { "type": "toll", "interval_secs": 60 }, + "reap_interval_secs": 60, + "prompt": "Jenkins build failed.\n\nJob: {job}\nBuild: #{build_number}\nURL: {url}\nStatus: {status}\n\nInvestigate the failure and propose a fix.", + "rite": { "cmd": "echo 'jenkins-rite-ok'" } +} +``` + +Janet plugin (installed in `.dirge/plugins/jenkins-poller.janet`): + +```janet +(defn poll-jenkins [] + (let [json-str (sh-capture + "curl -s http://localhost:8080/api/json?tree=jobs[name,lastBuild[number,result,url]]")] + (when json-str + (let [parsed (parse json-str) + jobs (if (indexed? (parsed :jobs)) (parsed :jobs) @[])] + (each job jobs + (when (= "FAILURE" (get-in job [:lastBuild :result])) + (vigil/emit "jenkins-remediate" + {:job (job :name) + :build_number (string (get-in job [:lastBuild :number])) + :url (get-in job [:lastBuild :url]) + :status "FAILURE"}))))))) + +(harness/register-command "poll-jenkins" "poll-jenkins") +``` + +The custom fields (`job`, `build_number`, `url`, `status`) flow into the prompt +template via `{job}`, `{build_number}`, etc. + +## Prefect flow run remediation + +Same pattern, polling Prefect's API for failed flow runs. + +```json +{ + "name": "prefect-remediate", + "trigger": { "type": "toll", "interval_secs": 60 }, + "reap_interval_secs": 60, + "prompt": "Prefect flow run failed.\n\nFlow: {flow_name}\nRun ID: {run_id}\nState: {state}\n\nInvestigate the failure and remediate.", + "rite": { "cmd": "echo 'prefect-rite-ok'" } +} +``` + +## Airflow DAG remediation + +Polling Airflow's API for failed DAG runs. + +```json +{ + "name": "airflow-remediate", + "trigger": { "type": "toll", "interval_secs": 60 }, + "reap_interval_secs": 60, + "prompt": "Airflow DAG run failed.\n\nDAG: {dag_id}\nRun ID: {run_id}\nState: {state}\n\nInvestigate and fix the DAG.", + "rite": { "cmd": "echo 'airflow-rite-ok'" } +} +``` + +## Chained multi-vigil workflow + +Toll watches CI every 5 minutes. If tests fail, it chains to the review vigil. +If the agent's fix changes files, the review vigil fires and assesses the diff. + +```json +[ + { + "name": "ci-watch", + "trigger": { "type": "toll", "interval_secs": 300 }, + "reap_interval_secs": 300, + "rite": { "cmd": "cargo test --quiet 2>&1" }, + "prompt": "Tests failed:\n{rite_output}\n\nFix the failing tests.", + "procession": "review-changes" + }, + { + "name": "review-changes", + "trigger": { "type": "watcher", "path": "src/" }, + "reap_interval_secs": 30, + "prompt": "Code changed after CI failure. Changed files: {files}\n\nReview the changes for correctness." + } +] +``` + +Procession bypasses the watcher trigger — the `review-changes` vigil fires +immediately after `ci-watch` observes, regardless of whether files actually +changed. This ensures the review runs even when the watcher missed the change. + +## Custom Janet plugin (anything you can poll) + +Any API, any condition, any protocol — write a Janet plugin that polls and +calls `(vigil/emit ...)`: + +```janet +(defn poll-github-alerts [] + (let [json-str (sh-capture + "curl -s -H 'Authorization: Bearer $GH_TOKEN' https://api.github.com/repos/my/repo/alerts")] + (each alert (parse json-str) + (vigil/emit "github-alerts" + {:alert_id (string (alert :number)) + :severity (alert :severity) + :description (alert :description)})))) +``` + +The toll trigger drives the reaper cadence. The plugin is the event producer. +Together they form a pull-based monitoring loop with no polling in Rust — the +plugin owns when and how to poll. + +## Cross-session messaging + +Two dirge sessions on the same machine can message each other via harbinger +ports — one session's vigil listens on a TCP port, the other sends to it. This +is analogous to Claude Code's cross-session messaging: sessions discover and +communicate with each other to hand off findings, coordinate parallel work, or +signal task completion. + +**Receiver session** — listens on a harbinger port: + +```json +{ + "name": "inbox", + "trigger": { + "type": "harbinger", + "address": "127.0.0.1:9092", + "protocol": "tcp" + }, + "reap_interval_secs": 5, + "prompt": "Message from session '{sender}':\n\n{harbinger_data}\n\nRespond or act on this." +} +``` + +The harbinger template mode delivers the full TCP payload as `{harbinger_data}`. +Custom fields like `{sender}` flow through from the sending session's payload. + +**Sender session** — the `/vigil emit` slash command or a Janet plugin pushes a +message to the receiver's harbinger port: + +```bash +echo '{"sender":"build-watcher","message":"Build failed in client/ with 3 errors. I am investigating."}' | nc -w1 127.0.0.1 9092 +``` + +Or from a Janet plugin running in the sender session: + +```janet +(defn notify-inbox [msg] + (let [payload (string/format "{\"sender\":\"%s\",\"message\":\"%s\"}" + (os/getenv "DIRGE_SESSION_NAME") + msg)] + (sh-capture (string "echo '" payload "' | nc -w1 127.0.0.1 9092")))) +``` + +**Coordination pattern** — two sessions working the same repo in separate +worktrees, signaling each other when one lands a shared dependency: + +Session A (`build-watcher`): + +```json +{ + "name": "ci-watch", + "trigger": { "type": "toll", "interval_secs": 120 }, + "reap_interval_secs": 120, + "rite": { "cmd": "cargo build 2>&1" }, + "prompt": "Build failed:\n{rite_output}\n\nFix the build errors.", + "procession": "notify-done" +} +``` + +```json +{ + "name": "notify-done", + "trigger": { "type": "toll", "interval_secs": 10 }, + "reap_interval_secs": 10, + "prompt": "Build is clean. Notify session B.", + "rite": { + "cmd": "echo '{\"sender\":\"build-watcher\",\"event\":\"build-clean\",\"note\":\"main builds, rebase safe\"}' | nc -w1 127.0.0.1 9092" + } +} +``` + +Session B (`feature-dev`) listens on port 9092 for notifications from session A +and rebases when the build is clean. + +**How this compares to Claude Code cross-session messaging:** + +- Transport: TCP (harbinger) rather than Unix-domain sockets — works across + containers, VM boundaries, and networks +- Discovery: manual port assignment (you pick the ports) rather than automatic + agent listing — use distinct ports per session or a port registry +- Recipient: message goes to the vigil, which wakes the agent, rather than + appearing inline between tool calls — the agent is asleep until the message + arrives +- No permission-class escalation: the message is just data in a vigil + observance — it never answers a permission prompt or changes config +- One-way by default: the sender pushes to a port and the receiver wakes — + reply requires the receiver to have its own harbinger the sender listens on + +Use cross-session messaging when two dirge sessions need to coordinate without +you manually switching terminals. Use processions to chain work within one +session; use harbinger messaging to chain work between sessions. diff --git a/docs/vigils/vs-loop-mcp.md b/docs/vigils/vs-loop-mcp.md new file mode 100644 index 00000000..f2434bde --- /dev/null +++ b/docs/vigils/vs-loop-mcp.md @@ -0,0 +1,110 @@ +# Vigils vs. Loop vs. MCP + +Dirge has three ways to drive agent behavior. They serve different needs. + +## Loop + +`--loop` runs the agent continuously — one turn after another, chained, until a +max iteration cap or manual stop. It has two extension flags: + +- `--loop-oneshot` — run exactly one iteration +- `--loop-persist` — save session to disk after each iteration + +Best for: long autonomous coding tasks where the agent owns the full run +end-to-end. Think "build this feature from scratch" or "fix all the clippy +warnings." + +## Vigil + +`--vigil` is a wakeup-and-sleep runtime. The agent is asleep most of the time. It +wakes only when: + +- A timer fires (toll) +- A file changes (watcher) +- A TCP connection arrives (harbinger) +- A Janet plugin pushes an event (`vigil/emit`) + +Before waking, an optional rite gate runs — a shell command that must pass. This +means no LLM cost for false alarms: the rite runs `cargo test`, and only if tests +fail does the agent get a turn. + +After a turn, the agent goes back to sleep. If `procession` is set, it injects an +event into another vigil's queue first — chaining without manual intervention. + +Best for: monitoring, alert-driven remediation, health checks, CI watchdogs, +webhook handlers, and anything where the agent should be reactive, not +continuously running. + +## MCP (Model Context Protocol) + +MCP is a protocol for connecting dirge to external tools. It's an integration +surface: an MCP server provides tools, resources, and prompts that the agent can +invoke during a turn. + +Best for: adding external capabilities to agent turns — database queries, API +calls, specialized tools. MCP extends *what the agent can do during a turn*; it's +not a scheduling or trigger mechanism. + +## Decision guide + +| Need | Use | +|---|---| +| Agent runs continuously on a long task | Loop | +| Agent wakes on a schedule, checks something, sleeps | Vigil (toll) | +| Agent wakes when files change | Vigil (watcher) | +| Agent wakes on an external signal (webhook, API poll) | Vigil (harbinger or plugin) | +| Agent only wakes if a check passes | Vigil (with rite) | +| Chain one agent task into another automatically | Vigil (procession) | +| Add tools the agent can call during a turn | MCP | +| Server-pushed notifications during a turn | MCP | +| Fire-and-forget command dispatch (no LLM) | Vigil (commands mode) | +| Persistent background polling of an external service | Vigil + Janet plugin | + +## Why vigil instead of cron + loop? + +A cron job running `dirge --loop-oneshot` is possible, but it lacks: + +- **Queue coalescing** — 15 rapid events become one agent turn with aggregated + context, not 15 separate turns +- **Rite gates** — cron always launches; vigils gate on an optional check, saving + LLM cost on false alarms +- **Procession chaining** — one vigil fires, another fires next, without a cron + scheduler mediating +- **Per-vigil reap cadence** — each vigil reaps independently; cron forces one + global schedule +- **Backpressure** — bounded ring-buffer queues; cron has no queue semantics +- **In-process state** — vigils share dirge's session, permission model, and + plugin system; cron processes are isolated + +Vigils are a first-class runtime primitive, not a workaround. + +## Why vigil instead of a Janet plugin alone? + +A Janet plugin can poll and call `(vigil/emit ...)`, but the plugin doesn't +provide: + +- The reaper: coalescing, rite gates, per-vigil cadence +- The queue: bounded, backpressured, per-vigil +- Template substitution: `{rite_output}`, `{files}`, `{job}`, custom fields +- Procession chaining +- `/vigil status`, `/vigil pause`, `/vigil resume` + +The plugin is the event *producer*. The vigil-keeper is the event *consumer*. +Together they form a complete monitoring and remediation loop. The plugin decides +when to emit; the vigil-keeper handles everything downstream. + +## Why vigil instead of MCP for monitoring? + +MCP is a synchronous request-response protocol. The agent calls an MCP tool +during a turn to get information. It doesn't: + +- Schedule periodic checks +- Push events to a sleeping agent +- Coalesce multiple events +- Gate on pre-checks before spending tokens +- Chain agent turns + +Vigils and MCP are complementary. An MCP tool might be what the agent *uses* +during a vigil observance turn to query a database, check a dashboard, or update +a ticket. The vigil handles *when and whether* the turn fires; MCP handles *what +capabilities* are available during it. diff --git a/src/cli.rs b/src/cli.rs index 1eb0d886..8a9d7933 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -234,6 +234,31 @@ pub struct Cli { )] pub loop_run: Option, + #[cfg(feature = "loop")] + #[arg( + long = "loop-oneshot", + help = "Run exactly one iteration then stop (shorthand for --loop-max 1)" + )] + pub loop_oneshot: bool, + + #[cfg(feature = "loop")] + #[arg( + long = "loop-persist", + help = "Persist session to disk after each iteration for cumulative context" + )] + pub loop_persist: bool, + + #[cfg(feature = "vigil")] + #[arg( + long = "vigil", + help = "Run in vigil heartbeat/wakeup mode (requires vigils in config)" + )] + pub vigil_mode: bool, + + #[cfg(feature = "vigil")] + #[arg(long = "vigil-config", help = "Path to a vigil JSON config file")] + pub vigil_config: Option, + #[arg( long = "auto-confirm", value_enum, @@ -287,6 +312,12 @@ pub enum Command { #[arg(long = "sandbox")] sandbox: Option, }, + /// Manage vigils — list, add, remove, pause, resume, restart. + #[cfg(feature = "vigil")] + Vigil { + #[command(subcommand)] + action: VigilAction, + }, } #[derive(clap::Subcommand, Debug)] @@ -325,6 +356,42 @@ pub enum SandboxAction { }, } +/// Vigil management subcommands. +#[cfg(feature = "vigil")] +#[derive(clap::Subcommand, Debug)] +pub enum VigilAction { + /// List all configured vigils and their status. + List, + /// Add a new vigil trigger. + Add { + /// Vigil name. + name: String, + /// Trigger type: toll, watcher, or harbinger. + #[arg(value_enum)] + trigger: VigilAddTrigger, + /// Additional trigger args as key=value pairs. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Remove a vigil by name. + Remove { name: String }, + /// Pause a running vigil. + Pause { name: String }, + /// Resume a paused vigil. + Resume { name: String }, + /// Restart a vigil (stop and re-create its trigger). + Rest { name: String }, +} + +/// Trigger type for `dirge vigil add`. +#[cfg(feature = "vigil")] +#[derive(clap::ValueEnum, Debug, Clone)] +pub enum VigilAddTrigger { + Toll, + Watcher, + Harbinger, +} + /// Where the resolved provider name came from. Kept separate from the /// resolution so the precedence is unit-testable without touching the /// environment or credential stores, and so each source can log diff --git a/src/config/mod.rs b/src/config/mod.rs index a7f56f78..55caf8ff 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use serde::Deserialize; +#[cfg(feature = "vigil")] +use serde::Serialize; use crate::session::storage; @@ -1203,6 +1205,92 @@ pub struct Config { /// future expansion but are not honored today. #[cfg(feature = "acp")] pub acp_servers: Option>, + + /// Vigil definitions loaded from `config.toml` under `[vigils.]`. + /// Only consulted when `--vigil` is active. + #[cfg(feature = "vigil")] + #[serde(default)] + pub vigils: Option>, +} + +/// A single vigil definition from config. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[serde(default)] +pub struct VigilEntry { + pub name: String, + pub trigger: VigilTrigger, + #[serde(default = "default_reap_interval")] + pub reap_interval_secs: u64, + #[serde(default)] + pub prompt: String, + /// Optional Janet script for per-observance procession. + #[serde(default)] + pub procession: Option, + #[serde(default)] + pub rite: Option, +} + +#[cfg(feature = "vigil")] +fn default_reap_interval() -> u64 { + 30 +} + +/// What triggers a vigil to fire. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum VigilTrigger { + /// Timer-based: fires every N seconds. + Toll { interval_secs: u64 }, + /// Filesystem watcher: fires on changes under `path`. + Watcher { path: String }, + /// Network socket: external process sends events to a TCP port. + Harbinger { + address: String, + #[serde(default)] + protocol: String, + /// `template` or `commands` — see `SocketMode`. + #[serde(default)] + socket_mode: SocketMode, + #[serde(default)] + commands: HashMap, + }, +} + +#[cfg(feature = "vigil")] +impl Default for VigilTrigger { + fn default() -> Self { + VigilTrigger::Toll { interval_secs: 30 } + } +} + +/// Harbinger socket mode. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum SocketMode { + #[default] + Template, + Commands, +} + +/// A pre-registered command for `commands` socket mode. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct VigilCommand { + pub tool: String, + #[serde(default)] + pub args: serde_json::Map, +} + +/// Optional gate condition checked before an observance runs. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct VigilRite { + pub cmd: Option, + #[serde(default)] + pub git_dirty: bool, } impl Config { diff --git a/src/extras/dirge_paths.rs b/src/extras/dirge_paths.rs index 2f632047..92d3126a 100644 --- a/src/extras/dirge_paths.rs +++ b/src/extras/dirge_paths.rs @@ -126,6 +126,13 @@ impl ProjectPaths { self.dirge_dir().join("skills") } + /// `.dirge/vigils/` — vigil definition files (one JSON file per vigil). + #[cfg(feature = "vigil")] + #[allow(dead_code)] + pub fn vigils_dir(&self) -> PathBuf { + self.dirge_dir().join("vigils") + } + /// `.dirge/sessions/` — SQLite session database and transcripts. pub fn sessions_dir(&self) -> PathBuf { self.dirge_dir().join("sessions") diff --git a/src/extras/mod.rs b/src/extras/mod.rs index 4b94d0f8..99e5b662 100644 --- a/src/extras/mod.rs +++ b/src/extras/mod.rs @@ -40,3 +40,7 @@ pub mod session_search; pub mod skill_db; pub mod skills; pub mod spec_db; +pub mod vigil_db; + +#[cfg(feature = "vigil")] +pub mod vigil; diff --git a/src/extras/vigil/dispatch.rs b/src/extras/vigil/dispatch.rs new file mode 100644 index 00000000..c916737c --- /dev/null +++ b/src/extras/vigil/dispatch.rs @@ -0,0 +1,263 @@ +//! Dispatch logic for vigil observances. +//! +//! In `commands` socket mode, the caller provides a command name; this module +//! looks it up in the pre-registered command map and substitutes `{arg_name}` +//! placeholders from the socket payload. +#![allow(dead_code)] + +use std::collections::HashMap; + +use crate::config::VigilCommand; + +use super::types::CoalescedBatch; + +/// Substitute vigil context into a prompt template. +/// +/// Supported variables: +/// - `{name}` — vigil name +/// - `{files}` — comma-separated changed file paths +/// - `{events}` — comma-separated event kinds +/// - `{event_count}` — number of events in this reap window +/// - `{timestamp}` — ISO 8601 reap time +/// - `{rite_output}` — stdout+stderr from rite command +/// - `{rite_exit_code}` — exit code from rite command +/// - `{harbinger_data}` — raw socket payload (first connection in window) +/// - Any `{key}` matching a string field in the merged event context objects +pub fn build_prompt(template: &str, batch: &CoalescedBatch) -> String { + let mut result = template.to_string(); + + let files = batch.files.join(", "); + let event_types: Vec<&str> = batch + .events + .iter() + .filter_map(|e| e.get("kind").and_then(|v| v.as_str())) + .collect(); + let events = event_types.join(", "); + let timestamp = batch.timestamp.to_rfc3339(); + let rite_output = batch.rite_output.as_deref().unwrap_or(""); + let rite_exit_code = batch + .rite_exit_code + .map_or(String::new(), |c| c.to_string()); + let harbinger_data = batch.harbinger_data.as_deref().unwrap_or(""); + + result = result.replace("{name}", &batch.vigil_name); + result = result.replace("{files}", &files); + result = result.replace("{events}", &events); + result = result.replace("{event_count}", &batch.event_count.to_string()); + result = result.replace("{timestamp}", ×tamp); + result = result.replace("{rite_output}", rite_output); + result = result.replace("{rite_exit_code}", &rite_exit_code); + result = result.replace("{harbinger_data}", harbinger_data); + + // Substitute any remaining {key} placeholders from merged event context + for event in batch.events.iter().rev() { + if let serde_json::Value::Object(map) = event { + for (key, val) in map { + if key == "kind" || key == "harbinger_data" || key == "files" { + continue; + } + let val_str = match val { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + result = result.replace(&format!("{{{}}}", key), &val_str); + } + } + } + + result +} + +/// Dispatch a named command from the pre-registered map. +/// Substitutes `{arg_name}` string templates in argument values from the payload. +pub fn dispatch_commands( + commands: &HashMap, + command_name: &str, + payload: &serde_json::Value, +) -> Result<(String, serde_json::Map), String> { + let cmd = commands + .get(command_name) + .ok_or_else(|| format!("unknown command: {command_name}"))?; + + let mut resolved_args = serde_json::Map::new(); + for (key, val) in &cmd.args { + let resolved = resolve_templates(val, payload); + resolved_args.insert(key.clone(), resolved); + } + + Ok((cmd.tool.clone(), resolved_args)) +} + +fn resolve_templates(value: &serde_json::Value, payload: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::String(s) => { + let resolved = substitute_placeholders(s, payload); + serde_json::Value::String(resolved) + } + serde_json::Value::Object(map) => { + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + new_map.insert(k.clone(), resolve_templates(v, payload)); + } + serde_json::Value::Object(new_map) + } + _ => value.clone(), + } +} + +fn substitute_placeholders(template: &str, payload: &serde_json::Value) -> String { + let mut result = template.to_string(); + if let serde_json::Value::Object(map) = payload { + let args = map.get("args"); + let source = args.unwrap_or(payload); + + if let serde_json::Value::Object(source_map) = source { + // Find patterns like {arg_name} and substitute from source_map + let mut start = 0; + while let Some(brace_start) = result[start..].find('{') { + let abs_start = start + brace_start; + if let Some(brace_end) = result[abs_start..].find('}') { + let abs_end = abs_start + brace_end; + let key = &result[abs_start + 1..abs_end]; + if let Some(val) = source_map.get(key) { + let replacement = match val { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + result.replace_range(abs_start..=abs_end, &replacement); + start = abs_start + replacement.len(); + } else { + start = abs_end + 1; + } + } else { + break; + } + } + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extras::vigil::types::TriggerKind; + use serde_json::json; + + fn make_commands() -> HashMap { + let mut map = HashMap::new(); + map.insert( + "build".to_string(), + VigilCommand { + tool: "bash".to_string(), + args: { + let mut args = serde_json::Map::new(); + args.insert( + "command".to_string(), + serde_json::Value::String("cargo build {release_flag}".to_string()), + ); + args + }, + }, + ); + map + } + + #[test] + fn test_dispatch_known_command() { + let commands = make_commands(); + let payload = json!({"command": "build", "args": {"release_flag": "--release"}}); + let result = dispatch_commands(&commands, "build", &payload).unwrap(); + assert_eq!(result.0, "bash"); + assert_eq!( + result.1.get("command").unwrap().as_str().unwrap(), + "cargo build --release" + ); + } + + #[test] + fn test_dispatch_unknown_command() { + let commands = make_commands(); + let payload = json!({"command": "delete_everything"}); + assert!(dispatch_commands(&commands, "delete_everything", &payload).is_err()); + } + + #[test] + fn test_substitute_missing_key_leaves_placeholder() { + let commands = make_commands(); + let payload = json!({"command": "build", "args": {}}); + let result = dispatch_commands(&commands, "build", &payload).unwrap(); + assert_eq!( + result.1.get("command").unwrap().as_str().unwrap(), + "cargo build {release_flag}" + ); + } + + #[test] + fn test_build_prompt_substitutes_variables() { + let batch = CoalescedBatch { + vigil_name: "test-vigil".to_string(), + files: vec!["src/main.rs".to_string()], + events: vec![json!({"kind": "toll"})], + event_count: 3, + timestamp: chrono::DateTime::parse_from_rfc3339("2026-01-15T12:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + trigger: TriggerKind::Toll, + rite_output: Some("rite output".to_string()), + rite_exit_code: Some(0), + harbinger_data: None, + }; + let template = "[{name}] {event_count} events on {files} ({events}) - {timestamp}"; + let result = build_prompt(template, &batch); + assert_eq!( + result, + "[test-vigil] 3 events on src/main.rs (toll) - 2026-01-15T12:00:00+00:00" + ); + } + + #[test] + fn test_build_prompt_empty_template_returns_empty() { + let batch = CoalescedBatch { + vigil_name: "v".to_string(), + files: vec![], + events: vec![], + event_count: 0, + timestamp: chrono::Utc::now(), + trigger: TriggerKind::Toll, + rite_output: None, + rite_exit_code: None, + harbinger_data: None, + }; + let template = ""; + let result = build_prompt(template, &batch); + assert_eq!(result, ""); + } + + #[test] + fn test_build_prompt_substitutes_event_context_fields() { + let batch = CoalescedBatch { + vigil_name: "jenkins-remediate".to_string(), + trigger: TriggerKind::Toll, + files: vec![], + events: vec![json!({ + "kind": "toll", + "job": "my-pipeline", + "build_number": "42", + "url": "http://jenkins:8080/job/my-pipeline/42", + "status": "FAILURE" + })], + event_count: 1, + timestamp: chrono::Utc::now(), + rite_output: None, + rite_exit_code: None, + harbinger_data: None, + }; + let template = "Job: {job}\nBuild: #{build_number}\nURL: {url}\nStatus: {status}"; + let result = build_prompt(template, &batch); + assert_eq!( + result, + "Job: my-pipeline\nBuild: #42\nURL: http://jenkins:8080/job/my-pipeline/42\nStatus: FAILURE" + ); + } +} diff --git a/src/extras/vigil/harbinger.rs b/src/extras/vigil/harbinger.rs new file mode 100644 index 00000000..2c308cb5 --- /dev/null +++ b/src/extras/vigil/harbinger.rs @@ -0,0 +1,149 @@ +//! Harbinger trigger — listens on a TCP or Unix socket for external wake-up +//! signals. Each accepted connection is read (with a 5s timeout), parsed as JSON, +//! and pushed as an event into the vigil's channel. +//! +//! Security: only binds to loopback (127.0.0.1) for TCP; `commands` mode requires +//! a non-empty command map. +#![allow(dead_code)] + +use std::collections::HashMap; +use std::net::{Ipv4Addr, SocketAddrV4}; + +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::mpsc; +use tracing::{error, warn}; + +use crate::config::VigilCommand; + +use super::dispatch::dispatch_commands; +use super::types::{HookDispatchRequest, TriggerKind, VigilEvent}; + +/// Spawn a harbinger trigger listening on `port` (TCP, loopback-only). +/// `commands_map` must be non-empty when `socket_mode` is Commands. +/// Dispatches `on-vigil-event` hook before pushing each accepted connection. +pub fn spawn_harbinger( + vigil_name: String, + port: u16, + commands_map: HashMap, + tx: mpsc::Sender, + hook_tx: mpsc::Sender, +) -> Result, String> { + let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port); + let listener = std::net::TcpListener::bind(addr) + .map_err(|e| format!("bind {addr} for {vigil_name}: {e}"))?; + listener + .set_nonblocking(true) + .map_err(|e| format!("set nonblocking for {vigil_name}: {e}"))?; + let listener = TcpListener::from_std(listener) + .map_err(|e| format!("convert listener for {vigil_name}: {e}"))?; + + let has_commands = !commands_map.is_empty(); + + Ok(tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((stream, peer)) => { + let vigil = vigil_name.clone(); + let cmds = commands_map.clone(); + let tx = tx.clone(); + let hook_tx = hook_tx.clone(); + tokio::spawn(async move { + match tokio::time::timeout( + std::time::Duration::from_secs(5), + handle_connection(stream, &vigil, &cmds, &tx, &hook_tx, has_commands), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(e)) => { + error!(%vigil, %peer, "harbinger connection error: {e}"); + } + Err(_) => { + warn!(%vigil, %peer, "harbinger connection timed out"); + } + } + }); + } + Err(e) => { + error!(%vigil_name, "accept error: {e}"); + break; + } + } + } + })) +} + +async fn handle_connection( + stream: tokio::net::TcpStream, + vigil_name: &str, + commands_map: &HashMap, + tx: &mpsc::Sender, + hook_tx: &mpsc::Sender, + has_commands: bool, +) -> Result<(), String> { + let peer = stream.peer_addr().map_err(|e| format!("peer addr: {e}"))?; + let reader = BufReader::new(stream); + let mut lines = reader.lines(); + + let line = lines + .next_line() + .await + .map_err(|e| format!("read from {peer}: {e}"))? + .unwrap_or_default(); + + let payload: serde_json::Value = + serde_json::from_str(&line).map_err(|e| format!("parse json from {peer}: {e}"))?; + + // In `commands` mode, validate and resolve the command. + let mut context = payload.clone(); + if has_commands { + if let Some(command_name) = payload.get("command").and_then(|v| v.as_str()) { + let (tool, args) = dispatch_commands(commands_map, command_name, &payload) + .map_err(|e| format!("dispatch command '{command_name}': {e}"))?; + // Enrich context with resolved tool dispatch. + if let serde_json::Value::Object(ref mut map) = context { + map.insert( + "_resolved_tool".to_string(), + serde_json::Value::String(tool), + ); + map.insert( + "_resolved_args".to_string(), + serde_json::Value::Object(args), + ); + } + } else { + return Err("commands mode requires 'command' field in payload".to_string()); + } + } + + // Store raw payload for {harbinger_data} template substitution + if let serde_json::Value::Object(ref mut map) = context { + map.insert( + "harbinger_data".to_string(), + serde_json::Value::String(line.clone()), + ); + } + + let event = VigilEvent { + vigil_name: vigil_name.to_string(), + trigger: TriggerKind::Harbinger, + context, + timestamp: chrono::Utc::now(), + }; + + let hook_ctx = format!("@{{:vigil \"{}\" :trigger :harbinger}}", vigil_name); + let _ = hook_tx.try_send(HookDispatchRequest { + hook_name: "on-vigil-event".into(), + context: hook_ctx, + }); + + if let Err(mpsc::error::TrySendError::Full(_)) = tx.try_send(event) { + warn!( + vigil = %vigil_name, + "harbinger queue full, dropping event" + ); + } + + Ok(()) +} diff --git a/src/extras/vigil/mod.rs b/src/extras/vigil/mod.rs new file mode 100644 index 00000000..cbfd4138 --- /dev/null +++ b/src/extras/vigil/mod.rs @@ -0,0 +1,293 @@ +//! Vigil heartbeat/wakeup runtime. +//! +//! Public API: +//! - `VigilKeeper::from_entries()` — build keeper from config entries. +//! - `VigilKeeper::run()` — start the reaper and all triggers, return when shutdown. +//! +//! Internal modules: +//! - `types` — VigilEvent, VigilInstance, VigilCtl +//! - `rite` — gate check evaluation +//! - `dispatch` — commands-mode template substitution +//! - `toll` — timer trigger +//! - `watcher` — filesystem trigger +//! - `harbinger` — socket trigger +//! - `reaper` — event drain + coalesce + observance dispatch + +pub mod dispatch; +pub mod harbinger; +pub mod reaper; +pub mod rite; +pub mod toll; +pub mod types; +pub mod watcher; + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use tokio::sync::mpsc; +use tracing::{info, warn}; + +use crate::config::VigilEntry; + +use self::reaper::Observance; +use self::types::{ + HookDispatchRequest, TriggerKind, VigilCtl, VigilEvent, VigilInstance, VigilReapInput, +}; + +/// Simple runtime state for vigil mode — exposed to the UI loop so it knows +/// whether to sleep between observances and carries pending observance data +/// so the post-turn handler can dispatch on-vigil-observance with :response. +pub struct VigilState { + pub active: bool, + /// If set, the current agent turn is a vigil observance. The post-turn + /// handler reads this to dispatch `on-vigil-observance` with the agent's + /// response text. Cleared after dispatch. + pub pending_observance: Option, +} + +/// Metadata for a vigil observance that will fire after the agent turn. +#[derive(Debug, Clone)] +pub struct PendingObservance { + pub vigil_name: String, + pub event_count: usize, + #[allow(dead_code)] + pub running: std::sync::Arc, +} + +/// The vigil-keeper — owns all active vigils, starts triggers, runs the reaper. +pub struct VigilKeeper { + pub vigils: Vec, + #[allow(dead_code)] + pub ctl_tx: Option>, + pub observance_rx: Option>, + /// Untyped wake channel — fires on every observance so the select! loop + /// (which can't cfg-gate arms) can wake and drain the typed receiver. + pub wake_rx: Option>, + /// Hook dispatch channel — trigger producers and reaper send hook requests; + /// the UI loop drains them. + pub hook_rx: Option>, + /// Janet plugin event sender — installed into the plugin bridge at startup. + /// Plugins call `(vigil/emit name data)` and the keeper routes events to + /// the correct vigil's event queue. + #[allow(dead_code)] + pub vigil_plugin_tx: Option>, +} + +impl VigilKeeper { + /// Build a vigil-keeper from config entries. Creates per-vigil channels + /// and spawns trigger tasks. + pub fn from_entries( + entries: Vec, + paused_names: std::collections::HashSet, + ) -> Result { + let (ctl_tx, ctl_rx) = mpsc::channel::(32); + let (obs_tx, obs_rx) = mpsc::channel::(64); + let (wake_tx, wake_rx) = mpsc::unbounded_channel::<()>(); + let (hook_tx, hook_rx) = mpsc::channel::(64); + + let mut vigils = Vec::new(); + let mut reap_inputs: Vec = Vec::new(); + + for entry in entries { + let (tx, rx) = types::make_vigil_channel(256); + let running = Arc::new(AtomicBool::new(false)); + + let name = entry.name.clone(); + let interval = entry.reap_interval_secs; + let prompt = entry.prompt.clone(); + let procession = entry.procession.clone(); + + let trigger_kind = match &entry.trigger { + crate::config::VigilTrigger::Toll { .. } => TriggerKind::Toll, + crate::config::VigilTrigger::Watcher { .. } => TriggerKind::Watcher, + crate::config::VigilTrigger::Harbinger { .. } => TriggerKind::Harbinger, + }; + + // Spawn trigger(s) based on type. + match entry.trigger { + crate::config::VigilTrigger::Toll { interval_secs } => { + toll::spawn_toll(name.clone(), interval_secs, tx.clone(), hook_tx.clone()); + } + crate::config::VigilTrigger::Watcher { path, .. } => { + let watch_path = std::path::PathBuf::from(&path); + watcher::spawn_watcher(name.clone(), watch_path, tx.clone(), hook_tx.clone())?; + } + crate::config::VigilTrigger::Harbinger { + address, + protocol: _, + socket_mode, + commands, + } => { + let port: u16 = address + .strip_prefix("127.0.0.1:") + .or_else(|| address.strip_prefix("localhost:")) + .and_then(|p| p.parse().ok()) + .unwrap_or(0); + if port == 0 { + return Err(format!( + "vigil {name}: invalid harbinger address '{address}'" + )); + } + + let has_commands = matches!(socket_mode, crate::config::SocketMode::Commands); + if has_commands && commands.is_empty() { + return Err(format!( + "vigil {name}: commands mode requires non-empty commands map" + )); + } + + harbinger::spawn_harbinger( + name.clone(), + port, + commands, + tx.clone(), + hook_tx.clone(), + )?; + } + } + + let rite = entry.rite.clone(); + + vigils.push(VigilInstance { + name: name.clone(), + reap_interval_secs: interval, + prompt: prompt.clone(), + procession: procession.clone(), + tx: tx.clone(), + running: running.clone(), + }); + + reap_inputs.push(VigilReapInput { + name: name.clone(), + trigger: trigger_kind, + reap_interval_secs: interval, + rx, + running, + rite, + prompt, + procession, + }); + } + + // Build a map of vigil name → sender for procession chaining. + let senders: std::collections::HashMap> = vigils + .iter() + .map(|v| (v.name.clone(), v.tx.clone())) + .collect(); + + // Clone senders for the Janet plugin bridge router so plugins + // calling (vigil/emit name data) can push events into any vigil's queue. + let router_senders = senders.clone(); + let (vigil_plugin_tx, mut vigil_plugin_rx) = mpsc::channel::(256); + tokio::spawn(async move { + while let Some(msg) = vigil_plugin_rx.recv().await { + match msg.split_once('\t') { + Some((name, payload)) => { + if let Some(sender) = router_senders.get(name) { + let context: serde_json::Value = serde_json::from_str(payload) + .unwrap_or_else(|_| serde_json::json!({"data": payload})); + let event = VigilEvent { + vigil_name: name.to_string(), + trigger: crate::extras::vigil::types::TriggerKind::Toll, + context, + timestamp: chrono::Utc::now(), + }; + if sender.try_send(event).is_err() { + warn!(%name, "vigil plugin event queue full, dropping"); + } + } else { + warn!(%name, "vigil/emit for unknown vigil, dropping event"); + } + } + None => { + warn!("vigil/emit received malformed message, dropping"); + } + } + } + }); + + // Launch the reaper in a background task. + let reaper_wake_tx = wake_tx; + let reaper_hook_tx = hook_tx; + tokio::spawn(async move { + let paused = paused_names; + reaper::run_reaper( + reap_inputs, + obs_tx, + ctl_rx, + Some(reaper_wake_tx), + senders, + reaper_hook_tx, + paused, + ) + .await; + }); + + Ok(Self { + vigils, + ctl_tx: Some(ctl_tx), + observance_rx: Some(obs_rx), + wake_rx: Some(wake_rx), + hook_rx: Some(hook_rx), + vigil_plugin_tx: Some(vigil_plugin_tx), + }) + } + + /// Build a vigil-keeper from config entries + `.dirge/vigils/*.json` files. + /// Filesystem entries are merged by name; config entries win on collision. + pub fn from_config_and_filesystem( + entries: Vec, + paused_names: std::collections::HashSet, + ) -> Result { + let mut merged = entries; + + // Scan .dirge/vigils/*.json for filesystem-defined vigils. + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let vigils_dir = crate::extras::dirge_paths::ProjectPaths::new(&cwd).vigils_dir(); + #[allow(clippy::collapsible_if)] + if vigils_dir.is_dir() { + if let Ok(readdir) = std::fs::read_dir(&vigils_dir) { + for entry in readdir.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + match std::fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(file_entry) => { + // Config wins — only add if not already present. + let name = &file_entry.name; + if !merged.iter().any(|e| e.name == *name) { + let file_path = path.display(); + info!(%name, file = %file_path, "imported vigil from filesystem"); + merged.push(file_entry); + } else { + info!(%name, "vigil from filesystem skipped: config entry wins on name collision"); + } + } + Err(e) => { + let file_path = path.display(); + warn!(file = %file_path, "invalid vigil JSON, skipping: {e}"); + } + }, + Err(e) => { + let file_path = path.display(); + warn!(file = %file_path, "cannot read vigil file, skipping: {e}"); + } + } + } + } + } + + Self::from_entries(merged, paused_names) + } + + /// Signal the reaper to stop. + #[allow(dead_code)] + pub async fn shutdown(&self) { + if let Some(ref tx) = self.ctl_tx { + let _ = tx.send(VigilCtl::Shutdown).await; + } + info!("vigil-keeper shutdown complete"); + } +} diff --git a/src/extras/vigil/reaper.rs b/src/extras/vigil/reaper.rs new file mode 100644 index 00000000..180a3720 --- /dev/null +++ b/src/extras/vigil/reaper.rs @@ -0,0 +1,337 @@ +//! Reaper — drains per-vigil event channels on configurable cadences. +//! Uses `FuturesUnordered` so each vigil reaps independently; one vigil's +//! slow observance doesn't delay another's reap. +#![allow(dead_code)] + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +use futures::StreamExt; +use futures::stream::FuturesUnordered; +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; + +use super::dispatch::build_prompt; +use super::rite::evaluate_rite; +use super::types::{ + CoalescedBatch, RiteResult, TriggerKind, VigilEvent, VigilReapInput, VigilStatusInfo, +}; + +/// Context passed to the agent executor for an observance. +#[derive(Debug, Clone)] +pub struct Observance { + pub vigil_name: String, + pub prompt: String, + pub context: serde_json::Value, + pub event_count: usize, + pub running: std::sync::Arc, +} + +/// Run the reaper loop. Drains events from all active vigils, coalesces them +/// per reap window, runs rite gates, and produces `Observance`s. +/// Observances are sent to `observance_tx` for the vigil-keeper to dispatch. +pub async fn run_reaper( + vigils: Vec, + observance_tx: mpsc::Sender, + mut ctl_rx: mpsc::Receiver, + wake_tx: Option>, + senders: HashMap>, + hook_tx: mpsc::Sender, + initial_paused: std::collections::HashSet, +) { + let mut reap_tasks: FuturesUnordered)>> = + FuturesUnordered::new(); + + // Lookup maps for metadata accessed in the reap-results arm. + let running: HashMap> = vigils + .iter() + .map(|v| (v.name.clone(), v.running.clone())) + .collect(); + let prompt_map: HashMap = vigils + .iter() + .map(|v| (v.name.clone(), v.prompt.clone())) + .collect(); + let rite_map: HashMap> = vigils + .iter() + .map(|v| (v.name.clone(), v.rite.clone())) + .collect(); + let procession_map: HashMap> = vigils + .iter() + .map(|v| (v.name.clone(), v.procession.clone())) + .collect(); + // Infer trigger kind from the vigil config. + let trigger_map: HashMap = + vigils.iter().map(|v| (v.name.clone(), v.trigger)).collect(); + + let mut paused: std::collections::HashSet = initial_paused; + + let reap_interval_map: HashMap = vigils + .iter() + .map(|v| (v.name.clone(), v.reap_interval_secs)) + .collect(); + + for mut input in vigils { + let name = input.name.clone(); + let interval = input.reap_interval_secs; + reap_tasks.push(tokio::spawn(async move { + reap_interval(name, interval, &mut input.rx).await + })); + } + + // Per-vigil reap statistics, updated each reap window and exposed via StatusReq. + type ReapStats = HashMap)>; + let reap_stats: Arc> = Arc::new(Mutex::new(HashMap::new())); + + loop { + tokio::select! { + Some(ctl) = ctl_rx.recv() => { + match ctl { + super::types::VigilCtl::Shutdown => { + info!("reaper shutting down"); + break; + } + super::types::VigilCtl::Pause { name } => { + debug!(%name, "reaper pausing vigil"); + paused.insert(name); + } + super::types::VigilCtl::PauseAll => { + debug!("reaper pausing all vigils"); + for name in running.keys() { + paused.insert(name.clone()); + } + } + super::types::VigilCtl::Resume { name } => { + debug!(%name, "reaper resuming vigil"); + paused.remove(&name); + } + super::types::VigilCtl::ResumeAll => { + debug!("reaper resuming all vigils"); + paused.clear(); + } + super::types::VigilCtl::StatusReq { respond_to } => { + let mut statuses = Vec::new(); + let stats = reap_stats.lock().unwrap(); + for (name, run_flag) in &running { + let trigger = trigger_map.get(name).copied().unwrap_or(TriggerKind::Toll); + let interval = reap_interval_map.get(name).copied().unwrap_or(0); + let (count, ts) = stats.get(name).copied().unwrap_or((0, chrono::Utc::now())); + statuses.push(VigilStatusInfo { + name: name.clone(), + trigger, + reap_interval_secs: interval, + running: run_flag.load(std::sync::atomic::Ordering::Relaxed), + paused: paused.contains(name), + last_event_count: count, + last_event_at: Some(ts.to_rfc3339()), + }); + } + let _ = respond_to.send(statuses); + } + } + } + Some(result) = reap_tasks.next() => { + match result { + Ok((vigil_name, events)) => { + if events.is_empty() { + continue; + } + + // Track event count and timestamp for the panel indicator. + { + let mut stats = reap_stats.lock().unwrap(); + stats.insert(vigil_name.clone(), (events.len(), chrono::Utc::now())); + } + + if paused.contains(&vigil_name) { + warn!(%vigil_name, "skipping reap — vigil paused"); + continue; + } + + // Check if an observance is already running for this vigil. + let run_flag = running.get(&vigil_name).cloned(); + if let Some(ref flag) = run_flag + && flag.load(Ordering::SeqCst) + { + warn!(%vigil_name, "skipping reap — observance in flight"); + continue; + } + + let trigger = trigger_map + .get(&vigil_name) + .copied() + .unwrap_or(TriggerKind::Toll); + + // Dispatch on-vigil-reap hook pre-rite. + let reap_ctx = format!( + "@{{:vigil \"{}\" :event_count {} :trigger :{}}}", + vigil_name, + events.len(), + trigger.as_str() + ); + let _ = hook_tx.try_send( + super::types::HookDispatchRequest { + hook_name: "on-vigil-reap".into(), + context: reap_ctx, + }, + ); + + // Rite gate check — skip observance if the rite fails. + let (rite_output, rite_exit_code) = + if let Some(Some(rite)) = rite_map.get(&vigil_name) { + match evaluate_rite(rite).await { + RiteResult::Pass { output } => (output, None), + RiteResult::Fail { reason } => { + warn!(%vigil_name, %reason, "rite gate failed, skipping observance"); + continue; + } + } + } else { + (None, None) + }; + + let batch = CoalescedBatch::from_events( + vigil_name.clone(), + trigger, + &events, + rite_output, + rite_exit_code, + ); + + let prompt_template = prompt_map + .get(&vigil_name) + .map(|s| s.as_str()) + .unwrap_or(""); + let prompt = if prompt_template.is_empty() { + String::new() + } else { + build_prompt(prompt_template, &batch) + }; + + // Skip if the prompt still has unresolved {placeholders} + // — happens when the batch has only toll ticks and the + // template expects plugin-emitted context (job, etc.). + // Use a regex to match only template-variable patterns like + // {job} or {build_number}, not JSON object braces from + // substituted {harbinger_data} values. + if !prompt.is_empty() { + static RE: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + regex::Regex::new(r"\{[a-zA-Z_][a-zA-Z0-9_]*\}").unwrap() + }); + if RE.is_match(&prompt) { + warn!(%vigil_name, "skipping observance — prompt has unresolved placeholders"); + continue; + } + } + + let context = coalesce_events(&events); + + // Mark in-flight so overlapping reaps for this vigil are skipped. + if let Some(ref flag) = run_flag { + flag.store(true, Ordering::SeqCst); + } + + let running_flag = run_flag.unwrap_or_else(|| { + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) + }); + + let observance = Observance { + vigil_name: vigil_name.clone(), + prompt, + context, + event_count: batch.event_count, + running: running_flag, + }; + + if observance_tx.try_send(observance).is_err() { + warn!(%vigil_name, "observance queue full, dropping"); + } else if let Some(ref wt) = wake_tx { + let _ = wt.send(()); + } + + // Procession: inject event into next vigil's queue. + if let Some(Some(next_name)) = procession_map.get(&vigil_name) { + if let Some(next_tx) = senders.get(next_name) { + let chain_event = VigilEvent { + vigil_name: next_name.clone(), + trigger: TriggerKind::Toll, + context: serde_json::json!({ + "procession_from": vigil_name, + "event_count": batch.event_count, + }), + timestamp: chrono::Utc::now(), + }; + if next_tx.try_send(chain_event).is_err() { + warn!(%next_name, from=%vigil_name, + "procession queue full for next vigil"); + } else { + debug!(%next_name, from=%vigil_name, + "procession: injected event into next vigil"); + } + } else { + warn!(%next_name, from=%vigil_name, + "procession target not found among active vigils"); + } + } + } + Err(e) => { + warn!("reap task panicked: {e}"); + } + } + } + } + } +} + +async fn reap_interval( + name: String, + interval_secs: u64, + rx: &mut mpsc::Receiver, +) -> (String, Vec) { + let mut events: Vec = Vec::new(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(interval_secs); + + loop { + match tokio::time::timeout_at(deadline, rx.recv()).await { + Ok(Some(event)) => { + events.push(event); + // Drain any additional events without blocking. + while let Ok(event) = rx.try_recv() { + events.push(event); + } + } + Ok(None) => break, // Channel closed. + Err(_) => break, // Timeout — reap window elapsed. + } + } + + (name, events) +} + +fn coalesce_events(events: &[VigilEvent]) -> serde_json::Value { + if events.len() == 1 { + return events[0].context.clone(); + } + + let mut files: Vec = Vec::new(); + let mut payloads: Vec = Vec::new(); + + for event in events { + if let Some(fs) = event.context.get("files").and_then(|v| v.as_array()) { + for f in fs { + if let Some(s) = f.as_str() { + files.push(s.to_string()); + } + } + } + payloads.push(event.context.clone()); + } + + serde_json::json!({ + "events": payloads, + "files": files, + "event_count": events.len(), + }) +} diff --git a/src/extras/vigil/rite.rs b/src/extras/vigil/rite.rs new file mode 100644 index 00000000..a410bf91 --- /dev/null +++ b/src/extras/vigil/rite.rs @@ -0,0 +1,113 @@ +//! Rite gate checks — optional conditions that must pass before an observance runs. +#![allow(dead_code)] + +use crate::config::VigilRite; + +use super::types::RiteResult; + +/// Evaluate a rite gate. If all conditions pass, returns `Pass`. +/// Currently supports: +/// - `cmd`: runs a shell command; 0 exit = pass. +/// - `git_dirty`: fails if the git working tree is dirty. +pub async fn evaluate_rite(rite: &VigilRite) -> RiteResult { + if let Some(ref cmd) = rite.cmd + && !cmd.is_empty() + { + match tokio::process::Command::new("sh") + .arg("-c") + .arg(cmd) + .output() + .await + { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + let trimmed = stdout.trim().to_string(); + if !trimmed.is_empty() { + return RiteResult::Pass { + output: Some(trimmed), + }; + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + return RiteResult::Fail { + reason: format!( + "rite cmd '{cmd}' exited {}: {}", + output.status, + stderr.trim() + ), + }; + } + Err(e) => { + return RiteResult::Fail { + reason: format!("rite cmd '{cmd}' failed: {e}"), + }; + } + } + } + + if rite.git_dirty { + match check_git_dirty().await { + Ok(true) => { + return RiteResult::Fail { + reason: "git working tree is dirty".to_string(), + }; + } + Ok(false) => {} + Err(e) => { + return RiteResult::Fail { reason: e }; + } + } + } + + RiteResult::Pass { output: None } +} + +async fn check_git_dirty() -> Result { + let output = tokio::process::Command::new("git") + .args(["status", "--porcelain"]) + .output() + .await + .map_err(|e| format!("git status failed: {e}"))?; + Ok(!output.stdout.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_empty_rite_passes() { + let rite = VigilRite::default(); + let result = evaluate_rite(&rite).await; + assert!(matches!(result, RiteResult::Pass { .. })); + } + + #[tokio::test] + async fn test_rite_cmd_success_passes() { + let rite = VigilRite { + cmd: Some("true".to_string()), + ..Default::default() + }; + let result = evaluate_rite(&rite).await; + assert!( + matches!(result, RiteResult::Pass { .. }), + "expected Pass but got {:?}", + result + ); + } + + #[tokio::test] + async fn test_rite_cmd_failure_fails() { + let rite = VigilRite { + cmd: Some("false".to_string()), + ..Default::default() + }; + let result = evaluate_rite(&rite).await; + assert!( + matches!(result, RiteResult::Fail { .. }), + "expected Fail but got {:?}", + result + ); + } +} diff --git a/src/extras/vigil/toll.rs b/src/extras/vigil/toll.rs new file mode 100644 index 00000000..f6d2bedc --- /dev/null +++ b/src/extras/vigil/toll.rs @@ -0,0 +1,64 @@ +//! Toll trigger — fires on a fixed timer interval. +#![allow(dead_code)] + +use std::collections::VecDeque; +use tokio::sync::mpsc; +use tracing::warn; + +use super::types::{HookDispatchRequest, TriggerKind, VigilEvent}; + +/// Number of events to buffer locally before dropping the oldest. +const LOCAL_RING_SIZE: usize = 256; + +/// Spawn a toll (timer) trigger. Pushes a `VigilEvent` into the channel at +/// every `interval_secs` boundary. Ring-buffer backpressure: when the channel +/// is full, the oldest event in the local buffer is dropped and retried. +/// Dispatches `on-vigil-event` hook before pushing each event. +pub fn spawn_toll( + vigil_name: String, + interval_secs: u64, + tx: mpsc::Sender, + hook_tx: mpsc::Sender, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); + let mut pending: VecDeque = VecDeque::with_capacity(LOCAL_RING_SIZE); + // Skip the immediate first tick — first fire after interval_secs. + interval.tick().await; + loop { + interval.tick().await; + let event = VigilEvent { + vigil_name: vigil_name.clone(), + trigger: TriggerKind::Toll, + context: serde_json::json!({"kind": "toll", "interval_secs": interval_secs}), + timestamp: chrono::Utc::now(), + }; + let hook_ctx = format!( + "@{{:vigil \"{}\" :trigger :toll :interval_secs {}}}", + vigil_name, interval_secs + ); + let _ = hook_tx.try_send(HookDispatchRequest { + hook_name: "on-vigil-event".into(), + context: hook_ctx, + }); + // Flush pending events before pushing the new one. + while let Some(ev) = pending.pop_front() { + if tx.try_send(ev.clone()).is_err() { + pending.push_front(ev); + break; + } + } + // Push new event; pop oldest if ring is full. + if pending.len() >= LOCAL_RING_SIZE { + let _ = pending.pop_front(); + warn!( + vigil = %vigil_name, + "toll local ring full, dropping oldest event" + ); + } + if tx.try_send(event.clone()).is_err() { + pending.push_back(event); + } + } + }) +} diff --git a/src/extras/vigil/types.rs b/src/extras/vigil/types.rs new file mode 100644 index 00000000..a83a9624 --- /dev/null +++ b/src/extras/vigil/types.rs @@ -0,0 +1,208 @@ +//! Core types for the vigil heartbeat/wakeup runtime. +#![allow(dead_code)] + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::mpsc; + +use crate::config::{VigilCommand, VigilRite}; + +/// An event pushed into a vigil's queue by a trigger (toll, watcher, harbinger). +#[derive(Debug, Clone)] +pub struct VigilEvent { + pub vigil_name: String, + pub trigger: TriggerKind, + /// Trigger-specific context data (file paths, socket payload, etc.). + pub context: serde_json::Value, + pub timestamp: chrono::DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TriggerKind { + Toll, + Watcher, + Harbinger, +} + +impl TriggerKind { + pub fn as_str(&self) -> &'static str { + match self { + TriggerKind::Toll => "toll", + TriggerKind::Watcher => "watcher", + TriggerKind::Harbinger => "harbinger", + } + } +} + +/// Per-vigil channel pair. `tx` (sender) is `Clone` and shared with triggers. +/// `rx` (receiver) is consumed by the reaper for that vigil. +pub fn make_vigil_channel(bound: usize) -> (mpsc::Sender, mpsc::Receiver) { + mpsc::channel(bound) +} + +/// Runtime state for one active vigil. `tx` is shared with triggers; +/// `rx` is taken by the reaper at startup. +pub struct VigilInstance { + pub name: String, + pub reap_interval_secs: u64, + pub prompt: String, + pub procession: Option, + pub tx: mpsc::Sender, + pub running: Arc, +} + +/// Bundle passed to the reaper: the input channel, rite config, prompt +/// template, and the "observance in flight" flag. +pub struct VigilReapInput { + pub name: String, + pub trigger: TriggerKind, + pub reap_interval_secs: u64, + pub rx: mpsc::Receiver, + pub running: Arc, + pub rite: Option, + pub prompt: String, + pub procession: Option, +} + +/// Snapshot of a single vigil's runtime state, returned by StatusReq queries. +#[derive(Debug, Clone)] +pub struct VigilStatusInfo { + pub name: String, + pub trigger: TriggerKind, + pub reap_interval_secs: u64, + pub running: bool, + pub paused: bool, + /// Number of events collected in the most recent reap window. + pub last_event_count: usize, + /// ISO 8601 timestamp of the most recent event reap. + pub last_event_at: Option, +} + +/// Request to dispatch a plugin hook from a background task (trigger producers +/// or reaper). The UI loop drains the hook channel and dispatches via PluginManager. +#[derive(Debug, Clone)] +pub struct HookDispatchRequest { + pub hook_name: String, + pub context: String, +} + +/// Control messages for the vigil-keeper / reaper. +#[derive(Debug)] +pub enum VigilCtl { + Shutdown, + Pause { + name: String, + }, + PauseAll, + Resume { + name: String, + }, + ResumeAll, + /// Query: respond with a snapshot of all vigil states. + StatusReq { + respond_to: tokio::sync::oneshot::Sender>, + }, +} + +/// Result of a rite gate check. +#[derive(Debug)] +pub enum RiteResult { + Pass { output: Option }, + Fail { reason: String }, +} + +/// Runtime representation of a vigil definition (deserialized from config +/// and/or filesystem). +#[derive(Debug, Clone)] +pub struct VigilConfig { + pub name: String, + pub trigger: TriggerKind, + pub reap_interval_secs: u64, + pub rite: Option, + pub prompt: String, + pub procession: Option, +} + +/// Per-trigger payload variant carried in a VigilEvent's context. +#[derive(Debug, Clone)] +pub enum VigilPayload { + Toll { + interval_secs: u64, + }, + Watcher { + file: String, + event: String, + }, + Harbinger { + data: String, + commands: HashMap, + }, +} + +/// Output of coalescing multiple VigilEvents into one batch. +#[derive(Debug, Clone)] +pub struct CoalescedBatch { + pub vigil_name: String, + pub trigger: TriggerKind, + pub files: Vec, + pub events: Vec, + pub event_count: usize, + pub timestamp: chrono::DateTime, + pub harbinger_data: Option, + pub rite_output: Option, + pub rite_exit_code: Option, +} + +impl CoalescedBatch { + pub fn from_events( + vigil_name: String, + trigger: TriggerKind, + events: &[VigilEvent], + rite_output: Option, + rite_exit_code: Option, + ) -> Self { + let mut files: Vec = Vec::new(); + let mut payloads: Vec = Vec::new(); + let mut harbinger_data: Option = None; + + for event in events { + if let Some(fs) = event.context.get("files").and_then(|v| v.as_array()) { + for f in fs { + if let Some(s) = f.as_str() + && !files.contains(&s.to_string()) + { + files.push(s.to_string()); + } + } + } + if harbinger_data.is_none() + && let Some(hd) = event.context.get("harbinger_data").and_then(|v| v.as_str()) + { + harbinger_data = Some(hd.to_string()); + } + payloads.push(event.context.clone()); + } + + Self { + vigil_name, + trigger, + files, + events: payloads, + event_count: events.len(), + timestamp: chrono::Utc::now(), + harbinger_data, + rite_output, + rite_exit_code, + } + } +} + +/// Runtime tracking for vigil mode — held by the keeper and surfaced to the +/// post-turn dispatch so `decide_post_done_action` knows whether a vigil +/// observance just completed. +#[derive(Debug, Clone)] +pub struct VigilRunState { + pub active: bool, + pub current_vigil: Option, + pub ctl_tx: Option>, +} diff --git a/src/extras/vigil/watcher.rs b/src/extras/vigil/watcher.rs new file mode 100644 index 00000000..fd1d1e0c --- /dev/null +++ b/src/extras/vigil/watcher.rs @@ -0,0 +1,140 @@ +//! Watcher trigger — fires on filesystem change events via the `notify` crate. +//! Debounces rapid-fire events at 500ms. +#![allow(dead_code)] + +use notify::{Event, EventKind, RecursiveMode, Watcher}; +use std::collections::VecDeque; +use std::path::PathBuf; +use tokio::sync::mpsc; +use tracing::warn; + +use super::types::{HookDispatchRequest, TriggerKind, VigilEvent}; + +/// Spawn a watcher trigger. Pushes coalesced events into the channel. +/// Ring-buffer backpressure via a local `VecDeque`: oldest event is dropped +/// when the ring is full, and pending events are flushed before new ones. +/// Dispatches `on-vigil-event` hook before pushing each batch. +pub fn spawn_watcher( + vigil_name: String, + path: PathBuf, + tx: mpsc::Sender, + hook_tx: mpsc::Sender, +) -> Result, String> { + let (event_tx, mut event_rx) = mpsc::channel::>(64); + + let mut watcher = notify::recommended_watcher(move |res: Result| { + if let Ok(event) = res { + let kind = event.kind; + if matches!( + kind, + EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) + ) { + let paths: Vec = event.paths; + let _ = event_tx.try_send(paths); + } + } + }) + .map_err(|e| format!("create watcher for {vigil_name}: {e}"))?; + + let watch_path = path.clone(); + watcher + .watch(&watch_path, RecursiveMode::Recursive) + .map_err(|e| format!("watch {:?} for {vigil_name}: {e}", watch_path))?; + + Ok(tokio::spawn(async move { + // Hold `_watcher` alive until this task ends. + let _watcher = watcher; + + let mut event_paths: Vec = Vec::new(); + let debounce = std::time::Duration::from_millis(500); + const LOCAL_RING_SIZE: usize = 256; + let mut pending: VecDeque = VecDeque::with_capacity(LOCAL_RING_SIZE); + + loop { + match tokio::time::timeout(debounce, event_rx.recv()).await { + Ok(Some(paths)) => { + event_paths.extend(paths); + // Drain any additional events that arrived during the debounce window. + while let Ok(paths) = event_rx.try_recv() { + event_paths.extend(paths); + } + + let event_count = event_paths.len(); + let event = VigilEvent { + vigil_name: vigil_name.clone(), + trigger: TriggerKind::Watcher, + context: serde_json::json!({ + "files": std::mem::take(&mut event_paths), + "event_count": event_count, + }), + timestamp: chrono::Utc::now(), + }; + let hook_ctx = format!( + "@{{:vigil \"{}\" :trigger :watcher :event_count {}}}", + vigil_name, event_count + ); + let _ = hook_tx.try_send(HookDispatchRequest { + hook_name: "on-vigil-event".into(), + context: hook_ctx, + }); + // Flush pending events before pushing the new batch. + while let Some(ev) = pending.pop_front() { + if tx.try_send(ev.clone()).is_err() { + pending.push_front(ev); + break; + } + } + if pending.len() >= LOCAL_RING_SIZE { + let _ = pending.pop_front(); + warn!( + vigil = %vigil_name, + "watcher local ring full, dropping oldest event" + ); + } + if tx.try_send(event.clone()).is_err() { + pending.push_back(event); + } + } + Ok(None) => break, // Channel closed. + Err(_) => { + // Timeout — no events in the debounce window, flush if any. + if !event_paths.is_empty() { + let event = VigilEvent { + vigil_name: vigil_name.clone(), + trigger: TriggerKind::Watcher, + context: serde_json::json!({ + "files": std::mem::take(&mut event_paths), + }), + timestamp: chrono::Utc::now(), + }; + let hook_ctx = format!( + "@{{:vigil \"{}\" :trigger :watcher :flush true}}", + vigil_name + ); + let _ = hook_tx.try_send(HookDispatchRequest { + hook_name: "on-vigil-event".into(), + context: hook_ctx, + }); + // Flush pending before timeout-flush event. + while let Some(ev) = pending.pop_front() { + if tx.try_send(ev.clone()).is_err() { + pending.push_front(ev); + break; + } + } + if pending.len() >= LOCAL_RING_SIZE { + let _ = pending.pop_front(); + warn!( + vigil = %vigil_name, + "watcher local ring full (flush), dropping oldest event" + ); + } + if tx.try_send(event.clone()).is_err() { + pending.push_back(event); + } + } + } + } + } + })) +} diff --git a/src/extras/vigil_db.rs b/src/extras/vigil_db.rs new file mode 100644 index 00000000..59165996 --- /dev/null +++ b/src/extras/vigil_db.rs @@ -0,0 +1,184 @@ +//! SQLite store for vigil heartbeat/wakeup configurations. +//! +//! Vigil entries live in the per-project session DB (`.dirge/sessions/state.db`). +//! The store owns its schema via idempotent `CREATE TABLE IF NOT EXISTS` on open. +#![allow(dead_code)] + +use std::path::Path; +use std::sync::Mutex; + +use rusqlite::{Connection, OpenFlags, OptionalExtension, params}; + +/// Lifecycle states for a vigil. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VigilStatus { + Active, + Paused, + Resting, +} + +impl VigilStatus { + pub fn as_str(&self) -> &'static str { + match self { + VigilStatus::Active => "active", + VigilStatus::Paused => "paused", + VigilStatus::Resting => "resting", + } + } +} + +/// A stored vigil row. +pub struct VigilRow { + pub name: String, + pub payload_json: String, + pub status: VigilStatus, + pub created_at: String, + pub updated_at: String, +} + +/// SQLite-backed vigil store. +pub struct VigilStore { + conn: Mutex, +} + +impl VigilStore { + pub fn open(paths: &super::dirge_paths::ProjectPaths) -> Result { + Self::open_at(&paths.session_db_path()) + } + + pub fn open_at(path: &Path) -> Result { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, + ) + .map_err(|e| format!("open vigil db at {}: {e}", path.display()))?; + let _ = conn.busy_timeout(std::time::Duration::from_secs(5)); + let _ = conn.pragma_update(None, "journal_mode", "WAL"); + let store = Self { + conn: Mutex::new(conn), + }; + store.ensure_schema()?; + Ok(store) + } + + fn ensure_schema(&self) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS vigils ( + name TEXT PRIMARY KEY NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_vigils_status ON vigils(status);", + ) + .map_err(|e| format!("create vigils table: {e}")) + } + + pub fn upsert(&self, name: &str, payload_json: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO vigils (name, payload_json, status, updated_at) + VALUES (?1, ?2, 'active', datetime('now')) + ON CONFLICT(name) DO UPDATE SET + payload_json = excluded.payload_json, + status = 'active', + updated_at = datetime('now')", + params![name, payload_json], + ) + .map_err(|e| format!("upsert vigil {name}: {e}"))?; + Ok(()) + } + + pub fn set_status(&self, name: &str, status: VigilStatus) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + let affected = conn + .execute( + "UPDATE vigils SET status = ?1, updated_at = datetime('now') WHERE name = ?2", + params![status.as_str(), name], + ) + .map_err(|e| format!("set status for vigil {name}: {e}"))?; + if affected == 0 { + return Err(format!("vigil {name} not found")); + } + Ok(()) + } + + pub fn remove(&self, name: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + let affected = conn + .execute("DELETE FROM vigils WHERE name = ?1", params![name]) + .map_err(|e| format!("remove vigil {name}: {e}"))?; + if affected == 0 { + return Err(format!("vigil {name} not found")); + } + Ok(()) + } + + pub fn get(&self, name: &str) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT name, payload_json, status, created_at, updated_at + FROM vigils WHERE name = ?1", + ) + .map_err(|e| format!("prepare get vigil {name}: {e}"))?; + let row = stmt + .query_row(params![name], |row| { + Ok(VigilRow { + name: row.get(0)?, + payload_json: row.get(1)?, + status: { + let s: String = row.get(2)?; + match s.as_str() { + "active" => VigilStatus::Active, + "paused" => VigilStatus::Paused, + "resting" => VigilStatus::Resting, + _ => VigilStatus::Active, + } + }, + created_at: row.get(3)?, + updated_at: row.get(4)?, + }) + }) + .optional() + .map_err(|e| format!("get vigil {name}: {e}"))?; + Ok(row) + } + + pub fn list_non_resting(&self) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT name, payload_json, status, created_at, updated_at + FROM vigils WHERE status != 'resting' ORDER BY name", + ) + .map_err(|e| format!("prepare list_non_resting: {e}"))?; + let rows = stmt + .query_map([], |row| { + Ok(VigilRow { + name: row.get(0)?, + payload_json: row.get(1)?, + status: { + let s: String = row.get(2)?; + match s.as_str() { + "active" => VigilStatus::Active, + "paused" => VigilStatus::Paused, + "resting" => VigilStatus::Resting, + _ => VigilStatus::Active, + } + }, + created_at: row.get(3)?, + updated_at: row.get(4)?, + }) + }) + .map_err(|e| format!("list_non_resting: {e}"))? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) + } +} diff --git a/src/main.rs b/src/main.rs index 18d5de89..6eeb444e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -537,6 +537,8 @@ async fn main() -> anyhow::Result<()> { cli::Command::Sandbox { .. } => {} #[cfg(feature = "mcp-server")] cli::Command::Mcp { .. } => {} + #[cfg(feature = "vigil")] + cli::Command::Vigil { .. } => {} } } @@ -669,12 +671,13 @@ async fn main() -> anyhow::Result<()> { let image_safe = image_ref.replace(['/', ':'], "_"); let base_dir = cache_dir.join(&image_safe).join("base"); if base_dir.exists() { + let daemon_path = base_dir.join("usr/sbin/dropbear"); let sshd_path = base_dir.join("usr/sbin/sshd"); - if sshd_path.exists() { + if daemon_path.exists() || sshd_path.exists() { println!(" Image already cached at {}", base_dir.display()); } else { println!( - " Cached rootfs is stale (missing sshd) — removing and re-preparing..." + " Cached rootfs is stale (missing daemon) — removing and re-preparing..." ); std::fs::remove_dir_all(&base_dir)?; rootfs::prepare(&image_ref, &cache_dir).await?; @@ -685,12 +688,13 @@ async fn main() -> anyhow::Result<()> { println!(" Done. Cached at {}", base_dir.display()); } - // Validate the prepared rootfs has sshd. + // Validate the prepared rootfs has a daemon binary. + let daemon_path = base_dir.join("usr/sbin/dropbear"); let sshd_path = base_dir.join("usr/sbin/sshd"); - if !sshd_path.exists() { + if !daemon_path.exists() && !sshd_path.exists() { anyhow::bail!( - "rootfs at {} is missing /usr/sbin/sshd after preparation — \ - the image may not have openssh-server installed", + "rootfs at {} is missing /usr/sbin/dropbear or /usr/sbin/sshd \ + after preparation — the image may not have a supported SSH daemon", base_dir.display() ); } @@ -704,6 +708,11 @@ async fn main() -> anyhow::Result<()> { cli::Command::Mcp { model, sandbox } => { return extras::mcp_server::serve(&cli, &cfg, model.clone(), sandbox.clone()).await; } + #[cfg(feature = "vigil")] + cli::Command::Vigil { action } => { + handle_vigil_command(action).await?; + return Ok(()); + } } } @@ -1658,10 +1667,16 @@ async fn main() -> anyhow::Result<()> { .unwrap_or_else(|| PathBuf::from("LOOP_PLAN.md")); let _use_existing = loop_mod::plan::handle_startup(&plan_file)?; + let effective_loop_max = if cli.loop_oneshot { + Some(1) + } else { + cli.loop_max + }; + let mut loop_state = loop_mod::LoopState::new( loop_prompt, plan_file, - cli.loop_max, + effective_loop_max, cli.loop_run.clone(), ); let session_id = Uuid::new_v4().to_string(); @@ -1927,6 +1942,115 @@ async fn main() -> anyhow::Result<()> { #[cfg(not(feature = "mcp"))] let mcp_wake_rx: Option> = None; + // Vigil: start the vigil-keeper and wire its wake channel. + // The keeper owns the background reaper and trigger tasks; + // we hold onto the keeper (so it stays alive) but take out the + // wake + observance receivers to hand to `run_interactive`. + #[cfg(feature = "vigil")] + let (mut _vigil_keeper, vigil_wake_rx, vigil_observance_rx, vigil_ctl_tx, vigil_hook_rx) = { + if !cli.vigil_mode { + (None, None, None, None, None) + } else { + // Merge config vigils with --vigil-config file entries (if any). + let mut entries = if let Some(cfg_entries) = cfg.vigils.as_ref() { + cfg_entries.clone() + } else { + vec![] + }; + if let Some(ref config_path) = cli.vigil_config { + if config_path.exists() { + match std::fs::read_to_string(config_path) { + Ok(json_str) => { + match serde_json::from_str::>( + &json_str, + ) { + Ok(file_entries) => { + for fe in file_entries { + if !entries.iter().any(|e| e.name == fe.name) { + entries.push(fe); + } + } + } + Err(e) => { + eprintln!( + "warning: --vigil-config file has invalid JSON: {e}" + ); + } + } + } + Err(e) => { + eprintln!("warning: cannot read --vigil-config file: {e}"); + } + } + } else { + eprintln!( + "warning: --vigil-config file not found: {}", + config_path.display() + ); + } + } + + let paused_names = { + let paths = crate::extras::dirge_paths::ProjectPaths::new( + &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ); + let db_path = paths.session_db_path(); + if db_path.exists() { + match crate::extras::vigil_db::VigilStore::open_at(&db_path) { + Ok(store) => store + .list_non_resting() + .unwrap_or_default() + .into_iter() + .filter(|r| { + matches!(r.status, crate::extras::vigil_db::VigilStatus::Paused) + }) + .map(|r| r.name) + .collect::>(), + Err(_) => std::collections::HashSet::new(), + } + } else { + std::collections::HashSet::new() + } + }; + match crate::extras::vigil::VigilKeeper::from_config_and_filesystem( + entries.clone(), + paused_names, + ) { + Ok(mut keeper) => { + if keeper.vigils.is_empty() { + eprintln!("warning: --vigil set but no vigils configured"); + (None, None, None, None, None) + } else { + let n = keeper.vigils.len(); + #[cfg(feature = "plugin")] + { + if let Some(ref vig_tx) = keeper.vigil_plugin_tx { + crate::plugin::worker::vigil_bridge::install_vigil_tx( + vig_tx.clone(), + ); + } + let names: Vec = + keeper.vigils.iter().map(|v| v.name.clone()).collect(); + crate::plugin::worker::vigil_bridge::install_vigil_names(names); + } + eprintln!("info: vigil-keeper started with {n} vigil(s)"); + let wake = keeper.wake_rx.take(); + let obs = keeper.observance_rx.take(); + let ctl = keeper.ctl_tx.clone(); + let hook_rx = keeper.hook_rx.take(); + (Some(keeper), wake, obs, ctl, hook_rx) + } + } + Err(e) => { + eprintln!("warning: vigil-keeper failed to start: {e}"); + (None, None, None, None, None) + } + } + } + }; + // vigil_wake_rx and vigil_observance_rx are only passed to run_interactive + // when #[cfg(feature = "vigil")] — no fallback let needed. + ui::run_interactive( client, agent, @@ -1958,6 +2082,14 @@ async fn main() -> anyhow::Result<()> { dialog_rx, subagent_chat_rx, sysload, + #[cfg(feature = "vigil")] + vigil_wake_rx, + #[cfg(feature = "vigil")] + vigil_observance_rx, + #[cfg(feature = "vigil")] + vigil_ctl_tx, + #[cfg(feature = "vigil")] + vigil_hook_rx, ) .await?; @@ -2115,6 +2247,20 @@ async fn run_headless_loop( eprintln!("[loop] warning: failed to save transcript: {}", e); } + if cli.loop_persist { + use crate::session::{MessageRole, Session, storage}; + let mut session = storage::load_session(session_id).unwrap_or_else(|_| { + let mut s = Session::new("loop", "unknown", 200_000); + s.id = compact_str::CompactString::new(session_id); + s + }); + session.add_message(MessageRole::User, &iteration_prompt); + session.add_message(MessageRole::Assistant, &response); + if let Err(e) = storage::save_session(&mut session) { + eprintln!("[loop] warning: failed to persist session: {}", e); + } + } + // `prepare-next-run` hooks fired inside `run_print` may have // set a `next_model` slot on the PluginManager. Drain it // BEFORE eprintln'ing "iteration complete" so the swap log @@ -2337,6 +2483,168 @@ mod resolve_mode_tests { } } +/// Handle `dirge vigil add/list/remove/pause/resume/rest` subcommands. +#[cfg(feature = "vigil")] +async fn handle_vigil_command(action: &crate::cli::VigilAction) -> anyhow::Result<()> { + use crate::extras::dirge_paths::ProjectPaths; + use crate::extras::vigil_db::{VigilStatus, VigilStore}; + + let paths = ProjectPaths::new( + &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ); + + match action { + crate::cli::VigilAction::List => { + let vigils = config::load().vigils.unwrap_or_default(); + println!("Vigils (from config):"); + for v in &vigils { + let trigger = match &v.trigger { + crate::config::VigilTrigger::Toll { interval_secs } => { + format!("toll every {interval_secs}s") + } + crate::config::VigilTrigger::Watcher { path } => { + format!("watcher on {path}") + } + crate::config::VigilTrigger::Harbinger { + address, protocol, .. + } => { + let p = if protocol.is_empty() { + "tcp" + } else { + protocol.as_str() + }; + format!("harbinger {p}://{address}") + } + }; + let prompt = if v.prompt.is_empty() { + "(default)".to_string() + } else { + v.prompt.clone() + }; + println!( + " {} - {trigger} - reap every {}s - prompt: {prompt}", + v.name, v.reap_interval_secs + ); + } + if vigils.is_empty() { + println!(" (none)"); + } + } + crate::cli::VigilAction::Add { + name, + trigger, + args, + } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + let entry = build_vigil_entry(name, trigger, args)?; + let json = serde_json::to_string(&entry)?; + store + .upsert(&entry.name, &json) + .map_err(|e| anyhow::anyhow!("{e}"))?; + println!( + "Added vigil '{}'. Run `dirge --vigil` to start the keeper.", + entry.name + ); + } + crate::cli::VigilAction::Remove { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + match store.remove(name) { + Ok(()) => println!("Removed vigil '{name}'."), + Err(e) => eprintln!("{e}"), + } + } + crate::cli::VigilAction::Pause { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + match store.set_status(name, VigilStatus::Paused) { + Ok(()) => println!("Paused vigil '{name}'."), + Err(e) => eprintln!("{e}"), + } + } + crate::cli::VigilAction::Resume { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + match store.set_status(name, VigilStatus::Active) { + Ok(()) => println!("Resumed vigil '{name}'."), + Err(e) => eprintln!("{e}"), + } + } + crate::cli::VigilAction::Rest { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + match store.set_status(name, VigilStatus::Active) { + Ok(()) => println!("Restarted vigil '{name}'."), + Err(e) => eprintln!("{e}"), + } + } + } + Ok(()) +} + +/// Build a VigilEntry from CLI `vigil add` args. +#[cfg(feature = "vigil")] +fn build_vigil_entry( + name: &str, + trigger: &crate::cli::VigilAddTrigger, + args: &[String], +) -> anyhow::Result { + use crate::config::{VigilEntry, VigilRite, VigilTrigger}; + + let parsed: std::collections::HashMap = args + .iter() + .filter_map(|a| a.split_once('=')) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let trigger = match trigger { + crate::cli::VigilAddTrigger::Toll => { + let secs = parsed + .get("interval_secs") + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + VigilTrigger::Toll { + interval_secs: secs, + } + } + crate::cli::VigilAddTrigger::Watcher => { + let path = parsed + .get("path") + .cloned() + .unwrap_or_else(|| ".".to_string()); + VigilTrigger::Watcher { path } + } + crate::cli::VigilAddTrigger::Harbinger => { + let address = parsed + .get("address") + .cloned() + .unwrap_or_else(|| "127.0.0.1:9000".to_string()); + let protocol = parsed.get("protocol").cloned().unwrap_or_default(); + VigilTrigger::Harbinger { + address, + protocol, + socket_mode: crate::config::SocketMode::Commands, + commands: std::collections::HashMap::new(), + } + } + }; + + let reap_interval_secs = parsed + .get("reap_interval_secs") + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + + let prompt = parsed.get("prompt").cloned().unwrap_or_default(); + + Ok(VigilEntry { + name: name.to_string(), + trigger, + reap_interval_secs, + prompt, + procession: None, + rite: Some(VigilRite { + cmd: None, + git_dirty: false, + }), + }) +} + /// dirge-o2bw — a present-but-unparseable `permission` config block /// must surface as an error, NOT silently fall back to defaults. /// `RuleConfig`/`PermissionConfig` carry `#[serde(deny_unknown_fields)]`, diff --git a/src/plugin/loader.rs b/src/plugin/loader.rs index e6f6a43f..0db2eaef 100644 --- a/src/plugin/loader.rs +++ b/src/plugin/loader.rs @@ -37,6 +37,10 @@ pub const HOOK_NAMES: &[&str] = &[ // ctx :messages (JSON); may call harness/set-compact-summary to // supply a summary instead of the LLM summarizer. "on-compact", + // --- vigil hooks (dirge-vigil) --- + "on-vigil-event", + "on-vigil-reap", + "on-vigil-observance", ]; /// Filter an input candidate list to only paths that exist as diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 96013a43..aa1750bc 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -131,16 +131,23 @@ pub enum PostDoneAction { LoopIter, LoopStop, Idle, + #[cfg(feature = "vigil")] + VigilSleep, } pub fn decide_post_done_action( followup: Option, loop_active: bool, loop_should_stop: bool, + #[cfg(feature = "vigil")] vigil_active: bool, ) -> PostDoneAction { if let Some(text) = followup { return PostDoneAction::Followup(text); } + #[cfg(feature = "vigil")] + if vigil_active { + return PostDoneAction::VigilSleep; + } if !loop_active { return PostDoneAction::Idle; } diff --git a/src/plugin/mod_tests.rs b/src/plugin/mod_tests.rs index 5b1d04c9..28505260 100644 --- a/src/plugin/mod_tests.rs +++ b/src/plugin/mod_tests.rs @@ -135,29 +135,75 @@ fn test_post_done_action() { // Plugin followup must take precedence over the loop iteration // so we never silently drop a queued prompt. let followup = Some("retry".to_string()); + #[cfg(feature = "vigil")] + let vigil_off = false; assert_eq!( - decide_post_done_action(followup.clone(), true, false), + decide_post_done_action( + followup.clone(), + true, + false, + #[cfg(feature = "vigil")] + vigil_off + ), PostDoneAction::Followup("retry".into()) ); assert_eq!( - decide_post_done_action(followup.clone(), false, false), + decide_post_done_action( + followup.clone(), + false, + false, + #[cfg(feature = "vigil")] + vigil_off + ), PostDoneAction::Followup("retry".into()) ); // Loop iteration only when no followup. assert_eq!( - decide_post_done_action(None, true, false), + decide_post_done_action( + None, + true, + false, + #[cfg(feature = "vigil")] + vigil_off + ), PostDoneAction::LoopIter ); // Loop stop only when no followup and should_stop. assert_eq!( - decide_post_done_action(None, true, true), + decide_post_done_action( + None, + true, + true, + #[cfg(feature = "vigil")] + vigil_off + ), PostDoneAction::LoopStop ); // Idle: nothing to do. assert_eq!( - decide_post_done_action(None, false, false), + decide_post_done_action( + None, + false, + false, + #[cfg(feature = "vigil")] + vigil_off + ), PostDoneAction::Idle ); + + #[cfg(feature = "vigil")] + { + // VigilSleep: vigil active outranks loop. + assert_eq!( + decide_post_done_action(None, true, false, true), + PostDoneAction::VigilSleep + ); + // Followup still beats vigil. + assert_eq!( + decide_post_done_action(followup.clone(), false, false, true), + PostDoneAction::Followup("retry".into()) + ); + } } #[test] diff --git a/src/plugin/worker.rs b/src/plugin/worker.rs index fcd39992..a91db76e 100644 --- a/src/plugin/worker.rs +++ b/src/plugin/worker.rs @@ -867,6 +867,73 @@ const HARNESS_LSP_INIT: &str = r#" (defn harness/lsp-diagnostics [file] (harness/lsp "diagnostics" file)) "#; +/// Vigil Janet prelude — exposes vigil/emit, vigil/list, vigil/set-state, +/// vigil/get, vigil/live? for plugins running inside a vigil-keeper. +#[cfg(all(feature = "plugin", feature = "vigil"))] +const HARNESS_VIGIL_INIT: &str = r#" +(defn vigil/live? + "True when the vigil bridge is active. False on builds without the + vigil feature, and also when vigil is not running — so a true result + guarantees that vigil/emit will actually reach the keeper." + [] + (if-let [entry (get (curenv) 'harness/__vigil-live)] + (truthy? ((entry :value))) + false)) + +(defn- json-encode + "Serialize a Janet value to JSON. Handles strings, numbers, booleans, + nil, indexed arrays, and dictionaries (tables/structs)." + [x] + (cond + (string? x) (string "\"" x "\"") + (number? x) (string x) + (= x true) "true" + (= x false) "false" + (nil? x) "null" + (dictionary? x) + (string "{" + (string/join + (map (fn [[k v]] + (string "\"" k "\":" (json-encode v))) + (pairs x)) + ",") + "}") + (indexed? x) + (string "[" + (string/join (map json-encode x) ",") + "]") + (string x))) + +(defn vigil/emit + "Push an event into the vigil-keeper. `event-name` is a string key; + `data` is a dict or JSON string with event context." + [event-name &opt data] + (when (and (vigil/live?) (string? event-name)) + (let [payload (if data + (if (string? data) data (json-encode data)) + "") + msg (string event-name "\t" payload)] + (harness/__vigil-emit msg)))) + +(defn vigil/list + "Return an array of all active vigil names." + [] + (when (vigil/live?) + (harness/__vigil-list))) + +(defn vigil/set-state + "Set a state key for a named vigil. (vigil/set-state name key value)" + [name key value] + (when (and (vigil/live?) (string? name) (string? key)) + (harness/__vigil-set-state name key (string value)))) + +(defn vigil/get + "Get the state table for a named vigil. (vigil/get name)" + [name] + (when (and (vigil/live?) (string? name)) + (harness/__vigil-get name))) +"#; + /// dirge-l6bf: neuter the Janet escape hatches that can terminate or /// destabilize the HOST process. Every hook / command / tool handler is /// already run inside a Janet `(try ...)` (see `mod.rs`), so an ordinary @@ -1390,6 +1457,30 @@ fn worker_loop( env.add_c_fn(CFunOptions::new(c"__lsp", janet_lsp_cfn).namespace(c"harness")); env.add_c_fn(CFunOptions::new(c"__lsp-live", janet_lsp_live_cfn).namespace(c"harness")); } + // Vigil bridge: expose vigil/live? and vigil/emit C functions + // so Janet plugins can interact with the vigil-keeper at runtime. + #[cfg(all(feature = "plugin", feature = "vigil"))] + { + env.add_c_fn( + CFunOptions::new(c"__vigil-live", vigil_bridge::vigil_live_cfn) + .namespace(c"harness"), + ); + env.add_c_fn( + CFunOptions::new(c"__vigil-emit", vigil_bridge::vigil_emit_cfn) + .namespace(c"harness"), + ); + env.add_c_fn( + CFunOptions::new(c"__vigil-list", vigil_bridge::vigil_list_cfn) + .namespace(c"harness"), + ); + env.add_c_fn( + CFunOptions::new(c"__vigil-get", vigil_bridge::vigil_get_cfn).namespace(c"harness"), + ); + env.add_c_fn( + CFunOptions::new(c"__vigil-set-state", vigil_bridge::vigil_set_state_cfn) + .namespace(c"harness"), + ); + } // Computer-use exec: forwards actions to the sandbox drainer. // The C function reads SANDBOX_EXEC_TX; if the channel wasn't // installed (e.g. --sandbox off), it returns nil gracefully. @@ -1420,6 +1511,15 @@ fn worker_loop( let _ = init_tx.send(Err(format!("harness lsp init failed: {e}"))); return; } + // Vigil Janet prelude — defines (vigil/emit), (vigil/list), + // (vigil/set-state), (vigil/get), (vigil/live?). + #[cfg(all(feature = "plugin", feature = "vigil"))] + { + if let Err(e) = client.run(HARNESS_VIGIL_INIT) { + let _ = init_tx.send(Err(format!("vigil init failed: {e}"))); + return; + } + } // dirge-l6bf: disable host-terminating Janet functions. MUST run after // the harness preludes and before any plugin loads, so plugin code // compiles against the neutered bindings. @@ -2399,6 +2499,224 @@ unsafe fn get_dict_int_array(v: janetrs::lowlevel::Janet, key: &str) -> Option>> = + const { std::cell::RefCell::new(None) }; + + /// Active vigil names, populated at keeper startup via install_vigil_names. + static VIGIL_NAMES: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; + + /// Per-vigil state map (name → JSON value). Janet code can + /// read/write this via vigil/get and vigil/set-state. + static VIGIL_STATE: std::cell::RefCell> = + std::cell::RefCell::new(HashMap::new()); + } + + /// Install the vigil bridge sender. Called from the tokio runtime + /// after the vigil-keeper is created. Panics if called twice. + pub fn install_vigil_tx(tx: tokio::sync::mpsc::Sender) { + VIGIL_TX.with(|cell| { + let mut borrowed = cell.borrow_mut(); + assert!(borrowed.is_none(), "vigil bridge already installed"); + *borrowed = Some(tx); + }); + } + + /// Install vigil names into the bridge. Called at keeper startup + /// after install_vigil_tx. + pub fn install_vigil_names(names: Vec) { + VIGIL_NAMES.with(|cell| { + *cell.borrow_mut() = names; + }); + } + + /// Return a Janet boolean — true if the vigil bridge has been installed. + pub unsafe extern "C-unwind" fn vigil_live_cfn( + _argc: i32, + _argv: *mut janetrs::lowlevel::Janet, + ) -> janetrs::lowlevel::Janet { + use janetrs::lowlevel::*; + let live = VIGIL_TX.with(|cell| cell.borrow().is_some()); + unsafe { janet_wrap_boolean(if live { 1 } else { 0 }) } + } + + /// Push a vigil event from Janet into the keeper. Takes one + /// string argument (the event payload). + pub unsafe extern "C-unwind" fn vigil_emit_cfn( + argc: i32, + argv: *mut janetrs::lowlevel::Janet, + ) -> janetrs::lowlevel::Janet { + use janetrs::lowlevel::*; + if argc < 1 { + return unsafe { janet_wrap_nil() }; + } + let msg = match unsafe { read_string_arg(argv, 0) } { + Some(s) => s, + None => return unsafe { janet_wrap_nil() }, + }; + VIGIL_TX.with(|cell| { + if let Some(ref tx) = *cell.borrow() { + let _ = tx.try_send(msg); + } + }); + unsafe { janet_wrap_nil() } + } + + /// (vigil/list) — return a Janet array of all active vigil names. + #[allow(clippy::ptr_offset_with_cast)] + pub unsafe extern "C-unwind" fn vigil_list_cfn( + _argc: i32, + _argv: *mut janetrs::lowlevel::Janet, + ) -> janetrs::lowlevel::Janet { + use janetrs::lowlevel::*; + VIGIL_NAMES.with(|cell| { + let names = cell.borrow(); + let tup = unsafe { janet_tuple_begin(names.len() as i32) }; + for (i, name) in names.iter().enumerate() { + unsafe { + let c_str = std::ffi::CString::new(name.as_str()).unwrap(); + let s = janet_wrap_string(janet_cstring(c_str.as_ptr())); + *tup.offset(i as isize) = s; + } + } + unsafe { janet_wrap_tuple(janet_tuple_end(tup)) } + }) + } + + /// (vigil/get name) — return a Janet table of state for the named vigil. + /// Returns nil if the vigil is not found. + pub unsafe extern "C-unwind" fn vigil_get_cfn( + argc: i32, + argv: *mut janetrs::lowlevel::Janet, + ) -> janetrs::lowlevel::Janet { + use janetrs::lowlevel::*; + if argc < 1 { + return unsafe { janet_wrap_nil() }; + } + let name = match unsafe { read_string_arg(argv, 0) } { + Some(s) => s, + None => return unsafe { janet_wrap_nil() }, + }; + VIGIL_STATE.with(|cell| { + let state = cell.borrow(); + if let Some(value) = state.get(&name) { + json_to_janet(value) + } else { + VIGIL_NAMES.with(|nc| { + if nc.borrow().contains(&name) { + // Vigil exists but has no state yet — return empty table. + let tab = unsafe { janet_table(0) }; + unsafe { janet_wrap_table(tab) } + } else { + unsafe { janet_wrap_nil() } + } + }) + } + }) + } + + /// (vigil/set-state name key value) — set a key-value pair on a vigil's + /// state. Returns the vigil name on success, nil on failure. + pub unsafe extern "C-unwind" fn vigil_set_state_cfn( + argc: i32, + argv: *mut janetrs::lowlevel::Janet, + ) -> janetrs::lowlevel::Janet { + use janetrs::lowlevel::*; + if argc < 3 { + return unsafe { janet_wrap_nil() }; + } + let name = match unsafe { read_string_arg(argv, 0) } { + Some(s) => s, + None => return unsafe { janet_wrap_nil() }, + }; + let key = match unsafe { read_string_arg(argv, 1) } { + Some(s) => s, + None => return unsafe { janet_wrap_nil() }, + }; + let value_str = match unsafe { read_string_arg(argv, 2) } { + Some(s) => s, + None => return unsafe { janet_wrap_nil() }, + }; + // Parse as JSON — if the value looks like JSON, use it; otherwise + // treat it as a raw string. + let value: serde_json::Value = + serde_json::from_str(&value_str).unwrap_or(serde_json::Value::String(value_str)); + VIGIL_STATE.with(|cell| { + let mut state = cell.borrow_mut(); + let entry = state + .entry(name.clone()) + .or_insert(serde_json::Value::Object(serde_json::Map::new())); + if let serde_json::Value::Object(map) = entry { + map.insert(key, value); + } + }); + let c_str = std::ffi::CString::new(name.as_str()).unwrap(); + unsafe { janet_wrap_string(janet_cstring(c_str.as_ptr())) } + } + + /// Convert a serde_json::Value to a Janet value. + #[allow(clippy::ptr_offset_with_cast)] + fn json_to_janet(value: &serde_json::Value) -> janetrs::lowlevel::Janet { + use janetrs::lowlevel::*; + match value { + serde_json::Value::Null => unsafe { janet_wrap_nil() }, + serde_json::Value::Bool(b) => unsafe { janet_wrap_boolean(if *b { 1 } else { 0 }) }, + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + unsafe { janet_wrap_number(i as f64) } + } else if let Some(f) = n.as_f64() { + unsafe { janet_wrap_number(f) } + } else { + unsafe { janet_wrap_nil() } + } + } + serde_json::Value::String(s) => { + let c_str = std::ffi::CString::new(s.as_str()).unwrap(); + unsafe { janet_wrap_string(janet_cstring(c_str.as_ptr())) } + } + serde_json::Value::Array(arr) => { + let tup = unsafe { janet_tuple_begin(arr.len() as i32) }; + for (i, v) in arr.iter().enumerate() { + unsafe { + *tup.offset(i as isize) = json_to_janet(v); + } + } + unsafe { janet_wrap_tuple(janet_tuple_end(tup)) } + } + serde_json::Value::Object(map) => { + let tab = unsafe { janet_table(map.len() as i32) }; + for (k, v) in map { + let c_str = std::ffi::CString::new(k.as_str()).unwrap(); + let key = unsafe { janet_wrap_string(janet_cstring(c_str.as_ptr())) }; + let val = json_to_janet(v); + unsafe { janet_table_put(tab, key, val) }; + } + unsafe { janet_wrap_table(tab) } + } + } + } +} + #[cfg(all(test, feature = "plugin"))] mod tests { use super::*; @@ -3037,4 +3355,64 @@ mod tests { assert!(r.contains("Привет"), "lost Cyrillic: {r:?}"); helper.join().unwrap(); } + + /// vigil/emit uses json-encode to serialize event data. Verify + /// the Janet dict → JSON round-trip produces valid JSON that + /// serde_json can parse back into structured fields — the keeper + /// path at src/extras/vigil/mod.rs:171. + #[cfg(feature = "vigil")] + #[test] + fn json_encode_produces_valid_json_for_vigil_emit() { + let (mut worker, _dialog_rx, _lsp_rx) = Worker::try_spawn().unwrap(); + let json_str = worker + .eval( + r#"(json-encode {:job "my-pipeline" + :build_number "42" + :url "http://jenkins:8080/job/my-pipeline/42" + :status "FAILURE"})"#, + ) + .unwrap(); + + let parsed: serde_json::Value = + serde_json::from_str(&json_str).expect("json-encode must produce valid JSON"); + + assert_eq!(parsed["job"], "my-pipeline"); + assert_eq!(parsed["build_number"], "42"); + assert_eq!(parsed["url"], "http://jenkins:8080/job/my-pipeline/42"); + assert_eq!(parsed["status"], "FAILURE"); + } + + /// Regression: verify that the full vigil/emit message format + /// (name\tjson) can be split and parsed by the keeper router. + #[cfg(feature = "vigil")] + #[test] + fn vigil_emit_message_format_is_parseable_by_keeper() { + let (mut worker, _dialog_rx, _lsp_rx) = Worker::try_spawn().unwrap(); + + // Simulate what (vigil/emit "jenkins-remediate" {...}) sends + // through harness/__vigil-emit. We can't call vigil/emit directly + // (vigil/live? is false in tests), so we call json-encode and + // format the message manually. + let payload = worker + .eval( + r#"(json-encode {:job "my-pipeline" + :build_number "42" + :url "http://jenkins:8080/job/my-pipeline/42" + :status "FAILURE"})"#, + ) + .unwrap(); + let msg = format!("jenkins-remediate\t{payload}"); + + // Simulate the keeper router (src/extras/vigil/mod.rs:167-173) + let (name, payload_str) = msg.split_once('\t').expect("tab-separated message"); + assert_eq!(name, "jenkins-remediate"); + + let context: serde_json::Value = + serde_json::from_str(payload_str).expect("payload must be valid JSON"); + + assert_eq!(context["job"], "my-pipeline"); + assert_eq!(context["build_number"], "42"); + assert_eq!(context["url"], "http://jenkins:8080/job/my-pipeline/42"); + assert_eq!(context["status"], "FAILURE"); + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1dcd3d3a..4b5ba911 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -90,6 +90,8 @@ use crate::ui::events::{render_session, sanitize_output}; use crate::ui::input::InputEditor; use crate::ui::keymap::{KeyAction, Keymaps}; use crate::ui::panel_render::{build_left_panel_info, build_panel_data}; +#[cfg(feature = "vigil")] +use crate::ui::renderer::VigilStatusRow; use crate::ui::renderer::{LineEntry, Renderer}; use crate::ui::search_rewind::{ allow_always_downgrade_reason, is_placeholder_pattern, open_rewind_picker, rewind_session, @@ -278,6 +280,16 @@ pub async fn run_interactive( // ui-redesign: shared system-load snapshot. Polled in the // background; read at panel paint time. Cheap clone (Arc bump). sysload: crate::ui::sysload::SharedSysLoad, + #[cfg(feature = "vigil")] mut vigil_wake_rx: Option>, + #[cfg(feature = "vigil")] mut vigil_observance_rx: Option< + tokio::sync::mpsc::Receiver, + >, + #[cfg(feature = "vigil")] vigil_ctl_tx: Option< + tokio::sync::mpsc::Sender, + >, + #[cfg(feature = "vigil")] mut vigil_hook_rx: Option< + tokio::sync::mpsc::Receiver, + >, ) -> anyhow::Result<()> { let _guard = TerminalGuard::new(cfg.keyboard_enhancement.unwrap_or(true))?; @@ -527,6 +539,13 @@ pub async fn run_interactive( #[cfg(feature = "loop")] let mut loop_state: Option = None; + #[cfg(feature = "vigil")] + let mut vigil_state: Option = + Some(crate::extras::vigil::VigilState { + active: true, + pending_observance: None, + }); + // Snapshot plugin-registered shortcuts (P9c). Seeded at UI // startup; refreshed at the top of each event loop iteration // (M2) so a plugin that registers a shortcut from a hook — @@ -1623,6 +1642,36 @@ pub async fn run_interactive( gitstat.snapshot(), )); } + #[cfg(feature = "vigil")] + { + if let Some(ref vigil_ctl) = vigil_ctl_tx { + let (tx, rx) = tokio::sync::oneshot::channel(); + let _ = vigil_ctl + .send(crate::extras::vigil::types::VigilCtl::StatusReq { respond_to: tx }) + .await; + if let Ok(statuses) = rx.await { + let rows: Vec = statuses + .into_iter() + .map(|s| VigilStatusRow { + name: s.name, + trigger: s.trigger.as_str().to_string(), + interval_secs: s.reap_interval_secs, + running: s.running, + paused: s.paused, + last_event_count: s.last_event_count, + last_event_age: s.last_event_at.and_then(|ts| { + chrono::DateTime::parse_from_rfc3339(&ts).ok().map(|dt| { + let elapsed = + chrono::Utc::now().signed_duration_since(dt.to_utc()); + crate::ui::panel_data::format_duration_short(elapsed) + }) + }), + }) + .collect(); + renderer.set_vigil_status(rows); + } + } + } } // H-R1: loop-top PM acquisitions use `try_lock` so a @@ -1757,6 +1806,22 @@ pub async fn run_interactive( // mount-timer select! arm can move it into its async block. let mount_deadline = ui.shell_mount_deadline; + // When vigil is active, slow the idle poll from 20Hz to 1Hz so the + // CPU isn't constantly waking during a quiet observance window. + #[cfg(feature = "vigil")] + let idle_sleep_ms: u64 = if vigil_state.as_ref().is_some_and(|vs| vs.active) { + 1000 + } else { + 50 + }; + #[cfg(not(feature = "vigil"))] + let idle_sleep_ms: u64 = 50; + + // When vigil is compiled out, declare a dummy wake receiver so + // the vigil select! arm is syntactically present but inert. + #[cfg(not(feature = "vigil"))] + let mut vigil_wake_rx: Option> = None; + tokio::select! { // #387: poll arms in order so USER INPUT takes priority — when a // keystroke and an agent event are both ready, the keystroke is @@ -2884,7 +2949,7 @@ pub async fn run_interactive( // /help) have no UserMessage event, so we keep the echo. write_user_lines(&mut renderer, &text)?; renderer.write_line("", Color::White)?; - let result = handle_slash(&expanded, &mut agent, &mut client, &mut renderer, session, cli, cfg, context, &mut ui.show_reasoning, &mut ui.is_running, &mut input, &permission, &ask_tx, &question_tx, &plan_tx, &mut ui.todo_tools_enabled, &bg_store, &sandbox, #[cfg(unix)] &user_tx, #[cfg(feature = "loop")] &mut loop_state, #[cfg(feature = "mcp")] mcp_manager.as_ref(), #[cfg(feature = "semantic")] semantic_manager, #[cfg(feature = "lsp")] lsp_manager.as_ref(), &mut ui.plan_phase).await; + let result = handle_slash(&expanded, &mut agent, &mut client, &mut renderer, session, cli, cfg, context, &mut ui.show_reasoning, &mut ui.is_running, &mut input, &permission, &ask_tx, &question_tx, &plan_tx, &mut ui.todo_tools_enabled, &bg_store, &sandbox, #[cfg(unix)] &user_tx, #[cfg(feature = "loop")] &mut loop_state, #[cfg(feature = "vigil")] &mut vigil_state, #[cfg(feature = "vigil")] &vigil_ctl_tx, #[cfg(feature = "mcp")] mcp_manager.as_ref(), #[cfg(feature = "semantic")] semantic_manager, #[cfg(feature = "lsp")] lsp_manager.as_ref(), &mut ui.plan_phase).await; match result { Ok(SlashOutcome::DeferCompress { instructions }) => { let instructions = instructions.as_deref().and_then(|s| { @@ -3529,6 +3594,10 @@ pub async fn run_interactive( state: &mut loop_state, label: &mut ui.loop_label, }; + #[cfg(feature = "vigil")] + let vigil_bits = run_handlers::done::VigilBits { + state: &mut vigil_state, + }; run_handlers::handle_done( &mut ctx, response, @@ -3549,6 +3618,8 @@ pub async fn run_interactive( &mut ui.done_phase, #[cfg(feature = "loop")] loop_bits, + #[cfg(feature = "vigil")] + vigil_bits, ).await?; } AgentEvent::Usage { @@ -3967,6 +4038,10 @@ pub async fn run_interactive( state: &mut loop_state, label: &mut ui.loop_label, }; + #[cfg(feature = "vigil")] + let vigil_bits = run_handlers::done::VigilBits { + state: &mut vigil_state, + }; run_handlers::done::finish_done( &mut ctx, result.response, @@ -3985,6 +4060,8 @@ pub async fn run_interactive( plugin_manager, #[cfg(feature = "loop")] loop_bits, + #[cfg(feature = "vigil")] + vigil_bits, ) .await?; } @@ -5129,9 +5206,86 @@ pub async fn run_interactive( // active path already re-asserts. _ = tokio::time::sleep(tokio::time::Duration::from_secs(1)), if !ui.is_running => { renderer.reassert_terminal_modes(); + }, + // Vigil wake — triggered by the reaper after a + // successful observance. The vigils may be disabled + // at compile time; the `if vigil_wake_rx.is_some()` + // guard ensures the arm is inert when vigil is off. + _ = async { + match &mut vigil_wake_rx { + Some(rx) => rx.recv().await, + None => std::future::pending::>().await, + } + }, if vigil_wake_rx.is_some() => { + #[cfg(feature = "vigil")] + { + // Drain any pending plugin hook dispatch requests from + // background tasks (trigger producers, reaper). + if let Some(ref mut hook_rx) = vigil_hook_rx { + while let Ok(req) = hook_rx.try_recv() { + #[cfg(feature = "plugin")] + if let Some(pm) = plugin_manager { + let pm = pm.clone(); + let hook = req.hook_name; + let ctx = req.context; + tokio::task::spawn_blocking(move || { + pm.lock_ignore_poison() + .dispatch_tool_hook(&hook, &ctx) + }) + .await + .ok(); + } + } + } + + if let Some(ref mut rx) = vigil_observance_rx + && let Ok(obs) = rx.try_recv() + && !ui.is_running + { + // Store observance metadata so the post-turn + // handler can dispatch on-vigil-observance + // with :response and :exit after the agent turn. + if let Some(ref mut vs) = vigil_state { + vs.pending_observance = Some( + crate::extras::vigil::PendingObservance { + vigil_name: obs.vigil_name.clone(), + event_count: obs.event_count, + running: obs.running.clone(), + }, + ); + } + let prompt = if obs.prompt.is_empty() { + format!("[vigil] {} - {} event(s)", obs.vigil_name, obs.event_count) + } else { + obs.prompt.clone() + }; + ui.last_user_prompt.clone_from(&prompt); + let history = crate::agent::runner::convert_history(session); + session.add_message(MessageRole::User, &prompt); + begin_snapshot_turn(session); + let runner = agent.clone().spawn_runner( + crate::provider::Prompt::text( + crate::agent::tools::background::prepend_pending_notifications( + &prompt, + bg_store.as_ref(), + ), + ), + history, + Some(ui.interjection_queue.clone()), + Some(session.assets_dir()), + ); + runner.install_into( + &mut ui.agent_rx, + &mut ui.agent_abort, + &mut ui.agent_interject, + &mut ui.agent_cancel, + &mut ui.is_running, + ); + } + } } else => { - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + tokio::time::sleep(tokio::time::Duration::from_millis(idle_sleep_ms)).await; } } } diff --git a/src/ui/panel_data.rs b/src/ui/panel_data.rs index 716eba8d..ff4483f7 100644 --- a/src/ui/panel_data.rs +++ b/src/ui/panel_data.rs @@ -86,6 +86,21 @@ pub struct LeftPanelInfo { pub git: Option, } +/// A single row in a vigil-status panel display. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Default)] +pub struct VigilStatusRow { + pub name: String, + pub trigger: String, + pub interval_secs: u64, + pub running: bool, + pub paused: bool, + /// Number of events in the most recent reap window. + pub last_event_count: usize, + /// Human-readable age of the most recent reap (e.g. "3s", "12m"). + pub last_event_age: Option, +} + /// Build a compact, glanceable label for a tool call shown in the /// left-panel `[ACTIVITY]` ticker — ` `. The /// target is the basename for path tools, the command head for `bash`, @@ -128,6 +143,19 @@ pub fn tool_call_label(name: &str, args: &serde_json::Value) -> String { } } +/// Human-readable short duration like "3s", "12m", "2h". +#[cfg(feature = "vigil")] +pub fn format_duration_short(d: chrono::TimeDelta) -> String { + let secs = d.num_seconds(); + if secs < 60 { + format!("{}s", secs.max(0)) + } else if secs < 3600 { + format!("{}m", secs / 60) + } else { + format!("{}h", secs / 3600) + } +} + #[cfg(test)] mod tests { use super::tool_call_label; diff --git a/src/ui/renderer.rs b/src/ui/renderer.rs index 09f88135..3d3a55e6 100644 --- a/src/ui/renderer.rs +++ b/src/ui/renderer.rs @@ -371,6 +371,8 @@ pub enum PanelMode { /// Show debug panel instead of system info (gated on ≥100 cols). /// Only meaningful when a DAP session is active. Debug, + /// Show vigil status in the left panel instead of vitals. + Vigil, } /// Which side panels a `/display` spec (or the `display` config value) @@ -425,6 +427,8 @@ pub fn parse_display_spec(spec: &str) -> Result { } // Re-exported from submodules so existing imports don't break. +#[cfg(feature = "vigil")] +pub use crate::ui::panel_data::VigilStatusRow; pub use crate::ui::panel_data::{LeftPanelInfo, PanelData, SubagentStatusRow}; /// Normalized selection range — `start <= end` in row-major order. /// Coordinates are `(buffer_line_idx, char_offset_in_line)`. Used by @@ -584,6 +588,9 @@ pub struct Renderer { /// ui-redesign: idle-state info for the left panel. Painted when /// `subagent_status` is empty so the gutter never looks dead. left_panel_info: LeftPanelInfo, + /// Live vigil status rows for the left panel when in Vigil mode. + #[cfg(feature = "vigil")] + vigil_status: Vec, /// DAP debug panel snapshot — updated each UI tick when a /// DAP session is active and panel mode is Debug. #[cfg(feature = "dap")] @@ -739,6 +746,8 @@ impl Renderer { panel_data: PanelData::default(), subagent_status: Vec::new(), left_panel_info: LeftPanelInfo::default(), + #[cfg(feature = "vigil")] + vigil_status: Vec::new(), #[cfg(feature = "dap")] debug_panel_data: None, alert_overlay: None, @@ -905,6 +914,7 @@ impl Renderer { selection_start, selection_end, right_panel_mode, + left_panel_mode, .. } = self; @@ -1105,6 +1115,9 @@ impl Renderer { input_bg: crate::ui::theme::input_bg(), picker: picker_overlay.as_ref(), right_panel_mode: *right_panel_mode, + left_panel_mode: *left_panel_mode, + #[cfg(feature = "vigil")] + vigil_data: &self.vigil_status, tooltip, #[cfg(feature = "dap")] debug_panel_data: self.debug_panel_data.as_ref(), @@ -1380,6 +1393,17 @@ impl Renderer { self.right_panel_mode = mode; } + /// Set only the left panel mode (used by `/panel vigils`). + pub fn set_left_panel_mode(&mut self, mode: PanelMode) { + self.left_panel_mode = mode; + } + + /// Replace the vigil status snapshot in the left panel. + #[cfg(feature = "vigil")] + pub fn set_vigil_status(&mut self, rows: Vec) { + self.vigil_status = rows; + } + /// Apply a parsed `/display` selection (or the `display` config /// value): each listed side panel is forced on, each omitted one /// forced off — an explicit user choice, so `On`/`Off` rather than @@ -1601,6 +1625,7 @@ impl Renderer { PanelMode::On => self.content_indent() >= 15, PanelMode::Auto => cols >= PANEL_AUTO_MIN_COLS && self.content_indent() >= 15, PanelMode::Debug => cols >= PANEL_AUTO_MIN_COLS && self.content_indent() >= 15, + PanelMode::Vigil => cols >= PANEL_AUTO_MIN_COLS && self.content_indent() >= 15, } } diff --git a/src/ui/run_handlers/done.rs b/src/ui/run_handlers/done.rs index 82fdb0d4..5ba14d82 100644 --- a/src/ui/run_handlers/done.rs +++ b/src/ui/run_handlers/done.rs @@ -42,6 +42,12 @@ pub(crate) struct LoopBits<'a> { pub label: &'a mut Option, } +/// Optional vigil-feature state passed through to `handle_done`. +#[cfg(feature = "vigil")] +pub(crate) struct VigilBits<'a> { + pub state: &'a mut Option, +} + /// Outcome of [`prepare_next_model_client`]: tells the caller whether to go on /// and rebuild the agent, and if so whether the provider changed. #[cfg(feature = "plugin")] @@ -201,8 +207,36 @@ pub(crate) async fn handle_done( // arm applies the model swap and runs finish_done once it resolves. #[cfg(feature = "plugin")] done_phase: &mut Option, #[cfg(feature = "loop")] loop_bits: LoopBits<'_>, + #[cfg(feature = "vigil")] vigil_bits: VigilBits<'_>, ) -> anyhow::Result<()> { *was_reasoning = false; + // Dispatch on-vigil-observance hook now that we have the agent's response. + // The vigil select! arm stored pending observance metadata in VigilState; + // we fire the hook here so it sees :response and :exit. + #[cfg(feature = "vigil")] + if let Some(vs) = vigil_bits.state + && let Some(pending) = vs.pending_observance.take() + { + #[cfg(feature = "plugin")] + if let Some(pm) = plugin_manager { + let escaped_name = pending + .vigil_name + .replace('\\', "\\\\") + .replace('\"', "\\\""); + let escaped_response = response.replace('\\', "\\\\").replace('\"', "\\\""); + let ctx = format!( + "@{{:vigil \"{}\" :count {} :response \"{}\" :exit :ok}}", + escaped_name, pending.event_count, escaped_response, + ); + let pm = pm.clone(); + tokio::task::spawn_blocking(move || { + pm.lock_ignore_poison() + .dispatch_tool_hook("on-vigil-observance", &ctx) + }) + .await + .ok(); + } + } // A successful turn must not leave a chamber // half-painted. If anything slipped through // — show_details=false skipping the body, an @@ -291,6 +325,8 @@ pub(crate) async fn handle_done( plugin_manager, #[cfg(feature = "loop")] loop_bits, + #[cfg(feature = "vigil")] + vigil_bits, ) .await } @@ -323,6 +359,7 @@ pub(crate) async fn finish_done( #[cfg_attr(not(feature = "experimental-graph-search"), allow(unused_variables))] plugin_manager: Option<&std::sync::Arc>>, #[cfg(feature = "loop")] loop_bits: LoopBits<'_>, + #[cfg(feature = "vigil")] vigil_bits: VigilBits<'_>, ) -> anyhow::Result<()> { let bg_store = deps.bg_store; @@ -415,6 +452,21 @@ pub(crate) async fn finish_done( #[cfg(not(feature = "loop"))] let (loop_active, loop_should_stop) = (false, false); + #[cfg(feature = "vigil")] + let vigil_active = vigil_bits + .state + .as_ref() + .map(|vs| vs.active) + .unwrap_or(false); + + #[cfg(feature = "vigil")] + let action = crate::plugin::decide_post_done_action( + followup_for_decision, + loop_active, + loop_should_stop, + vigil_active, + ); + #[cfg(not(feature = "vigil"))] let action = crate::plugin::decide_post_done_action( followup_for_decision, loop_active, @@ -491,6 +543,12 @@ pub(crate) async fn finish_done( } } crate::plugin::PostDoneAction::Idle => {} + #[cfg(feature = "vigil")] + crate::plugin::PostDoneAction::VigilSleep => { + // Vigil mode: don't auto-follow-up or loop; just sleep. + // The select! loop's vigil-wake arm will observe and + // launch the next agent turn. + } } // Phased `/plan` reviewer loop (P3e-b). If this `Done` closed a plan-driven diff --git a/src/ui/slash/cmd/mod.rs b/src/ui/slash/cmd/mod.rs index aeef7f95..11d47f92 100644 --- a/src/ui/slash/cmd/mod.rs +++ b/src/ui/slash/cmd/mod.rs @@ -43,6 +43,7 @@ pub(crate) mod tasks; pub(crate) mod toggle; pub(crate) mod tree; pub(crate) mod undo; +pub(crate) mod vigil_cmd; #[cfg(feature = "git-worktree")] pub(crate) mod worktree; #[cfg(feature = "git-worktree")] diff --git a/src/ui/slash/cmd/panel.rs b/src/ui/slash/cmd/panel.rs index 14bddec9..2a3c6fdf 100644 --- a/src/ui/slash/cmd/panel.rs +++ b/src/ui/slash/cmd/panel.rs @@ -11,9 +11,13 @@ pub(crate) async fn cmd_panel(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow: "off" => Some(PanelMode::Off), "auto" => Some(PanelMode::Auto), "debug" => Some(PanelMode::Debug), + "vigils" => Some(PanelMode::Vigil), other => { ctx.renderer.write_line( - &format!("unknown /panel mode '{}' (use on|off|auto|debug)", other), + &format!( + "unknown /panel mode '{}' (use on|off|auto|debug|vigils)", + other + ), c_error(), )?; return Ok(()); @@ -22,6 +26,8 @@ pub(crate) async fn cmd_panel(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow: if let Some(mode) = new_mode { if mode == PanelMode::Debug { ctx.renderer.set_right_panel_mode(mode); + } else if mode == PanelMode::Vigil { + ctx.renderer.set_left_panel_mode(mode); } else { ctx.renderer.set_panel_mode(mode); } diff --git a/src/ui/slash/cmd/vigil_cmd/add.rs b/src/ui/slash/cmd/vigil_cmd/add.rs new file mode 100644 index 00000000..474d8d7e --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/add.rs @@ -0,0 +1,132 @@ +//! /vigil add toll|watcher|harbinger [key=value ...] — add a new vigil. + +use crate::extras::dirge_paths::ProjectPaths; +use crate::extras::vigil_db::VigilStore; +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_add( + ctx: &mut SlashCtx<'_>, + parts: &[&str], + _text: &str, +) -> anyhow::Result<()> { + let trigger = parts.get(2).copied().unwrap_or(""); + let name = parts.get(3).copied().unwrap_or(""); + if trigger.is_empty() || name.is_empty() { + ctx.renderer.write_line( + "usage: /vigil add toll|watcher|harbinger [key=value ...]", + c_error(), + )?; + return Ok(()); + } + + let entry = match build_entry(trigger, name, &parts[4..]) { + Ok(e) => e, + Err(msg) => { + ctx.renderer.write_line(&msg, c_error())?; + return Ok(()); + } + }; + + let json = match serde_json::to_string(&entry) { + Ok(j) => j, + Err(e) => { + ctx.renderer + .write_line(&format!("failed to serialize: {e}"), c_error())?; + return Ok(()); + } + }; + + let paths = ProjectPaths::new( + &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ); + match VigilStore::open(&paths) { + Ok(store) => { + if let Err(e) = store.upsert(name, &json) { + ctx.renderer + .write_line(&format!("failed to save: {e}"), c_error())?; + return Ok(()); + } + } + Err(e) => { + ctx.renderer + .write_line(&format!("cannot open vigil store: {e}"), c_error())?; + return Ok(()); + } + } + + ctx.renderer.write_line( + &format!("vigil '{name}' (trigger: {trigger}) added"), + c_agent(), + )?; + Ok(()) +} + +fn build_entry( + trigger: &str, + name: &str, + args: &[&str], +) -> Result { + use crate::config::{SocketMode, VigilEntry, VigilRite, VigilTrigger}; + + let parsed: std::collections::HashMap = args + .iter() + .filter_map(|a| a.split_once('=')) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let trigger = match trigger { + "toll" => { + let secs = parsed + .get("interval_secs") + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + VigilTrigger::Toll { + interval_secs: secs, + } + } + "watcher" => { + let path = parsed + .get("path") + .cloned() + .unwrap_or_else(|| ".".to_string()); + VigilTrigger::Watcher { path } + } + "harbinger" => { + let address = parsed + .get("address") + .cloned() + .unwrap_or_else(|| "127.0.0.1:9000".to_string()); + let protocol = parsed.get("protocol").cloned().unwrap_or_default(); + VigilTrigger::Harbinger { + address, + protocol, + socket_mode: SocketMode::Commands, + commands: std::collections::HashMap::new(), + } + } + other => { + return Err(format!( + "unknown trigger '{other}'. use: toll, watcher, or harbinger" + )); + } + }; + + let reap_interval_secs = parsed + .get("reap_interval_secs") + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + + let prompt = parsed.get("prompt").cloned().unwrap_or_default(); + + Ok(VigilEntry { + name: name.to_string(), + trigger, + reap_interval_secs, + prompt, + procession: None, + rite: Some(VigilRite { + cmd: None, + git_dirty: false, + }), + }) +} diff --git a/src/ui/slash/cmd/vigil_cmd/mod.rs b/src/ui/slash/cmd/vigil_cmd/mod.rs new file mode 100644 index 00000000..ba46de27 --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/mod.rs @@ -0,0 +1,61 @@ +//! /vigil command dispatch. + +#[cfg(feature = "vigil")] +pub(crate) mod add; +#[cfg(feature = "vigil")] +pub(crate) mod pause; +#[cfg(feature = "vigil")] +pub(crate) mod remove; +#[cfg(feature = "vigil")] +pub(crate) mod rest; +#[cfg(feature = "vigil")] +pub(crate) mod resume; +#[cfg(feature = "vigil")] +pub(crate) mod start; +#[cfg(feature = "vigil")] +pub(crate) mod status; +#[cfg(feature = "vigil")] +pub(crate) mod stop; + +use crate::ui::slash::SlashCtx; +#[cfg(feature = "vigil")] +use crate::ui::slash::c_error; + +#[cfg(not(feature = "vigil"))] +use crate::ui::slash::c_agent; + +pub(crate) async fn cmd_vigil( + ctx: &mut SlashCtx<'_>, + #[allow(unused_variables)] parts: &[&str], + #[allow(unused_variables)] text: &str, +) -> anyhow::Result<()> { + #[cfg(feature = "vigil")] + { + let sub = parts.get(1).copied().unwrap_or("status"); + match sub { + "add" => add::cmd_vigil_add(ctx, parts, text).await, + "start" => start::cmd_vigil_start(ctx, parts).await, + "stop" => stop::cmd_vigil_stop(ctx, parts).await, + "status" => status::cmd_vigil_status(ctx).await, + "rest" => rest::cmd_vigil_rest(ctx, parts).await, + "pause" => pause::cmd_vigil_pause(ctx, parts).await, + "resume" => resume::cmd_vigil_resume(ctx, parts).await, + "remove" => remove::cmd_vigil_remove(ctx, parts).await, + _ => { + ctx.renderer.write_line( + "usage: /vigil [add|start|stop|status|rest|pause|resume|remove]", + c_error(), + )?; + Ok(()) + } + } + } + #[cfg(not(feature = "vigil"))] + { + ctx.renderer.write_line( + "/vigil requires the 'vigil' feature: cargo build --features vigil", + c_agent(), + )?; + Ok(()) + } +} diff --git a/src/ui/slash/cmd/vigil_cmd/pause.rs b/src/ui/slash/cmd/vigil_cmd/pause.rs new file mode 100644 index 00000000..13999d64 --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/pause.rs @@ -0,0 +1,25 @@ +//! /vigil pause — pause a vigil (keep config, don't fire). + +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_pause(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + if name.is_empty() { + ctx.renderer + .write_line("usage: /vigil pause ", c_error())?; + return Ok(()); + } + if let Some(tx) = ctx.vigil_ctl_tx { + let _ = tx + .send(crate::extras::vigil::types::VigilCtl::Pause { + name: name.to_string(), + }) + .await; + ctx.renderer + .write_line(&format!("vigil '{}' paused", name), c_agent())?; + } else { + ctx.renderer + .write_line("vigil keeper not running", c_error())?; + } + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/remove.rs b/src/ui/slash/cmd/vigil_cmd/remove.rs new file mode 100644 index 00000000..10148916 --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/remove.rs @@ -0,0 +1,43 @@ +//! /vigil remove — remove a vigil definition. + +use crate::extras::dirge_paths::ProjectPaths; +use crate::extras::vigil_db::VigilStore; +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_remove(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + if name.is_empty() { + ctx.renderer + .write_line("usage: /vigil remove ", c_error())?; + return Ok(()); + } + + let paths = ProjectPaths::new( + &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ); + let db_path = paths.session_db_path(); + if db_path.exists() { + match VigilStore::open_at(&db_path) { + Ok(store) => { + if let Err(e) = store.remove(name) { + ctx.renderer + .write_line(&format!("vigil '{name}' not found: {e}"), c_error())?; + return Ok(()); + } + } + Err(e) => { + ctx.renderer + .write_line(&format!("cannot open vigil store: {e}"), c_error())?; + return Ok(()); + } + } + } else { + ctx.renderer + .write_line(&format!("vigil '{name}' not found"), c_error())?; + return Ok(()); + } + + ctx.renderer + .write_line(&format!("vigil '{name}' removed"), c_agent())?; + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/rest.rs b/src/ui/slash/cmd/vigil_cmd/rest.rs new file mode 100644 index 00000000..2c5dfb89 --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/rest.rs @@ -0,0 +1,49 @@ +//! /vigil rest — put a vigil into resting state (sleep until next trigger). + +use crate::extras::dirge_paths::ProjectPaths; +use crate::extras::vigil_db::{VigilStatus, VigilStore}; +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_rest(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + if name.is_empty() { + ctx.renderer + .write_line("usage: /vigil rest ", c_error())?; + return Ok(()); + } + + let paths = ProjectPaths::new( + &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ); + let db_path = paths.session_db_path(); + if db_path.exists() { + match VigilStore::open_at(&db_path) { + Ok(store) => { + if let Err(e) = store.set_status(name, VigilStatus::Resting) { + ctx.renderer + .write_line(&format!("vigil '{name}' not found: {e}"), c_error())?; + return Ok(()); + } + } + Err(e) => { + ctx.renderer + .write_line(&format!("cannot open vigil store: {e}"), c_error())?; + return Ok(()); + } + } + } + + if let Some(ctl_tx) = ctx.vigil_ctl_tx { + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::Pause { + name: name.to_string(), + }) + .await; + } + + ctx.renderer.write_line( + &format!("vigil '{name}' resting (will sleep until next trigger)"), + c_agent(), + )?; + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/resume.rs b/src/ui/slash/cmd/vigil_cmd/resume.rs new file mode 100644 index 00000000..7e35423c --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/resume.rs @@ -0,0 +1,25 @@ +//! /vigil resume — resume a paused vigil. + +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_resume(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + if name.is_empty() { + ctx.renderer + .write_line("usage: /vigil resume ", c_error())?; + return Ok(()); + } + if let Some(tx) = ctx.vigil_ctl_tx { + let _ = tx + .send(crate::extras::vigil::types::VigilCtl::Resume { + name: name.to_string(), + }) + .await; + ctx.renderer + .write_line(&format!("vigil '{}' resumed", name), c_agent())?; + } else { + ctx.renderer + .write_line("vigil keeper not running", c_error())?; + } + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/start.rs b/src/ui/slash/cmd/vigil_cmd/start.rs new file mode 100644 index 00000000..30b0efcb --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/start.rs @@ -0,0 +1,29 @@ +//! /vigil start — (re)start one vigil or all vigils. + +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_start(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + + let Some(ctl_tx) = ctx.vigil_ctl_tx else { + ctx.renderer + .write_line("vigil keeper not running", c_error())?; + return Ok(()); + }; + + if name.is_empty() { + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::ResumeAll) + .await; + ctx.renderer.write_line("resumed all vigils", c_agent())?; + } else { + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::Resume { + name: name.to_string(), + }) + .await; + ctx.renderer + .write_line(&format!("vigil '{name}' started"), c_agent())?; + } + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/status.rs b/src/ui/slash/cmd/vigil_cmd/status.rs new file mode 100644 index 00000000..d90cdee9 --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/status.rs @@ -0,0 +1,58 @@ +//! /vigil status — show all vigils and their state. + +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +use tokio::sync::oneshot; + +pub(crate) async fn cmd_vigil_status(ctx: &mut SlashCtx<'_>) -> anyhow::Result<()> { + let Some(ctl_tx) = ctx.vigil_ctl_tx else { + ctx.renderer + .write_line("vigil keeper not running", c_error())?; + return Ok(()); + }; + + let (tx, rx) = oneshot::channel(); + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::StatusReq { respond_to: tx }) + .await; + + let statuses = match rx.await { + Ok(s) => s, + Err(_) => { + ctx.renderer + .write_line("vigil keeper did not respond", c_error())?; + return Ok(()); + } + }; + + if statuses.is_empty() { + ctx.renderer.write_line("no vigils configured", c_agent())?; + return Ok(()); + } + + for info in &statuses { + let trigger = info.trigger.as_str(); + let state = if info.paused { "paused" } else { "active" }; + ctx.renderer.write_line( + &format!( + " {} trigger={} reap={}s {}", + info.name, trigger, info.reap_interval_secs, state + ), + c_agent(), + )?; + } + + let active = statuses.iter().filter(|i| !i.paused).count(); + let paused = statuses.iter().filter(|i| i.paused).count(); + ctx.renderer.write_line( + &format!( + "{} vigil(s): {} active, {} paused", + statuses.len(), + active, + paused, + ), + c_agent(), + )?; + + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/stop.rs b/src/ui/slash/cmd/vigil_cmd/stop.rs new file mode 100644 index 00000000..e3fa8816 --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/stop.rs @@ -0,0 +1,29 @@ +//! /vigil stop — stop one vigil or all vigils. + +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_stop(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + + let Some(ctl_tx) = ctx.vigil_ctl_tx else { + ctx.renderer + .write_line("vigil keeper not running", c_error())?; + return Ok(()); + }; + + if name.is_empty() { + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::PauseAll) + .await; + ctx.renderer.write_line("stopped all vigils", c_agent())?; + } else { + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::Pause { + name: name.to_string(), + }) + .await; + ctx.renderer + .write_line(&format!("vigil '{name}' stopped"), c_agent())?; + } + Ok(()) +} diff --git a/src/ui/slash/completion.rs b/src/ui/slash/completion.rs index 7e984fe0..977fbe6c 100644 --- a/src/ui/slash/completion.rs +++ b/src/ui/slash/completion.rs @@ -179,7 +179,7 @@ static SUBCOMMAND_ENTRIES: &[(&str, &[&str])] = &[ "help", ], ), - ("/panel", &["on", "off", "auto", "debug"]), + ("/panel", &["on", "off", "auto", "debug", "vigils"]), ("/plugins", &["load"]), ("/display", &[]), // dynamic: pane spec ("/kill", &[]), // dynamic: subagent ID diff --git a/src/ui/slash/mod.rs b/src/ui/slash/mod.rs index ef5eb2b9..eea4f0a4 100644 --- a/src/ui/slash/mod.rs +++ b/src/ui/slash/mod.rs @@ -109,6 +109,11 @@ pub(super) struct SlashCtx<'a> { pub user_tx: &'a tokio::sync::mpsc::UnboundedSender, #[cfg(feature = "loop")] pub loop_state: &'a mut Option, + #[cfg(feature = "vigil")] + #[allow(dead_code)] + pub vigil_state: &'a mut Option, + #[cfg(feature = "vigil")] + pub vigil_ctl_tx: &'a Option>, #[cfg(feature = "mcp")] pub mcp_manager: Option<&'a McpClientManager>, #[cfg(feature = "semantic")] @@ -583,6 +588,10 @@ pub async fn handle_slash( sandbox: &Sandbox, #[cfg(unix)] user_tx: &tokio::sync::mpsc::UnboundedSender, #[cfg(feature = "loop")] loop_state: &mut Option, + #[cfg(feature = "vigil")] vigil_state: &mut Option, + #[cfg(feature = "vigil")] vigil_ctl_tx: &Option< + tokio::sync::mpsc::Sender, + >, #[cfg(feature = "mcp")] mcp_manager: Option<&McpClientManager>, #[cfg(feature = "semantic")] semantic_manager: Option<&SemanticManager>, // C8 (audit fix): every prior agent-rebuild path (/model, @@ -616,6 +625,10 @@ pub async fn handle_slash( user_tx, #[cfg(feature = "loop")] loop_state, + #[cfg(feature = "vigil")] + vigil_state, + #[cfg(feature = "vigil")] + vigil_ctl_tx, #[cfg(feature = "mcp")] mcp_manager, #[cfg(feature = "semantic")] @@ -671,6 +684,7 @@ pub async fn handle_slash( #[cfg(unix)] "/edit" => return Ok(SlashOutcome::DeferExternalEditor), "/undo" => cmd::undo::cmd_undo(&mut ctx).await?, + "/vigil" => cmd::vigil_cmd::cmd_vigil(&mut ctx, &parts, text).await?, "/retry" => cmd::retry::cmd_retry(&mut ctx).await?, "/allow" => cmd::allow::cmd_allow(&mut ctx, &parts, text).await?, "/why" => cmd::allow::why::cmd_why(&mut ctx, &parts).await?, @@ -865,6 +879,10 @@ fn slash_commands() -> Vec<(&'static str, &'static str)> { // (dirge-3p8j). The gated entry made it un-completable / "unknown" in // no-loop builds even though the arm handled it. cmds.push(("/loop", "start, stop, or show a background prompt loop")); + // `/vigil` follows the same always-dispatched pattern as `/loop` — + // its handler prints a feature-gated message when built without vigil, + // so it stays in the canonical list unconditionally. + cmds.push(("/vigil", "manage vigil heartbeat/watch triggers")); #[cfg(feature = "dap")] cmds.push(( "/debug", diff --git a/src/ui/tui/panels.rs b/src/ui/tui/panels.rs index 6e64ec68..22c47cf6 100644 --- a/src/ui/tui/panels.rs +++ b/src/ui/tui/panels.rs @@ -16,6 +16,8 @@ use ratatui::layout::Rect; use ratatui::style::{Color as RColor, Style}; use ratatui::widgets::Widget; +#[cfg(feature = "vigil")] +use crate::ui::renderer::VigilStatusRow; use crate::ui::renderer::{LeftPanelInfo, PanelData, SubagentStatusRow}; use super::chat::crossterm_to_ratatui; @@ -165,6 +167,124 @@ impl<'a> Widget for LeftPanel<'a> { } } +/// Left panel widget that displays vigil status rows. +#[cfg(feature = "vigil")] +pub struct VigilLeftPanel<'a> { + data: &'a [VigilStatusRow], + style: Style, +} + +#[cfg(feature = "vigil")] +impl<'a> VigilLeftPanel<'a> { + pub fn new(data: &'a [VigilStatusRow]) -> Self { + Self { + data, + style: Style::default().fg(RColor::Green), + } + } + + pub fn border_style(mut self, style: Style) -> Self { + self.style = style; + self + } +} + +#[cfg(feature = "vigil")] +impl<'a> Widget for VigilLeftPanel<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + paint_vigil_card(buf, area, self.data, self.style); + } +} + +#[cfg(feature = "vigil")] +fn paint_vigil_card(buf: &mut Buffer, area: Rect, data: &[VigilStatusRow], style: Style) { + let dim = RColor::DarkGray; + let green = RColor::Green; + let yellow = RColor::Yellow; + let panel_w = area.width as usize; + let box_w = area.width.saturating_sub(1); + let bs = style; + + let mut dy = LEFT_PANEL_TOP_PAD; + + // DIRGE banner + let banner = "D I R G E"; + if dy < area.height { + let bw = banner.chars().count(); + let bpad = panel_w.saturating_sub(bw) / 2; + buf.set_stringn( + area.x + bpad as u16, + area.y + dy, + banner, + panel_w.saturating_sub(bpad), + style, + ); + } + dy += 2; + + // VIGILS sub-panel + if data.is_empty() { + let h = 4; + if area.y + dy + h <= area.y + area.height { + let sp = SubPanel::new("VIGILS") + .line("· (none)", dim) + .border_style(bs); + sp.render(Rect::new(area.x, area.y + dy, box_w, h), buf); + } + } else { + // Each row: " ● name trigger XXs" + let rows = data.len().min( + (area.y + area.height) + .saturating_sub(area.y + dy) + .saturating_sub(2) as usize, + ); + let h = 2 + rows as u16; + if area.y + dy + h <= area.y + area.height { + let mut sp = SubPanel::new("VIGILS").border_style(bs); + let inner_w = box_w as usize; + for row in data.iter().take(rows) { + let glyph = if row.paused { + ("○", dim) + } else if row.running { + ("●", green) + } else { + ("◐", yellow) + }; + let interval = if row.interval_secs >= 60 { + format!("{}m", row.interval_secs / 60) + } else { + format!("{}s", row.interval_secs) + }; + // Event ticker: "⚡N" when events were recently reaped. + let ev_tick = if row.last_event_count > 0 { + let age = row.last_event_age.as_deref().unwrap_or(""); + format!("⚡{} {}", row.last_event_count, age) + } else { + String::new() + }; + // Layout: " ● name trigger ⚡3 5s 10s" + let rhs = if ev_tick.is_empty() { + format!("{} {}", row.trigger, interval) + } else { + format!("{} {} {}", row.trigger, ev_tick, interval) + }; + let name_limit = inner_w.saturating_sub(6 + rhs.len() + 2); + let name = if row.name.len() > name_limit && name_limit > 3 { + format!("{}…", &row.name[..name_limit.saturating_sub(1)]) + } else { + row.name.clone() + }; + let line = format!(" {} {} {}", glyph.0, name, rhs); + sp = sp.line(line, glyph.1); + } + sp.render(Rect::new(area.x, area.y + dy, box_w, h), buf); + } + } +} + /// One row of top padding so the left-panel content doesn't sit /// flush against the unified top frame. Matches the right panel's /// symmetric padding for visual balance. diff --git a/src/ui/tui/scene.rs b/src/ui/tui/scene.rs index 0d8af5b0..9cd8d4cf 100644 --- a/src/ui/tui/scene.rs +++ b/src/ui/tui/scene.rs @@ -19,7 +19,11 @@ use super::frame::{ChatBotFrame, TopFrame}; use super::layout::{ LEFT_PANEL_MIN_W, Layout, MAX_INPUT_ROWS, RIGHT_PANEL_MIN_W, overlay_max_rows, }; +#[cfg(feature = "vigil")] +use super::panels::VigilLeftPanel; use super::panels::{LeftPanel, RightPanel}; +#[cfg(feature = "vigil")] +use crate::ui::renderer::VigilStatusRow; use crate::ui::renderer::{ LeftPanelInfo, LineEntry, PanelData, PanelMode, SelectionRange, SubagentStatusRow, }; @@ -50,6 +54,13 @@ pub struct Scene<'a> { /// Current right-panel mode — determines whether to show the debug panel /// or the normal system-info panel on the right side. pub right_panel_mode: PanelMode, + /// Current left-panel mode — determines whether to show vigil status + /// or the normal idle card on the left side. + #[cfg_attr(not(feature = "vigil"), allow(dead_code))] + pub left_panel_mode: PanelMode, + /// Vigil status rows for the left panel when left_panel_mode is Vigil. + #[cfg(feature = "vigil")] + pub vigil_data: &'a [VigilStatusRow], /// dirge-b11: how many entries to skip from the *top* of the /// MODIFIED list (most-recent-first). Carried in Scene so the /// renderer can paint the scrolled view; persisted across @@ -117,12 +128,29 @@ pub fn render_frame(scene: &Scene, f: &mut Frame<'_>) { // Top frame (full width, across left panel + chat + right panel). f.render_widget(TopFrame::new(&layout).style(frame_style), area); - // Left panel — idle card or subagent list. Skip on narrow terminals. + // Left panel — idle card, subagent list, or vigil status. Skip on narrow terminals. if scene.show_left_panel && layout.left_panel.width >= LEFT_PANEL_MIN_W { - f.render_widget( - LeftPanel::new(scene.left_info, scene.subagents).border_style(frame_style), - layout.left_panel, - ); + #[cfg(feature = "vigil")] + { + if scene.left_panel_mode == PanelMode::Vigil { + f.render_widget( + VigilLeftPanel::new(scene.vigil_data).border_style(frame_style), + layout.left_panel, + ); + } else { + f.render_widget( + LeftPanel::new(scene.left_info, scene.subagents).border_style(frame_style), + layout.left_panel, + ); + } + } + #[cfg(not(feature = "vigil"))] + { + f.render_widget( + LeftPanel::new(scene.left_info, scene.subagents).border_style(frame_style), + layout.left_panel, + ); + } } // Chat region (content + │ verticals). @@ -365,6 +393,9 @@ pub fn empty_scene<'a>( #[cfg(feature = "dap")] debug_panel_data: None, right_panel_mode: PanelMode::Auto, + left_panel_mode: PanelMode::Auto, + #[cfg(feature = "vigil")] + vigil_data: &[], modified_offset: 0, left_info, subagents, @@ -619,6 +650,9 @@ mod tests { #[cfg(feature = "dap")] debug_panel_data: None, right_panel_mode: PanelMode::Auto, + left_panel_mode: PanelMode::Auto, + #[cfg(feature = "vigil")] + vigil_data: &[], modified_offset: 0, left_info: &info, subagents: &subs, @@ -987,6 +1021,9 @@ mod tests { #[cfg(feature = "dap")] debug_panel_data: None, right_panel_mode: PanelMode::Auto, + left_panel_mode: PanelMode::Auto, + #[cfg(feature = "vigil")] + vigil_data: &[], modified_offset: 0, left_info: &info, subagents: &subs, @@ -1021,6 +1058,9 @@ mod tests { #[cfg(feature = "dap")] debug_panel_data: None, right_panel_mode: PanelMode::Auto, + left_panel_mode: PanelMode::Auto, + #[cfg(feature = "vigil")] + vigil_data: &[], modified_offset: 0, left_info: &info, subagents: &subs, diff --git a/tests/fixtures/vigil/README.md b/tests/fixtures/vigil/README.md new file mode 100644 index 00000000..2b023014 --- /dev/null +++ b/tests/fixtures/vigil/README.md @@ -0,0 +1,193 @@ +# Vigil Functional Test Configs + +Four vigil configurations covering all trigger modes plus three workflow engine +integrations (Jenkins, Prefect, Airflow). Copy configs to `.dirge/vigils/` and +run `dirge --vigil` to test each mode. + +## Files + +- `sanity-toll.json` — Timer trigger, fires every 10s +- `sanity-watcher.json` — Filesystem trigger, watches `watch-dir/` +- `sanity-harbinger-template.json` — TCP socket, template mode (port 9090) +- `sanity-harbinger-commands.json` — TCP socket, commands mode (port 9091) +- `sanity-jenkins.json` — Vigil config for `jenkins-remediate` +- `sanity-prefect.json` — Vigil config for `prefect-remediate` +- `sanity-airflow.json` — Vigil config for `airflow-remediate` +- `setup-jenkins.sh` — Creates failing Jenkins job + build +- `setup-airflow.sh` — Configures Airflow basic auth, creates failing DAG + run +- `setup-prefect.sh` — Creates failing Prefect flow run +- `podman-compose.yml` — Jenkins (8080), Prefect Server (4200), Airflow (8081) +- `plugins/` — Janet poller plugins for each engine + +## Setup + +```bash +# Copy vigils into the project's runtime directory +cp tests/fixtures/vigil/sanity-*.json .dirge/vigils/ + +# Verify they were picked up +dirge vigil list +``` + +## Test: Toll (timer) + +The toll vigil fires every 10 seconds. Start dirge in vigil mode and +check the status panel: + +```bash +dirge --vigil +# In the TUI: /vigil status +# Expect: sanity-toll status=active, trigger=toll +# Every 10s the rite runs `echo 'toll-ok'` and the reaper fires. +# /vigil pause sanity-toll — to stop +# /vigil resume sanity-toll — to restart +``` + +## Test: Watcher (filesystem) + +```bash +dirge --vigil +# In another terminal, trigger a file change: +touch tests/fixtures/vigil/watch-dir/trigger.txt +echo "changed" >> tests/fixtures/vigil/watch-dir/trigger.txt + +# In the TUI: /vigil status +# Expect: sanity-watcher status=active, trigger=watcher +# The watcher fires on modify events; the 500ms debounce coalesces +# rapid changes. After the reap interval, an observance fires. +``` + +## Test: Harbinger (template mode) + +```bash +dirge --vigil +# In another terminal, send data to the socket: +echo '{"message":"hello from template mode"}' | nc -w1 127.0.0.1 9090 + +# The raw payload substitutes into {harbinger_data} in the prompt: +# "Harbinger received: {\"message\":\"hello from template mode\"}" +``` + +## Test: Harbinger (commands mode) + +```bash +dirge --vigil +# In another terminal, send a command: +echo '{"command":"echo","args":{"message":"hello world"}}' | nc -w1 127.0.0.1 9091 + +# The vigil-keeper looks up "echo" in the commands map, substitutes +# {message} → "hello world", and dispatches: +# bash -c "echo 'commands-ok: hello world'" + +# Static command (no args needed): +echo '{"command":"ping"}' | nc -w1 127.0.0.1 9091 +# → bash -c "echo 'pong'" + +# Unknown command: +echo '{"command":"nonexistent"}' | nc -w1 127.0.0.1 9091 +# → rejected with warning log +``` + +## Cleanup + +```bash +dirge vigil remove sanity-toll +dirge vigil remove sanity-watcher +dirge vigil remove sanity-harbinger-template +dirge vigil remove sanity-harbinger-commands +``` + +## Workflow Engine Integration (Jenkins, Prefect, Airflow) + +A podman-compose file starts all three engines locally. Janet plugins poll each +engine's API and push failures into vigil queues via `(vigil/emit ...)`. + +### Quick Start (automated) + +```bash +# Start engines +podman-compose -f tests/fixtures/vigil/podman-compose.yml up -d + +# Create failing entities in each engine +./tests/fixtures/vigil/setup-jenkins.sh +./tests/fixtures/vigil/setup-airflow.sh +./tests/fixtures/vigil/setup-prefect.sh + +# Install configs and plugins +cp tests/fixtures/vigil/sanity-*.json .dirge/vigils/ +cp tests/fixtures/vigil/plugins/*.janet .dirge/plugins/ + +# Start dirge and test +dirge --vigil +# In TUI: /plugins load all +# In TUI: /poll-jenkins, /poll-airflow, /poll-prefect +``` + +### Setup scripts + +Each `setup-*.sh` script is idempotent — it creates the test entity +and configures the engine for the poller plugin to detect. + +- `setup-jenkins.sh` — Jenkins (8080): Failing freestyle job `test-pipeline` (exit 1) +- `setup-airflow.sh` — Airflow (8081): Failing DAG `failing_dag` (bash exit 1), resets admin password +- `setup-prefect.sh` — Prefect (4200): Failing flow run `failing-flow` + +### Files + +- `podman-compose.yml` — Jenkins (8080), Prefect Server (4200), Airflow (8081) +- `plugins/jenkins-poller.janet` — Polls Jenkins API for failed builds +- `plugins/prefect-poller.janet` — Polls Prefect API for failed flow runs +- `plugins/airflow-poller.janet` — Polls Airflow API for failed DAG runs +- `sanity-jenkins.json` — Vigil config for `jenkins-remediate` +- `sanity-prefect.json` — Vigil config for `prefect-remediate` +- `sanity-airflow.json` — Vigil config for `airflow-remediate` + +### End-to-end test workflow + +1. Start engines and run setup scripts (see Quick Start above) +2. Start `dirge --vigil` +3. In TUI: `/plugins load all` — pollers auto-poll on load +4. In TUI: `/poll-jenkins`, `/poll-airflow`, `/poll-prefect` — manual polls +5. Each poller detects the failing entity and calls `(vigil/emit ...)` +6. The vigil-keeper reaper drains the queue, runs the rite, spawns an observance +7. The observance fires an agent turn with the engine-specific prompt template + +### Architecture + +Each Janet plugin hooks `on-init` to spawn a background fiber: + +``` +┌──────────────┐ poll (60s) ┌──────────────┐ +│ Janet plugin │ ─────────────→ │ Engine API │ +│ (fiber) │ ←───────────── │ (Jenkins, │ +└──────┬───────┘ failures │ Prefect, │ + │ │ Airflow) │ + │ vigil/emit └──────────────┘ + ▼ +┌──────────────┐ reap ┌──────────────┐ +│ vigil queue │ ───────→ │ observance │ +│ (mpsc 256) │ │ (agent turn) │ +└──────────────┘ └──────────────┘ +``` + +The plugin is the event producer — `(vigil/emit "jenkins-remediate" ctx)` pushes +directly into the named vigil's mpsc channel. The toll trigger just drives the +reaper interval. The reaper drains, coalesces, runs the rite, and if the rite +passes, spawns an observance. + +### Smoke test (no real engines needed) + +The plugins gracefully handle unreachable APIs. For a quick smoke test without +starting containers: + +1. Install plugins and vigil configs +2. Start `dirge --vigil` +3. Check `/vigil status` — all three engine vigils should appear as `active` +4. The plugins will log connection errors but won't crash — the vigil-keeper + stays running. The pipeline is verified: plugin → vigil/emit → queue → reaper. + +### Stop the engines + +```bash +podman-compose -f tests/fixtures/vigil/podman-compose.yml down +``` diff --git a/tests/fixtures/vigil/echo-airflow.sh b/tests/fixtures/vigil/echo-airflow.sh new file mode 100755 index 00000000..d3180314 --- /dev/null +++ b/tests/fixtures/vigil/echo-airflow.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Simulate an Airflow DAG run completing and notifying vigil via harbinger. +echo '{"engine":"airflow","dag":"echo-dag","run_id":"scheduled__2026-01-01","status":"success"}' | nc -w1 127.0.0.1 9092 +echo "airflow echo sent (port 9092)" diff --git a/tests/fixtures/vigil/echo-jenkins.sh b/tests/fixtures/vigil/echo-jenkins.sh new file mode 100755 index 00000000..2f74aa58 --- /dev/null +++ b/tests/fixtures/vigil/echo-jenkins.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Simulate a Jenkins build completing and notifying vigil via harbinger. +echo '{"engine":"jenkins","job":"echo-job","build":42,"status":"SUCCESS"}' | nc -w1 127.0.0.1 9092 +echo "jenkins echo sent (port 9092)" diff --git a/tests/fixtures/vigil/echo-prefect.sh b/tests/fixtures/vigil/echo-prefect.sh new file mode 100755 index 00000000..55f94c6e --- /dev/null +++ b/tests/fixtures/vigil/echo-prefect.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Simulate a Prefect flow run completing and notifying vigil via harbinger. +# This represents what a Prefect task would do as its last step. +echo '{"engine":"prefect","flow":"echo-flow","run_id":"run-001","status":"completed"}' | nc -w1 127.0.0.1 9092 +echo "prefect echo sent (port 9092)" diff --git a/tests/fixtures/vigil/plugins/airflow-poller.janet b/tests/fixtures/vigil/plugins/airflow-poller.janet new file mode 100644 index 00000000..00951834 --- /dev/null +++ b/tests/fixtures/vigil/plugins/airflow-poller.janet @@ -0,0 +1,45 @@ +# Airflow poller for vigil functional testing. +# +# Runs a one-shot curl at load time and registers /poll-airflow. +# Detected failures are pushed into the vigil-keeper via (vigil/emit ...). +# +# Requires: Airflow running (podman-compose up -d), vigil-keeper active. +# Vigil config: sanity-airflow.json + +(defn- sh-capture [cmd-str] + "Run command via /bin/sh -c with output redirection to a temp file. + Returns trimmed stdout string or nil on failure." + (let [tmp (string "/tmp/dirge-poll-" (os/time))] + (os/execute ["/bin/sh" "-c" (string cmd-str " > " tmp " 2>/dev/null")]) + (try + (let [f (file/open tmp :r) + data (file/read f :all)] + (file/close f) + (os/execute ["/bin/rm" "-f" tmp]) + (when (and data (not= data "")) (string/trim data))) + ([_] (do (os/execute ["/bin/rm" "-f" tmp]) nil))))) + +(defn- emit-airflow-failures [json-str] + "Parse Airflow JSON and emit failed DAG runs into the vigil-keeper." + (when json-str + (try + (let [parsed (parse json-str) + runs (if (indexed? (parsed :dag_runs)) (parsed :dag_runs) @[])] + (each run runs + (vigil/emit "airflow-remediate" + {:dag_id (run :dag_id) + :run_id (run :dag_run_id) + :state "failed"}))) + ([_] nil)))) + +(defn poll-airflow [] + "One-shot: curl Airflow API, parse JSON, emit failures to vigil." + (let [json-str (sh-capture "curl -s -u admin:admin http://localhost:8081/api/v1/dags/~/dagRuns?state=failed")] + (if json-str + (do + (emit-airflow-failures json-str) + (harness/notify "airflow-poller: poll complete" :info)) + (harness/notify "airflow-poller: curl failed (is Airflow running?)" :warn)))) + +(harness/register-command "poll-airflow" "poll-airflow") +(poll-airflow) diff --git a/tests/fixtures/vigil/plugins/jenkins-poller.janet b/tests/fixtures/vigil/plugins/jenkins-poller.janet new file mode 100644 index 00000000..9e22e71b --- /dev/null +++ b/tests/fixtures/vigil/plugins/jenkins-poller.janet @@ -0,0 +1,47 @@ +# Jenkins poller for vigil functional testing. +# +# Runs a one-shot curl at load time and registers /poll-jenkins. +# Detected failures are pushed into the vigil-keeper via (vigil/emit ...). +# +# Requires: Jenkins running (podman-compose up -d), vigil-keeper active. +# Vigil config: sanity-jenkins.json + +(defn- sh-capture [cmd-str] + "Run command via /bin/sh -c with output redirection to a temp file. + Returns trimmed stdout string or nil on failure." + (let [tmp (string "/tmp/dirge-poll-" (os/time))] + (os/execute ["/bin/sh" "-c" (string cmd-str " > " tmp " 2>/dev/null")]) + (try + (let [f (file/open tmp :r) + data (file/read f :all)] + (file/close f) + (os/execute ["/bin/rm" "-f" tmp]) + (when (and data (not= data "")) (string/trim data))) + ([_] (do (os/execute ["/bin/rm" "-f" tmp]) nil))))) + +(defn- emit-jenkins-failures [json-str] + "Parse Jenkins JSON and emit failed builds into the vigil-keeper." + (when json-str + (try + (let [parsed (parse json-str) + jobs (if (indexed? (parsed :jobs)) (parsed :jobs) @[])] + (each job jobs + (when (= "FAILURE" (get-in job [:lastBuild :result])) + (vigil/emit "jenkins-remediate" + {:job (job :name) + :build_number (string (get-in job [:lastBuild :number])) + :url (get-in job [:lastBuild :url]) + :status "FAILURE"})))) + ([_] nil)))) + +(defn poll-jenkins [] + "One-shot: curl Jenkins API, parse JSON, emit failures to vigil." + (let [json-str (sh-capture "curl -s http://localhost:8080/api/json?tree=jobs[name,lastBuild[number,result,url]]")] + (if json-str + (do + (emit-jenkins-failures json-str) + (harness/notify "jenkins-poller: poll complete" :info)) + (harness/notify "jenkins-poller: curl failed (is Jenkins running?)" :warn)))) + +(harness/register-command "poll-jenkins" "poll-jenkins") +(poll-jenkins) diff --git a/tests/fixtures/vigil/plugins/prefect-poller.janet b/tests/fixtures/vigil/plugins/prefect-poller.janet new file mode 100644 index 00000000..474b1ee2 --- /dev/null +++ b/tests/fixtures/vigil/plugins/prefect-poller.janet @@ -0,0 +1,46 @@ +# Prefect poller for vigil functional testing. +# +# Runs a one-shot curl at load time and registers /poll-prefect. +# Detected failures are pushed into the vigil-keeper via (vigil/emit ...). +# +# Requires: Prefect Server running (podman-compose up -d), vigil-keeper active. +# Vigil config: sanity-prefect.json + +(defn- sh-capture [cmd-str] + "Run command via /bin/sh -c with output redirection to a temp file. + Returns trimmed stdout string or nil on failure." + (let [tmp (string "/tmp/dirge-poll-" (os/time))] + (os/execute ["/bin/sh" "-c" (string cmd-str " > " tmp " 2>/dev/null")]) + (try + (let [f (file/open tmp :r) + data (file/read f :all)] + (file/close f) + (os/execute ["/bin/rm" "-f" tmp]) + (when (and data (not= data "")) (string/trim data))) + ([_] (do (os/execute ["/bin/rm" "-f" tmp]) nil))))) + +(defn- emit-prefect-failures [json-str] + "Parse Prefect JSON and emit failed flow runs into the vigil-keeper." + (when json-str + (try + (let [parsed (parse json-str) + runs (if (indexed? (parsed :data)) (parsed :data) @[])] + (each run runs + (vigil/emit "prefect-remediate" + {:run_id (run :id) + :flow_name (run :name) + :state "FAILED"}))) + ([_] nil)))) + +(defn poll-prefect [] + "One-shot: curl Prefect API, parse JSON, emit failures to vigil." + (let [json-str (sh-capture + "curl -s -X POST -H 'Content-Type: application/json' -d '{\"flow_runs\":{\"state\":{\"type\":{\"any_\":[\"FAILED\",\"CRASHED\"]}}}}' http://localhost:4200/api/flow_runs/filter")] + (if json-str + (do + (emit-prefect-failures json-str) + (harness/notify "prefect-poller: poll complete" :info)) + (harness/notify "prefect-poller: curl failed (is Prefect running?)" :warn)))) + +(harness/register-command "poll-prefect" "poll-prefect") +(poll-prefect) diff --git a/tests/fixtures/vigil/podman-compose.yml b/tests/fixtures/vigil/podman-compose.yml new file mode 100644 index 00000000..085fa004 --- /dev/null +++ b/tests/fixtures/vigil/podman-compose.yml @@ -0,0 +1,57 @@ +version: "3.8" + +# Workflow engines for vigil functional testing. +# Start: podman-compose -f tests/fixtures/vigil/podman-compose.yml up -d +# Stop: podman-compose -f tests/fixtures/vigil/podman-compose.yml down + +services: + jenkins: + image: docker.io/jenkins/jenkins:lts-jdk17 + container_name: vigil-jenkins + ports: + - "8080:8080" + - "50000:50000" + volumes: + - jenkins_home:/var/jenkins_home + environment: + JAVA_OPTS: "-Djenkins.install.runSetupWizard=false" + restart: unless-stopped + + prefect: + image: docker.io/prefecthq/prefect:3-latest + container_name: vigil-prefect + command: prefect server start + ports: + - "4200:4200" + environment: + PREFECT_SERVER_API_HOST: "0.0.0.0" + PREFECT_API_URL: "http://localhost:4200/api" + volumes: + - prefect_data:/root/.prefect + restart: unless-stopped + + airflow: + image: docker.io/apache/airflow:2.10.5 + container_name: vigil-airflow + ports: + - "8081:8080" + environment: + AIRFLOW__CORE__EXECUTOR: SequentialExecutor + AIRFLOW__CORE__LOAD_EXAMPLES: "false" + AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: sqlite:////opt/airflow/airflow.db + AIRFLOW__WEBSERVER__SECRET_KEY: vigil-test-key-do-not-use-in-production + AIRFLOW__API__AUTH_BACKENDS: airflow.api.auth.backend.basic_auth + volumes: + - airflow_data:/opt/airflow + command: > + bash -c " + airflow db init && + airflow users create -u admin -p admin -f Admin -l User -r Admin -e admin@example.com && + airflow standalone + " + restart: unless-stopped + +volumes: + jenkins_home: + prefect_data: + airflow_data: diff --git a/tests/fixtures/vigil/run-sanity-checks.sh b/tests/fixtures/vigil/run-sanity-checks.sh new file mode 100755 index 00000000..5ead6dd0 --- /dev/null +++ b/tests/fixtures/vigil/run-sanity-checks.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# run-sanity-checks.sh — Verify all engine containers, failure fixtures, and +# harbinger ports are ready for vigil e2e testing. +# +# Usage: ./run-sanity-checks.sh +# Requires: podman-compose up -d (engines running) and setup-*.sh run +# Exit 0 = everything ready. Exit 1 = something is wrong. + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +PASS="${GREEN}PASS${NC}" +FAIL="${RED}FAIL${NC}" +WARN="${YELLOW}WARN${NC}" + +errors=0 +warnings=0 + +check() { + local label="$1"; shift + local result="$1"; shift + if [ "$result" = "0" ]; then + printf " ${PASS} %s\n" "$label" + elif [ "$result" = "WARN" ]; then + printf " ${WARN} %s\n" "$label" + warnings=$((warnings + 1)) + else + printf " ${FAIL} %s\n" "$label" + errors=$((errors + 1)) + fi +} + +echo "=== Vigil Sanity Check ===" +echo "" + +# ── Containers ────────────────────────────────────────────────────────────── + +echo "--- Containers ---" + +for svc in vigil-jenkins vigil-prefect vigil-airflow; do + if podman ps --filter "name=$svc" --format '{{.Status}}' 2>/dev/null | grep -q '^Up'; then + check "$svc" 0 + else + check "$svc" 1 + fi +done +echo "" + +# ── Engine APIs ───────────────────────────────────────────────────────────── + +echo "--- Engine APIs ---" + +# Jenkins +if curl -sf --max-time 5 'http://localhost:8080/api/json' > /dev/null 2>&1; then + check "Jenkins API (8080)" 0 +else + check "Jenkins API (8080)" 1 +fi + +# Prefect +if curl -sf --max-time 5 'http://localhost:4200/api/health' > /dev/null 2>&1; then + check "Prefect API (4200)" 0 +else + check "Prefect API (4200)" 1 +fi + +# Airflow +if curl -sf --max-time 5 -u admin:admin 'http://localhost:8081/api/v1/dags' > /dev/null 2>&1; then + check "Airflow API (8081)" 0 +else + check "Airflow API (8081)" 1 +fi +echo "" + +# ── Failure fixtures ──────────────────────────────────────────────────────── + +echo "--- Failure Fixtures ---" + +# Jenkins: check test-pipeline job exists and last build failed +JENKINS_JOB=$(curl -sf --globoff --max-time 5 \ + 'http://localhost:8080/api/json?tree=jobs[name,color]' \ + 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); jobs=[j for j in d.get('jobs',[]) if j['name']=='test-pipeline']; print(jobs[0]['color'] if jobs else 'NOT_FOUND')" 2>/dev/null || echo "ERROR") + +case "$JENKINS_JOB" in + red*) check "Jenkins: test-pipeline FAILURE" 0 ;; + NOT_FOUND) check "Jenkins: test-pipeline job not found" 1 ;; + *) check "Jenkins: test-pipeline (color=$JENKINS_JOB)" 1 ;; +esac + +# Prefect: check for failed flow runs +PREFECT_FAILED=$(curl -sf --max-time 5 \ + -X POST -H 'Content-Type: application/json' \ + -d '{"flow_runs":{"state":{"type":{"any_":["FAILED","CRASHED"]}}}}' \ + 'http://localhost:4200/api/flow_runs/filter' \ + 2>/dev/null | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") + +if [ "$PREFECT_FAILED" -gt 0 ]; then + check "Prefect: $PREFECT_FAILED failed flow run(s)" 0 +else + check "Prefect: no failed flow runs" 1 +fi + +# Airflow: check for failed DAG runs +AIRFLOW_FAILED=$(curl -sf --max-time 5 \ + -u admin:admin \ + 'http://localhost:8081/api/v1/dags/~/dagRuns?state=failed' \ + 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('total_entries',0))" 2>/dev/null || echo "0") + +if [ "$AIRFLOW_FAILED" -gt 0 ]; then + check "Airflow: $AIRFLOW_FAILED failed DAG run(s)" 0 +else + check "Airflow: no failed DAG runs" 1 +fi +echo "" + +# ── Harbinger ports ───────────────────────────────────────────────────────── + +echo "--- Harbinger Ports ---" + +if nc -z -w2 127.0.0.1 9090 2>/dev/null; then + check "Harbinger template (9090)" 0 +else + check "Harbinger template (9090)" 1 +fi + +if nc -z -w2 127.0.0.1 9091 2>/dev/null; then + check "Harbinger commands (9091)" 0 +else + check "Harbinger commands (9091)" 1 +fi + +# Probe template mode +if nc -z -w2 127.0.0.1 9090 2>/dev/null; then + echo '{"message":"sanity-check-probe"}' | nc -w2 127.0.0.1 9090 > /dev/null 2>&1 && true + check " template probe sent" 0 +else + check " template probe (skipped, port down)" "WARN" +fi + +# Probe commands mode +if nc -z -w2 127.0.0.1 9091 2>/dev/null; then + echo '{"command":"ping"}' | nc -w2 127.0.0.1 9091 > /dev/null 2>&1 && true + check " commands ping sent" 0 +else + check " commands ping (skipped, port down)" "WARN" +fi +echo "" + +# ── Cross-session messaging ───────────────────────────────────────────────── + +echo "--- Cross-Session Messaging ---" + +# Send a realistic cross-session event to the template harbinger port. +# If a dirge --vigil instance is running with harbinger on 9090, it will +# receive this payload and fire an observance turn. +CROSS_MSG_DATA='{"sender":"sanity-checker","event":"coord-test","message":"Cross-session messaging test from run-sanity-checks.sh. If you see this, sessions can message each other via harbinger."}' +CROSS_OK=0 +echo "$CROSS_MSG_DATA" | nc -w2 127.0.0.1 9090 > /dev/null 2>&1 || CROSS_OK=1 + +if [ "$CROSS_OK" -eq 0 ]; then + check "Cross-session event sent to 9090" 0 +else + check "Cross-session event (send failed, is dirge --vigil running on 9090?)" "WARN" +fi +echo "" + +# ── Summary ───────────────────────────────────────────────────────────────── + +echo "=== Summary ===" + +if [ "$errors" -gt 0 ]; then + echo " Errors: $errors" +else + echo " Errors: 0" +fi +echo " Warnings: $warnings" +echo "" + +if [ "$errors" -gt 0 ]; then + echo "Some checks failed. Run the setup scripts:" + echo " ./tests/fixtures/vigil/setup-jenkins.sh" + echo " ./tests/fixtures/vigil/setup-prefect.sh" + echo " ./tests/fixtures/vigil/setup-airflow.sh" + echo "" + exit 1 +fi + +echo "All checks passed. Engines are ready for vigil e2e testing." +echo "" +echo "Next: start dirge --vigil and run:" +echo " /plugins load all" +echo " /poll-jenkins" +echo " /poll-prefect" +echo " /poll-airflow" +echo " /vigil status" diff --git a/tests/fixtures/vigil/sanity-airflow.json b/tests/fixtures/vigil/sanity-airflow.json new file mode 100644 index 00000000..14b6f149 --- /dev/null +++ b/tests/fixtures/vigil/sanity-airflow.json @@ -0,0 +1,12 @@ +{ + "name": "airflow-remediate", + "trigger": { + "type": "toll", + "interval_secs": 60 + }, + "reap_interval_secs": 60, + "prompt": "Airflow DAG run failed.\n\nDAG: {dag_id}\nRun ID: {run_id}\nState: {state}\n\nInvestigate and fix the DAG.", + "rite": { + "cmd": "echo 'airflow-rite-ok'" + } +} diff --git a/tests/fixtures/vigil/sanity-harbinger-commands.json b/tests/fixtures/vigil/sanity-harbinger-commands.json new file mode 100644 index 00000000..7ff50ddb --- /dev/null +++ b/tests/fixtures/vigil/sanity-harbinger-commands.json @@ -0,0 +1,29 @@ +{ + "name": "sanity-harbinger-commands", + "trigger": { + "type": "harbinger", + "address": "127.0.0.1:9091", + "protocol": "tcp", + "socket_mode": "commands", + "commands": { + "echo": { + "tool": "bash", + "args": { + "command": "echo 'commands-ok: {message}'", + "description": "Echo back the message arg" + } + }, + "ping": { + "tool": "bash", + "args": { + "command": "echo 'pong'" + } + } + } + }, + "reap_interval_secs": 10, + "prompt": "", + "rite": { + "cmd": "echo 'harbinger-commands-ok'" + } +} diff --git a/tests/fixtures/vigil/sanity-harbinger-template.json b/tests/fixtures/vigil/sanity-harbinger-template.json new file mode 100644 index 00000000..38ed40cd --- /dev/null +++ b/tests/fixtures/vigil/sanity-harbinger-template.json @@ -0,0 +1,14 @@ +{ + "name": "sanity-harbinger-template", + "trigger": { + "type": "harbinger", + "address": "127.0.0.1:9090", + "protocol": "tcp", + "socket_mode": "template" + }, + "reap_interval_secs": 10, + "prompt": "Harbinger received: {harbinger_data}", + "rite": { + "cmd": "echo 'harbinger-template-ok'" + } +} diff --git a/tests/fixtures/vigil/sanity-jenkins.json b/tests/fixtures/vigil/sanity-jenkins.json new file mode 100644 index 00000000..0596eb7c --- /dev/null +++ b/tests/fixtures/vigil/sanity-jenkins.json @@ -0,0 +1,12 @@ +{ + "name": "jenkins-remediate", + "trigger": { + "type": "toll", + "interval_secs": 60 + }, + "reap_interval_secs": 60, + "prompt": "Jenkins build failed.\n\nJob: {job}\nBuild: #{build_number}\nURL: {url}\nStatus: {status}\n\nInvestigate the failure and propose a fix.", + "rite": { + "cmd": "echo 'jenkins-rite-ok'" + } +} diff --git a/tests/fixtures/vigil/sanity-prefect.json b/tests/fixtures/vigil/sanity-prefect.json new file mode 100644 index 00000000..4f2348d6 --- /dev/null +++ b/tests/fixtures/vigil/sanity-prefect.json @@ -0,0 +1,12 @@ +{ + "name": "prefect-remediate", + "trigger": { + "type": "toll", + "interval_secs": 60 + }, + "reap_interval_secs": 60, + "prompt": "Prefect flow run failed.\n\nFlow: {flow_name}\nRun ID: {run_id}\nState: {state}\n\nInvestigate the failure and remediate.", + "rite": { + "cmd": "echo 'prefect-rite-ok'" + } +} diff --git a/tests/fixtures/vigil/sanity-toll.json b/tests/fixtures/vigil/sanity-toll.json new file mode 100644 index 00000000..33e5d1ef --- /dev/null +++ b/tests/fixtures/vigil/sanity-toll.json @@ -0,0 +1,12 @@ +{ + "name": "sanity-toll", + "trigger": { + "type": "toll", + "interval_secs": 10 + }, + "reap_interval_secs": 10, + "prompt": "Vigil toll fired. Rite output: {rite_output}", + "rite": { + "cmd": "echo 'toll-ok'" + } +} diff --git a/tests/fixtures/vigil/sanity-watcher.json b/tests/fixtures/vigil/sanity-watcher.json new file mode 100644 index 00000000..60548400 --- /dev/null +++ b/tests/fixtures/vigil/sanity-watcher.json @@ -0,0 +1,12 @@ +{ + "name": "sanity-watcher", + "trigger": { + "type": "watcher", + "path": "tests/fixtures/vigil/watch-dir" + }, + "reap_interval_secs": 10, + "prompt": "Files changed: {files}. Events: {events}. Count: {event_count}", + "rite": { + "cmd": "echo 'watcher-ok'" + } +} diff --git a/tests/fixtures/vigil/setup-airflow.sh b/tests/fixtures/vigil/setup-airflow.sh new file mode 100755 index 00000000..be6b488b --- /dev/null +++ b/tests/fixtures/vigil/setup-airflow.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# setup-airflow.sh — Configure Airflow for vigil e2e testing and create a failing DAG. +# +# Usage: ./setup-airflow.sh +# Requires: Airflow running on localhost:8081 (podman-compose up -d) +# Idempotent: overwrites the DAG file and re-triggers each run + +set -euo pipefail + +CONTAINER="vigil-airflow" +AIRFLOW_URL="http://localhost:8081" +AIRFLOW_USER="admin" +AIRFLOW_PASS="admin" + +echo "=== Airflow e2e setup ===" + +# 1. Fix auth: Airflow 2.10.5 standalone uses session auth by default, but the +# plugin expects basic auth. Switch to basic_auth backend. +echo " Configuring basic auth..." +podman exec "$CONTAINER" bash -c ' + sed -i "s|auth_backends = airflow.api.auth.backend.session|auth_backends = airflow.api.auth.backend.basic_auth|" /opt/airflow/airflow.cfg +' 2>/dev/null || true + +# Restart to pick up auth change +echo " Restarting Airflow..." +podman restart "$CONTAINER" > /dev/null 2>&1 +sleep 15 + +# 2. Reset the admin password (airflow standalone may override the compose-created password) +echo " Resetting admin password..." +podman exec "$CONTAINER" bash -c " + airflow users reset-password -u admin -p $AIRFLOW_PASS 2>&1 +" | tail -1 + +# Verify auth works +if ! curl -sf -u "$AIRFLOW_USER:$AIRFLOW_PASS" "$AIRFLOW_URL/api/v1/dags" > /dev/null 2>&1; then + echo " ERROR: basic auth not working after password reset. Check airflow.cfg auth_backends." >&2 + exit 1 +fi +echo " Auth verified (admin:$AIRFLOW_PASS)" + +# 3. Create a DAG file that always fails +echo " Creating failing_dag..." +podman exec "$CONTAINER" bash -c 'cat > /opt/airflow/dags/failing_dag.py << "DAGEOF" +from airflow import DAG +from airflow.operators.bash import BashOperator +from datetime import datetime + +with DAG( + dag_id="failing_dag", + start_date=datetime(2026, 1, 1), + schedule=None, + catchup=False, +) as dag: + BashOperator(task_id="will_fail", bash_command="exit 1") +DAGEOF +' + +# 4. Unpause the DAG +echo " Unpausing DAG..." +curl -sf -u "$AIRFLOW_USER:$AIRFLOW_PASS" -X PATCH "$AIRFLOW_URL/api/v1/dags/failing_dag" \ + -H 'Content-Type: application/json' \ + -d '{"is_paused":false}' > /dev/null + +# 5. Trigger a DAG run +echo " Triggering DAG run..." +curl -sf -u "$AIRFLOW_USER:$AIRFLOW_PASS" -X POST "$AIRFLOW_URL/api/v1/dags/failing_dag/dagRuns" \ + -H 'Content-Type: application/json' \ + -d '{}' > /dev/null + +# 6. Wait for the DAG to run and fail (SequentialExecutor processes one task at a time) +echo " Waiting for DAG run to fail..." +for i in $(seq 1 12); do + sleep 5 + FAILED=$(curl -sf -u "$AIRFLOW_USER:$AIRFLOW_PASS" "$AIRFLOW_URL/api/v1/dags/~/dagRuns?state=failed" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total_entries',0))" 2>/dev/null || echo "0") + if [ "$FAILED" -gt 0 ]; then + echo " DAG run failed ($FAILED failed run(s))" + break + fi +done + +if [ "${FAILED:-0}" -eq 0 ]; then + # Fallback: trigger via CLI inside container + echo " Falling back to CLI trigger..." + podman exec "$CONTAINER" bash -c 'airflow dags unpause failing_dag 2>&1; airflow dags trigger failing_dag 2>&1' | tail -3 + sleep 30 + FAILED=$(curl -sf -u "$AIRFLOW_USER:$AIRFLOW_PASS" "$AIRFLOW_URL/api/v1/dags/~/dagRuns?state=failed" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total_entries',0))" 2>/dev/null || echo "0") +fi + +echo "=== Airflow setup complete ===" +echo "DAG: failing_dag ($FAILED failed run(s))" +echo "Verify: curl -u admin:admin $AIRFLOW_URL/api/v1/dags/~/dagRuns?state=failed" diff --git a/tests/fixtures/vigil/setup-jenkins.sh b/tests/fixtures/vigil/setup-jenkins.sh new file mode 100755 index 00000000..f25c1215 --- /dev/null +++ b/tests/fixtures/vigil/setup-jenkins.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# setup-jenkins.sh — Create a failing Jenkins job and trigger a build for e2e vigil testing. +# +# Usage: ./setup-jenkins.sh +# Requires: Jenkins running on localhost:8080 (podman-compose up -d) +# Idempotent: deletes and recreates 'test-pipeline' each run + +set -euo pipefail + +JENKINS_URL="http://localhost:8080" +JOB_NAME="test-pipeline" + +echo "=== Jenkins e2e setup ===" + +# Fetch CSRF crumb with session cookie (Jenkins requires both) +echo " Fetching CSRF crumb..." +COOKIE_JAR=$(mktemp) +CRUMB_JSON=$(curl -sf -c "$COOKIE_JAR" "$JENKINS_URL/crumbIssuer/api/json") +CRUMB=$(echo "$CRUMB_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['crumb'])") +echo " Crumb: $CRUMB" + +# Delete existing job if present (ignore errors) +echo " Cleaning up old job..." +curl -sf -X POST "$JENKINS_URL/job/$JOB_NAME/doDelete" \ + -b "$COOKIE_JAR" \ + -H "Jenkins-Crumb: $CRUMB" > /dev/null 2>&1 || true + +# Create a freestyle job that runs 'exit 1' +echo " Creating $JOB_NAME..." +curl -sf -X POST "$JENKINS_URL/createItem?name=$JOB_NAME" \ + -b "$COOKIE_JAR" \ + -H "Jenkins-Crumb: $CRUMB" \ + -H 'Content-Type: application/xml' \ + --data-binary "echo 'build started'; exit 1" > /dev/null + +# Trigger a build +echo " Triggering build..." +curl -sf -X POST "$JENKINS_URL/job/$JOB_NAME/build" \ + -b "$COOKIE_JAR" \ + -H "Jenkins-Crumb: $CRUMB" > /dev/null + +# Wait for build to complete +sleep 8 + +# Verify the build failed +RESULT=$(curl -sf "$JENKINS_URL/job/$JOB_NAME/lastBuild/api/json" | python3 -c "import sys,json; print(json.load(sys.stdin).get('result','UNKNOWN'))") +echo " Build result: $RESULT" + +if [ "$RESULT" != "FAILURE" ]; then + echo "ERROR: expected FAILURE, got $RESULT" >&2 + exit 1 +fi + +rm -f "$COOKIE_JAR" +echo "=== Jenkins setup complete ===" +echo "Job: $JENKINS_URL/job/$JOB_NAME/" +echo "Last build: #1 (FAILURE)" diff --git a/tests/fixtures/vigil/setup-prefect.sh b/tests/fixtures/vigil/setup-prefect.sh new file mode 100755 index 00000000..12c10b20 --- /dev/null +++ b/tests/fixtures/vigil/setup-prefect.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# setup-prefect.sh — Create a failed Prefect flow run for vigil e2e testing. +# +# Usage: ./setup-prefect.sh +# Requires: Prefect running on localhost:4200 (podman-compose up -d) +# Idempotent: creates a new flow run each invocation + +set -euo pipefail + +PREFECT_URL="http://localhost:4200" +FLOW_NAME="failing-flow" + +echo "=== Prefect e2e setup ===" + +# 1. Create a flow (idempotent — existing flow is reused) +echo " Creating flow..." +FLOW_ID=$(curl -sf -X POST -H 'Content-Type: application/json' "$PREFECT_URL/api/flows/" \ + -d "{\"name\":\"$FLOW_NAME\"}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +echo " Flow ID: $FLOW_ID" + +# 2. Create a flow run in SCHEDULED state +echo " Creating flow run..." +RUN_JSON=$(curl -sf -X POST "$PREFECT_URL/api/flow_runs/" \ + -H 'Content-Type: application/json' \ + -d "{ + \"flow_id\": \"$FLOW_ID\", + \"name\": \"failing-run-$(date +%s)\", + \"state\": {\"type\": \"SCHEDULED\"} + }") +RUN_ID=$(echo "$RUN_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +echo " Run ID: $RUN_ID" + +# 3. Transition to FAILED (set_state returns {state: {type: "FAILED"}, status: "ACCEPT"}) +echo " Setting state to FAILED..." +RESPONSE=$(curl -sf -X POST "$PREFECT_URL/api/flow_runs/$RUN_ID/set_state" \ + -H 'Content-Type: application/json' \ + -d '{"state": {"type": "FAILED", "name": "Failed", "message": "simulated failure for vigil e2e test"}}') +STATUS=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','REJECTED'))" 2>/dev/null || echo "REJECTED") + +if [ "$STATUS" != "ACCEPT" ]; then + echo " ERROR: state transition rejected (status=$STATUS)" >&2 + echo " Response: $RESPONSE" >&2 + exit 1 +fi +echo " State transition accepted" + +# 4. Verify the flow run appears in the FAILED filter +echo " Verifying failed flow runs..." +FAILED_COUNT=$(curl -sf -X POST -H 'Content-Type: application/json' \ + -d '{"flow_runs":{"state":{"type":{"any_":["FAILED","CRASHED"]}}}}' \ + "$PREFECT_URL/api/flow_runs/filter" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))") + +echo "=== Prefect setup complete ===" +echo "Flow: $FLOW_NAME ($FLOW_ID)" +echo "Run: $RUN_ID (FAILED)" +echo "$FAILED_COUNT failed flow run(s) detected by filter" diff --git a/tests/fixtures/vigil/watch-dir/README.txt b/tests/fixtures/vigil/watch-dir/README.txt new file mode 100644 index 00000000..682560e0 --- /dev/null +++ b/tests/fixtures/vigil/watch-dir/README.txt @@ -0,0 +1 @@ +vigil watch-dir ready diff --git a/tests/fixtures/vigil/watch-dir/trigger.txt b/tests/fixtures/vigil/watch-dir/trigger.txt new file mode 100644 index 00000000..e69de29b