diff --git a/.github/release-plz.toml b/.github/release-plz.toml index 9794a427..6a9d2596 100644 --- a/.github/release-plz.toml +++ b/.github/release-plz.toml @@ -32,6 +32,12 @@ git_release_name = "v{{ version }}" # where check-changelog-headers.sh and the pre-commit hook already read it. changelog_path = "CHANGELOG.md" +# Workspace member that never ships: `publish = false` alone still gets tags and +# a release PR entry from release-plz. +[[package]] +name = "herdr-pond" +release = false + # Lance-style release notes: emoji-grouped sections, scope-bolded entries, PR # links, breaking markers, and a per-version compare link. release-plz strips the # `## [version]` heading line when posting the GitHub release body (GitHub shows diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59e712c5..21cea98f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,7 @@ jobs: moon ci repo:check-changelog pond:check-package pond:format pond:lint pond:test + herdr-pond:format herdr-pond:lint herdr-pond:test openclaw-pond:typecheck openclaw-pond:test pi-pond:typecheck pi-pond:test hermes-pond:test diff --git a/.moon/workspace.yml b/.moon/workspace.yml index 615d7e0b..2c4c25e0 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -1,9 +1,11 @@ # moon v2.x workspace config. packages/ monorepo: the Rust crate under -# packages/pond, the OpenClaw plugin under packages/openclaw-pond, the Hermes -# plugin under packages/hermes-pond, the pi extension under packages/pi-pond, +# packages/pond, the herdr plugin under packages/herdr-pond, the OpenClaw +# plugin under packages/openclaw-pond, the Hermes plugin under +# packages/hermes-pond, the pi extension under packages/pi-pond, # and repo-level tasks (changelog gate, dist build) on the root `repo` project. projects: pond: 'packages/pond' + herdr-pond: 'packages/herdr-pond' openclaw-pond: 'packages/openclaw-pond' hermes-pond: 'packages/hermes-pond' pi-pond: 'packages/pi-pond' diff --git a/Cargo.lock b/Cargo.lock index efddd763..5a738e92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,7 +342,7 @@ version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "serde_core", "serde_json", ] @@ -1033,15 +1033,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -1056,9 +1071,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitvec" @@ -1163,6 +1178,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.0" @@ -1295,7 +1316,7 @@ dependencies = [ "byteorder", "candle-core", "candle-nn", - "fancy-regex", + "fancy-regex 0.17.0", "num-traits", "rand 0.9.4", "rayon", @@ -1396,7 +1417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf", + "phf 0.12.1", ] [[package]] @@ -1639,6 +1660,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1760,6 +1790,12 @@ dependencies = [ "cfg-if 1.0.4", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1819,11 +1855,16 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "crossterm_winapi", + "derive_more", "document-features", + "futures-core", + "mio", "parking_lot", "rustix", + "signal-hook", + "signal-hook-mio", "winapi", ] @@ -1861,6 +1902,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "csv" version = "1.4.0" @@ -1899,7 +1950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix", + "nix 0.31.3", "windows-sys 0.61.2", ] @@ -2716,6 +2767,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -2768,6 +2825,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "dhat" version = "0.3.3" @@ -2841,7 +2920,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -3004,6 +3083,15 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -3047,13 +3135,23 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fancy-regex" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] @@ -3086,12 +3184,35 @@ dependencies = [ "version_check", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -3104,7 +3225,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "rustc_version", ] @@ -3953,6 +4074,25 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "herdr-pond" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "crossterm", + "futures-util", + "nix 0.31.3", + "ratatui", + "reqwest 0.13.4", + "serde", + "serde_json", + "textwrap", + "tokio", + "toml 1.1.2+spec-1.1.0", + "unicode-width", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -4492,6 +4632,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inlinable_string" version = "0.1.15" @@ -4508,6 +4657,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling 0.24.1", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "integer-encoding" version = "3.0.4" @@ -4520,7 +4682,7 @@ version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cfg-if 1.0.4", "libc", ] @@ -4716,6 +4878,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "konst" version = "0.4.3" @@ -4733,6 +4906,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lance" version = "12.0.0" @@ -5454,6 +5633,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "link-section" version = "0.19.0" @@ -5512,6 +5700,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -5546,6 +5743,16 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "mac_address" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a" +dependencies = [ + "nix 0.30.1", + "windows-sys 0.61.2", +] + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -5635,13 +5842,28 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metal" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "block", "core-graphics-types", "foreign-types", @@ -5695,6 +5917,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -5768,13 +5991,38 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if 1.0.4", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.2", + "cfg-if 1.0.4", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cfg-if 1.0.4", "cfg_aliases", "libc", @@ -5873,6 +6121,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -5946,6 +6205,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "numeric_cast" version = "0.3.0" @@ -5976,7 +6244,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "dispatch2", "objc2", ] @@ -5993,7 +6261,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -6016,7 +6284,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "block2", "dispatch2", "objc2", @@ -6091,7 +6359,7 @@ dependencies = [ "hyper", "itertools 0.15.0", "md-5", - "nix", + "nix 0.31.3", "parking_lot", "percent-encoding", "quick-xml", @@ -6152,7 +6420,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "libc", "once_cell", "onig_sys", @@ -6473,6 +6741,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "5.3.0" @@ -6507,6 +6784,39 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking" version = "2.2.1" @@ -6669,25 +6979,119 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df202b0b0f5b8e389955afd5f27b007b00fb948162953f1db9c70d2c7e3157d7" +[[package]] +name = "pest" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.5", "indexmap 2.14.0", "serde", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "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", + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[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 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", ] [[package]] @@ -7261,13 +7665,114 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if 1.0.4", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +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.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "raw-cpuid" version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -7328,7 +7833,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -7786,7 +8291,7 @@ version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -7838,7 +8343,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -8137,7 +8642,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -8383,6 +8888,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -8648,7 +9174,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -8663,6 +9198,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "struson" version = "0.7.2" @@ -8670,7 +9217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8eaaf563e9de627dadbe66ff8b7ef8f065fc69844c90ed6acdc90bdc9f0571" dependencies = [ "duplicate", - "strum", + "strum 0.27.2", "thiserror 2.0.18", ] @@ -8686,6 +9233,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -8734,7 +9292,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "byteorder", "enum-as-inner", "libc", @@ -8762,7 +9320,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8802,12 +9360,88 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.2", + "fancy-regex 0.11.0", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float 4.6.0", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -8902,7 +9536,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -9248,7 +9884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.11.1", + "bitflags 2.13.2", "bytes", "futures-core", "futures-util", @@ -9416,6 +10052,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "ug" version = "0.5.0" @@ -9515,6 +10157,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" @@ -9626,6 +10279,7 @@ version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -9656,6 +10310,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -9835,7 +10498,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -9879,6 +10542,78 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float 4.6.0", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "which" version = "7.0.3" @@ -10300,7 +11035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.2", "indexmap 2.14.0", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9f77378e..231682ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,9 @@ [workspace] resolver = "3" -members = ["packages/pond"] +members = ["packages/pond", "packages/herdr-pond"] +# Root builds (dist scripts, bare `cargo build/test/clippy`) stay pond-only; +# herdr-pond is built and gated by name (`-p herdr-pond`, its moon tasks). +default-members = ["packages/pond"] # Profiles are honored only at the workspace root, so they live here (not in the # member manifest, where cargo ignores them). The crate is packages/pond. diff --git a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md index 8576298e..a029c9c8 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -9,10 +9,10 @@ This document is self-contained for fresh implementation agents. Where it cites ## 1. Decisions (all resolved; do not reopen) 1. **herdr-only TUI, separate crate, no TUI in pond.** `packages/herdr-pond` (workspace member, `publish = false`, bin `herdr-pond`), ratatui. Spec 2.3's "no UI" stands for pond core. All herdr calls live in one module so a standalone desk later is a fallback impl plus packaging, not a rewrite. -2. **The desk's data plane is `pond serve` over localhost HTTP** - search ranks (`/v1/search`), SQL lists and reads (`/v1/x/sql`, new), get-session deferred (see decision 9). No new typed endpoints; the `/v1/sessions` listing endpoint and an empty-query search relaxation were both considered and dropped (SQL covers the listing and is the only surface reaching the ingest-host stamp in `options.pond`; search stays strict). +2. **The desk's data plane is `pond serve`, HTTP on an owner-only Unix socket** - search ranks (`/v1/search`), SQL lists and reads (`/v1/x/sql`, new), get-session deferred (see decision 9). No new typed endpoints; the `/v1/sessions` listing endpoint and an empty-query search relaxation were both considered and dropped (SQL covers the listing and is the only surface reaching the ingest-host stamp in `options.pond`; search stays strict). 3. **`/v1/x/sql` is always on.** The `x/` prefix means "outside the stable wire contract"; spec.md:676 ("not an HTTP operation") is edited, 7.5 gains the operation marked unstable, and 7.2's additive-evolution guarantee gains an explicit `/v1/x/` carve-out (today 7.2 states the guarantee with no exception, spec.md:637). Coupling to storage schema is acceptable: herdr-pond is first-party, same repo, versions in lockstep. Posture matches the field (Arrow Flight SQL, Trino, lance-namespace `query_table`): results self-describe (column names in-band), the `schema://pond-sql` resource text is the discovery surface, the protocol is versioned and the data schema explicitly is not. Tenant scoping ([#166](https://github.com/tenequm/pond/issues/166)) is FUTURE work for this endpoint, not free compatibility: scoping SQL means auditing every provider path (raw dataset providers sql.rs:573, the ranked-FTS provider sql.rs:614), metadata/EXPLAIN exposure (bare EXPLAIN can leak whole-table stats), and auth routing. State that in the spec edit; do not claim the endpoint composes with #166 unchanged. -4. **Plugin-owned serve, bounded to the herdr server's lifetime, behaviorally read-only.** The plugin must not alter the operator's sync arrangement (`pond schedule` stays the sync owner; the idle hook adds scoped syncs that share the same per-host lock and update the same sync cursors - that is feature 1 working, not interference). The plugin's serve never passes `--with-sync` (no periodic sync task, main.rs:1783) and the plugin only ever sends read queries - but the process is NOT hard read-only: `/v1/ingest` is always routed (transport.rs:158), store open can heal/create/migrate (substrate.rs:5044,5058,5431), and prewarm touches local caches. Two consequences the code must honor: every plugin-owned spawn passes `--host 127.0.0.1` explicitly (`POND_HOST` inherited from herdr's env would otherwise rebind it, main.rs:766), and docs describe the serve as "personal localhost server, plugin sends only reads" - never "read-only server". A hard `--read-only` serve flag was considered and deferred (add it when a second consumer wants the guarantee). Lifecycle: section 5.6; fallback: the desk spawns its own child serve (5.7). Cold cost is paid once per herdr server, in the background, with warm-up queries. -5. **Two PRs.** PR1 = pond-side `/v1/x/sql` + `--port-file` (a `feat`, rides the release train). PR2 = the herdr-pond crate + manifest + CI wiring. Nothing in PR2 compile-depends on PR1 (the desk can shell out to `pond sql` in dev until PR1's release is installed). +4. **Plugin-owned serve, bounded to the herdr server's lifetime, behaviorally read-only.** The plugin must not alter the operator's sync arrangement (`pond schedule` stays the sync owner; the idle hook adds scoped syncs that share the same per-host lock and update the same sync cursors - that is feature 1 working, not interference). The plugin's serve never passes `--with-sync` (no periodic sync task, main.rs:1783) and the plugin only ever sends read queries - but the process is NOT hard read-only: `/v1/ingest` is always routed (transport.rs:158), store open can heal/create/migrate (substrate.rs:5044,5058,5431), and prewarm touches local caches. Two consequences the code must honor: every plugin-owned spawn serves only on a Unix socket (`pond serve --socket `, created owner-only 0600, #311) with `POND_HOST`/`POND_PORT` stripped from its env (pond ignores them beside `--socket`, but clap still parses them, so a malformed inherited `POND_PORT` would fail serve before it binds), and docs describe the serve as "personal owner-only socket server, plugin sends only reads" - never "read-only server". A hard `--read-only` serve flag was considered and deferred (add it when a second consumer wants the guarantee). Lifecycle: section 5.6; fallback: the desk spawns its own child serve (5.7). Cold cost is paid once per herdr server, in the background, with warm-up queries. +5. **Two PRs.** PR1 = pond-side `/v1/x/sql` (a `feat`, rides the release train); `pond serve --socket` followed in #311. PR2 = the herdr-pond crate + manifest + CI wiring. Nothing in PR2 compile-depends on PR1 (the desk can shell out to `pond sql` in dev until PR1's release is installed). 6. **Sync-on-idle enabled by default**, config gate to disable. Busy store lock = WAIT, not skip: the detached worker runs `pond sync -q` WITHOUT `--no-wait` and blocks on the per-host flock until the running sync finishes (`--no-wait` exits 0 "skipped" on a busy lock, main.rs:4258 - with it, a codex-idle during a claude sync would be silently discarded). Trailing-edge coalescing per 5.5 guarantees the last idle event always produces a sync. 7. **Plugin id `pond`**, action/pane id `desk` (qualified action `pond.desk` - short for keybindings; local ids cannot contain dots, manifest.rs:600-608), binary and crate `herdr-pond` (the binary must not be named `pond` - PATH shadowing). 8. **JSON results are a new `sql::Outcome::Json` variant**, not a parallel entrypoint - one result type, and the three exhaustive `Outcome` consumers outside the SQL module (main.rs:2013, tests/integration/schema_migration.rs:111, benches/sync_oracle_bench.rs:144) gain one arm each and move into agent A's ownership. @@ -32,7 +32,7 @@ Unmeasured: the first fts search in a fresh serve process ([#165](https://github Query discipline (from the [read-latency campaign](2609-17-read-latency-campaign.md) sql audit - unscoped messages GROUP BYs are the dominant timeout family): listing scans narrow columns only (`session_id`, `timestamp`, `source_agent`) with a `timestamp >=` bound the zonemap can prune (never arithmetic on the column side), project filter pushed down, subagents excluded via `source_agent NOT LIKE '%/%'`, and an explicit SQL `LIMIT` in EVERY query (the server's inline caps apply AFTER full collection, sql.rs:232,1206 - they are presentation limits, not scan bounds; the HTTP `limit` field alone does not bound server work). JSON getters (`options.pond` host, titles) run only per visible page (`session_id IN (...)`), never corpus-wide. ~10.6k pre-stamp sessions have no host stamp; a missing stamp means UNKNOWN provenance, not local (spec 4.8, and per-message stamps can differ within a session, spec.md:433) - render unstamped as a dim `local?` fallback and say so in the README. Backfill stays out of scope. -## 3. PR1 - pond: `POST /v1/x/sql` + `--port-file` (agent A) +## 3. PR1 - pond: `POST /v1/x/sql` (agent A) ### 3.1 What exists (verified, packages/pond/src) @@ -49,9 +49,9 @@ Query discipline (from the [read-latency campaign](2609-17-read-latency-campaign 2. Update the three exhaustive `Outcome` consumers (decision 8). Benches are linted via `--all-targets` (packages/pond/moon.yml:44), so the bench arm is not optional. 3. Wire types: `SqlRequest { protocol_version, namespace, query (serde alias "sql"), #[serde(default)] limit: Option, timeout_seconds: Option }`, `SqlResponse { columns, rows, row_count, truncated, elapsed_ms }`, `SqlEnvelope` untagged. Errors: query-shaped failures -> `validation_failed` (400); infra -> `internal` / `storage_unavailable`. Extend the timeout text in `sql::run` with the HTTP field name. 4. `handlers::pond_sql(store, SqlRequest) -> SqlEnvelope` (validates protocol + namespace first; transport stays logic-free), route `.route("/v1/x/sql", post(sql))`, handler following the search pattern verbatim. -5. **`pond serve --port-file `**: after a successful bind, write `host:port` to the path atomically (temp + rename), then serve. This is the readiness signal the plugin lifecycle needs (5.6): serve opens the store BEFORE binding (main.rs:1768) and the stdout "listening" line prints pre-bind (main.rs:1794, transport.rs:185), so seconds can pass between spawn and a live socket, and bind-then-release port reservation races. `--port 0` + `--port-file` removes both problems. Small flag, no behavior change without it. +5. **Superseded by `pond serve --socket ` (#311).** The plugin never binds TCP: the serve listens on an owner-only (0600) Unix socket at a plugin-chosen path, and readiness is the capability probe (5.8) answering over that socket - serve opens the store BEFORE binding (main.rs:1768), so a socket file alone proves nothing. No port file, no port reservation race. 6. Spec edits: rewrite spec.md:676 (SQL now has one HTTP exposure, fenced), add the operation to 7.5 as a class (unstable, outside additive evolution, self-describing results, `schema://pond-sql` as discovery - note it is an MCP resource; HTTP-only consumers read the checked-in resource text in transport.rs), add the 7.2 carve-out (decision 3), and the tenant-future note (decision 3). This sets the `/v1/x/` convention - say so. -7. Tests: unit tests beside the code for the JSON mode caps, JSONB/binary/timestamp shape, EXPLAIN ANALYZE rejection, and gate behavior; HTTP tests in `packages/pond/tests/integration/transport_http.rs` (drives `router()` with `tower::ServiceExt::oneshot`; helpers at :67-112): success shape, DML/DDL rejected, bad namespace rejected before dataset open (`SELECT 1` + bad namespace), bad-JSON body (axum plain rejection - clients MUST send `Content-Type: application/json`), timeout mapping, `--port-file` written after bind. +7. Tests: unit tests beside the code for the JSON mode caps, JSONB/binary/timestamp shape, EXPLAIN ANALYZE rejection, and gate behavior; HTTP tests in `packages/pond/tests/integration/transport_http.rs` (drives `router()` with `tower::ServiceExt::oneshot`; helpers at :67-112): success shape, DML/DDL rejected, bad namespace rejected before dataset open (`SELECT 1` + bad namespace), bad-JSON body (axum plain rejection - clients MUST send `Content-Type: application/json`), timeout mapping. Known side findings, NOT in scope (file as separate issues): spec says `namespace_unknown` = 403 but `status_for` maps it 400; spec 7.5 claims search takes `format: text|json` but `SearchRequest` has no such field. @@ -173,19 +173,19 @@ Failures go to `sync.log` (with a size cap - truncate at ~1 MiB), never stderr ( ### 5.6 The `serve-daemon` subcommand (decision 4's lifecycle) -Startup hooks are one-shot commands run at server startup AND live handoff (bootstrap.rs:88,197), not supervised daemons, and they hold a command slot until their pipes close - so the hook process itself must exit immediately, and because hooks are unserialized (runtime.rs:121), every step is lock-guarded. All serve state is keyed per herdr server: `STATE_DIR/serve//` where `sockhash` = short hash of the canonical (symlink-resolved) `HERDR_SOCKET_PATH` - two herdr servers on one machine get two serves; a serve never outlives its own herdr server and never kills another's. Files: `lock` (owner-lifetime flock), `endpoint` (the published record: `{port, pid, token, pond_version}` - written atomically, temp + rename), `daemon.log`. +Startup hooks are one-shot commands run at server startup AND live handoff (bootstrap.rs:88,197), not supervised daemons, and they hold a command slot until their pipes close - so the hook process itself must exit immediately, and because hooks are unserialized (runtime.rs:121), every step is lock-guarded. All serve state is keyed per herdr server: `STATE_DIR/serve//` where `sockhash` = short hash of the canonical (symlink-resolved) `HERDR_SOCKET_PATH` - two herdr servers on one machine get two serves; a serve never outlives its own herdr server and never kills another's. Files: `lock` (owner-lifetime flock), `endpoint` (the published record: `{socket, token, pid}` - written atomically, temp + rename, at spawn), `owner.sock` (the serve's Unix socket), `daemon.log`. -1. **`serve-daemon`** (the startup hook): take the flock NON-BLOCKING. Held: an owner is alive - exit 0. Acquired: probe any existing `endpoint` (5.8); live and compatible: release, exit 0 (adopted). Else: release, spawn `serve-daemon --owner` fully detached (setsid, stdio -> `daemon.log`), exit 0. -2. **`--owner`** (the detached watchdog): re-acquire the flock, non-blocking, and HOLD it for the process lifetime (lost: exit - another owner won). Re-probe `endpoint` under the lock (the recheck closes the check-then-spawn race); live: exit. Spawn `pond serve --host 127.0.0.1 --port 0 --port-file /serve//port.tmp` as a waited-on child, stdio -> `daemon.log` (serve prints to stdout and traces to stderr - never inherit). From THIS moment supervise concurrently: child exit, herdr liveness (connect `HERDR_SOCKET_PATH` every ~20s), and deadlines. Poll for the port file (deadline 180s - store open on S3 comes first); on it, run the capability probe (5.8), then write `endpoint` atomically with a fresh random token, then fire warm-up requests once (the 14-day SQL listing + one throwaway fts search - the historical 47-300s cold FTS load is paid here, invisibly; deadline 300s, failure logged, not fatal). Steady state: the supervise loop. Herdr gone: SIGTERM the child, wait 10s, SIGKILL, reap, remove `endpoint` only if its token matches, exit. Child died unexpectedly: log, remove owned `endpoint`, exit (no restart loop in v1 - the desk fallback covers the gap; note as a follow-up). Deadline breach: kill child as above, log, exit. -3. Serve facts (verified): default `127.0.0.1:9797` overridable by env (`POND_HOST`/`POND_PORT`, main.rs:764-778) - which is exactly why `--host 127.0.0.1` is explicit; store open happens BEFORE bind (main.rs:1768); the stdout "listening" line is pre-bind and not a readiness signal (:1794); the serve SIGTERM handler is installed after open+bind (transport.rs:192,243) and the 5s drain bounds the HTTP drain, not process teardown (transport.rs:117) - hence the wait-then-SIGKILL step. +1. **`serve-daemon`** (the startup hook): take the flock NON-BLOCKING. Held: an owner is alive - exit 0. Acquired: release, spawn `serve-daemon --owner` fully detached (setsid, stdio -> `daemon.log`), exit 0. A free lock means no owner supervises whatever `endpoint` names, so a live endpoint is never adopted. +2. **`--owner`** (the detached watchdog): re-acquire the flock, non-blocking, and HOLD it for the process lifetime (lost: exit - another owner won). Resolve `pond` first, so a working serve is never stopped for a replacement that cannot spawn. Then read `endpoint` under the lock (a record aimed at any socket but this dir's `owner.sock` is corrupt and ignored): any serve it names is unsupervised (a dead owner's orphan), and pond's lifetime lock on `owner.sock.lock` refuses a fresh serve while it runs. An orphan to stop is one whose socket answers the probe, or one whose pid is alive with a command line passing `--socket ` - still opening its store, or wedged. The command line comes from `/proc//cmdline` on Linux and `ps -ww -o args= -p ` elsewhere, matched as the whole `--socket` argument (paths may hold spaces), and it must match before any signal. Log it, SIGTERM the recorded pid, wait up to the grace period, then SIGKILL, then remove its record; a zombie or reused pid (command line no longer naming the socket) counts as gone. The record names the owner's latest spawn; an owner killed between spawn and publish leaves an unrecorded serve that no pid reaches, so it lingers until killed by hand (rare: a microsecond window). A record without a pid is no record; a termination that fails (EPERM, command-line mismatch, survives SIGKILL) is logged and the owner exits. Remove any leftover `owner.sock` (a dead serve's, or that orphan's, which would otherwise pass for the new child's readiness), then spawn `pond serve --socket /serve//owner.sock` as a waited-on child and write `endpoint` atomically at once, with a fresh random token and the child's pid (desks treat a record whose socket does not answer as absent) - never `--host`/`--port`, and with `POND_HOST`/`POND_PORT` stripped from its env (clap parses them even beside `--socket`, so a malformed inherited one would fail serve) - stdio -> `daemon.log` (serve prints to stdout and traces to stderr - never inherit). From THIS moment supervise concurrently: child exit, herdr liveness (connect `HERDR_SOCKET_PATH` every ~20s), and deadlines. Readiness = the child is still running AND the socket exists AND the capability probe (5.8) passes over it, polled until a 180s deadline (store open on S3 comes first; a socket file alone proves nothing - a stale one refuses); then fire warm-up requests once (the 14-day SQL listing + one throwaway fts search - the historical 47-300s cold FTS load is paid here, invisibly; deadline 300s, failure logged, not fatal). Steady state: the supervise loop. Herdr gone: SIGTERM the child, wait 10s, SIGKILL, reap, remove `endpoint` only if its token matches, exit. Child died unexpectedly: log, remove owned `endpoint`, exit (no restart loop in v1 - the desk fallback covers the gap; note as a follow-up). Deadline breach: kill child as above, log, exit. +3. Serve facts: `--socket ` (#311) serves the same routes over a Unix socket created mode 0600, is exclusive with `--host`/`--port`, removes a stale socket at PATH before the store opens and its own on graceful shutdown, and binds only after store open (main.rs:1768) - the probe over the socket is the readiness signal; `/v1/x/sql` checks the Host header against a loopback allowlist, so the client sends `Host: localhost`; a pond without the flag exits 2 at clap parse naming `--socket` (mapped to "too old"); the serve SIGTERM handler is installed after open+bind (transport.rs:192,243) and the 5s drain bounds the HTTP drain, not process teardown (transport.rs:117) - hence the wait-then-SIGKILL step. ### 5.7 The desk's connection logic (`src/serve.rs`) -Read `endpoint` for this server's `sockhash` -> capability-probe it (5.8) -> use it. Missing/dead/incompatible: spawn a desk-owned fallback child - `pond serve --host 127.0.0.1 --port 0 --port-file `, **stdio redirected to a state-dir log (NEVER inherited - serve's stdout/stderr would corrupt the TUI)**, wait for the port file (spinner + "opening store..." status; deadline 180s), probe, use. Teardown: a drop-guard plus signal-path handling SIGTERMs the child, waits briefly, SIGKILLs - on every graceful exit path (normal quit, error return, SIGTERM/SIGHUP, panic via the hook). This is a graceful-exit guarantee only: SIGKILL of the desk orphans the child until it idles forever - documented accepted v1 risk (the next desk open finds no endpoint record for it and starts fresh; the orphan is visible in `ps`). Both paths give the same `base_url` to `api.rs`. +Read `endpoint` for this server's `sockhash` -> capability-probe it (5.8) -> use it. Missing/dead/incompatible: spawn a desk-owned fallback child - `pond serve --socket /serve//desk...sock` (one socket per spawn, so a retiring fallback's cleanup never removes its successor's), **stdio redirected to a state-dir log (NEVER inherited - serve's stdout/stderr would corrupt the TUI)**, wait until the probe passes over the socket (spinner + "opening store..." status; deadline 180s), use. Teardown: a drop-guard plus signal-path handling SIGTERMs the child, waits briefly, SIGKILLs, then removes the child's socket and the `.lock` pond serve keeps beside it (pond never removes that lock; the path is unique to this spawn, so nothing reuses it - the owner's fixed `owner.sock.lock` stays for its successors) - on every graceful exit path (normal quit, error return, SIGTERM/SIGHUP, panic via the hook). This is a graceful-exit guarantee only: SIGKILL of the desk orphans the child until it idles forever - documented accepted v1 risk (the next desk open finds no endpoint record for it and starts fresh; the orphan is visible in `ps`). Both paths hand `api.rs` a socket path; it builds one reqwest client per socket (`ClientBuilder::unix_socket`, base URL `http://localhost`). A refused or missing socket (ECONNREFUSED/ENOENT, reqwest `is_connect`) is a dead serve and triggers one re-resolve and retry; a timeout is not. ### 5.8 The capability probe (shared by 5.6/5.7) -`POST /v1/x/sql` with `{"protocol_version":1,"query":"SELECT 1 AS ready"}`, small timeout, and validate the success envelope. NOT `GET /v1/search` 405: a pre-PR1 pond also answers 405 there (transport.rs:155) and would then 404 every desk query, and any unrelated localhost service can 405. Probe answers: 200 + valid envelope = usable; 404 = pond too old - surface "pond >= required (`brew upgrade pond` / `cargo install pond`)" as a toast/full-screen error; connection refused/timeout = dead endpoint. +`POST /v1/x/sql` with `{"protocol_version":1,"query":"SELECT 1 AS ready"}`, small timeout, and validate the success envelope. NOT `GET /v1/search` 405: a pre-PR1 pond also answers 405 there (transport.rs:155) and would then 404 every desk query, and any unrelated localhost service can 405. Probe answers: 200 + valid envelope = usable; 404 = pond too old - surface "pond >= required (`brew upgrade pond` / `cargo install pond`)" as a toast/full-screen error; connection refused, socket missing, or timeout = dead endpoint. ## 6. PR2 part 3 - the desk TUI (agent C, `src/desk/` only) @@ -195,14 +195,14 @@ Single current-thread tokio runtime built by hand for the `tui` subcommand only ### 6.2 Request lanes (debounce + cancel, correct under races) -One `Lane { gen: u64, task: Option }` per request kind: search (150ms debounce), preview (80ms - arrow-key-hold safe), transcript pages (none, single-flight: at most one page request outstanding per pager), listing (none - fired on open/filter change only, NEVER per keystroke). Every spawned task carries its generation; `apply()` drops any `Msg` whose gen mismatches the lane's current one (abort alone is insufficient - a task can send in the gap before abort lands). Per-lane generations alone are NOT enough across views: every `Msg` also carries a **view epoch** (bumped on every view/filter transition - entering search, clearing search, changing the project/time toggles, leaving a pager) and its **target identity** (session id for preview/transcript, cursor for pages); `apply()` drops epoch mismatches and results for a no-longer-selected session. Loading flags set on `restart`, cleared on the matching-gen result. Client-side abort does NOT stop server work (the SQL timeout bounds execution, sql.rs:232, and each query builds its own runtime budget) - which is why every desk query carries an explicit SQL `LIMIT`, lanes are single-flight, and debounce keeps abandoned-request rate low; add one paused-time test for cancellation against a slow mock. +One `Lane { gen: u64, task: Option }` per request kind: search (150ms debounce), preview (80ms - arrow-key-hold safe), transcript pages (none, single-flight: at most one page request outstanding per pager), listing (none - fired on open/filter change only, NEVER per keystroke), titles, stats and hosts (the page-scoped hydration lanes of 6.3; none, and a busy one is not asked again until it answers), live (herdr's pane list, once per refresh). Search, preview and pages are view lanes (`Lane::is_view`): they answer for what is on screen, so a view transition cancels them and the epoch drops their late results. The rest are data lanes: they fill caches that outlive views, so a transition leaves them running and their results land whatever the view. Every spawned task carries its generation; `apply()` drops any `Msg` whose gen mismatches the lane's current one (abort alone is insufficient - a task can send in the gap before abort lands). Per-lane generations alone are NOT enough across views: every `Msg` also carries a **view epoch** (bumped on every view/filter transition - entering search, clearing search, changing the project/time toggles, leaving a pager) and its **target identity** (session id for preview/transcript, cursor for pages); `apply()` drops epoch mismatches and results for a no-longer-selected session. Loading flags set on `restart`, cleared on the matching-gen result. Client-side abort does NOT stop server work (the SQL timeout bounds execution, sql.rs:232, and each query builds its own runtime budget) - which is why every desk query carries an explicit SQL `LIMIT`, lanes are single-flight, and debounce keeps abandoned-request rate low; add one paused-time test for cancellation against a slow mock. ### 6.3 Views and data -All SQL lives in `types.rs` as named constants, written and reviewed in step 0 (section 7), every query with an explicit `LIMIT`. Client-side HTTP: connect + request deadlines on every call; decode errors distinctly: pond error envelope (surface its enriched text verbatim in the toast), axum plain rejection (bad JSON/route - not an envelope), 404 (old pond, 5.8 message), refused/timeout (endpoint dead - trigger 5.7 fallback path once, then error state). +All SQL lives in `types.rs` as named constants, written and reviewed in step 0 (section 7), every query with an explicit `LIMIT`. Client-side HTTP: connect + request deadlines on every call; decode errors distinctly: pond error envelope (the toast shows its enriched text up to the first clause; the full text goes to `desk.log`), axum plain rejection (bad JSON/route - not an envelope), 404 (old pond, 5.8 message), refused/missing socket (endpoint dead - trigger 5.7 fallback path once, then error state). A timeout is an error, not a failover: the serve may be alive and merely slow. -- **List (opening view)**: SQL listing (14-day window, project = the underlying pane's cwd from context JSON, toggles for all-projects/all-time), deterministic `ORDER BY last_ts DESC, session_id` + `LIMIT`, then one page-scoped hydration query - exactly one row per session: title = first nonempty user-role `search_text` (a `first_value` window emits per source row - reduce in the outer query), host via the JSON getter on `options` (exact getter syntax from the `schema://pond-sql` resource; strictly `session_id IN ()`-scoped), counts = whole-session message counts (not window-scoped - say so in the header). Rows: state glyph (live/recent - live = `agent_session` match from one `pane list` snapshot per refresh), machine (unstamped -> dim `local?`, an assumption not a verified host - section 2), adapter, age, title, count. Missing title -> `(no user message)`. **All-time toggle** deliberately re-enters the slow family (12.9s warm, section 2): loading state + raised client deadline, and the listing stays cached so toggling back is instant. Refresh: manual key + on-open; a fresh desk process has no cache from the daemon's warm-up (that warmed the SERVER) - the first listing still takes the ~1.7s warm number. Selection preserved across refresh by session id. -- **Typed search**: `/v1/search`, fts, project filter; request/response shapes verified in wire.rs:583-689: request `{protocol_version: 1, query, mode?, sort_by?, filters?: {project: {contains}|{regex}, source_agent, from_date, to_date}, limit}`; response `{sessions: [{session_id, project, source_agent, session_messages_count, matched_message_count, matches: [{message_id, role, timestamp, text (<=600 chars), score, parts_summary?}]}], matched_total, searchable_in_scope, has_more}`. Render `searchable_in_scope == 0` distinctly ("filters excluded everything") vs zero matches. +- **List (opening view)**: SQL listing (14-day window, project = the underlying pane's cwd from context JSON, toggles for all-projects/all-time), deterministic `ORDER BY last_ts DESC, session_id` + `LIMIT`, then three concurrent page-scoped hydration lanes over the rows around the selection (never the whole listing), each asking only for what is not known yet and answering at most one row per session, strictly `session_id IN ()`-scoped: titles (first nonempty user-role `search_text`, an aggregate `first_value` so it reduces per session), stats (whole-session message count plus first and last timestamp - not window-scoped, say so in the header), and hosts (the JSON getter on `options`, exact syntax from the `schema://pond-sql` resource, read only at each session's first timestamp, which stats supply when the listing window cannot). Rows: state glyph (live/recent - live = `agent_session` match from one `pane list` snapshot per refresh), machine (unstamped -> dim `local?`, an assumption not a verified host - section 2), adapter, age, title, count. Missing title -> `(no user message)`. **All-time toggle** deliberately re-enters the slow family (12.9s warm, section 2): loading state + raised client deadline, and the listing stays cached so toggling back is instant. Refresh: manual key + on-open. `desk-cache.json` in the plugin state dir (bounded, owner-only) carries listings and what hydration learned between opens, so the desk paints from it before pond answers and hydrates only what it lacks. Selection preserved across refresh by session id. +- **Typed search**: `/v1/search`, fts, over the whole corpus by default - `p` narrows to the desk's project, `t` to the listing window; request/response shapes verified in wire.rs:583-689: request `{protocol_version: 1, query, mode?, sort_by?, filters?: {project: {contains}|{regex}, source_agent, from_date, to_date}, limit}`; response `{sessions: [{session_id, project, source_agent, session_messages_count, matched_message_count, matches: [{message_id, role, timestamp, text (<=600 chars), score, parts_summary?}]}], matched_total, searchable_in_scope, has_more}`. Render `searchable_in_scope == 0` distinctly ("filters excluded everything") vs zero matches. - **Preview** (Space or selection-dwell): session-scoped SQL transcript, newest-first, cached per session id. - **Pager** (Enter on non-live rows): chronological, **composite cursor `(timestamp, message_id)`** - never timestamp alone: pond orders messages by `(timestamp, message_id)` (handlers.rs:686) and the `schema://pond-sql` resource prescribes the exact seek predicate (transport.rs:717,728); timestamps tie, so `timestamp > last` loses tied rows and `>=` repeats them. Microsecond precision; same composite ordering in the query's `ORDER BY`; EOF = a page shorter than the page's own SQL `LIMIT` (the response `truncated` flag says nothing about the query's LIMIT). Lazy on scroll, single-flight. The pager is the complete paginated **conversational-text** view: `search_text` deliberately excludes tool calls/results, reasoning, and system/tool-role messages (spec.md:742, transport.rs:547) - label it so (e.g. footer `conversation only - tool bodies via pond_sql/get_session`), and never widen `search_text` to fix the wording. - **Jump** (Enter on live rows): leave the alternate screen and restore the terminal BEFORE running `herdr agent focus` (CLI output must not corrupt the screen), then exit. @@ -234,7 +234,7 @@ Step 1 (three agents, parallel worktrees, no compile-time dependencies between t | Agent | Scope | Files | |---|---|---| -| A | PR1 whole: `Outcome::Json`, wire types, handler, route, `--port-file`, `open_tables` extraction, spec edits, tests, and the three exhaustive-match consumers | `packages/pond/src/{transport,sql,wire,handlers,main}.rs`, `packages/pond/tests/integration/{transport_http,schema_migration}.rs`, `packages/pond/benches/sync_oracle_bench.rs`, `docs/spec.md` | +| A | PR1 whole: `Outcome::Json`, wire types, handler, route, `open_tables` extraction, spec edits, tests, and the three exhaustive-match consumers | `packages/pond/src/{transport,sql,wire,handlers,main}.rs`, `packages/pond/tests/integration/{transport_http,schema_migration}.rs`, `packages/pond/benches/sync_oracle_bench.rs`, `docs/spec.md` | | B | plugin shell: `api.rs`, `serve.rs`, `herdr.rs`, `open`/`hook`/`serve-daemon`/`--owner`, config, manifest final, moon/CI/release-plz finalization, Cargo.{toml,lock} | `packages/herdr-pond/*` except `desk/`; `.moon/workspace.yml`, `ci.yml`, `release-plz.toml` | | C | desk TUI per section 6, against the step-0 `Api` trait + mock + fake server | `packages/herdr-pond/src/desk/` only | @@ -250,8 +250,8 @@ PR order: PR1 (pond) first - independently reviewable, rides the release train; 2. Typing searches message content across every harness and machine; Space previews; Enter opens the full conversational transcript (labeled as such, 6.3); no step reads a harness file. 3. Enter on a live row focuses that pane without corrupting the terminal. 4. Hook: an agent going idle is searchable from another pane's `pond_search` within ~15s under no lock contention (with a concurrent sync holding the lock, the worker syncs immediately after it releases - verified by the sync.log timeline); the hook process exits <50ms; the detached worker holds no herdr command slot (verify via `plugin log list` showing the hook `succeeded` immediately while the fake long-running child still runs, 6.5); no idle event is dropped across the debounce/lock matrix (two adapters idle concurrently; same adapter twice within 10s; idle during a held store lock). -5. Serve lifecycle: herdr server start warms a serve bound to 127.0.0.1; two concurrent `serve-daemon` runs yield exactly one owner (flock test); `kill` of that serve self-heals on next desk open (fallback child); stopping the herdr SERVER (not merely detaching a client - closing a client deliberately leaves the background server and therefore the serve running, herdr README) leaves no `pond serve` process behind; `pond schedule` registration, timers, and config are untouched by any plugin path (sync cursors/last-sync state DO advance when plugin-triggered syncs run - that is feature 1, assert it happens rather than pretending it does not). -6. Deterministic failure matrix (fake server/processes + sandboxed state dir, 6.5): empty store; zero search matches vs `searchable_in_scope == 0`; old pond (404 -> upgrade message); endpoint refused/timeout -> fallback; server death mid-query -> toast + recover; malformed/stale endpoint file; tied-timestamp pagination (more ties than a page); huge single message vs caps; ANSI/tab/CRLF transcript; tiny terminal + resize mid-pager; EOF on the event stream; SIGTERM/SIGHUP restore the terminal and kill the fallback child; `agent focus` failure surfaces an error after restore. +5. Serve lifecycle: herdr server start warms a serve on its owner-only Unix socket; two concurrent `serve-daemon` runs yield exactly one owner (flock test); `kill` of that serve self-heals on next desk open (fallback child); stopping the herdr SERVER (not merely detaching a client - closing a client deliberately leaves the background server and therefore the serve running, herdr README) leaves no `pond serve` process behind; `pond schedule` registration, timers, and config are untouched by any plugin path (sync cursors/last-sync state DO advance when plugin-triggered syncs run - that is feature 1, assert it happens rather than pretending it does not). +6. Deterministic failure matrix (fake server/processes + sandboxed state dir, 6.5): empty store; zero search matches vs `searchable_in_scope == 0`; old pond (404 -> upgrade message); endpoint refused/missing socket -> fallback, timeout -> error without failover; server death mid-query -> toast + recover; malformed/stale endpoint file; tied-timestamp pagination (more ties than a page); huge single message vs caps; ANSI/tab/CRLF transcript; tiny terminal + resize mid-pager; EOF on the event stream; SIGTERM/SIGHUP restore the terminal and kill the fallback child; `agent focus` failure surfaces an error after restore. 7. `cargo clippy --workspace -- -D warnings` and tests green via moon; CI gates the new crate; the dist scripts build pond only; a fresh-checkout `moon run herdr-pond:lint herdr-pond:test` passes with only the committed lockfile. 8. The desk contains no SQL outside `types.rs`'s named constants; every query carries an explicit LIMIT; JSON getters appear only in page-scoped queries. @@ -262,7 +262,7 @@ PR order: PR1 (pond) first - independently reviewable, rides the release train; - **Fallback-child orphan on desk SIGKILL** (5.7): accepted, documented; revisit if it bites. - **get_session ~6-7s is not regrowth** (diagnosed 2026-09-24: live layout 1-9 pages/column ~21h after the re-encode): one-shot reads pay an unfiltered `message_store_probe` scan across many fragments (~500 GETs, ~2s; tracked in [#310](https://github.com/tenequm/pond/issues/310)), and the MCP figure was most likely a first call (warm MCP 0.35-2.1s). Independent of the desk: the serve is long-lived, so it pays the probe once at prewarm. - **M2 upgrade path**: after PR #293, re-measure get_session; at ~1-2s the pager switches to it. -- **SQL contract drift** breaks the desk at run time, not compile time: queries live in one constants block; surface the sql handler's enriched error text verbatim in the toast; the step-0 golden examples are the shared contract. +- **SQL contract drift** breaks the desk at run time, not compile time: queries live in one constants block; surface the sql handler's enriched error text (its first clause in the toast, the whole in `desk.log`); the step-0 golden examples are the shared contract. - **32-slot budget**: our hook is millisecond-exit and workers/daemons detach with closed pipes, but a future action storm shares the cap with other plugins (usagebar) - keep every headless leg fast. - **RSS of the session-long serve**: observe in dogfood (pond has memory instrumentation); if heavy, an idle-linger/timeout mode on the daemon is the knob. -- **Parked**: resume/fork/handoff/park (2609-02 plan), `pond sessions` verb, gone rows, host backfill, hard `--read-only` serve flag (decision 4), install/marketplace distribution (`[[build]]` + release-archive decision, and the plugin-root symlink story for installs), Navigator integration (rejected for v1: its collect/open contract cannot do per-keystroke content search), spec/code mismatch issues from section 3.2. +- **Parked**: resume/fork/handoff/park (2609-02 plan), `pond sessions` verb, gone rows, host backfill, hard `--read-only` serve flag (decision 4), install/marketplace distribution (`[[build]]` + release-archive decision, and the plugin-root symlink story for installs), Navigator integration (rejected for v1: its collect/open contract cannot do per-keystroke content search), spec/code mismatch issues from section 3.2. A rowmap-backed `session_summaries()` SQL table function for desk hydration (per-session count, first/last timestamp, title from the mmap rowmap): parked, not planned - the narrowed hydration queries plus the desk disk cache were judged enough; if ever revisited it is a SQL table function, never a new typed endpoint. diff --git a/packages/herdr-pond/Cargo.toml b/packages/herdr-pond/Cargo.toml new file mode 100644 index 00000000..dab4bcd1 --- /dev/null +++ b/packages/herdr-pond/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "herdr-pond" +version = "0.1.0" +edition = "2024" +rust-version = "1.98" +license = "Apache-2.0" +description = "herdr plugin for pond: sync-on-idle and a read-only session desk" +repository = "https://github.com/tenequm/pond" +publish = false + +# A thin HTTP client of `pond serve`: never depend on the pond crate, which +# drags in lance, datafusion, candle and protoc. +[dependencies] +anyhow = "1" +chrono = { version = "0.4.44", default-features = false, features = ["std", "clock", "serde"] } +crossterm = { version = "0.29", features = ["event-stream"] } +futures-util = { version = "0.3", default-features = false } +nix = { version = "0.31", features = ["process", "signal", "fs", "hostname"] } +ratatui = "0.30.2" +reqwest = { version = "0.13", default-features = false, features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +textwrap = "0.16" +tokio = { version = "1.52", features = ["rt", "macros", "time", "sync", "signal", "process"] } +toml = "1.1" +unicode-width = "0.2" + +[dev-dependencies] +tokio = { version = "1.52", features = ["test-util", "net", "io-util"] } + +[lints.rust] +unsafe_code = "deny" +unreachable_pub = "warn" + +[lints.clippy] +all = { level = "deny", priority = -1 } +dbg_macro = "warn" +expect_used = "warn" +implicit_clone = "warn" +print_stdout = "warn" +semicolon_if_nothing_returned = "warn" +todo = "warn" +uninlined_format_args = "warn" +unwrap_used = "warn" diff --git a/packages/herdr-pond/README.md b/packages/herdr-pond/README.md new file mode 100644 index 00000000..2819ac5f --- /dev/null +++ b/packages/herdr-pond/README.md @@ -0,0 +1,69 @@ +# herdr-pond + +A [herdr](https://herdr.dev) plugin for pond: + +- **Sync-on-idle** - when an agent in a herdr pane goes idle, its adapter is synced into your pond store (`pond sync `), so the session is searchable seconds later. +- **The desk** - one overlay listing recent sessions across every harness and machine in your store, with content search, previews, and full conversational transcripts read straight from the store. Enter on a session that is running in a herdr pane jumps to that pane. + +## Prerequisites + +- `pond` installed and initialized: run `pond init` once, with the adapters you use enabled (`pond adapters enable `). Sync-on-idle only syncs adapters that are already enabled; it never enables one. +- A pond release that includes `POST /v1/x/sql` and `pond serve --socket` ([tenequm/pond#311](https://github.com/tenequm/pond/pull/311)). With an older pond the desk and `daemon.log` say it is too old and name the upgrade command. +- For live rows (the running-agent marker and jump): the official herdr integration for each agent, e.g. `herdr integration install claude`. Without it herdr knows the agent but not its session id. + +## Build and link + +```sh +cargo build --release -p herdr-pond +herdr plugin link packages/herdr-pond +``` + +The package must be named: a bare `cargo build --release` at the repo root builds pond only. + +`packages/herdr-pond/bin/herdr-pond` is a committed symlink to `../../../target/release/herdr-pond`, the default cargo target dir. If you set `CARGO_TARGET_DIR`, point that symlink at your target dir by hand. + +## Open the desk + +herdr has no action palette, so bind a key in your herdr config: + +```toml +[[keys.command]] +key = "..." +type = "plugin_action" +command = "pond.desk" +``` + +Then run `herdr server reload-config`. Pressing the key in a workspace whose desk is already open focuses that desk instead of opening a second one. + +## Config + +`config.toml` in the plugin config dir (`herdr plugin config-dir pond`), re-read on every run. A malformed file is logged and ignored. + +```toml +sync_on_idle = true # default; false turns the idle hook off +pond_bin = "/opt/homebrew/bin/pond" # optional, absolute; default: PATH lookup +``` + +herdr's PATH is fixed when the herdr server starts. If `pond` is not on it, set `pond_bin`. + +## How it runs + +- Each herdr server starts one `pond serve` in the background at startup, listening on a Unix socket in the plugin state dir (`serve//owner.sock`, owner-only), and stops it when that server exits. It is a personal server only your user can reach, not a TCP port; the plugin sends it only reads. It never runs sync (`--with-sync` is not passed), and `pond schedule` stays the owner of scheduled sync. +- If that serve is missing or dead, the desk starts its own, on its own socket, for as long as it is open. If the desk is killed with SIGKILL, that serve is orphaned (visible in `ps`) until you stop it. +- If the per-server serve outlives its herdr server's watchdog (the watchdog was killed), the next watchdog for that server stops it when it answers on its socket or its command line names that socket, then starts a fresh one; a process matching neither is left alone. +- Idle syncs wait for any sync already holding the store lock, then run. Bursts of idle events coalesce; the last one always produces a sync. +- The machine column shows each session's origin host, read from its first message; sessions from the machine the desk runs on show as `this`. Sessions ingested before pond stamped the ingest host have no recorded machine. The desk shows them as `local?` - unknown provenance, not a claim that they came from this machine. +- Typed search (`/`) covers the whole store - every project and all time - until `p` narrows it to this project or `t` to the last 14 days. In the listing, `p` and `t` widen instead: it opens on this project's last 14 days. +- The desk keeps what it learned (listings, titles, counts, hosts) in `desk-cache.json` in the plugin state dir, readable only by you, and paints from it at once on the next open while pond refreshes behind it. Deleting the file only costs that head start. +- The transcript view is conversation only: user and assistant text. Tool calls and results stay reachable through `pond_sql` and `pond_get_session`. + +## Logs + +In the plugin state dir (herdr's state dir, `plugins/pond/`): + +- `sync.log` - one line per idle sync (adapter, exit status, duration) plus pond's own output. +- `serve//daemon.log` - the per-server serve's lifecycle and output. +- `serve//desk-serve.log` - a desk-started serve's output. +- `desk.log` - what the desk did not show you: a `desk-cache.json` it could not read or write, failed background lookups of titles, counts and hosts (the rows are retried as you move), and the full text of every error it showed as a clipped toast. + +Each log starts over past 1 MiB. herdr's `plugin log list` only shows that a hook exited, not that a sync ran - `sync.log` is the record. diff --git a/packages/herdr-pond/bin/herdr-pond b/packages/herdr-pond/bin/herdr-pond new file mode 120000 index 00000000..181f51d2 --- /dev/null +++ b/packages/herdr-pond/bin/herdr-pond @@ -0,0 +1 @@ +../../../target/release/herdr-pond \ No newline at end of file diff --git a/packages/herdr-pond/herdr-plugin.toml b/packages/herdr-pond/herdr-plugin.toml new file mode 100644 index 00000000..7b3feb59 --- /dev/null +++ b/packages/herdr-pond/herdr-plugin.toml @@ -0,0 +1,28 @@ +id = "pond" +name = "pond" +version = "0.1.0" +# Floor for: agent focus moves clients, the plugin-pane PWD fix, and a plugin +# registry that survives client-only updates. +min_herdr_version = "0.9.1" +description = "Search and read every agent session from your pond store - all harnesses, all machines." +platforms = ["macos", "linux"] + +[[actions]] +id = "desk" +title = "pond: session desk" +contexts = ["pane", "workspace"] +command = ["bin/herdr-pond", "open"] + +# The title becomes the pane label, which `open` uses as the dedupe key. +[[panes]] +id = "desk" +title = "pond desk" +placement = "overlay" +command = ["bin/herdr-pond", "tui"] + +[[events]] +on = "pane.agent_status_changed" +command = ["bin/herdr-pond", "hook"] + +[[startup]] +command = ["bin/herdr-pond", "serve-daemon"] diff --git a/packages/herdr-pond/moon.yml b/packages/herdr-pond/moon.yml new file mode 100644 index 00000000..dcd17e5f --- /dev/null +++ b/packages/herdr-pond/moon.yml @@ -0,0 +1,29 @@ +language: rust + +env: + CARGO_TERM_COLOR: always + +# Cargo commands run from this project root; the workspace lockfile, manifest +# and toolchain pin live at the repo root. The root `default-members` keeps +# bare cargo commands pond-only, so every task names this package. +fileGroups: + sources: + - 'src/**/*' + - 'Cargo.toml' + - '/Cargo.toml' + - '/Cargo.lock' + - '/rust-toolchain.toml' + - '/.cargo/config.toml' + nixToolchain: + - '/ops/toolchain-id.json' + +tasks: + format: + command: 'cargo fmt -p herdr-pond --check' + inputs: ['@group(sources)', '@group(nixToolchain)'] + lint: + command: 'cargo clippy --locked -p herdr-pond --all-targets -- -D warnings' + inputs: ['@group(sources)', '@group(nixToolchain)'] + test: + command: 'cargo test --locked -p herdr-pond' + inputs: ['@group(sources)', '@group(nixToolchain)'] diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs new file mode 100644 index 00000000..3c036e1b --- /dev/null +++ b/packages/herdr-pond/src/api.rs @@ -0,0 +1,765 @@ +//! The HTTP [`Api`] implementation over `pond serve`'s Unix socket +//! (`/v1/x/sql`, `/v1/search`), plus herdr's pane list for live agents. +//! Tested against [`crate::fake_pond`]. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use serde::de::DeserializeOwned; +use tokio::sync::Mutex; + +use crate::herdr::{self, Herdr}; +use crate::serve::{self, Fallback, Origin}; +use crate::types::{ + Api, ApiError, ApiFuture, Cursor, ErrorEnvelope, ListingScope, LiveAgent, PAGE_ROWS, + PREVIEW_ROWS, SearchRequest, SearchResponse, SessionHost, SessionRow, SessionStart, + SessionStats, SessionTitle, SqlRequest, SqlResponse, TranscriptMessage, TranscriptPage, + hosts_sql, listing_sql, page_sql, preview_sql, stats_sql, titles_sql, +}; + +pub(crate) const SQL_PATH: &str = "/v1/x/sql"; +pub(crate) const SEARCH_PATH: &str = "/v1/search"; + +/// The host names only the `Host` header: `/v1/x/sql` answers a loopback one. +const BASE_URL: &str = "http://localhost"; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +/// Server-side execution budgets, sent as `timeout_seconds`. The client waits +/// [`CLIENT_SLACK`] longer so pond's enriched timeout error arrives instead of +/// a bare client-side timeout. +const QUERY_TIMEOUT_SECS: u64 = 25; +const ALL_TIME_TIMEOUT_SECS: u64 = 60; +const CLIENT_SLACK: Duration = Duration::from_secs(5); +pub(crate) const SEARCH_DEADLINE: Duration = Duration::from_secs(30); + +pub(crate) fn sql_deadline(timeout_seconds: u64) -> Duration { + Duration::from_secs(timeout_seconds) + CLIENT_SLACK +} + +/// One `pond serve --socket` path and a client bound to it. +#[derive(Debug, Clone)] +pub(crate) struct Socket { + pub path: PathBuf, + client: reqwest::Client, +} + +impl Socket { + pub(crate) fn new(path: PathBuf) -> Result { + let client = reqwest::Client::builder() + .unix_socket(path.as_path()) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .map_err(|error| { + ApiError::Request(format!("no HTTP client for {}: {error}", path.display())) + })?; + Ok(Self { path, client }) + } + + pub(crate) async fn post( + &self, + route: &str, + body: &B, + deadline: Duration, + ) -> Result + where + B: Serialize + ?Sized, + T: DeserializeOwned, + { + let response = self + .client + .post(format!("{BASE_URL}{route}")) + .json(body) + .timeout(deadline) + .send() + .await + .map_err(|error| self.transport(&error))?; + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|error| self.transport(&error))?; + decode(route, status, &body) + } + + /// Only a failed connect (a missing or refusing socket) proves the serve + /// gone; a timeout or a dropped response may come from a live serve that + /// is merely slow. reqwest's own message is only "error sending request" + /// and names no socket, so both are added. + fn transport(&self, error: &reqwest::Error) -> ApiError { + let mut message = format!("{}: {error}", self.path.display()); + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + message.push_str(": "); + message.push_str(&cause.to_string()); + source = cause.source(); + } + if error.is_connect() { + ApiError::Unreachable(message) + } else { + ApiError::Request(message) + } + } +} + +fn decode(path: &str, status: u16, body: &str) -> Result { + let decoded = (200..300) + .contains(&status) + .then(|| serde_json::from_str::(body)); + if let Some(Ok(value)) = decoded { + return Ok(value); + } + if let Ok(ErrorEnvelope { error }) = serde_json::from_str(body) { + return Err(ApiError::Pond { + code: error.code, + message: error.message, + }); + } + match decoded { + Some(Err(error)) => Err(ApiError::Decode(format!("{path}: {error}"))), + _ if status == 404 && path == SQL_PATH => Err(ApiError::PondTooOld), + _ => Err(ApiError::Rejected { + status, + body: body.trim().to_owned(), + }), + } +} + +/// How long a failed resolution answers for later callers instead of a new +/// attempt, so callers queued behind it do not each spawn another serve. +const RESOLVE_RETRY_AFTER: Duration = Duration::from_secs(1); + +/// The resolved serve. `serve` stays unset until the first call, so the +/// desk's loading state covers a cold fallback spawn. +#[derive(Default)] +struct Link { + serve: Option, + fallback: Option, + failed: Option<(Instant, ApiError)>, +} + +/// Owned by the api and by each resolution task, so resolution survives the +/// request that started it: the desk aborts a lane on every new fetch, and a +/// cancelled resolution would kill a half-open fallback serve mid store-open. +struct Resolver { + /// Why no serve can be found at all (not running under herdr), reported + /// on first use rather than before the desk can draw. + origin: Result, + link: Mutex, +} + +impl Resolver { + /// Runs with `link` locked, so concurrent callers queue behind one + /// resolution and then reuse its result. `stale` is a socket that just + /// refused; the same path is used again only once `connect` probed it + /// live, since a successor owner reuses its path. + async fn resolve(&self, stale: Option) -> Result { + let mut link = self.link.lock().await; + if let Some(current) = &link.serve + && stale.as_ref().is_none_or(|stale| current.path != *stale) + { + return Ok(current.clone()); + } + if let Some((at, error)) = &link.failed + && at.elapsed() < RESOLVE_RETRY_AFTER + { + return Err(error.clone()); + } + let origin = self + .origin + .as_ref() + .map_err(|error| ApiError::Unreachable(error.clone()))?; + match serve::connect(origin, link.fallback.take()).await { + Ok(connection) => { + link.fallback = connection.fallback; + link.serve = Some(connection.socket.clone()); + link.failed = None; + Ok(connection.socket) + } + Err(error) => { + link.serve = None; + link.failed = Some((Instant::now(), error.clone())); + Err(error) + } + } + } +} + +pub(crate) struct HttpApi { + resolver: Arc, + herdr: Herdr, +} + +impl HttpApi { + pub(crate) fn from_env() -> Self { + Self { + resolver: Arc::new(Resolver { + origin: Origin::from_env().map_err(|error| format!("{error:#}")), + link: Mutex::default(), + }), + herdr: Herdr::from_env(), + } + } + + /// The current serve, else a resolution run in its own task. + async fn resolve(&self, stale: Option) -> Result { + if stale.is_none() { + let current = self.resolver.link.lock().await.serve.clone(); + if let Some(socket) = current { + return Ok(socket); + } + } + let resolver = Arc::clone(&self.resolver); + tokio::spawn(async move { resolver.resolve(stale).await }) + .await + .map_err(|error| ApiError::Unreachable(format!("resolving pond serve: {error}")))? + } + + /// Sends to the resolved serve. When the serve refuses the connection, + /// the endpoint is resolved again (daemon record, else a fallback child) + /// and the request retried there once; a refusal on the retry stands as + /// this call's error, and the next call may fail over again. + async fn post(&self, route: &str, body: &B, deadline: Duration) -> Result + where + B: Serialize + ?Sized + Sync, + T: DeserializeOwned, + { + let socket = self.resolve(None).await?; + match socket.post(route, body, deadline).await { + Err(ApiError::Unreachable(_)) => { + self.resolve(Some(socket.path)) + .await? + .post(route, body, deadline) + .await + } + other => other, + } + } + + async fn sql( + &self, + query: String, + limit: usize, + timeout_seconds: u64, + ) -> Result { + let request = SqlRequest::new(query, limit, timeout_seconds); + self.post(SQL_PATH, &request, sql_deadline(timeout_seconds)) + .await + } + + /// A page-scoped query answering at most one row per session. + async fn per_session( + &self, + sessions: usize, + query: impl FnOnce() -> String, + ) -> Result, ApiError> { + if sessions == 0 { + return Ok(Vec::new()); + } + self.sql(query(), sessions, QUERY_TIMEOUT_SECS) + .await? + .into_rows() + } +} + +impl Api for HttpApi { + fn list_sessions(&self, scope: ListingScope) -> ApiFuture<'_, Vec> { + Box::pin(async move { + let timeout = if scope.since.is_none() { + ALL_TIME_TIMEOUT_SECS + } else { + QUERY_TIMEOUT_SECS + }; + self.sql(listing_sql(&scope), scope.limit, timeout) + .await? + .into_rows() + }) + } + + fn titles(&self, session_ids: Vec) -> ApiFuture<'_, Vec> { + Box::pin(async move { + self.per_session(session_ids.len(), || titles_sql(&session_ids)) + .await + }) + } + + fn stats(&self, session_ids: Vec) -> ApiFuture<'_, Vec> { + Box::pin(async move { + self.per_session(session_ids.len(), || stats_sql(&session_ids)) + .await + }) + } + + fn hosts(&self, starts: Vec) -> ApiFuture<'_, Vec> { + Box::pin(async move { self.per_session(starts.len(), || hosts_sql(&starts)).await }) + } + + fn search(&self, request: SearchRequest) -> ApiFuture<'_, SearchResponse> { + Box::pin(async move { self.post(SEARCH_PATH, &request, SEARCH_DEADLINE).await }) + } + + fn preview(&self, session_id: String) -> ApiFuture<'_, Vec> { + Box::pin(async move { + let query = preview_sql(&session_id); + self.sql(query, PREVIEW_ROWS, QUERY_TIMEOUT_SECS) + .await? + .into_rows() + }) + } + + fn page(&self, session_id: String, after: Option) -> ApiFuture<'_, TranscriptPage> { + Box::pin(async move { + let query = page_sql(&session_id, after.as_ref()); + let response = self.sql(query, PAGE_ROWS, QUERY_TIMEOUT_SECS).await?; + Ok(TranscriptPage { + truncated: response.truncated, + messages: response.into_rows()?, + }) + }) + } + + fn live_agents(&self) -> ApiFuture<'_, Vec> { + let herdr = self.herdr.clone(); + Box::pin(async move { + let panes = tokio::task::spawn_blocking(move || herdr.pane_list(None)) + .await + .map_err(|error| ApiError::Herdr(format!("pane list: {error}")))? + .map_err(|error| ApiError::Herdr(format!("{error:#}")))?; + Ok(herdr::live_agents(panes)) + }) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use super::*; + use crate::fake_pond::{ + FakePond, Reply, Sandbox, endpoint, golden, missing_socket, stale_socket, ts, write_script, + }; + use crate::serve::write_endpoint; + use crate::types::READY_SQL; + + /// An api resolving through `sandbox`'s state, pinned to `serve` if given. + fn api(sandbox: &Sandbox, serve: Option) -> HttpApi { + HttpApi { + resolver: Arc::new(Resolver { + origin: Ok(sandbox.origin()), + link: Mutex::new(Link { + serve, + ..Link::default() + }), + }), + herdr: Herdr::new(sandbox.path("bin/herdr")), + } + } + + /// Pinned to `serve`, with a `pond_bin` that points nowhere, so no + /// re-resolution can reach a real pond. + fn api_at(serve: Socket, sandbox: &Sandbox) -> HttpApi { + sandbox.write_config(&format!( + "pond_bin = \"{}\"\n", + sandbox.path("bin/no-pond").display() + )); + api(sandbox, Some(serve)) + } + + /// Answers the probe and the preview query. + async fn preview_pond() -> FakePond { + FakePond::with_sql( + vec![ + ("SELECT 1", Reply::json(golden::SQL_READY)), + ("DESC LIMIT", Reply::json(golden::SQL_PAGE)), + ], + Reply::json(golden::SEARCH), + ) + .await + } + + fn sent(pond: &FakePond) -> Vec { + pond.recorded() + .iter() + .map(|request| serde_json::from_str(&request.body).unwrap()) + .collect() + } + + #[tokio::test] + async fn listing_sends_its_sql_and_limit() { + let sandbox = Sandbox::new(); + let pond = FakePond::with_sql( + vec![("GROUP BY session_id", Reply::json(golden::SQL_LISTING))], + Reply::json(golden::SEARCH), + ) + .await; + let api = api_at(pond.connect(), &sandbox); + let scope = ListingScope { + project: Some("/home/me/pj/pond".to_owned()), + since: Some(ts("2026-09-11T00:00:00Z")), + limit: 200, + }; + let rows = api.list_sessions(scope.clone()).await.unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1].source_agent, "codex-cli"); + + let all_time = ListingScope { + since: None, + ..scope.clone() + }; + api.list_sessions(all_time.clone()).await.unwrap(); + + let recorded = pond.recorded(); + assert!(recorded.iter().all(|request| request.path == SQL_PATH)); + assert!(recorded.iter().all(|request| request.host == "localhost")); + let bodies = sent(&pond); + assert_eq!(bodies[0]["query"], listing_sql(&scope)); + assert_eq!(bodies[0]["limit"], 200); + assert_eq!(bodies[0]["protocol_version"], 1); + assert_eq!(bodies[0]["timeout_seconds"], QUERY_TIMEOUT_SECS); + assert_eq!(bodies[1]["query"], listing_sql(&all_time)); + assert_eq!(bodies[1]["timeout_seconds"], ALL_TIME_TIMEOUT_SECS); + } + + #[tokio::test] + async fn hydration_sends_three_bounded_queries_and_reads_omitted_nulls_as_none() { + let sandbox = Sandbox::new(); + let pond = FakePond::with_sql( + vec![ + ("AS title", Reply::json(golden::SQL_TITLES)), + ("AS first_ts", Reply::json(golden::SQL_STATS)), + ("AS host", Reply::json(golden::SQL_HOSTS)), + ], + Reply::json(golden::SEARCH), + ) + .await; + let api = api_at(pond.connect(), &sandbox); + assert!(api.titles(Vec::new()).await.unwrap().is_empty()); + assert!(api.stats(Vec::new()).await.unwrap().is_empty()); + assert!(api.hosts(Vec::new()).await.unwrap().is_empty()); + assert!(pond.recorded().is_empty(), "empty hydration sent a request"); + + let ids = vec!["s-live".to_owned(), "s-old".to_owned()]; + let starts = vec![SessionStart { + session_id: "s-live".to_owned(), + first_ts: ts("2026-09-24T21:10:00Z"), + }]; + let (titles, stats, hosts) = tokio::join!( + api.titles(ids.clone()), + api.stats(ids.clone()), + api.hosts(starts.clone()) + ); + assert_eq!( + titles.unwrap()[0].title.as_deref(), + Some("fix the timer re-arm") + ); + assert_eq!(stats.unwrap()[1].message_count, 3); + assert_eq!(hosts.unwrap()[1].host, None); + + let mut bodies = sent(&pond); + bodies.sort_by_key(|body| body["query"].as_str().unwrap().to_owned()); + let mut expected = [ + (titles_sql(&ids), 2), + (stats_sql(&ids), 2), + (hosts_sql(&starts), 1), + ]; + expected.sort(); + for (body, (query, limit)) in bodies.iter().zip(expected) { + assert_eq!(body["query"], query); + assert_eq!(body["limit"], limit); + } + } + + #[tokio::test] + async fn preview_and_page_carry_their_limits_and_truncation() { + let sandbox = Sandbox::new(); + let truncated = golden::SQL_PAGE.replace(r#""truncated":false"#, r#""truncated":true"#); + let pond = FakePond::with_sql( + vec![ + ("DESC LIMIT", Reply::json(golden::SQL_EMPTY)), + ("message_id > 'm-a'", Reply::json(&truncated)), + ( + "ORDER BY timestamp, message_id", + Reply::json(golden::SQL_PAGE), + ), + ], + Reply::json(golden::SEARCH), + ) + .await; + let api = api_at(pond.connect(), &sandbox); + assert!(api.preview("s1".to_owned()).await.unwrap().is_empty()); + + let first = api.page("s1".to_owned(), None).await.unwrap(); + assert!(!first.truncated); + assert_eq!(first.messages.len(), 2); + assert!(first.messages[0].text.contains("\u{1b}[31m")); + + let cursor = Cursor::after(&first.messages[0]); + let next = api + .page("s1".to_owned(), Some(cursor.clone())) + .await + .unwrap(); + assert!(next.truncated); + + let bodies = sent(&pond); + assert_eq!(bodies[0]["query"], preview_sql("s1")); + assert_eq!(bodies[0]["limit"], PREVIEW_ROWS); + assert_eq!(bodies[1]["limit"], PAGE_ROWS); + assert_eq!(bodies[2]["query"], page_sql("s1", Some(&cursor))); + } + + #[tokio::test] + async fn search_posts_the_wire_request() { + let sandbox = Sandbox::new(); + let pond = FakePond::with_sql(Vec::new(), Reply::json(golden::SEARCH_OUT_OF_SCOPE)).await; + let api = api_at(pond.connect(), &sandbox); + let scope = ListingScope { + project: Some("/pj/pond".to_owned()), + since: None, + limit: 1, + }; + let response = api + .search(SearchRequest::new("timer".to_owned(), 20).within(&scope)) + .await + .unwrap(); + assert_eq!(response.searchable_in_scope, 0); + assert_eq!(pond.recorded()[0].path, SEARCH_PATH); + assert_eq!( + sent(&pond)[0], + serde_json::json!({ + "protocol_version": 1, + "query": "timer", + "filters": {"project": {"contains": "/pj/pond"}}, + "limit": 20 + }) + ); + } + + #[tokio::test] + async fn every_failure_shape_maps_to_its_error() { + let sandbox = Sandbox::new(); + let pond = FakePond::start(|path, body| match (path, body) { + (SEARCH_PATH, _) => Reply::plain(422, golden::AXUM_REJECTION), + (_, body) if body.contains("preview_error") => Reply::status(400, golden::SQL_ERROR), + (_, body) if body.contains("s-bad") => Reply::json(r#"{"columns":[]}"#), + _ => Reply::plain(404, ""), + }) + .await; + let api = api_at(pond.connect(), &sandbox); + + let Err(ApiError::Pond { code, message }) = api.preview("preview_error".to_owned()).await + else { + panic!("expected a pond envelope error"); + }; + assert_eq!(code, "validation_failed"); + assert!(message.starts_with("sql error: query exceeded the 30s limit")); + + let rejected = api.search(SearchRequest::new("x".to_owned(), 1)).await; + assert_eq!( + rejected.unwrap_err(), + ApiError::Rejected { + status: 422, + body: golden::AXUM_REJECTION.to_owned() + } + ); + + assert!(matches!( + api.preview("s-bad".to_owned()).await, + Err(ApiError::Decode(_)) + )); + assert_eq!( + api.preview("other".to_owned()).await, + Err(ApiError::PondTooOld) + ); + } + + #[tokio::test] + async fn a_timeout_is_an_error_without_failover() { + let sandbox = Sandbox::new(); + let stalled = + FakePond::start(|_, _| Reply::json(golden::SQL_EMPTY).delayed(Duration::from_secs(5))) + .await; + let ready = preview_pond().await; + write_endpoint( + &sandbox.origin().dir.endpoint(), + &endpoint(&ready.socket, "t"), + ) + .unwrap(); + let api = api_at(stalled.connect(), &sandbox); + let request = SqlRequest::new(preview_sql("s"), PREVIEW_ROWS, 1); + let result: Result = api + .post(SQL_PATH, &request, Duration::from_millis(200)) + .await; + let Err(ApiError::Request(reason)) = result else { + panic!("expected a request error, got {result:?}"); + }; + assert!(reason.contains("timed out"), "{reason}"); + assert!( + ready.recorded().is_empty(), + "a timeout re-resolved the serve" + ); + } + + #[tokio::test] + async fn a_missing_or_refusing_socket_is_unreachable() { + let sandbox = Sandbox::new(); + let request = SqlRequest::new(READY_SQL.to_owned(), 1, 1); + for socket in [missing_socket(), stale_socket(&sandbox.path("stale.sock"))] { + let result: Result = socket + .post(SQL_PATH, &request, Duration::from_secs(5)) + .await; + let Err(ApiError::Unreachable(reason)) = result else { + panic!("expected Unreachable, got {result:?}"); + }; + assert!(reason.contains(&*socket.path.to_string_lossy()), "{reason}"); + } + } + + #[tokio::test] + async fn a_refused_socket_fails_over_to_the_endpoint() { + let sandbox = Sandbox::new(); + let ready = preview_pond().await; + write_endpoint( + &sandbox.origin().dir.endpoint(), + &endpoint(&ready.socket, "t"), + ) + .unwrap(); + let api = api_at(stale_socket(&sandbox.path("stale.sock")), &sandbox); + assert_eq!(api.preview("s1".to_owned()).await.unwrap().len(), 2); + assert_eq!(ready.recorded().len(), 2, "probe, then the retried preview"); + } + + #[tokio::test] + async fn a_failed_retry_does_not_wedge_failover() { + let sandbox = Sandbox::new(); + let endpoint_path = sandbox.origin().dir.endpoint(); + let stalling = FakePond::with_sql( + vec![ + ("SELECT 1", Reply::json(golden::SQL_READY)), + ( + "DESC LIMIT", + Reply::json(golden::SQL_PAGE).delayed(Duration::from_secs(5)), + ), + ], + Reply::json(golden::SEARCH), + ) + .await; + write_endpoint(&endpoint_path, &endpoint(&stalling.socket, "t")).unwrap(); + let api = api_at(missing_socket(), &sandbox); + let request = SqlRequest::new(preview_sql("s1"), PREVIEW_ROWS, 1); + let result: Result = api + .post(SQL_PATH, &request, Duration::from_millis(300)) + .await; + assert!(matches!(result, Err(ApiError::Request(_))), "{result:?}"); + + drop(stalling); + tokio::time::sleep(Duration::from_millis(50)).await; + let ready = preview_pond().await; + write_endpoint(&endpoint_path, &endpoint(&ready.socket, "t")).unwrap(); + assert_eq!(api.preview("s1".to_owned()).await.unwrap().len(), 2); + assert_eq!(ready.recorded().len(), 2, "probe, then the retried preview"); + } + + #[tokio::test] + async fn an_aborted_request_leaves_the_fallback_spawn_running() { + let sandbox = Sandbox::new(); + let pond = preview_pond().await; + let script = write_script( + &sandbox.path("bin/pond"), + &format!( + r#"printf '%s\n' "$*" >> '{calls}' +sleep 0.3 +eval "socket=\${{$#}}" +ln -s '{target}' "$socket" +exec sleep 30"#, + calls = sandbox.path("calls").display(), + target = pond.socket.display(), + ), + ); + sandbox.write_config(&format!("pond_bin = \"{}\"\n", script.display())); + let api = Arc::new(api(&sandbox, None)); + + let request = tokio::spawn({ + let api = Arc::clone(&api); + async move { api.preview("s1".to_owned()).await } + }); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while sandbox.lines("calls").is_empty() { + assert!(tokio::time::Instant::now() < deadline, "no serve spawned"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + request.abort(); + assert!(request.await.unwrap_err().is_cancelled()); + + assert_eq!(api.preview("s1".to_owned()).await.unwrap().len(), 2); + assert_eq!( + sandbox.lines("calls").len(), + 1, + "the abort killed the spawn" + ); + } + + #[tokio::test] + async fn callers_queued_behind_a_failed_resolution_share_its_error() { + let sandbox = Sandbox::new(); + let script = write_script( + &sandbox.path("bin/pond"), + &format!( + "echo spawned >> '{}'; sleep 0.2; exit 1", + sandbox.path("calls").display() + ), + ); + sandbox.write_config(&format!("pond_bin = \"{}\"\n", script.display())); + let api = api(&sandbox, None); + let (preview, titles) = tokio::join!( + api.preview("s1".to_owned()), + api.titles(vec!["s1".to_owned()]) + ); + let Err(error @ ApiError::Unreachable(_)) = preview else { + panic!("expected Unreachable, got {preview:?}"); + }; + assert_eq!(titles, Err(error)); + assert_eq!(sandbox.lines("calls").len(), 1, "one spawn for both"); + + tokio::time::sleep(RESOLVE_RETRY_AFTER).await; + assert!(api.preview("s1".to_owned()).await.is_err()); + assert_eq!( + sandbox.lines("calls").len(), + 2, + "a later call resolves again" + ); + } + + #[tokio::test] + async fn a_successor_live_at_the_refused_path_is_used() { + let sandbox = Sandbox::new(); + let owner = sandbox.origin().dir.socket("owner"); + let api = api_at(stale_socket(&owner), &sandbox); + let successor = preview_pond().await; + std::fs::remove_file(&owner).unwrap(); + std::os::unix::fs::symlink(&successor.socket, &owner).unwrap(); + write_endpoint(&sandbox.origin().dir.endpoint(), &endpoint(&owner, "t")).unwrap(); + + let socket = api.resolve(Some(owner.clone())).await.unwrap(); + assert_eq!(socket.path, owner); + assert_eq!(api.preview("s1".to_owned()).await.unwrap().len(), 2); + } + + #[tokio::test] + async fn live_agents_come_from_herdrs_pane_list() { + let sandbox = Sandbox::new(); + write_script( + &sandbox.path("bin/herdr"), + r#"echo '{"result":{"panes":[{"pane_id":"p1","agent":"codex","agent_session":{"kind":"path","value":"/s/rollout-abc.jsonl"}},{"pane_id":"p2"}]}}'"#, + ); + let api = api_at(missing_socket(), &sandbox); + let live = api.live_agents().await.unwrap(); + assert_eq!(live.len(), 1); + assert!(live[0].matches("abc")); + + write_script(&sandbox.path("bin/herdr"), "echo boom >&2; exit 1"); + let Err(ApiError::Herdr(reason)) = api.live_agents().await else { + panic!("expected an error"); + }; + assert!(reason.contains("boom"), "{reason}"); + } +} diff --git a/packages/herdr-pond/src/config.rs b/packages/herdr-pond/src/config.rs new file mode 100644 index 00000000..b202a19f --- /dev/null +++ b/packages/herdr-pond/src/config.rs @@ -0,0 +1,305 @@ +//! The plugin's own files: `HERDR_PLUGIN_CONFIG_DIR/config.toml` (re-read per +//! run, malformed falls back to defaults) and the state-dir logs, locks and +//! atomic writes every headless leg shares. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, bail}; +use nix::errno::Errno; +use nix::fcntl::{Flock, FlockArg}; +use serde::Deserialize; + +pub(crate) const CONFIG_FILE: &str = "config.toml"; + +/// Headless logs are the only record of what a detached leg did, so they are +/// kept but bounded: past this size the next writer starts the file over. +const LOG_CAP_BYTES: u64 = 1 << 20; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct Config { + pub sync_on_idle: bool, + pub pond_bin: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + sync_on_idle: true, + pond_bin: None, + } + } +} + +impl Config { + /// Never fails: a missing file is the defaults, a malformed one is logged + /// to `log` and also the defaults. + pub(crate) fn load(config_dir: &Path, log: &Path) -> Self { + let path = config_dir.join(CONFIG_FILE); + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Self::default(), + Err(error) => { + log_line( + log, + &format!("cannot read {}: {error}; using defaults", path.display()), + ); + return Self::default(); + } + }; + toml::from_str(&text).unwrap_or_else(|error| { + log_line( + log, + &format!("malformed {}: {error}; using defaults", path.display()), + ); + Self::default() + }) + } + + /// [`Self::load`] then [`Self::resolve_pond`]: the `pond` to spawn right now. + pub(crate) fn pond(config_dir: &Path, log: &Path) -> anyhow::Result { + Self::load(config_dir, log).resolve_pond(config_dir) + } + + /// The `pond` every spawn runs: `pond_bin` when set, else a PATH lookup. + /// herdr's PATH is the server's from whenever it started, so a lookup + /// failure names the config key that fixes it. + pub(crate) fn resolve_pond(&self, config_dir: &Path) -> anyhow::Result { + let config = config_dir.join(CONFIG_FILE); + if let Some(pond) = &self.pond_bin { + if !pond.is_absolute() { + bail!( + "pond_bin = {:?} in {} must be an absolute path", + pond.display(), + config.display() + ); + } + if !is_executable(pond) { + bail!( + "pond_bin = {:?} in {} is not an executable file", + pond.display(), + config.display() + ); + } + return Ok(pond.clone()); + } + let path = std::env::var_os("PATH").unwrap_or_default(); + find_on_path("pond", &path).with_context(|| { + format!( + "pond not found on herdr's PATH; set pond_bin = \"/absolute/path/to/pond\" in {}", + config.display() + ) + }) + } +} + +fn find_on_path(name: &str, path: &std::ffi::OsStr) -> Option { + std::env::split_paths(path) + .map(|dir| dir.join(name)) + .find(|candidate| candidate.is_absolute() && is_executable(candidate)) +} + +fn is_executable(path: &Path) -> bool { + fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) +} + +/// Opens `path` for appending, creating parents, and starts it over once it +/// passes the cap. Children handed this file append at its live end. +pub(crate) fn open_log(path: &Path) -> io::Result { + ensure_parent(path)?; + cap_log(path)?; + OpenOptions::new().create(true).append(true).open(path) +} + +/// Starts `path` over once it passes the cap. Writers holding it open in +/// append mode, like a long-lived serve, carry on at the new end. +pub(crate) fn cap_log(path: &Path) -> io::Result<()> { + if fs::metadata(path).is_ok_and(|meta| meta.len() > LOG_CAP_BYTES) { + OpenOptions::new().write(true).open(path)?.set_len(0)?; + } + Ok(()) +} + +/// Points every stdio end of `command` at /dev/null or `log`: an inherited +/// pipe would pin a herdr command slot, or corrupt the desk's terminal. +pub(crate) fn log_stdio<'a>(command: &'a mut Command, log: &Path) -> io::Result<&'a mut Command> { + let out = open_log(log)?; + let err = out.try_clone()?; + Ok(command.stdin(Stdio::null()).stdout(out).stderr(err)) +} + +/// Best effort: a headless leg has nowhere else to report a failed log write. +pub(crate) fn log_line(path: &Path, message: &str) { + if let Ok(mut file) = open_log(path) { + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let _ = writeln!(file, "{now} [{}] {message}", std::process::id()); + } +} + +/// A non-blocking exclusive flock on `path`, held until the guard drops. +/// `None` means another process holds it. +pub(crate) fn try_lock(path: &Path) -> io::Result>> { + ensure_parent(path)?; + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path)?; + match Flock::lock(file, FlockArg::LockExclusiveNonblock) { + Ok(lock) => Ok(Some(lock)), + Err((_, Errno::EWOULDBLOCK)) => Ok(None), + Err((_, errno)) => Err(io::Error::from(errno)), + } +} + +/// Temp file + rename, so a reader never sees a half-written file. `mode` +/// is filtered by the umask, as `fs::write`'s 0o666 is; the temp file is +/// created fresh so a leftover one cannot carry a wider mode over. +pub(crate) fn write_atomic(path: &Path, contents: &[u8], mode: u32) -> io::Result<()> { + ensure_parent(path)?; + let mut temp = path.as_os_str().to_owned(); + temp.push(format!(".tmp.{}", std::process::id())); + let temp = PathBuf::from(temp); + let _ = fs::remove_file(&temp); + OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(&temp)? + .write_all(contents)?; + fs::rename(&temp, path).inspect_err(|_| { + let _ = fs::remove_file(&temp); + }) +} + +fn ensure_parent(path: &Path) -> io::Result<()> { + path.parent().map_or(Ok(()), fs::create_dir_all) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use super::*; + use crate::fake_pond::{Sandbox, write_script}; + + #[test] + fn missing_config_is_the_defaults() { + let sandbox = Sandbox::new(); + let config = Config::load(&sandbox.config_dir(), &sandbox.path("log")); + assert_eq!(config, Config::default()); + assert!(config.sync_on_idle); + } + + #[test] + fn config_keys_are_read() { + let sandbox = Sandbox::new(); + sandbox.write_config("sync_on_idle = false\npond_bin = \"/opt/pond\"\n"); + let config = Config::load(&sandbox.config_dir(), &sandbox.path("log")); + assert!(!config.sync_on_idle); + assert_eq!(config.pond_bin, Some(PathBuf::from("/opt/pond"))); + } + + #[test] + fn malformed_config_is_logged_and_defaulted() { + let sandbox = Sandbox::new(); + let log = sandbox.path("state/sync.log"); + for text in [ + "sync_on_idle = \"yes\"", + "not toml [", + "sync_on_idel = false", + ] { + sandbox.write_config(text); + assert_eq!(Config::load(&sandbox.config_dir(), &log), Config::default()); + } + let logged = fs::read_to_string(&log).unwrap(); + assert_eq!(logged.matches("malformed").count(), 3, "{logged}"); + } + + #[test] + fn pond_bin_must_be_absolute_and_executable() { + let sandbox = Sandbox::new(); + let relative = Config { + pond_bin: Some(PathBuf::from("bin/pond")), + ..Config::default() + }; + let error = relative.resolve_pond(&sandbox.config_dir()).unwrap_err(); + assert!(error.to_string().contains("absolute"), "{error}"); + + let missing = Config { + pond_bin: Some(sandbox.path("nope/pond")), + ..Config::default() + }; + assert!(missing.resolve_pond(&sandbox.config_dir()).is_err()); + + let pond = write_script(&sandbox.path("bin/pond"), "exit 0"); + let set = Config { + pond_bin: Some(pond.clone()), + ..Config::default() + }; + assert_eq!(set.resolve_pond(&sandbox.config_dir()).unwrap(), pond); + } + + #[test] + fn path_lookup_skips_non_executables_and_relative_dirs() { + let sandbox = Sandbox::new(); + fs::create_dir_all(sandbox.path("plain")).unwrap(); + fs::write(sandbox.path("plain/pond"), "").unwrap(); + let pond = write_script(&sandbox.path("exec/pond"), "exit 0"); + let path = std::env::join_paths([ + PathBuf::from("relative"), + sandbox.path("plain"), + sandbox.path("exec"), + ]) + .unwrap(); + assert_eq!(find_on_path("pond", &path), Some(pond)); + assert_eq!(find_on_path("pond", std::ffi::OsStr::new("")), None); + } + + #[test] + fn log_starts_over_past_the_cap() { + let sandbox = Sandbox::new(); + let log = sandbox.path("state/sync.log"); + fs::create_dir_all(sandbox.path("state")).unwrap(); + fs::write( + &log, + vec![b'x'; usize::try_from(LOG_CAP_BYTES).unwrap() + 1], + ) + .unwrap(); + log_line(&log, "fresh"); + let text = fs::read_to_string(&log).unwrap(); + assert!( + text.ends_with("fresh\n") && text.len() < 200, + "{}", + text.len() + ); + } + + #[test] + fn lock_is_exclusive_until_dropped() { + let sandbox = Sandbox::new(); + let path = sandbox.path("state/worker.lock"); + let held = try_lock(&path).unwrap().expect("first lock"); + assert!(try_lock(&path).unwrap().is_none()); + drop(held); + assert!(try_lock(&path).unwrap().is_some()); + } + + #[test] + fn atomic_write_replaces_whole_file() { + let sandbox = Sandbox::new(); + let path = sandbox.path("state/endpoint"); + write_atomic(&path, b"one", 0o666).unwrap(); + write_atomic(&path, b"two", 0o600).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "two"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read_dir(sandbox.path("state")).unwrap().count(), 1); + } +} diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs new file mode 100644 index 00000000..0a468e94 --- /dev/null +++ b/packages/herdr-pond/src/daemon.rs @@ -0,0 +1,1053 @@ +//! The per-herdr-server `pond serve` owner: startup hook and detached +//! watchdog. +//! +//! Startup hooks are one-shot and unserialized, so the hook only decides and +//! detaches; the `--owner` watchdog holds `lock` for its whole life, so at +//! most one supervised serve exists per herdr server, and it never outlives +//! that server. + +use std::collections::hash_map::RandomState; +use std::future::Future; +use std::hash::BuildHasher; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use anyhow::bail; +use chrono::Utc; +use nix::errno::Errno; +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; + +use crate::api::{SEARCH_PATH, SQL_PATH, Socket, sql_deadline}; +use crate::config::{cap_log, log_line, try_lock}; +use crate::serve::{ + Endpoint, READY_DEADLINE, ServeChild, ServeDir, probe, read_endpoint, remove_endpoint_if_owned, + retire, write_endpoint, +}; +use crate::types::{ + LISTING_ROWS, ListingScope, SearchRequest, SearchResponse, SqlRequest, SqlResponse, listing_sql, +}; +use crate::{herdr, runtime, shutdown_signal}; + +struct Timing { + tick: Duration, + liveness_every: Duration, + /// A live handoff swaps herdr's socket, and the new server waits up to 5s + /// for the old one to close before binding: only misses spanning longer + /// than this mean herdr is gone. + handoff_window: Duration, + ready_deadline: Duration, + /// The historical 47-300s cold FTS load is paid here, not by the desk. + warmup_deadline: Duration, + grace: Duration, +} + +const TIMING: Timing = Timing { + tick: Duration::from_millis(500), + liveness_every: Duration::from_secs(20), + handoff_window: Duration::from_secs(10), + ready_deadline: READY_DEADLINE, + warmup_deadline: Duration::from_secs(300), + grace: Duration::from_secs(10), +}; + +const WARMUP_QUERY: &str = "session"; + +pub(crate) fn run(args: &[String]) -> anyhow::Result<()> { + match args { + [] => { + startup(); + Ok(()) + } + [flag] if flag == "--owner" => { + herdr::detach(); + owner() + } + _ => bail!(crate::USAGE), + } +} + +/// The startup hook: exits at once, failures go to `daemon.log`. +fn startup() { + let Ok(dir) = ServeDir::from_env() else { + return; + }; + let log = dir.daemon_log(); + let result = start(&dir, || { + let mut command = Command::new(std::env::current_exe()?); + command.args(["serve-daemon", "--owner"]); + herdr::spawn_detached(command, &log) + }); + if let Err(error) = result { + log_line(&log, &format!("serve-daemon: {error:#}")); + } +} + +/// Spawns an owner unless a live one holds the lock. A free lock means no +/// owner supervises whatever the endpoint names, so the owner replaces it. +fn start(dir: &ServeDir, spawn_owner: impl FnOnce() -> anyhow::Result<()>) -> anyhow::Result<()> { + if try_lock(&dir.lock())?.is_none() { + return Ok(()); + } + spawn_owner() +} + +fn owner() -> anyhow::Result<()> { + let dir = ServeDir::from_env()?; + let socket = herdr::socket_path()?; + let state_dir = herdr::state_dir()?; + let config_dir = herdr::config_dir()?; + let log = dir.daemon_log(); + runtime()?.block_on(async { + let shutdown = shutdown_signal()?; + own( + &dir, + &socket, + &TIMING, + || herdr::resolve_pond_or_toast(&config_dir, &state_dir, &log), + shutdown, + ) + .await + }) +} + +/// Holds the lock and supervises one serve until the serve dies, herdr goes +/// away, or `shutdown` fires; every ending tears down the same way. +async fn own( + dir: &ServeDir, + socket: &Path, + timing: &Timing, + resolve_pond: impl FnOnce() -> Option, + shutdown: impl Future, +) -> anyhow::Result<()> { + let log = dir.daemon_log(); + let Some(_lock) = try_lock(&dir.lock())? else { + return Ok(()); + }; + // Before any orphan is stopped: a working serve must not go for a + // replacement that cannot be spawned. + let Some(pond) = resolve_pond() else { + return Ok(()); + }; + let owner_socket = dir.socket("owner"); + // A record aimed anywhere but this dir's owner socket is corrupt: never + // let it steer a signal at a desk fallback or elsewhere. + if let Some(orphan) = + read_endpoint(&dir.endpoint()).filter(|record| record.socket == owner_socket) + { + let answers = match Socket::new(orphan.socket.clone()) { + Ok(socket) => probe(&socket).await.is_ok(), + Err(_) => false, + }; + if answers || still_serving(&orphan) { + log_line( + &log, + &format!( + "owner: {} (pid {}) {} but no owner supervises it (a dead owner's \ + orphan) - stopping it for a fresh serve", + orphan.socket.display(), + orphan.pid, + if answers { "answers" } else { "holds its lock" } + ), + ); + if let Err(error) = stop_orphan(&orphan, timing).await { + log_line(&log, &format!("owner: cannot stop the orphan - {error}")); + return Ok(()); + } + remove_endpoint_if_owned(&dir.endpoint(), &orphan.token); + } + } + let mut serve = ServeChild::spawn(&pond, owner_socket.clone(), log.clone(), timing.grace)?; + // Published at spawn, so the record names the owner's latest serve; + // desks treat it as absent until it answers. An owner killed between + // spawn and publish leaves an unrecorded serve no pid can reach. + let record = Endpoint { + socket: owner_socket, + token: random_token(), + pid: serve.id(), + }; + let reason = match write_endpoint(&dir.endpoint(), &record) { + Err(error) => format!("cannot publish the endpoint: {error}"), + Ok(()) => { + log_line( + &log, + &format!( + "owner: started {} (pid {}), published {}", + pond.display(), + record.pid, + record.socket.display() + ), + ); + tokio::select! { + reason = supervise(&mut serve, &log, timing) => reason, + reason = herdr_gone(socket, &log, timing) => reason, + signal = shutdown => format!("received {signal}"), + } + } + }; + log_line(&log, &format!("owner: stopping - {reason}")); + let _ = retire(serve).await; + remove_endpoint_if_owned(&dir.endpoint(), &record.token); + log_line(&log, "owner: stopped"); + Ok(()) +} + +fn random_token() -> String { + let state = RandomState::new(); + format!( + "{:016x}{:016x}", + state.hash_one(std::process::id()), + state.hash_one(Instant::now()) + ) +} + +/// Stops the serve a record names: published at spawn, the record names the +/// latest serve on its socket, and its command line must still say so. +async fn stop_orphan(orphan: &Endpoint, timing: &Timing) -> Result<(), String> { + let pid = record_pid(orphan) + .ok_or_else(|| format!("the record names no usable pid ({})", orphan.pid))?; + if !serves_at(pid, &orphan.socket) { + return Err(format!( + "pid {pid} is not a pond serve on {}", + orphan.socket.display() + )); + } + for signal in [Signal::SIGTERM, Signal::SIGKILL] { + if !still_serving(orphan) { + return Ok(()); + } + match kill(pid, signal) { + Ok(()) => {} + Err(Errno::ESRCH) => return Ok(()), + Err(error) => return Err(format!("{signal} to pid {pid}: {error}")), + } + let deadline = Instant::now() + timing.grace; + while Instant::now() < deadline { + if !still_serving(orphan) { + return Ok(()); + } + tokio::time::sleep(timing.tick).await; + } + } + Err(format!("pid {pid} survived SIGKILL")) +} + +fn record_pid(record: &Endpoint) -> Option { + i32::try_from(record.pid) + .ok() + .filter(|pid| *pid > 1) + .map(Pid::from_raw) +} + +/// Whether the recorded pid still runs as the serve on its socket. A +/// zombie's command line is empty and a reused pid's differs, so neither +/// counts. +fn still_serving(record: &Endpoint) -> bool { + record_pid(record) + .is_some_and(|pid| kill(pid, None) != Err(Errno::ESRCH) && serves_at(pid, &record.socket)) +} + +/// Whether `pid`'s command line serves `socket`, so a pid the record got +/// wrong is never signalled. +fn serves_at(pid: Pid, socket: &Path) -> bool { + names_socket(&command_line(pid), socket) +} + +/// `pid`'s arguments joined by spaces; empty for a gone or zombie process. +#[cfg(target_os = "linux")] +fn command_line(pid: Pid) -> String { + std::fs::read(format!("/proc/{pid}/cmdline")).map_or_else( + |_| String::new(), + |argv| { + argv.strip_suffix(b"\0") + .unwrap_or(&argv) + .split(|byte| *byte == 0) + .map(String::from_utf8_lossy) + .collect::>() + .join(" ") + }, + ) +} + +/// `pid`'s arguments as `ps` prints them; empty for a gone process or a `ps` +/// that does not answer within [`PS_DEADLINE`]. +#[cfg(not(target_os = "linux"))] +fn command_line(pid: Pid) -> String { + let child = Command::new("ps") + .args(["-ww", "-o", "args=", "-p", &pid.to_string()]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn(); + let Ok(mut child) = child else { + return String::new(); + }; + let deadline = Instant::now() + PS_DEADLINE; + while matches!(child.try_wait(), Ok(None)) { + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + return String::new(); + } + std::thread::sleep(Duration::from_millis(10)); + } + child + .wait_with_output() + .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) + .unwrap_or_default() +} + +#[cfg(not(target_os = "linux"))] +const PS_DEADLINE: Duration = Duration::from_secs(2); + +/// Whether a command line passes `--socket `. Paths may hold spaces, +/// so the line is never split: the argument ends it or is followed by a space. +fn names_socket(command_line: &str, socket: &Path) -> bool { + let Some(socket) = socket.to_str() else { + return false; + }; + let argument = format!(" --socket {socket}"); + let line = command_line.trim_end_matches('\n'); + line.ends_with(&argument) || line.contains(&format!("{argument} ")) +} + +/// Waits for serve to answer the capability probe, warms it up, and returns +/// why the owner must stop. +async fn supervise(serve: &mut ServeChild, log: &Path, timing: &Timing) -> String { + let socket = match serve.ready(timing.ready_deadline).await { + Ok(socket) => socket, + Err(error) => return error.to_string(), + }; + log_line(log, &format!("owner: ready on {}", socket.path.display())); + let ((), reason) = tokio::join!( + warm_up(&socket, log, timing.warmup_deadline), + exited(serve, timing.tick) + ); + reason +} + +async fn exited(serve: &mut ServeChild, tick: Duration) -> String { + loop { + if let Some(reason) = serve.exited() { + return reason; + } + tokio::time::sleep(tick).await; + } +} + +/// Returns once herdr's socket has refused connections for longer than a live +/// handoff takes. Each check also caps `daemon.log`, which the long-lived +/// serve appends to. +async fn herdr_gone(socket: &Path, log: &Path, timing: &Timing) -> String { + let mut first_miss: Option = None; + loop { + let _ = cap_log(log); + match UnixStream::connect(socket) { + Ok(_) => first_miss = None, + Err(error) => { + if first_miss.get_or_insert_with(Instant::now).elapsed() > timing.handoff_window { + return format!("herdr server is gone ({error})"); + } + } + } + let next = if first_miss.is_some() { + timing.tick + } else { + timing.liveness_every + }; + tokio::time::sleep(next).await; + } +} + +/// The desk's opening listing and a first FTS search, once, so their cold +/// cost lands here instead of on the first desk open. The listing may take +/// all of `budget` (a cold one exceeds the desk's own timeout); the search +/// gets what is left. Failure is not fatal. +async fn warm_up(socket: &Socket, log: &Path, budget: Duration) { + let started = Instant::now(); + let timeout_seconds = budget.as_secs(); + let listing = SqlRequest::new( + listing_sql(&ListingScope::recent(None, Utc::now())), + LISTING_ROWS, + timeout_seconds, + ); + let search = SearchRequest::new(WARMUP_QUERY.to_owned(), 1); + let listing_deadline = sql_deadline(timeout_seconds); + let result = async { + socket + .post::<_, SqlResponse>(SQL_PATH, &listing, listing_deadline) + .await?; + let search_deadline = budget.saturating_sub(started.elapsed()); + socket + .post::<_, SearchResponse>(SEARCH_PATH, &search, search_deadline) + .await + } + .await; + let outcome = match result { + Ok(_) => "done".to_owned(), + Err(error) => format!("failed: {error}"), + }; + log_line( + log, + &format!( + "owner: warm-up {outcome} ({:.1}s)", + started.elapsed().as_secs_f64() + ), + ); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use std::fs; + use std::os::unix::net::UnixListener; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + use crate::fake_pond::{ + FakePond, Reply, Sandbox, alive, endpoint, golden, stale_socket, write_script, + }; + use crate::serve::socket_lock; + + const FAST: Timing = Timing { + tick: Duration::from_millis(20), + liveness_every: Duration::from_millis(100), + handoff_window: Duration::from_millis(300), + ready_deadline: Duration::from_secs(1), + warmup_deadline: Duration::from_secs(5), + grace: Duration::from_secs(2), + }; + + struct Setup { + sandbox: Sandbox, + pond: FakePond, + socket: PathBuf, + dir: ServeDir, + } + + impl Setup { + async fn new() -> Self { + Self::with( + FakePond::with_sql( + vec![ + ("SELECT 1", Reply::json(golden::SQL_READY)), + ("GROUP BY session_id", Reply::json(golden::SQL_LISTING)), + ], + Reply::json(golden::SEARCH), + ) + .await, + ) + } + + fn with(pond: FakePond) -> Self { + let sandbox = Sandbox::new(); + let socket = sandbox.path("herdr.sock"); + let dir = ServeDir::new(&sandbox.state_dir(), &socket); + Self { + sandbox, + pond, + socket, + dir, + } + } + + /// A fake `pond serve` that answers through the fake server when + /// `publish`, then runs `after`. + fn fake_pond(&self, publish: bool, after: &str) -> PathBuf { + let target = publish.then_some(self.pond.socket.as_path()); + self.sandbox.fake_serve(target, after) + } + + async fn own(&self, pond: &Path) -> anyhow::Result<()> { + self.own_until(pond, std::future::pending()).await + } + + async fn own_until( + &self, + pond: &Path, + shutdown: impl Future, + ) -> anyhow::Result<()> { + let pond = pond.to_path_buf(); + own(&self.dir, &self.socket, &FAST, move || Some(pond), shutdown).await + } + + fn serve_calls(&self) -> usize { + self.sandbox + .lines("calls") + .iter() + .filter(|line| { + **line == format!("serve --socket {}", self.owner_socket().display()) + }) + .count() + } + + fn owner_socket(&self) -> PathBuf { + self.dir.socket("owner") + } + + fn log(&self) -> String { + fs::read_to_string(self.dir.daemon_log()).unwrap_or_default() + } + + fn endpoint(&self) -> Option { + read_endpoint(&self.dir.endpoint()) + } + + /// Serve answered the probe; the record was published at spawn. + async fn published(&self) { + wait_until("a ready serve", || self.log().contains("owner: ready on")).await; + } + } + + async fn wait_until(what: &str, condition: impl Fn() -> bool) { + let deadline = Instant::now() + Duration::from_secs(20); + while !condition() { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + #[tokio::test] + async fn one_owner_serves_until_herdr_goes_away() { + let setup = Setup::new().await; + let pond = setup.fake_pond(true, "exec sleep 30"); + let listener = UnixListener::bind(&setup.socket).unwrap(); + let herdr_stops = async { + wait_until("the endpoint and warm-up", || { + setup.endpoint().is_some() + && setup.pond.recorded().iter().any(|r| r.path == SEARCH_PATH) + }) + .await; + assert!(alive(setup.sandbox.serve_pid())); + drop(listener); + }; + let ((first, second), ()) = tokio::join!( + async { tokio::join!(setup.own(&pond), setup.own(&pond)) }, + herdr_stops + ); + first.unwrap(); + second.unwrap(); + + assert_eq!(setup.serve_calls(), 1, "{}", setup.log()); + assert!(setup.endpoint().is_none(), "endpoint outlived its serve"); + assert!( + fs::symlink_metadata(setup.owner_socket()).is_err(), + "socket outlived its serve" + ); + assert!( + socket_lock(&setup.owner_socket()).exists(), + "the owner's lock is its successor's" + ); + assert!( + !alive(setup.sandbox.serve_pid()), + "pond serve outlived herdr" + ); + assert!(setup.log().contains("herdr server is gone")); + let warmup = &setup.pond.recorded()[1]; + assert_eq!(warmup.path, SQL_PATH); + assert!( + warmup.body.contains("timestamp >= TIMESTAMP"), + "{}", + warmup.body + ); + let body: serde_json::Value = serde_json::from_str(&warmup.body).unwrap(); + assert_eq!( + body["timeout_seconds"], + FAST.warmup_deadline.as_secs(), + "the warm-up listing gets the whole budget" + ); + } + + #[tokio::test] + async fn a_handoff_gap_is_not_herdr_leaving() { + let setup = Setup::new().await; + let pond = setup.fake_pond(true, "exec sleep 30"); + let old_server = UnixListener::bind(&setup.socket).unwrap(); + let handoff = async { + setup.published().await; + drop(old_server); + fs::remove_file(&setup.socket).unwrap(); + tokio::time::sleep(FAST.handoff_window / 2).await; + let new_server = UnixListener::bind(&setup.socket).unwrap(); + fs::write(setup.dir.daemon_log(), vec![b'x'; 2 << 20]).unwrap(); + tokio::time::sleep(FAST.handoff_window * 2).await; + assert!(setup.endpoint().is_some(), "{}", setup.log()); + assert!(alive(setup.sandbox.serve_pid())); + let log_len = fs::metadata(setup.dir.daemon_log()).unwrap().len(); + assert!(log_len < 1 << 20, "daemon.log not capped: {log_len}"); + drop(new_server); + }; + let (owner, ()) = tokio::join!(setup.own(&pond), handoff); + owner.unwrap(); + assert!(setup.log().contains("herdr server is gone")); + assert!(setup.endpoint().is_none()); + } + + #[tokio::test] + async fn a_signal_tears_down_like_herdr_leaving() { + let setup = Setup::new().await; + let pond = setup.fake_pond(true, "exec sleep 30"); + let _listener = UnixListener::bind(&setup.socket).unwrap(); + let signal = async { + setup.published().await; + "SIGTERM" + }; + setup.own_until(&pond, signal).await.unwrap(); + assert!(setup.log().contains("received SIGTERM"), "{}", setup.log()); + assert!(setup.endpoint().is_none()); + assert!(!alive(setup.sandbox.serve_pid())); + } + + #[tokio::test] + async fn teardown_keeps_a_successors_endpoint() { + let setup = Setup::new().await; + let pond = setup.fake_pond(true, "exec sleep 30"); + let listener = UnixListener::bind(&setup.socket).unwrap(); + let successor = async { + setup.published().await; + let mut endpoint = setup.endpoint().unwrap(); + endpoint.token = "successor".to_owned(); + write_endpoint(&setup.dir.endpoint(), &endpoint).unwrap(); + drop(listener); + }; + let (owner, ()) = tokio::join!(setup.own(&pond), successor); + owner.unwrap(); + assert_eq!(setup.endpoint().unwrap().token, "successor"); + } + + /// A dead owner's serve, detached so it is nobody's child here: it takes + /// `owner.sock`'s lock as pond does and, given a `target`, answers there + /// through it; `ignore_term` makes it one only SIGKILL stops. + fn orphan_serve(setup: &Setup, target: Option<&Path>, ignore_term: bool) -> u32 { + let trap = if ignore_term { "trap '' TERM" } else { "" }; + let publish = target.map_or_else(String::new, |target| { + format!(r#"ln -s '{}' "$socket""#, target.display()) + }); + let script = write_script( + &setup.sandbox.path("bin/orphan"), + &format!( + r#"{trap} +eval "socket=\${{$#}}" +echo $$ > "$socket.lock" +{publish} +sleep 30; :"# + ), + ); + let owner_socket = setup.owner_socket(); + fs::create_dir_all(owner_socket.parent().unwrap()).unwrap(); + let output = Command::new("/bin/sh") + .arg("-c") + .arg(format!( + "'{}' serve --socket '{}' >/dev/null 2>&1 & echo $!", + script.display(), + owner_socket.display() + )) + .output() + .unwrap(); + let pid = String::from_utf8(output.stdout) + .unwrap() + .trim() + .parse() + .unwrap(); + let started = if target.is_some() { + owner_socket.clone() + } else { + socket_lock(&owner_socket) + }; + let deadline = Instant::now() + Duration::from_secs(5); + while fs::symlink_metadata(&started).is_err() { + assert!(Instant::now() < deadline, "the orphan never started"); + std::thread::sleep(Duration::from_millis(10)); + } + pid + } + + /// The orphan holds `owner.sock`'s lock, so a fresh serve starts only once + /// the owner has stopped it; its answer must not pass for the fresh serve's. + #[tokio::test] + async fn an_unsupervised_live_endpoint_is_replaced() { + let setup = Setup::new().await; + let orphan = FakePond::with_sql( + vec![("SELECT 1", Reply::json(golden::SQL_READY))], + Reply::json(golden::SEARCH), + ) + .await; + let owner_socket = setup.owner_socket(); + let orphan_pid = orphan_serve(&setup, Some(&orphan.socket), false); + let record = Endpoint { + pid: orphan_pid, + ..endpoint(&owner_socket, "orphan") + }; + write_endpoint(&setup.dir.endpoint(), &record).unwrap(); + let pond = setup.fake_pond(true, "exec sleep 30"); + let listener = UnixListener::bind(&setup.socket).unwrap(); + let herdr_stops = async { + setup.published().await; + let fresh = setup.endpoint().unwrap(); + assert_eq!(fresh.socket, owner_socket); + assert_eq!(fresh.pid, setup.sandbox.serve_pid()); + drop(listener); + }; + let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); + owner.unwrap(); + assert!(!alive(orphan_pid), "the orphan still runs"); + assert_eq!(setup.serve_calls(), 1); + assert_eq!(orphan.recorded().len(), 1, "only the liveness probe"); + assert!(!setup.pond.recorded().is_empty()); + assert!( + setup.log().contains("no owner supervises"), + "{}", + setup.log() + ); + } + + #[tokio::test] + async fn a_record_neither_answering_nor_naming_the_socket_is_spared() { + let setup = Setup::new().await; + let owner_socket = setup.owner_socket(); + stale_socket(&owner_socket); + let mut bystander = Command::new("/bin/sh") + .args(["-c", "sleep 30; :"]) + .spawn() + .unwrap(); + let record = Endpoint { + pid: bystander.id(), + ..endpoint(&owner_socket, "dead") + }; + write_endpoint(&setup.dir.endpoint(), &record).unwrap(); + let pond = setup.fake_pond(true, "exec sleep 30"); + let listener = UnixListener::bind(&setup.socket).unwrap(); + let herdr_stops = async { + setup.published().await; + drop(listener); + }; + let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); + owner.unwrap(); + assert!(alive(bystander.id()), "a dead record's pid was signalled"); + bystander.kill().unwrap(); + bystander.wait().unwrap(); + } + + #[tokio::test] + async fn an_orphan_ignoring_sigterm_is_killed_after_the_grace() { + let setup = Setup::new().await; + let orphan = FakePond::with_sql( + vec![("SELECT 1", Reply::json(golden::SQL_READY))], + Reply::json(golden::SEARCH), + ) + .await; + let orphan_pid = orphan_serve(&setup, Some(&orphan.socket), true); + let record = Endpoint { + pid: orphan_pid, + ..endpoint(&setup.owner_socket(), "orphan") + }; + write_endpoint(&setup.dir.endpoint(), &record).unwrap(); + let pond = setup.fake_pond(true, "exec sleep 30"); + let listener = UnixListener::bind(&setup.socket).unwrap(); + let started = Instant::now(); + let herdr_stops = async { + setup.published().await; + drop(listener); + }; + let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); + owner.unwrap(); + assert!(!alive(orphan_pid), "the orphan still runs"); + assert!( + started.elapsed() >= FAST.grace, + "killed before the grace ran out" + ); + } + + #[tokio::test] + async fn a_record_aimed_elsewhere_is_ignored() { + let setup = Setup::new().await; + let orphan = FakePond::with_sql( + vec![("SELECT 1", Reply::json(golden::SQL_READY))], + Reply::json(golden::SEARCH), + ) + .await; + let mut bystander = Command::new("sleep").arg("30").spawn().unwrap(); + let record = Endpoint { + pid: bystander.id(), + ..endpoint(&orphan.socket, "elsewhere") + }; + write_endpoint(&setup.dir.endpoint(), &record).unwrap(); + let pond = setup.fake_pond(true, "exec sleep 30"); + let listener = UnixListener::bind(&setup.socket).unwrap(); + let herdr_stops = async { + setup.published().await; + drop(listener); + }; + let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); + owner.unwrap(); + assert!( + alive(bystander.id()), + "a record aimed elsewhere was signalled" + ); + assert!( + !setup.log().contains("no owner supervises"), + "{}", + setup.log() + ); + bystander.kill().unwrap(); + bystander.wait().unwrap(); + } + + #[test] + fn a_command_line_names_the_socket_only_as_the_whole_argument() { + let socket = Path::new("/Users/me/Library/Application Support/herdr/owner.sock"); + let serve = format!("/opt/pond serve --socket {}", socket.display()); + assert!(names_socket(&serve, socket)); + assert!( + names_socket(&format!("{serve}\n"), socket), + "ps ends its line" + ); + assert!(names_socket(&format!("{serve} --verbose"), socket)); + for other in ["owner.sock.lock", "owner.sock.bak"] { + let line = format!( + "/opt/pond serve --socket {}", + socket.with_file_name(other).display() + ); + assert!(!names_socket(&line, socket), "{line}"); + assert!( + !names_socket(&format!("{line} --verbose"), socket), + "{line}" + ); + } + assert!(!names_socket("", socket), "a gone or zombie process"); + let bare = format!("sleep 30 {}", socket.display()); + assert!(!names_socket(&bare, socket), "not a --socket argument"); + } + + #[test] + fn this_process_has_a_command_line() { + let pid = Pid::from_raw(i32::try_from(std::process::id()).unwrap()); + assert!(!command_line(pid).is_empty()); + } + + /// Still opening its store, an orphan holds the lock without answering. + #[tokio::test] + async fn a_silent_orphan_holding_the_lock_is_stopped() { + let setup = Setup::new().await; + let owner_socket = setup.owner_socket(); + let orphan_pid = orphan_serve(&setup, None, false); + let record = Endpoint { + pid: orphan_pid, + ..endpoint(&owner_socket, "orphan") + }; + write_endpoint(&setup.dir.endpoint(), &record).unwrap(); + let pond = setup.fake_pond(true, "exec sleep 30"); + let listener = UnixListener::bind(&setup.socket).unwrap(); + let herdr_stops = async { + setup.published().await; + drop(listener); + }; + let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); + owner.unwrap(); + assert!(!alive(orphan_pid), "the orphan still runs"); + assert!(setup.log().contains("holds its lock"), "{}", setup.log()); + } + + /// An answering record whose pid is not a serve on its socket is never + /// signalled, and no serve is spawned beside it. + #[tokio::test] + async fn an_answering_record_naming_another_process_is_left_alone() { + let setup = Setup::new().await; + let orphan = FakePond::with_sql( + vec![("SELECT 1", Reply::json(golden::SQL_READY))], + Reply::json(golden::SEARCH), + ) + .await; + let owner_socket = setup.owner_socket(); + fs::create_dir_all(owner_socket.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&orphan.socket, &owner_socket).unwrap(); + let mut sleeper = Command::new("sleep").arg("30").spawn().unwrap(); + let record = Endpoint { + pid: sleeper.id(), + ..endpoint(&owner_socket, "orphan") + }; + write_endpoint(&setup.dir.endpoint(), &record).unwrap(); + let pond = setup.fake_pond(true, "exec sleep 30"); + setup.own(&pond).await.unwrap(); + assert!(alive(sleeper.id()), "an unrelated process was signalled"); + assert_eq!(setup.serve_calls(), 0); + assert!( + setup.log().contains("cannot stop the orphan"), + "{}", + setup.log() + ); + sleeper.kill().unwrap(); + sleeper.wait().unwrap(); + } + + #[tokio::test] + async fn the_record_names_the_serve_before_it_answers() { + let setup = Setup::new().await; + let pond = setup.fake_pond(false, "exec sleep 30"); + let listener = UnixListener::bind(&setup.socket).unwrap(); + let herdr_stops = async { + wait_until("the record", || { + setup.endpoint().is_some() && !setup.sandbox.lines("pid").is_empty() + }) + .await; + assert_eq!(setup.endpoint().unwrap().pid, setup.sandbox.serve_pid()); + assert!(!setup.log().contains("owner: ready on")); + drop(listener); + }; + let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); + owner.unwrap(); + assert!(setup.endpoint().is_none(), "the record outlived its serve"); + } + + #[tokio::test] + async fn a_probe_failing_while_serve_settles_is_retried() { + let unready = Arc::new(AtomicUsize::new(2)); + let setup = Setup::with( + FakePond::start(move |_, body| { + let settling = body.contains("SELECT 1") + && unready + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)) + .is_ok(); + if settling { + Reply::plain(503, "starting") + } else { + Reply::json(golden::SQL_READY) + } + }) + .await, + ); + let pond = setup.fake_pond(true, "exec sleep 30"); + let listener = UnixListener::bind(&setup.socket).unwrap(); + let herdr_stops = async { + setup.published().await; + drop(listener); + }; + let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); + owner.unwrap(); + assert!(!setup.log().contains("did not answer"), "{}", setup.log()); + let probes = setup + .pond + .recorded() + .iter() + .filter(|request| request.body.contains("SELECT 1")) + .count(); + assert!( + probes >= 3, + "two refused probes, then a passing one: {probes}" + ); + } + + #[tokio::test] + async fn a_probe_that_never_passes_gives_up() { + let setup = Setup::with(FakePond::start(|_, _| Reply::plain(503, "starting")).await); + let pond = setup.fake_pond(true, "exec sleep 30"); + let _listener = UnixListener::bind(&setup.socket).unwrap(); + setup.own(&pond).await.unwrap(); + let log = setup.log(); + assert!( + log.contains("did not answer") && log.contains("HTTP 503: starting"), + "{log}" + ); + assert!(setup.pond.recorded().len() > 1, "never retried"); + assert!(!alive(setup.sandbox.serve_pid())); + assert!(setup.endpoint().is_none()); + } + + #[tokio::test] + async fn a_dying_serve_ends_the_owner_without_restart() { + let setup = Setup::new().await; + let pond = setup.fake_pond(true, "sleep 0.3; exit 1"); + let _listener = UnixListener::bind(&setup.socket).unwrap(); + setup.own(&pond).await.unwrap(); + assert!(setup.log().contains("pond serve exited"), "{}", setup.log()); + assert!(setup.endpoint().is_none()); + assert_eq!(setup.serve_calls(), 1); + } + + #[tokio::test] + async fn a_serve_that_never_listens_is_killed_at_the_deadline() { + let setup = Setup::new().await; + let pond = setup.fake_pond(false, "exec sleep 30"); + let _listener = UnixListener::bind(&setup.socket).unwrap(); + setup.own(&pond).await.unwrap(); + assert!(setup.log().contains("did not answer"), "{}", setup.log()); + assert!(!alive(setup.sandbox.serve_pid())); + assert!(setup.endpoint().is_none()); + } + + /// A socket left by a SIGKILLed serve exists but refuses: never ready. + #[tokio::test] + async fn a_stale_socket_is_not_ready() { + let setup = Setup::new().await; + let stale = setup.sandbox.path("stale.sock"); + stale_socket(&stale); + let pond = setup.sandbox.fake_serve(Some(&stale), "exec sleep 30"); + let _listener = UnixListener::bind(&setup.socket).unwrap(); + setup.own(&pond).await.unwrap(); + let log = setup.log(); + assert!( + log.contains("did not answer") && log.contains("Connection refused"), + "{log}" + ); + assert!(setup.endpoint().is_none()); + } + + #[tokio::test] + async fn a_pond_without_socket_is_named_too_old() { + let setup = Setup::new().await; + let pond = setup.fake_pond( + false, + "echo \"error: unexpected argument '--socket' found\" >&2; exit 2", + ); + let _listener = UnixListener::bind(&setup.socket).unwrap(); + setup.own(&pond).await.unwrap(); + assert!(setup.log().contains("too old"), "{}", setup.log()); + assert!(setup.log().contains("upgrade pond"), "{}", setup.log()); + } + + #[tokio::test] + async fn another_usage_error_names_the_log_not_an_upgrade() { + let setup = Setup::new().await; + let pond = setup.fake_pond( + false, + "echo \"error: invalid value 'x' for '--storage-path '\" >&2; exit 2", + ); + let _listener = UnixListener::bind(&setup.socket).unwrap(); + setup.own(&pond).await.unwrap(); + let log = setup.log(); + assert!(!log.contains("too old"), "{log}"); + assert!( + log.contains("exited (exit status: 2) before listening - see") + && log.contains("daemon.log"), + "{log}" + ); + } + + #[test] + fn start_spawns_an_owner_unless_the_lock_is_held() { + let sandbox = Sandbox::new(); + let dir = sandbox.origin().dir; + let spawned = std::cell::Cell::new(0); + let spawn = || { + spawned.set(spawned.get() + 1); + Ok(()) + }; + start(&dir, spawn).unwrap(); + assert_eq!(spawned.get(), 1); + + let held = try_lock(&dir.lock()).unwrap().unwrap(); + start(&dir, spawn).unwrap(); + assert_eq!(spawned.get(), 1, "an owner holds the lock"); + drop(held); + } + + #[test] + fn tokens_differ() { + assert_ne!(random_token(), random_token()); + assert_eq!(random_token().len(), 32); + } +} diff --git a/packages/herdr-pond/src/desk/app.rs b/packages/herdr-pond/src/desk/app.rs new file mode 100644 index 00000000..db8c5879 --- /dev/null +++ b/packages/herdr-pond/src/desk/app.rs @@ -0,0 +1,2383 @@ +//! Desk state and reducers. `on_event` and `apply` stay sync and pure: they +//! return [`Effect`]s, and the runtime in `mod.rs` performs them and feeds the +//! results back as [`Msg`]s. + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::layout::{Rect, Size}; +use ratatui::text::Line; +use ratatui::widgets::ListState; +use unicode_width::UnicodeWidthStr; + +use super::cache::{Known, SavedListing, Snapshot}; +use super::ui; +use crate::types::{ + ApiError, Cursor, DeskContext, DeskExit, LISTING_ROWS, ListingScope, LiveAgent, PAGE_ROWS, + SearchRequest, SearchResponse, SessionHost, SessionRow, SessionStart, SessionStats, + SessionTitle, TranscriptMessage, TranscriptPage, +}; + +pub(super) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(150); +pub(super) const PREVIEW_DEBOUNCE: Duration = Duration::from_millis(80); +const SEARCH_LIMIT: usize = 50; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(super) enum Lane { + Listing, + Titles, + Stats, + Hosts, + Live, + Search, + Preview, + Page, +} + +impl Lane { + pub(super) const COUNT: usize = Self::Page as usize + 1; + const VIEW: [Self; 3] = [Self::Search, Self::Preview, Self::Page]; + + /// View lanes answer for what is on screen, so a view transition makes + /// their in-flight results stale; data lanes fill caches that outlive views. + fn is_view(self) -> bool { + Self::VIEW.contains(&self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) enum Call { + Listing(ListingScope), + Titles(Vec), + Stats(Vec), + Hosts(Vec), + Live, + Search(SearchRequest), + Preview(String), + Page { + session_id: String, + after: Option, + }, +} + +impl Call { + pub(super) fn lane(&self) -> Lane { + match self { + Self::Listing(_) => Lane::Listing, + Self::Titles(_) => Lane::Titles, + Self::Stats(_) => Lane::Stats, + Self::Hosts(_) => Lane::Hosts, + Self::Live => Lane::Live, + Self::Search(_) => Lane::Search, + Self::Preview(_) => Lane::Preview, + Self::Page { .. } => Lane::Page, + } + } + + /// A listing's `since` moves with the clock, so listings match by scope. + fn same_target(&self, other: &Self) -> bool { + match (self, other) { + (Self::Listing(a), Self::Listing(b)) => scope_key(a) == scope_key(b), + _ => self == other, + } + } + + /// The sessions a hydration call asks about; none for other calls. + fn ids(&self) -> Vec<&str> { + match self { + Self::Titles(ids) | Self::Stats(ids) => ids.iter().map(String::as_str).collect(), + Self::Hosts(starts) => starts + .iter() + .map(|start| start.session_id.as_str()) + .collect(), + _ => Vec::new(), + } + } +} + +#[derive(Debug)] +pub(super) enum Reply { + Listing(Result, ApiError>), + Titles(Result, ApiError>), + Stats(Result, ApiError>), + Hosts(Result, ApiError>), + Live(Result, ApiError>), + Search(Result), + Preview(Result, ApiError>), + Page(Result), +} + +/// A finished request. `call` is the target identity: `apply` checks it +/// against the current view, on top of the lane generation and view epoch. +#[derive(Debug)] +pub(super) struct Msg { + pub(super) generation: u64, + pub(super) epoch: u64, + pub(super) call: Call, + pub(super) reply: Reply, +} + +#[derive(Debug, PartialEq)] +pub(super) enum Effect { + /// Replaces (aborts) whatever the call's lane has in flight. + Fetch { + generation: u64, + epoch: u64, + delay: Duration, + call: Call, + }, + Cancel(Lane), + /// A line for `desk.log`: a background failure not worth a toast. + Log(String), + Exit(DeskExit), +} + +#[derive(Debug, Default)] +struct LaneState { + generation: u64, + in_flight: Option, + /// Hydration ids asked since the last listing landed, so an id the + /// server has no row for is not asked again until the next refresh. + asked: HashSet, +} + +/// What `p` and `t` narrow a view to: the desk's project, and the listing +/// window. +#[derive(Debug, Clone, Copy)] +pub(super) struct Narrowing { + pub(super) project: bool, + pub(super) recent: bool, +} + +impl Narrowing { + fn toggle(&mut self, projects: bool) { + if projects { + self.project = !self.project; + } else { + self.recent = !self.recent; + } + } +} + +#[derive(Debug, Default)] +pub(super) struct Input { + pub(super) text: String, + cursor: usize, +} + +impl Input { + /// In terminal cells: CJK and emoji are two wide. + pub(super) fn cursor_column(&self) -> usize { + self.text[..self.cursor].width() + } + + fn previous_boundary(&self) -> Option { + self.text[..self.cursor] + .char_indices() + .next_back() + .map(|(index, _)| index) + } + + fn insert(&mut self, c: char) { + self.text.insert(self.cursor, c); + self.cursor += c.len_utf8(); + } + + fn backspace(&mut self) { + if let Some(index) = self.previous_boundary() { + self.text.remove(index); + self.cursor = index; + } + } + + fn delete(&mut self) { + if self.cursor < self.text.len() { + self.text.remove(self.cursor); + } + } + + fn left(&mut self) { + self.cursor = self.previous_boundary().unwrap_or(0); + } + + fn right(&mut self) { + if let Some(c) = self.text[self.cursor..].chars().next() { + self.cursor += c.len_utf8(); + } + } +} + +#[derive(Debug)] +pub(super) struct Search { + pub(super) query: String, + pub(super) response: Option, +} + +/// The transcript, wrapped once per load or resize so a frame only slices it. +#[derive(Debug)] +pub(super) struct Pager { + pub(super) session_id: String, + messages: Vec, + starts: Vec, + pub(super) lines: Vec>, + pub(super) offset: usize, + pub(super) eof: bool, + width: usize, +} + +impl Pager { + fn new(session_id: String, width: usize) -> Self { + Self { + session_id, + messages: Vec::new(), + starts: Vec::new(), + lines: Vec::new(), + offset: 0, + eof: false, + width, + } + } + + pub(super) fn is_empty(&self) -> bool { + self.messages.is_empty() + } + + fn next_cursor(&self) -> Option { + self.messages.last().map(Cursor::after) + } + + fn append(&mut self, messages: Vec) { + for message in messages { + self.starts.push(self.lines.len()); + self.lines.extend(ui::message_lines(&message, self.width)); + self.messages.push(message); + } + } + + /// Keeps the message at the top of the viewport at the top. + fn rewrap(&mut self, width: usize) { + if width == self.width { + return; + } + let anchor = self + .starts + .partition_point(|start| *start <= self.offset) + .saturating_sub(1); + self.width = width; + self.lines.clear(); + self.starts.clear(); + let messages = std::mem::take(&mut self.messages); + self.append(messages); + self.offset = self.starts.get(anchor).copied().unwrap_or(0); + } + + fn scroll(&mut self, delta: isize, height: usize) { + let max = self.lines.len().saturating_sub(height); + self.offset = self.offset.saturating_add_signed(delta).min(max); + } +} + +pub(super) struct App { + pub(super) now: DateTime, + pub(super) context: DeskContext, + pub(super) size: Size, + epoch: u64, + lanes: [LaneState; Lane::COUNT], + /// The listing opens narrowed to the project and the listing window; + /// typed search opens on everything. + pub(super) listing_filter: Narrowing, + pub(super) search_filter: Narrowing, + /// One per scope, this desk's and other projects' alike, so saving the + /// cache keeps what other desks stored. + listings: Vec, + pub(super) known: HashMap, + pub(super) live: Vec, + pub(super) listing_state: ListState, + pub(super) search_state: ListState, + pub(super) input: Input, + pub(super) typing: bool, + pub(super) search: Option, + pub(super) preview_open: bool, + pub(super) previews: HashMap>, + pub(super) pager: Option, + pub(super) toast: Option, + pub(super) fatal: Option, + pub(super) spinner: usize, + pub(super) dirty: bool, + /// Set by a resize, so a burst of them re-wraps the pager once, before + /// the next frame. + resized: bool, +} + +impl App { + pub(super) fn new(context: DeskContext, now: DateTime, size: Size) -> Self { + Self { + now, + context, + size, + epoch: 0, + lanes: Default::default(), + listing_filter: Narrowing { + project: true, + recent: true, + }, + search_filter: Narrowing { + project: false, + recent: false, + }, + listings: Vec::new(), + known: HashMap::new(), + live: Vec::new(), + listing_state: ListState::default(), + search_state: ListState::default(), + input: Input::default(), + typing: false, + search: None, + preview_open: false, + previews: HashMap::new(), + pager: None, + toast: None, + fatal: None, + spinner: 0, + dirty: true, + resized: false, + } + } + + /// Paints the cached listing and facts at once; `start` then refreshes + /// behind them. + pub(super) fn restore(&mut self, snapshot: Snapshot) { + self.known = snapshot.sessions; + self.listings = snapshot.listings; + self.restore_listing_selection(None); + } + + pub(super) fn snapshot(&self) -> Snapshot { + Snapshot::new(self.known.clone(), self.listings.clone()) + } + + pub(super) fn start(&mut self) -> Vec { + let mut effects = self.refresh(); + effects.extend(self.hydrate_visible()); + effects + } + + pub(super) fn lane_loading(&self, lane: Lane) -> bool { + self.lanes[lane as usize].in_flight.is_some() + } + + /// The pager shows only its own page load; the error screen, none. + pub(super) fn spinner_visible(&self) -> bool { + if self.fatal.is_some() { + false + } else if self.pager.is_some() { + self.lane_loading(Lane::Page) + } else { + self.lanes.iter().any(|lane| lane.in_flight.is_some()) + } + } + + /// Called only while [`Self::spinner_visible`]. + pub(super) fn tick(&mut self) { + self.spinner = self.spinner.wrapping_add(1); + self.dirty = true; + } + + fn scope_for(&self, filter: Narrowing) -> ListingScope { + let project = filter + .project + .then(|| self.context.project.clone()) + .flatten(); + let mut scope = ListingScope::recent(project, self.now); + if !filter.recent { + scope.since = None; + } + scope + } + + pub(super) fn scope(&self) -> ListingScope { + self.scope_for(self.listing_filter) + } + + pub(super) fn search_scope(&self) -> ListingScope { + self.scope_for(self.search_filter) + } + + /// [`Self::scope`]'s key, without building the scope. + fn listing_key(&self) -> ScopeKey<'_> { + let filter = self.listing_filter; + let project = filter + .project + .then_some(self.context.project.as_deref()) + .flatten(); + (project, !filter.recent) + } + + fn saved_listing(&self) -> Option<&SavedListing> { + let key = self.listing_key(); + self.listings + .iter() + .find(|listing| saved_key(listing) == key) + } + + pub(super) fn listing(&self) -> Option<&[SessionRow]> { + self.saved_listing().map(|listing| listing.rows.as_slice()) + } + + pub(super) fn rows_len(&self) -> usize { + match &self.search { + Some(search) => search.response.as_ref().map_or(0, |r| r.sessions.len()), + None => self.listing().map_or(0, <[SessionRow]>::len), + } + } + + pub(super) fn id_at(&self, index: usize) -> Option<&str> { + match &self.search { + Some(search) => search + .response + .as_ref() + .and_then(|r| r.sessions.get(index)) + .map(|s| s.session_id.as_str()), + None => self + .listing() + .and_then(|rows| rows.get(index)) + .map(|row| row.session_id.as_str()), + } + } + + fn state(&self) -> &ListState { + if self.search.is_some() { + &self.search_state + } else { + &self.listing_state + } + } + + pub(super) fn state_mut(&mut self) -> &mut ListState { + if self.search.is_some() { + &mut self.search_state + } else { + &mut self.listing_state + } + } + + /// `ListState` only clamps at render time (`select_last` is `usize::MAX`), + /// so every index use goes through here. + pub(super) fn selected_index(&self) -> Option { + let len = self.rows_len(); + self.state() + .selected() + .filter(|_| len > 0) + .map(|index| index.min(len - 1)) + } + + pub(super) fn selected_id(&self) -> Option<&str> { + self.selected_index().and_then(|index| self.id_at(index)) + } + + fn selected_listing_id(&self) -> Option { + let rows = self.listing()?; + let index = self + .listing_state + .selected()? + .min(rows.len().checked_sub(1)?); + Some(rows[index].session_id.clone()) + } + + pub(super) fn live_agent(&self, session_id: &str) -> Option<&LiveAgent> { + self.live.iter().find(|agent| agent.matches(session_id)) + } + + fn area(&self) -> Rect { + Rect::new(0, 0, self.size.width, self.size.height) + } + + fn pager_viewport(&self) -> Rect { + ui::pager_areas(self.area()).text + } + + /// Single-flight: a call for what its lane is already fetching joins + /// that request instead of restarting it. + fn fetch(&mut self, call: Call, delay: Duration) -> Option { + let lane = &mut self.lanes[call.lane() as usize]; + if lane + .in_flight + .as_ref() + .is_some_and(|pending| pending.same_target(&call)) + { + return None; + } + lane.generation += 1; + lane.in_flight = Some(call.clone()); + Some(Effect::Fetch { + generation: lane.generation, + epoch: self.epoch, + delay, + call, + }) + } + + fn cancel(&mut self, lane: Lane) -> Effect { + let state = &mut self.lanes[lane as usize]; + state.generation += 1; + state.in_flight = None; + Effect::Cancel(lane) + } + + /// A view or filter change: results already in flight for the old view + /// must not land in the new one. + fn transition(&mut self) -> Vec { + self.epoch += 1; + Lane::VIEW + .into_iter() + .filter(|lane| self.lane_loading(*lane)) + .collect::>() + .into_iter() + .map(|lane| self.cancel(lane)) + .collect() + } + + fn refresh(&mut self) -> Vec { + self.previews.clear(); + let mut effects: Vec = self + .fetch(Call::Listing(self.scope()), Duration::ZERO) + .into_iter() + .chain(self.fetch(Call::Live, Duration::ZERO)) + .collect(); + if let Some(query) = self.search.as_ref().map(|s| s.query.clone()) { + effects.extend(self.fetch_search(query, Duration::ZERO)); + } + effects.extend(self.preview_selected(Duration::ZERO)); + effects + } + + fn fetch_search(&mut self, query: String, delay: Duration) -> Option { + let request = SearchRequest::new(query, SEARCH_LIMIT).within(&self.search_scope()); + self.fetch(Call::Search(request), delay) + } + + pub(super) fn on_event(&mut self, event: &Event) -> Vec { + match event { + Event::Resize(width, height) => self.resize(*width, *height), + Event::Key(key) if key.kind != KeyEventKind::Release => self.on_key(*key), + _ => Vec::new(), + } + } + + fn resize(&mut self, width: u16, height: u16) -> Vec { + self.size = Size::new(width, height); + self.dirty = true; + self.resized = true; + self.hydrate_visible() + } + + /// Re-wraps the pager for the latest size, then fetches more if the new + /// wrap left the viewport near the end. Runs before every frame. + pub(super) fn relayout(&mut self) -> Vec { + if !std::mem::take(&mut self.resized) { + return Vec::new(); + } + let viewport = self.pager_viewport(); + if let Some(pager) = &mut self.pager { + pager.rewrap(usize::from(viewport.width)); + pager.scroll(0, usize::from(viewport.height)); + } + self.load_more() + } + + fn on_key(&mut self, key: KeyEvent) -> Vec { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl && key.code == KeyCode::Char('c') { + return vec![Effect::Exit(DeskExit::Quit)]; + } + self.dirty = true; + if self.toast.take().is_some() && key.code == KeyCode::Esc { + return Vec::new(); + } + if self.fatal.is_some() { + return match key.code { + KeyCode::Char('r') => { + self.fatal = None; + self.refresh() + } + KeyCode::Char('q') | KeyCode::Esc => vec![Effect::Exit(DeskExit::Quit)], + _ => Vec::new(), + }; + } + if self.pager.is_some() { + return self.on_pager_key(key); + } + if self.typing { + return self.on_input_key(key); + } + self.on_list_key(key) + } + + fn page_height(&self) -> isize { + let height = ui::desk_areas(self.area(), self.preview_open).list.height; + isize::try_from(height.saturating_sub(1)) + .unwrap_or(1) + .max(1) + } + + fn on_list_key(&mut self, key: KeyEvent) -> Vec { + match key.code { + KeyCode::Char('q') => vec![Effect::Exit(DeskExit::Quit)], + KeyCode::Esc if self.search.is_some() => self.leave_search(), + KeyCode::Esc => vec![Effect::Exit(DeskExit::Quit)], + KeyCode::Char('/') => { + self.typing = true; + Vec::new() + } + KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), + KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), + KeyCode::PageDown => self.move_selection(self.page_height()), + KeyCode::PageUp => self.move_selection(-self.page_height()), + KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN), + KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX), + KeyCode::Enter => self.open(), + KeyCode::Char(' ') => { + self.preview_open = !self.preview_open; + let mut effects: Vec = + self.preview_selected(Duration::ZERO).into_iter().collect(); + effects.extend(self.hydrate_visible()); + effects + } + KeyCode::Char('p') => self.toggle_scope(true), + KeyCode::Char('t') => self.toggle_scope(false), + KeyCode::Char('r') => self.refresh(), + _ => Vec::new(), + } + } + + fn on_input_key(&mut self, key: KeyEvent) -> Vec { + let plain = !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT); + match key.code { + KeyCode::Esc if self.input.text.is_empty() => { + self.typing = false; + Vec::new() + } + KeyCode::Esc => self.leave_search(), + KeyCode::Enter => { + self.typing = false; + Vec::new() + } + KeyCode::Down => self.move_selection(1), + KeyCode::Up => self.move_selection(-1), + KeyCode::Left => { + self.input.left(); + Vec::new() + } + KeyCode::Right => { + self.input.right(); + Vec::new() + } + KeyCode::Home => { + self.input.cursor = 0; + Vec::new() + } + KeyCode::End => { + self.input.cursor = self.input.text.len(); + Vec::new() + } + KeyCode::Backspace => { + self.input.backspace(); + self.query_changed() + } + KeyCode::Delete => { + self.input.delete(); + self.query_changed() + } + KeyCode::Char(c) if plain => { + self.input.insert(c); + self.query_changed() + } + _ => Vec::new(), + } + } + + fn on_pager_key(&mut self, key: KeyEvent) -> Vec { + let height = usize::from(self.pager_viewport().height); + let page = isize::try_from(height.max(2) - 1).unwrap_or(1); + let delta = match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Backspace | KeyCode::Left => { + return self.close_pager(); + } + KeyCode::Down | KeyCode::Char('j') => 1, + KeyCode::Up | KeyCode::Char('k') => -1, + KeyCode::PageDown | KeyCode::Char(' ') => page, + KeyCode::PageUp | KeyCode::Char('b') => -page, + KeyCode::Home | KeyCode::Char('g') => isize::MIN, + KeyCode::End | KeyCode::Char('G') => isize::MAX, + _ => return Vec::new(), + }; + if let Some(pager) = &mut self.pager { + pager.scroll(delta, height); + } + self.load_more() + } + + fn move_selection(&mut self, delta: isize) -> Vec { + let len = self.rows_len(); + if len == 0 { + return Vec::new(); + } + let current = self.selected_index().unwrap_or(0); + let next = current.saturating_add_signed(delta).min(len - 1); + self.state_mut().select(Some(next)); + self.selection_changed() + } + + fn selection_changed(&mut self) -> Vec { + let mut effects = self.hydrate_visible(); + effects.extend(self.preview_selected(PREVIEW_DEBOUNCE)); + effects + } + + /// Hydrates the rows around the selection - never the whole listing - + /// with one request per lane, concurrently, each asking only for what is + /// not known yet. The window snaps to page-sized blocks so a held key + /// asks nothing new within one, titles are asked only where they show + /// (the listing and the pager header), and a host waits for the stats + /// that find the session's first message when the listing cannot. + fn hydrate_visible(&mut self) -> Vec { + let len = self.rows_len(); + let height = usize::from(ui::desk_areas(self.area(), self.preview_open).list.height).max(1); + let base = self.selected_index().unwrap_or(0) / height * height; + let window: Vec = (base.saturating_sub(height)..(base + 2 * height).min(len)) + .filter_map(|index| self.id_at(index)) + .map(str::to_owned) + .collect(); + let listing = self.search.is_none(); + let mut titled = if listing { window.clone() } else { Vec::new() }; + if let Some(pager) = &self.pager + && !titled.contains(&pager.session_id) + { + titled.push(pager.session_id.clone()); + } + let titles = self.unknown(Lane::Titles, &titled, |known| known.title().is_none()); + let stats = self.unknown(Lane::Stats, &window, |known| { + (listing && known.count().is_none()) + || (known.host().is_none() && known.first_ts().is_none()) + }); + let hosts: Vec = self + .unknown(Lane::Hosts, &window, |known| { + known.host().is_none() && known.first_ts().is_some() + }) + .into_iter() + .filter_map(|id| { + let first_ts = self.known.get(&id)?.first_ts()?; + Some(SessionStart { + session_id: id, + first_ts, + }) + }) + .collect(); + [ + self.request(Call::Titles(titles)), + self.request(Call::Stats(stats)), + self.request(Call::Hosts(hosts)), + ] + .into_iter() + .flatten() + .collect() + } + + /// The ids `lane` has not been asked about that still `need` it; none + /// while the lane is busy, since a new fetch would abort its request. + fn unknown(&self, lane: Lane, ids: &[String], need: impl Fn(&Known) -> bool) -> Vec { + if self.lane_loading(lane) { + return Vec::new(); + } + let asked = &self.lanes[lane as usize].asked; + ids.iter() + .filter(|id| !asked.contains(*id)) + .filter(|id| self.known.get(*id).is_none_or(&need)) + .cloned() + .collect() + } + + fn request(&mut self, call: Call) -> Option { + let ids: Vec = call.ids().into_iter().map(str::to_owned).collect(); + if ids.is_empty() { + return None; + } + self.lanes[call.lane() as usize].asked.extend(ids); + self.fetch(call, Duration::ZERO) + } + + fn forget_asked(&mut self) { + for lane in &mut self.lanes { + lane.asked.clear(); + } + } + + fn preview_selected(&mut self, delay: Duration) -> Option { + let wanted = self + .selected_id() + .filter(|id| self.preview_open && !self.previews.contains_key(*id)) + .map(str::to_owned); + match wanted { + Some(id) => self.fetch(Call::Preview(id), delay), + None => self + .lane_loading(Lane::Preview) + .then(|| self.cancel(Lane::Preview)), + } + } + + fn query_changed(&mut self) -> Vec { + let query = self.input.text.trim().to_owned(); + if query.is_empty() { + return if self.search.is_some() { + self.leave_search() + } else { + Vec::new() + }; + } + let mut effects = Vec::new(); + match &mut self.search { + Some(search) if search.query == query => return effects, + Some(search) => search.query.clone_from(&query), + None => { + effects = self.transition(); + self.search = Some(Search { + query: query.clone(), + response: None, + }); + self.search_state = ListState::default(); + } + } + effects.extend(self.fetch_search(query, SEARCH_DEBOUNCE)); + effects + } + + fn leave_search(&mut self) -> Vec { + self.input = Input::default(); + self.search = None; + let mut effects = self.transition(); + effects.extend(self.selection_changed()); + effects + } + + /// `p` means "this project only / everything" and `t` "the listing + /// window / all time", for whichever view is up: the listing and typed + /// search keep their own filters. A listing already fetched this run + /// shows at once; one restored from the file shows at once too, but may + /// be days old, so it is refetched behind. + fn toggle_scope(&mut self, projects: bool) -> Vec { + if projects && self.context.project.is_none() { + self.toast = Some( + "the desk was opened without a project: already covering all projects".to_owned(), + ); + return Vec::new(); + } + if let Some(search) = &mut self.search { + search.response = None; + let query = search.query.clone(); + self.search_filter.toggle(projects); + let mut effects = self.transition(); + effects.extend(self.fetch_search(query, Duration::ZERO)); + return effects; + } + let selected = self.selected_listing_id(); + self.listing_filter.toggle(projects); + let mut effects = self.transition(); + let fresh = self.saved_listing().map(|listing| listing.fresh); + if fresh.is_some() { + self.restore_listing_selection(selected.as_deref()); + } + if fresh != Some(true) { + effects.extend(self.fetch(Call::Listing(self.scope()), Duration::ZERO)); + } + effects.extend(self.selection_changed()); + effects + } + + fn restore_listing_selection(&mut self, selected: Option<&str>) { + let rows = self.listing().unwrap_or_default(); + let index = if rows.is_empty() { + None + } else { + let by_id = selected.and_then(|id| rows.iter().position(|row| row.session_id == id)); + let fallback = self + .listing_state + .selected() + .unwrap_or(0) + .min(rows.len() - 1); + Some(by_id.unwrap_or(fallback)) + }; + self.listing_state.select(index); + } + + fn open(&mut self) -> Vec { + let Some(id) = self.selected_id().map(str::to_owned) else { + return Vec::new(); + }; + if let Some(agent) = self.live_agent(&id) { + return vec![Effect::Exit(DeskExit::Jump { + pane_id: agent.pane_id.clone(), + })]; + } + let width = usize::from(self.pager_viewport().width); + self.pager = Some(Pager::new(id, width)); + let mut effects = self.hydrate_visible(); + effects.extend(self.load_more()); + effects + } + + fn close_pager(&mut self) -> Vec { + self.pager = None; + let mut effects = self.transition(); + let stale_search = self + .search + .as_ref() + .filter(|search| search.response.is_none()) + .map(|search| search.query.clone()); + if let Some(query) = stale_search { + effects.extend(self.fetch_search(query, Duration::ZERO)); + } + effects.extend(self.selection_changed()); + effects + } + + /// Single-flight and lazy: the next page is fetched only when the viewport + /// is within a screen of the end of what is loaded. + fn load_more(&mut self) -> Vec { + let height = usize::from(self.pager_viewport().height).max(1); + let Some(pager) = &self.pager else { + return Vec::new(); + }; + if pager.eof + || self.lane_loading(Lane::Page) + || pager.offset + 2 * height < pager.lines.len() + { + return Vec::new(); + } + let call = Call::Page { + session_id: pager.session_id.clone(), + after: pager.next_cursor(), + }; + self.fetch(call, Duration::ZERO).into_iter().collect() + } + + pub(super) fn apply(&mut self, msg: Msg) -> Vec { + let lane = msg.call.lane(); + if msg.generation != self.lanes[lane as usize].generation + || (lane.is_view() && msg.epoch != self.epoch) + { + return Vec::new(); + } + self.lanes[lane as usize].in_flight = None; + self.dirty = true; + match (msg.call, msg.reply) { + (Call::Listing(scope), Reply::Listing(result)) => self.on_listing(&scope, result), + (call @ (Call::Titles(_) | Call::Stats(_) | Call::Hosts(_)), reply) => { + self.on_hydration(&call, reply) + } + (Call::Live, Reply::Live(result)) => match result { + Ok(agents) => { + self.live = agents; + Vec::new() + } + Err(error) => self.toast(&error), + }, + (Call::Search(request), Reply::Search(result)) => { + self.on_search(&request.query, result) + } + (Call::Preview(id), Reply::Preview(result)) => { + if self.selected_id() != Some(id.as_str()) { + return Vec::new(); + } + match result { + Ok(messages) => { + if self.previews.len() >= LISTING_ROWS { + self.previews.clear(); + } + let clean = messages + .into_iter() + .map(|message| TranscriptMessage { + text: ui::preview_text(&message.text), + ..message + }) + .collect(); + self.previews.insert(id, clean); + Vec::new() + } + Err(error) => self.toast(&error), + } + } + (Call::Page { session_id, after }, Reply::Page(result)) => { + self.on_page(&session_id, after.as_ref(), result) + } + _ => Vec::new(), + } + } + + /// The toast is clipped and capped when drawn, so `desk.log` keeps the + /// whole error. + fn toast(&mut self, error: &ApiError) -> Vec { + self.toast = Some(ui::error_text(error)); + vec![Effect::Log(format!("desk: {error}"))] + } + + fn on_listing( + &mut self, + scope: &ListingScope, + result: Result, ApiError>, + ) -> Vec { + match result { + Ok(rows) => { + for row in &rows { + let known = self.known.entry(row.session_id.clone()).or_default(); + known.observe(row, scope.since); + } + let key = scope_key(scope); + let current = key == self.listing_key(); + let selected = self.selected_listing_id(); + self.listings.retain(|listing| saved_key(listing) != key); + self.listings.push(SavedListing { + project: scope.project.clone(), + all_time: scope.since.is_none(), + saved_at: self.now, + rows, + fresh: true, + }); + if !current { + return Vec::new(); + } + self.fatal = None; + self.forget_asked(); + self.restore_listing_selection(selected.as_deref()); + self.hydrate_visible() + } + Err(error) => { + let nothing_shown = + scope_key(scope) == self.listing_key() && self.listing().is_none(); + if nothing_shown && matches!(error, ApiError::PondTooOld | ApiError::Unreachable(_)) + { + self.fatal = Some(error.to_string()); + Vec::new() + } else { + self.toast(&error) + } + } + } + } + + /// Learns what a hydration reply proves. A failed one is only logged - + /// the user asked for none of it - and un-asks its ids, so their rows are + /// asked again instead of waiting for the next listing. + fn on_hydration(&mut self, call: &Call, reply: Reply) -> Vec { + let learned = match reply { + Reply::Titles(result) => result.map(|rows| { + let mut titles: HashMap> = rows + .into_iter() + .map(|row| (row.session_id, row.title)) + .collect(); + for id in call.ids() { + let title = titles.remove(id).flatten(); + self.known + .entry(id.to_owned()) + .or_default() + .set_title(title); + } + }), + Reply::Stats(result) => result.map(|rows| { + for row in rows { + let known = self.known.entry(row.session_id.clone()).or_default(); + known.set_stats(&row); + } + }), + Reply::Hosts(result) => result.map(|rows| { + for row in rows { + self.known + .entry(row.session_id) + .or_default() + .set_host(row.host); + } + }), + _ => return Vec::new(), + }; + match learned { + Ok(()) => self.hydrate_visible(), + Err(error) => { + let ids = call.ids(); + let asked = &mut self.lanes[call.lane() as usize].asked; + for id in &ids { + asked.remove(*id); + } + vec![Effect::Log(format!( + "desk: {:?} for {} sessions failed: {error}", + call.lane(), + ids.len() + ))] + } + } + } + + fn on_search(&mut self, query: &str, result: Result) -> Vec { + let Some(search) = self.search.as_mut().filter(|search| search.query == query) else { + return Vec::new(); + }; + match result { + Ok(response) => { + let first = (!response.sessions.is_empty()).then_some(0); + search.response = Some(response); + self.search_state.select(first); + self.selection_changed() + } + Err(error) => self.toast(&error), + } + } + + fn on_page( + &mut self, + session_id: &str, + after: Option<&Cursor>, + result: Result, + ) -> Vec { + let Some(pager) = self.pager.as_mut().filter(|pager| { + pager.session_id == session_id && pager.next_cursor().as_ref() == after + }) else { + return Vec::new(); + }; + // A page answering means the serve does, so the header's title is + // asked again if the request `open` could not make is still missing. + match result { + Ok(page) if page.messages.is_empty() => { + pager.eof = true; + if page.truncated { + self.toast = Some( + "the next message exceeds pond's response size budget - the transcript stops here" + .to_owned(), + ); + } + self.hydrate_visible() + } + Ok(page) => { + pager.eof = page.messages.len() < PAGE_ROWS && !page.truncated; + pager.append(page.messages); + let mut effects = self.load_more(); + effects.extend(self.hydrate_visible()); + effects + } + Err(error) => self.toast(&error), + } + } +} + +/// Listings are cached per project and window kind (all time or not), not +/// per timestamp, so toggling back is instant even though `since` moves +/// with the clock. +type ScopeKey<'a> = (Option<&'a str>, bool); + +fn scope_key(scope: &ListingScope) -> ScopeKey<'_> { + (scope.project.as_deref(), scope.since.is_none()) +} + +fn saved_key(listing: &SavedListing) -> ScopeKey<'_> { + (listing.project.as_deref(), listing.all_time) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use crossterm::event::KeyCode; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + use ratatui::style::Style; + + use chrono::TimeDelta; + + use super::*; + use crate::desk::tests::{ + MockApi, app, context, key, message, now, press, screen, settle, sql_rows, + }; + use crate::fake_pond::golden; + use crate::types::{LISTING_ROWS, ProjectFilter}; + + fn opened(api: &MockApi, width: u16, height: u16) -> App { + let mut app = app(width, height); + let effects = app.start(); + assert_eq!(settle(&mut app, api, effects), None); + app + } + + fn row(id: &str) -> SessionRow { + SessionRow { + session_id: id.to_owned(), + last_ts: now() - TimeDelta::hours(1), + first_ts: now() - TimeDelta::hours(3), + message_count: 7, + source_agent: "codex-cli".to_owned(), + project: "/home/me/pj/pond".to_owned(), + } + } + + fn hydrations(api: &MockApi) -> Vec { + api.calls() + .into_iter() + .filter(|call| matches!(call.lane(), Lane::Titles | Lane::Stats | Lane::Hosts)) + .collect() + } + + fn ids(ids: &[&str]) -> Vec { + ids.iter().map(|id| (*id).to_owned()).collect() + } + + fn search_response(body: &str) -> SearchResponse { + serde_json::from_str(body).unwrap() + } + + fn fetches(effects: &[Effect]) -> Vec<&Call> { + effects + .iter() + .filter_map(|effect| match effect { + Effect::Fetch { call, .. } => Some(call), + _ => None, + }) + .collect() + } + + fn type_query(app: &mut App, api: &MockApi, text: &str) { + for code in std::iter::once('/').chain(text.chars()).map(KeyCode::Char) { + press(app, api, code); + } + } + + #[test] + fn opening_lists_then_hydrates_the_visible_page() { + let api = MockApi::golden(); + let mut app = opened(&api, 110, 10); + let calls = api.calls(); + assert!(matches!(&calls[0], Call::Listing(scope) if scope.limit == LISTING_ROWS)); + assert_eq!(calls[1], Call::Live); + let page = ids(&["s-live", "s-old"]); + assert_eq!( + hydrations(&api), + [ + Call::Titles(page.clone()), + Call::Stats(page), + Call::Hosts( + sql_rows::(golden::SQL_STATS) + .into_iter() + .map(|stats| SessionStart { + session_id: stats.session_id, + first_ts: stats.first_ts, + }) + .collect() + ), + ], + "one request per lane for the page; hosts wait for the starts" + ); + let screen = screen(&mut app); + assert!(screen.contains("msgs = whole-session counts"), "{screen}"); + assert!(screen.contains("● ws-pond-01 claude-code"), "{screen}"); + assert!(screen.contains("fix the timer re-arm"), "{screen}"); + assert!(screen.contains("local?"), "unstamped host: {screen}"); + assert!(screen.contains("(no user message)"), "{screen}"); + assert!(screen.contains(" 94 "), "{screen}"); + } + + #[test] + fn scrolling_hydrates_only_rows_near_the_selection() { + let api = MockApi { + sessions: (0..60).map(|i| row(&format!("s{i:02}"))).collect(), + ..MockApi::default() + }; + let mut app = opened(&api, 100, 10); + let first = api.calls().into_iter().find_map(|call| match call { + Call::Titles(ids) => Some(ids), + _ => None, + }); + assert!(first.unwrap().len() < 20, "never the whole listing"); + press(&mut app, &api, KeyCode::End); + let Some(Call::Titles(ids)) = api + .calls() + .into_iter() + .rev() + .find(|call| matches!(call, Call::Titles(_))) + else { + panic!("jumping to the end hydrates the new page"); + }; + assert!(ids.contains(&"s59".to_owned())); + assert!(!ids.contains(&"s00".to_owned())); + } + + #[test] + fn moving_within_a_page_block_asks_nothing_new() { + let api = MockApi { + sessions: (0..60).map(|i| row(&format!("s{i:02}"))).collect(), + ..MockApi::default() + }; + let mut app = opened(&api, 100, 10); + let height = usize::from(ui::desk_areas(app.area(), false).list.height); + let opening = hydrations(&api).len(); + for _ in 1..height { + press(&mut app, &api, KeyCode::Down); + } + assert_eq!(hydrations(&api).len(), opening, "held j inside one block"); + press(&mut app, &api, KeyCode::Down); + assert!(hydrations(&api).len() > opening, "the next block is asked"); + } + + #[test] + fn a_failed_hydration_is_asked_again() { + let mut app = opened(&MockApi::golden(), 110, 10); + app.known.clear(); + app.forget_asked(); + let Some(Effect::Fetch { + generation, + epoch, + call, + .. + }) = app.hydrate_visible().into_iter().find(|effect| { + matches!( + effect, + Effect::Fetch { + call: Call::Titles(_), + .. + } + ) + }) + else { + panic!("no titles asked"); + }; + let error = ApiError::Request("timed out".to_owned()); + let failed = app.apply(Msg { + generation, + epoch, + call: call.clone(), + reply: Reply::Titles(Err(error.clone())), + }); + assert_eq!(app.toast, None, "the user asked for no hydration"); + assert!( + matches!(&failed[..], [Effect::Log(line)] if line.contains("Titles") && line.contains(&error.to_string())), + "{failed:?}" + ); + assert!(fetches(&app.hydrate_visible()).contains(&&call)); + } + + #[test] + fn a_windowed_count_waits_for_the_session_start_and_all_time_needs_none() { + let api = MockApi { + titles_delay: Duration::from_secs(1), + ..MockApi::golden() + }; + let mut app = app(110, 10); + let effects = app.start(); + let listing: Vec = effects + .into_iter() + .filter(|effect| { + matches!( + effect, + Effect::Fetch { + call: Call::Listing(_), + .. + } + ) + }) + .collect(); + let hydration = settle_listing(&mut app, &api, listing); + assert!( + !screen(&mut app).contains(" 94 "), + "an in-window count is not the whole session's until the start is known" + ); + assert!( + hydration.contains(&Call::Stats(ids(&["s-live", "s-old"]))), + "{hydration:?}" + ); + + let all_time = MockApi::golden(); + let mut app = opened(&all_time, 110, 10); + press(&mut app, &all_time, KeyCode::Char('t')); + let after_toggle: Vec = hydrations(&all_time).into_iter().skip(3).collect(); + assert!( + after_toggle.is_empty(), + "known starts and counts need no new hydration: {after_toggle:?}" + ); + assert!(screen(&mut app).contains(" 94 ")); + } + + /// Applies just the listing reply and returns the hydration it asks for. + fn settle_listing(app: &mut App, api: &MockApi, listing: Vec) -> Vec { + let [ + Effect::Fetch { + generation, + epoch, + call, + .. + }, + ] = &listing[..] + else { + panic!("{listing:?}"); + }; + let reply = Reply::Listing(Ok(api.sessions.clone())); + app.apply(Msg { + generation: *generation, + epoch: *epoch, + call: call.clone(), + reply, + }) + .into_iter() + .filter_map(|effect| match effect { + Effect::Fetch { call, .. } => Some(call), + _ => None, + }) + .collect() + } + + #[test] + fn hosts_render_before_titles_land() { + let api = MockApi::golden(); + let mut app = opened(&api, 110, 10); + app.known.clear(); + app.forget_asked(); + for id in ["s-live", "s-old"] { + app.known + .entry(id.to_owned()) + .or_default() + .set_stats(&SessionStats { + session_id: id.to_owned(), + message_count: 5, + first_ts: now() - TimeDelta::days(1), + last_ts: now(), + }); + } + let effects = app.hydrate_visible(); + let lanes: Vec = effects + .iter() + .filter_map(|effect| match effect { + Effect::Fetch { call, .. } => Some(call.lane()), + _ => None, + }) + .collect(); + assert_eq!( + lanes, + [Lane::Titles, Lane::Hosts], + "concurrent, not chained" + ); + + let hosts = effects.into_iter().filter(|effect| { + matches!( + effect, + Effect::Fetch { + call: Call::Hosts(_), + .. + } + ) + }); + settle(&mut app, &api, hosts.collect()); + let partial = screen(&mut app); + assert!(partial.contains("ws-pond-01"), "{partial}"); + assert!(!partial.contains("fix the timer re-arm"), "{partial}"); + assert!(app.lane_loading(Lane::Titles)); + } + + #[test] + fn a_restored_cache_paints_at_once_and_hydrates_only_what_it_lacks() { + let warm = MockApi::golden(); + let saved = opened(&warm, 110, 10).snapshot(); + + let mut fresh_rows = warm.sessions.clone(); + let mut new_session = row("s-new"); + new_session.last_ts = now() - TimeDelta::minutes(1); + fresh_rows.insert(0, new_session); + let api = MockApi { + sessions: fresh_rows, + ..MockApi::golden() + }; + let mut app = app(110, 10); + app.restore(saved); + let painted = screen(&mut app); + assert!(painted.contains("fix the timer re-arm"), "{painted}"); + assert!(painted.contains("ws-pond-01"), "{painted}"); + assert!(painted.contains(" 94 "), "{painted}"); + + let effects = app.start(); + assert!( + fetches(&effects) + .iter() + .all(|call| !matches!(call.lane(), Lane::Titles | Lane::Stats | Lane::Hosts)), + "every cached row is complete: {effects:?}" + ); + assert!(app.spinner_visible(), "the refresh runs behind the cache"); + assert!(screen(&mut app).contains("2 sessions")); + settle(&mut app, &api, effects); + assert!(screen(&mut app).contains("3 sessions")); + for call in hydrations(&api) { + let asked = match call { + Call::Titles(ids) | Call::Stats(ids) => ids, + Call::Hosts(starts) => starts.into_iter().map(|s| s.session_id).collect(), + _ => unreachable!(), + }; + assert_eq!(asked, ["s-new"], "only the new session is hydrated"); + } + } + + #[test] + fn hosts_show_short_names_and_this_machine() { + let api = MockApi { + hosts: vec![ + SessionHost { + session_id: "s-live".to_owned(), + host: Some("beelink-eq14.tail1234.ts.net".to_owned()), + }, + SessionHost { + session_id: "s-old".to_owned(), + host: Some("DEVBOX.lan".to_owned()), + }, + ], + ..MockApi::golden() + }; + let mut app = opened(&api, 110, 10); + let screen_text = screen(&mut app); + assert!( + screen_text.contains("● beelink-eq14 claude-code"), + "short name, uncut: {screen_text}" + ); + assert!( + screen_text.contains(" this codex-cli"), + "{screen_text}" + ); + assert!(!screen_text.contains("tail1234"), "{screen_text}"); + assert!( + screen_text.contains(" machine adapter"), + "{screen_text}" + ); + + let mut narrow = opened(&api, 60, 10); + let narrow_text = screen(&mut narrow); + assert!(narrow_text.contains("● beelink-e… claude"), "{narrow_text}"); + } + + #[test] + fn an_empty_store_says_so() { + let api = MockApi::default(); + let mut app = opened(&api, 100, 10); + let screen = screen(&mut app); + assert!( + screen.contains("no sessions in last 14 days for /home/me/pj/pond"), + "{screen}" + ); + } + + #[test] + fn pond_too_old_on_the_first_listing_is_a_full_screen_error() { + let api = MockApi { + listing_error: Some(ApiError::PondTooOld), + ..MockApi::golden() + }; + let mut app = opened(&api, 100, 12); + assert_eq!(app.fatal, Some(ApiError::PondTooOld.to_string())); + let screen = screen(&mut app); + assert!(screen.contains("upgrade pond"), "{screen}"); + assert!(screen.contains("r retry"), "{screen}"); + + let fixed = MockApi::golden(); + press(&mut app, &fixed, KeyCode::Char('r')); + assert_eq!(app.fatal, None); + assert_eq!(app.listing().map(<[SessionRow]>::len), Some(2)); + } + + /// `snapshot` as `load` reads it back from the file. + fn reloaded(snapshot: &Snapshot) -> Snapshot { + serde_json::from_slice(&serde_json::to_vec(snapshot).unwrap()).unwrap() + } + + #[test] + fn a_failed_listing_is_fatal_while_its_own_scope_shows_nothing() { + let api = MockApi::golden(); + let mut warm = opened(&api, 100, 12); + press(&mut warm, &api, KeyCode::Char('t')); + let mut saved = reloaded(&warm.snapshot()); + saved.listings.retain(|listing| listing.all_time); + + let failing = MockApi { + listing_error: Some(ApiError::PondTooOld), + ..MockApi::golden() + }; + let mut app = app(100, 12); + app.restore(saved); + let effects = app.start(); + settle(&mut app, &failing, effects); + assert_eq!(app.fatal, Some(ApiError::PondTooOld.to_string())); + } + + #[test] + fn a_restored_listing_for_another_scope_shows_at_once_and_refreshes() { + let api = MockApi::golden(); + let mut warm = opened(&api, 100, 12); + press(&mut warm, &api, KeyCode::Char('t')); + let mut app = app(100, 12); + app.restore(reloaded(&warm.snapshot())); + let effects = app.start(); + settle(&mut app, &api, effects); + + let toggled = app.on_event(&key(KeyCode::Char('t'))); + assert_eq!( + app.listing().map(<[SessionRow]>::len), + Some(2), + "shown at once" + ); + assert!( + fetches(&toggled) + .iter() + .any(|call| matches!(call, Call::Listing(scope) if scope.since.is_none())), + "a restored listing is refetched: {toggled:?}" + ); + settle(&mut app, &api, toggled); + press(&mut app, &api, KeyCode::Char('t')); + let again = app.on_event(&key(KeyCode::Char('t'))); + assert!( + !fetches(&again) + .iter() + .any(|call| matches!(call, Call::Listing(_))), + "fetched this run: {again:?}" + ); + } + + #[test] + fn later_errors_are_compact_toasts() { + let mut app = opened(&MockApi::golden(), 100, 12); + let error = ApiError::Pond { + code: "validation_failed".to_owned(), + message: "sql error: query exceeded the 25s limit; add a narrower WHERE. \ + Scope-then-scan: filter by session_id first" + .to_owned(), + }; + let failing = MockApi { + listing_error: Some(error.clone()), + ..MockApi::golden() + }; + press(&mut app, &failing, KeyCode::Char('r')); + assert_eq!(app.fatal, None, "a loaded desk keeps its rows"); + let compact = "pond validation_failed: sql error: query exceeded the 25s limit"; + assert_eq!(app.toast.as_deref(), Some(compact)); + assert_eq!( + app.toast(&error), + [Effect::Log(format!("desk: {error}"))], + "the whole text reaches desk.log" + ); + assert!(screen(&mut app).contains("pond validation_failed: sql error")); + press(&mut app, &failing, KeyCode::Esc); + assert_eq!(app.toast, None); + assert_eq!(app.listing().map(<[SessionRow]>::len), Some(2)); + let unclipped = ApiError::Request("timed out".to_owned()); + assert_eq!( + app.toast(&unclipped), + [Effect::Log(format!("desk: {unclipped}"))], + "every toast reaches desk.log" + ); + } + + #[test] + fn a_long_toast_is_capped_to_a_few_lines() { + let mut app = opened(&MockApi::golden(), 100, 20); + app.toast = Some(format!("{}TAIL", "word ".repeat(200))); + let screen_text = screen(&mut app); + let rows = screen_text + .lines() + .filter(|line| line.contains("word")) + .count(); + assert_eq!(rows, 3, "{screen_text}"); + assert!(screen_text.contains('…') && !screen_text.contains("TAIL")); + + let unreachable = ApiError::Unreachable("x; y. z".to_owned()); + assert_eq!(ui::error_text(&unreachable), unreachable.to_string()); + } + + #[test] + fn selection_survives_a_refresh_by_session_id() { + let api = MockApi { + sessions: vec![row("a"), row("b"), row("c")], + ..MockApi::default() + }; + let mut app = opened(&api, 100, 12); + press(&mut app, &api, KeyCode::Down); + assert_eq!(app.selected_id(), Some("b")); + let reordered = MockApi { + sessions: vec![row("x"), row("y"), row("c"), row("b")], + ..MockApi::default() + }; + press(&mut app, &reordered, KeyCode::Char('r')); + assert_eq!(app.selected_id(), Some("b")); + } + + #[test] + fn select_last_is_clamped_before_indexing() { + let api = MockApi { + sessions: vec![row("a"), row("b")], + ..MockApi::default() + }; + let mut app = opened(&api, 100, 12); + app.listing_state.select_last(); + assert_eq!(app.selected_index(), Some(1)); + press(&mut app, &api, KeyCode::Enter); + assert_eq!(app.pager.as_ref().map(|p| p.session_id.as_str()), Some("b")); + } + + #[test] + fn all_time_is_a_slow_loading_state_and_toggling_back_is_cached() { + let api = MockApi::golden(); + let mut app = opened(&api, 100, 12); + let effects = app.on_event(&key(KeyCode::Char('t'))); + let calls = fetches(&effects); + assert!(matches!(calls[0], Call::Listing(scope) if scope.since.is_none())); + assert!(screen(&mut app).contains("loading the all-time listing")); + settle(&mut app, &api, effects); + assert!(screen(&mut app).contains("| all time |")); + + let effects = app.on_event(&key(KeyCode::Char('t'))); + assert!( + !fetches(&effects) + .iter() + .any(|call| matches!(call, Call::Listing(_))), + "toggling back is served from the cache: {effects:?}" + ); + assert_eq!(app.listing().map(<[SessionRow]>::len), Some(2)); + } + + #[test] + fn a_refresh_joins_requests_already_in_flight() { + let mut app = app(100, 12); + let first = app.start(); + assert_eq!(fetches(&first), [&Call::Listing(app.scope()), &Call::Live]); + app.now += TimeDelta::seconds(5); + let again = app.on_event(&key(KeyCode::Char('r'))); + assert!(fetches(&again).is_empty(), "{again:?}"); + } + + #[test] + fn toggling_back_leaves_the_slow_listing_running_into_the_cache() { + let api = MockApi::golden(); + let mut app = opened(&api, 100, 12); + let all_time = app.on_event(&key(KeyCode::Char('t'))); + let listing = all_time + .into_iter() + .find(|effect| { + matches!( + effect, + Effect::Fetch { + call: Call::Listing(_), + .. + } + ) + }) + .unwrap(); + let back = app.on_event(&key(KeyCode::Char('t'))); + assert!( + !back.contains(&Effect::Cancel(Lane::Listing)), + "toggling back cancelled the listing: {back:?}" + ); + assert!(app.listing_filter.recent); + settle(&mut app, &api, vec![listing]); + assert!(app.listing_filter.recent, "the late listing moved the view"); + let cached = app.on_event(&key(KeyCode::Char('t'))); + assert!( + !fetches(&cached) + .iter() + .any(|call| matches!(call, Call::Listing(_))), + "the landed listing was not cached: {cached:?}" + ); + assert_eq!(app.listing().map(<[SessionRow]>::len), Some(2)); + } + + #[test] + fn previews_are_cached_clipped_and_clean() { + let api = MockApi { + transcript: vec![message( + "m1", + now(), + &format!("\u{1b}[31m{}", "x".repeat(ui::PREVIEW_CHARS * 3)), + )], + ..MockApi::golden() + }; + let mut app = opened(&api, 100, 20); + press(&mut app, &api, KeyCode::Char(' ')); + let text = &app.previews["s-live"][0].text; + assert!(text.chars().count() <= ui::PREVIEW_CHARS); + assert!(!text.contains('\u{1b}') && !text.contains("[31m")); + } + + #[test] + fn the_spinner_ticks_only_where_it_shows() { + let mut app = opened(&MockApi::golden(), 100, 12); + assert!(!app.spinner_visible()); + let refresh = app.on_event(&key(KeyCode::Char('r'))); + assert!(!refresh.is_empty()); + assert!( + app.spinner_visible(), + "the desk footer shows the listing load" + ); + app.pager = Some(Pager::new("s-old".to_owned(), 80)); + assert!(!app.spinner_visible(), "the pager shows only its own load"); + app.load_more(); + assert!(app.spinner_visible()); + } + + fn last_search(api: &MockApi) -> SearchRequest { + api.calls() + .into_iter() + .rev() + .find_map(|call| match call { + Call::Search(request) => Some(request), + _ => None, + }) + .expect("a search was sent") + } + + #[test] + fn search_covers_everything_until_p_or_t_narrow_it() { + let api = MockApi::golden(); + let mut app = opened(&api, 140, 12); + type_query(&mut app, &api, "timer"); + let wide = last_search(&api); + assert_eq!(wide.filters.project, None); + assert_eq!(wide.filters.from_date, None); + assert!(screen(&mut app).contains("| searching everything |")); + + press(&mut app, &api, KeyCode::Enter); + press(&mut app, &api, KeyCode::Char('p')); + let project = last_search(&api); + assert_eq!( + project.filters.project, + Some(ProjectFilter::Contains("/home/me/pj/pond".to_owned())) + ); + assert_eq!(project.filters.from_date, None); + assert!(screen(&mut app).contains("| searching this project |")); + assert!( + !api.calls() + .iter() + .any(|call| matches!(call, Call::Listing(s) if s.project.is_none())), + "p in search leaves the listing scope alone" + ); + + press(&mut app, &api, KeyCode::Char('t')); + assert_eq!( + last_search(&api).filters.from_date.as_deref(), + Some("2026-09-11") + ); + assert!(screen(&mut app).contains("| searching this project, last 14 days |")); + press(&mut app, &api, KeyCode::Char('p')); + assert!(screen(&mut app).contains("| searching all projects, last 14 days |")); + + press(&mut app, &api, KeyCode::Esc); + assert!( + app.listing_filter.project && app.listing_filter.recent, + "the listing kept its default" + ); + press(&mut app, &api, KeyCode::Char('p')); + assert!( + api.calls() + .iter() + .any(|call| matches!(call, Call::Listing(s) if s.project.is_none())), + "p in the listing widens the listing" + ); + } + + #[test] + fn p_without_a_project_explains_itself() { + let api = MockApi::golden(); + let mut app = App::new( + DeskContext { + project: None, + ..context() + }, + now(), + Size::new(100, 12), + ); + let effects = app.start(); + settle(&mut app, &api, effects); + type_query(&mut app, &api, "timer"); + press(&mut app, &api, KeyCode::Enter); + let before = api.calls().len(); + press(&mut app, &api, KeyCode::Char('p')); + assert_eq!(api.calls().len(), before); + assert!(app.toast.as_ref().unwrap().contains("without a project")); + } + + #[test] + fn zero_matches_and_nothing_in_scope_read_differently() { + let empty_scope = MockApi { + search: Some(search_response(golden::SEARCH_OUT_OF_SCOPE)), + ..MockApi::golden() + }; + let mut app = opened(&empty_scope, 120, 12); + type_query(&mut app, &empty_scope, "timer"); + let screen_text = screen(&mut app); + assert!( + screen_text.contains("nothing searchable in scope (searching everything)"), + "{screen_text}" + ); + + let no_matches = MockApi { + search: Some(SearchResponse { + searchable_in_scope: 4120, + ..search_response(golden::SEARCH_OUT_OF_SCOPE) + }), + ..MockApi::golden() + }; + let mut app = opened(&no_matches, 120, 12); + type_query(&mut app, &no_matches, "xyz"); + let screen_text = screen(&mut app); + assert!( + screen_text.contains("no matches for \"xyz\" among 4120 searchable messages"), + "{screen_text}" + ); + } + + #[test] + fn search_results_render_and_esc_returns_to_the_listing() { + let api = MockApi { + search: Some(search_response(golden::SEARCH)), + ..MockApi::golden() + }; + let mut app = opened(&api, 120, 12); + type_query(&mut app, &api, "timer"); + let screen_text = screen(&mut app); + assert!(screen_text.contains("1 sessions match"), "{screen_text}"); + assert!( + screen_text.contains("2/94 the systemd timer stops re-arming"), + "{screen_text}" + ); + press(&mut app, &api, KeyCode::Esc); + assert!(app.search.is_none()); + assert!(screen(&mut app).contains("2 sessions")); + } + + #[test] + fn search_asks_no_titles_and_the_pager_asks_for_its_own() { + let api = MockApi { + search: Some(search_response(golden::SEARCH)), + live: Vec::new(), + ..MockApi::golden() + }; + let mut app = opened(&api, 120, 12); + type_query(&mut app, &api, "timer"); + press(&mut app, &api, KeyCode::Enter); + app.known.clear(); + app.forget_asked(); + let searching = app.hydrate_visible(); + assert!( + fetches(&searching) + .iter() + .all(|call| !matches!(call, Call::Titles(_))), + "search rows show snippets, not titles: {searching:?}" + ); + settle(&mut app, &api, searching); + + let opening = app.on_event(&key(KeyCode::Enter)); + assert!(fetches(&opening).contains(&&Call::Titles(ids(&["s-live"])))); + assert!(!screen(&mut app).contains(ui::NO_TITLE)); + settle(&mut app, &api, opening); + assert!(screen(&mut app).starts_with("fix the timer re-arm | s-live")); + } + + #[test] + fn input_cursor_counts_cells_not_chars() { + let api = MockApi::golden(); + let mut app = opened(&api, 100, 12); + type_query(&mut app, &api, "日本x"); + assert_eq!(app.input.cursor_column(), 5); + press(&mut app, &api, KeyCode::Left); + press(&mut app, &api, KeyCode::Left); + assert_eq!(app.input.cursor_column(), 2); + press(&mut app, &api, KeyCode::Backspace); + assert_eq!(app.input.text, "本x"); + assert_eq!(app.search.as_ref().unwrap().query, "本x"); + } + + #[test] + fn stale_messages_are_dropped() { + let api = MockApi::golden(); + let mut app = opened(&api, 100, 12); + let effects = app.on_event(&key(KeyCode::Char('/'))); + assert!(effects.is_empty()); + let first = app.on_event(&key(KeyCode::Char('a'))); + let second = app.on_event(&key(KeyCode::Char('b'))); + let reply = |effect: &Effect, epoch_shift: u64| { + let Effect::Fetch { + generation, + epoch, + call, + .. + } = effect + else { + panic!("{effect:?}"); + }; + Msg { + generation: *generation, + epoch: epoch - epoch_shift, + call: call.clone(), + reply: Reply::Search(Ok(search_response(golden::SEARCH))), + } + }; + let latest = second.last().unwrap(); + app.apply(reply(first.last().unwrap(), 0)); + assert!( + app.search.as_ref().unwrap().response.is_none(), + "old generation" + ); + app.apply(reply(latest, 1)); + assert!( + app.search.as_ref().unwrap().response.is_none(), + "old view epoch" + ); + app.apply(reply(latest, 0)); + assert!(app.search.as_ref().unwrap().response.is_some()); + } + + #[test] + fn a_preview_for_a_session_no_longer_selected_is_dropped() { + let api = MockApi::golden(); + let mut app = opened(&api, 100, 20); + let effects = app.on_event(&key(KeyCode::Char(' '))); + let preview = effects + .into_iter() + .find(|effect| { + matches!( + effect, + Effect::Fetch { + call: Call::Preview(_), + .. + } + ) + }) + .unwrap(); + app.listing_state.select(Some(1)); + settle(&mut app, &api, vec![preview]); + assert!(app.previews.is_empty()); + + press(&mut app, &api, KeyCode::Up); + assert_eq!(app.previews.get("s-live").map(Vec::len), Some(2)); + assert!(screen(&mut app).contains("preview - newest first")); + } + + #[test] + fn enter_on_a_live_row_jumps_and_on_others_opens_the_pager() { + let api = MockApi::golden(); + let mut app = opened(&api, 100, 12); + assert_eq!( + press(&mut app, &api, KeyCode::Enter), + Some(DeskExit::Jump { + pane_id: "p7".to_owned() + }) + ); + press(&mut app, &api, KeyCode::Down); + assert_eq!(press(&mut app, &api, KeyCode::Enter), None); + let pager = app.pager.as_ref().unwrap(); + assert_eq!(pager.session_id, "s-old"); + assert!(pager.eof); + let screen_text = screen(&mut app); + assert!(screen_text.contains(ui::PAGER_FOOTER), "{screen_text}"); + press(&mut app, &api, KeyCode::Char('q')); + assert!(app.pager.is_none()); + } + + #[test] + fn the_pager_seeks_through_more_ties_than_a_page() { + let tied = now() - TimeDelta::hours(2); + let mut transcript: Vec<_> = (0..PAGE_ROWS * 2 + 7) + .map(|i| message(&format!("m{i:03}"), tied, &format!("tied {i}"))) + .collect(); + transcript.push(message("a-late", tied + TimeDelta::microseconds(1), "last")); + transcript.reverse(); + let api = MockApi { + transcript, + ..MockApi::golden() + }; + let mut app = opened(&api, 100, 12); + press(&mut app, &api, KeyCode::Down); + press(&mut app, &api, KeyCode::Enter); + while !app.pager.as_ref().unwrap().eof { + press(&mut app, &api, KeyCode::End); + } + let pager = app.pager.as_ref().unwrap(); + let ids: Vec<&str> = pager + .messages + .iter() + .map(|m| m.message_id.as_str()) + .collect(); + assert_eq!(ids.len(), PAGE_ROWS * 2 + 8, "nothing skipped or repeated"); + assert_eq!(ids.first(), Some(&"m000")); + assert_eq!(ids.last(), Some(&"a-late")); + let pages: Vec> = api + .calls() + .into_iter() + .filter_map(|call| match call { + Call::Page { after, .. } => Some(after), + _ => None, + }) + .collect(); + assert_eq!(pages.len(), 3, "single-flight, one request per page"); + assert_eq!( + pages[1], + Some(Cursor { + timestamp: tied, + message_id: format!("m{:03}", PAGE_ROWS - 1), + }) + ); + } + + fn open_pager(app: &mut App) -> Effect { + app.pager = Some(Pager::new("s-old".to_owned(), 80)); + let mut effects = app.load_more(); + assert_eq!(effects.len(), 1); + effects.remove(0) + } + + fn page_reply(effect: Effect, messages: Vec, truncated: bool) -> Msg { + let Effect::Fetch { + generation, + epoch, + call, + .. + } = effect + else { + panic!("{effect:?}"); + }; + Msg { + generation, + epoch, + call, + reply: Reply::Page(Ok(TranscriptPage { + messages, + truncated, + })), + } + } + + #[test] + fn a_page_reply_asks_again_for_a_title_that_failed() { + let api = MockApi { + live: Vec::new(), + ..MockApi::golden() + }; + let mut app = opened(&api, 110, 12); + app.known.clear(); + app.forget_asked(); + let Some(Effect::Fetch { + generation, + epoch, + call, + .. + }) = app.hydrate_visible().into_iter().find(|effect| { + matches!( + effect, + Effect::Fetch { + call: Call::Titles(_), + .. + } + ) + }) + else { + panic!("no titles asked"); + }; + let opening = app.on_event(&key(KeyCode::Enter)); + assert!( + !fetches(&opening) + .iter() + .any(|call| matches!(call, Call::Titles(_))), + "the busy titles lane is not restarted: {opening:?}" + ); + app.apply(Msg { + generation, + epoch, + call, + reply: Reply::Titles(Err(ApiError::Request("timed out".to_owned()))), + }); + let page = opening + .into_iter() + .find(|effect| { + matches!( + effect, + Effect::Fetch { + call: Call::Page { .. }, + .. + } + ) + }) + .unwrap(); + let after = app.apply(page_reply(page, vec![message("m1", now(), "hi")], false)); + assert!( + fetches(&after).iter().any( + |call| matches!(call, Call::Titles(ids) if ids.contains(&"s-live".to_owned())) + ), + "{after:?}" + ); + } + + #[test] + fn a_short_truncated_page_is_not_the_end() { + let mut app = opened(&MockApi::golden(), 100, 30); + let request = open_pager(&mut app); + let short = vec![message("m1", now(), "one"), message("m2", now(), "two")]; + let next = app.apply(page_reply(request, short, true)); + let calls = fetches(&next); + assert!( + matches!(calls[..], [Call::Page { after: Some(Cursor { message_id, .. }), .. }] if message_id == "m2"), + "{next:?}" + ); + assert!(!app.pager.as_ref().unwrap().eof); + + let empty = app.apply(page_reply( + next.into_iter().next().unwrap(), + Vec::new(), + true, + )); + assert!(empty.is_empty()); + assert!( + app.pager.as_ref().unwrap().eof, + "an empty page cannot advance the cursor" + ); + assert!(app.toast.as_ref().unwrap().contains("size budget")); + } + + #[test] + fn a_huge_single_message_is_wrapped_once_and_sliced() { + let mut app = opened(&MockApi::golden(), 80, 24); + let request = open_pager(&mut app); + let huge = "lorem ipsum dolor ".repeat(40_000); + app.apply(page_reply( + request, + vec![message("m1", now(), &huge)], + false, + )); + let lines = app.pager.as_ref().unwrap().lines.len(); + assert!(lines > 9_000, "{lines}"); + assert!(lines > usize::from(u16::MAX) / 8); + press(&mut app, &MockApi::golden(), KeyCode::End); + let screen_text = screen(&mut app); + assert!( + screen_text.contains(&format!("line {}/{lines}", lines - 21)), + "{screen_text}" + ); + } + + #[test] + fn ansi_tab_and_crlf_are_cleaned_in_the_pager() { + let mut app = opened(&MockApi::golden(), 60, 12); + let request = open_pager(&mut app); + let rows = sql_rows::(golden::SQL_PAGE); + app.apply(page_reply(request, rows, false)); + let screen_text = screen(&mut app); + assert!( + screen_text.contains("check open issues "), + "{screen_text}" + ); + assert!(screen_text.contains("red done"), "{screen_text}"); + assert!(!screen_text.contains("[31m"), "{screen_text}"); + assert!(!screen_text.contains("[0m"), "{screen_text}"); + } + + #[test] + fn tiny_terminals_render_and_a_resize_keeps_the_pager_position() { + for (width, height) in [(1, 1), (3, 2), (12, 4)] { + let mut app = opened(&MockApi::golden(), width, height); + screen(&mut app); + press(&mut app, &MockApi::golden(), KeyCode::Char(' ')); + screen(&mut app); + let request = open_pager(&mut app); + app.apply(page_reply( + request, + vec![message("m1", now(), "hello world")], + false, + )); + screen(&mut app); + } + + let mut app = opened(&MockApi::golden(), 80, 10); + let request = open_pager(&mut app); + let messages: Vec<_> = (0..20) + .map(|i| { + message( + &format!("m{i:02}"), + now(), + &format!("message {i} {}", "word ".repeat(30)), + ) + }) + .collect(); + app.apply(page_reply(request, messages, false)); + let target = app.pager.as_ref().unwrap().starts[10]; + app.pager.as_mut().unwrap().offset = target; + let wide = app.pager.as_ref().unwrap().lines.len(); + + app.on_event(&Event::Resize(50, 8)); + app.on_event(&Event::Resize(30, 6)); + assert_eq!( + app.pager.as_ref().unwrap().lines.len(), + wide, + "a resize waits for the next draw to re-wrap" + ); + let screen_text = screen(&mut app); + let pager = app.pager.as_ref().unwrap(); + assert!(pager.lines.len() > wide, "re-wrapped narrower"); + assert_eq!( + pager.offset, pager.starts[10], + "the same message stays on top" + ); + assert!(screen_text.contains("message 10"), "{screen_text}"); + } + + #[test] + fn widening_fetches_more_once_the_rewrap_runs_short() { + let mut app = opened(&MockApi::golden(), 30, 10); + let width = usize::from(app.pager_viewport().width); + app.pager = Some(Pager::new("s-old".to_owned(), width)); + let request = app.load_more().remove(0); + let messages: Vec<_> = (0..3) + .map(|i| message(&format!("m{i}"), now(), &"word ".repeat(60))) + .collect(); + assert!(app.apply(page_reply(request, messages, true)).is_empty()); + + let resized = app.on_event(&Event::Resize(200, 10)); + assert!(fetches(&resized).is_empty(), "{resized:?}"); + let relaid = app.relayout(); + assert!( + matches!(fetches(&relaid)[..], [Call::Page { after: Some(_), .. }]), + "{relaid:?}" + ); + } + + #[test] + fn the_pager_frame_is_the_viewport_slice() { + let mut app = app(40, 6); + app.known + .entry("s-old".to_owned()) + .or_default() + .set_title(Some("fix it".to_owned())); + let mut pager = Pager::new("s-old".to_owned(), 39); + pager.append(vec![ + message("m1", now(), "one\ntwo"), + TranscriptMessage { + role: "assistant".to_owned(), + ..message("m2", now(), "three") + }, + ]); + pager.offset = 1; + pager.eof = true; + app.pager = Some(pager); + let mut terminal = Terminal::new(TestBackend::new(40, 6)).unwrap(); + terminal.draw(|frame| ui::render(frame, &mut app)).unwrap(); + let mut frame = terminal.backend().buffer().clone(); + frame.set_style(frame.area, Style::reset()); + assert_eq!( + frame, + Buffer::with_lines([ + "fix it | s-old ", + "one ▲", + "two █", + " ║", + "assistant 2026-09-25 05:00:00 UTC ▼", + "conversation only - tool bodies via pond", + ]) + ); + } +} diff --git a/packages/herdr-pond/src/desk/cache.rs b/packages/herdr-pond/src/desk/cache.rs new file mode 100644 index 00000000..21f9463d --- /dev/null +++ b/packages/herdr-pond/src/desk/cache.rs @@ -0,0 +1,445 @@ +//! What the desk knows about sessions, and the bounded file that carries it +//! between opens (`desk-cache.json` in the plugin state dir). A missing or +//! unreadable file is an empty cache, never an error. + +use std::collections::HashMap; +use std::io::ErrorKind; +use std::path::Path; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::config::{log_line, write_atomic}; +use crate::types::{SessionRow, SessionStats}; + +const CACHE_FILE: &str = "desk-cache.json"; +const LOG_FILE: &str = "desk.log"; +/// A format change bumps this, and older files read as empty. +const VERSION: u32 = 1; +const MAX_SESSIONS: usize = 2000; +const MAX_LISTINGS: usize = 8; +/// Owner-only: it holds prompt titles, project paths and host names. +const CACHE_MODE: u32 = 0o600; + +/// Titles and hosts never change once read; a missing title holds only for +/// the `last_ts` it was read at, a count until activity past its own. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub(super) struct Known { + /// The newest `last_ts` a listing reported. + #[serde(default)] + last_ts: Option>, + /// The session's first message: its host is the origin host, and a + /// listing window starting at or before it holds the whole session. + #[serde(default)] + first_ts: Option>, + #[serde(default)] + count: Option, + #[serde(default)] + title: Option, + #[serde(default)] + host: Option<Host>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +struct Counted { + last_ts: Option<DateTime<Utc>>, + messages: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Title { + Text(String), + Missing { as_of: Option<DateTime<Utc>> }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum Host { + Stamped(String), + /// Pre-stamp rows: unknown provenance, never "this machine". + Unstamped, +} + +impl Known { + /// The whole-session count, if one is known at or past the current `last_ts`. + pub(super) fn count(&self) -> Option<u64> { + self.count + .filter(|counted| counted.last_ts >= self.last_ts) + .map(|counted| counted.messages) + } + + /// `None` until known; `Some(None)` for a session with no user message. + pub(super) fn title(&self) -> Option<Option<&str>> { + match &self.title { + Some(Title::Text(text)) => Some(Some(text)), + Some(Title::Missing { as_of }) if *as_of == self.last_ts => Some(None), + _ => None, + } + } + + pub(super) fn set_title(&mut self, title: Option<String>) { + self.title = Some(title.map_or( + Title::Missing { + as_of: self.last_ts, + }, + Title::Text, + )); + } + + pub(super) fn first_ts(&self) -> Option<DateTime<Utc>> { + self.first_ts + } + + pub(super) fn host(&self) -> Option<&Host> { + self.host.as_ref() + } + + /// `None` is a first message with no host stamp. + pub(super) fn set_host(&mut self, host: Option<String>) { + self.host = Some(host.map_or(Host::Unstamped, Host::Stamped)); + } + + /// The count holds for the activity the stats read saw, which a listing + /// that landed meanwhile may already have moved past. + pub(super) fn set_stats(&mut self, stats: &SessionStats) { + self.first_ts = Some(stats.first_ts); + self.count_as_of(Some(stats.last_ts), stats.message_count); + } + + /// Keeps whichever count saw the newer activity. + fn count_as_of(&mut self, last_ts: Option<DateTime<Utc>>, messages: u64) { + if self.count.is_none_or(|counted| counted.last_ts <= last_ts) { + self.count = Some(Counted { last_ts, messages }); + } + } + + /// Takes what a listing row proves. The row counts only its window, so + /// its count is the session's only when the window reaches the session's + /// start: always for the all-time listing (`since` is `None`), else only + /// once that start is known from an earlier all-time row or stats read. + pub(super) fn observe(&mut self, row: &SessionRow, since: Option<DateTime<Utc>>) { + if self.last_ts.is_some_and(|last| last > row.last_ts) { + return; + } + self.last_ts = Some(row.last_ts); + if since.is_none() { + self.first_ts = Some(row.first_ts); + } + let whole = self + .first_ts + .is_some_and(|first| since.is_none_or(|since| first >= since)); + if whole { + self.count_as_of(self.last_ts, row.message_count); + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(super) struct SavedListing { + pub(super) project: Option<String>, + pub(super) all_time: bool, + pub(super) saved_at: DateTime<Utc>, + pub(super) rows: Vec<SessionRow>, + /// Landed during this desk run, as opposed to restored from the file. + #[serde(skip)] + pub(super) fresh: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub(super) struct Snapshot { + #[serde(default)] + version: u32, + pub(super) sessions: HashMap<String, Known>, + pub(super) listings: Vec<SavedListing>, +} + +impl Snapshot { + pub(super) fn new(sessions: HashMap<String, Known>, listings: Vec<SavedListing>) -> Self { + Self { + version: VERSION, + sessions, + listings, + } + } + + /// Keeps the newest listings, and the sessions with the newest activity. + fn bound(&mut self) { + self.listings + .sort_by_key(|listing| std::cmp::Reverse(listing.saved_at)); + self.listings.truncate(MAX_LISTINGS); + if self.sessions.len() > MAX_SESSIONS { + let mut newest: Vec<(Option<DateTime<Utc>>, String)> = self + .sessions + .iter() + .map(|(id, known)| (known.last_ts, id.clone())) + .collect(); + newest.sort_by(|a, b| b.cmp(a)); + for (_, id) in newest.split_off(MAX_SESSIONS) { + self.sessions.remove(&id); + } + } + } +} + +pub(super) fn load(state_dir: &Path) -> Snapshot { + let path = state_dir.join(CACHE_FILE); + let loaded = match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice::<Snapshot>(&bytes).map_err(|error| error.to_string()), + Err(error) if error.kind() == ErrorKind::NotFound => return Snapshot::default(), + Err(error) => Err(error.to_string()), + }; + match loaded { + Ok(snapshot) if snapshot.version == VERSION => snapshot, + Ok(snapshot) => { + log( + state_dir, + &format!( + "ignoring {}: version {}, expected {VERSION}", + path.display(), + snapshot.version + ), + ); + Snapshot::default() + } + Err(error) => { + log( + state_dir, + &format!("ignoring unreadable {}: {error}", path.display()), + ); + Snapshot::default() + } + } +} + +pub(super) fn save(state_dir: &Path, mut snapshot: Snapshot) { + snapshot.bound(); + let path = state_dir.join(CACHE_FILE); + let written = serde_json::to_vec(&snapshot) + .map_err(std::io::Error::other) + .and_then(|json| write_atomic(&path, &json, CACHE_MODE)); + if let Err(error) = written { + log( + state_dir, + &format!("cannot write {}: {error}", path.display()), + ); + } +} + +/// One line in `desk.log`, the only record of what the desk did not show. +pub(super) fn log(state_dir: &Path, message: &str) { + log_line(&state_dir.join(LOG_FILE), message); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use std::os::unix::fs::PermissionsExt; + + use chrono::TimeDelta; + + use super::*; + use crate::fake_pond::{Sandbox, ts}; + + fn row(first: &str, last: &str, messages: u64) -> SessionRow { + SessionRow { + session_id: "s".to_owned(), + last_ts: ts(last), + first_ts: ts(first), + message_count: messages, + source_agent: "codex-cli".to_owned(), + project: "/p".to_owned(), + } + } + + #[test] + fn a_windowed_count_is_whole_only_when_the_session_start_is_inside() { + let since = Some(ts("2026-09-11T00:00:00Z")); + let straddling = row("2026-09-12T00:00:00Z", "2026-09-20T00:00:00Z", 5); + + let mut unknown_start = Known::default(); + unknown_start.observe(&straddling, since); + assert_eq!( + unknown_start.count(), + None, + "a later in-window first row does not prove the session started there" + ); + + let mut started_before = Known { + first_ts: Some(ts("2026-09-01T00:00:00Z")), + ..Known::default() + }; + started_before.observe(&straddling, since); + assert_eq!(started_before.count(), None); + + let mut started_inside = Known { + first_ts: Some(ts("2026-09-12T00:00:00Z")), + ..Known::default() + }; + started_inside.observe(&straddling, since); + assert_eq!(started_inside.count(), Some(5)); + + let mut all_time = Known::default(); + all_time.observe(&straddling, None); + assert_eq!(all_time.count(), Some(5)); + assert_eq!(all_time.first_ts, Some(ts("2026-09-12T00:00:00Z"))); + } + + #[test] + fn new_activity_invalidates_counts_and_a_missing_title_but_not_a_title() { + let mut known = Known::default(); + known.observe( + &row("2026-09-12T00:00:00Z", "2026-09-20T00:00:00Z", 5), + None, + ); + known.set_title(None); + assert_eq!(known.title(), Some(None)); + + known.observe( + &row("2026-09-12T00:00:00Z", "2026-09-21T00:00:00Z", 9), + Some(ts("2026-09-15T00:00:00Z")), + ); + assert_eq!(known.count(), None, "the window misses the start"); + assert_eq!( + known.title(), + None, + "a resumed session may have a title now" + ); + + known.set_title(Some("fix it".to_owned())); + known.observe( + &row("2026-09-12T00:00:00Z", "2026-09-22T00:00:00Z", 12), + None, + ); + assert_eq!(known.title(), Some(Some("fix it"))); + assert_eq!(known.count(), Some(12)); + + known.observe( + &row("2026-09-12T00:00:00Z", "2026-09-21T00:00:00Z", 9), + None, + ); + assert_eq!(known.count(), Some(12), "an older row is ignored"); + } + + #[test] + fn a_saved_cache_loads_back_bounded() { + let sandbox = Sandbox::new(); + let dir = sandbox.state_dir(); + let base = ts("2026-01-01T00:00:00Z"); + let sessions = (0..MAX_SESSIONS + 5) + .map(|i| { + let mut known = Known::default(); + let at = base + TimeDelta::minutes(i64::try_from(i).unwrap()); + known.observe(&row("2025-12-31T00:00:00Z", &at.to_rfc3339(), 1), None); + known.set_title(Some(format!("title {i}"))); + (format!("s{i}"), known) + }) + .collect(); + let listings = (0..MAX_LISTINGS + 2) + .map(|i| SavedListing { + project: Some(format!("/p{i}")), + all_time: false, + saved_at: base + TimeDelta::hours(i64::try_from(i).unwrap()), + rows: Vec::new(), + fresh: true, + }) + .collect(); + save(&dir, Snapshot::new(sessions, listings)); + let mode = std::fs::metadata(dir.join(CACHE_FILE)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, CACHE_MODE); + + let loaded = load(&dir); + assert!(loaded.listings.iter().all(|listing| !listing.fresh)); + assert_eq!(loaded.sessions.len(), MAX_SESSIONS); + assert!(!loaded.sessions.contains_key("s0"), "the oldest is dropped"); + assert_eq!( + loaded.sessions[&format!("s{}", MAX_SESSIONS + 4)].title(), + Some(Some(format!("title {}", MAX_SESSIONS + 4).as_str())) + ); + assert_eq!(loaded.listings.len(), MAX_LISTINGS); + assert_eq!( + loaded.listings[0].project.as_deref(), + Some(format!("/p{}", MAX_LISTINGS + 1).as_str()) + ); + } + + #[test] + fn a_missing_corrupt_or_foreign_cache_is_empty() { + let sandbox = Sandbox::new(); + let dir = sandbox.state_dir(); + assert_eq!(load(&dir), Snapshot::default()); + assert!(!dir.join(LOG_FILE).exists(), "a missing cache is not news"); + + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(CACHE_FILE), b"{\"sessions\": [tru").unwrap(); + assert_eq!(load(&dir), Snapshot::default()); + let log = std::fs::read_to_string(dir.join(LOG_FILE)).unwrap(); + assert!(log.contains("ignoring unreadable"), "{log}"); + + std::fs::write( + dir.join(CACHE_FILE), + br#"{"version":99,"sessions":{},"listings":[]}"#, + ) + .unwrap(); + assert_eq!(load(&dir), Snapshot::default()); + let log = std::fs::read_to_string(dir.join(LOG_FILE)).unwrap(); + assert!( + log.contains(&format!("version 99, expected {VERSION}")), + "{log}" + ); + } + + fn stats(last: &str, messages: u64) -> SessionStats { + SessionStats { + session_id: "s".to_owned(), + message_count: messages, + first_ts: ts("2026-09-01T00:00:00Z"), + last_ts: ts(last), + } + } + + #[test] + fn a_stats_count_holds_for_the_activity_it_saw() { + let mut known = Known::default(); + known.observe( + &row("2026-09-12T00:00:00Z", "2026-09-20T00:00:00Z", 5), + Some(ts("2026-09-15T00:00:00Z")), + ); + known.set_stats(&stats("2026-09-19T00:00:00Z", 4)); + assert_eq!( + known.count(), + None, + "a read from before the listing's activity undercounts" + ); + + known.set_stats(&stats("2026-09-20T00:00:00Z", 9)); + assert_eq!(known.count(), Some(9)); + known.set_stats(&stats("2026-09-19T00:00:00Z", 4)); + assert_eq!(known.count(), Some(9), "an older read is ignored"); + + known.set_stats(&stats("2026-09-21T00:00:00Z", 12)); + assert_eq!(known.count(), Some(12), "a read past the listing counts"); + known.observe( + &row("2026-09-12T00:00:00Z", "2026-09-20T00:00:00Z", 9), + None, + ); + assert_eq!(known.count(), Some(12), "an older listing row is ignored"); + } + + #[test] + fn a_stats_read_leaves_a_missing_title_standing() { + let mut known = Known::default(); + known.observe( + &row("2026-09-12T00:00:00Z", "2026-09-20T00:00:00Z", 5), + Some(ts("2026-09-15T00:00:00Z")), + ); + known.set_title(None); + known.set_stats(&stats("2026-09-21T00:00:00Z", 6)); + assert_eq!(known.title(), Some(None)); + assert_eq!(known.count(), Some(6)); + } +} diff --git a/packages/herdr-pond/src/desk/mod.rs b/packages/herdr-pond/src/desk/mod.rs new file mode 100644 index 00000000..380ad653 --- /dev/null +++ b/packages/herdr-pond/src/desk/mod.rs @@ -0,0 +1,676 @@ +//! The session desk TUI: a runtime that owns the terminal and performs the +//! effects of the pure reducers in `app`, `ui` to draw their state, and +//! `cache` to carry what the desk learned into the next open. + +mod app; +mod cache; +mod ui; + +use std::future::Future; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use crossterm::event::{Event, EventStream}; +use futures_util::{Stream, StreamExt}; +use ratatui::Terminal; +use ratatui::backend::Backend; +use tokio::sync::mpsc; +use tokio::task::AbortHandle; + +use self::app::{App, Call, Effect, Lane, Msg, Reply}; +use crate::types::{Api, DeskContext, DeskExit}; + +const SPINNER_TICK: Duration = Duration::from_millis(100); +/// How long exit waits for in-flight blocking calls (herdr's CLI) to finish. +const EXIT_GRACE: Duration = Duration::from_millis(500); + +/// Builds its own current-thread runtime and owns the terminal until it +/// returns; the terminal is restored on every return path, before the api - +/// and any fallback serve it owns, whose teardown blocks - is dropped. +pub(crate) fn run(api: Arc<dyn Api>, context: DeskContext) -> anyhow::Result<DeskExit> { + let runtime = crate::runtime()?; + let mut terminal = ratatui::try_init().inspect_err(|_| ratatui::restore())?; + let result = runtime.block_on(async { + let shutdown = crate::shutdown_signal()?; + let shutdown = async { + shutdown.await; + }; + event_loop( + &mut terminal, + Arc::clone(&api), + context, + EventStream::new(), + shutdown, + ) + .await + }); + ratatui::restore(); + runtime.shutdown_timeout(EXIT_GRACE); + drop(api); + result +} + +/// Ends on quit, jump, `shutdown`, or the event stream ending - a closed or +/// failing stream means the pane is gone. The cache is read before the first +/// frame and written on every one of those exits. +async fn event_loop<B, S>( + terminal: &mut Terminal<B>, + api: Arc<dyn Api>, + context: DeskContext, + events: S, + shutdown: impl Future<Output = ()>, +) -> anyhow::Result<DeskExit> +where + B: Backend, + B::Error: Send + Sync + 'static, + S: Stream<Item = io::Result<Event>> + Unpin, +{ + let state_dir = context.state_dir.clone(); + let mut app = App::new(context, Utc::now(), terminal.size()?); + if let Some(dir) = &state_dir { + app.restore(cache::load(dir)); + } + let exit = drive(terminal, api, &mut app, events, shutdown).await; + if let Some(dir) = &state_dir { + cache::save(dir, app.snapshot()); + } + exit +} + +async fn drive<B, S>( + terminal: &mut Terminal<B>, + api: Arc<dyn Api>, + app: &mut App, + mut events: S, + shutdown: impl Future<Output = ()>, +) -> anyhow::Result<DeskExit> +where + B: Backend, + B::Error: Send + Sync + 'static, + S: Stream<Item = io::Result<Event>> + Unpin, +{ + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut runner = Runner::new(api, tx, app.context.state_dir.clone()); + let mut spinner = tokio::time::interval(SPINNER_TICK); + spinner.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + tokio::pin!(shutdown); + let mut effects = app.start(); + loop { + effects.extend(app.relayout()); + for effect in effects.drain(..) { + if let Some(exit) = runner.perform(effect) { + return Ok(exit); + } + } + if app.dirty { + terminal.draw(|frame| ui::render(frame, app))?; + app.dirty = false; + } + effects = tokio::select! { + event = events.next() => match event { + Some(Ok(event)) => { + app.now = Utc::now(); + app.on_event(&event) + } + Some(Err(_)) | None => return Ok(DeskExit::Quit), + }, + Some(msg) = rx.recv() => app.apply(msg), + _ = spinner.tick(), if app.spinner_visible() => { + app.tick(); + Vec::new() + } + () = &mut shutdown => return Ok(DeskExit::Quit), + }; + } +} + +/// Performs effects: one task per lane, a new fetch aborting the old one. +struct Runner { + api: Arc<dyn Api>, + tx: mpsc::UnboundedSender<Msg>, + tasks: [Option<AbortHandle>; Lane::COUNT], + /// Where [`Effect::Log`] lines go; `None` drops them. + state_dir: Option<PathBuf>, +} + +impl Runner { + fn new(api: Arc<dyn Api>, tx: mpsc::UnboundedSender<Msg>, state_dir: Option<PathBuf>) -> Self { + Self { + api, + tx, + tasks: Default::default(), + state_dir, + } + } + + fn abort(&mut self, lane: Lane) { + if let Some(task) = self.tasks[lane as usize].take() { + task.abort(); + } + } + + fn perform(&mut self, effect: Effect) -> Option<DeskExit> { + match effect { + Effect::Exit(exit) => return Some(exit), + Effect::Cancel(lane) => self.abort(lane), + Effect::Log(line) => { + if let Some(dir) = &self.state_dir { + cache::log(dir, &line); + } + } + Effect::Fetch { + generation, + epoch, + delay, + call, + } => { + let lane = call.lane(); + self.abort(lane); + let api = Arc::clone(&self.api); + let tx = self.tx.clone(); + let debounced = tokio::time::Instant::now() + delay; + let task = tokio::spawn(async move { + tokio::time::sleep_until(debounced).await; + let reply = call_api(api.as_ref(), &call).await; + let _ = tx.send(Msg { + generation, + epoch, + call, + reply, + }); + }); + self.tasks[lane as usize] = Some(task.abort_handle()); + } + } + None + } +} + +async fn call_api(api: &dyn Api, call: &Call) -> Reply { + match call.clone() { + Call::Listing(scope) => Reply::Listing(api.list_sessions(scope).await), + Call::Titles(ids) => Reply::Titles(api.titles(ids).await), + Call::Stats(ids) => Reply::Stats(api.stats(ids).await), + Call::Hosts(starts) => Reply::Hosts(api.hosts(starts).await), + Call::Live => Reply::Live(api.live_agents().await), + Call::Search(request) => Reply::Search(api.search(request).await), + Call::Preview(id) => Reply::Preview(api.preview(id).await), + Call::Page { session_id, after } => Reply::Page(api.page(session_id, after).await), + } +} + +#[cfg(test)] +pub(super) mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use std::collections::VecDeque; + use std::pin::Pin; + use std::sync::Mutex; + + use chrono::{DateTime, TimeDelta}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use futures_util::FutureExt; + use futures_util::stream; + use ratatui::backend::TestBackend; + use ratatui::layout::Size; + use serde::de::DeserializeOwned; + + use super::*; + use crate::fake_pond::{Sandbox, golden}; + use crate::types::{ + ApiError, ApiFuture, Cursor, ListingScope, LiveAgent, PAGE_ROWS, PREVIEW_ROWS, + SearchRequest, SearchResponse, SessionHost, SessionRow, SessionStart, SessionStats, + SessionTitle, SqlResponse, TranscriptMessage, TranscriptPage, + }; + + pub(in crate::desk) fn now() -> DateTime<Utc> { + "2026-09-25T05:00:00Z".parse().unwrap() + } + + pub(in crate::desk) fn sql_rows<T: DeserializeOwned>(body: &str) -> Vec<T> { + serde_json::from_str::<SqlResponse>(body) + .unwrap() + .into_rows() + .unwrap() + } + + pub(in crate::desk) fn message(id: &str, ts: DateTime<Utc>, text: &str) -> TranscriptMessage { + TranscriptMessage { + message_id: id.to_owned(), + timestamp: ts, + role: "user".to_owned(), + text: text.to_owned(), + } + } + + /// Canned data behind the real trait: records every call, seeks pages by + /// `(timestamp, message_id)` like the SQL does, and can delay replies. + #[derive(Default)] + pub(in crate::desk) struct MockApi { + pub(in crate::desk) sessions: Vec<SessionRow>, + pub(in crate::desk) titles: Vec<SessionTitle>, + pub(in crate::desk) stats: Vec<SessionStats>, + pub(in crate::desk) hosts: Vec<SessionHost>, + pub(in crate::desk) titles_delay: Duration, + pub(in crate::desk) transcript: Vec<TranscriptMessage>, + pub(in crate::desk) live: Vec<LiveAgent>, + pub(in crate::desk) listing_error: Option<ApiError>, + pub(in crate::desk) search: Option<SearchResponse>, + pub(in crate::desk) search_delay: Option<fn(&str) -> Duration>, + pub(in crate::desk) calls: Mutex<Vec<Call>>, + } + + impl MockApi { + /// The golden listing and hydration, with `s-live` running in pane `p7`. + pub(in crate::desk) fn golden() -> Self { + Self { + sessions: sql_rows(golden::SQL_LISTING), + titles: sql_rows(golden::SQL_TITLES), + stats: sql_rows(golden::SQL_STATS), + hosts: sql_rows(golden::SQL_HOSTS), + transcript: sql_rows(golden::SQL_PAGE), + live: vec![LiveAgent { + pane_id: "p7".to_owned(), + session: "s-live".to_owned(), + }], + ..Self::default() + } + } + + pub(in crate::desk) fn calls(&self) -> Vec<Call> { + self.calls.lock().unwrap().clone() + } + + fn reply<T: Send + 'static>( + &self, + call: Call, + delay: Duration, + result: Result<T, ApiError>, + ) -> ApiFuture<'_, T> { + self.calls.lock().unwrap().push(call); + Box::pin(async move { + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + result + }) + } + } + + impl Api for MockApi { + fn list_sessions(&self, scope: ListingScope) -> ApiFuture<'_, Vec<SessionRow>> { + let result = self + .listing_error + .clone() + .map_or_else(|| Ok(self.sessions.clone()), Err); + self.reply(Call::Listing(scope), Duration::ZERO, result) + } + + fn titles(&self, session_ids: Vec<String>) -> ApiFuture<'_, Vec<SessionTitle>> { + let rows = self + .titles + .iter() + .filter(|row| session_ids.contains(&row.session_id)) + .cloned() + .collect(); + self.reply(Call::Titles(session_ids), self.titles_delay, Ok(rows)) + } + + fn stats(&self, session_ids: Vec<String>) -> ApiFuture<'_, Vec<SessionStats>> { + let rows = self + .stats + .iter() + .filter(|row| session_ids.contains(&row.session_id)) + .cloned() + .collect(); + self.reply(Call::Stats(session_ids), Duration::ZERO, Ok(rows)) + } + + fn hosts(&self, starts: Vec<SessionStart>) -> ApiFuture<'_, Vec<SessionHost>> { + let rows = self + .hosts + .iter() + .filter(|row| { + starts + .iter() + .any(|start| start.session_id == row.session_id) + }) + .cloned() + .collect(); + self.reply(Call::Hosts(starts), Duration::ZERO, Ok(rows)) + } + + fn search(&self, request: SearchRequest) -> ApiFuture<'_, SearchResponse> { + let delay = self + .search_delay + .map_or(Duration::ZERO, |delay| delay(&request.query)); + let response = self.search.clone().unwrap_or_else(|| SearchResponse { + sessions: Vec::new(), + searchable_in_scope: 100, + }); + self.reply(Call::Search(request), delay, Ok(response)) + } + + fn preview(&self, session_id: String) -> ApiFuture<'_, Vec<TranscriptMessage>> { + let newest: Vec<_> = self + .transcript + .iter() + .rev() + .take(PREVIEW_ROWS) + .cloned() + .collect(); + self.reply(Call::Preview(session_id), Duration::ZERO, Ok(newest)) + } + + fn page(&self, session_id: String, after: Option<Cursor>) -> ApiFuture<'_, TranscriptPage> { + let mut sorted = self.transcript.clone(); + sorted.sort_by(|a, b| (a.timestamp, &a.message_id).cmp(&(b.timestamp, &b.message_id))); + let messages = sorted + .into_iter() + .filter(|m| { + after + .as_ref() + .is_none_or(|c| (m.timestamp, &m.message_id) > (c.timestamp, &c.message_id)) + }) + .take(PAGE_ROWS) + .collect(); + let page = TranscriptPage { + messages, + truncated: false, + }; + self.reply(Call::Page { session_id, after }, Duration::ZERO, Ok(page)) + } + + fn live_agents(&self) -> ApiFuture<'_, Vec<LiveAgent>> { + self.reply(Call::Live, Duration::ZERO, Ok(self.live.clone())) + } + } + + pub(in crate::desk) fn context() -> DeskContext { + DeskContext { + project: Some("/home/me/pj/pond".to_owned()), + hostname: Some("devbox".to_owned()), + state_dir: None, + } + } + + pub(in crate::desk) fn app(width: u16, height: u16) -> App { + App::new(context(), now(), Size::new(width, height)) + } + + /// Performs effects synchronously against an undelayed mock, feeding + /// every reply back through `apply` until nothing is left in flight. + pub(in crate::desk) fn settle( + app: &mut App, + api: &MockApi, + effects: Vec<Effect>, + ) -> Option<DeskExit> { + let mut queue = VecDeque::from(effects); + while let Some(effect) = queue.pop_front() { + match effect { + Effect::Fetch { + generation, + epoch, + call, + .. + } => { + let reply = call_api(api, &call).now_or_never().expect("undelayed mock"); + queue.extend(app.apply(Msg { + generation, + epoch, + call, + reply, + })); + } + Effect::Cancel(_) | Effect::Log(_) => {} + Effect::Exit(exit) => return Some(exit), + } + } + None + } + + pub(in crate::desk) fn key(code: KeyCode) -> Event { + Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + pub(in crate::desk) fn press(app: &mut App, api: &MockApi, code: KeyCode) -> Option<DeskExit> { + let effects = app.on_event(&key(code)); + settle(app, api, effects) + } + + pub(in crate::desk) fn screen(app: &mut App) -> String { + app.relayout(); + let mut terminal = + Terminal::new(TestBackend::new(app.size.width, app.size.height)).unwrap(); + terminal.draw(|frame| ui::render(frame, app)).unwrap(); + buffer_text(terminal.backend()) + } + + fn buffer_text(backend: &TestBackend) -> String { + let buffer = backend.buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n") + } + + fn searches(api: &MockApi) -> Vec<String> { + api.calls() + .into_iter() + .filter_map(|call| match call { + Call::Search(request) => Some(request.query), + _ => None, + }) + .collect() + } + + fn perform_all(runner: &mut Runner, effects: Vec<Effect>) { + for effect in effects { + assert_eq!(runner.perform(effect), None); + } + } + + async fn drain(app: &mut App, runner: &mut Runner, rx: &mut mpsc::UnboundedReceiver<Msg>) { + tokio::task::yield_now().await; + while let Ok(msg) = rx.try_recv() { + let effects = app.apply(msg); + perform_all(runner, effects); + tokio::task::yield_now().await; + } + } + + fn type_text(app: &mut App, runner: &mut Runner, text: &str) { + for c in text.chars() { + let effects = app.on_event(&key(KeyCode::Char(c))); + perform_all(runner, effects); + } + } + + #[tokio::test(start_paused = true)] + async fn search_is_debounced() { + let api = Arc::new(MockApi::golden()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut runner = Runner::new(Arc::clone(&api) as Arc<dyn Api>, tx, None); + let mut app = app(100, 20); + let effects = app.start(); + perform_all(&mut runner, effects); + drain(&mut app, &mut runner, &mut rx).await; + + type_text(&mut app, &mut runner, "/tim"); + tokio::time::advance(Duration::from_millis(100)).await; + type_text(&mut app, &mut runner, "er"); + tokio::time::advance(Duration::from_millis(149)).await; + drain(&mut app, &mut runner, &mut rx).await; + assert!( + searches(&api).is_empty(), + "fired inside the debounce window" + ); + + tokio::time::advance(Duration::from_millis(2)).await; + drain(&mut app, &mut runner, &mut rx).await; + assert_eq!(searches(&api), ["timer"]); + assert!(app.search.as_ref().unwrap().response.is_some()); + } + + #[tokio::test(start_paused = true)] + async fn a_slow_search_is_cancelled_by_a_newer_query() { + let api = Arc::new(MockApi { + search_delay: Some(|query| { + if query == "a" { + Duration::from_secs(20) + } else { + Duration::from_millis(10) + } + }), + ..MockApi::golden() + }); + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut runner = Runner::new(Arc::clone(&api) as Arc<dyn Api>, tx, None); + let mut app = app(100, 20); + + type_text(&mut app, &mut runner, "/a"); + tokio::time::advance(Duration::from_millis(200)).await; + tokio::task::yield_now().await; + assert_eq!(searches(&api), ["a"], "the slow request reached the server"); + + type_text(&mut app, &mut runner, "b"); + tokio::time::sleep(Duration::from_secs(30)).await; + let mut delivered = Vec::new(); + tokio::task::yield_now().await; + while let Ok(msg) = rx.try_recv() { + if let Call::Search(request) = &msg.call { + delivered.push(request.query.clone()); + } + app.apply(msg); + } + assert_eq!(searches(&api), ["a", "ab"]); + assert_eq!(delivered, ["ab"], "the aborted request never delivered"); + assert_eq!(app.search.as_ref().unwrap().query, "ab"); + assert!(!app.lane_loading(Lane::Search)); + } + + async fn run_loop( + api: MockApi, + events: impl Stream<Item = io::Result<Event>> + Send + 'static, + shutdown: impl Future<Output = ()>, + ) -> (anyhow::Result<DeskExit>, String) { + run_loop_in(DeskContext::default(), api, events, shutdown).await + } + + async fn run_loop_in( + context: DeskContext, + api: MockApi, + events: impl Stream<Item = io::Result<Event>> + Send + 'static, + shutdown: impl Future<Output = ()>, + ) -> (anyhow::Result<DeskExit>, String) { + let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap(); + let events: Pin<Box<dyn Stream<Item = io::Result<Event>> + Send>> = Box::pin(events); + let exit = event_loop(&mut terminal, Arc::new(api), context, events, shutdown).await; + (exit, buffer_text(terminal.backend())) + } + + #[tokio::test(start_paused = true)] + async fn the_cache_paints_the_next_open_before_pond_answers() { + let sandbox = Sandbox::new(); + let context = DeskContext { + state_dir: Some(sandbox.state_dir()), + ..context() + }; + let quit_later = stream::once(async { + tokio::time::sleep(Duration::from_millis(500)).await; + Ok(key(KeyCode::Char('q'))) + }); + let (exit, _) = run_loop_in( + context.clone(), + MockApi::golden(), + quit_later.chain(stream::pending()), + std::future::pending(), + ) + .await; + assert_eq!(exit.unwrap(), DeskExit::Quit); + + let offline = MockApi { + listing_error: Some(ApiError::Request("timed out".to_owned())), + ..MockApi::default() + }; + let (_, screen) = + run_loop_in(context, offline, stream::empty(), std::future::pending()).await; + assert!(screen.contains("fix the timer re-arm"), "{screen}"); + assert!(screen.contains("ws-pond-01"), "{screen}"); + assert!(screen.contains(" 94 "), "{screen}"); + } + + #[tokio::test] + async fn event_stream_eof_or_error_quits() { + let (exit, screen) = + run_loop(MockApi::golden(), stream::empty(), std::future::pending()).await; + assert_eq!(exit.unwrap(), DeskExit::Quit); + assert!(screen.contains("pond desk")); + + let failing = stream::iter([Err(io::Error::other("tty closed"))]); + let (exit, _) = run_loop(MockApi::golden(), failing, std::future::pending()).await; + assert_eq!(exit.unwrap(), DeskExit::Quit); + } + + #[tokio::test] + async fn shutdown_signal_quits() { + let (exit, _) = run_loop(MockApi::golden(), stream::pending(), async {}).await; + assert_eq!(exit.unwrap(), DeskExit::Quit); + } + + #[tokio::test(start_paused = true)] + async fn enter_on_a_live_row_returns_the_jump() { + let keys = stream::iter([Ok(key(KeyCode::Enter))]).then(|event| async { + tokio::time::sleep(Duration::from_millis(500)).await; + event + }); + let (exit, screen) = run_loop( + MockApi::golden(), + keys.chain(stream::pending()), + std::future::pending(), + ) + .await; + assert_eq!( + exit.unwrap(), + DeskExit::Jump { + pane_id: "p7".to_owned() + } + ); + assert!(screen.contains("fix the timer re-arm"), "{screen}"); + } + + #[test] + fn log_effects_go_to_the_desk_log() { + let sandbox = Sandbox::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + let mut runner = Runner::new(Arc::new(MockApi::default()), tx, Some(sandbox.state_dir())); + assert_eq!( + runner.perform(Effect::Log("titles failed".to_owned())), + None + ); + let log = std::fs::read_to_string(sandbox.state_dir().join("desk.log")).unwrap(); + assert!(log.contains("titles failed"), "{log}"); + } + + #[test] + fn listing_window_is_fourteen_days() { + let mut app = app(100, 20); + let effects = app.start(); + let Some(Effect::Fetch { + call: Call::Listing(scope), + .. + }) = effects.first() + else { + panic!("the desk opens with a listing: {effects:?}"); + }; + assert_eq!(scope.since, Some(now() - TimeDelta::days(14))); + assert_eq!(scope.project.as_deref(), Some("/home/me/pj/pond")); + } +} diff --git a/packages/herdr-pond/src/desk/ui.rs b/packages/herdr-pond/src/desk/ui.rs new file mode 100644 index 00000000..9ceca698 --- /dev/null +++ b/packages/herdr-pond/src/desk/ui.rs @@ -0,0 +1,777 @@ +//! Rendering, plus the text hygiene every transcript string passes through +//! before it reaches a buffer. + +use chrono::{DateTime, Utc}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Clear, HighlightSpacing, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, + ScrollbarState, Wrap, +}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use super::app::{App, Lane}; +use super::cache::{Host, Known}; +use crate::types::{ApiError, LISTING_WINDOW_DAYS, SearchSession, SessionRow, TranscriptMessage}; + +const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const TAB_STOP: usize = 4; +/// The machine column fits the widest name in the listing, between these +/// bounds; a list narrower than [`MACHINE_WIDE_LIST_MIN`] keeps to the +/// narrow cap. +const MACHINE_MIN: usize = 7; +const MACHINE_NARROW: usize = 10; +const MACHINE_WIDE: usize = 16; +const MACHINE_WIDE_LIST_MIN: u16 = 100; +const THIS_MACHINE: &str = "this"; +const UNSTAMPED: &str = "local?"; +const ADAPTER: usize = 12; +const AGE: usize = 4; +const COUNT: usize = 7; +/// Below this body width the preview stacks under the list instead of beside it. +const SIDE_BY_SIDE_MIN_WIDTH: u16 = 100; +const TOAST_MAX_WIDTH: u16 = 60; +/// A toast covers the rows it reports on, so a long one ends in an ellipsis. +const TOAST_MAX_LINES: usize = 3; +/// The preview wraps on every frame, so one huge message must not reach it whole. +pub(super) const PREVIEW_CHARS: usize = 2000; +pub(super) const NO_TITLE: &str = "(no user message)"; +pub(super) const PAGER_FOOTER: &str = "conversation only - tool bodies via pond_sql/get_session"; + +pub(super) struct DeskAreas { + pub(super) header: Rect, + pub(super) input: Rect, + pub(super) columns: Rect, + pub(super) list: Rect, + pub(super) preview: Option<Rect>, + pub(super) footer: Rect, +} + +pub(super) fn desk_areas(area: Rect, preview: bool) -> DeskAreas { + let [header, input, columns, body, footer] = area.layout(&Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ])); + let (list, preview) = if !preview { + (body, None) + } else if body.width >= SIDE_BY_SIDE_MIN_WIDTH { + let [list, preview] = body.layout(&Layout::horizontal([Constraint::Fill(1); 2])); + (list, Some(preview)) + } else { + let [list, preview] = body.layout(&Layout::vertical([Constraint::Fill(1); 2])); + (list, Some(preview)) + }; + DeskAreas { + header, + input, + columns: Rect { + width: list.width, + ..columns + }, + list, + preview, + footer, + } +} + +pub(super) struct PagerAreas { + pub(super) header: Rect, + pub(super) text: Rect, + pub(super) bar: Rect, + pub(super) footer: Rect, +} + +pub(super) fn pager_areas(area: Rect) -> PagerAreas { + let [header, body, footer] = area.layout(&Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ])); + let [text, bar] = body.layout(&Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(1), + ])); + PagerAreas { + header, + text, + bar, + footer, + } +} + +pub(super) fn render(frame: &mut Frame, app: &mut App) { + if let Some(message) = &app.fatal { + render_fatal(frame, message); + } else if app.pager.is_some() { + render_pager(frame, app); + } else { + render_desk(frame, app); + } + if let Some(toast) = &app.toast { + render_toast(frame, toast); + } +} + +fn render_fatal(frame: &mut Frame, message: &str) { + let text = vec![ + Line::from("pond desk cannot load sessions").bold(), + Line::default(), + Line::from(message.to_owned()), + Line::default(), + Line::from("r retry q quit").dim(), + ]; + frame.render_widget( + Paragraph::new(text) + .wrap(Wrap { trim: true }) + .block(Block::bordered().title(" pond desk ")), + frame.area(), + ); +} + +fn render_desk(frame: &mut Frame, app: &mut App) { + let areas = desk_areas(frame.area(), app.preview_open); + frame.render_widget(Paragraph::new(header(app)), areas.header); + render_input(frame, app, areas.input); + let machine = machine_width(app, areas.list.width); + frame.render_widget(Paragraph::new(column_header(machine)).dim(), areas.columns); + render_rows(frame, app, areas.list, machine); + if let Some(preview) = areas.preview { + render_preview(frame, app, preview); + } + frame.render_widget(Paragraph::new(footer(app)), areas.footer); +} + +fn window_label(app: &App) -> String { + if !app.listing_filter.recent { + "all time".to_owned() + } else { + format!("last {LISTING_WINDOW_DAYS} days") + } +} + +/// The typed-search scope in words: the whole corpus unless `p` or `t` +/// narrowed it. +fn search_label(app: &App) -> String { + let scope = app.search_scope(); + let window = scope.since.map_or_else(String::new, |_| { + format!(", last {LISTING_WINDOW_DAYS} days") + }); + match (scope.project.is_some(), scope.since.is_some()) { + (false, false) => "searching everything".to_owned(), + (true, _) => format!("searching this project{window}"), + (false, true) => format!("searching all projects{window}"), + } +} + +fn header(app: &App) -> Line<'static> { + let text = match &app.search { + Some(search) => format!( + " | {} | {} | msgs = matched/whole-session", + search.response.as_ref().map_or_else( + || "searching".to_owned(), + |r| format!("{} sessions match", r.sessions.len()), + ), + search_label(app) + ), + None => format!( + " | {} | {} | {} | msgs = whole-session counts", + app.listing().map_or_else( + || "loading".to_owned(), + |rows| format!("{} sessions", rows.len()), + ), + app.scope().project.as_deref().unwrap_or("all projects"), + window_label(app) + ), + }; + Line::from(vec!["pond desk".bold(), Span::raw(text)]) +} + +fn render_input(frame: &mut Frame, app: &App, area: Rect) { + let [prompt, field] = area.layout(&Layout::horizontal([ + Constraint::Length(2), + Constraint::Fill(1), + ])); + frame.render_widget(Paragraph::new("/ ".bold()), prompt); + if !app.typing && app.input.text.is_empty() { + frame.render_widget( + Paragraph::new("press / to search message content".dim()), + field, + ); + return; + } + let column = app.input.cursor_column(); + let visible = usize::from(field.width).saturating_sub(1); + let skip = column.saturating_sub(visible); + frame.render_widget( + Paragraph::new(app.input.text.clone()).scroll((0, u16::try_from(skip).unwrap_or(u16::MAX))), + field, + ); + if app.typing && field.width > 0 { + let x = u16::try_from(column - skip).unwrap_or(0); + frame.set_cursor_position(Position::new(field.x + x, field.y)); + } +} + +fn column_header(machine: usize) -> String { + format!( + " {} {} {:>AGE$} {:>COUNT$} title", + fit("machine", machine), + fit("adapter", ADAPTER), + "age", + "msgs" + ) +} + +fn render_rows(frame: &mut Frame, app: &mut App, area: Rect, machine: usize) { + let items = match row_items(app, machine) { + Ok(items) => items, + Err(placeholder) => { + frame.render_widget( + Paragraph::new(placeholder.dim()).wrap(Wrap { trim: true }), + area, + ); + return; + } + }; + let list = List::new(items) + .highlight_symbol("> ") + .highlight_spacing(HighlightSpacing::Always) + .highlight_style(Style::new().reversed()) + .scroll_padding(1); + frame.render_stateful_widget(list, area, app.state_mut()); +} + +/// The rows of the current view, or the sentence that stands in for them. +fn row_items(app: &App, machine: usize) -> Result<Vec<ListItem<'static>>, String> { + let project = app + .scope() + .project + .unwrap_or_else(|| "all projects".to_owned()); + if let Some(search) = &app.search { + return match &search.response { + None => Err("searching...".to_owned()), + Some(response) if response.searchable_in_scope == 0 => Err(format!( + "nothing searchable in scope ({}): the filters excluded every message before search ran - p this project/everything, t last {LISTING_WINDOW_DAYS} days/any time", + search_label(app) + )), + Some(response) if response.sessions.is_empty() => Err(format!( + "no matches for \"{}\" among {} searchable messages", + search.query, response.searchable_in_scope + )), + Some(response) => Ok(response + .sessions + .iter() + .map(|session| search_item(app, session, machine)) + .collect()), + }; + } + match app.listing() { + None if app.lane_loading(Lane::Listing) && !app.listing_filter.recent => { + Err("loading the all-time listing - this can take a while".to_owned()) + } + None if app.lane_loading(Lane::Listing) => Err("loading sessions...".to_owned()), + None => Err("no listing loaded - r to retry".to_owned()), + Some([]) => Err(format!( + "no sessions in {} for {project} - p all projects, t all time", + window_label(app) + )), + Some(rows) => Ok(rows + .iter() + .map(|row| listing_item(app, row, machine)) + .collect()), + } +} + +/// The host name without its domain: `beelink-eq14.lan` is `beelink-eq14`. +fn short_host(host: &str) -> &str { + host.split('.').next().unwrap_or(host) +} + +fn machine_label<'a>(app: &'a App, session_id: &str) -> Span<'a> { + let this = |name: &str| { + app.context + .hostname + .as_deref() + .is_some_and(|local| short_host(local).eq_ignore_ascii_case(short_host(name))) + }; + match app.known.get(session_id).and_then(Known::host) { + Some(Host::Stamped(name)) if this(name) => THIS_MACHINE.fg(Color::Cyan), + Some(Host::Stamped(name)) => Span::raw(short_host(name)), + Some(Host::Unstamped) => UNSTAMPED.dim(), + None => Span::raw(""), + } +} + +fn machine_width(app: &App, list_width: u16) -> usize { + let cap = if list_width >= MACHINE_WIDE_LIST_MIN { + MACHINE_WIDE + } else { + MACHINE_NARROW + }; + let width = |id: &str| machine_label(app, id).width(); + let widest = match &app.search { + Some(search) => search + .response + .iter() + .flat_map(|response| &response.sessions) + .map(|session| width(&session.session_id)) + .max(), + None => app + .listing() + .unwrap_or_default() + .iter() + .map(|row| width(&row.session_id)) + .max(), + }; + widest.unwrap_or(0).clamp(MACHINE_MIN, cap) +} + +fn machine(app: &App, session_id: &str, width: usize) -> Span<'static> { + let label = machine_label(app, session_id); + Span::styled(fit(&label.content, width), label.style) +} + +fn glyph(app: &App, session_id: &str) -> Span<'static> { + if app.live_agent(session_id).is_some() { + "● ".fg(Color::Green) + } else { + Span::raw(" ") + } +} + +fn listing_item(app: &App, row: &SessionRow, machine_width: usize) -> ListItem<'static> { + let known = app.known.get(&row.session_id); + let count = known + .and_then(|known| known.count()) + .map_or_else(String::new, |count| count.to_string()); + let title = match known.and_then(|known| known.title()) { + Some(Some(title)) => Span::raw(one_line(title)), + Some(None) => NO_TITLE.dim(), + None => "...".dim(), + }; + ListItem::new(Line::from(vec![ + glyph(app, &row.session_id), + machine(app, &row.session_id, machine_width), + Span::raw(format!( + " {} {:>AGE$} {:>COUNT$} ", + fit(&row.source_agent, ADAPTER), + age(app.now, row.last_ts), + count + )), + title, + ])) +} + +fn search_item(app: &App, session: &SearchSession, machine_width: usize) -> ListItem<'static> { + let newest = session.matches.iter().map(|m| m.timestamp).max(); + let snippet = session + .matches + .first() + .map_or_else(String::new, |m| one_line(&m.text)); + let count = format!( + "{}/{}", + session.matched_message_count, session.session_messages_count + ); + ListItem::new(Line::from(vec![ + glyph(app, &session.session_id), + machine(app, &session.session_id, machine_width), + Span::raw(format!( + " {} {:>AGE$} {:>COUNT$} {snippet}", + fit(&session.source_agent, ADAPTER), + newest.map_or_else(String::new, |ts| age(app.now, ts)), + count + )), + ])) +} + +fn render_preview(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::bordered().title(" preview - newest first "); + let lines = match app.selected_id() { + None => vec![Line::from("nothing selected".dim())], + Some(id) => match app.previews.get(id) { + None => vec![Line::from("loading preview...".dim())], + Some(messages) if messages.is_empty() => { + vec![Line::from("(no conversational messages)".dim())] + } + Some(messages) => messages + .iter() + .flat_map(|message| { + let mut lines = vec![Line::from(vec![ + Span::styled(message.role.clone(), role_style(&message.role)), + Span::raw(format!(" {} ago", age(app.now, message.timestamp))).dim(), + ])]; + lines.extend(message.text.split('\n').map(|l| Line::raw(l.to_owned()))); + lines.push(Line::default()); + lines + }) + .collect(), + }, + }; + frame.render_widget( + Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn footer(app: &App) -> Line<'static> { + let help = if app.typing { + "enter done esc clear up/down select".to_owned() + } else if app.search.is_some() { + format!( + "/ edit esc back enter open space preview p project/everything \ + t {LISTING_WINDOW_DAYS} days/any q quit" + ) + } else { + "/ search enter open space preview p projects t time r refresh q quit".to_owned() + }; + let mut spans = Vec::new(); + if app.spinner_visible() { + spans.push(Span::raw(format!("{} ", spinner_frame(app))).fg(Color::Yellow)); + } + spans.push(Span::raw(help).dim()); + Line::from(spans) +} + +fn spinner_frame(app: &App) -> &'static str { + SPINNER[app.spinner % SPINNER.len()] +} + +fn render_pager(frame: &mut Frame, app: &App) { + let Some(pager) = &app.pager else { + return; + }; + let areas = pager_areas(frame.area()); + let title = match app.known.get(&pager.session_id).and_then(Known::title) { + Some(Some(title)) => Span::raw(one_line(title)).bold(), + Some(None) => NO_TITLE.dim(), + None => "...".dim(), + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + title, + Span::raw(format!(" | {}", pager.session_id)).dim(), + ])), + areas.header, + ); + let height = usize::from(areas.text.height); + if pager.is_empty() { + let text = if pager.eof { + "(no conversational messages)" + } else { + "loading transcript..." + }; + frame.render_widget(Paragraph::new(text.dim()), areas.text); + } else { + let end = (pager.offset + height).min(pager.lines.len()); + let start = pager.offset.min(end); + frame.render_widget(Paragraph::new(pager.lines[start..end].to_vec()), areas.text); + let mut state = + ScrollbarState::new(pager.lines.len().saturating_sub(height)).position(pager.offset); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + areas.bar, + &mut state, + ); + } + let status = if app.lane_loading(Lane::Page) { + format!("{} loading", spinner_frame(app)) + } else if pager.eof { + "end".to_owned() + } else { + "more below".to_owned() + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(PAGER_FOOTER).dim(), + Span::raw(format!( + " | {status} | line {}/{} | q back", + (pager.offset + 1).min(pager.lines.len()), + pager.lines.len() + )), + ])), + areas.footer, + ); +} + +/// An error as a toast shows it: pond's envelope message only up to its +/// first clause, since the rest is recovery advice written for agents. +pub(super) fn error_text(error: &ApiError) -> String { + match error { + ApiError::Pond { code, message } => { + let end = [message.find(';'), message.find(". ")] + .into_iter() + .flatten() + .min() + .unwrap_or(message.len()); + format!("pond {code}: {}", &message[..end]) + } + _ => error.to_string(), + } +} + +fn render_toast(frame: &mut Frame, text: &str) { + let area = frame.area(); + let width = area.width.min(TOAST_MAX_WIDTH); + let inner = usize::from(width.saturating_sub(2)).max(1); + let mut lines: Vec<String> = textwrap::wrap(text, inner) + .into_iter() + .map(std::borrow::Cow::into_owned) + .collect(); + if lines.len() > TOAST_MAX_LINES { + lines.truncate(TOAST_MAX_LINES); + if let Some(last) = lines.last_mut() { + if last.width() >= inner { + last.pop(); + } + last.push('…'); + } + } + let height = u16::try_from(lines.len()) + .unwrap_or(u16::MAX) + .saturating_add(2) + .min(area.height); + let toast = Rect { + x: area.right() - width, + y: area.y + area.height.saturating_sub(height + 1), + width, + height, + }; + frame.render_widget(Clear, toast); + frame.render_widget( + Paragraph::new(lines.into_iter().map(Line::from).collect::<Vec<_>>()) + .block(Block::bordered().title(" esc dismiss ").fg(Color::Red)), + toast, + ); +} + +fn role_style(role: &str) -> Style { + match role { + "user" => Style::new().fg(Color::Cyan).bold(), + "assistant" => Style::new().fg(Color::Green).bold(), + _ => Style::new().bold(), + } +} + +/// A transcript message as pre-wrapped lines: a role header, one or more +/// lines per source line, and a blank separator. +pub(super) fn message_lines(message: &TranscriptMessage, width: usize) -> Vec<Line<'static>> { + let mut lines = vec![Line::from(vec![ + Span::styled(message.role.clone(), role_style(&message.role)), + Span::raw(format!( + " {}", + message.timestamp.format("%Y-%m-%d %H:%M:%S UTC") + )) + .dim(), + ])]; + let options = + textwrap::Options::new(width.max(1)).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit); + for source in sanitize(&message.text).split('\n') { + if source.is_empty() { + lines.push(Line::default()); + } else { + lines.extend( + textwrap::wrap(source, &options) + .into_iter() + .map(|piece| Line::raw(piece.into_owned())), + ); + } + } + lines.push(Line::default()); + lines +} + +/// Ratatui drops control characters but keeps the rest of an escape sequence +/// (`[31m` would render as text), so escapes go whole: CSI and OSC sequences, +/// two-byte escapes, `\r`, and every other control except `\n`. Tabs expand +/// to spaces. +pub(super) fn sanitize(text: &str) -> String { + enum State { + Text, + Escape, + Csi, + Osc, + OscEscape, + } + let mut out = String::with_capacity(text.len()); + let mut state = State::Text; + let mut column = 0; + for c in text.chars() { + state = match state { + State::Text => match c { + '\u{1b}' => State::Escape, + '\u{9b}' => State::Csi, + '\u{9d}' => State::Osc, + '\n' => { + out.push('\n'); + column = 0; + State::Text + } + '\t' => { + let pad = TAB_STOP - column % TAB_STOP; + out.extend(std::iter::repeat_n(' ', pad)); + column += pad; + State::Text + } + c if c.is_control() => State::Text, + c => { + out.push(c); + column += c.width().unwrap_or(0); + State::Text + } + }, + State::Escape => match c { + '[' => State::Csi, + ']' => State::Osc, + ' '..='/' => State::Escape, + _ => State::Text, + }, + State::Csi => match c { + '@'..='~' => State::Text, + '\n' => { + out.push('\n'); + column = 0; + State::Text + } + _ => State::Csi, + }, + State::Osc => match c { + '\u{7}' | '\u{9c}' => State::Text, + '\u{1b}' => State::OscEscape, + _ => State::Osc, + }, + State::OscEscape if c == '\\' => State::Text, + State::OscEscape => State::Osc, + }; + } + out +} + +/// A preview message's text as the cache keeps it: clipped, then sanitized. +pub(super) fn preview_text(text: &str) -> String { + sanitize(&text.chars().take(PREVIEW_CHARS).collect::<String>()) +} + +/// A clean single line: sanitized, whitespace runs collapsed. +pub(super) fn one_line(text: &str) -> String { + sanitize(text) + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") +} + +/// Exactly `width` cells: padded, or cut with an ellipsis. +pub(super) fn fit(text: &str, width: usize) -> String { + let used = text.width(); + if used <= width { + return format!("{text}{}", " ".repeat(width - used)); + } + let mut out = String::new(); + let mut used = 0; + for c in text.chars() { + let w = c.width().unwrap_or(0); + if used + w + 1 > width { + break; + } + out.push(c); + used += w; + } + if width > 0 { + out.push('…'); + used += 1; + } + out.push_str(&" ".repeat(width.saturating_sub(used))); + out +} + +pub(super) fn age(now: DateTime<Utc>, then: DateTime<Utc>) -> String { + let seconds = (now - then).num_seconds().max(0); + match seconds { + s if s < 60 => format!("{s}s"), + s if s < 3_600 => format!("{}m", s / 60), + s if s < 86_400 => format!("{}h", s / 3_600), + s if s < 14 * 86_400 => format!("{}d", s / 86_400), + s if s < 365 * 86_400 => format!("{}w", s / (7 * 86_400)), + s => format!("{}y", s / (365 * 86_400)), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use chrono::TimeDelta; + + use super::*; + use crate::desk::tests::{message, now}; + + #[test] + fn sanitize_strips_escapes_and_carriage_returns() { + assert_eq!(sanitize("a\r\nb"), "a\nb"); + assert_eq!(sanitize("\u{1b}[1;31mred\u{1b}[0m"), "red"); + assert_eq!(sanitize("\u{1b}]0;title\u{7}x"), "x"); + assert_eq!( + sanitize("\u{1b}]8;;http://x\u{1b}\\link\u{1b}]8;;\u{1b}\\"), + "link" + ); + assert_eq!(sanitize("\u{1b}(Bplain\u{1b}=k"), "plaink"); + assert_eq!(sanitize("\u{9b}2Jc1"), "c1"); + assert_eq!(sanitize("bell\u{7} nul\u{0}"), "bell nul"); + assert_eq!(sanitize("\u{1b}[31\nnext"), "\nnext"); + } + + #[test] + fn sanitize_expands_tabs_by_cell_width() { + assert_eq!(sanitize("\tx"), " x"); + assert_eq!(sanitize("ab\tx"), "ab x"); + assert_eq!(sanitize("日\tx"), "日 x"); + assert_eq!(sanitize("abcd\tx\n\ty"), "abcd x\n y"); + } + + #[test] + fn fit_pads_and_cuts_by_cells() { + assert_eq!(fit("ab", 4), "ab "); + assert_eq!(fit("abcdef", 4), "abc…"); + assert_eq!(fit("日本語", 4), "日… "); + assert_eq!(fit("anything", 0), ""); + assert_eq!(fit("日本語", 1).width(), 1); + } + + #[test] + fn age_is_compact() { + let now = now(); + assert_eq!(age(now, now - TimeDelta::seconds(5)), "5s"); + assert_eq!(age(now, now - TimeDelta::minutes(90)), "1h"); + assert_eq!(age(now, now - TimeDelta::days(3)), "3d"); + assert_eq!(age(now, now - TimeDelta::days(30)), "4w"); + assert_eq!(age(now, now + TimeDelta::minutes(1)), "0s"); + } + + #[test] + fn message_lines_are_one_line_per_wrapped_source_line() { + let lines = message_lines(&message("m", now(), "one two three\n\nfour"), 8); + let text: Vec<String> = lines.iter().map(ToString::to_string).collect(); + assert_eq!( + text, + [ + "user 2026-09-25 05:00:00 UTC", + "one two", + "three", + "", + "four", + "", + ] + ); + assert!( + lines + .iter() + .all(|line| line.width() <= 8 || line == &lines[0]) + ); + } +} diff --git a/packages/herdr-pond/src/fake_pond.rs b/packages/herdr-pond/src/fake_pond.rs new file mode 100644 index 00000000..64e718c5 --- /dev/null +++ b/packages/herdr-pond/src/fake_pond.rs @@ -0,0 +1,404 @@ +//! A canned-response stand-in for `pond serve --socket`, so the HTTP client +//! is tested against real bytes on a real Unix socket - trait mocks alone +//! would let the client's serialization drift while every test stays green. +//! Also the sandbox dirs and fake `pond`/`herdr` scripts the shell-level +//! tests run. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use nix::sys::signal::kill; +use nix::unistd::Pid; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{UnixListener, UnixStream}; + +use crate::api::{SEARCH_PATH, SQL_PATH, Socket}; +use crate::config::CONFIG_FILE; +use crate::serve::{Endpoint, Origin, ServeDir}; + +static NEXT: AtomicUsize = AtomicUsize::new(0); + +/// A fresh path under the temp dir: short, since a socket path is capped +/// near 100 bytes. +fn temp_path(kind: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "herdr-pond-{kind}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )) +} + +/// Golden bodies: the frozen `/v1/x/sql` and `/v1/search` contract. +pub(crate) mod golden { + pub(crate) const SQL_READY: &str = r#"{"columns":["ready"],"rows":[{"ready":1}],"row_count":1,"truncated":false,"elapsed_ms":1}"#; + + pub(crate) const SQL_LISTING: &str = r#"{"columns":["session_id","last_ts","first_ts","message_count","source_agent","project"],"rows":[ + {"session_id":"s-live","last_ts":"2026-09-25T04:00:02.384123Z","first_ts":"2026-09-24T21:10:00.000000Z","message_count":94,"source_agent":"claude-code","project":"/home/me/pj/pond"}, + {"session_id":"s-old","last_ts":"2026-09-23T19:29:20.100000Z","first_ts":"2026-09-23T19:20:00.000000Z","message_count":3,"source_agent":"codex-cli","project":"/home/me/pj/pond/packages/pond"} + ],"row_count":2,"truncated":false,"elapsed_ms":1712}"#; + + /// `s-old` has no user message, so it has no row. + pub(crate) const SQL_TITLES: &str = r#"{"columns":["session_id","title"],"rows":[ + {"session_id":"s-live","title":"fix the timer re-arm"} + ],"row_count":1,"truncated":false,"elapsed_ms":910}"#; + + pub(crate) const SQL_STATS: &str = r#"{"columns":["session_id","message_count","first_ts","last_ts"],"rows":[ + {"session_id":"s-live","message_count":94,"first_ts":"2026-09-24T21:10:00.000000Z","last_ts":"2026-09-25T04:00:02.384123Z"}, + {"session_id":"s-old","message_count":3,"first_ts":"2026-09-23T19:20:00.000000Z","last_ts":"2026-09-23T19:29:20.100000Z"} + ],"row_count":2,"truncated":false,"elapsed_ms":380}"#; + + /// Nulls are omitted: `s-old`'s first message carries no host stamp. + pub(crate) const SQL_HOSTS: &str = r#"{"columns":["session_id","host"],"rows":[ + {"session_id":"s-live","host":"ws-pond-01.lan"}, + {"session_id":"s-old"} + ],"row_count":2,"truncated":false,"elapsed_ms":760}"#; + + /// Two rows share a timestamp: the pager must order and seek on the pair. + pub(crate) const SQL_PAGE: &str = r#"{"columns":["message_id","timestamp","role","search_text"],"rows":[ + {"message_id":"m-a","timestamp":"2026-09-22T14:24:45.991000Z","role":"user","search_text":"check open issues\r\n\u001b[31mred\u001b[0m\tdone"}, + {"message_id":"m-b","timestamp":"2026-09-22T14:24:45.991000Z","role":"assistant","search_text":"I'll check the open issues."} + ],"row_count":2,"truncated":false,"elapsed_ms":270}"#; + + pub(crate) const SQL_EMPTY: &str = r#"{"columns":["message_id","timestamp","role","search_text"],"rows":[],"row_count":0,"truncated":false,"elapsed_ms":90}"#; + + pub(crate) const SQL_ERROR: &str = r#"{"error":{"code":"validation_failed","message":"sql error: query exceeded the 30s limit; add a narrower WHERE or a LIMIT, or raise timeout_seconds","details":{}}}"#; + + pub(crate) const SEARCH: &str = r#"{"sessions":[{"session_id":"s-live","project":"/home/me/pj/pond","source_agent":"claude-code","session_messages_count":94,"matched_message_count":2,"matches":[ + {"message_id":"m-a","role":"user","timestamp":"2026-09-22T14:24:45.991Z","text":"the systemd timer stops re-arming","score":7.25}, + {"message_id":"m-c","role":"assistant","timestamp":"2026-09-22T14:30:00Z","text":"timer fixed","score":3.5,"parts_summary":[{"kind":"text"}]} + ]}],"matched_total":2,"searchable_in_scope":4120,"has_more":false}"#; + + pub(crate) const SEARCH_OUT_OF_SCOPE: &str = + r#"{"sessions":[],"matched_total":0,"searchable_in_scope":0,"has_more":false}"#; + + /// What axum sends for a body it cannot parse: plain text, not an envelope. + pub(crate) const AXUM_REJECTION: &str = + "Failed to deserialize the JSON body into the target type: missing field `query`"; +} + +/// One canned reply, chosen per request by [`FakePond`]'s router closure. +#[derive(Debug, Clone)] +pub(crate) struct Reply { + status: u16, + body: String, + content_type: &'static str, + delay: Duration, +} + +impl Reply { + pub(crate) fn json(body: &str) -> Self { + Self::status(200, body) + } + + pub(crate) fn status(status: u16, body: &str) -> Self { + Self { + status, + body: body.to_owned(), + content_type: "application/json", + delay: Duration::ZERO, + } + } + + pub(crate) fn plain(status: u16, body: &str) -> Self { + Self { + content_type: "text/plain; charset=utf-8", + ..Self::status(status, body) + } + } + + pub(crate) fn delayed(self, delay: Duration) -> Self { + Self { delay, ..self } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Recorded { + pub path: String, + pub host: String, + pub body: String, +} + +type Router = dyn Fn(&str, &str) -> Reply + Send + Sync; + +/// An HTTP/1.1 server on a Unix socket answering each request through +/// `router(path, body)`. Every request is recorded, so tests can assert on +/// the SQL sent. +pub(crate) struct FakePond { + pub socket: PathBuf, + requests: Arc<Mutex<Vec<Recorded>>>, + task: tokio::task::JoinHandle<()>, +} + +impl FakePond { + pub(crate) async fn start( + router: impl Fn(&str, &str) -> Reply + Send + Sync + 'static, + ) -> Self { + let socket = temp_path("fake"); + let listener = UnixListener::bind(&socket).unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let router: Arc<Router> = Arc::new(router); + let recorded = Arc::clone(&requests); + let task = tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + tokio::spawn(serve_one( + stream, + Arc::clone(&router), + Arc::clone(&recorded), + )); + } + }); + Self { + socket, + requests, + task, + } + } + + /// Routes `/v1/x/sql` by a substring of the SQL and `/v1/search` to one + /// body; anything else is a 404 like an old pond. + pub(crate) async fn with_sql(routes: Vec<(&'static str, Reply)>, search: Reply) -> Self { + Self::start(move |path, body| match path { + SQL_PATH => routes + .iter() + .find(|(needle, _)| body.contains(needle)) + .map_or_else( + || Reply::status(400, golden::SQL_ERROR), + |(_, reply)| reply.clone(), + ), + SEARCH_PATH => search.clone(), + _ => Reply::plain(404, ""), + }) + .await + } + + pub(crate) fn recorded(&self) -> Vec<Recorded> { + self.requests.lock().unwrap().clone() + } + + pub(crate) fn connect(&self) -> Socket { + Socket::new(self.socket.clone()).unwrap() + } +} + +impl Drop for FakePond { + fn drop(&mut self) { + self.task.abort(); + let _ = std::fs::remove_file(&self.socket); + } +} + +async fn serve_one( + mut stream: UnixStream, + router: Arc<Router>, + recorded: Arc<Mutex<Vec<Recorded>>>, +) { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 8192]; + let (head_end, content_length) = loop { + let Ok(read) = stream.read(&mut chunk).await else { + return; + }; + if read == 0 { + return; + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&buffer[..end]).to_ascii_lowercase(); + let length = head + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .and_then(|value| value.trim().parse::<usize>().ok()) + .unwrap_or(0); + break (end + 4, length); + } + }; + while buffer.len() < head_end + content_length { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(read) => buffer.extend_from_slice(&chunk[..read]), + } + } + let head = String::from_utf8_lossy(&buffer[..head_end]).into_owned(); + let path = head + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_owned(); + let host = head + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("host:") + .map(str::to_owned) + }) + .unwrap_or_default() + .trim() + .to_owned(); + let body = String::from_utf8_lossy(&buffer[head_end..head_end + content_length]).into_owned(); + let reply = router(&path, &body); + recorded.lock().unwrap().push(Recorded { path, host, body }); + tokio::time::sleep(reply.delay).await; + let response = format!( + "HTTP/1.1 {} X\r\ncontent-type: {}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + reply.status, + reply.content_type, + reply.body.len(), + reply.body + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; +} + +/// A throwaway directory standing in for the plugin's config and state dirs, +/// removed on drop. +pub(crate) struct Sandbox { + root: PathBuf, +} + +impl Sandbox { + pub(crate) fn new() -> Self { + let root = temp_path("test"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + Self { root } + } + + pub(crate) fn path(&self, relative: &str) -> PathBuf { + self.root.join(relative) + } + + pub(crate) fn config_dir(&self) -> PathBuf { + self.path("config") + } + + pub(crate) fn state_dir(&self) -> PathBuf { + self.path("state") + } + + pub(crate) fn write_config(&self, text: &str) { + std::fs::create_dir_all(self.config_dir()).unwrap(); + std::fs::write(self.config_dir().join(CONFIG_FILE), text).unwrap(); + } + + pub(crate) fn origin(&self) -> Origin { + Origin { + dir: ServeDir::new(&self.state_dir(), &self.path("herdr.sock")), + config_dir: self.config_dir(), + } + } + + /// The lines of a sandbox file; a missing file has none. + pub(crate) fn lines(&self, relative: &str) -> Vec<String> { + std::fs::read_to_string(self.path(relative)) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + + /// A fake `pond serve` at `bin/pond`, set as `pond_bin`: records its argv + /// in `calls` and its pid in `pid`, prints to both streams, takes the + /// `<socket>.lock` pond keeps beside its socket (refusing while a live + /// pid holds it, as pond's lifetime lock does), and when + /// given a `target` socket answers at its `--socket` path through a + /// symlink to it (connect follows symlinks), then runs `after`. + pub(crate) fn fake_serve(&self, target: Option<&Path>, after: &str) -> PathBuf { + let publish = target.map_or_else(String::new, |target| { + format!(r#"ln -s '{}' "$socket""#, target.display()) + }); + let pond = write_script( + &self.path("bin/pond"), + &format!( + r#"printf '%s\n' "$*" >> '{calls}' +echo $$ > '{pid}' +echo "serve stdout"; echo "serve stderr" >&2 +eval "socket=\${{$#}}" +if [ -s "$socket.lock" ] && kill -0 "$(cat "$socket.lock")" 2>/dev/null; then + echo "error: --socket $socket: another pond serve owns this path" >&2; exit 1 +fi +echo $$ > "$socket.lock" +{publish} +{after}"#, + calls = self.path("calls").display(), + pid = self.path("pid").display(), + ), + ); + self.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); + pond + } + + /// The pid [`Self::fake_serve`] recorded. + pub(crate) fn serve_pid(&self) -> u32 { + self.lines("pid")[0].parse().unwrap() + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +pub(crate) fn ts(raw: &str) -> DateTime<Utc> { + raw.parse().unwrap() +} + +pub(crate) fn alive(pid: u32) -> bool { + kill(Pid::from_raw(i32::try_from(pid).unwrap()), None).is_ok() +} + +/// A socket path with nothing at it (connect fails with ENOENT). +pub(crate) fn missing_socket() -> Socket { + Socket::new(temp_path("missing")).unwrap() +} + +/// A socket file left by a listener that is gone (connect fails with +/// ECONNREFUSED), as a SIGKILLed serve leaves it. +pub(crate) fn stale_socket(path: &Path) -> Socket { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + drop(std::os::unix::net::UnixListener::bind(path).unwrap()); + Socket::new(path.to_path_buf()).unwrap() +} + +/// A record naming pid 0, which no owner ever signals. +pub(crate) fn endpoint(socket: &Path, token: &str) -> Endpoint { + Endpoint { + socket: socket.to_path_buf(), + token: token.to_owned(), + pid: 0, + } +} + +/// Writes an executable `/bin/sh` script, the stand-in for `pond` or `herdr`. +/// A child another test forks while the script is open for writing holds +/// that fd until it execs, and exec fails with ETXTBSY meanwhile - so the +/// script is dry-run (it exits at once under [`DRY_RUN`]) until it execs. +pub(crate) fn write_script(path: &Path, body: &str) -> PathBuf { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + format!("#!/bin/sh\n[ -z \"${DRY_RUN}\" ] || exit 0\n{body}\n"), + ) + .unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + for _ in 0..100 { + match std::process::Command::new(path).env(DRY_RUN, "1").status() { + Err(error) if error.raw_os_error() == Some(nix::libc::ETXTBSY) => { + std::thread::sleep(Duration::from_millis(10)); + } + status => { + assert!(status.unwrap().success()); + break; + } + } + } + path.to_path_buf() +} + +const DRY_RUN: &str = "HERDR_POND_TEST_DRY_RUN"; diff --git a/packages/herdr-pond/src/herdr.rs b/packages/herdr-pond/src/herdr.rs new file mode 100644 index 00000000..be1fa48c --- /dev/null +++ b/packages/herdr-pond/src/herdr.rs @@ -0,0 +1,425 @@ +//! Every herdr CLI call (`pane list`, `agent focus`, `plugin pane open|focus`, +//! `notification show`) and the plugin runtime env. + +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, bail}; +use serde::Deserialize; + +use crate::config::{Config, log_line, log_stdio}; +use crate::types::LiveAgent; + +const PLUGIN_ID: &str = "pond"; +const DESK_ENTRYPOINT: &str = "desk"; +/// The manifest pane title, which herdr uses as the pane label. +const DESK_LABEL: &str = "pond desk"; +/// herdr answers in milliseconds; a hung CLI must not hang a hook or the desk. +const CALL_DEADLINE: Duration = Duration::from_secs(3); +const CALL_POLL: Duration = Duration::from_millis(5); + +/// A plugin-runtime path herdr sets for every plugin process. +fn plugin_env(var: &str) -> anyhow::Result<PathBuf> { + std::env::var_os(var) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .with_context(|| format!("{var} is unset - herdr-pond runs only as a herdr plugin")) +} + +pub(crate) fn state_dir() -> anyhow::Result<PathBuf> { + plugin_env("HERDR_PLUGIN_STATE_DIR") +} + +pub(crate) fn config_dir() -> anyhow::Result<PathBuf> { + plugin_env("HERDR_PLUGIN_CONFIG_DIR") +} + +pub(crate) fn socket_path() -> anyhow::Result<PathBuf> { + plugin_env("HERDR_SOCKET_PATH") +} + +/// The desk's project: the underlying pane's cwd, else the workspace's. +pub(crate) fn context_project() -> Option<String> { + project_from_context(&std::env::var("HERDR_PLUGIN_CONTEXT_JSON").ok()?) +} + +fn project_from_context(json: &str) -> Option<String> { + #[derive(Deserialize)] + struct Context { + focused_pane_cwd: Option<String>, + workspace_cwd: Option<String>, + } + let context: Context = serde_json::from_str(json).ok()?; + [context.focused_pane_cwd, context.workspace_cwd] + .into_iter() + .flatten() + .find(|cwd| !cwd.is_empty()) +} + +/// Spawns `command` so it holds no herdr command slot: herdr reads a plugin +/// command's stdout/stderr to EOF before releasing its slot, so every stdio +/// end goes to /dev/null or `log`. The child calls [`detach`] itself - a +/// pre-exec `setsid` would need `unsafe`. +pub(crate) fn spawn_detached(mut command: Command, log: &Path) -> anyhow::Result<()> { + log_stdio(&mut command, log) + .with_context(|| format!("opening {}", log.display()))? + .spawn() + .with_context(|| format!("spawning {:?}", command.get_program()))?; + Ok(()) +} + +/// Leaves herdr's session, so a detached leg outlives the hook that spawned it. +pub(crate) fn detach() { + let _ = nix::unistd::setsid(); +} + +/// Resolves `pond` for a headless leg. A failure is logged and toasted once; +/// the toast re-arms after the next successful resolution. +pub(crate) fn resolve_pond_or_toast( + config_dir: &Path, + state_dir: &Path, + log: &Path, +) -> Option<PathBuf> { + let marker = state_dir.join("pond-missing.toasted"); + match Config::pond(config_dir, log) { + Ok(pond) => { + let _ = fs::remove_file(&marker); + Some(pond) + } + Err(error) => { + log_line(log, &format!("{error:#}")); + if !marker.exists() && fs::write(&marker, b"").is_ok() { + let _ = Herdr::from_env().notify("pond: cannot find pond", &format!("{error:#}")); + } + None + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct Pane { + pub pane_id: String, + #[serde(default)] + pub label: Option<String>, + #[serde(default)] + pub agent_session: Option<AgentSession>, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct AgentSession { + pub value: String, +} + +/// Only panes whose agent reported a session identity can be matched to a +/// pond session; the rest are not live rows. +pub(crate) fn live_agents(panes: Vec<Pane>) -> Vec<LiveAgent> { + panes + .into_iter() + .filter_map(|pane| { + Some(LiveAgent { + session: pane.agent_session?.value, + pane_id: pane.pane_id, + }) + }) + .collect() +} + +#[derive(Debug, Clone)] +pub(crate) struct Herdr { + bin: PathBuf, + deadline: Duration, +} + +impl Herdr { + pub(crate) fn from_env() -> Self { + Self::new(plugin_env("HERDR_BIN_PATH").unwrap_or_else(|_| PathBuf::from("herdr"))) + } + + pub(crate) fn new(bin: PathBuf) -> Self { + Self { + bin, + deadline: CALL_DEADLINE, + } + } + + /// Runs one CLI call, bounded by the deadline end to end, and returns its + /// `result` object. herdr reports errors on stderr with a nonzero exit, + /// never in the stdout JSON. + fn call(&self, args: &[&str]) -> anyhow::Result<serde_json::Value> { + let command = args.iter().take(3).copied().collect::<Vec<_>>().join(" "); + let mut child = Command::new(&self.bin) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("running {}", self.bin.display()))?; + let stdout = drain(child.stdout.take()); + let stderr = drain(child.stderr.take()); + let started = Instant::now(); + let status = loop { + let failure = match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if started.elapsed() > self.deadline => { + format!("timed out after {:?}", self.deadline) + } + Ok(None) => { + std::thread::sleep(CALL_POLL); + continue; + } + Err(error) => format!("could not be waited on: {error}"), + }; + let _ = child.kill(); + let _ = child.wait(); + bail!("herdr {command} {failure}"); + }; + // A process herdr left behind can hold the pipes open past its exit; + // its reader thread is then abandoned rather than joined. + let drained = |pipe: Receiver<Vec<u8>>| { + pipe.recv_timeout(self.deadline.saturating_sub(started.elapsed())) + }; + if !status.success() { + let stderr = drained(stderr).unwrap_or_default(); + bail!( + "herdr {command} failed ({status}): {}", + String::from_utf8_lossy(&stderr).trim() + ); + } + let stdout = drained(stdout).map_err(|_| { + anyhow::anyhow!( + "herdr {command} exited but its output stayed open past {:?}", + self.deadline + ) + })?; + let mut response: serde_json::Value = serde_json::from_slice(&stdout) + .with_context(|| format!("herdr {command} printed no JSON response"))?; + Ok(response["result"].take()) + } + + pub(crate) fn pane_list(&self, workspace: Option<&str>) -> anyhow::Result<Vec<Pane>> { + let mut args = vec!["pane", "list"]; + if let Some(workspace) = workspace { + args.extend(["--workspace", workspace]); + } + let mut result = self.call(&args)?; + serde_json::from_value(result["panes"].take()).context("herdr pane list: unexpected panes") + } + + pub(crate) fn agent_focus(&self, pane_id: &str) -> anyhow::Result<()> { + self.call(&["agent", "focus", pane_id]).map(drop) + } + + pub(crate) fn notify(&self, title: &str, body: &str) -> anyhow::Result<()> { + self.call(&["notification", "show", title, "--body", body]) + .map(drop) + } + + /// Focuses this workspace's open desk, else opens one. Any failure to find + /// or focus an existing desk degrades to opening a new one. + pub(crate) fn open_desk(&self, workspace: Option<&str>) -> anyhow::Result<()> { + let existing = self.pane_list(workspace).ok().and_then(|panes| { + panes + .into_iter() + .find(|pane| pane.label.as_deref() == Some(DESK_LABEL)) + }); + if let Some(pane) = existing + && self + .call(&["plugin", "pane", "focus", &pane.pane_id]) + .is_ok() + { + return Ok(()); + } + self.call(&[ + "plugin", + "pane", + "open", + "--plugin", + PLUGIN_ID, + "--entrypoint", + DESK_ENTRYPOINT, + "--focus", + ]) + .map(drop) + } +} + +/// Reads a child's pipe to EOF on its own thread, so a large reply cannot +/// fill the pipe and stall the child before it exits. +fn drain(pipe: Option<impl Read + Send + 'static>) -> Receiver<Vec<u8>> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + if let Some(mut pipe) = pipe { + let _ = pipe.read_to_end(&mut bytes); + } + let _ = sender.send(bytes); + }); + receiver +} + +/// The `open` action: herdr sets `HERDR_WORKSPACE_ID` from the invocation +/// context, which scopes the dedupe to the focused workspace. +pub(crate) fn open_desk() -> anyhow::Result<()> { + let workspace = std::env::var("HERDR_WORKSPACE_ID").ok(); + Herdr::from_env().open_desk(workspace.as_deref()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use super::*; + use crate::fake_pond::{Sandbox, write_script}; + + const PANES: &str = r#"{"id":"cli:pane:list","result":{"type":"pane_list","panes":[ + {"pane_id":"wD:pS","agent":"claude","agent_status":"idle","agent_session":{"agent":"claude","kind":"id","source":"herdr:claude","value":"0a1d69bd"}}, + {"pane_id":"wD:pT","agent":"codex","agent_status":"working"}, + {"pane_id":"wD:pU","label":"pond desk","agent_status":"unknown"} + ]}}"#; + + /// A fake herdr that records its argv and answers `pane list` from + /// `panes.json`; `plugin pane focus` exits with `focus_exit`. + fn fake_herdr(sandbox: &Sandbox, panes: &str, focus_exit: i32) -> Herdr { + fs::write(sandbox.path("panes.json"), panes).unwrap(); + let bin = write_script( + &sandbox.path("bin/herdr"), + &format!( + r#"printf '%s\n' "$*" >> '{calls}' +case "$1 $2 $3" in + "pane list"*) cat '{panes}' ;; + "plugin pane focus") [ {focus_exit} = 0 ] && {{ echo '{{"result":{{}}}}'; exit 0; }} + echo '{{"error":{{"code":"not_found"}}}}' >&2; exit {focus_exit} ;; + *) echo '{{"id":"cli:x","result":{{}}}}' ;; +esac"#, + calls = sandbox.path("calls").display(), + panes = sandbox.path("panes.json").display(), + ), + ); + Herdr::new(bin) + } + + #[test] + fn context_prefers_the_focused_pane_cwd() { + let both = r#"{"workspace_cwd":"/w","focused_pane_cwd":"/p","focused_pane_id":"x"}"#; + assert_eq!(project_from_context(both).as_deref(), Some("/p")); + let workspace_only = r#"{"workspace_cwd":"/w","focused_pane_cwd":""}"#; + assert_eq!(project_from_context(workspace_only).as_deref(), Some("/w")); + assert_eq!(project_from_context("{}"), None); + assert_eq!(project_from_context("not json"), None); + } + + #[test] + fn live_agents_are_panes_with_a_session() { + let sandbox = Sandbox::new(); + let herdr = fake_herdr(&sandbox, PANES, 0); + let live = live_agents(herdr.pane_list(None).unwrap()); + assert_eq!( + live, + vec![LiveAgent { + pane_id: "wD:pS".to_owned(), + session: "0a1d69bd".to_owned(), + }] + ); + } + + #[test] + fn open_focuses_an_existing_desk() { + let sandbox = Sandbox::new(); + fake_herdr(&sandbox, PANES, 0) + .open_desk(Some("wD")) + .unwrap(); + assert_eq!( + sandbox.lines("calls"), + ["pane list --workspace wD", "plugin pane focus wD:pU"] + ); + } + + #[test] + fn open_degrades_to_opening_a_new_desk() { + let open = "plugin pane open --plugin pond --entrypoint desk --focus"; + for (panes, focus_exit, expected) in [ + (PANES, 1, vec!["pane list", "plugin pane focus wD:pU", open]), + ("not json", 0, vec!["pane list", open]), + ( + r#"{"result":{"panes":[{"pane_id":"a","label":"other"}]}}"#, + 0, + vec!["pane list", open], + ), + ] { + let sandbox = Sandbox::new(); + fake_herdr(&sandbox, panes, focus_exit) + .open_desk(None) + .unwrap(); + assert_eq!(sandbox.lines("calls"), expected); + } + } + + #[test] + fn a_hung_call_is_killed_at_the_deadline() { + let sandbox = Sandbox::new(); + let bin = write_script(&sandbox.path("bin/herdr"), "exec sleep 30"); + let herdr = Herdr { + deadline: Duration::from_millis(200), + ..Herdr::new(bin) + }; + let started = Instant::now(); + let error = herdr.pane_list(None).unwrap_err(); + assert!(error.to_string().contains("timed out"), "{error}"); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn output_held_open_after_exit_is_bounded() { + let sandbox = Sandbox::new(); + let bin = write_script( + &sandbox.path("bin/herdr"), + r#"echo '{"result":{"panes":[]}}'; sleep 3 & exit 0"#, + ); + let herdr = Herdr { + deadline: Duration::from_millis(300), + ..Herdr::new(bin) + }; + let started = Instant::now(); + let error = herdr.pane_list(None).unwrap_err(); + assert!(error.to_string().contains("output stayed open"), "{error}"); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[test] + fn a_large_reply_is_read_whole() { + let sandbox = Sandbox::new(); + let panes: Vec<String> = (0..2000) + .map(|i| format!(r#"{{"pane_id":"p{i}","label":"{}"}}"#, "x".repeat(64))) + .collect(); + fs::write( + sandbox.path("panes.json"), + format!(r#"{{"result":{{"panes":[{}]}}}}"#, panes.join(",")), + ) + .unwrap(); + let bin = write_script( + &sandbox.path("bin/herdr"), + &format!("cat '{}'", sandbox.path("panes.json").display()), + ); + assert_eq!(Herdr::new(bin).pane_list(None).unwrap().len(), 2000); + } + + #[test] + fn a_failed_call_carries_herdrs_stderr() { + let sandbox = Sandbox::new(); + let bin = write_script( + &sandbox.path("bin/herdr"), + "echo 'agent not found: wD:p9' >&2; exit 2", + ); + let error = Herdr::new(bin).agent_focus("wD:p9").unwrap_err(); + let message = error.to_string(); + assert!( + message.contains("herdr agent focus wD:p9 failed") + && message.contains("agent not found"), + "{message}" + ); + } +} diff --git a/packages/herdr-pond/src/hook.rs b/packages/herdr-pond/src/hook.rs new file mode 100644 index 00000000..e12adebd --- /dev/null +++ b/packages/herdr-pond/src/hook.rs @@ -0,0 +1,484 @@ +//! Sync-on-idle: the millisecond event hook and its detached per-adapter +//! worker. +//! +//! No idle event may be dropped, so the worker runs trailing-edge: the hook +//! creates `pending.<adapter>`; the worker deletes it just before each +//! `pond sync`, and syncs again whenever it reappears. A hook that finds the +//! worker's flock held relies on that, and the worker re-checks `pending` +//! after releasing the flock to close the window where it was exiting. + +use std::fs::{self, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use anyhow::bail; +use serde::Deserialize; + +use crate::config::{Config, log_line, log_stdio, try_lock}; +use crate::herdr; + +const SYNC_LOG: &str = "sync.log"; +/// Absorbs an event burst (min observed gap 302ms) into one sync. +const COALESCE: Duration = Duration::from_secs(2); + +/// herdr agent name -> pond adapter name. +const ADAPTERS: &[(&str, &str)] = &[ + ("claude", "claude-code"), + ("codex", "codex-cli"), + ("pi", "pi-coding-agent"), + ("omp", "oh-my-pi"), + ("opencode", "opencode"), + ("grok", "grok-build"), + ("hermes", "hermes"), + ("letta", "letta-code"), + ("agy", "agy"), +]; + +pub(crate) fn run(args: &[String]) -> anyhow::Result<()> { + match args { + [] => { + on_event(); + Ok(()) + } + [flag, adapter] if flag == "--worker" => { + herdr::detach(); + worker(adapter) + } + _ => bail!(crate::USAGE), + } +} + +/// Always exits 0: herdr keeps hook stderr only in an in-memory ring, so +/// failures go to `sync.log`. +fn on_event() { + let Ok(state_dir) = herdr::state_dir() else { + return; + }; + let log = state_dir.join(SYNC_LOG); + let event = std::env::var("HERDR_PLUGIN_EVENT_JSON").unwrap_or_default(); + let result = herdr::config_dir().and_then(|config_dir| { + handle_event(&event, &state_dir, &config_dir, |adapter| { + let mut command = Command::new(std::env::current_exe()?); + command.args(["hook", "--worker", adapter]); + herdr::spawn_detached(command, &log) + }) + }); + if let Err(error) = result { + log_line(&log, &format!("hook: {error:#}")); + } +} + +/// The adapter to sync when this event is an agent going idle (`done` is +/// idle-but-unseen). +fn idle_adapter(event_json: &str) -> Option<&'static str> { + #[derive(Deserialize)] + struct Event { + data: Data, + } + #[derive(Deserialize)] + struct Data { + agent_status: Option<String>, + agent: Option<String>, + } + let data = serde_json::from_str::<Event>(event_json).ok()?.data; + if !matches!(data.agent_status.as_deref(), Some("idle" | "done")) { + return None; + } + let agent = data.agent?; + ADAPTERS + .iter() + .find(|(name, _)| *name == agent) + .map(|(_, adapter)| *adapter) +} + +fn pending_path(state_dir: &Path, adapter: &str) -> PathBuf { + state_dir.join(format!("pending.{adapter}")) +} + +fn worker_lock(state_dir: &Path, adapter: &str) -> PathBuf { + state_dir.join(format!("worker.{adapter}.lock")) +} + +fn handle_event( + event_json: &str, + state_dir: &Path, + config_dir: &Path, + spawn_worker: impl FnOnce(&str) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + let Some(adapter) = idle_adapter(event_json) else { + return Ok(()); + }; + if !Config::load(config_dir, &state_dir.join(SYNC_LOG)).sync_on_idle { + return Ok(()); + } + fs::create_dir_all(state_dir)?; + OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(pending_path(state_dir, adapter))?; + let worker_running = try_lock(&worker_lock(state_dir, adapter))?.is_none(); + if worker_running { + return Ok(()); + } + spawn_worker(adapter) +} + +fn worker(adapter: &str) -> anyhow::Result<()> { + let state_dir = herdr::state_dir()?; + let config_dir = herdr::config_dir()?; + let log = state_dir.join(SYNC_LOG); + let Some(pond) = herdr::resolve_pond_or_toast(&config_dir, &state_dir, &log) else { + return Ok(()); + }; + work(adapter, &state_dir, &pond, COALESCE) +} + +/// Syncs `adapter` until no pending stamp is left. Losing the flock to +/// another worker means that worker covers the stamp. +fn work(adapter: &str, state_dir: &Path, pond: &Path, coalesce: Duration) -> anyhow::Result<()> { + let pending = pending_path(state_dir, adapter); + let log = state_dir.join(SYNC_LOG); + loop { + let Some(lock) = try_lock(&worker_lock(state_dir, adapter))? else { + return Ok(()); + }; + while pending.exists() { + std::thread::sleep(coalesce); + if let Err(error) = fs::remove_file(&pending) + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(error.into()); + } + sync(adapter, pond, &log); + } + drop(lock); + if !pending.exists() { + return Ok(()); + } + } +} + +/// Without `--no-wait`: a busy store lock makes this sync wait its turn +/// instead of exiting "skipped" and silently dropping the idle event. +fn sync(adapter: &str, pond: &Path, log: &Path) { + let started = Instant::now(); + let status = + log_stdio(Command::new(pond).args(["sync", adapter, "-q"]), log).and_then(Command::status); + let outcome = match status { + Ok(status) => status.to_string(), + Err(error) => format!("cannot run {}: {error}", pond.display()), + }; + log_line( + log, + &format!( + "sync {adapter}: {outcome} after {:.1}s", + started.elapsed().as_secs_f64() + ), + ); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use std::io::Read; + use std::process::Stdio; + use std::sync::{Arc, Mutex}; + use std::thread::JoinHandle; + + use super::*; + use crate::fake_pond::{Sandbox, write_script}; + + const TEST_COALESCE: Duration = Duration::from_millis(50); + const ROLE: &str = "HERDR_POND_TEST_ROLE"; + /// Re-runs this test binary as exactly [`self_exec_role`]. + const SELF_EXEC_ARGS: [&str; 4] = [ + "--exact", + "hook::tests::self_exec_role", + "--test-threads=1", + "-q", + ]; + + fn idle(agent: &str) -> String { + format!( + r#"{{"event":"pane_agent_status_changed","data":{{"type":"pane_agent_status_changed","pane_id":"wD:p1","workspace_id":"wD","agent_status":"idle","agent":"{agent}"}}}}"# + ) + } + + /// A fake `pond sync` that logs start/end to `events`, holds while + /// `store.lock` exists (pond's own per-host lock wait) and then works for + /// `seconds`. + fn fake_pond(sandbox: &Sandbox, seconds: &str) -> PathBuf { + write_script( + &sandbox.path("bin/pond"), + &format!( + r#"printf '%s\n' "$*" >> '{calls}' +echo "start $2" >> '{events}' +while [ -e '{lock}' ]; do sleep 0.02; done +sleep {seconds} +echo "end $2" >> '{events}'"#, + calls = sandbox.path("calls").display(), + events = sandbox.path("events").display(), + lock = sandbox.path("store.lock").display(), + ), + ) + } + + fn wait_for(what: &str, condition: impl Fn() -> bool) { + let deadline = Instant::now() + Duration::from_secs(20); + while !condition() { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + /// Runs the hook leg in-process with workers on threads. + struct Harness { + sandbox: Sandbox, + pond: PathBuf, + workers: Arc<Mutex<Vec<JoinHandle<()>>>>, + } + + impl Harness { + fn new(sync_seconds: &str) -> Self { + let sandbox = Sandbox::new(); + let pond = fake_pond(&sandbox, sync_seconds); + Self { + sandbox, + pond, + workers: Arc::default(), + } + } + + fn trigger(&self, agent: &str) { + let state_dir = self.sandbox.state_dir(); + let pond = self.pond.clone(); + let workers = Arc::clone(&self.workers); + handle_event( + &idle(agent), + &state_dir.clone(), + &self.sandbox.config_dir(), + move |adapter| { + let adapter = adapter.to_owned(); + let worker = std::thread::spawn(move || { + work(&adapter, &state_dir, &pond, TEST_COALESCE).unwrap(); + }); + workers.lock().unwrap().push(worker); + Ok(()) + }, + ) + .unwrap(); + } + + fn join(&self) { + while let Some(worker) = self.workers.lock().unwrap().pop() { + worker.join().unwrap(); + } + } + + fn events(&self) -> Vec<String> { + self.sandbox.lines("events") + } + + fn has_event(&self, event: &str) -> bool { + self.events().iter().any(|line| line == event) + } + } + + #[test] + fn only_idle_events_of_known_agents_sync() { + assert_eq!(idle_adapter(&idle("claude")), Some("claude-code")); + assert_eq!(idle_adapter(&idle("codex")), Some("codex-cli")); + let done = idle("pi").replace("\"idle\"", "\"done\""); + assert_eq!(idle_adapter(&done), Some("pi-coding-agent")); + let working = idle("claude").replace("\"idle\"", "\"working\""); + assert_eq!(idle_adapter(&working), None); + assert_eq!(idle_adapter(&idle("vim")), None); + let no_agent = r#"{"event":"pane_agent_status_changed","data":{"agent_status":"idle"}}"#; + assert_eq!(idle_adapter(no_agent), None); + assert_eq!(idle_adapter(""), None); + assert_eq!(idle_adapter("{"), None); + } + + #[test] + fn disabled_config_does_nothing() { + let sandbox = Sandbox::new(); + sandbox.write_config("sync_on_idle = false\n"); + handle_event( + &idle("claude"), + &sandbox.state_dir(), + &sandbox.config_dir(), + |_| panic!("spawned a worker while disabled"), + ) + .unwrap(); + assert!(!pending_path(&sandbox.state_dir(), "claude-code").exists()); + } + + #[test] + fn a_held_worker_lock_leaves_only_the_stamp() { + let sandbox = Sandbox::new(); + let state_dir = sandbox.state_dir(); + let _held = try_lock(&worker_lock(&state_dir, "claude-code")) + .unwrap() + .unwrap(); + handle_event(&idle("claude"), &state_dir, &sandbox.config_dir(), |_| { + panic!("spawned a second worker") + }) + .unwrap(); + assert!(pending_path(&state_dir, "claude-code").exists()); + } + + #[test] + fn idles_during_a_sync_coalesce_into_one_more() { + let harness = Harness::new("0.4"); + harness.trigger("claude"); + wait_for("the first sync", || harness.has_event("start claude-code")); + harness.trigger("claude"); + harness.trigger("claude"); + harness.join(); + assert_eq!( + harness.events(), + [ + "start claude-code", + "end claude-code", + "start claude-code", + "end claude-code" + ] + ); + let log = fs::read_to_string(harness.sandbox.state_dir().join(SYNC_LOG)).unwrap(); + assert_eq!(log.matches("sync claude-code: exit status: 0").count(), 2); + } + + #[test] + fn two_adapters_sync_concurrently() { + let harness = Harness::new("0.4"); + harness.trigger("claude"); + harness.trigger("codex"); + harness.join(); + let events = harness.events(); + assert_eq!(events.len(), 4, "{events:?}"); + for adapter in ["claude-code", "codex-cli"] { + assert_eq!( + events.iter().filter(|e| e.ends_with(adapter)).count(), + 2, + "{events:?}" + ); + } + assert!(events[1].starts_with("start"), "not concurrent: {events:?}"); + } + + #[test] + fn idle_during_a_held_store_lock_waits_and_is_not_dropped() { + let harness = Harness::new("0.05"); + fs::write(harness.sandbox.path("store.lock"), "").unwrap(); + harness.trigger("claude"); + wait_for("the blocked sync", || { + harness.has_event("start claude-code") + }); + std::thread::sleep(Duration::from_millis(200)); + assert!(!harness.has_event("end claude-code")); + harness.trigger("claude"); + fs::remove_file(harness.sandbox.path("store.lock")).unwrap(); + harness.join(); + assert_eq!(harness.events().len(), 4, "{:?}", harness.events()); + let calls = harness.sandbox.lines("calls"); + assert!( + calls.iter().all(|call| call == "sync claude-code -q"), + "{calls:?}" + ); + } + + #[test] + fn a_stamp_left_after_release_is_picked_up() { + let harness = Harness::new("0"); + let state_dir = harness.sandbox.state_dir(); + fs::create_dir_all(&state_dir).unwrap(); + fs::write(pending_path(&state_dir, "codex-cli"), "").unwrap(); + work("codex-cli", &state_dir, &harness.pond, TEST_COALESCE).unwrap(); + assert_eq!(harness.events(), ["start codex-cli", "end codex-cli"]); + assert!(!pending_path(&state_dir, "codex-cli").exists()); + assert!( + try_lock(&worker_lock(&state_dir, "codex-cli")) + .unwrap() + .is_some() + ); + } + + fn self_exec(role: &str, sandbox: &Sandbox) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(SELF_EXEC_ARGS) + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env(ROLE, role) + .env("HERDR_PLUGIN_STATE_DIR", sandbox.state_dir()) + .env("HERDR_PLUGIN_CONFIG_DIR", sandbox.config_dir()) + .env("HERDR_BIN_PATH", sandbox.path("bin/no-herdr")) + .env("HERDR_PLUGIN_EVENT_JSON", idle("claude")); + command + } + + /// Not a test on its own: the process entry for + /// [`hook_exits_and_closes_its_pipes_while_the_sync_runs`], which re-runs + /// this test binary as the hook and as its detached worker. + #[test] + fn self_exec_role() { + let Ok(role) = std::env::var(ROLE) else { + return; + }; + let state_dir = herdr::state_dir().unwrap(); + let config_dir = herdr::config_dir().unwrap(); + if role == "worker" { + herdr::detach(); + worker("claude-code").unwrap(); + return; + } + let event = std::env::var("HERDR_PLUGIN_EVENT_JSON").unwrap(); + let log = state_dir.join(SYNC_LOG); + handle_event(&event, &state_dir, &config_dir, |_| { + let mut command = Command::new(std::env::current_exe()?); + command.args(SELF_EXEC_ARGS).env(ROLE, "worker"); + herdr::spawn_detached(command, &log) + }) + .unwrap(); + } + + #[test] + fn hook_exits_and_closes_its_pipes_while_the_sync_runs() { + let sandbox = Sandbox::new(); + let pond = fake_pond(&sandbox, "1"); + sandbox.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); + let started = Instant::now(); + let mut hook = self_exec("hook", &sandbox) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = hook.stdout.take().unwrap(); + let mut stderr = hook.stderr.take().unwrap(); + let stderr_reader = std::thread::spawn(move || { + let mut text = String::new(); + stderr.read_to_string(&mut text).unwrap(); + text + }); + let mut text = String::new(); + stdout.read_to_string(&mut text).unwrap(); + let stderr_text = stderr_reader.join().unwrap(); + let eof_after = started.elapsed(); + assert!(hook.wait().unwrap().success(), "{text}{stderr_text}"); + + let ended = || { + sandbox + .lines("events") + .contains(&"end claude-code".to_owned()) + }; + assert!( + !ended(), + "pipes stayed open for the whole sync ({eof_after:?})" + ); + wait_for("the detached sync to finish", ended); + assert_eq!(sandbox.lines("calls"), ["sync claude-code -q"]); + } +} diff --git a/packages/herdr-pond/src/main.rs b/packages/herdr-pond/src/main.rs new file mode 100644 index 00000000..073bb04c --- /dev/null +++ b/packages/herdr-pond/src/main.rs @@ -0,0 +1,90 @@ +//! herdr plugin for pond: sync-on-idle and a read-only session desk. Design: +//! `docs/plans/2609-24-herdr-pond-v1-desk-plan.md`. +//! +//! The desk draws with ratatui over crossterm directly - pond's CLI output +//! stack rule covers the pond binary, not this crate. `unsafe_code` is denied, +//! so process groups, signals and locks go through `nix`'s safe wrappers. + +mod api; +mod config; +mod daemon; +mod desk; +#[cfg(test)] +mod fake_pond; +mod herdr; +mod hook; +mod serve; +mod types; + +use std::future::Future; +use std::process::ExitCode; +use std::sync::Arc; + +use tokio::signal::unix::{SignalKind, signal}; + +use crate::types::{DeskContext, DeskExit}; + +const USAGE: &str = "usage: herdr-pond open|tui|hook [--worker <adapter>]|serve-daemon [--owner]"; + +fn main() -> ExitCode { + let args: Vec<String> = std::env::args().skip(1).collect(); + let (command, rest) = args + .split_first() + .map_or(("", &[][..]), |(c, r)| (c.as_str(), r)); + let result = match command { + "open" => herdr::open_desk(), + "tui" => desk_main(), + "hook" => hook::run(rest), + "serve-daemon" => daemon::run(rest), + _ => Err(anyhow::anyhow!(USAGE)), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("herdr-pond {command}: {error:#}"); + ExitCode::FAILURE + } + } +} + +/// The single-threaded runtime the daemon and the desk build by hand: the hook +/// path must never pay for one. +fn runtime() -> std::io::Result<tokio::runtime::Runtime> { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() +} + +/// Resolves with the name of the first SIGTERM, SIGINT or SIGHUP. Registering +/// replaces the default die-at-once, so install it before anything needs +/// tearing down. +fn shutdown_signal() -> std::io::Result<impl Future<Output = &'static str>> { + let mut terminate = signal(SignalKind::terminate())?; + let mut interrupt = signal(SignalKind::interrupt())?; + let mut hangup = signal(SignalKind::hangup())?; + Ok(async move { + tokio::select! { + _ = terminate.recv() => "SIGTERM", + _ = interrupt.recv() => "SIGINT", + _ = hangup.recv() => "SIGHUP", + } + }) +} + +/// Runs the desk, then performs a jump only after it has restored the +/// terminal. The api (and any fallback serve it owns) is dropped when +/// `desk::run` returns, before herdr's CLI runs. +fn desk_main() -> anyhow::Result<()> { + let context = DeskContext { + project: herdr::context_project(), + hostname: nix::unistd::gethostname() + .ok() + .and_then(|name| name.into_string().ok()), + state_dir: herdr::state_dir().ok(), + }; + let api = Arc::new(api::HttpApi::from_env()); + match desk::run(api, context)? { + DeskExit::Quit => Ok(()), + DeskExit::Jump { pane_id } => herdr::Herdr::from_env().agent_focus(&pane_id), + } +} diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs new file mode 100644 index 00000000..40f2ceb5 --- /dev/null +++ b/packages/herdr-pond/src/serve.rs @@ -0,0 +1,707 @@ +//! Finding a usable `pond serve` for the desk: this herdr server's published +//! endpoint, else a desk-owned fallback child, both vetted by the capability +//! probe. The per-server state layout and the serve spawn/teardown are +//! shared with the daemon. + +use std::fs; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{Duration, Instant}; + +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; +use serde::{Deserialize, Serialize}; + +use crate::api::{SQL_PATH, Socket, sql_deadline}; +use crate::config::{Config, log_line, log_stdio, write_atomic}; +use crate::herdr; +use crate::types::{ApiError, READY_SQL, SqlRequest, SqlResponse}; + +/// Store open (seconds on S3) happens before `pond serve` binds. +pub(crate) const READY_DEADLINE: Duration = Duration::from_secs(180); +const PROBE_TIMEOUT_SECS: u64 = 5; +const FALLBACK_GRACE: Duration = Duration::from_secs(2); +const READY_POLL: Duration = Duration::from_millis(100); +const TERMINATE_POLL: Duration = Duration::from_millis(25); +/// clap's usage-error exit, for any bad flag or env value; only a rejection +/// naming `--socket` marks a pond from before the flag. +const USAGE_ERROR_EXIT: i32 = 2; +/// `sockaddr_un.sun_path`, NUL included: a longer path cannot be bound, so a +/// serve spawned on one would open the store only to fail at bind. +#[cfg(target_os = "linux")] +const SUN_PATH_BYTES: usize = 108; +#[cfg(not(target_os = "linux"))] +const SUN_PATH_BYTES: usize = 104; + +/// `STATE_DIR/serve/<sockhash>/`: herdr keys plugin state by plugin id only, +/// so two herdr servers on one machine share the state dir - everything a +/// serve owns is keyed by the server's socket instead. +#[derive(Debug, Clone)] +pub(crate) struct ServeDir { + root: PathBuf, +} + +impl ServeDir { + pub(crate) fn new(state_dir: &Path, socket: &Path) -> Self { + Self { + root: state_dir.join("serve").join(sockhash(socket)), + } + } + + pub(crate) fn from_env() -> anyhow::Result<Self> { + Ok(Self::new(&herdr::state_dir()?, &herdr::socket_path()?)) + } + + pub(crate) fn lock(&self) -> PathBuf { + self.root.join("lock") + } + + pub(crate) fn endpoint(&self) -> PathBuf { + self.root.join("endpoint") + } + + pub(crate) fn daemon_log(&self) -> PathBuf { + self.root.join("daemon.log") + } + + pub(crate) fn socket(&self, owner: &str) -> PathBuf { + self.root.join(format!("{owner}.sock")) + } + + fn desk_log(&self) -> PathBuf { + self.root.join("desk-serve.log") + } +} + +/// Mirrors `pond serve --socket`'s lifetime lock, `<socket>.lock`, which pond +/// never removes. +pub(crate) fn socket_lock(socket: &Path) -> PathBuf { + let mut lock = socket.as_os_str().to_owned(); + lock.push(".lock"); + PathBuf::from(lock) +} + +/// FNV-1a over the canonical socket path: stable across builds and processes, +/// unlike std's hasher. +fn sockhash(socket: &Path) -> String { + let canonical = fs::canonicalize(socket).unwrap_or_else(|_| socket.to_path_buf()); + let hash = canonical + .as_os_str() + .as_bytes() + .iter() + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }); + format!("{:012x}", hash & 0xffff_ffff_ffff) +} + +/// The published record of a daemon-owned serve. The token names the owner, +/// so an exiting owner never removes a successor's record; the pid names the +/// serve, so a successor can stop it once its owner is gone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Endpoint { + pub socket: PathBuf, + pub token: String, + pub pid: u32, +} + +/// A missing or malformed record is no record. +pub(crate) fn read_endpoint(path: &Path) -> Option<Endpoint> { + serde_json::from_slice(&fs::read(path).ok()?).ok() +} + +pub(crate) fn write_endpoint(path: &Path, endpoint: &Endpoint) -> std::io::Result<()> { + let json = serde_json::to_vec(endpoint).map_err(std::io::Error::other)?; + write_atomic(path, &json, 0o666) +} + +pub(crate) fn remove_endpoint_if_owned(path: &Path, token: &str) -> bool { + read_endpoint(path).is_some_and(|endpoint| endpoint.token == token) + && fs::remove_file(path).is_ok() +} + +/// The published endpoint and its socket, if the socket answers the probe. +pub(crate) async fn live_endpoint(dir: &ServeDir) -> Option<(Endpoint, Socket)> { + let endpoint = read_endpoint(&dir.endpoint())?; + let socket = Socket::new(endpoint.socket.clone()).ok()?; + probe(&socket).await.ok().map(|()| (endpoint, socket)) +} + +/// `SELECT 1` over `/v1/x/sql`: proves both a live pond and one new enough +/// for the desk. A 405 from `/v1/search` would prove neither. +pub(crate) async fn probe(socket: &Socket) -> Result<(), ApiError> { + let request = SqlRequest::new(READY_SQL.to_owned(), 1, PROBE_TIMEOUT_SECS); + let deadline = sql_deadline(PROBE_TIMEOUT_SECS); + let response: SqlResponse = socket.post(SQL_PATH, &request, deadline).await?; + if response.rows.is_empty() { + return Err(ApiError::Decode(format!( + "{} answered the readiness probe with no rows", + socket.path.display() + ))); + } + Ok(()) +} + +/// A spawned `pond serve`, terminated (and its socket removed) on drop. +/// Termination blocks for up to `grace`, so async code drops one through +/// [`retire`]. +pub(crate) struct ServeChild { + child: Child, + socket: PathBuf, + log: PathBuf, + /// Where this child's output starts in the shared `log`. + log_start: u64, + grace: Duration, + /// Set for a socket path no later serve reuses, whose lock file would + /// otherwise pile up; a reused path keeps its lock for the next serve. + unique_path: bool, +} + +impl ServeChild { + /// `pond serve --socket <socket>`; stdio goes to `log` because serve's + /// output would corrupt the TUI or pin a herdr slot. A leftover socket at + /// the path is removed first: pond clears a dead serve's itself, but an + /// unsupervised orphan's still answers and would pass [`Self::ready`]. + pub(crate) fn spawn( + pond: &Path, + socket: PathBuf, + log: PathBuf, + grace: Duration, + ) -> std::io::Result<Self> { + let length = socket.as_os_str().len(); + if length >= SUN_PATH_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "socket path {} is {length} bytes, past the {}-byte Unix socket limit - \ + herdr's plugin state dir (HERDR_PLUGIN_STATE_DIR) is nested too deep", + socket.display(), + SUN_PATH_BYTES - 1 + ), + )); + } + let _ = fs::remove_file(&socket); + let mut command = serve_command(pond, &socket); + log_stdio(&mut command, &log)?; + let log_start = fs::metadata(&log).map_or(0, |meta| meta.len()); + let child = command.spawn()?; + Ok(Self { + child, + socket, + log, + log_start, + grace, + unique_path: false, + }) + } + + /// Removes pond's lock file along with the socket on drop, once the + /// child has exited and no server can hold it. + pub(crate) fn unique_path(mut self) -> Self { + self.unique_path = true; + self + } + + pub(crate) fn id(&self) -> u32 { + self.child.id() + } + + /// Why serve is gone, once it has exited. + pub(crate) fn exited(&mut self) -> Option<String> { + match self.child.try_wait() { + Ok(None) => None, + Ok(Some(status)) => Some(format!("pond serve exited ({status})")), + Err(error) => Some(format!("cannot watch pond serve: {error}")), + } + } + + /// Waits for serve to answer the capability probe on its socket. The + /// socket file alone proves nothing: serve binds only after the store + /// opens, and a stale one refuses connections. + pub(crate) async fn ready(&mut self, deadline: Duration) -> Result<Socket, ApiError> { + let socket = Socket::new(self.socket.clone())?; + let started = Instant::now(); + let mut last_probe = String::new(); + loop { + if let Ok(Some(status)) = self.child.try_wait() { + if status.code() == Some(USAGE_ERROR_EXIT) && self.rejected_socket_flag() { + return Err(ApiError::PondTooOld); + } + return Err(ApiError::Unreachable(format!( + "pond serve exited ({status}) before listening - see {}", + self.log.display() + ))); + } + if self.socket.exists() { + match probe(&socket).await { + // An answer while the child is gone came from another + // process; the next pass reports the exit. + Ok(()) if matches!(self.child.try_wait(), Ok(None)) => return Ok(socket), + Ok(()) => {} + Err(ApiError::PondTooOld) => return Err(ApiError::PondTooOld), + Err(error) => last_probe = format!(" (last probe: {error})"), + } + } + if started.elapsed() > deadline { + return Err(ApiError::Unreachable(format!( + "pond serve did not answer on {} within {}s{last_probe} - see {}", + self.socket.display(), + deadline.as_secs(), + self.log.display() + ))); + } + tokio::time::sleep(READY_POLL).await; + } + } + + fn rejected_socket_flag(&self) -> bool { + fs::read(&self.log).is_ok_and(|log| { + usize::try_from(self.log_start) + .ok() + .and_then(|start| log.get(start..)) + .is_some_and(|output| String::from_utf8_lossy(output).contains("--socket")) + }) + } +} + +impl Drop for ServeChild { + fn drop(&mut self) { + terminate(&mut self.child, self.grace); + let _ = fs::remove_file(&self.socket); + if self.unique_path { + let _ = fs::remove_file(socket_lock(&self.socket)); + } + } +} + +fn serve_command(pond: &Path, socket: &Path) -> Command { + let mut command = Command::new(pond); + command.args(["serve", "--socket"]).arg(socket); + // pond ignores these beside `--socket`, but clap still parses them: a + // malformed ambient POND_PORT would fail serve before it binds. + command.env_remove("POND_HOST").env_remove("POND_PORT"); + command +} + +/// Drops `serve` on the blocking pool; await the handle to know it is gone. +pub(crate) fn retire(serve: ServeChild) -> tokio::task::JoinHandle<()> { + tokio::task::spawn_blocking(move || drop(serve)) +} + +/// SIGTERM, a bounded wait, then SIGKILL and reap: serve's own drain bounds +/// only the HTTP side, not process teardown. +fn terminate(child: &mut Child, grace: Duration) { + if !matches!(child.try_wait(), Ok(None)) { + return; + } + if let Ok(pid) = i32::try_from(child.id()) { + let _ = kill(Pid::from_raw(pid), Signal::SIGTERM); + } + let deadline = Instant::now() + grace; + while Instant::now() < deadline { + if !matches!(child.try_wait(), Ok(None)) { + return; + } + std::thread::sleep(TERMINATE_POLL); + } + let _ = child.kill(); + let _ = child.wait(); +} + +/// A desk-owned `pond serve`, torn down when the desk drops it - on every +/// graceful exit. A SIGKILLed desk orphans it (accepted v1 risk, README). +pub(crate) struct Fallback { + serve: ServeChild, + socket: Socket, +} + +/// Where the desk finds its serve: this herdr server's state plus the plugin +/// config that names `pond`. +pub(crate) struct Origin { + pub dir: ServeDir, + pub config_dir: PathBuf, +} + +impl Origin { + pub(crate) fn from_env() -> anyhow::Result<Self> { + Ok(Self { + dir: ServeDir::from_env()?, + config_dir: herdr::config_dir()?, + }) + } +} + +pub(crate) struct Connection { + pub socket: Socket, + pub fallback: Option<Fallback>, +} + +/// The daemon's endpoint when it probes live, else the desk's existing +/// fallback when it still does, else a freshly spawned fallback. +pub(crate) async fn connect( + origin: &Origin, + fallback: Option<Fallback>, +) -> Result<Connection, ApiError> { + if let Some((_, socket)) = live_endpoint(&origin.dir).await { + if let Some(fallback) = fallback { + retire(fallback.serve); + } + return Ok(Connection { + socket, + fallback: None, + }); + } + if let Some(fallback) = fallback { + if probe(&fallback.socket).await.is_ok() { + return Ok(Connection { + socket: fallback.socket.clone(), + fallback: Some(fallback), + }); + } + retire(fallback.serve); + } + let fallback = spawn_fallback(origin).await?; + Ok(Connection { + socket: fallback.socket.clone(), + fallback: Some(fallback), + }) +} + +async fn spawn_fallback(origin: &Origin) -> Result<Fallback, ApiError> { + // One socket per spawn: a retiring fallback removes its own on drop, + // while its successor may already be listening there. + static SPAWNED: AtomicU32 = AtomicU32::new(0); + let log = origin.dir.desk_log(); + let pond = Config::pond(&origin.config_dir, &log) + .map_err(|error| ApiError::Unreachable(format!("{error:#}")))?; + let socket = origin.dir.socket(&format!( + "desk.{}.{}", + std::process::id(), + SPAWNED.fetch_add(1, Ordering::Relaxed) + )); + log_line(&log, &format!("desk: starting fallback {}", pond.display())); + let mut serve = ServeChild::spawn(&pond, socket, log, FALLBACK_GRACE) + .map_err(|error| { + ApiError::Unreachable(format!("cannot start {}: {error}", pond.display())) + })? + .unique_path(); + match serve.ready(READY_DEADLINE).await { + Ok(socket) => Ok(Fallback { serve, socket }), + Err(error) => { + retire(serve); + Err(error) + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use super::*; + use crate::fake_pond::{ + FakePond, Reply, Sandbox, alive, endpoint, golden, missing_socket, stale_socket, + write_script, + }; + + async fn ready_pond() -> FakePond { + FakePond::with_sql( + vec![("SELECT 1", Reply::json(golden::SQL_READY))], + Reply::json(golden::SEARCH), + ) + .await + } + + /// A fake serve answering through `pond` that stays up. + fn fake_serve(sandbox: &Sandbox, pond: &FakePond) -> Origin { + sandbox.fake_serve(Some(&pond.socket), "exec sleep 30"); + sandbox.origin() + } + + #[test] + fn sockhash_is_stable_and_resolves_symlinks() { + let sandbox = Sandbox::new(); + let socket = sandbox.path("herdr.sock"); + fs::write(&socket, "").unwrap(); + let link = sandbox.path("link.sock"); + std::os::unix::fs::symlink(&socket, &link).unwrap(); + assert_eq!(sockhash(&socket), sockhash(&link)); + assert_eq!(sockhash(&socket).len(), 12); + assert_ne!(sockhash(&socket), sockhash(&sandbox.path("other.sock"))); + assert_eq!(sockhash(Path::new("/a")), sockhash(Path::new("/a"))); + } + + #[test] + fn endpoint_round_trips_and_is_removed_only_by_its_owner() { + let sandbox = Sandbox::new(); + let path = sandbox.path("state/endpoint"); + let socket = sandbox.path("state/owner.sock"); + write_endpoint(&path, &endpoint(&socket, "mine")).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({"socket": socket.display().to_string(), "token": "mine", "pid": 0}) + ); + assert_eq!(read_endpoint(&path), Some(endpoint(&socket, "mine"))); + assert!(!remove_endpoint_if_owned(&path, "theirs")); + assert!(path.exists()); + assert!(remove_endpoint_if_owned(&path, "mine")); + assert!(!path.exists()); + assert!(!remove_endpoint_if_owned(&path, "mine")); + } + + #[test] + fn malformed_port_or_pidless_endpoint_is_absent() { + let sandbox = Sandbox::new(); + let path = sandbox.path("endpoint"); + for text in [ + "", + "{", + r#"{"socket":1,"token":"t","pid":1}"#, + r#"{"port":1,"token":"t","pid":1}"#, + r#"{"socket":"/s/owner.sock","token":"t"}"#, + ] { + fs::write(&path, text).unwrap(); + assert_eq!(read_endpoint(&path), None, "{text}"); + assert!(!remove_endpoint_if_owned(&path, "t")); + } + } + + #[tokio::test] + async fn probe_tells_ready_from_old_from_dead() { + let ready = ready_pond().await; + probe(&ready.connect()).await.unwrap(); + assert!(ready.recorded()[0].body.contains(r#""limit":1"#)); + + let old = FakePond::start(|_, _| Reply::plain(404, "")).await; + assert_eq!(probe(&old.connect()).await, Err(ApiError::PondTooOld)); + + let sandbox = Sandbox::new(); + for dead in [missing_socket(), stale_socket(&sandbox.path("stale.sock"))] { + assert!(matches!(probe(&dead).await, Err(ApiError::Unreachable(_)))); + } + } + + #[tokio::test] + async fn live_endpoint_is_used_without_a_fallback() { + let sandbox = Sandbox::new(); + let pond = ready_pond().await; + let origin = sandbox.origin(); + sandbox.fake_serve(None, "exec sleep 30"); + write_endpoint(&origin.dir.endpoint(), &endpoint(&pond.socket, "t")).unwrap(); + let connection = connect(&origin, None).await.unwrap(); + assert_eq!(connection.socket.path, pond.socket); + assert!(connection.fallback.is_none()); + assert!(!sandbox.path("calls").exists(), "no pond spawned"); + } + + #[tokio::test] + async fn dead_endpoint_falls_back_to_an_owned_child() { + let sandbox = Sandbox::new(); + let pond = ready_pond().await; + let origin = fake_serve(&sandbox, &pond); + let stale = stale_socket(&origin.dir.socket("owner")); + write_endpoint(&origin.dir.endpoint(), &endpoint(&stale.path, "t")).unwrap(); + + let connection = connect(&origin, None).await.unwrap(); + let fallback = connection.fallback.expect("fallback child"); + let socket = fallback.serve.socket.clone(); + assert_eq!(connection.socket.path, socket); + assert_eq!(pond.recorded().len(), 1, "one readiness probe"); + let calls = sandbox.lines("calls"); + assert_eq!(calls, [format!("serve --socket {}", socket.display())]); + let name = socket.file_name().unwrap().to_string_lossy().into_owned(); + assert!( + name.starts_with(&format!("desk.{}.", std::process::id())) && name.ends_with(".sock"), + "{name}" + ); + let log = fs::read_to_string(origin.dir.desk_log()).unwrap(); + assert!(log.contains("serve stdout") && log.contains("serve stderr")); + + let pid = fallback.serve.id(); + assert!(alive(pid)); + drop(fallback); + assert!(!alive(pid), "fallback serve survived the desk"); + assert!(fs::symlink_metadata(&socket).is_err(), "socket left behind"); + assert!(!socket_lock(&socket).exists(), "lock file left behind"); + } + + #[test] + fn serve_gets_only_the_socket_and_never_the_bind_env() { + let command = serve_command(Path::new("/bin/pond"), Path::new("/s/owner.sock")); + let args: Vec<_> = command.get_args().collect(); + assert_eq!(args, ["serve", "--socket", "/s/owner.sock"]); + let removed: Vec<_> = command + .get_envs() + .filter(|(_, value)| value.is_none()) + .map(|(key, _)| key) + .collect(); + assert_eq!(removed, ["POND_HOST", "POND_PORT"]); + } + + #[test] + fn a_socket_path_past_the_limit_is_refused_before_spawning() { + let sandbox = Sandbox::new(); + let pond = sandbox.fake_serve(None, "exec sleep 30"); + let socket = sandbox.path(&format!("{}/owner.sock", "x".repeat(SUN_PATH_BYTES))); + let Err(error) = ServeChild::spawn(&pond, socket, sandbox.path("log"), FALLBACK_GRACE) + else { + panic!("spawned on an unbindable path"); + }; + assert!( + error.to_string().contains("HERDR_PLUGIN_STATE_DIR"), + "{error}" + ); + assert!(!sandbox.path("calls").exists(), "pond was started"); + } + + #[test] + fn only_a_unique_path_serve_takes_its_lock_file_along() { + let sandbox = Sandbox::new(); + let pond = sandbox.fake_serve(None, "exec sleep 30"); + let spawn = |name: &str| { + ServeChild::spawn( + &pond, + sandbox.path(name), + sandbox.path("log"), + FALLBACK_GRACE, + ) + .unwrap() + }; + let (fallback, owner) = (spawn("desk.1.0.sock").unique_path(), spawn("owner.sock")); + let locks = [ + socket_lock(&sandbox.path("desk.1.0.sock")), + socket_lock(&sandbox.path("owner.sock")), + ]; + let deadline = Instant::now() + Duration::from_secs(5); + while !locks.iter().all(|lock| lock.exists()) { + assert!(Instant::now() < deadline, "the fake serve made no locks"); + std::thread::sleep(Duration::from_millis(10)); + } + drop((fallback, owner)); + assert!(!locks[0].exists(), "the fallback's lock outlived it"); + assert!(locks[1].exists(), "the owner's lock is its successor's"); + } + + #[tokio::test] + async fn a_socket_that_refuses_is_not_ready() { + let sandbox = Sandbox::new(); + let stale = sandbox.path("stale.sock"); + stale_socket(&stale); + let pond = sandbox.fake_serve(Some(&stale), "exec sleep 30"); + let mut serve = ServeChild::spawn( + &pond, + sandbox.path("s.sock"), + sandbox.path("log"), + FALLBACK_GRACE, + ) + .unwrap(); + let result = serve.ready(Duration::from_millis(500)).await; + let Err(ApiError::Unreachable(reason)) = result else { + panic!("expected Unreachable, got {result:?}"); + }; + assert!( + reason.contains("did not answer") && reason.contains("last probe"), + "{reason}" + ); + retire(serve).await.unwrap(); + } + + #[tokio::test] + async fn a_live_fallback_is_kept_on_reconnect() { + let sandbox = Sandbox::new(); + let pond = ready_pond().await; + let origin = fake_serve(&sandbox, &pond); + let first = connect(&origin, None).await.unwrap(); + let pid = first.fallback.as_ref().unwrap().serve.id(); + let second = connect(&origin, first.fallback).await.unwrap(); + assert_eq!(second.fallback.as_ref().unwrap().serve.id(), pid); + assert_eq!(sandbox.lines("calls").len(), 1); + } + + #[tokio::test] + async fn fallback_that_dies_before_listening_names_its_log() { + let sandbox = Sandbox::new(); + let pond = write_script(&sandbox.path("bin/pond"), "echo 'no store' >&2; exit 3"); + sandbox.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); + let origin = sandbox.origin(); + let Err(ApiError::Unreachable(reason)) = connect(&origin, None).await else { + panic!("expected Unreachable"); + }; + assert!(reason.contains("desk-serve.log"), "{reason}"); + assert!( + fs::read_to_string(origin.dir.desk_log()) + .unwrap() + .contains("no store") + ); + } + + #[tokio::test] + async fn a_pond_that_rejects_socket_is_too_old() { + let sandbox = Sandbox::new(); + let pond = write_script( + &sandbox.path("bin/pond"), + "echo \"error: unexpected argument '--socket' found\" >&2; exit 2", + ); + sandbox.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); + let result = connect(&sandbox.origin(), None).await; + assert!(matches!(result, Err(ApiError::PondTooOld))); + } + + #[tokio::test] + async fn another_usage_error_is_not_too_old() { + let sandbox = Sandbox::new(); + let pond = write_script( + &sandbox.path("bin/pond"), + "echo \"error: invalid value 'x' for '--storage-path <PATH>'\" >&2; exit 2", + ); + sandbox.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); + let origin = sandbox.origin(); + let log = origin.dir.desk_log(); + fs::create_dir_all(log.parent().unwrap()).unwrap(); + fs::write(&log, "error: unexpected argument '--socket' found\n").unwrap(); + let Err(ApiError::Unreachable(reason)) = connect(&origin, None).await else { + panic!("expected Unreachable"); + }; + assert!(reason.contains("desk-serve.log"), "{reason}"); + } + + #[tokio::test] + async fn a_retired_fallback_keeps_its_successors_socket() { + let sandbox = Sandbox::new(); + let origin = sandbox.origin(); + let first_pond = ready_pond().await; + sandbox.fake_serve(Some(&first_pond.socket), "trap '' TERM; exec sleep 30"); + let first = connect(&origin, None).await.unwrap(); + let retired = first.fallback.as_ref().unwrap().serve.id(); + + drop(first_pond); + let second_pond = ready_pond().await; + sandbox.fake_serve(Some(&second_pond.socket), "exec sleep 30"); + let second = connect(&origin, first.fallback).await.unwrap(); + let socket = second.fallback.as_ref().unwrap().serve.socket.clone(); + assert_eq!(second.socket.path, socket); + assert_eq!(second_pond.recorded().len(), 1); + + let deadline = Instant::now() + FALLBACK_GRACE * 3; + while alive(retired) { + assert!(Instant::now() < deadline, "the retired fallback survived"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + tokio::time::sleep(Duration::from_millis(200)).await; + assert!(socket.exists(), "the retired fallback removed it"); + } + + #[tokio::test] + async fn missing_pond_names_the_config_key() { + let sandbox = Sandbox::new(); + sandbox.write_config("pond_bin = \"/nonexistent/pond\"\n"); + let origin = sandbox.origin(); + let Err(ApiError::Unreachable(reason)) = connect(&origin, None).await else { + panic!("expected Unreachable"); + }; + assert!(reason.contains("pond_bin"), "{reason}"); + } +} diff --git a/packages/herdr-pond/src/types.rs b/packages/herdr-pond/src/types.rs new file mode 100644 index 00000000..9f4eca9b --- /dev/null +++ b/packages/herdr-pond/src/types.rs @@ -0,0 +1,644 @@ +//! The seam between the desk and everything that talks to pond or herdr: the +//! [`Api`] trait, the rows it returns, the pond wire mirrors, and every SQL +//! query the desk runs. No SQL may live anywhere else in the crate. + +use std::collections::BTreeSet; +use std::fmt; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; + +use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +pub(crate) const PROTOCOL_VERSION: u16 = 1; + +/// Boxed so `dyn Api` stays object-safe and the mock and the HTTP client +/// interchange behind one `Arc<dyn Api>`. +pub(crate) type ApiFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApiError>> + Send + 'a>>; + +/// Everything the desk reads. The HTTP implementation resolves (or spawns) a +/// `pond serve` lazily on first use, so a call can take as long as a cold store +/// open; the desk shows its loading state for the whole wait. +pub(crate) trait Api: Send + Sync { + fn list_sessions(&self, scope: ListingScope) -> ApiFuture<'_, Vec<SessionRow>>; + // The page-scoped hydration queries: order unspecified, and empty input + // returns empty without a request. + /// Each session's first user message, at most one row per id; none without one. + fn titles(&self, session_ids: Vec<String>) -> ApiFuture<'_, Vec<SessionTitle>>; + /// Each session's whole message count and first and last timestamps, at most one row per id. + fn stats(&self, session_ids: Vec<String>) -> ApiFuture<'_, Vec<SessionStats>>; + /// Each session's origin host, read from its first message only, at most one per id. + fn hosts(&self, starts: Vec<SessionStart>) -> ApiFuture<'_, Vec<SessionHost>>; + fn search(&self, request: SearchRequest) -> ApiFuture<'_, SearchResponse>; + /// Newest first, at most [`PREVIEW_ROWS`]. + fn preview(&self, session_id: String) -> ApiFuture<'_, Vec<TranscriptMessage>>; + /// Chronological page strictly after `after`. The last page is the first + /// one shorter than [`PAGE_ROWS`] that was not `truncated`. + fn page(&self, session_id: String, after: Option<Cursor>) -> ApiFuture<'_, TranscriptPage>; + /// Agents running in herdr panes right now, for the live-row glyph and jump. + fn live_agents(&self) -> ApiFuture<'_, Vec<LiveAgent>>; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ListingScope { + /// Exact project path; sessions in its subdirectories match too. + pub project: Option<String>, + /// `None` is the all-time listing, an unbounded scan of every message. + pub since: Option<DateTime<Utc>>, + pub limit: usize, +} + +impl ListingScope { + /// The opening view: the last [`LISTING_WINDOW_DAYS`], at most [`LISTING_ROWS`]. + pub(crate) fn recent(project: Option<String>, now: DateTime<Utc>) -> Self { + Self { + project, + since: Some(now - TimeDelta::days(LISTING_WINDOW_DAYS)), + limit: LISTING_ROWS, + } + } +} + +/// `first_ts` and `message_count` cover only the listing's window: they are +/// the whole session's only when nothing precedes the window start. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct SessionRow { + pub session_id: String, + pub last_ts: DateTime<Utc>, + pub first_ts: DateTime<Utc>, + pub message_count: u64, + pub source_agent: String, + pub project: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SessionTitle { + pub session_id: String, + /// First non-empty user message, clipped server-side. + #[serde(default)] + pub title: Option<String>, +} + +/// Whole-session, whatever the listing window, as of `last_ts`. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SessionStats { + pub session_id: String, + pub message_count: u64, + pub first_ts: DateTime<Utc>, + pub last_ts: DateTime<Utc>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SessionStart { + pub session_id: String, + pub first_ts: DateTime<Utc>, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SessionHost { + pub session_id: String, + /// `None` means unknown provenance (pre-stamp rows), never "this machine". + #[serde(default)] + pub host: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct TranscriptMessage { + pub message_id: String, + pub timestamp: DateTime<Utc>, + pub role: String, + #[serde(rename = "search_text")] + pub text: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct TranscriptPage { + pub messages: Vec<TranscriptMessage>, + /// The server dropped rows to fit its byte budget: fetch again after the + /// last message even when the page came back short. + pub truncated: bool, +} + +/// Keyset position `(timestamp, message_id)`. Timestamps tie, so both halves +/// are needed to neither skip nor repeat rows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Cursor { + pub timestamp: DateTime<Utc>, + pub message_id: String, +} + +impl Cursor { + pub(crate) fn after(message: &TranscriptMessage) -> Self { + Self { + timestamp: message.timestamp, + message_id: message.message_id.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LiveAgent { + pub pane_id: String, + /// herdr's `agent_session` value: a session id, or a path whose file name + /// contains one. + pub session: String, +} + +impl LiveAgent { + pub(crate) fn matches(&self, session_id: &str) -> bool { + self.session == session_id + || self + .session + .rsplit('/') + .next() + .is_some_and(|file| file.contains(session_id)) + } +} + +/// What the desk is opened on, computed by the caller from herdr's context. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct DeskContext { + /// The underlying pane's cwd (`focused_pane_cwd`, else `workspace_cwd`). + pub project: Option<String>, + /// This machine's hostname, to tell its sessions from other machines'. + pub hostname: Option<String>, + /// Where the desk keeps its cache between opens; `None` keeps nothing. + pub state_dir: Option<PathBuf>, +} + +/// How the desk leaves: the caller runs the jump only after the terminal has +/// been restored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DeskExit { + Quit, + Jump { pane_id: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ApiError { + /// A pond error envelope; `message` is pond's enriched text, shown verbatim. + Pond { code: String, message: String }, + /// A non-envelope rejection (axum's plain-text JSON/route errors). + Rejected { status: u16, body: String }, + /// The installed pond predates `/v1/x/sql` or `pond serve --socket`. + PondTooOld, + /// Connection refused or no socket (the serve is gone), or no serve could + /// be started. + Unreachable(String), + /// Timed out or cut off mid-response; the serve may still be alive. + Request(String), + /// The response did not match the contract. + Decode(String), + /// A herdr CLI call failed. + Herdr(String), +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pond { code, message } => write!(f, "pond {code}: {message}"), + Self::Rejected { status, body } => write!(f, "HTTP {status}: {body}"), + Self::PondTooOld => f.write_str( + "this pond is too old for the desk (needs /v1/x/sql and `pond serve --socket`) - upgrade pond (`brew upgrade pond` / `cargo install pond-db`)", + ), + Self::Unreachable(reason) => write!(f, "pond serve unreachable: {reason}"), + Self::Request(reason) => write!(f, "request to pond serve failed: {reason}"), + Self::Decode(reason) => write!(f, "unexpected response from pond: {reason}"), + Self::Herdr(reason) => write!(f, "herdr: {reason}"), + } + } +} + +impl std::error::Error for ApiError {} + +// ---- pond wire mirrors (packages/pond/src/wire.rs) ---- + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct SqlRequest { + protocol_version: u16, + pub query: String, + /// Always the query's own SQL `LIMIT`, so the server's default 100-row + /// cap never cuts a page. + pub limit: usize, + pub timeout_seconds: u64, +} + +impl SqlRequest { + pub(crate) fn new(query: String, limit: usize, timeout_seconds: u64) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + query, + limit, + timeout_seconds, + } + } +} + +/// NULL fields are omitted from `rows`: decode with `#[serde(default)]`, +/// never by key presence. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SqlResponse { + pub rows: Vec<serde_json::Value>, + pub truncated: bool, +} + +impl SqlResponse { + pub(crate) fn into_rows<T: DeserializeOwned>(self) -> Result<Vec<T>, ApiError> { + self.rows + .into_iter() + .map(|row| { + serde_json::from_value(row).map_err(|error| ApiError::Decode(error.to_string())) + }) + .collect() + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct ErrorEnvelope { + pub error: ErrorBody, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct ErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct SearchRequest { + protocol_version: u16, + pub query: String, + pub filters: SearchFilters, + pub limit: usize, +} + +impl SearchRequest { + pub(crate) fn new(query: String, limit: usize) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + query, + filters: SearchFilters::default(), + limit, + } + } + + /// A scope as search filters; `from_date` is a calendar day. + pub(crate) fn within(mut self, scope: &ListingScope) -> Self { + self.filters = SearchFilters { + project: scope.project.clone().map(ProjectFilter::Contains), + from_date: scope + .since + .map(|since| since.format("%Y-%m-%d").to_string()), + }; + self + } +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize)] +pub(crate) struct SearchFilters { + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option<ProjectFilter>, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_date: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ProjectFilter { + Contains(String), +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SearchResponse { + pub sessions: Vec<SearchSession>, + /// 0 means the filters excluded everything before retrieval - distinct + /// from "nothing matched". + #[serde(default)] + pub searchable_in_scope: usize, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SearchSession { + pub session_id: String, + pub source_agent: String, + pub session_messages_count: usize, + pub matched_message_count: usize, + pub matches: Vec<SearchMatch>, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SearchMatch { + pub timestamp: DateTime<Utc>, + pub text: String, +} + +// ---- SQL ---- +// +// Every query carries an explicit LIMIT, since the server's row caps apply +// only after full collection and do not bound the scan; the listing +// scans narrow columns with a literal `timestamp >=` bound the zonemap can +// prune; JSON getters run only in page-scoped (`session_id IN (...)`) queries. +// Every interpolated value goes through `quote` - no other escaping exists. + +pub(crate) const READY_SQL: &str = "SELECT 1 AS ready"; + +pub(crate) const LISTING_ROWS: usize = 200; +pub(crate) const PREVIEW_ROWS: usize = 12; +pub(crate) const PAGE_ROWS: usize = 50; +pub(crate) const TITLE_CHARS: usize = 240; +pub(crate) const LISTING_WINDOW_DAYS: i64 = 14; + +pub(crate) fn listing_sql(scope: &ListingScope) -> String { + let mut filters = vec!["source_agent NOT LIKE '%/%'".to_owned()]; + if let Some(since) = scope.since { + filters.push(format!( + "timestamp >= TIMESTAMP {}", + timestamp_literal(since) + )); + } + if let Some(project) = &scope.project { + let project = project.trim_end_matches('/'); + filters.push(format!( + "(project = {} OR starts_with(project, {}))", + quote(project), + quote(&format!("{project}/")) + )); + } + format!( + "SELECT session_id, MAX(timestamp) AS last_ts, MIN(timestamp) AS first_ts, \ + COUNT(*) AS message_count, MIN(source_agent) AS source_agent, \ + MIN(project) AS project FROM messages WHERE {} GROUP BY session_id \ + ORDER BY last_ts DESC, session_id LIMIT {}", + filters.join(" AND "), + scope.limit + ) +} + +/// `search_text` stays out of the WHERE clause: there it would be read for +/// every candidate row, while the aggregate FILTER reads it only for the user +/// rows that pass. +pub(crate) fn titles_sql(session_ids: &[String]) -> String { + format!( + "SELECT session_id, substr(first_value(search_text ORDER BY timestamp, message_id) \ + FILTER (WHERE search_text <> ''), 1, {TITLE_CHARS}) AS title FROM messages \ + WHERE session_id IN ({}) AND role = 'user' GROUP BY session_id LIMIT {}", + id_list(session_ids.iter()), + session_ids.len() + ) +} + +/// Narrow columns only: the whole-session count and start, and the newest +/// activity the count covers. +pub(crate) fn stats_sql(session_ids: &[String]) -> String { + format!( + "SELECT session_id, COUNT(*) AS message_count, MIN(timestamp) AS first_ts, \ + MAX(timestamp) AS last_ts FROM messages WHERE session_id IN ({}) \ + GROUP BY session_id LIMIT {}", + id_list(session_ids.iter()), + session_ids.len() + ) +} + +/// The wide `options` column is read only for rows at a session's first +/// timestamp. One session's row can share another's start, so the ordering +/// by timestamp keeps each session's own first row. +pub(crate) fn hosts_sql(starts: &[SessionStart]) -> String { + let timestamps = starts + .iter() + .map(|start| format!("TIMESTAMP {}", timestamp_literal(start.first_ts))) + .collect::<BTreeSet<_>>() + .into_iter() + .collect::<Vec<_>>() + .join(", "); + format!( + "SELECT session_id, first_value(json_get_string(options, 'pond', 'ingest', 'host', \ + 'hostname') ORDER BY timestamp, message_id) AS host FROM messages \ + WHERE session_id IN ({}) AND timestamp IN ({timestamps}) GROUP BY session_id LIMIT {}", + id_list(starts.iter().map(|start| &start.session_id)), + starts.len() + ) +} + +fn id_list<'a>(ids: impl Iterator<Item = &'a String>) -> String { + ids.map(|id| quote(id)).collect::<Vec<_>>().join(", ") +} + +pub(crate) fn preview_sql(session_id: &str) -> String { + format!( + "SELECT message_id, timestamp, role, search_text FROM messages \ + WHERE session_id = {} AND search_text <> '' \ + ORDER BY timestamp DESC, message_id DESC LIMIT {PREVIEW_ROWS}", + quote(session_id) + ) +} + +/// DataFusion rejects the row-value form `(timestamp, message_id) > (...)` with +/// a timestamp literal, so the seek predicate is spelled out. +pub(crate) fn page_sql(session_id: &str, after: Option<&Cursor>) -> String { + let seek = after.map_or_else(String::new, |cursor| { + let ts = timestamp_literal(cursor.timestamp); + format!( + " AND (timestamp > TIMESTAMP {ts} OR (timestamp = TIMESTAMP {ts} AND message_id > {}))", + quote(&cursor.message_id) + ) + }); + format!( + "SELECT message_id, timestamp, role, search_text FROM messages \ + WHERE session_id = {} AND search_text <> ''{seek} \ + ORDER BY timestamp, message_id LIMIT {PAGE_ROWS}", + quote(session_id) + ) +} + +/// A single-quoted SQL string literal. +pub(crate) fn quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +/// Microsecond precision: the store's resolution, so a cursor round-trips exactly. +pub(crate) fn timestamp_literal(ts: DateTime<Utc>) -> String { + quote(&ts.to_rfc3339_opts(SecondsFormat::Micros, true)) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use super::*; + use crate::fake_pond::{golden, ts}; + + #[test] + fn quote_escapes_single_quotes() { + assert_eq!(quote("it's"), "'it''s'"); + } + + #[test] + fn listing_is_bounded_and_scoped() { + let sql = listing_sql(&ListingScope { + project: Some("/home/me/pj/pond/".to_owned()), + since: Some(ts("2026-09-11T00:00:00Z")), + limit: 200, + }); + assert!(sql.contains("timestamp >= TIMESTAMP '2026-09-11T00:00:00.000000Z'")); + assert!(sql.contains("project = '/home/me/pj/pond'")); + assert!(sql.contains("starts_with(project, '/home/me/pj/pond/')")); + assert!(sql.contains("MIN(timestamp) AS first_ts, COUNT(*) AS message_count")); + assert!(sql.ends_with("LIMIT 200")); + } + + #[test] + fn hydration_queries_are_page_scoped_and_bounded() { + let ids = ["a".to_owned(), "b'c".to_owned()]; + let titles = titles_sql(&ids); + assert!(titles.contains("WHERE session_id IN ('a', 'b''c') AND role = 'user'")); + assert!( + titles.contains("FILTER (WHERE search_text <> '')"), + "{titles}" + ); + assert!( + !titles + .split(" WHERE session_id") + .nth(1) + .unwrap() + .contains("search_text"), + "search_text in WHERE is read for every candidate row: {titles}" + ); + assert!(titles.ends_with("GROUP BY session_id LIMIT 2")); + + let stats = stats_sql(&ids); + assert!(stats.contains( + "COUNT(*) AS message_count, MIN(timestamp) AS first_ts, MAX(timestamp) AS last_ts" + )); + assert!(stats.ends_with("LIMIT 2")); + assert!(!stats.contains("search_text") && !stats.contains("options")); + + let tied = ts("2026-09-20T10:00:00Z"); + let starts: Vec<SessionStart> = [ + ("a", tied), + ("b", tied), + ("c", ts("2026-09-21T10:00:00.5Z")), + ] + .into_iter() + .map(|(id, first_ts)| SessionStart { + session_id: id.to_owned(), + first_ts, + }) + .collect(); + let hosts = hosts_sql(&starts); + assert!( + hosts.contains("WHERE session_id IN ('a', 'b', 'c')"), + "{hosts}" + ); + assert!( + hosts.contains( + "timestamp IN (TIMESTAMP '2026-09-20T10:00:00.000000Z', \ + TIMESTAMP '2026-09-21T10:00:00.500000Z')" + ), + "one literal per distinct start: {hosts}" + ); + assert!(hosts.contains("ORDER BY timestamp, message_id) AS host")); + assert!(hosts.ends_with("GROUP BY session_id LIMIT 3")); + } + + #[test] + fn all_time_listing_has_no_time_bound() { + let sql = listing_sql(&ListingScope { + project: None, + since: None, + limit: 10, + }); + assert!(!sql.contains("timestamp >=")); + assert!(!sql.contains("project =")); + } + + #[test] + fn page_seek_uses_the_composite_cursor() { + let cursor = Cursor { + timestamp: ts("2026-09-22T14:24:45.991123Z"), + message_id: "m'1".to_owned(), + }; + let sql = page_sql("s1", Some(&cursor)); + assert!(sql.contains( + "(timestamp > TIMESTAMP '2026-09-22T14:24:45.991123Z' OR (timestamp = TIMESTAMP \ + '2026-09-22T14:24:45.991123Z' AND message_id > 'm''1'))" + )); + assert!(sql.contains("ORDER BY timestamp, message_id LIMIT 50")); + assert!(!page_sql("s1", None).contains("message_id >")); + } + + #[test] + fn every_query_carries_a_limit() { + let scope = ListingScope { + project: None, + since: None, + limit: 5, + }; + let ids = ["a".to_owned()]; + let starts = [SessionStart { + session_id: "a".to_owned(), + first_ts: ts("2026-09-20T10:00:00Z"), + }]; + for sql in [ + listing_sql(&scope), + titles_sql(&ids), + stats_sql(&ids), + hosts_sql(&starts), + preview_sql("a"), + page_sql("a", None), + ] { + assert!(sql.contains(" LIMIT "), "{sql}"); + } + for unscoped in [listing_sql(&scope), stats_sql(&ids)] { + assert!(!unscoped.contains("json_get"), "{unscoped}"); + } + } + + #[test] + fn golden_sql_response_decodes() { + let decode = |body| serde_json::from_str::<SqlResponse>(body).unwrap(); + let rows: Vec<SessionRow> = decode(golden::SQL_LISTING).into_rows().unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].last_ts, ts("2026-09-25T04:00:02.384123Z")); + + assert_eq!(rows[1].first_ts, ts("2026-09-23T19:20:00Z")); + assert_eq!(rows[1].message_count, 3); + + let hosts: Vec<SessionHost> = decode(golden::SQL_HOSTS).into_rows().unwrap(); + assert_eq!(hosts[1].host, None); + let titles: Vec<SessionTitle> = decode(golden::SQL_TITLES).into_rows().unwrap(); + assert_eq!(titles.len(), 1); + let stats: Vec<SessionStats> = decode(golden::SQL_STATS).into_rows().unwrap(); + assert_eq!(stats[0].message_count, 94); + assert_eq!(stats[1].last_ts, ts("2026-09-23T19:29:20.1Z")); + + let messages: Vec<TranscriptMessage> = decode(golden::SQL_PAGE).into_rows().unwrap(); + assert_eq!(messages[0].timestamp, messages[1].timestamp); + } + + #[test] + fn golden_search_and_error_decode() { + let search: SearchResponse = serde_json::from_str(golden::SEARCH).unwrap(); + assert_eq!(search.sessions[0].matches[1].text, "timer fixed"); + let empty: SearchResponse = serde_json::from_str(golden::SEARCH_OUT_OF_SCOPE).unwrap(); + assert_eq!(empty.searchable_in_scope, 0); + let error: ErrorEnvelope = serde_json::from_str(golden::SQL_ERROR).unwrap(); + assert_eq!(error.error.code, "validation_failed"); + } + + #[test] + fn live_agent_matches_id_or_path() { + let by_id = LiveAgent { + pane_id: "p1".to_owned(), + session: "abc".to_owned(), + }; + let by_path = LiveAgent { + session: "/home/me/.codex/sessions/rollout-2026-abc.jsonl".to_owned(), + ..by_id.clone() + }; + assert!(by_id.matches("abc")); + assert!(by_path.matches("abc")); + assert!(!by_path.matches("xyz")); + } +}