diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e345a..6e44b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,112 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.20.0] - 2026-08-27 + +A directory somebody else wrote can add to your session, and you can see exactly +what it added. + +**Capability bundles load, and `/plugin` says what each one brought.** A bundle +is a directory with a `plugin.toml`, named by a `[[plugin]] path = "..."` entry +in a configuration file — a declaration and never a scan, so nothing loads by +being present on disk. One can contribute six kinds of thing at once: skills, +prompt templates, `[[agent]]` definitions, `[[mcp]]` servers, `[[hook]]` tables +and policy layers. `/plugin` lists what loaded and what each bundle contributed, +and under that the row it really exists for: every bundle that was declared and +dropped, carrying io-harness's own sentence for why. That loader has no error +path — a dropped bundle is recorded and otherwise silently absent while every +other one loads, which is how a bundle you believe is running stays gone for a +week. A bundle can also be stopped from that list: the last row under its +contributions removes its `[[plugin]]` entry after a confirmation naming the scope +it will edit. The entry is found by matching the directory across all three scope +files rather than by counting rows on screen, and where no file names it, io says +so and removes nothing. The directory itself is never touched. + +**Which file declared a bundle decides what it may contribute.** A bundle named +in the project-scoped `io.toml` — the file a `git clone` delivers — may +contribute skills, templates, agents and policy, and may not contribute hooks or +MCP servers, because both run a program on this machine. One that tries is +refused **whole**: it contributes nothing, not the half that would have been +safe. The same directory named from `io.local.toml` or the user file loads +completely, so the rule is about which file, not about the bundle. Its policy may +only **deny** as well: a `[policy] defaults` table is refused by name and any rule +whose effect is not `deny` drops the bundle, so the worst a bundle can do to your +boundary is narrow it. An id matches `[a-z0-9][a-z0-9-]{0,31}`, and every agent +name, server id and layer name it contributes is namespaced to `__` +by io-harness — which is the string `/plugin` draws, because it is the one a +refusal, a call and a spawn all use. + +**A bundle's hooks are the one contribution io-cli cannot itemise, and the row +says so rather than being left out.** io-harness's `Hook` type is private and +there is no accessor, so `/plugin` can report that a bundle contributed hooks and +nothing about what they run. An omitted row would read as a bundle with no hooks, +which is the false reading, on the contribution kind that runs programs. + +**`[[hook]]` tables run.** They were parsed and inert before this — a file asking +for every event to be appended to `audit.jsonl` produced an empty file and no +error — and they now run in a session turn and in `io exec` from the same call. A +hook either appends one JSON line per event to a file or runs an argv array, +never a shell string. `on` names the events to observe and empty means all of +them; `at = "before_tool"` puts it in front of a tool call, with `tools` to +filter which calls it sees; the two are mutually exclusive and an `at` hook must +have a `run`. `on_failure` decides what a failure costs: `continue` carries on, +`cancel` ends the turn at the next step boundary and leaves the run resumable, +`refuse` turns that one tool call back. `timeout_ms` is 5000 when absent. + +**A project-scoped file may not declare `[[hook]]`, and that refusal is now +readable.** io-harness refuses the whole configuration rather than dropping the +table, because a hook runs a command on this machine and `io.toml` arrives with a +`git clone`. There is no `Config` to be had and `io` genuinely cannot start — +what changed is the words. io-harness's own sentence names the key, the reason +and the two files that may carry it, and it is printed whole under a line saying +which file was being read. Before this it arrived as a bare error string from a +program that had already exited, against a repository the operator had just +cloned. + +**The fleet has names.** A child is drawn by the address it was spawned under — +the `as` argument, or a derived one like `reviewer#42` — with its roster role +beside it, instead of by a run number nobody chose. A message one agent sent a +named sibling is shown in the tree with its body, which is the case a run number +told you nothing about. And a child that detached rather than being waited for +can be selected and attached to: a parent that stops waiting is not a parent that +stops the work, and that child was a row you could read and could not reach. A +queued child is still a count and still has no address, because io-harness names +a child when it admits one. + +**Documents: twelve tools, and six of them write.** io-cli turns on io-harness's +`documents` feature — `xlsx`, `docx`, `pptx`, `pdf`, `barcode` — so the agent +gains `xlsx_sheets`, `xlsx_read`, `docx_read`, `pptx_read`, `pdf_read` and +`barcode_decode` to read with, and `xlsx_write`, `xlsx_set_cell`, `docx_write`, +`pdf_write`, `pdf_watermark` and `pdf_fill_form` to write with. `xlsx_write` +replaces a file that already exists. Every one of them passes the policy gate, +the approval prompt and the refusal rendering any other read or write passes — +and io-cli cannot take a tool out of io-harness's workspace tool set, which is +the ground `view_image` was disclosed on and the reason the six writers are named +here one by one rather than counted. The reader is chosen by the tool the model +called and not by the file's extension. + +**What the document tools do not do is in the README**, and is worth reading +before pointing one at a file that matters. Word is generate-and-read with no +edit in place, so a read-then-write drops comments, content controls, fields and +vendor extensions; PowerPoint is read-only; PDF text extraction is best-effort +about reading order, and a scanned page comes back with empty text rather than an +error; `xlsx_set_cell` preserves the rest of a workbook in practice rather than +by guarantee, and not for chart-, pivot- or macro-heavy files. There is no OCR +and no barcode generation. + +**`documents` costs 159 transitive crates and pins `image` down a patch.** +`barcode` is `rxing`, and every published `rxing 0.9.x` requires `image` at +exactly `=0.25.8`; this crate asks for `^0.25` and the lockfile stood at 0.25.10, +which cargo cannot reconcile, so the lock is pinned to 0.25.8 — the one version +both requirements accept. A `cargo update` that lifts `image` again will fail to +resolve until `rxing` relaxes. It is written here because an operator otherwise +meets it in a red resolve rather than in a release note. + +**`/plugin` is the twenty-seventh command**, and it is in the **configure** +group beside `/mcp` and `/provider` because it is the third surface of that kind: +something a configuration file declares by name whose effect on the session is +otherwise invisible. Configure goes to seven. + ## [0.19.0] - 2026-08-27 Ask for a thing io can do, in your own words, and have it happen. diff --git a/Cargo.lock b/Cargo.lock index 1f50557..0a9c1a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,59 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -17,6 +64,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -97,6 +153,39 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atoi_simd" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44" +dependencies = [ + "debug_unsafe", + "rustversion", +] + [[package]] name = "atomic" version = "0.6.1" @@ -118,6 +207,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.18.0" @@ -192,6 +304,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -204,6 +322,12 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + [[package]] name = "block-buffer" version = "0.10.4" @@ -213,6 +337,39 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + [[package]] name = "bumpalo" version = "3.20.3" @@ -231,6 +388,12 @@ version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -243,6 +406,23 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "calamine" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa68281b1a76b54a62156474adb06bb380a67e07dd60656e3217152b42183f3" +dependencies = [ + "atoi_simd", + "byteorder", + "codepage", + "encoding_rs", + "fast-float2", + "log", + "quick-xml", + "serde", + "zip", +] + [[package]] name = "castaway" version = "0.2.4" @@ -252,6 +432,24 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cc" version = "1.4.3" @@ -264,6 +462,33 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cff-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5810ca1a2b5870df2aab1c03e11c40c361ba51d6e3e361e56310f1cb3b4e087" + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -299,6 +524,36 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.6.6" @@ -348,6 +603,30 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "codepage-437" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535" +dependencies = [ + "csv", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -384,6 +663,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -409,6 +694,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -442,6 +733,31 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crossterm" version = "0.28.1" @@ -501,6 +817,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -508,7 +833,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ "lab", - "phf", + "phf 0.11.3", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -545,6 +900,43 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + +[[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.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "deltae" version = "0.3.2" @@ -585,17 +977,29 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", ] [[package]] -name = "displaydoc" -version = "0.2.7" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "proc-macro2", + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", "quote", "syn 3.0.3", ] @@ -609,18 +1013,82 @@ dependencies = [ "litrs", ] +[[package]] +name = "docx-rs" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fdf00e8af6d0b3e92d4bbf9b76f773d8b84ea80f310324ad16cbdc2e653e02c" +dependencies = [ + "base64 0.22.1", + "crc32fast", + "image", + "quick-xml", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.20", + "zip", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ecb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -637,6 +1105,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "euclid" version = "0.22.14" @@ -646,6 +1123,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -679,6 +1173,23 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + [[package]] name = "fastrand" version = "2.5.0" @@ -737,6 +1248,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -905,12 +1417,12 @@ dependencies = [ [[package]] name = "gif" -version = "0.14.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" dependencies = [ "color_quant", - "weezl", + "weezl 0.1.12", ] [[package]] @@ -967,6 +1479,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "html5gum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428502d3ec1742c35e015871aef742bc4722a9acfcb616b8ff79b519922e1c36" +dependencies = [ + "jetscii", +] + [[package]] name = "http" version = "1.5.0" @@ -1006,6 +1536,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1200,18 +1739,23 @@ dependencies = [ [[package]] name = "image" -version = "0.25.10" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", + "exr", "gif", "image-webp", "moxcms", "num-traits", "png", + "qoi", + "ravif", + "rayon", + "rgb", "tiff", "zune-core", "zune-jpeg", @@ -1227,6 +1771,36 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imageproc" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image", + "itertools 0.12.1", + "nalgebra", + "num", + "rand 0.8.8", + "rand_distr", + "rayon", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + [[package]] name = "indexmap" version = "2.14.0" @@ -1246,6 +1820,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding 0.3.3", + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + [[package]] name = "instability" version = "0.3.13" @@ -1259,9 +1853,20 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "io-cli" -version = "0.19.0" +version = "0.20.0" dependencies = [ "clap", "crossterm 0.28.1", @@ -1273,7 +1878,7 @@ dependencies = [ "syntect", "tempfile", "tokio", - "toml", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -1283,21 +1888,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28c9b7027129f38c071489894f9e9760b7b027a5f5025d3af81944da258ce9df" dependencies = [ "base64 0.23.1", + "calamine", + "docx-rs", "futures-util", "image", "libc", + "lopdf 0.44.0", + "pdf-extract", + "quick-xml", "regex", "reqwest", "rmcp", "rusqlite", + "rust_xlsxwriter", + "rxing", "serde", "serde_json", "tempfile", "thiserror 2.0.20", "tokio", - "toml", + "toml 1.1.4+spec-1.1.0", "tracing", + "umya-spreadsheet", "windows-sys 0.61.2", + "zip", ] [[package]] @@ -1312,6 +1926,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1327,6 +1950,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jetscii" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + +[[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", +] + +[[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.119", +] + +[[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" @@ -1420,12 +2102,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.16" @@ -1491,6 +2189,72 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lopdf" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c" +dependencies = [ + "aes 0.8.4", + "bitflags 2.13.1", + "cbc 0.1.2", + "ecb 0.1.2", + "encoding_rs", + "flate2", + "getrandom 0.4.3", + "indexmap", + "itoa", + "log", + "md-5 0.10.6", + "nom 8.0.0", + "rand 0.10.2", + "rangemap", + "sha2 0.10.9", + "stringprep", + "thiserror 2.0.20", + "ttf-parser", + "weezl 0.1.12", +] + +[[package]] +name = "lopdf" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2ec995d822e05cabc3f06d196ee43650af3fe4fe38012cacb35e0c3d113b68" +dependencies = [ + "aes 0.9.2", + "bitflags 2.13.1", + "cbc 0.2.1", + "chrono", + "ecb 0.2.1", + "encoding_rs", + "flate2", + "getrandom 0.4.3", + "indexmap", + "itoa", + "jiff", + "log", + "md-5 0.11.0", + "nom 8.0.0", + "rand 0.10.2", + "rangemap", + "rayon", + "sha2 0.11.0", + "stringprep", + "thiserror 2.0.20", + "time", + "weezl 0.2.1", +] + [[package]] name = "lru" version = "0.18.2" @@ -1516,6 +2280,46 @@ dependencies = [ "winapi", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1567,19 +2371,40 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.8.1" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", ] [[package]] -name = "nix" -version = "0.29.0" +name = "nalgebra" +version = "0.32.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.1", "cfg-if", @@ -1610,6 +2435,55 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1627,6 +2501,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1634,6 +2538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1672,6 +2577,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "palette" version = "0.7.7" @@ -1728,6 +2642,29 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pdf-extract" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417e8fdc940f1d5bc62c5f89864c3a2255f74f69aa353c98509213d67df61e73" +dependencies = [ + "adobe-cmap-parser", + "cff-parser", + "encoding_rs", + "euclid 0.20.14", + "log", + "lopdf 0.42.0", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1782,8 +2719,28 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", + "serde", ] [[package]] @@ -1792,8 +2749,8 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -1802,18 +2759,41 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.8", ] +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -1828,6 +2808,24 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1853,12 +2851,33 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + [[package]] name = "portable-atomic" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.6" @@ -1874,6 +2893,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1897,18 +2925,80 @@ dependencies = [ "windows", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "encoding_rs", + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.11" @@ -1993,6 +3083,8 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ + "libc", + "rand_chacha", "rand_core 0.6.4", ] @@ -2007,11 +3099,24 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -2019,6 +3124,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + [[package]] name = "rand_pcg" version = "0.10.2" @@ -2028,6 +3143,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "ratatui" version = "0.30.2" @@ -2053,7 +3174,7 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", @@ -2079,45 +3200,136 @@ dependencies = [ ] [[package]] -name = "ratatui-termina" -version = "0.1.0" +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.12.1", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand 0.8.8", + "rand_chacha", + "simd_helpers", + "system-deps", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ - "instability", - "ratatui-core", - "termina", + "either", + "rayon-core", ] [[package]] -name = "ratatui-termwiz" -version = "0.1.2" +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ - "ratatui-core", - "termwiz", + "crossbeam-deque", + "crossbeam-utils", ] [[package]] -name = "ratatui-widgets" -version = "0.3.2" +name = "reborrow" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" -dependencies = [ - "bitflags 2.13.1", - "hashbrown 0.17.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "serde", - "strum", - "time", - "unicode-segmentation", - "unicode-width", -] +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" [[package]] name = "redox_syscall" @@ -2197,6 +3409,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -2260,6 +3481,15 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust_xlsxwriter" +version = "0.98.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d730a6f64f9620e047b2de2876e61a691fbcaca3aaf436edade9b897c7a27" +dependencies = [ + "zip", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2382,12 +3612,55 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rxing" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6609a7ccb6435312dd3a2bff9924cdbb1c96050510fff30676c3d7b8b0045739" +dependencies = [ + "chrono", + "chrono-tz", + "codepage-437", + "encoding_rs", + "fancy-regex 0.18.0", + "image", + "imageproc", + "num", + "once_cell", + "regex", + "rxing-one-d-proc-derive", + "serde", + "thiserror 2.0.20", + "unicode-segmentation", + "uriparse", + "urlencoding", +] + +[[package]] +name = "rxing-one-d-proc-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee3a551320da932c705835119f5f9a69a144966d18780d35e3b394585c22ce3" +dependencies = [ + "quote", + "syn 3.0.3", +] + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2484,6 +3757,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2501,7 +3783,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2541,6 +3834,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2557,6 +3863,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -2628,6 +3943,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2732,6 +4058,25 @@ dependencies = [ "walkdir", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.27.0" @@ -2765,8 +4110,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", - "nom", - "phf", + "nom 7.1.3", + "phf 0.11.3", "phf_codegen", ] @@ -2803,8 +4148,8 @@ dependencies = [ "ordered-float", "pest", "pest_derive", - "phf", - "sha2", + "phf 0.11.3", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -2861,17 +4206,23 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "tiff" -version = "0.11.3" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" dependencies = [ "fax", "flate2", "half", "quick-error", - "weezl", + "weezl 0.1.12", "zune-jpeg", ] @@ -2888,6 +4239,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2896,6 +4248,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2983,6 +4345,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -2991,11 +4365,20 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -3007,13 +4390,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] @@ -3104,6 +4500,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type1-encoding-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" +dependencies = [ + "pom", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -3116,12 +4533,62 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "umya-spreadsheet" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560ef3816b56fce89975a58a7f271c57f7b3873aac8fba70572f93f3683be405" +dependencies = [ + "aes 0.9.2", + "base64 0.22.1", + "byteorder", + "cbc 0.2.1", + "cfb", + "chrono", + "encoding_rs", + "fancy-regex 0.18.0", + "hmac", + "html5gum", + "imagesize", + "jiff", + "md-5 0.11.0", + "num-traits", + "phf 0.14.0", + "quick-xml", + "rand 0.10.2", + "rgb", + "sha2 0.11.0", + "thousands", + "zip", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -3134,7 +4601,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -3151,6 +4618,16 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + [[package]] name = "url" version = "2.5.8" @@ -3163,6 +4640,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3187,12 +4670,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -3345,6 +4845,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -3363,7 +4869,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -3411,12 +4917,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" dependencies = [ "bitflags 1.3.2", - "euclid", + "euclid 0.22.14", "lazy_static", "serde", "wezterm-dynamic", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3649,6 +5165,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" @@ -3770,23 +5295,64 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" -version = "0.5.3" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] [[package]] name = "zune-jpeg" -version = "0.5.15" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index e5c2bb4..3a71ad9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.19.0" +version = "0.20.0" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness @@ -40,7 +40,30 @@ path = "src/lib.rs" # consequence of enabling it. That is a capability change arriving inside an # interface release and it is stated in the README and the CHANGELOG rather than # left to be discovered. It is governed by the same policy as any other read. -io-harness = { version = "0.69", features = ["media", "browser"] } +# `documents` is on from 0.20.0, and it is the umbrella io-harness already +# declares — `["xlsx", "docx", "pptx", "pdf", "barcode"]`, exactly the five +# formats this release names. One word rather than five, because the harness +# had already grouped them and a list written out here would be a second copy +# to keep true. +# +# **It costs 159 transitive crates and it moves `image` down a patch, and both +# are consequences rather than choices.** `barcode` is `rxing`, and every +# published `rxing 0.9.x` pins `image = "=0.25.8"` exactly. This crate asks for +# `^0.25` and the lockfile stood at 0.25.10, which cargo cannot reconcile — so +# the lock is pinned to 0.25.8, which both requirements accept. A `cargo update` +# that lifts `image` again will fail to resolve until `rxing` relaxes; that is +# the whole of the constraint and it is written here rather than discovered in +# a red CI run. +# +# **The `default-features = false` on `image` below no longer wins, and saying +# so is the point.** `rxing` and `docx-rs` both take `image` with its defaults, +# and cargo unifies features across a graph — so `avif` (`ravif`, an AV1 +# encoder), `exr`, `qoi`, `dds` and `rayon` are compiled into this tree from +# 0.20.0 despite the argument made below for excluding them. That argument is +# still correct about what this *renderer* can be handed; it is simply no longer +# load-bearing on the build. Turning `barcode` back off is what would restore +# it, and `documents` would then have to be spelled out as its other four. +io-harness = { version = "0.69", features = ["media", "browser", "documents"] } # **`tui-textarea` is gone as of 0.18.0, and it is why this pair was frozen.** # The comment that stood here said ratatui stayed at 0.29 and crossterm at 0.28 # because the composer was built out of that widget and the 0.30 line carried diff --git a/README.md b/README.md index 3c49b24..036c6b9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ if this crate ever grows an HTTP client, a TLS stack, a database or a sandbox. - [Install](#install) · [First run](#first-run) · [While it works](#while-it-works) - [Keys](#keys) · [Commands](#commands) · [Configuration](#configuration) -- [The fleet](#the-fleet) · [Pictures](#pictures) · [Background jobs](#background-jobs) +- [Capability bundles](#capability-bundles) · [Hooks](#hooks) · [The fleet](#the-fleet) +- [Pictures](#pictures) · [Documents](#documents) · [Background jobs](#background-jobs) - [Reading it without seeing it](#reading-it-without-seeing-it) · [Headless](#headless) - [What this release is not](#what-this-release-is-not) · [Platform support](#platform-support) · [Stability](#stability) @@ -44,6 +45,7 @@ on one row and the keys and the posture on the next.](docs/screenshot.png) | **Headless** | `io exec` runs one goal to completion with documented exit codes and `--json` | | **Readable without seeing it** | `--plain` animates nothing and commits every state change as text, for a screen reader, a braille display or a log | | **Markdown, rendered** | Headings, bullets, code and emphasis drawn as themselves rather than printed as notation | +| **Documents, read and written** | Spreadsheets, Word, slide decks, PDFs and barcodes through io-harness's own tools — twelve of them, six of which write, every one under the same gate as any other read or write | ## It never takes your terminal @@ -390,6 +392,12 @@ above a row that ranked there for reasons having nothing to do with it. | `/memory` | what io remembers: the instruction files, and the agent's own notes | | `/mcp` | the MCP servers configured, and what this session has seen of each | | `/provider` | the providers configured, in the order a turn tries them | +| `/plugin` | the capability bundles loaded, what each contributed, and the ones that failed | + +`/plugin` is new in 0.20.0 and sits beside those two because it is the third +surface of the same kind: something a configuration file declares by name, whose +effect on the session is otherwise invisible. See [Capability +bundles](#capability-bundles). `/mcp` and `/provider` sit under **configure** from 0.19.0, and it is a correction rather than a promotion: both open with a list, and both go on from @@ -536,6 +544,146 @@ the limit who gains five more would otherwise get no skills at all as their upgrade. io-cli counts first, installs up to the ceiling and stops, and says how many it installed and how many it withheld. +## Capability bundles + +**A bundle is a directory with a `plugin.toml`, and it is in your session because +a file of yours named it.** One `[[plugin]]` entry, and nothing else: + +```toml +[[plugin]] +path = "~/bundles/rust-review" +``` + +That is a declaration and never a scan. There is no directory io walks looking +for bundles, no registry it fetches from, and nothing that loads by being present +on disk — declaring one is the whole of installing one, and deleting the line is +the whole of removing one. + +One directory can hand over six kinds of thing at once: skills, prompt templates, +`[[agent]]` definitions for a fan-out to draw children from, `[[mcp]]` servers, +`[[hook]]` tables, and policy layers. That breadth is why `/plugin` exists. +Every other capability in a session is one you put there — a skill file is yours +or io-cli's, an `[[mcp]]` entry is a line you wrote, a policy layer came from a +posture you chose. A bundle is a directory somebody else wrote that adds names to +four subsystems on one line, and *what did that directory put in my session* is a +question whose only previous answer was to open the manifest. + +**`/plugin` answers it, and it answers the dropped ones too.** One row per bundle +with its id, its root and what it contributed; choosing one opens what it brought, +by name. Under those, one row per bundle that was *declared and did not load*, +carrying io-harness's own sentence whole. That second list is what the surface is +really for: io-harness's plugin loader has no error path, so a bundle with no +manifest, unparseable TOML, an unusable id or a contribution its scope may not +make is dropped, recorded, and otherwise silently absent while every other bundle +loads. A bundle you believe is running can be gone for a week. This is where that +week ends. + +**And a bundle can be stopped from the same list.** The last row under a bundle's +contributions removes its `[[plugin]]` entry, after a confirmation that names the +scope and the entry it will take out. io finds that entry by matching the +directory across all three scope files rather than by counting rows on screen — +the two lists have no relation to the order entries appear in any file, and a row +number read against the wrong list removes a bundle you never mentioned. Where no +file names the directory, io says so and removes nothing. + +The directory itself is never touched. This surface edits a configuration file, +and deleting somebody's work because they stopped loading it is not a thing a list +should do. Declaring a bundle is still a line you write yourself: a path is +something you type more comfortably into your own file than into a picker. + +**Which file declared a bundle decides what it may contribute.** A bundle named +in the project-scoped `io.toml` — the file a `git clone` delivers — may +contribute skills, templates, agents and policy, and may **not** contribute +hooks or MCP servers, because both of those run a program on this machine. A +project-scoped bundle that tries is refused **whole**: it contributes nothing at +all, not the half that would have been safe. Move the `[[plugin]]` line into +`io.local.toml` or into your user file and the same directory loads completely. +The rule is about which file names it, exactly as it is for `[browser]`. + +**A bundle's policy may only narrow.** Its layers may deny and may never allow: a +`[policy] defaults` table in a manifest is refused by name, and a single rule +whose effect is anything but `deny` drops the bundle. So the worst a bundle you +misjudged can do to your permission boundary is take something out of it. + +**A bundle id must match `[a-z0-9][a-z0-9-]{0,31}`**, and every name it +contributes is rewritten by io-harness to `__` at load — an agent's +name, an MCP server's id, a policy layer's name. `/plugin` draws that namespaced +string unchanged rather than a prettier short form, because it is what a refusal +will name, what a tool call will name, and what you will type to spawn the agent. +A shorter name here would be a third spelling of the same thing. + +**Hooks are the one contribution io cannot itemise, and the row says so.** +io-harness applies a bundle's hooks and keeps its `Hook` type private, so there +is no API by which this program can count them or say what any of them runs. +`/plugin` therefore draws a row saying the bundle contributed hooks and that io +cannot say what they do. The alternative was to leave the row out, which reads as +a bundle with no hooks — the one reading that is false, on the contribution kind +that runs programs. + +## Hooks + +**`[[hook]]` tables run from 0.20.0.** They were parsed before this release and +then installed on nothing, so a file asking for every event to be written to +`audit.jsonl` produced an empty file and no error. They now run in a session turn +and in `io exec` alike, from the same call that builds everything else. + +A hook either writes events down or runs a program: + +```toml +[[hook]] +on = [] # the events to observe; empty means every one +append = "audit.jsonl" # one JSON line per event, appended + +[[hook]] +at = "before_tool" # the only `at` there is +tools = ["shell"] # which calls this one sees +run = ["./scripts/gate.sh"] # argv, never a shell string +on_failure = "refuse" +timeout_ms = 5000 # the default +``` + +`on` and `at` are mutually exclusive, because the first is an observer of events +and the second is a gate in front of a tool call. An `at` hook must have a `run`. +Exactly one of `append` and `run`: a hook that did both would be a hook whose +failure meant two things. + +**`on_failure` is where a hook's power actually is.** `continue` lets the turn go +on, which is what an audit hook wants. `cancel` ends the turn at the next step +boundary and **the run stays resumable** — it is a stop, not a crash. `refuse` +turns that single tool call back and leaves the turn running, which makes a +`before_tool` hook a rule of your own standing beside the policy engine's. + +**`run` is an argv array and never a shell string.** Nothing is word-split and +nothing is expanded: the program you named is the program that runs, with the +arguments you wrote. + +**A `run` hook runs on the turn's own critical path, and `timeout_ms` is the only +thing bounding it.** An observer is called synchronously by the run, and the run +shares a task with the loop that reads your keyboard — so while a hook's program +is running, the interface is not repainting and not answering keys. A script that +takes a tenth of a second, on a hook matching every event, costs that tenth of a +second per event. Keep a `run` hook fast, match it to the events you actually +want with `on`, and lower `timeout_ms` from its five-second default if the program +can hang. An `append` hook has none of this cost: it is a line written to a file. + +**A hook that fails is quiet.** io-harness reports a failed hook through a log +this binary installs no subscriber for, so an `append` path that cannot be written +and a `run` program that does not exist both leave the session looking normal — +and a hook with `on_failure = "cancel"` ends the turn without saying which hook +did it. Verify a new hook by checking that it did something: read the file, or +give the program a visible side effect. This is a real limitation of 0.20.0 rather +than a subtlety. + +**A project-scoped file may not declare `[[hook]]` at all.** io-harness refuses +the whole configuration rather than dropping the table — a hook runs a command on +this machine and `io.toml` is the file a `git clone` delivers. There is no +`Config` to be had, so `io` genuinely cannot start, and 0.20.0 does not soften +that. What it changes is the words: io-harness's own sentence, which names the +key, the reason and the two files that may carry it, under a line saying which +file was being read. Before this it arrived as a bare error string from a program +that had already exited, against a repository you had just cloned. Write the +table in `io.local.toml` or in your user file. + ## The fleet An agent can break a task into sub-agents and run them over the same workspace. @@ -560,9 +708,28 @@ tree's remaining budget on the status line beside everything else. A refused spawn says which cap refused it and that the agent carries on with what it has. A report collected from a child lands in the transcript where it arrives. +**From 0.20.0 a child is shown by the name it was spawned under.** io-harness +gives every admitted child an address — the `as` argument the parent chose, or +one derived from the agent it drew, like `reviewer#42` — and that is what the row +carries, with its roster role beside it, instead of a run number nobody picked. A +run id identifies a row in the store; an address is what the parent used to reach +the child, what a message between two of them names, and what you type to attach +to one. + +**A message one agent sent a named sibling is drawn in the tree, with its body.** +Children talking to each other is the case a run number told you nothing about: +one addressed line with the text under it, landing where it happened. + +**A child that detached can be selected and attached to.** A parent that stops +waiting is not a parent that stops the work — a detached child is still running, +and until now it was a row you could read and could not reach. + **A waiting child is a number and not a row**, because until a concurrency slot -frees it has no run of its own to name. A fleet that is queueing and a fleet that -is stuck look identical without that count, which is why it is there. +frees it has no run of its own to name. It has no address either, for the same +reason: io-harness names a child when it admits one, so there is nothing to call +a queued child even now that the admitted ones have names. A fleet that is +queueing and a fleet that is stuck look identical without that count, which is +why it is there. **And from 0.12.0 that is all it costs you.** `Ctrl+C` still ends the turn, at the next point where no child is in flight, and the interface tells you that is what @@ -623,11 +790,16 @@ are refused **by name**, because a refusal that says which format it was is one you can act on. A provider that does not accept images at all is refused at the door rather than after you have typed the prompt. -**The agent can look at images in the workspace from this release**, using -io-harness's own `view_image` tool, which enabling its `media` feature switches -on. It is bounded by the same policy as any other read. When it looks, the same -picture goes into your scrollback at that point in the conversation, so you are -reading what it read rather than a path you would have to open yourself. +**The agent can look at images in the workspace**, using io-harness's own +`view_image` tool, which enabling its `media` feature switches on. It is bounded +by the same policy as any other read. When it looks, the same picture goes into +your scrollback at that point in the conversation, so you are reading what it +read rather than a path you would have to open yourself. + +That is the shape every capability of this kind arrives in, and 0.20.0 adds +twelve more of them: **io-cli cannot take a tool out of io-harness's workspace +tool set**, so a feature this crate turns on is a tool the agent has, and the +only honest thing to do with that is say so. See [Documents](#documents). A picture is drawn from half blocks — `▀` splits a cell into two halves that are each about square — fitted to your terminal's width and bounded in height. On @@ -643,6 +815,50 @@ picture at all — one line naming the file, its format and its size. A half-blo picture is colour carrying the entire meaning, which is the one thing this interface will not do. +## Documents + +**The agent can read and write spreadsheets, Word files, slide decks, PDFs and +barcodes from 0.20.0**, because io-cli turns on io-harness's `documents` feature +— `xlsx`, `docx`, `pptx`, `pdf` and `barcode`. That is twelve tools in its +workspace tool set, and **six of them write**: + +| Format | Reads with | Writes with | +| --- | --- | --- | +| Spreadsheets | `xlsx_sheets`, `xlsx_read` | `xlsx_write`, `xlsx_set_cell` | +| Word | `docx_read` | `docx_write` | +| PowerPoint | `pptx_read` | — | +| PDF | `pdf_read` | `pdf_write`, `pdf_watermark`, `pdf_fill_form` | +| Barcodes | `barcode_decode` | — | + +Every one of them is a read or a write like any other: the same policy gate, the +same approval prompt answered where it was asked, and the same refusal naming the +act, the target, the rule and the layer. **`xlsx_write` replaces a file that +already exists** — under the write gate, so it is proposed to you before it +happens rather than reported afterwards. + +**Which reader runs is decided by the tool the model called, not by the file's +extension.** A `.docx` handed to `pdf_read` is a failed read rather than a guess, +and renaming a file changes nothing about what can be done to it. + +**What they do not do**, because a document tool that half-works is worse than +one that is absent: + +- **Word is generate-and-read, with no edit in place.** A read followed by a + write produces a new document out of the text that came back, so comments, + content controls, fields and vendor extensions that were in the original are + not in the result. It is the right tool for producing a document and the wrong + one for touching up somebody else's contract. +- **PowerPoint is read-only.** There is no `pptx_write`, and the table above is + the whole of it. +- **PDF text extraction is best-effort about reading order**, which is what + extracting text from a page-description format means. **A scanned page comes + back with empty text rather than an error**, because there is nothing in it to + extract — there is no OCR anywhere in this. +- **`xlsx_set_cell` preserves the rest of a workbook in practice rather than by + guarantee.** It is the tool for changing a value in a sheet of data, and not + the tool for a workbook heavy with charts, pivot tables or macros. +- **There is no barcode generation**, only decoding. + ## Background jobs An agent can start something that outlives the step that started it — a dev @@ -826,6 +1042,11 @@ tries them. Reorder it and you have arranged the fallback chain io-harness has supported since its 0.27.0. The twenty-one presets it reaches through one `Compatible` provider are offered by name with the endpoint each resolves to. +**`/plugin`** shows the capability bundles a `[[plugin]]` entry declared: what +each one contributed, by name, and every bundle that was declared and dropped +with io-harness's own reason beside it. See [Capability +bundles](#capability-bundles). + **`/profile`** switches to a named `[profile.]` for the session, and `--profile ` picks one for a single run without writing anything. @@ -908,7 +1129,9 @@ rewrites a permission boundary on disk is the opposite of what that key is for. from the same call.** `[sandbox]` limits, `[run]` budgets, `[run.commit_identity]`, `[[agent]]`, `[web]`, `[memory]`, `[instructions]`, `[[mcp]]`, `[[lsp]]` and `[browser]` are all applied to a turn's contract, in your terminal exactly as in -CI. There is no longer a section of this file that a session reads past. +CI — and from 0.20.0 so are `[[plugin]]` and `[[hook]]`, which are the last two +that reached nothing. There is no longer a section of this file that a session +reads past. The layers run weakest to strongest, and that order is asserted rather than described: io-harness's own defaults, then io-cli's step floor, then everything @@ -961,24 +1184,20 @@ it](#reading-it-without-seeing-it). ## What this release is not -0.14.0 is the release where the configuration file reaches your terminal: every -section of it bounds a session turn as it already bounded `io exec`, `/status` -commits the whole picture into the scrollback, and the ceilings in force are on -the status line beside what has been drawn against them. - -**Four sections of the file are still not applied, and each has a reason.** -`[[hook]]` and capability bundles reach a contract through their own builders and -need a surface that reports what loaded and what was dropped; `[prices]` is not -part of a contract at all and belongs with the release that reads the -provider-call rows; there is no `[verify]` section to apply, and giving a session -verification gates needs its own surface; and `run.templates` is the thirteenth -`[run]` key, reachable only through its own accessor. None of them is a silent -omission any more — this is where they are named. - -**There is no way to change a key from inside the session.** `/status` reads the -state and never writes it, and editing configuration is 0.16.0 — a surface for -changing a key is worth building once changing the key does something, which is -what this release is. +The configuration file has reached your terminal since 0.14.0: every section of +it bounds a session turn as it already bounded `io exec`, `/status` commits the +whole picture into the scrollback, and the ceilings in force are on the status +line beside what has been drawn against them. + +**`[[hook]]` and capability bundles are applied from 0.20.0**, each with the +surface the omission was waiting on: `/plugin` for what a bundle brought and what +was dropped, and io-harness's own refusal sentence for a hook a project file may +not declare. That leaves two sections and one key still unapplied, and each has a +reason. `[prices]` is not part of a contract at all and belongs with the release +that reads the provider-call rows; there is no `[verify]` section to apply, and +giving a session verification gates needs its own surface; and `run.templates` is +the thirteenth `[run]` key, reachable only through its own accessor. None of them +is a silent omission — this is where they are named. **Sixel is still absent**, because encoding it means palette quantisation and another dependency, for terminals that either speak one of the two protocols @@ -1007,6 +1226,12 @@ than the provider would mean reading the live catalogue on every attach, and it would still not close the door the agent opens. **If you work with images, choose a model that accepts them.** +**The twelve document tools cannot be taken out of that tool set either**, and +six of them write. A model that reaches for `docx_write` in a session where you +never meant a document to be written is stopped by the write gate rather than by +the absence of the tool — which is what the gate is for, and why the writers are +named one by one in [Documents](#documents) rather than counted. + **An image the agent was *given* rather than asked for is not shown.** A picture returned by an MCP tool, and a browser screenshot, both become images inside io-harness — but through private plumbing and with no event of any kind, so diff --git a/docs/config.example.toml b/docs/config.example.toml index c16f405..a56fe06 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -98,6 +98,10 @@ net = "deny" # [[agent]] the roster a fan-out draws its children from # [web] provider-executed search and fetch # [memory] how much durable memory a run may keep +# [[plugin]] capability bundles, declared by path. 0.20.0; see the +# block below +# [[hook]] what to do on an event, or in front of a tool call. +# Parsed and inert before 0.20.0; see the block below # # THIS BLOCK USED TO SAY THE OPPOSITE, and the reason it gave went stale a # release before the block did. Through 0.13.1 these sections reached `io exec` @@ -160,9 +164,93 @@ net = "deny" # max_chars = 200000 # the total size of them # max_entry_chars = 4000 # the size of any one of them +# --- Capability bundles (0.20.0) -------------------------------------------- +# +# A bundle is a directory with a `plugin.toml` in it, and it is loaded because +# THIS FILE NAMES IT. There is no directory io walks looking for bundles and no +# registry it fetches from: a `[[plugin]]` entry is the whole of installing one, +# and deleting the entry is the whole of removing one. +# +# One directory can contribute six kinds of thing at once — skills, prompt +# templates, [[agent]] definitions, [[mcp]] servers, [[hook]] tables and policy +# layers — which is why there is a surface for it. `/plugin` lists what loaded, +# what each bundle contributed, and every bundle that was declared and DROPPED +# with io-harness's own reason beside it. That loader has no error path: a +# dropped bundle is recorded and otherwise silently absent while every other one +# loads. +# +# WHICH FILE DECLARES A BUNDLE DECIDES WHAT IT MAY CONTRIBUTE. A bundle named in +# the project-scoped io.toml — the file a `git clone` delivers — may contribute +# skills, templates, agents and policy, and may NOT contribute hooks or MCP +# servers, because both of those run a program on this machine. One that tries is +# refused WHOLE: it contributes nothing at all, not the half that would have been +# safe. Name it from io.local.toml or from this user-scope file and the same +# directory loads completely. The rule is about which file, not about the bundle. +# +# A BUNDLE'S POLICY MAY ONLY DENY. A `[policy] defaults` table in a manifest is +# refused by name, and a single rule whose effect is not `deny` drops the bundle. +# So the worst a bundle can do to the boundary above is narrow it. +# +# An id must match [a-z0-9][a-z0-9-]{0,31}, and every agent name, MCP server id +# and policy layer name a bundle contributes is rewritten to `__` +# by io-harness at load. That is the name a refusal, a call and a spawn will all +# use, and the name `/plugin` draws. +# +# io-cli CANNOT LIST A BUNDLE'S HOOKS. io-harness's `Hook` type is private and +# there is no accessor, so `/plugin` reports that a bundle contributed hooks and +# nothing about how many there are or what they run. +# +# [[plugin]] +# path = "~/bundles/rust-review" + +# --- Hooks (0.20.0) --------------------------------------------------------- +# +# What to do when something happens. PARSED AND INERT BEFORE 0.20.0 — a table +# here was read, accepted and then installed on nothing, so a file asking for +# every event to be written to a log produced an empty file and no error. From +# 0.20.0 hooks run in a session turn and in an `io exec` run alike. +# +# A hook either writes events down or runs a program, and exactly one of `append` +# and `run` says which — a hook that did both would be a hook whose failure meant +# two things. `on` and `at` are mutually exclusive: `on` observes events, `at` +# gates a tool call, and an `at` hook must have a `run`. +# +# on the event names to observe. EMPTY MEANS EVERY EVENT +# at "before_tool", which is the only one there is +# tools for an `at` hook, which tool calls it sees +# append a file to append one JSON line per event to +# run argv, NEVER a shell string: nothing is word-split or expanded +# on_failure continue | cancel | refuse +# timeout_ms 5000 when absent +# +# `on_failure` is where a hook's power actually is. `continue` lets the turn go +# on, which is what an audit hook wants. `cancel` ends the turn at the next step +# boundary and the run STAYS RESUMABLE — it is a stop, not a crash. `refuse` +# turns that one tool call back and leaves the turn running, which makes a +# `before_tool` hook a rule of your own standing beside the policy engine's. +# +# A PROJECT-SCOPED FILE MAY NOT DECLARE [[hook]] AT ALL, and io-harness refuses +# the WHOLE configuration rather than dropping the table — a hook runs a command +# on this machine, and io.toml is the file a `git clone` delivers. There is no +# Config to be had, so io genuinely cannot start against such a file; what 0.20.0 +# changed is that it prints io-harness's own sentence, which names the key, the +# reason and the two files that may carry it, instead of a bare error string. +# Write the table here or in io.local.toml. +# +# [[hook]] +# on = [] # every event +# append = "audit.jsonl" +# +# [[hook]] +# at = "before_tool" +# tools = ["shell"] +# run = ["./scripts/gate.sh"] +# on_failure = "refuse" +# timeout_ms = 5000 + # --- io-cli's own section --------------------------------------------------- # -# Six keys and five tables. io-harness does not validate any of it, so an +# Eight keys and five tables. io-harness does not validate any of it, so an # unrecognised VALUE reads as the default rather than stopping a session. A # table io-harness cannot parse at all is different: the session starts on the # defaults and says so, carrying the harness's own message. @@ -201,14 +289,13 @@ plain = false # # How many steps one turn may take. # -# DEPRECATED IN 0.14.0, STILL WINNING, AND REMOVED IN 0.16.0. It exists because -# the flat turn once had no way to raise io-harness's own cap of twelve, and -# `[run] max_steps` above now does the same job from a table io-harness documents -# itself. Until 0.16.0 this key keeps working and keeps beating `[run] max_steps` -# — it is applied last, over everything the harness's own table said — and a file -# that carries it is told so once at session start, naming where the number -# moves to. A file with neither key gets io-cli's own floor of a thousand, which -# is not a budget you set and therefore draws no field on the status line. +# DEPRECATED IN 0.14.0 AND REMOVED IN 0.16.0. It existed because the flat turn +# once had no way to raise io-harness's own cap of twelve, and `[run] max_steps` +# above does the same job from a table io-harness documents itself. It is no +# longer read at all: a file still carrying it is told so once at session start, +# with the steps it asks for named and the key they move to. A file with neither +# key gets io-cli's own floor of a thousand, which is not a budget you set and +# therefore draws no field on the status line. # # max_steps = 40 diff --git a/src/app.rs b/src/app.rs index db475ad..c963666 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,6 +93,21 @@ pub enum Command { /// A command rather than something this type does, because the transcript /// lives in the harness's store and the store belongs to the driver. Transcript, + /// Watch a child that is already running, by the run id the fleet is holding. + /// + /// **A command for exactly the reason [`Command::Transcript`] is one**: what + /// this asks for lives in the harness's store, and the store belongs to the + /// driver. `io_harness::Attach` reads the `run_events` table over a second + /// connection, and nothing in this type has ever held one. + /// + /// Asked for on a **detached** child, which is the case that means anything: a + /// detached child is still running and its parent has merely stopped waiting + /// for it, so it is the one with events still to come and nobody watching + /// them. Attaching to a child that is still being waited for is legal and + /// io-harness does not care, but its events are already arriving on the + /// stream this interface is drawing, so it would be a second copy of what is + /// already on screen. + Attach(i64), /// The first `Esc` at an empty prompt: say what undoing the last turn would /// undo, and wait for the second. /// @@ -614,6 +629,23 @@ impl App { // wants of the row above the prompt is how long THIS turn has been going // and what it is costing — not how long the terminal has been open. self.status.start_run(); + // **The fleet belongs to one turn, and it is cleared here rather than + // when a turn ends.** `Fleet::forget` was reachable only from `/resume`, + // `/fork`, a rewind and `/clear`, so a turn that fanned out left its + // children in the model for the rest of the conversation. 0.19.0 could + // afford that — the rows were stale and that was all. 0.20.0 cannot: the + // model now also holds mail, and `note_fleet`'s cheap early return is + // `Fleet::is_empty`, so a single fan-out anywhere in a session would (a) + // draw the *previous* turn's agents and their messages as though they were + // this turn's, and (b) put a `run_root` and a `tree_addresses` query on + // every step of every ordinary turn afterwards — the exact cost that guard + // exists to avoid. + // + // **At the start of a turn and not at the end of one**, so a fleet stays + // readable after the turn that produced it. An operator whose agents have + // just finished can still open the pane and see what happened; it is the + // *next* prompt that means those rows are no longer about anything. + self.fleet.forget(); self.quits = 0; self.announce(); } @@ -970,6 +1002,30 @@ impl App { self.pending.extend(lines); } + /// Take an event from a run this session is **watching** rather than driving. + /// + /// **The same lines as [`App::event`] and none of the bookkeeping**, and the + /// difference is the whole reason this exists. `App::event` also folds the + /// event into `status_from` and into the fleet — both of which describe *this + /// conversation*. A detached child is somebody else's run: its tokens are not + /// this session's spend, its provider is not the one this session is talking + /// to, its `Fleet` tier is not this turn's fan-out shape, and its own children + /// are not this turn's children. + /// + /// Routing a watched run through `App::event` therefore corrupts the footer + /// permanently — `Status::start_run` resets the clock and the run's tokens and + /// nothing else, so a session total inflated by a watched child never comes + /// back down — and it grafts that child's tree into the pane the operator + /// opened to look at it. Neither is recoverable without ending the session, + /// and neither is visible as a bug: the numbers are simply wrong afterwards. + /// + /// So a watched event draws and does nothing else. What the operator asked for + /// is to see the run; they did not ask for it to be counted as theirs. + pub fn watched(&mut self, event: &RunEvent, at: Duration) { + let lines = self.events.event(event, at); + self.pending.extend(lines); + } + /// What a step changed, as diffs. /// /// Handed in by the driver rather than read here. The store belongs to the @@ -1379,6 +1435,41 @@ impl App { self.fleet.move_by(1); return Command::None; } + // **`Enter` on a detached child, at an empty prompt, and nothing + // on any other row.** The pane has had a selection since 0.8.0 + // that drove only the highlight; this is what it was for. A + // detached child is the one an operator can usefully go and look + // at — it is still running and nothing is watching it — so it is + // the one row where `Enter` means something. + // + // **`self.composer.is_empty()` is the whole guard, and leaving it + // out is a real defect this arm shipped without for one adversarial + // review.** This pane is drawn *over the prompt*, not in front of + // the keyboard — the module comment above says so — and the queue + // surface below gets it right for exactly this reason. Without the + // guard, every `Enter` while the pane is open returns here: a line + // typed to be queued behind a running turn is silently dropped, and + // `Shift+Enter` never reaches the composer, so a multi-line prompt + // cannot even be written. Both are invisible — no message, no + // change on screen — and the pane is open precisely when a turn is + // running, which is when queueing a line is most likely. + // + // With text in the composer `Enter` goes on meaning what it means + // everywhere else, which is *queue this*. + // **`modifiers.is_empty()` as well, because a bare `Enter` and a + // `Shift+Enter` are different keys and only the first is this + // one.** Guarding on the empty composer alone still swallowed the + // newline at an empty prompt: an operator opening a multi-line + // prompt with `Shift+Enter` while watching a fan-out got nothing, + // which is the same defect one keystroke smaller. + KeyCode::Enter if self.composer.is_empty() && key.modifiers.is_empty() => { + if let Some(child) = self.fleet.selected_child() { + if child.state == crate::fleet::State::Detached { + return Command::Attach(child.run_id); + } + } + return Command::None; + } // `armed` and not `self.armed`: this function took the arming out // a few lines above, so the field is always `None` here and the // guard was always true. Pre-existing, and found while writing the diff --git a/src/commands.rs b/src/commands.rs index bd7f5f9..809f864 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -193,6 +193,23 @@ pub const COMMANDS: &[(&str, &str)] = &[ "/provider", "the providers configured, in the order a turn tries them", ), + // Beside `/mcp` and `/provider` because it is the third of the same kind: a + // surface that reads a declaration out of the configuration file and can + // take one back out of it. A bundle is the widest of the three — it can hand + // over skills, templates, agents, servers, hooks and policy in one directory — + // which is the argument for it being visible at all, and the argument for it + // being under "configure" rather than "inspect". + // + // **What it does not do is add one, and the description says "loaded" rather + // than implying otherwise.** Declaring a bundle means naming a directory, + // which is a path an operator types far more comfortably into their own file + // than into a picker; removing one means finding the right entry among three + // scope files, which is exactly the part a surface can do better than a + // person. So this does the second and not the first. + ( + "/plugin", + "the capability bundles loaded, what each contributed, and the ones that failed", + ), ( "/profile", "switch to a named profile from the configuration, for this session", @@ -324,6 +341,7 @@ pub const GROUPS: &[(Group, &[&str])] = &[ "/memory", "/mcp", "/provider", + "/plugin", ], ), ]; @@ -856,6 +874,23 @@ pub enum Action { Provider, /// List the named profiles, and switch to one for this session. Profile, + /// List the capability bundles a `[[plugin]]` entry declared: what each one + /// contributed, and what each dropped bundle failed on. + /// + /// **What can honestly be shown is bounded by what io-harness makes public, + /// and the surface says so rather than implying more.** A bundle's agents, + /// MCP servers and policy layers are all readable and are listed by the names + /// io-harness namespaced them to. Its **hooks are not**: there is no + /// `Plugin::hooks()` and `Hook` is `pub(crate)`, so the word `hooks` from + /// `Plugin::contributions()` is the entire signal available — the bundle + /// declared some, and io-cli cannot say how many or what they run. + /// + /// A dropped bundle carries io-harness's own sentence, re-worded by nobody. + /// That matters most for the one an operator will actually hit: a bundle + /// declared in the project file that contributes `[[hook]]` or `[[mcp]]` is + /// refused **whole**, and the sentence is what names the two files it could + /// move to instead. + Plugin, } /// What `/copy` was asked for. @@ -1434,6 +1469,10 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { "skills" => Action::Skills, "provider" | "providers" => Action::Provider, "profile" | "profiles" => Action::Profile, + // `/plugins` is admitted for the same reason `/servers` and `/providers` + // are: the thing being listed is plural, so the plural is what a hand + // reaches for, and refusing it teaches nothing. + "plugin" | "plugins" => Action::Plugin, // **An alias earns no row of its own.** `/usage` is what an operator // coming from another agent types for "what is this costing me", and the // answer is `/status` — which already commits the spend, the budgets and diff --git a/src/configure.rs b/src/configure.rs index 7681177..c4e68b1 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -251,6 +251,32 @@ pub fn scope_path(root: &std::path::Path, scope: Scope) -> Option { } } +/// What to print when the configuration cannot be read at all. +/// +/// **The whole of io-harness's sentence, and one line of io-cli's own.** A +/// `Config::discover` that fails is not always a broken file: since io-harness +/// refuses a project-scoped `[[hook]]` outright — a hook runs a command, and +/// `io.toml` is the file a `git clone` delivers — the commonest way for a +/// perfectly well-formed repository to stop io from starting is a table somebody +/// added in good faith to the wrong one of three files. +/// +/// io-harness's own message names the key, says why, and names the two files that +/// may carry it. Nothing io-cli could write would be better, so it is passed +/// through whole. What io-cli adds is the one thing the harness cannot know: which +/// directory was being read, because an operator who ran `io` in the wrong place +/// is looking at a message about a file they have never opened. +/// +/// **This lives here and not in `main.rs`.** Nothing under `tests/` links the +/// binary, so a sentence composed in that file is one no test drives and no +/// sabotage can make fail — the same reasoning that put plain-mode resolution in +/// the library. +pub fn refusal(root: &std::path::Path, error: &io_harness::Error) -> String { + format!( + "the configuration in {} could not be read:\n{error}", + root.display() + ) +} + /// Write one change into the scope the operator picked, and prove it landed. /// /// **The write is verified by io-harness reading it back, and rolled back when it diff --git a/src/contract.rs b/src/contract.rs index 81e1bcf..e859417 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -203,6 +203,10 @@ refer to earlier output by where it is on the screen — it has scrolled."; /// `[app.io-cli]` is the fifth and strongest layer, and it belongs to [`session`] /// alone: it is io-cli's own table and `io exec` does not read it. pub fn configured(text: impl Into, root: PathBuf, config: &Config) -> TaskContract { + // Kept before `root` is moved into the contract: both the plugin hook merge + // and nothing else needs it, and cloning a `PathBuf` once per turn is not a + // cost worth threading an argument to avoid. + let dir = root.clone(); let contract = config.apply_to(TaskContract::workspace(text, root).with_max_steps(MAX_STEPS)); let contract = match config.sandbox() { Some(sandbox) => contract.with_contained_exec(sandbox), @@ -217,12 +221,89 @@ pub fn configured(text: impl Into, root: PathBuf, config: &Config) -> Ta // would bound a terminal and leave CI running with the defaults, which is // the 0.14.0 asymmetry this product deleted once already. let contract = crate::settings::ceilings(config).apply(contract); + // **The bundles, applied here and in no other function, which is what makes + // one call cover both arms.** `Config::plugins()` re-reads every directory a + // `[[plugin]]` entry names; `Plugins::apply_to` merges their `[[agent]]` + // definitions into the roster, extends `[[mcp]]` rather than assigning over + // it, and sets `contract.plugins` — which is the field + // `TaskContract::discover_skills` reads at run start to fold a bundle's + // skills in, and the field `emit_plugins` iterates at step 0 to report what + // loaded. Applied *after* `config.apply_to` on purpose: the merge has to see + // the operator's own roster to merge into it. + // + // Nothing here can fail. A bundle that will not load is not an error, it is + // a row in `Plugins::dropped()` — which is what stops one bad directory in a + // shared configuration file from costing a whole team their session, and + // which is why `/plugin` is the surface that reads it rather than this + // function returning a `Result`. + let plugins = config.plugins(); + let contract = plugins.apply_to(contract); + // **The hooks, and the `is_empty` guard is not an optimisation.** io-harness + // disables read speculation on any run carrying a `Hooks` at all — even one + // holding no hooks — so attaching it unconditionally would quietly make every + // session slower for the overwhelming majority of operators who have never + // written a hook, with nothing failing and nothing on screen to connect the + // loss to this release. + // + // `with_tool_hooks` is the *lifecycle* half — the `at = "before_tool"` tables + // that can turn a tool call back. The `on = [...]` event tables arrive by a + // different road entirely: the `Hooks` value is also an `Observer`, and the + // driver puts it in the fan-out beside the interface's own. Both installs, or + // one half of the file is accepted and silently never runs. + // + // **And not at all where the caller has no root, which is not a nicety.** + // `io_harness::Hooks::new` **creates every `append` path it is given, empty, + // as it is built** — so building one against `PathBuf::new()` resolves a + // relative `append = "audit.jsonl"` against the *process working directory* + // and leaves a stray empty file wherever `io` happened to be launched from. + // [`server_notices`] calls this function with exactly that empty root, at + // startup, purely to read the merged `[[mcp]]` and `[[lsp]]` lists back off a + // throwaway contract — so before 0.20.0 the empty root cost nothing and now it + // would cost a file in the operator's home. A contract with no root cannot run + // a turn, so it has no use for hooks either. + let hooks = if dir.as_os_str().is_empty() { + None + } else { + let hooks = plugins.apply_to_hooks(config.hooks(), &dir); + (!hooks.is_empty()).then_some(hooks) + }; + let contract = match hooks { + Some(hooks) => contract.with_tool_hooks(std::sync::Arc::new(hooks)), + None => contract, + }; // `[run] skills` has had its say, and `io exec` reads no other key that can // name one — so for the headless arm this is already the point after every // key. [`session`] calls this again once `[app.io-cli]` has had its own. resolve_skills(contract) } +/// The hooks a run should be observed by, for the caller that installs the +/// fan-out. +/// +/// **The same value [`configured`] puts on the contract, built the same way and +/// deliberately built twice rather than threaded.** A `Hooks` is cheap — it is a +/// parsed `Vec` and a directory — and the two installs happen at different +/// points in different functions with different lifetimes: the lifecycle half +/// goes onto the contract behind an `Arc` before the turn, the event half is +/// borrowed by the fan-out for exactly the turn's duration. Returning one value +/// for both would mean handing an `Arc` to a `&dyn Observer` site and keeping the +/// contract's copy alive across a `/config` reload that rebuilt the `Config` it +/// came from. +/// +/// `None` where the file declares no hook, so the caller can leave the fan-out at +/// one observer and keep read speculation — the same guard [`configured`] makes, +/// made once here so the two cannot drift apart. +pub fn hooks(config: &Config, root: &std::path::Path) -> Option { + // The same empty-root guard [`configured`] makes, and for the same reason: + // building a `Hooks` creates its `append` files, so a rootless caller would + // leave them in the process working directory. + if root.as_os_str().is_empty() { + return None; + } + let hooks = config.plugins().apply_to_hooks(config.hooks(), root); + (!hooks.is_empty()).then_some(hooks) +} + /// The skills directory this session will really hand the agent, for the one /// surface that has to know it before a turn exists. /// diff --git a/src/exec.rs b/src/exec.rs index cea4d12..5d150ee 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -471,6 +471,31 @@ impl WithProvider for Headless { let json = Ndjson::new(std::io::stdout()); let observer: &dyn Observer = if self.args.json { &json } else { &Ignore }; + // **The same composition the session arm builds, and it is here for the + // reason the asymmetry this product deleted in 0.14.0 was a defect.** A + // hook is configuration, and configuration that reaches a terminal and + // not CI is worse than configuration that reaches neither: an audit log + // set up for unattended runs would record nothing in exactly the place + // nobody is watching a screen to notice. So `[[hook]]` runs under + // `io exec` on the same terms. + // + // `Broadcast` is here too, and for `io exec` it is the more useful half: + // a headless run is the one somebody else's process is most likely to + // want to attach to. + let hooks = crate::contract::hooks(&self.config, self.session.root()); + let mut observers: Vec<&dyn Observer> = vec![observer]; + if let Some(hooks) = &hooks { + observers.push(hooks); + } + let fanout = crate::fanout::Fanout::new(observers); + let durable = + crate::settings::store_path().and_then(|path| io_harness::Store::open(&path).ok()); + let broadcast = durable.map(|store| io_harness::Broadcast::new(store, &fanout)); + let watcher: &dyn Observer = match &broadcast { + Some(broadcast) => broadcast, + None => &fanout, + }; + let result = turn( &provider, &self.store, @@ -479,7 +504,7 @@ impl WithProvider for Headless { &self.policy, self.args.goal.clone(), self.args.sandbox.map(crate::cli::Sandbox::mode), - observer, + watcher, ) .await?; diff --git a/src/fanout.rs b/src/fanout.rs new file mode 100644 index 0000000..b61d9af --- /dev/null +++ b/src/fanout.rs @@ -0,0 +1,176 @@ +//! One `&dyn Observer` for a turn that has more than one watcher. +//! +//! io-harness takes exactly one observer per run, and 0.69.0 ships nothing that +//! combines two: the whole crate declares three implementations — `Hooks`, +//! `Ignore`, and `Broadcast`, which is a store-writing decorator over one inner +//! observer, not a tee. Until 0.20.0 that was enough, because io-cli had one +//! watcher: [`Bridge`](crate::bridge::Bridge), feeding the screen. Now the +//! operator's `[[hook]]` tables run as well, and those are driven by io-harness's +//! own `Hooks`, which is itself an `Observer`. Two observers, one slot. **The +//! combinator has to live here, because the crate that owns the trait does not +//! provide one** — not because io-cli wants its own abstraction over observing. +//! +//! # Cancel wins, whatever the order +//! +//! [`Flow`] folds one way: any `Cancel` makes the whole fan-out `Cancel`. A hook +//! that asked to stop the turn must not be overruled by whichever observer +//! happened to be registered ahead of it, and a watcher that returns `Continue` +//! — which is what a watcher always returns — must never be able to veto a stop. +//! Registration order is a call-site detail; whether the turn ends is not. So the +//! fold is a logical OR over `Cancel`, which is commutative, and the order the +//! observers were passed in cannot change the answer. +//! +//! # Every observer is called, always +//! +//! The loop never short-circuits on the first `Cancel`. An observer that was +//! registered for an event gets told about it even when an earlier one has +//! already asked to stop, because observers have side effects: a `[[hook]]` that +//! appends to an audit log has to record *the event that cancelled the turn*, +//! which is exactly the event a short-circuiting fold would hide from it. Cheap +//! to get right once here; impossible to debug later from a log with a hole in it. +//! +//! # What it costs +//! +//! [`Observer::event`] is synchronous and runs on the run's own task, in order, +//! for every event — so this loop is on the run's critical path, and so is every +//! observer in it. A slow observer slows the run; **a panic in one takes the run's +//! future with it and leaves the run's row `running`**, with no outcome ever +//! written. The fan-out adds nothing but a slice walk; the observers inside it +//! are where that cost is spent, and each is responsible for its own. + +use io_harness::{Flow, Observer, RunEvent}; + +/// Several observers behind the single `&dyn Observer` a turn accepts. +/// +/// Borrows rather than owns, to match the entry points: `turn_*_steered` takes +/// `&dyn Observer` for the length of one turn, so the observers already outlive +/// the call and there is nothing for this to own. +pub struct Fanout<'a> { + observers: Vec<&'a dyn Observer>, +} + +impl<'a> Fanout<'a> { + /// Fan every event out to `observers`, in the given order. + /// + /// Order decides who is called first and nothing else — see the module docs. + /// An empty fan-out is legal and behaves as `Ignore`. + pub fn new(observers: Vec<&'a dyn Observer>) -> Self { + Self { observers } + } +} + +impl Observer for Fanout<'_> { + fn event(&self, event: &RunEvent) -> Flow { + // Deliberately not `any(|o| o.event(event).is_cancel())`: that + // short-circuits, and an observer past the first `Cancel` would never + // see the event that stopped the turn. + let mut flow = Flow::Continue; + for observer in &self.observers { + if observer.event(event).is_cancel() { + flow = Flow::Cancel; + } + } + flow + } +} + +#[cfg(test)] +mod tests { + use super::*; + use io_harness::EventKind; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Counts what it was told and answers with a fixed [`Flow`]. + struct Spy { + seen: AtomicUsize, + answer: Flow, + } + + impl Spy { + fn new(answer: Flow) -> Self { + Self { + seen: AtomicUsize::new(0), + answer, + } + } + + fn seen(&self) -> usize { + self.seen.load(Ordering::Relaxed) + } + } + + impl Observer for Spy { + fn event(&self, _event: &RunEvent) -> Flow { + self.seen.fetch_add(1, Ordering::Relaxed); + self.answer + } + } + + fn an_event() -> RunEvent { + RunEvent::new(1, 1, EventKind::Stalled) + } + + #[test] + fn every_observer_is_told_even_after_one_cancels() { + let first = Spy::new(Flow::Continue); + let stopper = Spy::new(Flow::Cancel); + let last = Spy::new(Flow::Continue); + + let flow = Fanout::new(vec![&first, &stopper, &last]).event(&an_event()); + + assert_eq!(flow, Flow::Cancel); + // The one that matters: an audit hook registered after the hook that + // stopped the turn still has to record the event that stopped it. + assert_eq!(last.seen(), 1, "the fold short-circuited on Cancel"); + assert_eq!(first.seen(), 1); + assert_eq!(stopper.seen(), 1); + } + + #[test] + fn cancel_wins_from_either_end() { + for cancel_first in [true, false] { + let canceller = Spy::new(Flow::Cancel); + let watcher = Spy::new(Flow::Continue); + let order: Vec<&dyn Observer> = if cancel_first { + vec![&canceller, &watcher] + } else { + vec![&watcher, &canceller] + }; + + let flow = Fanout::new(order).event(&an_event()); + + assert_eq!( + flow, + Flow::Cancel, + "registration order decided the turn (cancel_first={cancel_first})" + ); + assert_eq!(canceller.seen(), 1); + assert_eq!(watcher.seen(), 1); + } + } + + #[test] + fn all_continue_is_continue() { + let a = Spy::new(Flow::Continue); + let b = Spy::new(Flow::Continue); + + assert_eq!( + Fanout::new(vec![&a, &b]).event(&an_event()), + Flow::Continue, + "a fan-out must not invent a cancel nobody asked for" + ); + } + + #[test] + fn an_empty_fanout_watches_nothing() { + assert_eq!(Fanout::new(Vec::new()).event(&an_event()), Flow::Continue); + } + + #[test] + fn the_fanout_is_send_and_sync() { + // `Observer: Send + Sync`, so a type that is not cannot be one — and this + // fails at compile time, before any assertion runs. + fn require() {} + require::>(); + } +} diff --git a/src/fleet.rs b/src/fleet.rs index 6559b0c..1159409 100644 --- a/src/fleet.rs +++ b/src/fleet.rs @@ -1,9 +1,26 @@ //! The fleet: what a decomposed task looks like while it is running. //! -//! **A model over the event stream, and nothing else.** io-harness reports a -//! tree as five kinds of fact, each on the run it belongs to, and this assembles -//! them back into a shape a person can read. It owns no state io-harness does not -//! report, reads no store, and decides nothing about the run. +//! **A model over the event stream, and over what somebody else has already read +//! for it.** io-harness reports a tree as five kinds of fact, each on the run it +//! belongs to, and this assembles them back into a shape a person can read. It +//! owns no state io-harness does not report and decides nothing about the run. +//! +//! **It still reads no store; it now draws what one said.** Two of the things on +//! screen are simply not on the event stream. `EventKind::Spawned` carries a run +//! id and a goal and no name at all, so a child's *address* — the instance name it +//! is reachable at, `as` in `spawn_agent` or a derived `reviewer#42` — can only +//! come from `Store::tree_addresses`; and a message one agent sent a named sibling +//! is never emitted as an event, so it can only come from `Store::messages_for`. +//! Both arrive here as **arguments** ([`Fleet::name`], [`Fleet::traffic`]). This +//! module opens no connection, prepares no statement and holds no cursor: whoever +//! calls it owns the read, its schedule and its failure. That is what keeps every +//! row here testable without a database, and what keeps a disk read on main.rs's +//! clock rather than on the frame's. +//! +//! **`messages_for`, never `read_messages`.** The second stamps `read_at` and *is* +//! the delivery — a view that called it would consume a sibling's mail in order to +//! draw it, and the agent the message was addressed to would never see it. +//! Showing traffic must not be the same act as receiving it. //! //! Three properties of that stream shape everything here. //! @@ -19,7 +36,10 @@ //! frees and a run id exists. So a waiting child has no id, no goal and nothing to //! draw a row from — inventing a placeholder for one would put a row on screen for //! an agent that does not exist yet, which is the fabrication F4's sabotage arm -//! adds on purpose. +//! adds on purpose. **It has no address either**, and cannot: `queued_agents` +//! returns `(tier, goal)`, and an address is derived from a run id that has not +//! been allocated. That limit is real and stays — a count is the whole truth about +//! a child nothing has started. //! //! **Tier counts are replaced, never accumulated.** Each `Fleet` carries that //! tier's whole shape — working, queued and done as they now stand — including @@ -31,7 +51,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; -use io_harness::{EventKind, RunEvent}; +use io_harness::{AgentMessage, Agents, EventKind, RunEvent}; use crate::glyphs::Glyphs; use crate::picker::fit; @@ -59,11 +79,43 @@ impl State { } } +/// The character io-harness derives an unnamed child's address with: +/// `#`. +/// +/// Spelled here because the harness keeps its own `DERIVED_MARK` private, and +/// there is exactly one thing this is used for: splitting a derived address back +/// into the role it was derived from. **A copy of a constant is a thing that can +/// disagree**, so it is used only where being wrong is harmless — a mark that +/// changed would cost a role label, never a wrong address: the address is always +/// drawn whole, exactly as the store spelled it. +const DERIVED_MARK: char = '#'; + /// One admitted child. #[derive(Debug, Clone)] pub struct Child { /// Its own run id, which every event of its own carries. + /// + /// Kept even though a named child no longer draws by it: it is what every + /// event is keyed on, and what [`Fleet::name`] matches an address against. pub run_id: i64, + /// Where it is reachable — the instance name it was spawned under, or a + /// derived `reviewer#42` — or `None` until somebody has read one for it. + /// + /// **Not the role.** Several children can be the same kind of agent, and an + /// operator attaching to one, or an agent addressing mail to one, means a + /// particular one. This is the only field on a child that identifies *which*. + /// + /// `None` is honest and common: it is a child announced by an event whose + /// address has not been read back yet, and it stays `None` for as long as + /// that is true — see [`Fleet::name`]. + pub address: Option, + /// The `[[agent]]` definition it was spawned from, where that is knowable. + /// + /// Answers "what kind of agent is this", which the address deliberately does + /// not. `None` where the roster has nothing to say — which is not a defect, + /// see [`Fleet::name`] for the two shapes that resolve and the one that + /// cannot. + pub role: Option, /// Its nesting level: one below the event that announced it. pub depth: u32, /// What it was asked to do. @@ -77,6 +129,77 @@ pub struct Child { pub drawn: u64, } +impl Child { + /// What identifies this row to a person, role included where it is known. + /// + /// **The address, and `run ` only where there is none.** A run id is a + /// database key: it is unique, it is stable, and it is the one thing about an + /// agent an operator cannot use — they cannot address mail to it, and it does + /// not tell them which of three reviewers this is. The fallback is not a + /// lesser spelling of the same thing, it is an admission that nothing has told + /// us the name yet, and it stays visible rather than being hidden behind a + /// placeholder for the same reason a queued child gets no row at all. + /// + /// The role is drawn beside the address only when it is not the address, so a + /// child a parent named after its own role reads `scout` and not + /// `scout (scout)`. + fn label(&self) -> String { + let Some(address) = &self.address else { + return format!("run {}", self.run_id); + }; + match &self.role { + Some(role) if role != address => format!("{address} ({role})"), + _ => address.clone(), + } + } +} + +/// One message an agent sent a named sibling, as this view holds it. +/// +/// An owned four-field row rather than an `io_harness::AgentMessage`, for two +/// reasons. `AgentMessage` is `#[non_exhaustive]`, so a test in this crate could +/// not build one to assert a row against — a view type nothing outside a database +/// can construct is a view type nothing can test. And it is keyed by run id, +/// while everything on this screen is now keyed by address: the recipient's +/// address is not on the message at all (only `to_run_id` is), so somebody has to +/// resolve it, and doing that once at the boundary beats doing it per frame. +/// +/// `id`, `sent_at` and `read_at` are dropped deliberately. The order rows arrive +/// in is the order they are drawn in, a wall-clock stamp would spend columns the +/// body needs, and `read_at` is a fact about *delivery* that this view must never +/// look like it participates in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + /// The sender's address, as it signed itself. + pub from: String, + /// The recipient's address, resolved by the caller from `to_run_id`. + pub to: String, + /// The sender's step when it sent this. + pub step: u32, + /// What was said. + pub body: String, +} + +impl Message { + /// One store row, addressed to the agent at `to`. + /// + /// Takes the recipient's address as an argument because the row does not + /// carry one: `AgentMessage::to_run_id` is a run id, and only + /// `Store::tree_addresses` can turn it into a name. The caller is holding + /// both by the time it calls this. + pub fn received(message: &AgentMessage, to: &str) -> Self { + Self { + // `from_name` is the sender's ADDRESS — the instance, not the roster + // definition it was spawned from. io-harness documents it that way and + // the whole mailbox depends on it: mail is addressed to instances. + from: message.from_name.clone(), + to: to.to_string(), + step: message.step, + body: message.body.clone(), + } + } +} + /// One nesting level of the tree, as io-harness last reported it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Tier { @@ -91,6 +214,13 @@ pub struct Tier { pub struct Fleet { children: Vec, tiers: Vec, + /// The traffic between them, in the order the caller read it. + /// + /// Held flat rather than hung off each child: a message is a fact about a + /// *pair* of agents, the sender is frequently not a child of this tree's root + /// at all, and the recipient may be the root itself — which has no row here, + /// because the root is not a child. + messages: Vec, /// Which row the operator has marked, as an index into [`Fleet::children`]. /// /// Held independently of the rows so that "nothing is selected" is @@ -105,8 +235,12 @@ impl Fleet { } /// Whether anything has happened that this can draw. + /// + /// Traffic counts. A view holding mail is a view with rows in it, and one that + /// called itself empty while `rows` returned something would be hidden with + /// its contents still there. pub fn is_empty(&self) -> bool { - self.children.is_empty() && self.tiers.is_empty() + self.children.is_empty() && self.tiers.is_empty() && self.messages.is_empty() } pub fn children(&self) -> &[Child] { @@ -121,6 +255,93 @@ impl Fleet { self.selected } + /// The marked child itself, or `None` when there is nothing to mark. + /// + /// **This is the handle, and [`Fleet::selection`] is not.** An index into a + /// private vector is worth exactly what the vector's current contents make it + /// worth: it drives the highlight and nothing else, and a caller that kept one + /// across an event would be holding a number whose meaning had moved. What a + /// caller outside this module actually wants is the child — its `run_id` to + /// attach to, and its `state` to know whether attaching is a sensible thing to + /// ask for. `State::Detached` is the case that matters: a detached child is + /// **still running**, its parent has merely stopped waiting for it, so it is + /// the one an operator can go and look at. That decision stays with the + /// caller; this hands over the two facts it needs to make it and refuses to + /// make it here, because a model that decided what could be attached to would + /// be deciding something about the run. + pub fn selected_child(&self) -> Option<&Child> { + self.selected.and_then(|at| self.children.get(at)) + } + + /// Every message this view is holding, oldest first. + pub fn messages(&self) -> &[Message] { + &self.messages + } + + /// Give the children their addresses, from `(address, run id)` pairs the + /// caller read out of `Store::tree_addresses`, and their roles from `roster`. + /// + /// **It takes the data instead of reading it.** A `Store` opened in here would + /// put a disk read on the render path, make every test of a row a test that + /// needs a database, and give this module a second opinion about the run + /// alongside the event stream. The caller already holds the store, already + /// knows the root run id, and already has a schedule that is not "once a + /// frame". + /// + /// **Idempotent, and safe to call with a partial answer.** A child no pair + /// names is left exactly as it was rather than being cleared — a read that + /// raced a spawn returns the addresses that existed when it ran, and a view + /// that blanked the rest would flicker a name off screen and back. Naming is + /// monotonic here: a child gets an address once and keeps it, which is also + /// what io-harness guarantees about the address itself across a restart. + /// + /// The pair for the root (`ROOT_ADDRESS`) matches no child and is ignored, + /// which is correct for the same reason the root's `Finished` matches no row: + /// the root is not a child. + /// + /// **Bounded per call by the size of the *tree*, not the length of the run.** + /// It is one pass over the pairs, each doing one scan of the children, and + /// both of those are the number of agents that have been spawned — a number + /// that does not grow with steps, events, tokens or messages. That is the + /// difference from the trap `src/status.rs` records for `context_events`, + /// where a per-event read of the whole history makes an `n`-step run cost + /// `n(n+1)/2`. Nothing here re-walks history. + pub fn name(&mut self, addresses: &[(String, i64)], roster: &Agents) { + for (address, run_id) in addresses { + let role = role_of(address, roster); + if let Some(child) = self.child_mut(*run_id) { + child.address = Some(address.clone()); + // Assigned rather than merged: `role_of` is a pure function of the + // address and the roster, so a `None` here means the roster cannot + // name this one, and keeping a stale role after an `/agents` + // reload would be showing a definition that no longer exists. + child.role = role; + } + } + } + + /// Replace the traffic with what the caller last read. + /// + /// **Replaced, never appended** — the same rule the tier counts follow, and + /// for a sharper version of the same reason. `Store::messages_for` returns a + /// recipient's whole mailbox every call, so a view that extended would show + /// every message twice after the second poll and three times after the third. + /// Replacement also means this holds no cursor of its own that could disagree + /// with the store about what has been read. + /// + /// Takes the rows by value: the caller has just built them and has no use for + /// them afterwards, so moving is the whole cost. Bounded per call by what was + /// handed over, and this does not walk the run to work out what changed. + /// + /// **The caller must have used `messages_for` and not `read_messages`.** The + /// latter stamps `read_at`, which is io-harness's delivery record: calling it + /// to populate a view would hand a sibling's mail to the screen instead of to + /// the agent it was addressed to, and the agent would wait forever for a + /// message that was already marked delivered. + pub fn traffic(&mut self, messages: Vec) { + self.messages = messages; + } + /// Forget the run this describes. /// /// Called where the conversation under it changes — a turn ending, `/resume`, @@ -130,6 +351,11 @@ impl Fleet { pub fn forget(&mut self) { self.children.clear(); self.tiers.clear(); + // Mail included, and it is the piece most likely to be missed: a message + // outlives the turn it was sent in — it is a row in a table, not an event + // — so a fleet that dropped its children and kept its traffic would draw a + // conversation between agents that are no longer on screen. + self.messages.clear(); self.selected = None; } @@ -148,6 +374,12 @@ impl Fleet { EventKind::Spawned { child_run_id, goal } if self.child(*child_run_id).is_none() => { self.children.push(Child { run_id: *child_run_id, + // No address and no role: the event carries neither, and a + // guess made from the goal or the tier would be a name an + // operator could type at nothing. It gets one when somebody + // reads one — see `Fleet::name`. + address: None, + role: None, depth: event.depth + 1, goal: goal.clone(), state: State::Working, @@ -242,28 +474,74 @@ impl Fleet { .join(" ") } - /// One row per admitted child, fitted to `width`. + /// One row per admitted child, then one per message, all fitted to `width`. + /// + /// **The children come first and stay index-aligned with + /// [`Fleet::children`]**, because the selection this type holds is an index + /// into that vector — see [`Fleet::selection`] — and [`Fleet::render`] marks a + /// row by comparing it. Messages are + /// appended after the last child rather than interleaved under their + /// recipients: interleaving would put rows in front of children and silently + /// shift every index the marker uses, and a marker that lands on the wrong row + /// is an operator attaching to the wrong agent. It also reads better — the + /// traffic is a conversation, and a conversation is worth reading in the order + /// it happened rather than sorted into the tree. + /// + /// Bounded per call by what this holds — one pass, one row each, no history + /// walked. pub fn rows(&self, width: u16, glyphs: &Glyphs) -> Vec { let room = width as usize; - self.children + let sep = glyphs.separator.trim(); + let mut rows: Vec = self + .children .iter() .map(|child| { let indent = " ".repeat(child.depth.saturating_sub(1) as usize); let drawn = crate::status::format_tokens(child.drawn); let head = format!( - "{indent}run {} {} {} {} {drawn} drawn {} ", - child.run_id, - glyphs.separator.trim(), + "{indent}{} {sep} {} {sep} {drawn} drawn {sep} ", + child.label(), child.state.word(), - glyphs.separator.trim(), - glyphs.separator.trim(), ); // The goal is what gets cut, because everything in front of it // identifies the row and it is the only part that can be long. let room_for_goal = room.saturating_sub(head.chars().count()); - format!("{head}{}", fit(&child.goal, room_for_goal, glyphs)) + clamp( + format!("{head}{}", fit(&child.goal, room_for_goal, glyphs)), + room, + glyphs, + ) }) - .collect() + .collect(); + rows.extend(self.messages.iter().map(|message| { + // Indented one level under the tree, because that is what it is: a + // thing that happened inside the fleet rather than another member of + // it. The indent is the child rows' own unit, so the two read as one + // block whatever the terminal's width is. + let head = format!( + " {} {} {} {sep} step {} {sep} ", + message.from, + arrow(glyphs), + message.to, + message.step, + ); + // The body gives way, exactly as the goal does above, and for the same + // reason: the addresses either side of the arrow are what make the row + // mean anything, and a body cut to nothing still says that one agent + // wrote to another at a known step. + // + // Newlines are flattened rather than trimmed away: a body is free text + // an agent wrote, and one `\n` in it would otherwise turn a single row + // into several and push a child off an eight-row view. + let flat = message.body.replace(['\n', '\r'], " "); + let room_for_body = room.saturating_sub(head.chars().count()); + clamp( + format!("{head}{}", fit(&flat, room_for_body, glyphs)), + room, + glyphs, + ) + })); + rows } /// Draw the view: the tier line, then as many child rows as there is room for. @@ -293,6 +571,15 @@ impl Fleet { let mut marked_row = None; for (offset, row) in rows.iter().skip(first).take(visible).enumerate() { let index = first + offset; + // A message row can never match: `selected` is only ever set to an + // index into `children`, and `rows` puts every child before every + // message. That is the invariant the ordering in `rows` exists to + // hold — checked here rather than assumed, in the same spirit as the + // fall-through note on the `Spawned` arm. + debug_assert!( + self.selected.is_none_or(|at| at < self.children.len()), + "the marker must index a child, not a message row", + ); let tone = if Some(index) == self.selected { marked_row = Some(area.y + 1 + offset as u16); Tone::Normal @@ -311,3 +598,337 @@ impl Fleet { } } } + +/// The `[[agent]]` definition an address was spawned from, where the address can +/// still say. +/// +/// Two shapes resolve and one cannot, and the one that cannot is why this returns +/// an `Option` rather than a guess: +/// +/// * `reviewer#42` — an address io-harness **derived**, because the parent named +/// nothing. The part in front of the mark is the definition's own name, so the +/// role is recoverable exactly. +/// * `scout` — an address the parent **assigned** that happens to be a roster +/// name. Confirmed against the roster rather than assumed, for the reason +/// `Agents::get` documents: a name nobody registered is a misspelling, not a +/// permissive default. +/// * `left-hand` — an address the parent assigned that is nothing the roster has +/// heard of. **The role is genuinely unknown here**: the definition a spawn used +/// is not on the event stream and not in `tree_addresses`, and the nearest thing +/// to a guess — matching on the goal, or on which definitions exist — would put +/// a wrong role beside a right address. `None` is the true answer and it costs a +/// label, not a row. +/// +/// Pure and bounded: one split and one `BTreeMap` lookup, no allocation on the +/// path that fails. +fn role_of(address: &str, roster: &Agents) -> Option { + let stem = address.split(DERIVED_MARK).next().unwrap_or(address); + roster.get(stem).map(|def| def.name.clone()) +} + +/// The mark between a sender and a recipient, in whichever set is in play. +/// +/// **An arrow that keeps its direction when it cannot be an arrow.** `->` is not a +/// decoration standing in the same column, it is the same statement in the +/// character set the terminal admits to — which is the whole test +/// [`crate::glyphs`] sets for a mark that degrades. It is one cell wider in ASCII, +/// and nothing measures it except the row that then gives way in the body, so the +/// widths need not agree. +/// +/// Chosen by the set rather than added to [`Glyphs`] as an eleventh class, +/// because the set's own test for a class is that two call sites would otherwise +/// spell one mark differently, and there is one call site. It still answers to +/// `--plain` and to a locale that does not claim UTF-8, because it is the resolved +/// set it reads and never the environment. +fn arrow(glyphs: &Glyphs) -> &'static str { + if glyphs.name == crate::glyphs::ASCII.name { + "->" + } else { + "→" + } +} + +/// The last clamp on a finished row. +/// +/// The parts that give way have already given way by the time this runs, so this +/// only ever bites when the head *alone* overruns — a long assigned address, a +/// deep indent, two addresses either side of an arrow. A row that overran would +/// wrap, and a wrapped row in an eight-row view costs a whole other agent its +/// place on screen; a row that is cut costs the end of a name. The row is taken by +/// value and handed straight back when it fits, so the common case — every row on +/// a terminal wide enough — copies nothing. +fn clamp(row: String, room: usize, glyphs: &Glyphs) -> String { + if row.chars().count() <= room { + return row; + } + fit(&row, room, glyphs) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glyphs::{ASCII, UNICODE}; + use io_harness::AgentDef; + + /// A spawn, as io-harness emits one: on the **parent's** run, at the parent's + /// depth, carrying the child's id. + fn spawned(parent: i64, depth: u32, child: i64, goal: &str) -> RunEvent { + RunEvent::at_depth( + parent, + 1, + depth, + EventKind::Spawned { + child_run_id: child, + goal: goal.to_string(), + }, + ) + } + + fn tier(tier: u32, working: u32, queued: u32, done: u32) -> RunEvent { + RunEvent::new( + 1, + 1, + EventKind::Fleet { + tier, + working, + queued, + done, + }, + ) + } + + fn message(from: &str, to: &str, step: u32, body: &str) -> Message { + Message { + from: from.to_string(), + to: to.to_string(), + step, + body: body.to_string(), + } + } + + #[test] + fn a_named_child_draws_by_address_and_role() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "read every file under src/")); + fleet.name( + // The root's pair is in here exactly as `tree_addresses` returns it, + // and must match nothing: the root is not a child. + &[("root".to_string(), 1), ("reviewer#7".to_string(), 7)], + &Agents::new().with(AgentDef::new("reviewer")), + ); + let rows = fleet.rows(80, &UNICODE); + assert_eq!(rows.len(), 1, "the root got no row: {rows:?}"); + assert!(rows[0].contains("reviewer#7"), "{:?}", rows[0]); + assert!( + rows[0].contains("(reviewer)"), + "the role is beside the address: {:?}", + rows[0] + ); + assert!( + !rows[0].contains("run 7"), + "the database key is gone once there is a name: {:?}", + rows[0] + ); + } + + /// The address is what a parent assigned, and it is emphatically not the role: + /// two children of one definition are two different agents to address. + #[test] + fn siblings_of_one_role_draw_as_different_agents() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "left half")); + fleet.event(&spawned(1, 0, 8, "right half")); + fleet.name( + &[("reviewer#7".to_string(), 7), ("reviewer#8".to_string(), 8)], + &Agents::new().with(AgentDef::new("reviewer")), + ); + let rows = fleet.rows(80, &UNICODE); + assert!(rows[0].contains("reviewer#7"), "{rows:?}"); + assert!(rows[1].contains("reviewer#8"), "{rows:?}"); + } + + /// An assigned address that *is* a roster name reads once, not twice. + #[test] + fn a_child_named_after_its_role_does_not_say_it_twice() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "look around")); + fleet.name( + &[("scout".to_string(), 7)], + &Agents::new().with(AgentDef::new("scout")), + ); + assert!(!fleet.rows(80, &UNICODE)[0].contains("scout (scout)")); + } + + #[test] + fn an_unnamed_child_falls_back_to_its_run_id() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "read every file under src/")); + assert!( + fleet.rows(80, &UNICODE)[0].contains("run 7"), + "an event stream alone knows no names", + ); + // A read that answered about some other run leaves this one alone rather + // than clearing it, and the fallback is still what shows. + fleet.name(&[("scout".to_string(), 99)], &Agents::new()); + assert_eq!(fleet.children()[0].address, None); + assert!(fleet.rows(80, &UNICODE)[0].contains("run 7")); + } + + /// An address the roster has never heard of keeps its address and gets no role + /// invented for it. + #[test] + fn an_unknown_address_gets_no_role() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "one")); + fleet.name( + &[("left-hand".to_string(), 7)], + &Agents::new().with(AgentDef::new("reviewer")), + ); + assert_eq!(fleet.children()[0].role, None); + let row = &fleet.rows(80, &UNICODE)[0]; + assert!(row.contains("left-hand"), "{row:?}"); + assert!(!row.contains('('), "no role was guessed: {row:?}"); + } + + /// The 0.20.0 limit, still true: a queued child has no run id, so it has no + /// address, so it stays a number in the tier line. + #[test] + fn a_queued_child_stays_a_count() { + let mut fleet = Fleet::new(); + fleet.event(&tier(1, 1, 2, 0)); + fleet.name(&[("root".to_string(), 1)], &Agents::new()); + assert!(fleet.children().is_empty()); + assert!( + fleet.rows(80, &UNICODE).is_empty(), + "nothing was admitted, so nothing has a row", + ); + assert!(fleet.summary().contains("2 queued"), "{}", fleet.summary()); + } + + #[test] + fn a_message_draws_as_its_own_row() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "one")); + fleet.traffic(vec![message("scout#7", "reviewer#8", 3, "the file moved")]); + let rows = fleet.rows(80, &UNICODE); + assert_eq!(rows.len(), 2, "the child, then the message: {rows:?}"); + assert!( + rows[1].starts_with(" "), + "indented under the tree: {rows:?}" + ); + assert!(rows[1].contains("scout#7 → reviewer#8"), "{:?}", rows[1]); + assert!(rows[1].contains("step 3"), "{:?}", rows[1]); + assert!(rows[1].contains("the file moved"), "{:?}", rows[1]); + } + + /// Every new mark has an ASCII form that says the same thing. The arrow is the + /// only one this release adds — the role's parentheses and the `step` label are + /// letters and punctuation that every terminal draws. + #[test] + fn the_ascii_arrow_keeps_its_direction() { + let mut fleet = Fleet::new(); + fleet.traffic(vec![message("a", "b", 1, "hello")]); + let unicode = fleet.rows(80, &UNICODE).remove(0); + let ascii = fleet.rows(80, &ASCII).remove(0); + assert!(unicode.contains("a → b"), "{unicode:?}"); + assert!(ascii.contains("a -> b"), "{ascii:?}"); + assert!(ascii.is_ascii(), "the ASCII row is ASCII: {ascii:?}"); + // And the row is still assembled out of the set it was handed, separator + // included, rather than out of literals typed here. + assert!(ascii.contains(ASCII.separator.trim()), "{ascii:?}"); + } + + /// A child row in the ASCII set is ASCII too, address and all. + #[test] + fn the_ascii_child_row_is_ascii() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "read every file under src/")); + fleet.name( + &[("reviewer#7".to_string(), 7)], + &Agents::new().with(AgentDef::new("reviewer")), + ); + let row = fleet.rows(80, &ASCII).remove(0); + assert!(row.is_ascii(), "{row:?}"); + assert!(row.contains("reviewer#7 (reviewer)"), "{row:?}"); + } + + /// Eighty columns, a long address and a long body: the body gives way, and + /// nothing wraps. + #[test] + fn a_long_message_gives_way_at_eighty_columns() { + let mut fleet = Fleet::new(); + fleet.traffic(vec![message( + "an-unusually-long-assigned-address", + "another-unusually-long-assigned-address", + 12, + &"and a body far longer than any of it ".repeat(10), + )]); + for glyphs in [UNICODE, ASCII] { + let row = fleet.rows(80, &glyphs).remove(0); + assert!(row.chars().count() <= 80, "{} {row:?}", glyphs.name); + assert!(!row.contains('\n')); + } + // Even where the head alone overruns, the row is cut rather than wrapped. + let row = fleet.rows(24, &UNICODE).remove(0); + assert!(row.chars().count() <= 24, "{row:?}"); + } + + /// A body an agent wrote can contain newlines; a row cannot. + #[test] + fn a_multi_line_body_stays_one_row() { + let mut fleet = Fleet::new(); + fleet.traffic(vec![message("a", "b", 1, "first line\nsecond line")]); + let row = fleet.rows(80, &UNICODE).remove(0); + assert!(!row.contains('\n'), "{row:?}"); + assert!(row.contains("first line second line"), "{row:?}"); + } + + /// `messages_for` hands back the whole mailbox every call, so this replaces. + #[test] + fn traffic_replaces_rather_than_accumulates() { + let mut fleet = Fleet::new(); + let mail = vec![message("a", "b", 1, "one")]; + fleet.traffic(mail.clone()); + fleet.traffic(mail); + assert_eq!(fleet.messages().len(), 1); + assert_eq!(fleet.rows(80, &UNICODE).len(), 1); + } + + #[test] + fn forget_drops_the_mail_with_the_children() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "one")); + fleet.traffic(vec![message("a", "b", 1, "one")]); + fleet.forget(); + assert!(fleet.is_empty()); + assert!( + fleet.rows(80, &UNICODE).is_empty(), + "mail outlives its turn in the store, but not on this screen", + ); + } + + /// What main.rs attaches with: the marked child's id, and whether it is the + /// kind of child there is any point attaching to. + #[test] + fn the_selected_child_carries_its_run_id_and_state() { + let mut fleet = Fleet::new(); + assert!(fleet.selected_child().is_none(), "nothing to mark yet"); + fleet.event(&spawned(1, 0, 7, "one")); + fleet.event(&spawned(1, 0, 8, "two")); + fleet.move_by(1); + let child = fleet.selected_child().expect("a marked child"); + assert_eq!(child.run_id, 8); + assert_eq!(child.state, State::Working); + + fleet.event(&RunEvent::new( + 1, + 2, + EventKind::ChildDetached { + child_run_id: 8, + after: Some(std::time::Duration::from_secs(30)), + }, + )); + let child = fleet.selected_child().expect("still marked"); + assert_eq!(child.state, State::Detached, "detached is still running"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 05f3cd0..783b203 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,7 @@ pub mod editor; pub mod events; pub mod exec; pub mod failure; +pub mod fanout; pub mod fleet; pub mod fuzzy; pub mod glyphs; @@ -58,6 +59,7 @@ pub mod memory; pub mod picker; pub mod picture; pub mod plan; +pub mod pluginview; pub mod provider; pub mod providers; pub mod queue; diff --git a/src/main.rs b/src/main.rs index 8216ebe..63d6532 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,10 +9,10 @@ use std::io::IsTerminal; use std::process::ExitCode; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use clap::Parser; -use crossterm::event::{Event, KeyEventKind}; +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use io_harness::{Config, Policy, Provider, ProviderSpec, Session, Steer, Store, Templates}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; @@ -111,7 +111,22 @@ async fn run(report: &mut Vec) -> Result { report.extend(io_cli::skills::install(&home)); } } - let config = Config::discover(&root).map_err(|error| error.to_string())?; + // **A refusal is not a crash, and until 0.20.0 this line made every refusal + // look like one.** io-harness refuses a whole `Config::discover` when the + // project-scoped file — the one a `git clone` delivers — declares `[[hook]]`, + // because a hook runs a command on this machine. That refusal is correct and + // io-cli does not soften it: there is no `Config` to be had, so the program + // genuinely cannot start. + // + // What it can do is say so in words the operator can act on. io-harness's own + // sentence names the key, the reason and the two files that may carry it, and + // it is worth more than anything written here — so it is passed through + // unreworded, with only a line above it saying which file was being read. + // Before this, the whole thing arrived as a bare error string from a program + // that had already exited, against a repository the operator had just cloned, + // with nothing connecting one table in one file to the failure. + let config = + Config::discover(&root).map_err(|error| io_cli::configure::refusal(&root, &error))?; // **A profile, if one was asked for, before anything reads the configuration.** // Applied here rather than per arm so a session and an `io exec` run get the // same overlay from the same decision, and refused with io-harness's own @@ -913,6 +928,163 @@ async fn loop_over Result>( // the row being drawn and this keystroke the operator may // have moved the file themselves, and a carried row would // then name a path that is no longer there. + Pick::PluginEntry { + id, + bundle, + action_at, + } => { + // Every row but one is a fact to read. + if index == *action_at { + match io_cli::pluginview::declared_at(session.root(), bundle) { + Some((scope, at)) => { + descended = Some(( + Picker::new( + format!("Stop loading {id}?"), + vec![ + Row::new("leave it".to_string()), + Row::with_detail( + "stop loading it".to_string(), + format!( + "removes `plugin[{at}]` from the {} \ + scope; the directory is left alone", + io_cli::configure::Decided::File { + scope, + path: Default::default(), + } + .word() + ), + ), + ], + ), + Pick::PluginRemove { + id: id.clone(), + scope, + index: at, + }, + )); + } + // **Said, not guessed at.** No file names this + // path, so there is no entry to remove — and + // removing whichever entry happened to sit at + // some other index would take a bundle the + // operator never mentioned, silently. + None => app.record( + Tone::Refused, + format!( + "no `[[plugin]]` entry in any scope names {}; \ + nothing was removed", + bundle.display() + ), + ), + } + } + } + // `at` and not `index`: the outer `index` is the row the + // operator chose, and the entry's own position is a + // different number entirely. Binding both to one name is + // how a confirmation removes the wrong bundle. + Pick::PluginRemove { + id, + scope, + index: at, + } => { + // Row 0 is "leave it", which is the default and does + // nothing — the same shape `/skills`' toggle uses. + if index == 1 { + let edit = io_cli::pluginview::remove(*at); + match io_cli::configure::write(session.root(), *scope, &[edit]) { + Ok(()) => { + // The directory is untouched on purpose: + // this surface edits a configuration file, + // and deleting an operator's directory + // because they stopped loading it is not a + // thing a list should do. + app.record( + Tone::Success, + format!( + "{id} is no longer declared; its directory is \ + untouched, and the change is in force from the \ + next turn", + ), + ); + } + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + } + Pick::Plugins(view) => { + // **Answered from the reading the rows were drawn + // from, and `/skills` does the opposite on purpose.** + // There, a row names a file the operator may have + // moved between two keystrokes, so the directory is + // read again and a row that moved is refused. Here the + // rows describe what is *loaded in this session* — the + // bundle a turn taken right now would actually use — + // and re-reading would answer a different question + // than the one the operator asked by choosing a row. + // + // The split is `pluginview::rows`'s own: loaded first, + // refused after, no headings, so the index is direct. + if let Some(plugin) = view.plugins.get(index) { + // `descended`, not `picker`: the assignment at the + // end of this match installs it, while `kind` + // still borrows the one being replaced. + // + // **A pane rather than a line in the scrollback, + // and `/mcp` above does the opposite for a good + // reason that does not hold here.** A server is + // one row of facts, so a sentence says all of it. + // A bundle is several *lists* — its agents, its + // servers, its policy layers — and a sentence + // holding three lists is a sentence nobody reads. + let mut rows = io_cli::pluginview::detail( + plugin, + screen.width(), + &app.theme.glyphs, + ); + // **The action's index is taken before the row is + // pushed, never worked out afterwards.** Every + // other index in this surface addresses a list + // somewhere else, and the whole class of defect + // here is a row number being read against the + // wrong list — so this one is the length of what + // was already there, which cannot be wrong. + let action_at = rows.len(); + rows.push(Row::with_detail( + "stop loading this bundle".to_string(), + "removes its `[[plugin]]` entry".to_string(), + )); + descended = Some(( + Picker::new(plugin.id.clone(), rows), + Pick::PluginEntry { + id: plugin.id.clone(), + bundle: plugin.root.clone(), + action_at, + }, + )); + } else if let Some(refused) = + view.refused.get(index - view.plugins.len()) + { + // **`Tone::Refused` and io-harness's own sentence, + // re-worded by nobody.** This is the one an + // operator will actually hit — a bundle declared + // in the project file that contributes hooks or + // servers is refused whole — and the harness's + // message is the only text that names both files + // it could move to instead. `record` rather than + // `say`: a refusal explains a boundary and outlives + // the keystroke that earned it. + app.record( + Tone::Refused, + format!( + "{} ({}): {}", + refused.id, + refused.path.display(), + refused.error, + ), + ); + } + } Pick::Skills(drawn) => { // **Located by what the row said, not by where it // sat.** The list is read again — the operator may @@ -1421,6 +1593,9 @@ async fn loop_over Result>( paint(screen, &mut app)?; } Command::Transcript => commit_transcript(screen, &session, &store, &app.theme)?, + Command::Attach(run_id) => { + watch_child(screen, &mut app, &store, inputs, run_id).await?; + } // The first `Esc`. Nothing has changed yet; this says what the second // one would change, in the turn's own words, so a confirmation is a // confirmation of something specific rather than of a keystroke. @@ -1529,6 +1704,35 @@ async fn loop_over Result>( )); } } + Action::Plugin => { + // **Read fresh, every time, and that is io-harness's design + // rather than a choice made here.** `Config::plugins()` + // re-walks every declared directory on each call, so a bundle + // the operator has just edited on disk is reflected without a + // reload — and a bundle they have just broken shows up as a + // refusal in the same breath. + let view = io_cli::pluginview::view(&config); + if view.is_empty() { + // Naming the file rather than the concept: an operator who + // has never declared a bundle needs to know where the + // entry goes, and one who has needs to know io-cli read + // the file they meant. + app.record( + Tone::Muted, + "no capability bundles are declared; add one with a \ + `[[plugin]]` entry naming its directory" + .to_string(), + ); + } else { + picker = Some(( + Picker::new( + "Plugins", + io_cli::pluginview::rows(&view, screen.width(), &app.theme.glyphs), + ), + Pick::Plugins(view), + )); + } + } Action::Provider => { let chain = io_cli::providers::chain(&config); if chain.is_empty() { @@ -2519,14 +2723,61 @@ async fn turn( // to read them from. They then persist, because `Status::forget_run` does not // clear them — the file does not change while a session runs. app.status.budgets = io_cli::status::Budgets::in_force(&contract); + // **The observer composition, and it is the spine of 0.20.0.** Through 0.19.0 + // this was one value — `Bridge`, the channel the interface draws from. Three + // things this release wants are all downstream of that single seam, so the + // seam is where they arrive: + // + // 1. `[[hook]]` tables run, because io-harness's `Hooks` *is* an `Observer` + // and running the operator's hooks is nothing more than putting it here. + // io-cli spawns no process of its own for this — the subprocess, its + // timeout, its kill and its drained stdout are all io-harness's, which is + // what keeps the one-spawning-module rule in `tests/dependencies.rs` true. + // 2. `Fanout` exists because io-harness has no combinator to do it with. + // `Broadcast` is *not* a tee — it is a store-writing decorator over exactly + // one inner observer — and the only three `impl Observer` in the whole + // crate are `Hooks`, `Ignore` and `Broadcast`. So io-cli folds `Flow` + // itself, and folds it so `Cancel` wins over `Continue` whatever the order. + // 3. `Broadcast` wraps the result for a different reason than the fan-out + // exists: it is the **only** writer of the `run_events` table, and that + // table is what `Attach` reads. Without this wrapper a detached child can + // be selected and attached to and will show nothing, forever, and look + // like a quiet child rather than a missing write. + // + // The order is deliberate: `Broadcast` outermost, so an event is durable + // before either the interface or a hook is told about it, and a hook that + // cancels the turn cannot leave a gap in the sequence a reader is following. + let hooks = io_cli::contract::hooks(config, session.root()); + let mut observers: Vec<&dyn io_harness::Observer> = vec![&observer]; + if let Some(hooks) = &hooks { + observers.push(hooks); + } + let fanout = io_cli::fanout::Fanout::new(observers); + // A second connection to the same file, which is what io-harness's own + // documentation asks for rather than tolerates: `Observer` is `Send + Sync`, + // `rusqlite::Connection` is `Send` and not `Sync`, so the run's borrowed + // `&Store` cannot live inside one. `Store::open` has set `journal_mode = WAL` + // and a busy timeout since 0.12.0 precisely so a second handle works. It is a + // spectator — it writes events and never takes a run lease — so it is not the + // two-drivers-over-one-file shape that has produced `DatabaseBusy` here. + // + // `None` where the store cannot be opened a second time: attach is the thing + // that stops working, and a turn that runs without a durable event stream is + // strictly better than a turn that refuses to start. + let durable = io_cli::settings::store_path().and_then(|path| Store::open(&path).ok()); + let broadcast = durable.map(|store| io_harness::Broadcast::new(store, &fanout)); + let watcher: &dyn io_harness::Observer = match &broadcast { + Some(broadcast) => broadcast, + None => &fanout, + }; let mut running: std::pin::Pin< Box> + '_>, > = match containment { Some(caps) => Box::pin(session.turn_contained_bounded_steered( - &contract, provider, store, policy, &approver, caps, &observer, &inbox, + &contract, provider, store, policy, &approver, caps, watcher, &inbox, )), None => Box::pin(session.turn_bounded_steered( - &contract, provider, store, policy, &approver, &observer, &inbox, + &contract, provider, store, policy, &approver, watcher, &inbox, )), }; @@ -2574,6 +2825,7 @@ async fn turn( // same turn. The trace is the fallback for the window between a // step landing and the first completion call being seen. note_context(app, store, &event, seen, &contract); + note_fleet(app, store, &event, &contract); commit_viewed(screen, app, &root, policy, &event)?; commit_fold(app, store, &event, &mut folding); paint(screen, app)?; @@ -2908,6 +3160,7 @@ async fn turn( // for: the last step of a turn is exactly the one whose event the select // loop loses to the turn's own return. note_context(app, store, &event, seen, &contract); + note_fleet(app, store, &event, &contract); // And the picture, for the same reason and the same race: a `view_image` // on the turn's last step is exactly the one the drain would otherwise // lose. @@ -3042,6 +3295,74 @@ async fn turn( /// there is no request to measure and the section the trace records is the only /// number there is — better than a blank field, and it converges the moment a /// request has been seen. +/// Give the fleet the two facts the event stream does not carry. +/// +/// **On `Step` and on nothing else, and the reason is a race rather than a +/// preference.** io-harness documents `Step` as emitted once the step has been +/// committed to the store, so the spawn row is there to be read. The obvious +/// place — `Spawned`, the event that announces the child — is a read of a row +/// that may not exist yet, and it fails the way races fail: green on a quiet +/// laptop, intermittent on a loaded CI machine. `src/status.rs` already records +/// this rule for the context read; this is the same rule applied to addresses. +/// +/// **Naming is cheap and unconditional; traffic is not and is conditional.** +/// `tree_addresses` costs one query returning one row per agent — a number that +/// grows with the size of the fleet and not with the length of the run — and it +/// is what makes a child draw as `reviewer` rather than `run 41`, which is worth +/// having on screen whether or not the fleet pane is open, because the scrollback +/// keeps it. +/// +/// The mailbox is the opposite shape. `Store::messages_for` returns a +/// recipient's **whole** mailbox every call, so polling it once a step would +/// re-read every message already read — `n(n+1)/2` reads over an `n`-message +/// run, which is exactly the trap `src/status.rs` names for `context_events`. +/// So it is read only while the pane that shows it is open. That is not a +/// half-measure: a message an operator cannot see has nowhere to arrive, and the +/// pane is refreshed by the next step the moment they open it. +fn note_fleet( + app: &mut App, + store: &Store, + event: &io_harness::RunEvent, + contract: &io_harness::TaskContract, +) { + if !matches!(event.kind, io_harness::EventKind::Step { .. }) { + return; + } + // A run with no children has an empty tree and nothing to name. Asking the + // store anyway would be one query per step of every ordinary turn, which is + // the overwhelming majority of them. + if app.fleet.is_empty() { + return; + } + let Ok(root) = store.run_root(event.run_id) else { + return; + }; + if let Ok(addresses) = store.tree_addresses(root) { + app.fleet.name(&addresses, &contract.agents); + } + if !app.fleet_open() { + return; + } + // One mailbox per child that has an address, and a child without one has no + // name to show a message under yet. Bounded by the fleet, and re-read whole + // because `Fleet::traffic` replaces rather than appends — `messages_for` is + // the non-consuming reader, so this never takes delivery of a sibling's mail. + let mut traffic: Vec = Vec::new(); + for child in app.fleet.children() { + let Some(address) = child.address.as_deref() else { + continue; + }; + if let Ok(messages) = store.messages_for(child.run_id) { + traffic.extend( + messages + .iter() + .map(|message| io_cli::fleet::Message::received(message, address)), + ); + } + } + app.fleet.traffic(traffic); +} + fn note_context( app: &mut App, store: &Store, @@ -3354,6 +3675,45 @@ enum Pick { path: std::path::PathBuf, enabled: bool, }, + /// The capability bundles this configuration declared, loaded and refused. + /// + /// **The whole view rather than a list of ids, and for the same reason + /// [`Pick::Skills`] carries pairs**: descending into a bundle has to show + /// what that bundle contributed, and re-reading `Config::plugins()` to answer + /// a keystroke would be a second reading of directories that can change + /// between two of them — so the rows an operator chose from and the detail + /// they descend into come from one reading. + /// + /// A refused bundle is in here too, and choosing one shows io-harness's + /// sentence rather than a detail pane: there is nothing to descend into, + /// because the bundle contributed nothing at all. + Plugins(io_cli::pluginview::View), + /// One bundle's contributions, and the one thing that can be done about it. + /// + /// `bundle` is the resolved directory, which is the only thing a row on screen + /// and an entry in a file genuinely share — `Config::plugins()` reports neither + /// the scope that declared a bundle nor its position in that file, so the path + /// is what `pluginview::declared_at` matches on to find the entry. + /// + /// `action_at` is where the removal row sits, recorded by the code that built + /// the rows rather than recomputed here. + PluginEntry { + id: String, + bundle: std::path::PathBuf, + action_at: usize, + }, + /// The second half of removing a bundle: which entry, and are you sure. + /// + /// **Two steps rather than one, for the reason [`Pick::SkillToggle`] gives** — + /// this is a picker that would otherwise change a file on the way past. It + /// carries the scope and index `declared_at` resolved, so the confirmation acts + /// on the entry that was found rather than searching again against a file the + /// operator may have edited in between. + PluginRemove { + id: String, + scope: io_harness::config::Scope, + index: usize, + }, /// The named profiles a file declares, in the order `configure::profiles` /// sorted them. Profile(Vec), @@ -3430,6 +3790,179 @@ fn completion( ))) } +/// How often an attached child's events are asked for. +/// +/// io-harness is explicit that `Attach` is a poll and that there is no push, so +/// this number is the whole of the latency an operator sees. A tenth of a second +/// is under the threshold at which a terminal reads as live, and the query behind +/// it is one indexed read of rows after a cursor — not a scan — so the cost of +/// asking and finding nothing is close to nothing. +const ATTACH_POLL: Duration = Duration::from_millis(100); + +/// Watch a child that is already running, until it stops or the operator leaves. +/// +/// **A mode rather than a background poll, and the idle loop's own shape is the +/// argument.** That loop blocks on `inputs.recv()` and has no tick — there is +/// nowhere for a periodic read to live without giving every idle session a timer +/// it does not need. Watching a child is also inherently something an operator is +/// *doing*: they picked a row and asked to see it. So this borrows the loop for +/// as long as they want it and gives it back on `Esc`. +/// +/// **`from_now`, not from the beginning.** A detached child may have been running +/// for minutes and its earlier steps are in the trace already; replaying them +/// into the scrollback would bury the thing the operator opened this to see. The +/// cursor starts where they asked. +/// +/// The events are fed to the same `App::event` the live stream uses, so a child's +/// step draws exactly as it would have if its parent had waited for it. That is +/// the property worth having: attaching changes when an operator sees a run, not +/// what it looks like. +async fn watch_child( + screen: &mut Screen>, + app: &mut App, + store: &Store, + inputs: &mut UnboundedReceiver, + run_id: i64, +) -> Result<(), String> { + // **The watch's own clock, and the alternative would be a worse lie.** The + // `at` an event carries into `App::event` is an age, used to say how long a + // tool call took. A detached child has been running since before this watch + // began — possibly for minutes — so no clock this session holds gives its + // steps their true ages. Timing from the moment of attaching at least + // measures something real: how long the operator has been watching. The + // driver's own `Instant` is not in scope here and reaching for it would put + // the same wrong number behind a more convincing name. + let watching = Instant::now(); + // **Ask whether there is anything left to watch, before opening a watch that + // can never end.** `Attach::from_now()` sets the cursor to the store's current + // position and does not look at the run — so attaching to a child that has + // already finished succeeds, polls forever, and prints nothing. That is the + // ordinary case rather than an exotic one: a detached child finishes on its + // own schedule, its `Finished` reaches nobody once the parent's turn has + // ended, so the fleet row still reads `detached` long after the child stopped. + // The operator selects exactly that row, because it is the one the pane + // invites them to select. + match store.run_status(run_id) { + Ok(Some(io_harness::RunStatus::Running)) => {} + Ok(Some(status)) => { + app.record( + Tone::Muted, + format!( + "run {run_id} is no longer running ({}); there is nothing left to watch", + format!("{status:?}").to_lowercase() + ), + ); + return Ok(()); + } + // No row, or a store that cannot answer. Neither is a reason to open a + // watch that would show nothing; both are worth saying. + Ok(None) => { + app.record(Tone::Refused, format!("run {run_id} is not in the store")); + return Ok(()); + } + Err(error) => { + app.record(Tone::Refused, format!("cannot watch run {run_id}: {error}")); + return Ok(()); + } + } + let mut attach = match io_harness::Attach::to(store, run_id).from_now() { + Ok(attach) => attach, + Err(error) => { + // The store is readable — the session is running out of it — so this + // is the run being unknown to it, which is a fact worth saying rather + // than a silence to interpret. + app.record(Tone::Refused, format!("cannot watch run {run_id}: {error}")); + return Ok(()); + } + }; + app.record( + Tone::Muted, + format!("watching run {run_id} — Esc to stop watching"), + ); + paint(screen, app)?; + loop { + match attach.poll() { + Ok(events) => { + let mut drawn = false; + for event in &events { + // **`watched`, not `event`.** This run's tokens are not this + // session's spend and its tree is not this turn's fan-out; + // `App::watched` draws the lines and folds nothing into the + // status line or the fleet. + app.watched(event, watching.elapsed()); + drawn = true; + // The child is finished when it says so. Leaving on its own + // `Finished` rather than on a status query keeps this reading + // one stream instead of two sources that can disagree — and + // the rest of the batch is drawn first rather than abandoned + // mid-loop, so a `HandleKilled` sitting behind the `Finished` + // is not lost. + if matches!(event.kind, io_harness::EventKind::Finished { .. }) + && event.run_id == run_id + { + app.record(Tone::Muted, format!("run {run_id} finished")); + paint(screen, app)?; + return Ok(()); + } + } + if drawn { + paint(screen, app)?; + } + } + Err(error) => { + app.record(Tone::Refused, format!("stopped watching: {error}")); + paint(screen, app)?; + return Ok(()); + } + } + // **One deadline per poll, and the loop waits out the whole of it.** + // Re-entering `timeout` with a fresh `ATTACH_POLL` after every key would + // busy-spin: an auto-repeating key or a dragged window edge returns + // immediately, and the loop would go straight back to `attach.poll()` — + // tens of store reads a second instead of ten, on the connection the + // driver is also using. + let deadline = tokio::time::Instant::now() + ATTACH_POLL; + loop { + match tokio::time::timeout_at(deadline, inputs.recv()).await { + // Both stop keys, and `Ctrl+C` is here because it is the one key + // this product refuses to let a configuration file rebind. An + // operator who wants out of anything presses it; a watch it did + // nothing to would be the only surface in the interface where + // that is false. + Ok(Some(Event::Key(key))) + if key.kind == KeyEventKind::Press + && (key.code == KeyCode::Esc + || (key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL))) => + { + app.record(Tone::Muted, format!("stopped watching run {run_id}")); + paint(screen, app)?; + return Ok(()); + } + // A resize while watching is a resize, and the other two loops in + // this file already say so. Dropping it leaves the `Screen` + // believing in a width the terminal no longer has, and every row + // drawn after the watch ends writes past the edge. + Ok(Some(Event::Resize(width, height))) => { + screen + .resize(width, height) + .map_err(|error| error.to_string())?; + paint(screen, app)?; + } + // The channel closing is the terminal going away, which the caller + // handles by returning; there is nothing to watch for any more. + Ok(None) => return Ok(()), + // Any other key is ignored on purpose: this is a window onto a run + // the operator is not driving, so a key that did something here + // would be a key that did it to the wrong run. + Ok(Some(_)) => {} + // The deadline, which is the only way out of this inner loop. + Err(_) => break, + } + } + } +} + fn paint( screen: &mut Screen>, app: &mut App, diff --git a/src/pluginview.rs b/src/pluginview.rs new file mode 100644 index 0000000..ac68509 --- /dev/null +++ b/src/pluginview.rs @@ -0,0 +1,790 @@ +//! `/plugin` — which capability bundles this configuration declared, what each +//! one brought, and which ones io-harness refused to load. +//! +//! A plugin is a directory with a `plugin.toml` contributing skills, templates, +//! agents, MCP servers, hooks and deny-only policy layers, and io-cli has never +//! had a surface for one. It needs one for a reason the other management panels +//! do not: **every other capability an operator sees is one they put there.** A +//! skill file is theirs or io-cli's, an `[[mcp]]` entry is a line they wrote, a +//! policy layer came from a posture they chose. A bundle is none of those — it is +//! a directory somebody else wrote, named once by path, that then adds names to +//! four subsystems at once. The question this surface exists to answer is *what +//! did that directory put in my session*, and until now the only way to answer it +//! was to open the manifest. +//! +//! # A dropped bundle is the state this surface is really for +//! +//! [`io_harness::Plugins`] has no error path. A bundle with no manifest, +//! unparseable TOML, a bad id, or a contribution its declaring scope may not make +//! is **dropped** — recorded, reported, and otherwise silently absent while every +//! other bundle loads. io-harness's own module docs call that a deliberate trade +//! with a cost: *a bundle an operator believes is loaded can be silently absent +//! for a week*. This panel is where that week ends, so the refused set is a field +//! of [`View`] rather than an error, drawn beside the loaded set and never +//! instead of it. +//! +//! **The reason is io-harness's sentence and it is carried verbatim.** It names +//! the file, names the key, and — for the project-scope refusal — explains in two +//! clauses why a `git clone`d `io.toml` may not name a program this machine will +//! run. io-cli could not write that better and must not try: a reworded refusal +//! is io-cli's opinion about somebody else's rule, and the operator who has to fix +//! it needs the words of whoever enforced it. [`rows`] may *shorten* the sentence +//! to fit a row, with the mark that says so; [`Refused::error`] holds it whole, +//! and the driver prints it whole beneath the list. +//! +//! # Hooks cannot be listed, and this file says so on screen +//! +//! There is no `Plugin::hooks()` and `Hook` is `pub(crate)`. A bundle's hooks are +//! applied — [`io_harness::Plugins::apply_to_hooks`] installs them — and there is +//! no API by which io-cli can name one, count them, or say what any of them runs. +//! The string `"hooks"` from [`io_harness::Plugin::contributions`] is the entire +//! honest signal, and [`detail`] draws exactly that: a row saying hooks were +//! declared and that io-cli cannot say what they do. **The alternative was to omit +//! the row**, which reads as a bundle with no hooks — the one reading that is +//! actually false, on the contribution kind that runs programs. +//! +//! # The names drawn here are already namespaced, and that is the point +//! +//! io-harness rewrites every contributed agent name, MCP server id and policy +//! layer name to `__` at load, so `agents()[0].name` is +//! `rust-review__reviewer`. This surface draws that string unchanged. It is not a +//! prettier name with the prefix stripped, because the namespaced form is what a +//! refusal will name in `PolicyEvent::layer`, what a call will name in +//! `McpEvent::server`, and what the operator will type to spawn the agent — and a +//! panel that showed a shorter name than the trace does would have invented a +//! third spelling of the same thing. +//! +//! # No TOML, no terminal I/O, no keys +//! +//! Nothing here opens a `plugin.toml`. Every fact comes from +//! [`io_harness::Config::plugins`], which re-reads each declared bundle from disk +//! itself; a second reader in this crate would be a second opinion about what a +//! manifest means, and `tests/dependencies.rs` forbids one by path. Like +//! [`crate::servers`] and [`crate::skillview`], this is a data model and pure +//! functions: the driver in `src/main.rs` owns the keyboard and applies what +//! [`add`] and [`remove`] return. + +use std::path::{Path, PathBuf}; + +use crate::glyphs::Glyphs; +use crate::picker::{fit, fit_left, Row}; + +/// The mark on a bundle that loaded. +/// +/// **One ASCII character in both glyph sets, and that is the ASCII form N4 asks +/// for**, by [`crate::skillview::Origin::word`]'s argument: a mark degrades only +/// where the substitute still carries the meaning, and a pair of dots or arrows +/// that both fall back to `*` would leave an operator unable to tell a loaded +/// bundle from a refused one on the terminal that needed the fallback. `+` and +/// `!` are the marks [`crate::commands`] already uses for *present* and for +/// *attention*, they are legible in either set, so they need no set. +pub const LOADED_MARK: &str = "+"; + +/// The mark on a bundle that was declared and did not load. See [`LOADED_MARK`]. +pub const REFUSED_MARK: &str = "!"; + +/// The narrowest a bundle's root may be drawn at, before its separator. +/// +/// Twenty cells is `...bundles/rust-review` — the last segments and the mark +/// saying the front went, which is what identifies a directory on a machine where +/// every bundle shares the first several segments of its path. The same floor +/// [`crate::skillview`] fits a skill file to, and for the same reason: a row may +/// lose a fact, and may not draw one that cannot be read. +const ROOT_FLOOR: usize = 20; + +/// One bundle that loaded, as `/plugin` lists it. +/// +/// Everything is owned and copied out of the borrowed [`io_harness::Plugin`], +/// because `Config::plugins()` returns a fresh value that re-read the disk: the +/// borrow dies with the call, and a view that held it would tie every surface to +/// the lifetime of one read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Listed { + /// The manifest's `name`, which is also what every contribution below is + /// namespaced by. + pub id: String, + /// The manifest's one line for a human, if it carried one. + pub description: Option, + /// The bundle's own version. **Documentation and nothing else** — io-harness + /// resolves nothing with it and compares no two bundles by it — so it is + /// drawn as a fact about the directory, never as a claim about compatibility. + pub version: Option, + /// The directory the manifest was read from. + pub root: PathBuf, + /// Which kinds of contribution it declared, in io-harness's own fixed order: + /// skills, templates, agents, mcp, hooks, policy. + /// + /// **This is the only place `hooks` can come from.** See the module docs. + pub contributions: Vec<&'static str>, + /// The skills directory it contributes, absolute, if it declared one. + pub skills: Option, + /// The templates directory it contributes, absolute, if it declared one. + pub templates: Option, + /// The agent names it contributes, already namespaced. + pub agents: Vec, + /// The MCP server ids it contributes, already namespaced. + pub servers: Vec, + /// The policy layer names it contributes, already namespaced. Deny rules + /// only — io-harness drops a bundle whose layer carries anything else. + pub layers: Vec, +} + +/// One bundle that was declared and did not load. +/// +/// Named for what happened rather than mirroring io-harness's `Dropped`: from +/// this side of the boundary the fact is that a configuration asked for a bundle +/// and the loader said no, and the operator's next move is in the sentence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refused { + /// The manifest's `name` where it could be read, and the directory's own name + /// where it could not. A label, not a key — [`Refused::path`] identifies it. + pub id: String, + /// The directory the `[[plugin]]` entry named. + pub path: PathBuf, + /// io-harness's own sentence, whole. **Never reworded and never summarised**; + /// [`rows`] may shorten it to fit, and this field is what the driver prints + /// underneath. See the module docs. + pub error: String, +} + +/// Everything `/plugin` draws. +/// +/// Two lists rather than a `Result`, for [`crate::skillview::View`]'s reason: they +/// are not alternatives. One broken bundle costs exactly itself in io-harness, so +/// a refused bundle sits beside four that loaded and both facts are true at once. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct View { + /// The bundles that loaded, in the order they were declared — which is the + /// order their policy layers stack and their contributions are applied in. + pub plugins: Vec, + /// The bundles that did not, with the reason each one did not. + pub refused: Vec, +} + +impl View { + /// Whether the configuration declared no bundle at all. + /// + /// **Both lists, because a configuration whose only bundle was refused has + /// declared one.** `Plugins::is_empty` says nothing about `dropped()`, and a + /// surface that read it alone would tell an operator with a broken manifest + /// that they have no plugins — which is the false sentence this module exists + /// to stop being told. + #[must_use] + pub fn is_empty(&self) -> bool { + self.plugins.is_empty() && self.refused.is_empty() + } +} + +/// Every bundle this configuration declared, loaded and refused. +/// +/// Infallible, because [`io_harness::Config::plugins`] is: it re-reads every +/// declared directory from disk on the call and reports what it could not read +/// rather than returning an error. That re-read is deliberate here — the surface +/// an operator opens after editing a manifest must see the edit, and a set cached +/// at session start would not. +#[must_use] +pub fn view(config: &io_harness::Config) -> View { + let plugins = config.plugins(); + View { + plugins: plugins + .iter() + .map(|plugin| Listed { + id: plugin.id().to_string(), + description: plugin.description().map(str::to_string), + version: plugin.version().map(str::to_string), + root: plugin.root().to_path_buf(), + contributions: plugin.contributions(), + skills: plugin.skills_dir(), + templates: plugin.templates_dir(), + agents: plugin.agents().iter().map(|def| def.name.clone()).collect(), + servers: plugin + .mcp_servers() + .iter() + .map(|server| server.id.clone()) + .collect(), + layers: plugin + .policy_layers() + .iter() + .map(|layer| layer.name.clone()) + .collect(), + }) + .collect(), + refused: plugins + .dropped() + .iter() + .map(|dropped| Refused { + id: dropped.id.clone(), + path: dropped.path.clone(), + error: dropped.error.clone(), + }) + .collect(), + } +} + +/// The picker rows for the whole list, fitted for a terminal this wide. +/// +/// **One row per bundle, loaded first and refused after, and no headings.** The +/// picker hands a chosen index straight back into the caller's own list, so a +/// heading row is an index that maps to no bundle — [`crate::commands`] carries a +/// parallel vector of `Held::Nothing` to survive that, and this surface has two +/// lists to index rather than one. So the contract is positional and written down: +/// **index `i` is `view.plugins[i]` while `i < view.plugins.len()`, and +/// `view.refused[i - view.plugins.len()]` after it.** The mark, not a heading, +/// says which — and unlike a heading it survives a typed query, which is exactly +/// when a refused bundle is hardest to tell from a loaded one. +/// +/// # The narrow form, and what gives way in it +/// +/// The id is the label. The contributions are unconditional, because they are the +/// answer to what the bundle *did to this session* and are the only field here +/// with no other home. The version follows when there is one, the description +/// takes what is left, and **the root path is the field that gives way** — below +/// a floor this module keeps privately it is dropped whole rather than drawn as an +/// ellipsis and an extension. At eighty columns a row is therefore the id, what it +/// contributed and +/// as much description as fits; widen the terminal and the path arrives. +/// +/// A refused row never carries the root, and not because it did not fit: +/// io-harness's sentence already opens with the path, so appending it would draw +/// the same string twice on the row with the least room to spare. +pub fn rows(view: &View, width: u16, glyphs: &Glyphs) -> Vec { + let separator = glyphs.separator; + let separator_width = separator.chars().count(); + // Worth appending only if there is room for more than the mark that would say + // it had been shortened, measured off the set: the ellipsis is one cell in + // Unicode and three in ASCII. + let floor = separator_width + glyphs.ellipsis.chars().count(); + + let mut out = Vec::with_capacity(view.plugins.len() + view.refused.len()); + + for plugin in &view.plugins { + // Commas inside a field, the glyph set's separator between fields: this + // is one fact — the list of kinds — and punctuating it with the same run + // that divides the row would read as five fields. + let mut detail = plugin.contributions.join(", "); + if detail.is_empty() { + // A manifest with a `name` and nothing else. Loaded, contributing + // nothing, and saying so is the whole point of the row. + detail = "contributes nothing".to_string(); + } + if let Some(version) = &plugin.version { + detail.push_str(separator); + detail.push_str(version); + } + + // The picker's own arithmetic, mirrored rather than guessed: two cells of + // marker, the label, two cells of gap. A budget one cell out is how an + // ellipsis ends up on the floor. + let mut left = (width as usize) + .saturating_sub(4) + .saturating_sub(plugin.id.chars().count()) + .saturating_sub(detail.chars().count()); + + if let Some(description) = &plugin.description { + if left > floor { + let described = fit(description, left - separator_width, glyphs); + left -= separator_width + described.chars().count(); + detail.push_str(separator); + detail.push_str(&described); + } + } + if left >= separator_width + ROOT_FLOOR { + detail.push_str(separator); + // From the left, because every bundle on one machine shares the first + // several segments of its path and the end is what identifies it. + detail.push_str(&fit_left( + &plugin.root.display().to_string(), + left - separator_width, + glyphs, + )); + } + + out.push(Row::marked(LOADED_MARK, plugin.id.clone(), detail)); + } + + for refused in &view.refused { + let mut detail = String::from("not loaded"); + let left = (width as usize) + .saturating_sub(4) + .saturating_sub(refused.id.chars().count()) + .saturating_sub(detail.chars().count()); + if left > floor { + detail.push_str(separator); + // Shortened from the head like any other prose, with the mark that + // says so. `Refused::error` still holds the sentence whole, and the + // driver prints it whole — a row is a pointer, not the report. + detail.push_str(&fit(&refused.error, left - separator_width, glyphs)); + } + out.push(Row::marked(REFUSED_MARK, refused.id.clone(), detail)); + } + + out +} + +/// The picker rows for one bundle: what it actually put into the session. +/// +/// Grouped under headings, which are free here and are not free in [`rows`]: this +/// list is read rather than chosen from, so an index that maps to no bundle costs +/// nothing. The order is io-harness's own contribution order, so a bundle reads +/// the same way here as it does in the trace. +/// +/// Every name is drawn **namespaced, exactly as io-harness rewrote it** — see the +/// module docs. +pub fn detail(plugin: &Listed, width: u16, glyphs: &Glyphs) -> Vec { + // The picker's own arithmetic again: marker and gap, with no detail column to + // budget for on the rows that are a path. + let room = (width as usize).saturating_sub(4); + let mut out = Vec::new(); + + if let Some(dir) = &plugin.skills { + out.push(Row::heading("skills")); + out.push(Row::new(fit_left(&dir.display().to_string(), room, glyphs))); + } + if let Some(dir) = &plugin.templates { + out.push(Row::heading("prompt templates")); + out.push(Row::new(fit_left(&dir.display().to_string(), room, glyphs))); + } + if !plugin.agents.is_empty() { + out.push(Row::heading("agents")); + out.extend(plugin.agents.iter().map(|name| Row::new(name.clone()))); + } + if !plugin.servers.is_empty() { + out.push(Row::heading("mcp servers")); + out.extend(plugin.servers.iter().map(|id| Row::new(id.clone()))); + } + if plugin.contributions.contains(&"hooks") { + out.push(Row::heading("hooks")); + // **The whole of what io-cli knows, stated as the whole of what it + // knows.** There is no accessor and `Hook` is `pub(crate)`, so the count, + // the events and the argv are all unreachable — and a bundle's hooks are + // the contribution that runs programs. Omitting the group would read as a + // bundle with no hooks, which is the one reading that is false. + out.push(Row::with_detail( + "declared", + fit( + // No dash and no other mark that would need a glyph set: this is a + // rendered string, and the one class of mark in it — the + // separator between the two clauses — is a semicolon in every + // terminal there is. + "io-harness does not expose a bundle's hooks, so io-cli cannot say what they run; \ + read the bundle's plugin.toml", + room.saturating_sub("declared".chars().count() + 2), + glyphs, + ), + )); + } + if !plugin.layers.is_empty() { + out.push(Row::heading("policy layers, deny only")); + out.extend(plugin.layers.iter().map(|name| Row::new(name.clone()))); + } + + if out.is_empty() { + // A manifest that declared a name and nothing else. It loaded, so it is + // in the list; this is the row that says the list entry is all there is. + out.push(Row::new("this bundle contributes nothing")); + } + out +} + +/// The edit that declares a bundle. +/// +/// A whole `[[plugin]]` entry, because an array of tables grows by gaining a block +/// and [`crate::edit::Edit::set`] can only reach inside one that exists — the same +/// shape [`crate::servers::add`] writes for `[[mcp]]`. +/// +/// The path is written as given. **A relative one resolves against the discovery +/// root**, not against the file that named it, so io-cli neither absolutises it +/// here nor tidies it: a relative path in a committed `io.toml` is how a bundle +/// vendored into a repository is shared, and rewriting it to this machine's +/// absolute path would break that for everyone else who clones it. +pub fn add(dir: &Path) -> crate::edit::Edit { + crate::edit::Edit::append( + "plugin", + format!("path = {}", quoted(&dir.display().to_string())), + ) +} + +/// The edit that removes the `index`-th declared bundle whole. +/// +/// By index rather than by id, because the id is the *manifest's* and the entry is +/// the *configuration's*: a bundle that was refused for having an unusable `name` +/// has no id to remove it by, and it is exactly the entry an operator wants gone. +/// +/// **`index` is an index into the file's `[[plugin]]` array and into nothing +/// else.** In particular it is *not* a row number from [`rows`], which lists the +/// loaded bundles and then the refused ones — two lists whose order has no +/// relation to the order the entries appear in any file. Handing a row number to +/// this function deletes a different bundle than the one on screen. Find the entry +/// with [`declared_at`], which answers the question this argument asks. +pub fn remove(index: usize) -> crate::edit::Edit { + crate::edit::Edit::remove(format!("plugin[{index}]")) +} + +/// Which scope file declares `root`, and at which `[[plugin]]` index. +/// +/// **The bridge between a row on screen and an entry in a file, and it exists +/// because there is no other honest way across.** `Config::plugins()` hands back +/// loaded bundles and dropped ones as two lists, in neither case ordered by the +/// file that named them, and it says nothing at all about which of the three +/// scopes carried a given entry. So the only thing a row and an entry genuinely +/// share is the path — which is why this matches on that and refuses to guess when +/// it cannot. +/// +/// Each scope's file is read and its `plugin[i].path` values are compared, both +/// as written and resolved against `root`, because a declaration may be relative +/// or absolute and both name the same directory. The first scope that matches wins, +/// searched local-first — the same precedence order the harness itself applies, so +/// a bundle declared twice is removed from the file that was actually deciding. +/// +/// `None` means no file names that path, and the caller must say so rather than +/// removing something. Deleting the wrong `[[plugin]]` entry is silent: the +/// operator loses a bundle they never mentioned and finds out when its skills stop +/// being offered. +pub fn declared_at(root: &Path, bundle: &Path) -> Option<(io_harness::config::Scope, usize)> { + use io_harness::config::Scope; + for scope in [Scope::Local, Scope::Project, Scope::User] { + let Some(path) = crate::configure::scope_path(root, scope) else { + continue; + }; + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + // The array is walked until a gap, which is what `value_at` reports by + // returning `None` — an array of tables is contiguous, so the first miss + // is the end of it. + for index in 0.. { + let Some(raw) = crate::edit::value_at(&text, &format!("plugin[{index}].path")) else { + break; + }; + let declared = PathBuf::from(raw.trim().trim_matches('"')); + let resolved = if declared.is_absolute() { + declared.clone() + } else { + root.join(&declared) + }; + if declared == bundle || resolved == bundle { + return Some((scope, index)); + } + } + } + None +} + +/// A TOML basic string, escaped. +/// +/// The twin of [`crate::servers`]'s own, which is private to that module — here +/// rather than shared because a value is the one thing `toml::to_string` cannot be +/// asked for on its own, and because this crate writes VALUES. It earns its keep +/// on Windows, where a bundle's path is full of backslashes that a bare `"{}"` +/// would hand to the parser as escapes. +fn quoted(text: &str) -> String { + let escaped = text.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glyphs::{ASCII, UNICODE}; + + /// A bundle with something of every kind that can be listed. + fn listed() -> Listed { + Listed { + id: "rust-review".to_string(), + description: Some("Everything our Rust reviews need.".to_string()), + version: Some("1.2.0".to_string()), + root: PathBuf::from("/Users/someone/code/io-cli/bundles/rust-review"), + contributions: vec!["skills", "agents", "hooks", "policy"], + skills: Some(PathBuf::from( + "/Users/someone/code/io-cli/bundles/rust-review/skills", + )), + templates: None, + agents: vec!["rust-review__reviewer".to_string()], + servers: Vec::new(), + layers: vec!["rust-review__no-secrets".to_string()], + } + } + + fn view_of(plugin: Listed) -> View { + View { + plugins: vec![plugin], + refused: Vec::new(), + } + } + + #[test] + fn a_wide_row_carries_every_fact_in_both_glyph_sets() { + for glyphs in [&UNICODE, &ASCII] { + let rows = rows(&view_of(listed()), 200, glyphs); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label, "rust-review"); + assert_eq!(rows[0].mark, Some(LOADED_MARK)); + let detail = rows[0].detail.clone().expect("a listed bundle has detail"); + assert!( + detail.starts_with("skills, agents, hooks, policy"), + "{}: the contributions lead the row: {detail}", + glyphs.name + ); + assert!(detail.contains("1.2.0"), "{}: {detail}", glyphs.name); + assert!( + detail.contains("Everything our Rust reviews need."), + "{}: {detail}", + glyphs.name + ); + assert!( + detail.contains("bundles/rust-review"), + "{}: a wide row keeps the root: {detail}", + glyphs.name + ); + assert!( + detail.contains(glyphs.separator), + "{}: fields are divided by the set's own separator: {detail}", + glyphs.name + ); + } + } + + #[test] + fn at_eighty_columns_the_root_is_what_gives_way() { + for glyphs in [&UNICODE, &ASCII] { + let rows = rows(&view_of(listed()), 80, glyphs); + let detail = rows[0].detail.clone().expect("a listed bundle has detail"); + assert!( + detail.starts_with("skills, agents, hooks, policy"), + "{}: the contributions never give way: {detail}", + glyphs.name + ); + assert!( + !detail.contains("/Users/someone"), + "{}: the root is dropped rather than drawn illegibly: {detail}", + glyphs.name + ); + // The picker's own budget: marker, label, gap, detail. + assert!( + rows[0].label.chars().count() + detail.chars().count() + 4 <= 80, + "{}: the row fits: {detail}", + glyphs.name + ); + } + } + + #[test] + fn a_bundle_that_contributes_nothing_says_so_rather_than_drawing_an_empty_field() { + let mut plugin = listed(); + plugin.contributions = Vec::new(); + let rows = rows(&view_of(plugin), 120, &UNICODE); + let detail = rows[0].detail.clone().expect("a listed bundle has detail"); + assert!(detail.starts_with("contributes nothing"), "{detail}"); + } + + #[test] + fn a_refused_bundle_is_marked_apart_and_carries_the_harness_sentence() { + let sentence = "/repo/bundles/empty: no plugin.toml; a plugin is a directory with a \ + manifest at its root"; + let view = View { + plugins: vec![listed()], + refused: vec![Refused { + id: "empty".to_string(), + path: PathBuf::from("/repo/bundles/empty"), + error: sentence.to_string(), + }], + }; + for glyphs in [&UNICODE, &ASCII] { + let rows = rows(&view, 200, glyphs); + // The positional contract `rows` documents: loaded first, refused + // after, one row each and nothing in between. + assert_eq!(rows.len(), 2, "{}", glyphs.name); + assert_eq!(rows[0].mark, Some(LOADED_MARK), "{}", glyphs.name); + assert_eq!(rows[1].mark, Some(REFUSED_MARK), "{}", glyphs.name); + assert_eq!(rows[1].label, "empty"); + let detail = rows[1].detail.clone().expect("a refused bundle has detail"); + assert!(detail.starts_with("not loaded"), "{detail}"); + assert!( + detail.contains(sentence), + "{}: the sentence is carried verbatim where it fits: {detail}", + glyphs.name + ); + } + } + + #[test] + fn a_refused_row_is_shortened_rather_than_reworded() { + let sentence = "/repo/bundles/x: key `mcp`: a plugin declared in a project-scoped \ + `io.toml` may not contribute `[[mcp]]`"; + let view = View { + plugins: Vec::new(), + refused: vec![Refused { + id: "x".to_string(), + path: PathBuf::from("/repo/bundles/x"), + error: sentence.to_string(), + }], + }; + for glyphs in [&UNICODE, &ASCII] { + let rows = rows(&view, 80, glyphs); + let detail = rows[0].detail.clone().expect("a refused bundle has detail"); + assert!( + detail.ends_with(glyphs.ellipsis), + "{}: a shortened sentence carries the set's own mark: {detail}", + glyphs.name + ); + assert!( + sentence.starts_with( + detail + .trim_start_matches("not loaded") + .trim_start_matches(glyphs.separator) + .trim_end_matches(glyphs.ellipsis) + ), + "{}: what is drawn is a prefix of the harness's own words: {detail}", + glyphs.name + ); + assert!( + rows[0].label.chars().count() + detail.chars().count() + 4 <= 80, + "{}: the row fits: {detail}", + glyphs.name + ); + } + } + + #[test] + fn the_detail_view_groups_what_a_bundle_contributed() { + for glyphs in [&UNICODE, &ASCII] { + let rows = detail(&listed(), 100, glyphs); + let labels: Vec<&str> = rows.iter().map(|row| row.label.as_str()).collect(); + assert!(labels.contains(&"skills"), "{}: {labels:?}", glyphs.name); + assert!(labels.contains(&"agents"), "{}: {labels:?}", glyphs.name); + assert!( + labels.contains(&"rust-review__reviewer"), + "{}: the namespaced name io-harness wrote, not a stripped one: {labels:?}", + glyphs.name + ); + assert!( + labels.contains(&"rust-review__no-secrets"), + "{}: {labels:?}", + glyphs.name + ); + assert!( + !labels.contains(&"mcp servers"), + "{}: a group with nothing in it is not drawn: {labels:?}", + glyphs.name + ); + assert!( + rows.iter().any(|row| row.heading), + "{}: the groups are headings", + glyphs.name + ); + } + } + + #[test] + fn hooks_are_declared_as_unlistable_rather_than_omitted() { + let rows = detail(&listed(), 120, &UNICODE); + let position = rows + .iter() + .position(|row| row.label == "hooks") + .expect("a bundle declaring hooks says so"); + assert!(rows[position].heading); + let said = rows[position + 1] + .detail + .clone() + .expect("the hooks row explains itself"); + assert!( + said.contains("io-harness does not expose"), + "io-cli says what it cannot say: {said}" + ); + + // And a bundle with no hooks has no such group, so the sentence above is + // never drawn over a bundle it is not true of. + let mut quiet = listed(); + quiet.contributions = vec!["skills"]; + assert!(detail(&quiet, 120, &UNICODE) + .iter() + .all(|row| row.label != "hooks")); + } + + #[test] + fn a_bundle_with_nothing_listable_still_draws_a_row() { + let plugin = Listed { + id: "bare".to_string(), + description: None, + version: None, + root: PathBuf::from("/repo/bare"), + contributions: Vec::new(), + skills: None, + templates: None, + agents: Vec::new(), + servers: Vec::new(), + layers: Vec::new(), + }; + let rows = detail(&plugin, 80, &ASCII); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label, "this bundle contributes nothing"); + } + + #[test] + fn the_edits_name_the_plugin_array() { + assert_eq!( + add(Path::new("bundles/rust-review")), + crate::edit::Edit::append("plugin", "path = \"bundles/rust-review\"") + ); + assert_eq!( + remove(2), + crate::edit::Edit::remove("plugin[2]".to_string()) + ); + } + + #[test] + fn a_windows_path_is_escaped_rather_than_handed_over_as_escapes() { + assert_eq!( + add(Path::new(r"C:\bundles\rust-review")), + crate::edit::Edit::append("plugin", r#"path = "C:\\bundles\\rust-review""#) + ); + } + + #[test] + fn the_view_reads_the_loaded_and_the_refused_out_of_one_configuration() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path(); + std::fs::create_dir_all(root.join("good").join("skills")).expect("the bundle directory"); + std::fs::write( + root.join("good").join("plugin.toml"), + "name = \"rust-review\"\ndescription = \"reviews\"\nversion = \"1.2.0\"\n\ + skills = \"skills\"\n\n[[agent]]\nname = \"reviewer\"\n", + ) + .expect("the manifest"); + std::fs::create_dir(root.join("bad")).expect("the empty bundle directory"); + std::fs::write( + root.join("io.local.toml"), + "[[plugin]]\npath = \"good\"\n\n[[plugin]]\npath = \"bad\"\n", + ) + .expect("the configuration"); + + let config = io_harness::Config::discover(root).expect("the configuration loads"); + let view = view(&config); + assert!(!view.is_empty()); + + let plugin = view + .plugins + .iter() + .find(|plugin| plugin.id == "rust-review") + .expect("the good bundle loaded"); + assert_eq!(plugin.description.as_deref(), Some("reviews")); + assert_eq!(plugin.version.as_deref(), Some("1.2.0")); + assert_eq!(plugin.contributions, vec!["skills", "agents"]); + assert_eq!( + plugin.agents, + vec!["rust-review__reviewer".to_string()], + "the name is namespaced by io-harness before io-cli ever sees it", + ); + + let refused = view + .refused + .iter() + .find(|refused| refused.path.ends_with("bad")) + .expect("the manifest-less bundle was dropped rather than erroring"); + assert!( + refused.error.contains("plugin.toml"), + "the harness's own sentence, whole: {}", + refused.error + ); + } +} diff --git a/src/triage.rs b/src/triage.rs index 7f29b17..b376a29 100644 --- a/src/triage.rs +++ b/src/triage.rs @@ -41,7 +41,7 @@ pub enum Disposition { Silent, } -/// Every kind io-harness 0.66 declares, in its own declaration order. +/// Every kind io-harness 0.69 declares, in its own declaration order. /// /// The order is the enum's rather than alphabetical so that this table can be /// read down the side of `observe.rs` when the pin moves. @@ -165,8 +165,10 @@ pub const TRIAGE: &[(&str, Disposition, &str)] = &[ ( "plugin_loaded", Disposition::Silent, - "`io exec --json` and the durable trace; a bundle that loaded changed nothing an operator \ - asked about mid-session", + "`/plugin`, which lists it and what it contributed; also `io exec --json` and the durable \ + trace. Silent on the stream because it fires at step 0 of every turn — a bundle that \ + loaded changed nothing an operator asked about mid-session, and a line each time would be \ + a line about a directory that has not moved since the session started", ), ("lsp_started", Disposition::Status, "the lsp field"), ("browser_started", Disposition::Status, "the web field"), @@ -183,7 +185,13 @@ pub const TRIAGE: &[(&str, Disposition, &str)] = &[ ( "plugin_dropped", Disposition::Silent, - "`io exec --json` and the durable trace", + "`/plugin`, which lists it with io-harness's own sentence for why; also `io exec --json` \ + and the durable trace. **Silent is a close call and it is recorded as one.** A bundle \ + that failed to load is exactly the kind of fact this interface argues should be visible — \ + but it is a standing misconfiguration rather than an event, it fires at step 0 of every \ + turn for as long as it goes unfixed, and a refusal repeated once a turn teaches an \ + operator to stop reading refusals. So it goes to the surface that holds standing facts, \ + and `/plugin` exists from this release for that", ), ( "rewound", diff --git a/tests/commands.rs b/tests/commands.rs index 1c74fba..223b05a 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -64,6 +64,11 @@ fn the_commands_are_the_commands() { "/skills", "/mcp", "/provider", + // 0.20.0 — the third surface that lists what the file declared and + // goes on to change it, and the widest of the three: one directory + // can hand over skills, templates, agents, servers, hooks and policy + // at once. That breadth is the argument for it being visible at all. + "/plugin", "/profile", "/contain", // 0.12.0 — the planning phase stopped being something diff --git a/tests/fleet.rs b/tests/fleet.rs index 2a84632..841a0cb 100644 --- a/tests/fleet.rs +++ b/tests/fleet.rs @@ -406,3 +406,152 @@ fn f5_forgetting_a_run_forgets_the_fleet_and_shuts_the_view() { assert!(!app.fleet_open()); assert!(app.fleet.is_empty()); } + +/// A fleet belongs to one turn, and the turn that clears it is the turn that +/// starts. +/// +/// **Not the turn that ends, and the difference is the whole of it.** Clearing on +/// `finished` would throw away the tree at the exact moment the operator is most +/// likely to open `/fleet` and read what their fan-out did — which is why +/// `f5_the_view_closes_when_the_turn_ends_and_the_model_survives` above asserts the +/// opposite for `App::finished`, and why the two tests have to sit beside each +/// other. Clearing at the start keeps the record readable for as long as it is the +/// record of the last thing that happened, and no longer. +/// +/// Sabotage: drop `self.fleet.forget()` from `App::started`. **Nothing else in +/// this suite fails.** Every test above builds its fleet inside one turn, so a +/// model that never clears agrees with all of them. What ships instead is a +/// session where one fan-out anywhere poisons the rest of the conversation: turn +/// two's `/fleet` draws turn one's agents and their mail as though they were +/// current — rows for children that finished minutes ago, under a pane whose whole +/// claim is that it shows what is running now — and `note_fleet`'s `is_empty` +/// early return is defeated for good, so every ordinary step of every later turn +/// pays a `run_root` and a `tree_addresses` query for a tree that is not there. +/// One of those is a lie on screen and the other is a cost with nothing to show +/// for it, and neither leaves a mark anyone would trace back here. +#[test] +fn f5_a_turn_starting_clears_the_previous_turns_fleet() { + let mut app = App::new(DARK, "a-model"); + + // Turn one fans out. + app.started(); + app.event( + &spawned(1, 0, 7, "read every file under src/"), + Duration::ZERO, + ); + app.event(&tier(1, 1, 0, 0), Duration::ZERO); + app.finished(); + assert_eq!( + app.fleet.children().len(), + 1, + "the tree survives the turn it belonged to, which is the other half of \ + this rule", + ); + + // Turn two starts, and it starts with nothing. + app.started(); + assert!( + app.fleet.is_empty(), + "turn one's agents are still in the model, so `/fleet` will draw them as \ + this turn's: {:?}", + app.fleet.children(), + ); + assert!( + app.fleet.children().is_empty(), + "the rows in particular, which are what reaches the screen", + ); + assert!( + app.fleet.tiers().is_empty(), + "and the counts, which are what the summary line is built from", + ); + assert_eq!( + app.fleet.selection(), + None, + "nothing is marked, rather than a row zero that no longer exists", + ); +} + +/// F5 — `Enter` with something typed is still `queue this`, with the pane open. +/// +/// The pane is drawn *over the prompt*, not in front of the keyboard: `Ctrl+C` +/// still interrupts through it and the composer still takes typing, because the +/// moment this view is worth opening is mid-turn. `Enter` on a fleet row means +/// something only at an empty prompt — there is one row it can act on and nothing +/// half-written to lose — so with text in the composer it has to fall through. +/// +/// Sabotage: drop the `if self.composer.is_empty()` guard from the `KeyCode::Enter` +/// arm of the fleet block in `App::key`, which is what this arm actually shipped +/// with. **The return value does not change** — a prompt queued mid-turn is +/// `Command::None` and a swallowed key is `Command::None` — so nothing a caller can +/// see distinguishes the two, and the assertion that bites is the queue's contents. +/// What the operator gets is a line they typed against a running turn, pressed +/// `Enter` on, and that is simply gone: no message, no change on screen, and the +/// pane is open at precisely the moment queueing a line is most likely. +#[test] +fn f5_enter_with_a_written_prompt_reaches_the_queue_through_the_open_view() { + let mut app = App::new(DARK, "a-model"); + app.started(); + app.event( + &spawned(1, 0, 7, "read every file under src/"), + Duration::ZERO, + ); + app.toggle_fleet(); + assert!(app.fleet_open()); + + for character in "and then run the tests".chars() { + app.key(key(KeyCode::Char(character))); + } + assert_eq!( + app.composer.text(), + "and then run the tests", + "the pane is over the prompt, not in front of the keyboard", + ); + + app.key(key(KeyCode::Enter)); + + assert_eq!( + app.queued_prompts(), + ["and then run the tests"], + "the line was swallowed by a pane that had nothing to do with it", + ); + assert!( + app.composer.is_empty(), + "and the prompt is clear for the next one, the way every other submit \ + leaves it", + ); + assert!( + app.fleet_open(), + "queueing a line is not a reason to close the view that was being read", + ); +} + +/// F5 — `Shift+Enter` writes a newline through the open view. +/// +/// The same guard, on the key that has no other road at all. `Enter` at least had +/// a reading while the pane was up; a shifted `Enter` never means "act on a fleet +/// row" under any circumstances, so an arm that matched `KeyCode::Enter` without +/// looking at the modifiers took a key it could not possibly want. +/// +/// Sabotage: the same missing `composer.is_empty()`. Under it a multi-line prompt +/// cannot be *written* while the pane is open — the second line never starts, the +/// cursor does not move, and the operator concludes the key is broken rather than +/// that a view they opened is eating it. +#[test] +fn f5_shift_enter_still_writes_a_newline_through_the_open_view() { + let mut app = App::new(DARK, "a-model"); + app.toggle_fleet(); + for character in "first line".chars() { + app.key(key(KeyCode::Char(character))); + } + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + for character in "second line".chars() { + app.key(key(KeyCode::Char(character))); + } + + assert_eq!( + app.composer.text(), + "first line\nsecond line", + "the shifted Enter never reached the composer, so a multi-line prompt \ + cannot be written while the view is open", + ); +} diff --git a/tests/hooks.rs b/tests/hooks.rs new file mode 100644 index 0000000..14479d8 --- /dev/null +++ b/tests/hooks.rs @@ -0,0 +1,466 @@ +//! F8 and F9 — the `[[hook]]` tables: the scope that may declare one, the +//! configurations that get a `Hooks` at all, and the validation io-cli must not +//! reimplement. +//! +//! **io-cli has no opinion about what a hook is.** Every rule below — which scope +//! may declare one, `on` against `at`, exactly one of `append` and `run`, whether +//! `tools` means anything without a lifecycle point — is enforced inside +//! `Config::discover`, and this file asserts io-harness's own sentences reach the +//! operator rather than asserting that io-cli reproduced them. A second copy of +//! those rules in this crate would be a second thing to keep true, and the release +//! it drifted in would be the one where a hook loads in io-cli and is refused by +//! the run. +//! +//! **What io-cli does own is the guard**, and it is the subject of F9. io-harness +//! disables read speculation on any run carrying a `Hooks` value at all — even one +//! holding no hooks — so whether that value is attached is a decision about every +//! operator who has never written a hook, which is nearly all of them. +//! +//! Fixtures that must be exact about the *absence* of a hook use +//! `Config::from_toml`, which reads only the text it is given. +//! `Config::discover` also reads a user-scope file where the machine has one. + +use std::path::PathBuf; + +use io_harness::config::{Config, LOCAL_FILE, PROJECT_FILE}; +use io_harness::PLUGIN_FILE; + +/// A temporary root, kept alive by the returned guard. +fn root() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path().to_path_buf(); + (dir, root) +} + +/// A root whose `file` holds `text`. +fn written(file: &str, text: &str) -> (tempfile::TempDir, PathBuf) { + let (dir, root) = root(); + std::fs::write(root.join(file), text).expect("the configuration"); + (dir, root) +} + +/// A hook that watches an event and runs a program. Valid in every scope but the +/// project one. +const EVENT_HOOK: &str = "[[hook]]\non = [\"finished\"]\nrun = [\"true\"]\n"; + +// --------------------------------------------------------------------------- +// F8 — the scope that may declare a hook +// --------------------------------------------------------------------------- + +/// **F8.** A `[[hook]]` in the committed `io.toml` fails discovery, and io-cli's +/// refusal carries io-harness's own sentence and names the directory it came from. +/// +/// Both halves matter and neither substitutes for the other. The sentence is the +/// only thing that tells an operator what to do — move the table to +/// `io.local.toml` — and the directory is the only thing that tells them *which* +/// checkout is refusing, which is the whole question when a session will not start +/// in one of six clones on the same machine. +/// +/// Sabotage: reword `configure::refusal` to "your io.toml is invalid", the sort of +/// tidy-up that reads as an improvement in review. Under it only this test fails, +/// and what ships is an operator staring at a session that will not start, with no +/// key named, no rule explained and no file to open. +#[test] +fn f8_a_project_scoped_hook_is_refused_and_io_cli_names_the_rule_and_the_root() { + let (_dir, root) = written(PROJECT_FILE, EVENT_HOOK); + + let error = + Config::discover(&root).expect_err("a hook in the file a `git clone` delivers is refused"); + let said = io_cli::configure::refusal(&root, &error); + + // io-harness's own words, not a paraphrase of them. + assert!( + said.contains( + "a project-scoped file may not declare hooks, because a hook runs or writes on \ + this machine" + ), + "io-cli reworded the refusal: {said}", + ); + assert!( + said.contains(LOCAL_FILE), + "the operator is not told where the table may live: {said}", + ); + assert!( + said.contains(&root.display().to_string()), + "the refusal names no directory, so an operator with six checkouts cannot tell \ + which one refused: {said}", + ); + + // The same table one file over is the operator's own machine talking, and it + // loads — so the test above is about the scope rather than about hooks. + let (_dir, local) = written(LOCAL_FILE, EVENT_HOOK); + let config = Config::discover(&local).expect("a local hook is the operator's own"); + assert!(!config.hooks().is_empty()); +} + +// --------------------------------------------------------------------------- +// F9 — the guard, which is the most expensive line in this release to get wrong +// --------------------------------------------------------------------------- + +/// **F9.** A configuration that declares no hook gets no `Hooks` at all — not an +/// empty one. +/// +/// **The sabotage is returning `Some` unconditionally**, and it is the reason this +/// is the most important test in the file. Nothing fails under it. Every hook that +/// was ever written still runs, every test about hook behaviour stays green, and +/// `/config` looks identical. What changes is that io-harness stops speculating +/// reads on every run carrying a `Hooks` value — so the product gets slower for the +/// overwhelming majority of operators who have never written a hook, with nothing +/// on screen connecting the loss to this release and nothing in the suite to catch +/// it on the way out. +/// +/// Both io-cli's callers are asserted, because the guard is made twice — once in +/// `contract::configured` for the lifecycle half and once in `contract::hooks` for +/// the fan-out's event half — and a fix to one is not a fix to the other. +#[test] +fn f9_a_configuration_with_no_hook_attaches_no_hooks_at_all() { + let (_dir, root) = root(); + + // The text-only reading, which no file on this machine can influence. + let bare = Config::from_toml("").expect("an empty configuration"); + assert!( + io_cli::contract::hooks(&bare, &root).is_none(), + "a configuration with nothing in it was handed a Hooks", + ); + assert!( + io_cli::contract::configured("go", root.clone(), &bare) + .tool_hooks + .is_none(), + "an empty configuration put a Hooks on the contract, which turns off read \ + speculation for every operator who has never written one", + ); + + // And a configuration that says plenty, none of it a hook — so `None` is not + // `None` because there was no file to read. + let (_dir, busy) = written( + LOCAL_FILE, + "[run]\nmax_steps = 40\n\n[[mcp]]\nid = \"docs\"\ntransport = \"stdio\"\n\ + command = \"mcp-docs\"\n", + ); + let config = Config::discover(&busy).expect("the configuration loads"); + assert!(config.hooks().is_empty()); + assert!( + io_cli::contract::hooks(&config, &busy).is_none(), + "a configuration with servers and a step cap and no hook was handed a Hooks", + ); + let contract = io_cli::contract::configured("go", busy.clone(), &config); + assert!( + contract.tool_hooks.is_none(), + "the same, on the contract the turn carries", + ); + assert_eq!( + contract.max_steps, 40, + "the fixture's other keys did not apply, so this proves nothing about a busy file", + ); +} + +/// **F9, the other side.** A configuration that declares one gets it, on both +/// roads. +/// +/// Without this the guard above is indistinguishable from `None` always — which +/// would pass every assertion in it, and would ship a product where `[[hook]]` is +/// a section io-harness validates, io-cli reports, and nothing ever runs. +/// +/// Sabotage: invert the guard, or drop `with_tool_hooks` while keeping the +/// fan-out install. Under the second only the contract assertion here fails, and it +/// fails by accepting half the file: an `on = [...]` table runs and an +/// `at = "before_tool"` table is silently never consulted, so a hook written to +/// refuse a tool call refuses nothing. +#[test] +fn f9_a_declared_hook_reaches_both_the_contract_and_the_fan_out() { + let (_dir, root) = written( + LOCAL_FILE, + "[[hook]]\nat = \"before_tool\"\ntools = [\"write_file\"]\nrun = [\"true\"]\n\n\ + [[hook]]\non = [\"finished\"]\nappend = \"audit.jsonl\"\n", + ); + let config = Config::discover(&root).expect("the configuration loads"); + + let hooks = io_cli::contract::hooks(&config, &root).expect("the file declares two hooks"); + assert!(!hooks.is_empty()); + + let contract = io_cli::contract::configured("go", root.clone(), &config); + assert!( + contract.tool_hooks.is_some(), + "the lifecycle half of the file is accepted and never consulted", + ); +} + +/// **F9, through a bundle.** A hook a capability bundle contributes counts as a +/// declared hook, even though the configuration file itself declares none. +/// +/// Sabotage: build the value from `config.hooks()` alone and drop the +/// `plugins().apply_to_hooks` merge. Under it only this test fails, and it fails +/// silently in the direction that matters least visibly: `/plugin` still draws the +/// bundle's `hooks` row, the manifest still declares them, and not one of them ever +/// runs — for a contribution kind whose entire purpose is to run programs. +#[test] +fn f9_a_hook_a_bundle_contributes_is_a_declared_hook() { + let (_dir, root) = root(); + let bundle = root.join("runner"); + std::fs::create_dir_all(&bundle).expect("the bundle directory"); + std::fs::write( + bundle.join(PLUGIN_FILE), + "name = \"runner\"\n\n[[hook]]\nat = \"before_tool\"\nrun = [\"true\"]\n", + ) + .expect("the manifest"); + std::fs::write(root.join(LOCAL_FILE), "[[plugin]]\npath = \"runner\"\n") + .expect("the configuration"); + + let config = Config::discover(&root).expect("the configuration loads"); + assert!( + config.hooks().is_empty(), + "the file itself declares a hook, so this test would pass without the merge", + ); + assert!( + config.plugins().dropped().is_empty(), + "the bundle did not load: {:?}", + config.plugins().dropped(), + ); + + assert!( + io_cli::contract::hooks(&config, &root).is_some(), + "the bundle's hook is declared and nothing in this session will run it", + ); + assert!( + io_cli::contract::configured("go", root.clone(), &config) + .tool_hooks + .is_some(), + "the same, on the contract the turn carries", + ); +} + +// --------------------------------------------------------------------------- +// Validation — io-cli's job is to carry the refusal, not to make it +// --------------------------------------------------------------------------- + +/// A malformed `[[hook]]` is refused by io-harness, and io-cli adds no check and +/// no wording of its own. +/// +/// The four shapes are the ones the table's keys make representable and its +/// meaning forbids. Each is checked in `Hook::check`, none of them is expressible +/// in the type — `deny_unknown_fields` cannot say "exactly one of these two" — and +/// every one of them would otherwise be a hook that loads, installs and never +/// fires, which is the failure this module can least afford. +/// +/// Sabotage: give io-cli its own validator, however small — an `if +/// hook.at.is_some() && !hook.on.is_empty()` in `configure`, or a friendlier +/// message wrapped around the harness's. Under it only this test fails, and it +/// fails on the assertion that the refusal is io-harness's string and nothing +/// else. What it prevents is the release where io-cli's copy and io-harness's rule +/// disagree, and a hook the panel accepts is refused by the run that carries it. +#[test] +fn a_malformed_hook_is_refused_by_io_harness_and_io_cli_adds_no_check_of_its_own() { + for (table, phrase) in [ + ( + "[[hook]]\non = [\"finished\"]\nat = \"before_tool\"\nrun = [\"true\"]\n", + "a hook attaches to events or to a lifecycle point", + ), + ( + "[[hook]]\nat = \"before_tool\"\nappend = \"audit.jsonl\"\n", + "a lifecycle hook decides whether a call happens, so it needs `run`", + ), + ( + "[[hook]]\non = [\"finished\"]\nappend = \"audit.jsonl\"\nrun = [\"true\"]\n", + "a hook has one action", + ), + ( + "[[hook]]\non = [\"finished\"]\ntools = [\"write_file\"]\nrun = [\"true\"]\n", + "`tools` filters a lifecycle hook, and this one has no `at`", + ), + ] { + let (_dir, root) = written(LOCAL_FILE, table); + let error = Config::discover(&root) + .err() + .unwrap_or_else(|| panic!("io-harness accepted a malformed hook: {table}")); + + assert!( + error.to_string().contains(phrase), + "refused for something other than the shape under test: {error}", + ); + + // **The whole of io-cli's contribution, asserted as an equality.** Anything + // io-cli added — a rewording, a hint, a check of its own that fired first — + // makes this fail, which is the point: the operator reads the words of + // whoever enforced the rule. + assert_eq!( + io_cli::configure::refusal(&root, &error), + format!( + "the configuration in {} could not be read:\n{error}", + root.display() + ), + "io-cli put something of its own between the operator and the refusal", + ); + } +} + +/// A hook that names an event io-harness does not emit is refused rather than +/// installed. +/// +/// The one validation whose absence is pure silence: a misspelled tag deserializes +/// perfectly, installs perfectly, and matches no event for the life of the +/// session. There is nothing to notice. +/// +/// Sabotage: drop the `EVENT_NAMES` loop from `Hook::check`. Under it only this +/// test fails, and what an operator gets is a hook they wrote, that `/config` +/// lists, that io-cli attaches to the fan-out, and that never runs once. +#[test] +fn a_hook_naming_an_event_that_does_not_exist_is_refused_rather_than_installed() { + let (_dir, root) = written( + LOCAL_FILE, + "[[hook]]\non = [\"finsihed\"]\nrun = [\"true\"]\n", + ); + let error = Config::discover(&root) + .expect_err("a misspelled event name is a hook that would never fire"); + assert!( + error + .to_string() + .contains("is not an event this crate emits"), + "{error}", + ); + + // The correctly spelled one loads, so the assertion above is about the + // spelling rather than about the key. + let (_dir, spelled) = written(LOCAL_FILE, EVENT_HOOK); + let config = Config::discover(&spelled).expect("the configuration loads"); + assert!(io_cli::contract::hooks(&config, &spelled).is_some()); +} + +// --------------------------------------------------------------------------- +// The empty-root guard — a `Hooks` is a write, so building one is a decision +// --------------------------------------------------------------------------- +// +// `io_harness::Hooks::new` creates every `append` path it is given, empty, as the +// value is constructed (`io-harness-0.69.0/src/hooks.rs`) — deliberately, so that +// "the filter matched nothing" stays distinguishable from "the hook was never +// installed". That makes *constructing* a `Hooks` a write to the filesystem, and +// it resolves a relative `append` against the directory it was handed. Handed +// `PathBuf::new()`, that directory is the process working directory. +// +// Which would be a curiosity except that `contract::server_notices` calls +// `configured(String::new(), PathBuf::new(), config)` at startup — with an empty +// root on purpose, because all it wants is the merged `[[mcp]]` and `[[lsp]]` +// lists off a throwaway contract that will never run a turn. So an operator with +// `[[hook]] append = "audit.jsonl"` in their configuration got a stray empty +// `audit.jsonl` dropped in whatever directory they happened to launch `io` from, +// every single start, in a session where no hook ran and no turn was taken. + +/// A configuration whose one hook appends to a path relative to the root. +const APPENDING_HOOK: &str = "[[hook]]\non = [\"finished\"]\nappend = \"audit.jsonl\"\n"; + +/// A caller with no root gets no `Hooks`, on both roads, even though the +/// configuration declares one. +/// +/// **The two assertions are the whole of the guard and neither covers the other** +/// — it is made once in `contract::hooks` for the fan-out's event half and once in +/// `contract::configured` for the lifecycle half, and `server_notices` goes through +/// the second. `f9_a_declared_hook_reaches_both_the_contract_and_the_fan_out` +/// above is the other side of this pair: the same file, with a root, gets its +/// hooks. So `None` here is about the root and nothing else. +/// +/// **What this proves about the file, and what it does not.** The first half below +/// is the positive control: with a real root the `Hooks` is built and io-harness +/// creates `audit.jsonl` inside it, which is the mechanism the guard exists for and +/// which would otherwise be a claim in a comment. Given that, `None` from the two +/// rootless calls means no `Hooks` was constructed at all — and a file that is only +/// ever created by that constructor cannot appear when it is never run. The test +/// does not `set_current_dir` to watch the empty directory stay empty: the process +/// working directory is shared by every test in this binary and they run in +/// parallel, so a test that moved it would be reaching into the others. +/// +/// Sabotage: delete `if root.as_os_str().is_empty() { return None; }` from +/// `contract::hooks`, or the `dir.as_os_str().is_empty()` arm from +/// `contract::configured`. Nothing else in the suite fails — every hook that was +/// ever written still runs, `/config` is identical, the contract that carries a +/// turn is unchanged — and what ships is io-cli littering an empty file into the +/// operator's home, their repository, or whatever directory they started in, once +/// per launch, for a hook that never fired. +#[test] +fn a_rootless_caller_builds_no_hooks_and_therefore_writes_no_append_file() { + let (_dir, root) = written(LOCAL_FILE, APPENDING_HOOK); + let config = Config::discover(&root).expect("the configuration loads"); + + // The positive control: this is what building a `Hooks` costs. + assert!( + !root.join("audit.jsonl").exists(), + "the fixture already had the file, so its appearance below proves nothing", + ); + assert!( + io_cli::contract::hooks(&config, &root).is_some(), + "a declared hook with a root is a Hooks", + ); + assert!( + root.join("audit.jsonl").exists(), + "io-harness no longer creates `append` paths as the value is built, which is \ + the entire reason the guard below exists — reread it before deleting either", + ); + + // And the same configuration with no root writes nothing, because it builds + // nothing. + assert!( + io_cli::contract::hooks(&config, std::path::Path::new("")).is_none(), + "a rootless caller was handed a Hooks, which created `audit.jsonl` in \ + whatever directory `io` was launched from", + ); + assert!( + io_cli::contract::configured("", PathBuf::new(), &config) + .tool_hooks + .is_none(), + "the road `server_notices` actually takes at startup: an empty root put a \ + Hooks on a throwaway contract that will never run a turn", + ); +} + +/// `server_notices` — the caller the guard was written for — reads its lists +/// without leaving a file in the directory `io` was launched from. +/// +/// Asserted through the real function rather than through the empty root it +/// happens to pass today, because the guard is only as good as the call that +/// reaches it: a `server_notices` that started resolving a root before asking +/// would pass every assertion in the test above and reintroduce the write at a +/// different address. +/// +/// **The relative path is watched where it would actually land, and the process +/// working directory is not moved to do it.** `Hooks::new` resolves +/// `append = "audit.jsonl"` against the empty directory it is handed, which is the +/// bare relative path `audit.jsonl` — so a broken guard drops the file into +/// whatever directory the suite is running in, and that is where this looks. It is +/// recorded before and compared after rather than asserted absent outright, so a +/// checkout that happens to hold such a file is not a red test about the wrong +/// thing. Nothing is moved and nothing global is set, so this stays safe beside +/// every other test in this binary running at the same time. +/// +/// The `[[mcp]]` entry is here so the call has something to find: a +/// `server_notices` that returned early on a configuration it had nothing to say +/// about would never reach `configured`, and this would be asserting that an +/// unentered branch writes no file. +/// +/// Sabotage: either guard — `contract::hooks`'s or `contract::configured`'s — or a +/// `server_notices` that names a root. Under the second only this test fails, and +/// it fails by leaving the very file it is describing. +#[test] +fn server_notices_leaves_no_append_file_in_the_directory_io_was_launched_from() { + let (_dir, root) = written( + LOCAL_FILE, + &format!( + "{APPENDING_HOOK}\n[[mcp]]\nid = \"docs\"\ntransport = \"stdio\"\n\ + command = \"mcp-docs\"\n" + ), + ); + let config = Config::discover(&root).expect("the configuration loads"); + assert!(!config.hooks().is_empty(), "the fixture declares a hook"); + + // Exactly what `Hooks::new` would `create` for this hook under an empty root. + let stray = std::path::Path::new("audit.jsonl"); + let before = stray.exists(); + + let _ = io_cli::contract::server_notices(&config, &io_cli::contract::Capabilities::default()); + + assert_eq!( + stray.exists(), + before, + "reading the merged server lists at startup created {} in the working \ + directory — which is what every launch of `io` does, for a hook that \ + never fires", + stray.display(), + ); +} diff --git a/tests/plugins.rs b/tests/plugins.rs new file mode 100644 index 0000000..3b81e5a --- /dev/null +++ b/tests/plugins.rs @@ -0,0 +1,875 @@ +//! F1 to F4 — capability bundles: what a declared directory puts into a turn, +//! what a broken one costs, what a project-scoped one may not contribute, and the +//! two writes `/plugin` makes. +//! +//! **Every claim about what a bundle contributed goes through io-harness's own +//! call**, never through io-cli's account of it. `Config::plugins()` re-reads each +//! declared directory from disk on every call, `Plugins::apply_to` is what merges +//! a bundle's agents and servers into a contract, and the namespacing that turns +//! `reviewer` into `rust-review__reviewer` happens inside `load_one` before io-cli +//! has seen a byte of it. A test that asserted on `pluginview::View` alone would +//! be asserting that io-cli copied fields correctly, which is not the question: +//! the question is whether the turn the operator gets carries the bundle. +//! +//! **Nothing here touches the environment.** `Config::discover` reads a user-scope +//! file where one exists, so every fixture that must be exact about the *absence* +//! of something uses `Config::from_toml`, which has no scope but the text; the +//! rest declare their bundles in a temporary root of their own and run in +//! parallel. + +use std::path::{Path, PathBuf}; + +use io_cli::pluginview; +use io_harness::config::{Config, LOCAL_FILE, PROJECT_FILE}; +use io_harness::PLUGIN_FILE; + +/// The manifest io-harness's own module docs open with: a bundle contributing +/// something of five of the six kinds, and nothing that runs a program. +/// +/// Written out here rather than assembled from parts, because it is the shape a +/// bundle author copies out of the documentation and it has to keep loading. +const MINIMAL: &str = r#" +name = "rust-review" +description = "Everything our Rust reviews need." +skills = "skills" +templates = "templates" + +[[agent]] +name = "reviewer" +model = "cheap-model" +deny_write = true + +[policy] +layers = [ + { name = "no-secrets", rules = [{ act = "write", effect = "deny", pattern = "secrets/**" }] }, +] +"#; + +/// A temporary root, kept alive by the returned guard. +fn root() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path().to_path_buf(); + (dir, root) +} + +/// A bundle directory holding `manifest`, with the two directories a manifest can +/// name already on disk. +fn bundle(root: &Path, at: &str, manifest: &str) -> PathBuf { + let dir = root.join(at); + std::fs::create_dir_all(dir.join("skills")).expect("the skills directory"); + std::fs::create_dir_all(dir.join("templates")).expect("the templates directory"); + // `PLUGIN_FILE` rather than a literal: the name a directory is recognised by + // is io-harness's to state, and a fixture that spelled it itself would keep + // passing through a release that renamed it. + std::fs::write(dir.join(PLUGIN_FILE), manifest).expect("the manifest"); + dir +} + +/// A directory that is declared as a bundle and holds no manifest at all. +fn empty_bundle(root: &Path, at: &str) -> PathBuf { + let dir = root.join(at); + std::fs::create_dir_all(&dir).expect("the bundle directory"); + dir +} + +/// Write `file` in `root` declaring each named directory as a bundle, in order. +fn declaring(root: &Path, file: &str, paths: &[&str]) { + let text: String = paths + .iter() + .map(|path| format!("[[plugin]]\npath = \"{path}\"\n\n")) + .collect(); + std::fs::write(root.join(file), text).expect("the configuration"); +} + +/// The dropped row for the bundle at `at`, by the directory the entry named. +/// +/// Keyed on the path rather than on `Dropped::id`, because a bundle whose manifest +/// could not be read has no id — the field falls back to the directory's own name +/// — and keying on it would make a test about unparseable TOML depend on the +/// fallback rather than on the refusal. +fn dropped_at<'a>(plugins: &'a io_harness::Plugins, at: &str) -> &'a io_harness::plugin::Dropped { + plugins + .dropped() + .iter() + .find(|dropped| dropped.path.ends_with(at)) + .unwrap_or_else(|| { + panic!( + "no bundle at `{at}` was dropped; dropped: {:?}", + plugins + .dropped() + .iter() + .map(|d| d.path.display().to_string()) + .collect::>() + ) + }) +} + +// --------------------------------------------------------------------------- +// F1 — a declared bundle reaches the turn +// --------------------------------------------------------------------------- + +/// **F1.** A declared bundle loads, and what it contributed is on the contract +/// under the namespaced name io-harness gave it. +/// +/// Sabotage: drop the `plugins.apply_to(contract)` line from +/// `contract::configured`. Under it only this test fails, and it fails silently in +/// the field: `/plugin` still lists the bundle, `Config::plugins()` still loads it, +/// the trace still reports it — and the agent it defines cannot be spawned, its +/// skills never reach the run, and the operator has no way to tell a bundle that +/// did nothing from one that was never read. +#[test] +fn f1_a_declared_bundle_reaches_the_turn_with_every_name_namespaced() { + let (_dir, root) = root(); + bundle(&root, "bundles/rust-review", MINIMAL); + declaring(&root, PROJECT_FILE, &["bundles/rust-review"]); + + let config = Config::discover(&root).expect("the configuration loads"); + + // io-harness's own verdict first: this bundle loaded and nothing was dropped. + let plugins = config.plugins(); + assert_eq!(plugins.names(), vec!["rust-review"]); + assert_eq!(plugins.len(), 1); + assert!(!plugins.is_empty()); + assert!( + plugins.dropped().is_empty(), + "a manifest out of io-harness's own documentation was refused: {:?}", + plugins.dropped(), + ); + + let plugin = plugins.get("rust-review").expect("loaded by id"); + assert_eq!(plugin.id(), "rust-review"); + assert_eq!(plugin.root(), root.join("bundles/rust-review")); + assert_eq!( + plugin.description(), + Some("Everything our Rust reviews need.") + ); + assert_eq!(plugin.version(), None, "the manifest declared none"); + assert_eq!( + plugin.skills_dir(), + Some(root.join("bundles/rust-review/skills")), + ); + assert_eq!( + plugin.templates_dir(), + Some(root.join("bundles/rust-review/templates")), + ); + assert_eq!( + plugin.contributions(), + vec!["skills", "templates", "agents", "policy"], + "the kinds io-harness reports, in its own fixed order", + ); + assert_eq!(plugin.agents()[0].name, "rust-review__reviewer"); + assert_eq!(plugin.policy_layers()[0].name, "rust-review__no-secrets"); + assert!(plugin.mcp_servers().is_empty()); + + // And the contract a turn actually carries. + let contract = io_cli::contract::configured("review this", root.clone(), &config); + assert!( + contract.agents.get("rust-review__reviewer").is_some(), + "the bundle's agent is not on the contract, so nothing can spawn it: {:?}", + contract.agents.names(), + ); + assert!( + contract.agents.get("reviewer").is_none(), + "the bare name is on the roster, so the trace and the roster spell the same \ + agent two different ways: {:?}", + contract.agents.names(), + ); + assert_eq!( + contract.plugins.len(), + 1, + "`contract.plugins` is what `discover_skills` reads at run start to fold a \ + bundle's skills in, and it is empty", + ); + + // io-cli's own surface agrees with the harness it read. + let view = pluginview::view(&config); + assert!(!view.is_empty()); + assert_eq!(view.plugins.len(), 1); + assert!(view.refused.is_empty()); + assert_eq!(view.plugins[0].agents, vec!["rust-review__reviewer"]); +} + +// --------------------------------------------------------------------------- +// F2 — a broken bundle is one row, never an error +// --------------------------------------------------------------------------- + +/// **F2.** Each of the five ways a bundle can be refused costs exactly that +/// bundle, and `Config::plugins()` returns rather than failing. +/// +/// Sabotage: the `if let Some(bad) = plugins.dropped().first() { return Err(...) }` +/// io-harness's own module docs offer, written into `contract::configured` — or, +/// equivalently, a `/plugin` that returned a `Result`. Under it only this test +/// fails, and it fails the way a shared `io.toml` fails a team: one operator +/// commits a bundle with a typo in its `[policy]` block and every session in the +/// repository stops starting, including the four bundles that were fine. +#[test] +fn f2_five_ways_a_bundle_breaks_each_cost_exactly_that_bundle() { + let (_dir, root) = root(); + + bundle(&root, "good", MINIMAL); + // 1. A directory declared as a bundle with no manifest in it. + empty_bundle(&root, "no-manifest"); + // 2. A manifest that is not TOML. + bundle(&root, "unparseable", "this is not a toml document at all\n"); + // 3. An id outside `[a-z0-9][a-z0-9-]{0,31}`. + bundle(&root, "bad-id", "name = \"Rust_Review\"\n"); + // 4. A `[policy]` carrying a `defaults` table — accepted by the parser and + // refused by name, because a default decides every action no rule mentions. + bundle( + &root, + "policy-defaults", + "name = \"defaulting\"\n\n[policy.defaults]\nwrite = \"allow\"\n", + ); + // 5. A `[policy]` rule whose effect is not `deny`. A bundle may take capability + // away and may never hand it out. + bundle( + &root, + "policy-allow", + "name = \"widening\"\n\n[policy]\nlayers = [{ name = \"open\", rules = [\ + { act = \"write\", effect = \"allow\", pattern = \"**\" }] }]\n", + ); + + declaring( + &root, + LOCAL_FILE, + &[ + "good", + "no-manifest", + "unparseable", + "bad-id", + "policy-defaults", + "policy-allow", + ], + ); + + // The call returns. That is the first half of the criterion and it is asserted + // by there being a line after it. + let config = Config::discover(&root).expect("a broken bundle is not a broken configuration"); + let plugins = config.plugins(); + + assert_eq!( + plugins.names(), + vec!["rust-review"], + "the one sound bundle did not load beside five broken ones", + ); + assert_eq!(plugins.dropped().len(), 5); + + // Each one, by the reason io-harness gave for it. + for (at, phrase) in [ + ( + "no-manifest", + "a plugin is a directory with a manifest at its root", + ), + ("unparseable", PLUGIN_FILE), + ("bad-id", "is not a usable plugin id"), + ("policy-defaults", "key `policy.defaults`"), + ("policy-allow", "may only contribute `deny`"), + ] { + let dropped = dropped_at(&plugins, at); + assert!( + dropped.error.contains(phrase), + "the bundle at `{at}` was dropped for something other than the reason \ + under test: {}", + dropped.error, + ); + assert!( + plugins.get(&dropped.id).is_none(), + "`{at}` is both dropped and loaded", + ); + } + + // And io-cli's surface holds both facts at once rather than one instead of the + // other — which is what `View::is_empty` reading both lists exists for. + let view = pluginview::view(&config); + assert_eq!(view.plugins.len(), 1); + assert_eq!(view.refused.len(), 5); + assert!(!view.is_empty()); +} + +// --------------------------------------------------------------------------- +// F3 — the trust rule, and that it costs the whole bundle +// --------------------------------------------------------------------------- + +/// A bundle that contributes an agent, skills, and one thing that runs a program. +fn executing(kind: &str) -> String { + let block = match kind { + "hook" => "[[hook]]\non = [\"finished\"]\nrun = [\"touch\", \"ran\"]\n", + "mcp" => "[[mcp]]\nid = \"docs\"\ntransport = \"stdio\"\ncommand = \"mcp-docs\"\n", + other => panic!("no such contribution: {other}"), + }; + format!("name = \"runner\"\nskills = \"skills\"\n\n[[agent]]\nname = \"reviewer\"\n\n{block}") +} + +/// **F3.** A bundle declared in the committed `io.toml` that contributes a +/// `[[hook]]` or an `[[mcp]]` is dropped **whole**. +/// +/// The assertion is deliberately not "the hook was skipped". A half-applied +/// stranger's manifest is the failure the rule exists to prevent: the bundle's +/// agent, its skills directory and its policy come from the same file as the argv, +/// and a loader that kept them would be trusting the author it just refused. +/// +/// Sabotage: change `refuse_executing_contributions` from an error into a filter +/// that clears `manifest.hook` and `manifest.mcp` and lets the rest load — which is +/// the reading a reviewer would call the friendlier fix. Under it only this test +/// fails, and what ships is a `git clone` that installs an agent definition and a +/// skills directory of the cloned repository's choosing into every session run in +/// it. +#[test] +fn f3_a_bundle_that_names_a_program_is_dropped_whole_from_the_committed_file() { + for kind in ["hook", "mcp"] { + let (_dir, root) = root(); + bundle(&root, "runner", &executing(kind)); + declaring(&root, PROJECT_FILE, &["runner"]); + + let config = Config::discover(&root).expect("a refused bundle is not a refused file"); + let plugins = config.plugins(); + + assert!( + plugins.is_empty(), + "{kind}: the bundle loaded from the file a `git clone` delivers", + ); + let dropped = dropped_at(&plugins, "runner"); + assert!( + dropped + .error + .contains(&format!("may not contribute `[[{kind}]]`")), + "{kind}: dropped for something else: {}", + dropped.error, + ); + + // **Nothing at all, rather than everything but the offending array.** + let contract = io_cli::contract::configured("go", root.clone(), &config); + assert!( + contract.agents.get("runner__reviewer").is_none(), + "{kind}: the refused bundle's agent reached the turn anyway: {:?}", + contract.agents.names(), + ); + assert!( + contract.plugins.is_empty(), + "{kind}: the refused bundle is on the contract, so its skills directory \ + is folded in at run start", + ); + assert!( + !contract + .mcp + .iter() + .any(|server| server.id.contains("runner")), + "{kind}: the refused bundle's server reached the turn", + ); + assert!( + io_cli::contract::hooks(&config, &root).is_none(), + "{kind}: the refused bundle's hooks are installed", + ); + } +} + +/// **F3, the other half.** The same manifest declared in `io.local.toml` loads, +/// with all six kinds. +/// +/// Without this the rule above is indistinguishable from "a bundle may never +/// contribute a hook or a server", which would make the feature useless rather +/// than safe — and a fix that refused everywhere would pass every assertion in the +/// test above. +/// +/// Sabotage: apply `refuse_executing_contributions` in every scope rather than +/// under `if scope == Scope::Project`. Under it only this test fails, and the +/// product ships a bundle format whose two most useful contributions can never be +/// declared by anyone. +#[test] +fn f3_the_same_manifest_declared_locally_contributes_all_of_it() { + for kind in ["hook", "mcp"] { + let (_dir, root) = root(); + bundle(&root, "runner", &executing(kind)); + // The local file, which does not travel with a clone — and no `io.toml` at + // all, so nothing but the scope differs from the test above. + declaring(&root, LOCAL_FILE, &["runner"]); + + let config = Config::discover(&root).expect("the configuration loads"); + let plugins = config.plugins(); + + assert!( + plugins.dropped().is_empty(), + "{kind}: dropped from the local scope: {:?}", + plugins.dropped(), + ); + assert_eq!(plugins.names(), vec!["runner"]); + let plugin = plugins.get("runner").expect("loaded"); + assert!( + plugin.contributions().contains(&kind_name(kind)), + "{kind}: loaded without the contribution under test: {:?}", + plugin.contributions(), + ); + + let contract = io_cli::contract::configured("go", root.clone(), &config); + assert!( + contract.agents.get("runner__reviewer").is_some(), + "{kind}: the bundle loaded and its agent did not reach the turn: {:?}", + contract.agents.names(), + ); + assert_eq!(contract.plugins.len(), 1); + match kind { + "mcp" => assert!( + contract.mcp.iter().any(|s| s.id == "runner__docs"), + "the namespaced server is not on the contract: {:?}", + contract.mcp.iter().map(|s| &s.id).collect::>(), + ), + _ => assert!( + io_cli::contract::hooks(&config, &root).is_some(), + "the bundle's hook is declared and nothing will run it", + ), + } + } +} + +/// What `Plugin::contributions` calls each kind, which is not what the TOML array +/// is called. +fn kind_name(kind: &str) -> &'static str { + match kind { + "hook" => "hooks", + "mcp" => "mcp", + other => panic!("no such contribution: {other}"), + } +} + +// --------------------------------------------------------------------------- +// F4 — the two writes +// --------------------------------------------------------------------------- + +/// A file with a comment, a bundle already declared, and two sections io-cli does +/// not model. +const OPERATORS: &str = "\ +# the bundles this repository uses +[[plugin]] +path = \"fixtures/nowhere/alpha\" + +[instructions] +files = [\"AGENTS.md\"] + +[run] +max_steps = 30 +"; + +/// **F4.** `add` appends a whole `[[plugin]]` entry, and everything else in the +/// file survives it byte for byte. +/// +/// This crate splices bytes rather than re-serialising, precisely so a key it has +/// no model for survives a write to its neighbour — and that property has to be +/// asserted rather than assumed, because the alternative implementation is the +/// obvious one. +/// +/// Sabotage: implement `add` by deserializing the file into io-cli's own types, +/// pushing an entry and writing the result back. Under it only this test fails, +/// and it fails by deleting the operator's comment, their `[instructions]` table +/// and every key of a section a later io-harness added — silently, on a keystroke +/// they pressed to add one line. +#[test] +fn f4_add_appends_an_entry_and_the_rest_of_the_file_survives_untouched() { + let after = io_cli::edit::apply( + OPERATORS, + &[pluginview::add(Path::new("fixtures/nowhere/beta"))], + ) + .expect("the edit applies"); + + // Everything that was there is still there, line for line — the comment + // included. + for line in OPERATORS.lines().filter(|line| !line.trim().is_empty()) { + assert!(after.contains(line), "line lost: {line:?}"); + } + + // The result is still the harness's schema, and the harness's own reader is + // what says which bundles it now declares and in what order. + let config = Config::from_toml(&after).expect("the written file loads"); + let declared: Vec = config + .plugins() + .dropped() + .iter() + .map(|dropped| dropped.path.display().to_string().replace('\\', "/")) + .collect(); + assert_eq!( + declared, + vec!["./fixtures/nowhere/alpha", "./fixtures/nowhere/beta"], + "the new entry is not the last declaration, so it does not stack last", + ); +} + +/// **F4.** `remove` takes the indexed entry whole and leaves its siblings and the +/// sections around it. +/// +/// By index rather than by id, and the fixture says why: the second entry names a +/// directory with no manifest, so it has no id to be removed by — and it is +/// exactly the entry an operator opens `/plugin` to be rid of. +/// +/// Sabotage: `Edit::remove("plugin")` without the index, or an index off by one. +/// Under it only this test fails, and it fails by removing a bundle the operator +/// did not pick — or by taking the `[run]` table that followed the last entry with +/// it, which is a step cap silently back to io-harness's twelve. +#[test] +fn f4_remove_takes_the_indexed_entry_and_leaves_its_siblings() { + const TWO: &str = "\ +[[plugin]] +path = \"fixtures/nowhere/alpha\" + +[[plugin]] +path = \"fixtures/nowhere/beta\" + +[run] +max_steps = 30 +"; + let after = io_cli::edit::apply(TWO, &[pluginview::remove(0)]).expect("the edit applies"); + + let config = Config::from_toml(&after).expect("the written file loads"); + let declared: Vec = config + .plugins() + .dropped() + .iter() + .map(|dropped| dropped.path.display().to_string().replace('\\', "/")) + .collect(); + assert_eq!( + declared, + vec!["./fixtures/nowhere/beta"], + "the removal did not reach exactly the entry it named", + ); + assert!( + after.contains("max_steps = 30"), + "the section after the last entry was taken with it", + ); +} + +// --------------------------------------------------------------------------- +// F2/F3 rendering — the sentence, and the index mapping +// --------------------------------------------------------------------------- + +/// A root with one bundle that loads and one that does not. +fn one_of_each() -> (tempfile::TempDir, PathBuf, Config) { + let (dir, root) = root(); + bundle(&root, "good", MINIMAL); + empty_bundle(&root, "broken"); + declaring(&root, LOCAL_FILE, &["good", "broken"]); + let config = Config::discover(&root).expect("the configuration loads"); + (dir, root, config) +} + +/// **F2/F3 rendering.** A refused bundle's row carries io-harness's sentence +/// unmodified, and the row index maps straight back into the two lists. +/// +/// The sentence is the operator's only instruction. It names the directory, names +/// the key, and for the project-scope refusal explains in two clauses why a cloned +/// `io.toml` may not name a program this machine will run. io-cli could not write +/// that better and must not try. +/// +/// Sabotage, either half: reword the row to "this plugin could not be loaded", or +/// insert a heading row above the refused group. Under the first, an operator is +/// told a bundle is broken and never told what to change. Under the second, every +/// index past the heading is off by one and `/plugin` acts on the wrong bundle — +/// which is the defect `rows` is heading-free to avoid, and which the unit tests +/// in `src/pluginview.rs` cannot see because they build their `View` by hand. +#[test] +fn f2_a_refused_row_carries_the_harness_sentence_and_the_index_still_maps() { + let (_dir, _root, config) = one_of_each(); + let view = pluginview::view(&config); + assert_eq!(view.plugins.len(), 1); + assert_eq!(view.refused.len(), 1); + + for glyphs in [&io_cli::glyphs::UNICODE, &io_cli::glyphs::ASCII] { + // Wide enough that nothing has to be shortened, so "verbatim" is a + // comparison rather than a prefix check. + let rows = pluginview::rows(&view, 400, glyphs); + assert_eq!( + rows.len(), + view.plugins.len() + view.refused.len(), + "{}: `rows` is one row per bundle and no headings", + glyphs.name, + ); + + // The positional contract, asserted as the contract states it. + assert_eq!(rows[0].label, view.plugins[0].id, "{}", glyphs.name); + assert_eq!(rows[0].mark, Some(pluginview::LOADED_MARK)); + assert_eq!(rows[1].label, view.refused[0].id, "{}", glyphs.name); + assert_eq!(rows[1].mark, Some(pluginview::REFUSED_MARK)); + assert!( + rows.iter().all(|row| !row.heading), + "{}: a heading is an index that maps to no bundle", + glyphs.name, + ); + + let detail = rows[1].detail.clone().expect("a refused row has a detail"); + assert_eq!( + detail, + format!("not loaded{}{}", glyphs.separator, view.refused[0].error), + "{}: the row is not io-harness's sentence, whole", + glyphs.name, + ); + assert!( + view.refused[0] + .error + .contains("a plugin is a directory with a manifest at its root"), + "the sentence io-cli carried is not the one io-harness wrote: {}", + view.refused[0].error, + ); + } +} + +/// **N4.** Every row fits eighty columns in both glyph sets, and the ASCII set +/// draws nothing an ASCII terminal cannot. +/// +/// Asserted over a `View` built from a real configuration rather than by hand, so +/// what is being fitted is a real tempdir path and io-harness's real sentence — +/// both far longer than anything a hand-built fixture would think to use, and both +/// what an operator will actually have on the row. +/// +/// Sabotage: budget the row against `width` instead of `width - 4`, which is the +/// picker's marker and gap. Under it only this test fails, and it fails on the +/// operator's terminal rather than in the suite: every `/plugin` row wraps, the +/// list doubles in height, and the panel scrolls where it used to fit. +#[test] +fn n4_every_row_fits_eighty_columns_in_both_glyph_sets() { + let (_dir, _root, config) = one_of_each(); + let view = pluginview::view(&config); + + for glyphs in [&io_cli::glyphs::UNICODE, &io_cli::glyphs::ASCII] { + for row in pluginview::rows(&view, 80, glyphs) { + let detail = row.detail.clone().expect("every bundle row has a detail"); + assert!( + row.label.chars().count() + detail.chars().count() + 4 <= 80, + "{}: the row does not fit: {} / {detail}", + glyphs.name, + row.label, + ); + if glyphs.name == io_cli::glyphs::ASCII.name { + for character in detail.chars().chain(row.label.chars()) { + assert!( + character.is_ascii(), + "the ASCII set drew {character:?}, which the terminal that \ + needed the fallback cannot: {detail}", + ); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// `declared_at` — the bridge from a row on screen to an entry in a file +// --------------------------------------------------------------------------- +// +// `/plugin`'s remove is `edit::Edit::remove("plugin[i]")`, and `i` is an index +// into one file's `[[plugin]]` array. Nothing io-harness returns carries that +// number: `Config::plugins()` hands back loaded bundles and dropped ones as two +// lists ordered by neither the file nor the entry, and it never says which of the +// three scopes declared any of them. So the only thing a row and an entry share is +// the path, and `declared_at` is the whole of the crossing. Everything below is +// about the two ways that crossing can be wrong — landing on the wrong entry, or +// claiming to have found one that is not there — because both are silent: the +// operator loses a bundle they never mentioned and finds out a week later when its +// skills stop being offered. +// +// These fixtures declare paths inside a fresh `tempfile` root, so the user-scope +// file `declared_at` also reads on the machine running the suite cannot name one +// of them and cannot answer instead. + +/// A `[[plugin]]` entry naming `path`, spelled as a TOML basic string. +/// +/// The backslash escaping is not decoration: `pluginview::add` writes the value +/// through `quoted` for the same reason, and an unescaped absolute Windows path in +/// `path = "C:\Users\..."` is a different path or a parse error. A fixture that +/// wrote `format!("path = \"{}\"", ...)` would be green on Unix and would test the +/// wrong string on the one platform the escaping exists for. +fn declaration(path: &Path) -> String { + format!( + "[[plugin]]\npath = \"{}\"\n\n", + path.display().to_string().replace('\\', "\\\\") + ) +} + +/// The entry's index is the entry's own, not the position of anything else. +/// +/// The target sits second of three in the file, and the two either side are there +/// so that an off-by-one, a `.position()` over the loaded list, or a row number +/// from `rows` cannot accidentally agree with the right answer. +/// +/// Sabotage: return the row's index — `rows(&view, ..)` position — instead of +/// walking the file, which is exactly the shortcut this function exists to refuse +/// and which reads as obviously equivalent in review. Under it only this test +/// fails, and what ships is `/plugin` confirming the bundle on screen and deleting +/// the `[[plugin]]` entry above or below it. +#[test] +fn declared_at_finds_the_entrys_own_index_and_not_a_position_in_some_other_list() { + let (_dir, root) = root(); + let target = root.join("bundles/rust-review"); + let text = format!( + "{}{}{}", + declaration(&root.join("bundles/first")), + declaration(&target), + declaration(&root.join("bundles/third")), + ); + std::fs::write(root.join(LOCAL_FILE), text).expect("the configuration"); + + assert_eq!( + pluginview::declared_at(&root, &target), + Some((io_harness::config::Scope::Local, 1)), + "the second of three entries is entry one", + ); + // The neighbours resolve to themselves, so the answer above is a match rather + // than a constant. + assert_eq!( + pluginview::declared_at(&root, &root.join("bundles/third")), + Some((io_harness::config::Scope::Local, 2)), + ); +} + +/// A declaration written relative and one written absolute name the same +/// directory, and both are found. +/// +/// Both spellings are legal and both are common — `path = "bundles/rust-review"` +/// is what an operator types, and `pluginview::add` writes whatever the picker +/// resolved, which is absolute. What comes back from `Config::plugins()` is always +/// the resolved directory, so a comparison against the raw string alone would find +/// the relative entry never, and a comparison against the resolved value alone +/// would find nothing in a file whose author wrote an absolute path with a +/// trailing difference. Both roads are walked and this asserts both. +/// +/// Sabotage: drop the `resolved` half and compare only `declared == bundle`. Under +/// it only this test fails, and it fails in the direction that costs most: every +/// relative declaration — the spelling a human writes by hand — becomes a bundle +/// `/plugin` can list and can never remove, so the panel's remove key refuses on a +/// row that is plainly there. +#[test] +fn declared_at_matches_a_relative_declaration_and_an_absolute_one_alike() { + let bundle = "bundles/rust-review"; + + let (_dir, relative) = root(); + std::fs::write( + relative.join(LOCAL_FILE), + format!("[[plugin]]\npath = \"{bundle}\"\n"), + ) + .expect("the configuration"); + assert_eq!( + pluginview::declared_at(&relative, &relative.join(bundle)), + Some((io_harness::config::Scope::Local, 0)), + "a relative declaration is resolved against the root before it is compared", + ); + + let (_dir, absolute) = root(); + let resolved = absolute.join(bundle); + std::fs::write(absolute.join(LOCAL_FILE), declaration(&resolved)).expect("the configuration"); + assert_eq!( + pluginview::declared_at(&absolute, &resolved), + Some((io_harness::config::Scope::Local, 0)), + "an absolute declaration is compared as written", + ); +} + +/// A path no file names is `None`, and that is the answer the caller has to be +/// able to get. +/// +/// **The most important test here, and the one whose absence is invisible.** The +/// caller's alternative to `None` is removing *something* — the nearest index, row +/// zero, the last entry it looked at — and every one of those deletes a +/// `[[plugin]]` line the operator never pointed at. There is no error, no +/// confirmation that reads wrong, and no way back: the bundle is simply gone from +/// the file, and the next session is missing skills, agents and MCP servers nobody +/// removed. +/// +/// The fixture declares a real entry, so `None` here is a miss rather than an +/// empty file — and the near-miss path is a sibling of the declared one, because +/// the mistakes this guards against are prefix and suffix confusions rather than +/// wild pointers. +/// +/// Sabotage: end the scope loop with `Some((Scope::Local, 0))` instead of `None` +/// — a "we must have meant the first one" fallback, the kind that gets added to +/// silence an `Option` the caller found awkward. Under it only this test fails. +#[test] +fn declared_at_refuses_a_path_no_file_names_rather_than_guessing() { + let (_dir, root) = root(); + std::fs::write( + root.join(LOCAL_FILE), + declaration(&root.join("bundles/rust-review")), + ) + .expect("the configuration"); + + for missing in [ + root.join("bundles/rust-reviewer"), + root.join("bundles/rust-review/skills"), + root.join("bundles"), + root.join("elsewhere/rust-review"), + ] { + assert_eq!( + pluginview::declared_at(&root, &missing), + None, + "{} is named by no file, and the caller was handed an entry to delete", + missing.display(), + ); + } + + // The declared one is still found, so the four answers above are misses rather + // than a function that has stopped working. + assert!(pluginview::declared_at(&root, &root.join("bundles/rust-review")).is_some()); +} + +/// Declared in two scopes, the local file answers. +/// +/// Local-first is the precedence io-harness itself applies, so the entry that was +/// actually deciding is the entry that gets removed. Removing the project one +/// instead is worse than doing nothing twice over: the bundle keeps loading — +/// `io.local.toml` still names it — and the operator has silently edited a file +/// that is committed and shared with everyone else on the repository. +/// +/// Sabotage: iterate `[Scope::User, Scope::Project, Scope::Local]`, which is the +/// order the enum declares its variants in and therefore the order a rewrite +/// naturally reaches for. Under it only this test fails. +#[test] +fn declared_at_answers_the_local_file_when_two_scopes_declare_the_same_bundle() { + let (_dir, root) = root(); + let target = root.join("bundles/rust-review"); + // Two entries in the project file, so a scope confusion would also land on a + // different index and cannot be mistaken for a coincidence. + std::fs::write( + root.join(PROJECT_FILE), + format!( + "{}{}", + declaration(&root.join("bundles/first")), + declaration(&target) + ), + ) + .expect("the project configuration"); + std::fs::write(root.join(LOCAL_FILE), declaration(&target)).expect("the local configuration"); + + assert_eq!( + pluginview::declared_at(&root, &target), + Some((io_harness::config::Scope::Local, 0)), + "the file that was deciding is the file that is edited", + ); +} + +/// A configuration with no `[[plugin]]` at all is `None`, and so is no +/// configuration at all. +/// +/// The walk stops at the first index `value_at` cannot answer, on the argument +/// that an array of tables is contiguous. At index zero of a file that has no such +/// array that argument has to terminate the loop rather than run it — and a file +/// that is not there at all has to be skipped rather than end the search of the +/// scopes after it. +/// +/// Sabotage: `for index in 0..` with a `continue` on the miss instead of a +/// `break`. Nothing fails: the suite is green, `/plugin` works, and the process +/// hangs on the first configuration that declares no bundle — which is every +/// configuration in the world until somebody writes their first `[[plugin]]`. +/// Under this test it fails by never returning, which is what a hang looks like in +/// a suite and is the only way this shape can be caught at all. +#[test] +fn declared_at_is_none_where_no_file_declares_a_plugin_array() { + let (_dir, empty) = root(); + std::fs::write(empty.join(LOCAL_FILE), "[run]\nmax_steps = 40\n").expect("the configuration"); + assert_eq!( + pluginview::declared_at(&empty, &empty.join("bundles/rust-review")), + None, + ); + + // And with nothing written at all, so an unreadable scope file is skipped + // rather than ending the search. + let (_dir, bare) = root(); + assert_eq!( + pluginview::declared_at(&bare, &bare.join("bundles/rust-review")), + None, + ); +} diff --git a/tests/steer.rs b/tests/steer.rs index cb2d0af..49a1ce0 100644 --- a/tests/steer.rs +++ b/tests/steer.rs @@ -75,6 +75,14 @@ fn squashed(text: &str) -> String { /// reads. Only this test fails — and it is the one failure an operator cannot /// detect from the screen, because the send still returns `Ok` and no event ever /// said otherwise. +/// +/// **The observer argument is spelled `watcher` from 0.20.0, and the change is +/// the point rather than a rename.** Through 0.19.0 the driver passed `&observer` +/// — the `Bridge` itself, one value, the only observer there was. It now passes +/// `Broadcast` over a `Fanout` over the bridge and the operator's `Hooks`, and +/// the local holding that composition is named for what it is. What this test +/// asserts is unchanged: both arms take the caller's contract **and** the inbox, +/// in that positional order. #[test] fn f5_both_arms_are_handed_a_steer_inbox() { let text = driver(); @@ -82,7 +90,7 @@ fn f5_both_arms_are_handed_a_steer_inbox() { assert!( flat.contains( - "session.turn_bounded_steered(&contract,provider,store,policy,&approver,&observer,\ + "session.turn_bounded_steered(&contract,provider,store,policy,&approver,watcher,\ &inbox" ), "the flat arm takes the contract and the inbox", @@ -90,7 +98,7 @@ fn f5_both_arms_are_handed_a_steer_inbox() { assert!( flat.contains( "session.turn_contained_bounded_steered(&contract,provider,store,policy,&approver,\ - caps,&observer,&inbox" + caps,watcher,&inbox" ), "and so does the contained one, which is the arm that could not have one at all before \ io-harness 0.67.0",