From c6fec02bad8cae59fb34794d57fd17c8162c8e39 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 04:15:56 +0000 Subject: [PATCH 01/41] feat(herdr-pond): scaffold plugin crate and freeze the desk contract Workspace member (default-members keeps root builds pond-only), manifest, launcher symlink, moon/CI/release-plz wiring, the Api seam with every desk SQL query, golden /v1/x/sql and /v1/search bodies, and a fake pond server. --- .github/release-plz.toml | 6 + .github/workflows/ci.yml | 1 + .moon/workspace.yml | 6 +- Cargo.lock | 789 ++++++++++++++++++++++++-- Cargo.toml | 5 +- packages/herdr-pond/Cargo.toml | 44 ++ packages/herdr-pond/bin/herdr-pond | 1 + packages/herdr-pond/herdr-plugin.toml | 28 + packages/herdr-pond/moon.yml | 31 + packages/herdr-pond/src/api.rs | 3 + packages/herdr-pond/src/config.rs | 2 + packages/herdr-pond/src/daemon.rs | 6 + packages/herdr-pond/src/desk/mod.rs | 12 + packages/herdr-pond/src/fake_pond.rs | 205 +++++++ packages/herdr-pond/src/herdr.rs | 6 + packages/herdr-pond/src/hook.rs | 6 + packages/herdr-pond/src/main.rs | 46 ++ packages/herdr-pond/src/serve.rs | 3 + packages/herdr-pond/src/types.rs | 474 ++++++++++++++++ 19 files changed, 1638 insertions(+), 36 deletions(-) create mode 100644 packages/herdr-pond/Cargo.toml create mode 120000 packages/herdr-pond/bin/herdr-pond create mode 100644 packages/herdr-pond/herdr-plugin.toml create mode 100644 packages/herdr-pond/moon.yml create mode 100644 packages/herdr-pond/src/api.rs create mode 100644 packages/herdr-pond/src/config.rs create mode 100644 packages/herdr-pond/src/daemon.rs create mode 100644 packages/herdr-pond/src/desk/mod.rs create mode 100644 packages/herdr-pond/src/fake_pond.rs create mode 100644 packages/herdr-pond/src/herdr.rs create mode 100644 packages/herdr-pond/src/hook.rs create mode 100644 packages/herdr-pond/src/main.rs create mode 100644 packages/herdr-pond/src/serve.rs create mode 100644 packages/herdr-pond/src/types.rs diff --git a/.github/release-plz.toml b/.github/release-plz.toml index 1ed3b8da..50d1f187 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 35f2c714..9eeca4ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,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 f65fe9bd..07f5a329 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", @@ -2998,6 +3077,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" @@ -3031,13 +3119,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", ] @@ -3070,12 +3168,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" @@ -3088,7 +3209,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", ] @@ -3937,6 +4058,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" @@ -4476,6 +4616,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" @@ -4504,6 +4653,19 @@ dependencies = [ "tempfile", ] +[[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" @@ -4516,7 +4678,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", ] @@ -4712,6 +4874,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" @@ -4729,6 +4902,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" @@ -5450,6 +5629,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" @@ -5508,6 +5696,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" @@ -5542,6 +5739,16 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -5631,13 +5838,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", @@ -5691,6 +5913,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", ] @@ -5764,13 +5987,26 @@ 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", + "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", @@ -5869,6 +6105,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" @@ -5942,6 +6189,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" @@ -5972,7 +6228,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", ] @@ -5989,7 +6245,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", @@ -6012,7 +6268,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", @@ -6087,7 +6343,7 @@ dependencies = [ "hyper", "itertools 0.15.0", "md-5", - "nix", + "nix 0.31.3", "parking_lot", "percent-encoding", "quick-xml", @@ -6148,7 +6404,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", @@ -6469,6 +6725,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" @@ -6503,6 +6768,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" @@ -6665,25 +6963,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]] @@ -7256,13 +7648,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]] @@ -7323,7 +7816,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]] @@ -7781,7 +8274,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", @@ -7833,7 +8326,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", @@ -8132,7 +8625,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", @@ -8378,6 +8871,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" @@ -8649,7 +9163,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]] @@ -8664,6 +9187,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" @@ -8671,7 +9206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8eaaf563e9de627dadbe66ff8b7ef8f065fc69844c90ed6acdc90bdc9f0571" dependencies = [ "duplicate", - "strum", + "strum 0.27.2", "thiserror 2.0.18", ] @@ -8687,6 +9222,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" @@ -8735,7 +9281,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", @@ -8763,7 +9309,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", ] @@ -8803,12 +9349,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" @@ -8903,7 +9525,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -9249,7 +9873,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", @@ -9417,6 +10041,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" @@ -9516,6 +10146,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" @@ -9627,6 +10268,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", @@ -9657,6 +10299,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" @@ -9836,7 +10487,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", @@ -9880,6 +10531,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" @@ -10301,7 +11024,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/packages/herdr-pond/Cargo.toml b/packages/herdr-pond/Cargo.toml new file mode 100644 index 00000000..a749110f --- /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"] } +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/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..d5aeb884 --- /dev/null +++ b/packages/herdr-pond/moon.yml @@ -0,0 +1,31 @@ +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' + - 'herdr-plugin.toml' + - 'bin/**/*' + - '/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..9fc983d2 --- /dev/null +++ b/packages/herdr-pond/src/api.rs @@ -0,0 +1,3 @@ +//! The HTTP [`Api`](crate::types::Api) implementation over `pond serve` +//! (`/v1/x/sql`, `/v1/search`), plus herdr's pane list for live agents. +//! Owner: agent B. Tested against [`crate::fake_pond`]. diff --git a/packages/herdr-pond/src/config.rs b/packages/herdr-pond/src/config.rs new file mode 100644 index 00000000..f41c4de2 --- /dev/null +++ b/packages/herdr-pond/src/config.rs @@ -0,0 +1,2 @@ +//! `HERDR_PLUGIN_CONFIG_DIR/config.toml`, re-read per run; malformed falls back +//! to defaults (plan 5.3). Owner: agent B. diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs new file mode 100644 index 00000000..e56625e6 --- /dev/null +++ b/packages/herdr-pond/src/daemon.rs @@ -0,0 +1,6 @@ +//! The per-herdr-server `pond serve` owner: startup hook and detached +//! watchdog (plan 5.6). Owner: agent B. + +pub(crate) fn run(_args: &[String]) -> anyhow::Result<()> { + anyhow::bail!("not implemented") +} diff --git a/packages/herdr-pond/src/desk/mod.rs b/packages/herdr-pond/src/desk/mod.rs new file mode 100644 index 00000000..c96079ad --- /dev/null +++ b/packages/herdr-pond/src/desk/mod.rs @@ -0,0 +1,12 @@ +//! The session desk TUI (plan section 6). Owner: agent C - everything under +//! `desk/` and nothing outside it. + +use std::sync::Arc; + +use crate::types::{Api, DeskContext, DeskExit}; + +/// Builds its own current-thread runtime and owns the terminal until it +/// returns; the terminal is restored on every return path. +pub(crate) fn run(_api: Arc, _context: DeskContext) -> anyhow::Result { + anyhow::bail!("not implemented") +} diff --git a/packages/herdr-pond/src/fake_pond.rs b/packages/herdr-pond/src/fake_pond.rs new file mode 100644 index 00000000..bd257c8a --- /dev/null +++ b/packages/herdr-pond/src/fake_pond.rs @@ -0,0 +1,205 @@ +//! A canned-response stand-in for `pond serve`, so the HTTP client is tested +//! against real bytes on a real socket - trait mocks alone would let the +//! client's serialization drift while every test stays green. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// 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","source_agent","project"],"rows":[ + {"session_id":"s-live","last_ts":"2026-09-25T04:00:02.384123Z","source_agent":"claude-code","project":"/home/me/pj/pond"}, + {"session_id":"s-old","last_ts":"2026-09-23T19:29:20.100000Z","source_agent":"codex-cli","project":"/home/me/pj/pond/packages/pond"} + ],"row_count":2,"truncated":false,"elapsed_ms":1712}"#; + + /// Nulls are omitted: `s-old` has no user message and no host stamp. + pub(crate) const SQL_HYDRATE: &str = r#"{"columns":["session_id","message_count","title","host"],"rows":[ + {"session_id":"s-live","message_count":94,"title":"fix the timer re-arm","host":"ws-pond-01"}, + {"session_id":"s-old","message_count":3} + ],"row_count":2,"truncated":false,"elapsed_ms":380}"#; + + /// 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 { + pub status: u16, + pub body: String, + pub content_type: &'static str, + pub 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 body: String, +} + +type Router = dyn Fn(&str, &str) -> Reply + Send + Sync; + +/// A loopback HTTP/1.1 server answering each request through `router(path, +/// body)`. Every request is recorded, so tests can assert on the SQL sent. +pub(crate) struct FakePond { + pub base_url: String, + pub requests: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl FakePond { + pub(crate) async fn start( + router: impl Fn(&str, &str) -> Reply + Send + Sync + 'static, + ) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let router: Arc = 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 { + base_url, + 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 { + "/v1/x/sql" => routes + .iter() + .find(|(needle, _)| body.contains(needle)) + .map_or_else( + || Reply::status(400, golden::SQL_ERROR), + |(_, reply)| reply.clone(), + ), + "/v1/search" => search.clone(), + _ => Reply::plain(404, ""), + }) + .await + } + + pub(crate) fn recorded(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +impl Drop for FakePond { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn serve_one( + mut stream: tokio::net::TcpStream, + router: Arc, + recorded: Arc>>, +) { + 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::().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 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, 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; +} diff --git a/packages/herdr-pond/src/herdr.rs b/packages/herdr-pond/src/herdr.rs new file mode 100644 index 00000000..de2ce103 --- /dev/null +++ b/packages/herdr-pond/src/herdr.rs @@ -0,0 +1,6 @@ +//! Every herdr CLI call (`pane list`, `agent focus`, `plugin pane open`, +//! `notification show`) and the plugin runtime env (plan 5.2, 5.4). Owner: agent B. + +pub(crate) fn open_desk() -> anyhow::Result<()> { + anyhow::bail!("not implemented") +} diff --git a/packages/herdr-pond/src/hook.rs b/packages/herdr-pond/src/hook.rs new file mode 100644 index 00000000..68866fcb --- /dev/null +++ b/packages/herdr-pond/src/hook.rs @@ -0,0 +1,6 @@ +//! Sync-on-idle: the millisecond event hook and its detached per-adapter +//! worker (plan 5.5). Owner: agent B. + +pub(crate) fn run(_args: &[String]) -> anyhow::Result<()> { + anyhow::bail!("not implemented") +} diff --git a/packages/herdr-pond/src/main.rs b/packages/herdr-pond/src/main.rs new file mode 100644 index 00000000..95e55515 --- /dev/null +++ b/packages/herdr-pond/src/main.rs @@ -0,0 +1,46 @@ +//! herdr plugin for pond: sync-on-idle and a read-only session desk. +//! +//! 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::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = 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: herdr-pond open|tui|hook|serve-daemon" + )), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("herdr-pond {command}: {error:#}"); + ExitCode::FAILURE + } + } +} + +/// Runs the desk, then performs a jump only after it has restored the terminal. +fn desk_main() -> anyhow::Result<()> { + anyhow::bail!("not implemented") +} diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs new file mode 100644 index 00000000..6f7ef6ea --- /dev/null +++ b/packages/herdr-pond/src/serve.rs @@ -0,0 +1,3 @@ +//! Finding a usable `pond serve` for the desk: this herdr server's published +//! endpoint, else a desk-owned fallback child (plan 5.7), both vetted by the +//! capability probe (plan 5.8). Owner: agent B. diff --git a/packages/herdr-pond/src/types.rs b/packages/herdr-pond/src/types.rs new file mode 100644 index 00000000..c4fe59cb --- /dev/null +++ b/packages/herdr-pond/src/types.rs @@ -0,0 +1,474 @@ +//! 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::fmt; +use std::future::Future; +use std::pin::Pin; + +use chrono::{DateTime, SecondsFormat, Utc}; +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`. +pub(crate) type ApiFuture<'a, T> = Pin> + 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>; + /// One row per id that exists; order is unspecified. Empty input returns + /// empty without a request. + fn hydrate(&self, session_ids: Vec) -> ApiFuture<'_, Vec>; + fn search(&self, request: SearchRequest) -> ApiFuture<'_, SearchResponse>; + /// Newest first, at most [`PREVIEW_ROWS`]. + fn preview(&self, session_id: String) -> ApiFuture<'_, Vec>; + /// 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) -> ApiFuture<'_, TranscriptPage>; + /// Agents running in herdr panes right now, for the live-row glyph and jump. + fn live_agents(&self) -> ApiFuture<'_, Vec>; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ListingScope { + /// Exact project path; sessions in its subdirectories match too. + pub project: Option, + /// `None` is the all-time listing - the slow query family. + pub since: Option>, + pub limit: usize, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SessionRow { + pub session_id: String, + pub last_ts: DateTime, + pub source_agent: String, + pub project: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SessionDetail { + pub session_id: String, + /// Whole-session count, not limited to the listing window. + pub message_count: u64, + /// First non-empty user message, clipped server-side. + #[serde(default)] + pub title: Option, + /// `None` means unknown provenance (pre-stamp rows), never "this machine". + #[serde(default)] + pub host: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct TranscriptMessage { + pub message_id: String, + pub timestamp: DateTime, + pub role: String, + #[serde(rename = "search_text")] + pub text: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct TranscriptPage { + pub messages: Vec, + /// 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, + 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, + pub agent: Option, + /// 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, +} + +/// 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 }, + /// `/v1/x/sql` is missing: the installed pond predates the endpoint. + PondTooOld, + /// Refused, timed out, or no serve could be started. + Unreachable(String), + /// The response did not match the contract. + Decode(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 has no /v1/x/sql - upgrade pond (`brew upgrade pond` / `cargo install pond-db`)", + ), + Self::Unreachable(reason) => write!(f, "pond serve unreachable: {reason}"), + Self::Decode(reason) => write!(f, "unexpected response from pond: {reason}"), + } + } +} + +impl std::error::Error for ApiError {} + +// ---- pond wire mirrors (packages/pond/src/wire.rs) ---- + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct SqlRequest { + pub protocol_version: u16, + pub query: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, +} + +/// NULL fields are omitted from `rows`: decode with `#[serde(default)]`, +/// never by key presence. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SqlResponse { + pub columns: Vec, + pub rows: Vec, + pub row_count: usize, + pub truncated: bool, + pub elapsed_ms: u64, +} + +#[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 { + pub protocol_version: u16, + pub query: String, + pub filters: SearchFilters, + pub limit: usize, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize)] +pub(crate) struct SearchFilters { + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_date: Option, +} + +#[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, + pub matched_total: usize, + /// 0 means the filters excluded everything before retrieval - distinct + /// from "nothing matched". + #[serde(default)] + pub searchable_in_scope: usize, + pub has_more: bool, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SearchSession { + pub session_id: String, + pub project: String, + pub source_agent: String, + pub session_messages_count: usize, + pub matched_message_count: usize, + pub matches: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SearchMatch { + pub message_id: String, + pub role: String, + pub timestamp: DateTime, + pub text: String, + pub score: f64, +} + +// ---- SQL ---- +// +// Discipline (read-latency campaign): every query carries an explicit LIMIT, +// since the server's row caps apply only after full collection; 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(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 + ) +} + +pub(crate) fn hydrate_sql(session_ids: &[String]) -> String { + let ids = session_ids + .iter() + .map(|id| quote(id)) + .collect::>() + .join(", "); + format!( + "SELECT session_id, COUNT(*) AS message_count, \ + substr(first_value(search_text ORDER BY timestamp, message_id) \ + FILTER (WHERE role = 'user' AND search_text <> ''), 1, {TITLE_CHARS}) AS title, \ + MAX(json_get_string(options, 'pond', 'ingest', 'host', 'hostname')) AS host \ + FROM messages WHERE session_id IN ({ids}) GROUP BY session_id LIMIT {}", + session_ids.len() + ) +} + +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) -> 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; + + fn ts(raw: &str) -> DateTime { + raw.parse().unwrap() + } + + #[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.ends_with("LIMIT 200")); + } + + #[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, + }; + for sql in [ + listing_sql(&scope), + hydrate_sql(&["a".to_owned()]), + preview_sql("a"), + page_sql("a", None), + ] { + assert!(sql.contains(" LIMIT "), "{sql}"); + } + } + + #[test] + fn golden_sql_response_decodes() { + let response: SqlResponse = serde_json::from_str(golden::SQL_LISTING).unwrap(); + let rows: Vec = response + .rows + .into_iter() + .map(serde_json::from_value) + .collect::>() + .unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].last_ts, ts("2026-09-25T04:00:02.384123Z")); + + let details: SqlResponse = serde_json::from_str(golden::SQL_HYDRATE).unwrap(); + let details: Vec = details + .rows + .into_iter() + .map(serde_json::from_value) + .collect::>() + .unwrap(); + assert_eq!(details[1].host, None); + assert_eq!(details[1].title, None); + + let page: SqlResponse = serde_json::from_str(golden::SQL_PAGE).unwrap(); + let messages: Vec = page + .rows + .into_iter() + .map(serde_json::from_value) + .collect::>() + .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[0].role, "user"); + 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(), + agent: Some("claude".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")); + } +} From e3e0398033972cef4c3ae09d47567dde25064aae Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 04:34:07 +0000 Subject: [PATCH 02/41] feat(herdr-pond): session desk TUI The desk overlay per plan section 6: a hand-built current-thread runtime owning the terminal (restored on every return path), pure `on_event`/`apply` reducers emitting effects, request lanes with generation + view epoch + target identity, the 14-day listing with cached all-projects/all-time toggles and page-scoped hydration, debounced search and preview, a pre-wrapped keyset pager, live-row jump, and transcript hygiene. --- packages/herdr-pond/src/desk/app.rs | 1517 +++++++++++++++++++++++++++ packages/herdr-pond/src/desk/mod.rs | 553 +++++++++- packages/herdr-pond/src/desk/ui.rs | 667 ++++++++++++ 3 files changed, 2733 insertions(+), 4 deletions(-) create mode 100644 packages/herdr-pond/src/desk/app.rs create mode 100644 packages/herdr-pond/src/desk/ui.rs diff --git a/packages/herdr-pond/src/desk/app.rs b/packages/herdr-pond/src/desk/app.rs new file mode 100644 index 00000000..b7e0fb2e --- /dev/null +++ b/packages/herdr-pond/src/desk/app.rs @@ -0,0 +1,1517 @@ +//! 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, TimeDelta, 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::ui; +use crate::types::{ + ApiError, Cursor, DeskContext, DeskExit, LISTING_ROWS, LISTING_WINDOW_DAYS, ListingScope, + LiveAgent, PAGE_ROWS, PROTOCOL_VERSION, ProjectFilter, SearchFilters, SearchRequest, + SearchResponse, SessionDetail, SessionRow, 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)] +pub(super) enum Lane { + Listing, + Hydrate, + Live, + Search, + Preview, + Page, +} + +impl Lane { + pub(super) const COUNT: usize = 6; + 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), + Hydrate(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::Hydrate(_) => Lane::Hydrate, + Self::Live => Lane::Live, + Self::Search(_) => Lane::Search, + Self::Preview(_) => Lane::Preview, + Self::Page { .. } => Lane::Page, + } + } +} + +#[derive(Debug)] +pub(super) enum Reply { + Listing(Result, ApiError>), + Hydrate(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), + Exit(DeskExit), +} + +#[derive(Debug, Default, Clone, Copy)] +struct LaneState { + generation: u64, + loading: bool, +} + +#[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, + pub(super) title: 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, title: String, width: usize) -> Self { + Self { + session_id, + title, + 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(); + for message in &self.messages { + self.starts.push(self.lines.len()); + self.lines.extend(ui::message_lines(message, width)); + } + 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], + pub(super) all_projects: bool, + pub(super) all_time: bool, + listings: HashMap<(bool, bool), Vec>, + pub(super) details: HashMap, + /// Requested since the last listing fetch, so a refresh re-hydrates counts + /// while the old details stay on screen. + hydrated: HashSet, + 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, +} + +impl App { + pub(super) fn new(context: DeskContext, now: DateTime, size: Size) -> Self { + Self { + now, + context, + size, + epoch: 0, + lanes: [LaneState::default(); Lane::COUNT], + all_projects: false, + all_time: false, + listings: HashMap::new(), + details: HashMap::new(), + hydrated: HashSet::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, + } + } + + pub(super) fn start(&mut self) -> Vec { + self.refresh() + } + + pub(super) fn is_loading(&self) -> bool { + self.lanes.iter().any(|lane| lane.loading) + } + + pub(super) fn lane_loading(&self, lane: Lane) -> bool { + self.lanes[lane as usize].loading + } + + pub(super) fn tick(&mut self) { + self.spinner = self.spinner.wrapping_add(1); + self.dirty = true; + } + + pub(super) fn scope(&self) -> ListingScope { + ListingScope { + project: if self.all_projects { + None + } else { + self.context.project.clone() + }, + since: (!self.all_time).then(|| self.now - TimeDelta::days(LISTING_WINDOW_DAYS)), + limit: LISTING_ROWS, + } + } + + pub(super) fn listing(&self) -> Option<&[SessionRow]> { + self.listings + .get(&scope_key(&self.scope())) + .map(Vec::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), + } + } + + 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 + } + } + + 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 + } + + fn fetch(&mut self, call: Call, delay: Duration) -> Effect { + let lane = &mut self.lanes[call.lane() as usize]; + lane.generation += 1; + lane.loading = true; + 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.loading = false; + 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), + self.fetch(Call::Live, Duration::ZERO), + ]; + if let Some(query) = self.search.as_ref().map(|s| s.query.clone()) { + effects.push(self.fetch(Call::Search(self.search_request(query)), Duration::ZERO)); + } + effects.extend(self.preview_selected(Duration::ZERO)); + effects + } + + fn search_request(&self, query: String) -> SearchRequest { + let scope = self.scope(); + SearchRequest { + protocol_version: PROTOCOL_VERSION, + query, + filters: SearchFilters { + project: scope.project.map(ProjectFilter::Contains), + from_date: scope + .since + .map(|since| since.format("%Y-%m-%d").to_string()), + }, + limit: SEARCH_LIMIT, + } + } + + 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; + 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)); + } + let mut effects: Vec = self.hydrate_visible().into_iter().collect(); + effects.extend(self.load_more()); + effects + } + + 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: Vec = self.hydrate_visible().into_iter().collect(); + effects.extend(self.preview_selected(PREVIEW_DEBOUNCE)); + effects + } + + /// One hydrate per visible page: the rows around the selection that this + /// listing has not asked about yet, never the whole listing. + fn hydrate_visible(&mut self) -> Option { + if self.lane_loading(Lane::Hydrate) { + return None; + } + let len = self.rows_len(); + let selected = self.selected_index().unwrap_or(0); + let height = usize::from(ui::desk_areas(self.area(), self.preview_open).list.height).max(1); + let window = selected.saturating_sub(height)..(selected + height + 1).min(len); + let missing: Vec = window + .filter_map(|index| self.id_at(index)) + .filter(|id| !self.hydrated.contains(*id)) + .map(str::to_owned) + .collect(); + if missing.is_empty() { + return None; + } + self.hydrated.extend(missing.iter().cloned()); + Some(self.fetch(Call::Hydrate(missing), Duration::ZERO)) + } + + 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) => Some(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.push(self.fetch(Call::Search(self.search_request(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 + } + + fn toggle_scope(&mut self, projects: bool) -> Vec { + let selected = self.selected_listing_id(); + if projects { + if self.context.project.is_none() { + self.toast = Some( + "the desk was opened without a project: already showing all projects" + .to_owned(), + ); + return Vec::new(); + } + self.all_projects = !self.all_projects; + } else { + self.all_time = !self.all_time; + } + let mut effects = self.transition(); + if self.listing().is_some() { + if self.lane_loading(Lane::Listing) { + effects.push(self.cancel(Lane::Listing)); + } + self.restore_listing_selection(selected.as_deref()); + } else { + effects.push(self.fetch(Call::Listing(self.scope()), Duration::ZERO)); + } + if let Some(search) = &mut self.search { + search.response = None; + let query = search.query.clone(); + effects.push(self.fetch(Call::Search(self.search_request(query)), 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 title = self + .details + .get(&id) + .and_then(|detail| detail.title.as_deref()) + .map_or_else(|| "(no user message)".to_owned(), ui::one_line); + let width = usize::from(self.pager_viewport().width); + self.pager = Some(Pager::new(id, title, width)); + self.load_more() + } + + 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.push(self.fetch(Call::Search(self.search_request(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(), + }; + vec![self.fetch(call, Duration::ZERO)] + } + + 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].loading = false; + self.dirty = true; + match (msg.call, msg.reply) { + (Call::Listing(scope), Reply::Listing(result)) => self.on_listing(&scope, result), + (Call::Hydrate(_), Reply::Hydrate(result)) => match result { + Ok(details) => { + self.details + .extend(details.into_iter().map(|d| (d.session_id.clone(), d))); + self.hydrate_visible().into_iter().collect() + } + Err(error) => self.toast(&error), + }, + (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) => { + self.previews.insert(id, messages); + 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(), + } + } + + fn toast(&mut self, error: &ApiError) -> Vec { + self.toast = Some(error.to_string()); + Vec::new() + } + + fn on_listing( + &mut self, + scope: &ListingScope, + result: Result, ApiError>, + ) -> Vec { + match result { + Ok(rows) => { + let current = scope_key(scope) == scope_key(&self.scope()); + let selected = self.selected_listing_id(); + self.listings.insert(scope_key(scope), rows); + if !current { + return Vec::new(); + } + self.fatal = None; + self.hydrated.clear(); + self.restore_listing_selection(selected.as_deref()); + self.hydrate_visible().into_iter().collect() + } + Err(error) => { + if self.listings.is_empty() + && matches!(error, ApiError::PondTooOld | ApiError::Unreachable(_)) + { + self.fatal = Some(error.to_string()); + Vec::new() + } else { + self.toast(&error) + } + } + } + } + + 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(); + }; + 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(), + ); + } + Vec::new() + } + Ok(page) => { + pager.eof = page.messages.len() < PAGE_ROWS && !page.truncated; + pager.append(page.messages); + self.load_more() + } + Err(error) => self.toast(&error), + } + } +} + +/// Listings are cached per toggle state, not per timestamp, so toggling back +/// is instant even though `since` moves with the clock. +fn scope_key(scope: &ListingScope) -> (bool, bool) { + (scope.project.is_none(), scope.since.is_none()) +} + +#[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 super::*; + use crate::desk::tests::{MockApi, app, key, message, now, press, screen, settle}; + use crate::fake_pond::golden; + + 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), + source_agent: "codex-cli".to_owned(), + project: "/home/me/pj/pond".to_owned(), + } + } + + 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); + assert_eq!( + calls[2], + Call::Hydrate(vec!["s-live".to_owned(), "s-old".to_owned()]) + ); + assert_eq!(calls.len(), 3, "exactly one hydrate for the page"); + 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::Hydrate(ids) => Some(ids), + _ => None, + }); + assert!(first.unwrap().len() < 20, "never the whole listing"); + press(&mut app, &api, KeyCode::End); + let Some(Call::Hydrate(ids)) = api.calls().pop() else { + panic!("jumping to the end hydrates the new page"); + }; + assert!(ids.contains(&"s59".to_owned())); + assert!(!ids.contains(&"s00".to_owned())); + } + + #[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)); + } + + #[test] + fn later_errors_are_verbatim_toasts() { + let mut app = opened(&MockApi::golden(), 100, 12); + let error = ApiError::Pond { + code: "validation_failed".to_owned(), + message: "sql error: query exceeded the 30s limit".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"); + assert_eq!(app.toast, Some(error.to_string())); + 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)); + } + + #[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 project_toggle_widens_listing_and_search() { + let api = MockApi::golden(); + let mut app = opened(&api, 100, 12); + type_query(&mut app, &api, "timer"); + let Some(Call::Search(scoped)) = api.calls().pop() else { + panic!("typing searches"); + }; + assert_eq!( + scoped.filters.project, + Some(ProjectFilter::Contains("/home/me/pj/pond".to_owned())) + ); + assert_eq!(scoped.filters.from_date.as_deref(), Some("2026-09-11")); + + press(&mut app, &api, KeyCode::Enter); + press(&mut app, &api, KeyCode::Char('p')); + let calls = api.calls(); + assert!( + calls + .iter() + .any(|call| matches!(call, Call::Listing(s) if s.project.is_none())) + ); + let Some(Call::Search(wide)) = calls.iter().rev().find(|c| matches!(c, Call::Search(_))) + else { + panic!("the search follows the scope"); + }; + assert_eq!(wide.filters.project, None); + } + + #[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"), + "{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 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(), "t".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_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 = crate::desk::tests::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(30, 6)); + 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" + ); + let screen_text = screen(&mut app); + assert!(screen_text.contains("message 10"), "{screen_text}"); + } + + #[test] + fn the_pager_frame_is_the_viewport_slice() { + let mut app = app(40, 6); + let mut pager = Pager::new("s-old".to_owned(), "fix it".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/mod.rs b/packages/herdr-pond/src/desk/mod.rs index c96079ad..551c63a5 100644 --- a/packages/herdr-pond/src/desk/mod.rs +++ b/packages/herdr-pond/src/desk/mod.rs @@ -1,12 +1,557 @@ -//! The session desk TUI (plan section 6). Owner: agent C - everything under -//! `desk/` and nothing outside it. +//! The session desk TUI: a runtime that owns the terminal and performs the +//! effects of the pure reducers in `app`, and `ui` to draw their state. +mod app; +mod ui; + +use std::future::Future; +use std::io; 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::signal::unix::{SignalKind, signal}; +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); + /// Builds its own current-thread runtime and owns the terminal until it /// returns; the terminal is restored on every return path. -pub(crate) fn run(_api: Arc, _context: DeskContext) -> anyhow::Result { - anyhow::bail!("not implemented") +pub(crate) fn run(api: Arc, context: DeskContext) -> anyhow::Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let mut terminal = ratatui::try_init().inspect_err(|_| ratatui::restore())?; + let result = runtime.block_on(async { + let mut terminate = signal(SignalKind::terminate())?; + let mut hangup = signal(SignalKind::hangup())?; + let shutdown = async move { + tokio::select! { + _ = terminate.recv() => {} + _ = hangup.recv() => {} + } + }; + event_loop(&mut terminal, api, context, EventStream::new(), shutdown).await + }); + ratatui::restore(); + result +} + +/// Ends on quit, jump, `shutdown`, or the event stream ending - a closed or +/// failing stream means the pane is gone. +async fn event_loop( + terminal: &mut Terminal, + api: Arc, + context: DeskContext, + mut events: S, + shutdown: impl Future, +) -> anyhow::Result +where + B: Backend, + B::Error: Send + Sync + 'static, + S: Stream> + Unpin, +{ + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut runner = Runner::new(api, tx); + let mut app = App::new(context, Utc::now(), terminal.size()?); + 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 { + for effect in effects.drain(..) { + if let Some(exit) = runner.perform(effect) { + return Ok(exit); + } + } + if app.dirty { + terminal.draw(|frame| ui::render(frame, &mut 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.is_loading() => { + 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, + tx: mpsc::UnboundedSender, + tasks: [Option; Lane::COUNT], +} + +impl Runner { + fn new(api: Arc, tx: mpsc::UnboundedSender) -> Self { + Self { + api, + tx, + tasks: Default::default(), + } + } + + 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 { + match effect { + Effect::Exit(exit) => return Some(exit), + Effect::Cancel(lane) => self.abort(lane), + 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::Hydrate(ids) => Reply::Hydrate(api.hydrate(ids).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::golden; + use crate::types::{ + ApiError, ApiFuture, Cursor, ListingScope, LiveAgent, PAGE_ROWS, PREVIEW_ROWS, + SearchRequest, SearchResponse, SessionDetail, SessionRow, SqlResponse, TranscriptMessage, + TranscriptPage, + }; + + pub(in crate::desk) fn now() -> DateTime { + "2026-09-25T05:00:00Z".parse().unwrap() + } + + pub(in crate::desk) fn sql_rows(body: &str) -> Vec { + let response: SqlResponse = serde_json::from_str(body).unwrap(); + response + .rows + .into_iter() + .map(|row| serde_json::from_value(row).unwrap()) + .collect() + } + + pub(in crate::desk) fn message(id: &str, ts: DateTime, 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, + pub(in crate::desk) details: Vec, + pub(in crate::desk) transcript: Vec, + pub(in crate::desk) live: Vec, + pub(in crate::desk) listing_error: Option, + pub(in crate::desk) search: Option, + pub(in crate::desk) search_delay: Option Duration>, + pub(in crate::desk) calls: Mutex>, + } + + 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), + details: sql_rows(golden::SQL_HYDRATE), + transcript: sql_rows(golden::SQL_PAGE), + live: vec![LiveAgent { + pane_id: "p7".to_owned(), + agent: Some("claude".to_owned()), + session: "s-live".to_owned(), + }], + ..Self::default() + } + } + + pub(in crate::desk) fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + + fn reply( + &self, + call: Call, + delay: Duration, + result: Result, + ) -> 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> { + let result = self + .listing_error + .clone() + .map_or_else(|| Ok(self.sessions.clone()), Err); + self.reply(Call::Listing(scope), Duration::ZERO, result) + } + + fn hydrate(&self, session_ids: Vec) -> ApiFuture<'_, Vec> { + let details = self + .details + .iter() + .filter(|d| session_ids.contains(&d.session_id)) + .cloned() + .collect(); + self.reply(Call::Hydrate(session_ids), Duration::ZERO, Ok(details)) + } + + 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(), + matched_total: 0, + searchable_in_scope: 100, + has_more: false, + }); + self.reply(Call::Search(request), delay, Ok(response)) + } + + fn preview(&self, session_id: String) -> ApiFuture<'_, Vec> { + 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) -> 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> { + self.reply(Call::Live, Duration::ZERO, Ok(self.live.clone())) + } + } + + pub(in crate::desk) fn app(width: u16, height: u16) -> App { + let context = DeskContext { + project: Some("/home/me/pj/pond".to_owned()), + }; + 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, + ) -> Option { + 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::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 { + let effects = app.on_event(&key(code)); + settle(app, api, effects) + } + + pub(in crate::desk) fn screen(app: &mut App) -> String { + 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::() + }) + .collect::>() + .join("\n") + } + + fn searches(api: &MockApi) -> Vec { + 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) { + for effect in effects { + assert_eq!(runner.perform(effect), None); + } + } + + async fn drain(app: &mut App, runner: &mut Runner, rx: &mut mpsc::UnboundedReceiver) { + 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, tx); + 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, tx); + 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> + Send + 'static, + shutdown: impl Future, + ) -> (anyhow::Result, String) { + let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap(); + let events: Pin> + Send>> = Box::pin(events); + let exit = event_loop( + &mut terminal, + Arc::new(api), + DeskContext::default(), + events, + shutdown, + ) + .await; + (exit, buffer_text(terminal.backend())) + } + + #[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 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..d9082c92 --- /dev/null +++ b/packages/herdr-pond/src/desk/ui.rs @@ -0,0 +1,667 @@ +//! 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 crate::types::{SearchSession, SessionRow, TranscriptMessage}; + +const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const TAB_STOP: usize = 4; +const MACHINE: usize = 10; +const ADAPTER: usize = 12; +const AGE: usize = 4; +const COUNT: usize = 7; +/// The preview wraps on every frame, so one huge message must not reach it whole. +const PREVIEW_CHARS: usize = 2000; +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, + 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 >= 100 { + 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); + frame.render_widget(Paragraph::new(column_header()).dim(), areas.columns); + render_rows(frame, app, areas.list); + 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.all_time { + "all time".to_owned() + } else { + format!("last {} days", crate::types::LISTING_WINDOW_DAYS) + } +} + +fn header(app: &App) -> Line<'static> { + let scope = app.scope(); + let count = match &app.search { + Some(search) => search.response.as_ref().map_or_else( + || "searching".to_owned(), + |r| format!("{} sessions match", r.sessions.len()), + ), + None => app.listing().map_or_else( + || "loading".to_owned(), + |rows| format!("{} sessions", rows.len()), + ), + }; + Line::from(vec![ + "pond desk".bold(), + Span::raw(format!( + " | {count} | {} | {} | msgs = whole-session counts", + scope.project.as_deref().unwrap_or("all projects"), + window_label(app) + )), + ]) +} + +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() -> String { + format!( + " {} {} {:>AGE$} {:>COUNT$} title", + fit("machine", MACHINE), + fit("adapter", ADAPTER), + "age", + "msgs" + ) +} + +fn render_rows(frame: &mut Frame, app: &mut App, area: Rect) { + let items = match row_items(app) { + 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); + let state = if app.search.is_some() { + &mut app.search_state + } else { + &mut app.listing_state + }; + frame.render_stateful_widget(list, area, state); +} + +/// The rows of the current view, or the sentence that stands in for them. +fn row_items(app: &App) -> Result>, 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 ({project}, {}) excluded every message before search ran - p all projects, t all time", + window_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)) + .collect()), + }; + } + match app.listing() { + None if app.lane_loading(Lane::Listing) && app.all_time => Err( + "loading the all-time listing - the slow query family, this can take ~15s".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)).collect()), + } +} + +fn machine(app: &App, session_id: &str) -> Span<'static> { + match app.details.get(session_id) { + Some(detail) => match &detail.host { + Some(host) => Span::raw(fit(host, MACHINE)), + None => Span::raw(fit("local?", MACHINE)).dim(), + }, + None => Span::raw(fit("", MACHINE)), + } +} + +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) -> ListItem<'static> { + let detail = app.details.get(&row.session_id); + let count = detail.map_or_else(String::new, |d| d.message_count.to_string()); + let title = match detail { + Some(detail) => detail + .title + .as_deref() + .map_or_else(|| "(no user message)".dim(), |t| Span::raw(one_line(t))), + None => "...".dim(), + }; + ListItem::new(Line::from(vec![ + glyph(app, &row.session_id), + machine(app, &row.session_id), + 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) -> 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), + 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 clipped: String = message.text.chars().take(PREVIEW_CHARS).collect(); + 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( + sanitize(&clipped) + .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" + } else { + "/ search enter open space preview p projects t time r refresh q quit" + }; + let mut spans = Vec::new(); + if app.is_loading() { + spans.push( + Span::raw(format!("{} ", SPINNER[app.spinner % SPINNER.len()])).fg(Color::Yellow), + ); + } + spans.push(Span::raw(help).dim()); + Line::from(spans) +} + +fn render_pager(frame: &mut Frame, app: &App) { + let Some(pager) = &app.pager else { + return; + }; + let areas = pager_areas(frame.area()); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(pager.title.clone()).bold(), + 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[app.spinner % SPINNER.len()]) + } 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, + ); +} + +fn render_toast(frame: &mut Frame, text: &str) { + let area = frame.area(); + let width = area.width.min(60); + let inner = usize::from(width.saturating_sub(2)).max(1); + let lines = u16::try_from(textwrap::wrap(text, inner).len()).unwrap_or(u16::MAX); + let height = lines.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(text.to_owned()) + .wrap(Wrap { trim: true }) + .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> { + 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 clean single line: sanitized, whitespace runs collapsed. +pub(super) fn one_line(text: &str) -> String { + sanitize(text) + .split_whitespace() + .collect::>() + .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, then: DateTime) -> 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 = 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]) + ); + } +} From 8a4970815da18e84ecd4fbbe55d1101a8d340bfd Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 04:37:14 +0000 Subject: [PATCH 03/41] feat(herdr-pond): sync-on-idle hook, plugin config and herdr CLI wrappers The `hook` leg maps an idle/done agent to its pond adapter, stamps `pending.` and detaches a per-adapter worker (setsid, all stdio to `sync.log`) unless one holds the worker flock, then exits 0 in milliseconds. The worker consumes the stamp before each `pond sync -q` (no `--no-wait`, so a busy store lock waits instead of dropping the event) and re-checks it after releasing the flock, so no idle event is lost. `open` focuses the workspace's existing desk or opens one; config.toml gates sync and names `pond`. --- packages/herdr-pond/src/config.rs | 276 ++++++++++++++- packages/herdr-pond/src/fake_pond.rs | 57 ++- packages/herdr-pond/src/herdr.rs | 336 +++++++++++++++++- packages/herdr-pond/src/hook.rs | 504 ++++++++++++++++++++++++++- 4 files changed, 1164 insertions(+), 9 deletions(-) diff --git a/packages/herdr-pond/src/config.rs b/packages/herdr-pond/src/config.rs index f41c4de2..e5d228ca 100644 --- a/packages/herdr-pond/src/config.rs +++ b/packages/herdr-pond/src/config.rs @@ -1,2 +1,274 @@ -//! `HERDR_PLUGIN_CONFIG_DIR/config.toml`, re-read per run; malformed falls back -//! to defaults (plan 5.3). Owner: agent B. +//! The plugin's own files: `HERDR_PLUGIN_CONFIG_DIR/config.toml` (re-read per +//! run, malformed falls back to defaults - plan 5.3) 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::PermissionsExt; +use std::path::{Path, PathBuf}; + +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() + }) + } + + /// 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 { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + if fs::metadata(path).is_ok_and(|meta| meta.len() > LOG_CAP_BYTES) { + OpenOptions::new().write(true).open(path)?.set_len(0)?; + } + OpenOptions::new().create(true).append(true).open(path) +} + +/// 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>> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + 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. +pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut temp = path.as_os_str().to_owned(); + temp.push(format!(".tmp.{}", std::process::id())); + let temp = PathBuf::from(temp); + fs::write(&temp, contents)?; + fs::rename(&temp, path).inspect_err(|_| { + let _ = fs::remove_file(&temp); + }) +} + +#[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").unwrap(); + write_atomic(&path, b"two").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "two"); + assert_eq!(fs::read_dir(sandbox.path("state")).unwrap().count(), 1); + } +} diff --git a/packages/herdr-pond/src/fake_pond.rs b/packages/herdr-pond/src/fake_pond.rs index bd257c8a..60f375b7 100644 --- a/packages/herdr-pond/src/fake_pond.rs +++ b/packages/herdr-pond/src/fake_pond.rs @@ -1,9 +1,13 @@ //! A canned-response stand-in for `pond serve`, so the HTTP client is tested //! against real bytes on a real socket - trait mocks alone would let the -//! client's serialization drift while every test stays green. +//! 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; @@ -203,3 +207,54 @@ async fn serve_one( 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 { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "herdr-pond-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + 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.toml"), text).unwrap(); + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +/// Writes an executable `/bin/sh` script, the stand-in for `pond` or `herdr`. +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{body}\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path.to_path_buf() +} diff --git a/packages/herdr-pond/src/herdr.rs b/packages/herdr-pond/src/herdr.rs index de2ce103..f5f4d982 100644 --- a/packages/herdr-pond/src/herdr.rs +++ b/packages/herdr-pond/src/herdr.rs @@ -1,6 +1,336 @@ -//! Every herdr CLI call (`pane list`, `agent focus`, `plugin pane open`, -//! `notification show`) and the plugin runtime env (plan 5.2, 5.4). Owner: agent B. +//! Every herdr CLI call (`pane list`, `agent focus`, `plugin pane open|focus`, +//! `notification show`) and the plugin runtime env (plan 5.2, 5.4). +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, bail}; +use serde::Deserialize; + +use crate::config::{Config, log_line, open_log}; +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"; + +/// A plugin-runtime path herdr sets for every plugin process. +pub(crate) fn plugin_env(var: &str) -> anyhow::Result { + 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 { + plugin_env("HERDR_PLUGIN_STATE_DIR") +} + +pub(crate) fn config_dir() -> anyhow::Result { + plugin_env("HERDR_PLUGIN_CONFIG_DIR") +} + +pub(crate) fn socket_path() -> anyhow::Result { + plugin_env("HERDR_SOCKET_PATH") +} + +/// The desk's project: the underlying pane's cwd, else the workspace's. +pub(crate) fn context_project() -> Option { + project_from_context(&std::env::var("HERDR_PLUGIN_CONTEXT_JSON").ok()?) +} + +fn project_from_context(json: &str) -> Option { + #[derive(Deserialize)] + struct Context { + focused_pane_cwd: Option, + workspace_cwd: Option, + } + 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<()> { + let out = open_log(log).with_context(|| format!("opening {}", log.display()))?; + let err = out.try_clone()?; + command + .stdin(Stdio::null()) + .stdout(out) + .stderr(err) + .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: &Config, + config_dir: &Path, + state_dir: &Path, + log: &Path, +) -> Option { + let marker = state_dir.join("pond-missing.toasted"); + match config.resolve_pond(config_dir) { + 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, + #[serde(default)] + pub agent: Option, + #[serde(default)] + pub agent_session: Option, +} + +#[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) -> Vec { + panes + .into_iter() + .filter_map(|pane| { + Some(LiveAgent { + session: pane.agent_session?.value, + pane_id: pane.pane_id, + agent: pane.agent, + }) + }) + .collect() +} + +#[derive(Debug, Clone)] +pub(crate) struct Herdr { + bin: PathBuf, +} + +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 } + } + + /// Runs one CLI call 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 { + let output = Command::new(&self.bin) + .args(args) + .stdin(Stdio::null()) + .output() + .with_context(|| format!("running {}", self.bin.display()))?; + let command = args.iter().take(3).copied().collect::>().join(" "); + if !output.status.success() { + bail!( + "herdr {command} failed ({}): {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let mut response: serde_json::Value = serde_json::from_slice(&output.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> { + 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) + } +} + +/// 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<()> { - anyhow::bail!("not implemented") + 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) + } + + fn calls(sandbox: &Sandbox) -> Vec { + fs::read_to_string(sandbox.path("calls")) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + + #[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(), + agent: Some("claude".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!( + calls(&sandbox), + ["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!(calls(&sandbox), expected); + } + } + + #[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 index 68866fcb..2b690248 100644 --- a/packages/herdr-pond/src/hook.rs +++ b/packages/herdr-pond/src/hook.rs @@ -1,6 +1,504 @@ //! Sync-on-idle: the millisecond event hook and its detached per-adapter -//! worker (plan 5.5). Owner: agent B. +//! worker (plan 5.5). +//! +//! No idle event may be dropped, so the worker runs trailing-edge: the hook +//! creates `pending.`; 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. -pub(crate) fn run(_args: &[String]) -> anyhow::Result<()> { - anyhow::bail!("not implemented") +use std::fs::{self, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::bail; +use serde::Deserialize; + +use crate::config::{Config, log_line, open_log, 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!("usage: herdr-pond hook [--worker ]"), + } +} + +/// 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, + agent: Option, + } + let data = serde_json::from_str::(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 config = Config::load(&config_dir, &log); + let Some(pond) = herdr::resolve_pond_or_toast(&config, &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 = open_log(log).and_then(|out| { + let err = out.try_clone()?; + Command::new(pond) + .args(["sync", adapter, "-q"]) + .stdin(Stdio::null()) + .stdout(out) + .stderr(err) + .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::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"; + + 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 lines(path: &Path) -> Vec { + fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + + 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>>>, + } + + 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 { + lines(&self.sandbox.path("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()); + assert!( + lines(&harness.sandbox.path("calls")) + .iter() + .all(|call| call == "sync claude-code -q"), + "{:?}", + lines(&harness.sandbox.path("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([ + "--exact", + "hook::tests::self_exec_role", + "--test-threads=1", + "-q", + ]) + .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([ + "--exact", + "hook::tests::self_exec_role", + "--test-threads=1", + "-q", + ]) + .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 events = sandbox.path("events"); + assert!( + !lines(&events).contains(&"end claude-code".to_owned()), + "pipes stayed open for the whole sync ({eof_after:?})" + ); + wait_for("the detached sync to finish", || { + lines(&events).contains(&"end claude-code".to_owned()) + }); + assert_eq!(lines(&sandbox.path("calls")), ["sync claude-code -q"]); + } } From ac75d9e2a6d6a3150533af978f07941322f98766 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 04:37:23 +0000 Subject: [PATCH 04/41] feat(herdr-pond): per-server pond serve daemon and the desk's HTTP client `serve-daemon` (startup hook) adopts a live endpoint or detaches an `--owner` that holds a per-herdr-server flock for life, runs `pond serve --host 127.0.0.1 --port 0 --port-file`, publishes `{port, pid, token, pond_version}` after the `/v1/x/sql` capability probe, warms the 14-day listing and one FTS search, and tears the serve down (SIGTERM, 10s, SIGKILL) when herdr's socket stops answering; the endpoint is removed only by the token that wrote it. `HttpApi` implements the desk's `Api`: endpoint resolution is lazy, a dead serve fails over once (daemon record, else a desk-owned fallback serve killed on drop), every SQL request passes its own LIMIT as `limit`, and responses map to pond envelope / plain rejection / old pond / unreachable errors. `tui` runs the desk and jumps with `herdr agent focus` only after the terminal is restored. --- packages/herdr-pond/src/api.rs | 583 +++++++++++++++++++++++++++++- packages/herdr-pond/src/daemon.rs | 548 +++++++++++++++++++++++++++- packages/herdr-pond/src/main.rs | 18 +- packages/herdr-pond/src/serve.rs | 498 ++++++++++++++++++++++++- 4 files changed, 1639 insertions(+), 8 deletions(-) diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs index 9fc983d2..78593ff7 100644 --- a/packages/herdr-pond/src/api.rs +++ b/packages/herdr-pond/src/api.rs @@ -1,3 +1,584 @@ //! The HTTP [`Api`](crate::types::Api) implementation over `pond serve` //! (`/v1/x/sql`, `/v1/search`), plus herdr's pane list for live agents. -//! Owner: agent B. Tested against [`crate::fake_pond`]. +//! Tested against [`crate::fake_pond`]. + +use std::time::Duration; + +use serde::Serialize; +use serde::de::DeserializeOwned; +use tokio::sync::Mutex; + +use crate::herdr::{self, Herdr}; +use crate::serve::{self, Origin, ServeChild}; +use crate::types::{ + Api, ApiError, ApiFuture, Cursor, ErrorEnvelope, ListingScope, LiveAgent, PAGE_ROWS, + PREVIEW_ROWS, PROTOCOL_VERSION, SearchRequest, SearchResponse, SessionDetail, SessionRow, + SqlRequest, SqlResponse, TranscriptMessage, TranscriptPage, hydrate_sql, listing_sql, page_sql, + preview_sql, +}; + +pub(crate) const SQL_PATH: &str = "/v1/x/sql"; +pub(crate) const SEARCH_PATH: &str = "/v1/search"; + +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. +pub(crate) 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); + +/// Loopback only: an inherited `HTTP_PROXY` must never see desk traffic. +pub(crate) fn client() -> anyhow::Result { + Ok(reqwest::Client::builder() + .no_proxy() + .connect_timeout(CONNECT_TIMEOUT) + .build()?) +} + +/// `limit` is always the query's own SQL `LIMIT`, so the server's default +/// 100-row cap never cuts a page. +pub(crate) fn sql_request(query: String, limit: usize, timeout_seconds: u64) -> SqlRequest { + SqlRequest { + protocol_version: PROTOCOL_VERSION, + query, + limit: Some(limit), + timeout_seconds: Some(timeout_seconds), + } +} + +pub(crate) fn sql_deadline(timeout_seconds: u64) -> Duration { + Duration::from_secs(timeout_seconds) + CLIENT_SLACK +} + +/// One request against a known base URL. +pub(crate) async fn post( + client: &reqwest::Client, + base_url: &str, + path: &str, + body: &B, + deadline: Duration, +) -> Result +where + B: Serialize + ?Sized, + T: DeserializeOwned, +{ + let response = client + .post(format!("{base_url}{path}")) + .json(body) + .timeout(deadline) + .send() + .await + .map_err(transport)?; + let status = response.status().as_u16(); + let body = response.text().await.map_err(transport)?; + decode(path, status, &body) +} + +/// reqwest's own message is only "error sending request"; the cause chain +/// says refused vs timed out. +fn transport(error: reqwest::Error) -> ApiError { + let mut message = error.to_string(); + 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(); + } + ApiError::Unreachable(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(), + }), + } +} + +fn rows(response: SqlResponse) -> Result, ApiError> { + response + .rows + .into_iter() + .map(|row| serde_json::from_value(row).map_err(|error| ApiError::Decode(error.to_string()))) + .collect() +} + +/// The resolved serve. `base_url` stays unset until the first call, so the +/// desk's loading state covers a cold fallback spawn. +#[derive(Default)] +struct Link { + base_url: Option, + fallback: Option, + failed_over: bool, +} + +pub(crate) struct HttpApi { + client: reqwest::Client, + /// 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, + herdr: Herdr, + link: Mutex, +} + +impl HttpApi { + pub(crate) fn from_env() -> anyhow::Result { + Ok(Self { + client: client()?, + origin: Origin::from_env().map_err(|error| format!("{error:#}")), + herdr: Herdr::from_env(), + link: Mutex::default(), + }) + } + + async fn resolve(&self, link: &mut Link) -> Result { + let origin = self + .origin + .as_ref() + .map_err(|error| ApiError::Unreachable(error.clone()))?; + let connection = serve::connect(&self.client, origin, link.fallback.take()).await?; + link.fallback = connection.fallback; + link.base_url = Some(connection.base_url.clone()); + Ok(connection.base_url) + } + + /// Sends to the resolved serve. The first time a serve becomes unreachable + /// the endpoint is resolved again (daemon record, else a fallback child) + /// and the request retried there; after that, errors stand. + async fn post(&self, path: &str, body: &B, deadline: Duration) -> Result + where + B: Serialize + ?Sized + Sync, + T: DeserializeOwned, + { + let url = { + let mut link = self.link.lock().await; + match link.base_url.clone() { + Some(url) => url, + None => self.resolve(&mut link).await?, + } + }; + let reason = match post(&self.client, &url, path, body, deadline).await { + Err(ApiError::Unreachable(reason)) => reason, + other => return other, + }; + let retry = { + let mut link = self.link.lock().await; + match link.base_url.clone() { + Some(current) if current != url => current, + _ if link.failed_over => return Err(ApiError::Unreachable(reason)), + _ => { + let fresh = self.resolve(&mut link).await?; + if fresh == url { + return Err(ApiError::Unreachable(reason)); + } + link.failed_over = true; + fresh + } + } + }; + post(&self.client, &retry, path, body, deadline).await + } + + async fn sql( + &self, + query: String, + limit: usize, + timeout_seconds: u64, + ) -> Result { + let request = sql_request(query, limit, timeout_seconds); + self.post(SQL_PATH, &request, sql_deadline(timeout_seconds)) + .await + } +} + +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 + }; + rows(self.sql(listing_sql(&scope), scope.limit, timeout).await?) + }) + } + + fn hydrate(&self, session_ids: Vec) -> ApiFuture<'_, Vec> { + Box::pin(async move { + if session_ids.is_empty() { + return Ok(Vec::new()); + } + let query = hydrate_sql(&session_ids); + rows( + self.sql(query, session_ids.len(), QUERY_TIMEOUT_SECS) + .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); + rows(self.sql(query, PREVIEW_ROWS, QUERY_TIMEOUT_SECS).await?) + }) + } + + 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: rows(response)?, + }) + }) + } + + 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::Unreachable(format!("herdr pane list: {error}")))? + .map_err(|error| ApiError::Unreachable(format!("{error:#}")))?; + Ok(herdr::live_agents(panes)) + }) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use chrono::{DateTime, Utc}; + + use super::*; + use crate::fake_pond::{FakePond, Reply, Sandbox, golden, write_script}; + use crate::serve::{Endpoint, ServeDir, write_endpoint}; + use crate::types::{ProjectFilter, SearchFilters}; + + /// An api pinned to `base_url`, re-resolving through `sandbox`'s state. + /// `pond_bin` points nowhere, so no re-resolution can reach a real pond. + fn api_at(base_url: &str, sandbox: &Sandbox) -> HttpApi { + sandbox.write_config(&format!( + "pond_bin = \"{}\"\n", + sandbox.path("bin/no-pond").display() + )); + HttpApi { + client: client().unwrap(), + origin: Ok(Origin { + dir: ServeDir::new(&sandbox.state_dir(), &sandbox.path("herdr.sock")), + config_dir: sandbox.config_dir(), + }), + herdr: Herdr::new(sandbox.path("bin/herdr")), + link: Mutex::new(Link { + base_url: Some(base_url.to_owned()), + ..Link::default() + }), + } + } + + fn sent(pond: &FakePond) -> Vec { + pond.recorded() + .iter() + .map(|request| serde_json::from_str(&request.body).unwrap()) + .collect() + } + + fn dead_url() -> String { + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + format!("http://127.0.0.1:{port}") + } + + fn ts(raw: &str) -> DateTime { + raw.parse().unwrap() + } + + #[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.base_url, &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)); + 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 hydrate_reads_omitted_nulls_as_none() { + let sandbox = Sandbox::new(); + let pond = FakePond::with_sql( + vec![("COUNT(*)", Reply::json(golden::SQL_HYDRATE))], + Reply::json(golden::SEARCH), + ) + .await; + let api = api_at(&pond.base_url, &sandbox); + assert!(api.hydrate(Vec::new()).await.unwrap().is_empty()); + assert!(pond.recorded().is_empty(), "empty hydrate sent a request"); + + let ids = vec!["s-live".to_owned(), "s-old".to_owned()]; + let details = api.hydrate(ids.clone()).await.unwrap(); + assert_eq!(details[0].host.as_deref(), Some("ws-pond-01")); + assert_eq!( + (details[1].title.clone(), details[1].host.clone()), + (None, None) + ); + assert_eq!(details[1].message_count, 3); + let body = &sent(&pond)[0]; + assert_eq!(body["query"], hydrate_sql(&ids)); + assert_eq!(body["limit"], 2); + } + + #[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.base_url, &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.base_url, &sandbox); + let response = api + .search(SearchRequest { + protocol_version: PROTOCOL_VERSION, + query: "timer".to_owned(), + filters: SearchFilters { + project: Some(ProjectFilter::Contains("/pj/pond".to_owned())), + from_date: None, + }, + limit: 20, + }) + .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.base_url, &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 { + protocol_version: PROTOCOL_VERSION, + query: "x".to_owned(), + filters: SearchFilters::default(), + limit: 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_stalled_server_is_unreachable() { + let pond = + FakePond::start(|_, _| Reply::json(golden::SQL_EMPTY).delayed(Duration::from_secs(5))) + .await; + let request = sql_request(preview_sql("s"), PREVIEW_ROWS, 1); + let result: Result = post( + &client().unwrap(), + &pond.base_url, + SQL_PATH, + &request, + Duration::from_millis(200), + ) + .await; + let Err(ApiError::Unreachable(reason)) = result else { + panic!("expected Unreachable, got {result:?}"); + }; + assert!(reason.contains("timed out"), "{reason}"); + } + + #[tokio::test] + async fn a_dead_serve_fails_over_once() { + let sandbox = Sandbox::new(); + let replacement = FakePond::with_sql( + vec![ + ("SELECT 1", Reply::json(golden::SQL_READY)), + ("DESC LIMIT", Reply::json(golden::SQL_PAGE)), + ], + Reply::json(golden::SEARCH), + ) + .await; + let api = api_at(&dead_url(), &sandbox); + let port = replacement + .base_url + .rsplit(':') + .next() + .unwrap() + .parse() + .unwrap(); + let endpoint = Endpoint { + port, + pid: 1, + token: "t".to_owned(), + pond_version: "pond".to_owned(), + }; + let dir = ServeDir::new(&sandbox.state_dir(), &sandbox.path("herdr.sock")); + write_endpoint(&dir.endpoint(), &endpoint).unwrap(); + + let messages = api.preview("s1".to_owned()).await.unwrap(); + assert_eq!(messages.len(), 2); + + drop(replacement); + tokio::time::sleep(Duration::from_millis(50)).await; + std::fs::remove_file(dir.endpoint()).unwrap(); + let pond = write_script(&sandbox.path("bin/pond"), "exit 9"); + sandbox.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); + assert!(matches!( + api.preview("s1".to_owned()).await, + Err(ApiError::Unreachable(_)) + )); + assert!( + !sandbox + .state_dir() + .join("serve") + .read_dir() + .unwrap() + .any(|entry| { entry.unwrap().path().join("desk-serve.log").exists() }), + "a second failover spawned a fallback" + ); + } + + #[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(&dead_url(), &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::Unreachable(reason)) = api.live_agents().await else { + panic!("expected an error"); + }; + assert!(reason.contains("boom"), "{reason}"); + } +} diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index e56625e6..3705d879 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -1,6 +1,548 @@ //! The per-herdr-server `pond serve` owner: startup hook and detached -//! watchdog (plan 5.6). Owner: agent B. +//! watchdog (plan 5.6). +//! +//! 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 serve exists per herdr server, and it never outlives that server. -pub(crate) fn run(_args: &[String]) -> anyhow::Result<()> { - anyhow::bail!("not implemented") +use std::collections::hash_map::RandomState; +use std::hash::BuildHasher; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::bail; +use chrono::Utc; + +use crate::api::{SEARCH_PATH, SQL_PATH, client, post, sql_deadline, sql_request}; +use crate::config::{Config, log_line, try_lock}; +use crate::herdr; +use crate::serve::{ + Endpoint, PORT_DEADLINE, ServeDir, probe, read_endpoint, read_port_file, + remove_endpoint_if_owned, spawn_serve, terminate, write_endpoint, +}; +use crate::types::{ + LISTING_ROWS, LISTING_WINDOW_DAYS, ListingScope, PROTOCOL_VERSION, SearchFilters, + SearchRequest, SearchResponse, SqlResponse, listing_sql, +}; + +struct Timing { + tick: Duration, + liveness_every: Duration, + port_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), + port_deadline: PORT_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!("usage: herdr-pond serve-daemon [--owner]"), + } +} + +fn runtime() -> std::io::Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() +} + +/// 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 = runtime().map_err(anyhow::Error::from).and_then(|runtime| { + runtime.block_on(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 one is alive (lock held) or the published +/// endpoint still answers the probe (adopted). +async fn start( + dir: &ServeDir, + spawn_owner: impl FnOnce() -> anyhow::Result<()>, +) -> anyhow::Result<()> { + let Some(lock) = try_lock(&dir.lock())? else { + return Ok(()); + }; + if let Some(endpoint) = read_endpoint(&dir.endpoint()) + && probe(&client()?, &endpoint.base_url()).await.is_ok() + { + log_line( + &dir.daemon_log(), + &format!("adopted live endpoint {}", endpoint.base_url()), + ); + return Ok(()); + } + drop(lock); + 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(own(&dir, &socket, &TIMING, || { + let config = Config::load(&config_dir, &log); + herdr::resolve_pond_or_toast(&config, &config_dir, &state_dir, &log) + })) +} + +async fn own( + dir: &ServeDir, + socket: &Path, + timing: &Timing, + resolve_pond: impl FnOnce() -> Option, +) -> anyhow::Result<()> { + let log = dir.daemon_log(); + let Some(_lock) = try_lock(&dir.lock())? else { + return Ok(()); + }; + let client = client()?; + if let Some(endpoint) = read_endpoint(&dir.endpoint()) + && probe(&client, &endpoint.base_url()).await.is_ok() + { + return Ok(()); + } + let Some(pond) = resolve_pond() else { + return Ok(()); + }; + let pond_version = pond_version(&pond); + let port_file = dir.port_file("owner"); + let mut child = spawn_serve(&pond, &port_file, &log)?; + log_line( + &log, + &format!("owner: started {pond_version} (pid {})", child.id()), + ); + let serve = Serve { + client, + child: &mut child, + port_file: &port_file, + pond_version, + socket, + log: &log, + }; + let token = serve.supervise(dir, timing).await; + terminate(&mut child, timing.grace); + if let Some(token) = token { + remove_endpoint_if_owned(&dir.endpoint(), &token); + } + let _ = std::fs::remove_file(&port_file); + log_line(&log, "owner: stopped"); + Ok(()) +} + +fn pond_version(pond: &Path) -> String { + Command::new(pond) + .arg("--version") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) + .filter(|version| !version.is_empty()) + .unwrap_or_else(|| "unknown".to_owned()) +} + +fn random_token() -> String { + let state = RandomState::new(); + format!( + "{:016x}{:016x}", + state.hash_one(std::process::id()), + state.hash_one(Instant::now()) + ) +} + +struct Serve<'a> { + client: reqwest::Client, + child: &'a mut Child, + port_file: &'a Path, + pond_version: String, + socket: &'a Path, + log: &'a Path, +} + +impl Serve<'_> { + /// Watches the child, herdr and the port deadline until one ends the + /// owner; publishes the endpoint once serve listens and passes the probe. + /// Returns the published token, if any. + async fn supervise(self, dir: &ServeDir, timing: &Timing) -> Option { + let started = Instant::now(); + let mut next_liveness = started + timing.liveness_every; + let mut herdr_missed = false; + let mut token = None; + let mut warmup = None; + let reason = loop { + match self.child.try_wait() { + Ok(Some(status)) => break format!("pond serve exited unexpectedly ({status})"), + Err(error) => break format!("cannot watch pond serve: {error}"), + Ok(None) => {} + } + // Two misses a tick apart, so a live handoff's socket swap is not a death. + if Instant::now() >= next_liveness { + match UnixStream::connect(self.socket) { + Err(error) if herdr_missed => break format!("herdr server is gone ({error})"), + Err(_) => { + herdr_missed = true; + next_liveness = Instant::now() + timing.tick; + } + Ok(_) => { + herdr_missed = false; + next_liveness = Instant::now() + timing.liveness_every; + } + } + } + if token.is_none() { + if let Some(addr) = read_port_file(self.port_file) { + let base_url = format!("http://{addr}"); + if let Err(error) = probe(&self.client, &base_url).await { + break format!("capability probe failed: {error}"); + } + let endpoint = Endpoint { + port: addr.port(), + pid: self.child.id(), + token: random_token(), + pond_version: self.pond_version.clone(), + }; + if let Err(error) = write_endpoint(&dir.endpoint(), &endpoint) { + break format!("cannot publish the endpoint: {error}"); + } + log_line(self.log, &format!("owner: published {base_url}")); + warmup = Some(tokio::spawn(warm_up( + self.client.clone(), + base_url, + self.log.to_path_buf(), + timing.warmup_deadline, + ))); + token = Some(endpoint.token); + } else if started.elapsed() > timing.port_deadline { + break format!( + "pond serve did not listen within {}s", + timing.port_deadline.as_secs() + ); + } + } + tokio::time::sleep(timing.tick).await; + }; + log_line(self.log, &format!("owner: stopping - {reason}")); + if let Some(warmup) = warmup { + warmup.abort(); + } + token + } +} + +/// The desk's opening listing and a first FTS search, once, so their cold +/// cost lands here instead of on the first desk open. Failure is not fatal. +async fn warm_up(client: reqwest::Client, base_url: String, log: PathBuf, deadline: Duration) { + let started = Instant::now(); + let scope = ListingScope { + project: None, + since: Some(Utc::now() - chrono::TimeDelta::days(LISTING_WINDOW_DAYS)), + limit: LISTING_ROWS, + }; + let listing = sql_request( + listing_sql(&scope), + LISTING_ROWS, + crate::api::QUERY_TIMEOUT_SECS, + ); + let search = SearchRequest { + protocol_version: PROTOCOL_VERSION, + query: WARMUP_QUERY.to_owned(), + filters: SearchFilters::default(), + limit: 1, + }; + let result = tokio::time::timeout(deadline, async { + let deadline = sql_deadline(crate::api::QUERY_TIMEOUT_SECS); + post::<_, SqlResponse>(&client, &base_url, SQL_PATH, &listing, deadline).await?; + post::<_, SearchResponse>(&client, &base_url, SEARCH_PATH, &search, deadline).await + }) + .await; + let outcome = match result { + Ok(Ok(_)) => "done".to_owned(), + Ok(Err(error)) => format!("failed: {error}"), + Err(_) => format!("gave up after {}s", deadline.as_secs()), + }; + 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 nix::sys::signal::kill; + use nix::unistd::Pid; + + use super::*; + use crate::fake_pond::{FakePond, Reply, Sandbox, golden, write_script}; + + const FAST: Timing = Timing { + tick: Duration::from_millis(20), + liveness_every: Duration::from_millis(100), + port_deadline: Duration::from_millis(500), + 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 { + let sandbox = Sandbox::new(); + let pond = 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; + let socket = sandbox.path("herdr.sock"); + let dir = ServeDir::new(&sandbox.state_dir(), &socket); + Self { + sandbox, + pond, + socket, + dir, + } + } + + /// A fake `pond` whose `serve` publishes the fake server's address, + /// then runs `after` (default: stays up). + fn fake_pond(&self, publish: bool, after: &str) -> PathBuf { + let publish = if publish { + format!( + r#"printf '%s' '{}' > "$port_file.tmp" && mv "$port_file.tmp" "$port_file""#, + self.pond.base_url.trim_start_matches("http://") + ) + } else { + String::new() + }; + write_script( + &self.sandbox.path("bin/pond"), + &format!( + r#"[ "$1" = --version ] && {{ echo 'pond 9.9.9'; exit 0; }} +printf '%s\n' "$*" >> '{calls}' +eval "port_file=\${{$#}}" +{publish} +{after}"#, + calls = self.sandbox.path("calls").display(), + ), + ) + } + + async fn own(&self, pond: &Path) -> anyhow::Result<()> { + let pond = pond.to_path_buf(); + own(&self.dir, &self.socket, &FAST, move || Some(pond)).await + } + + fn serve_calls(&self) -> usize { + fs::read_to_string(self.sandbox.path("calls")) + .unwrap_or_default() + .lines() + .filter(|line| line.starts_with("serve --host 127.0.0.1 --port 0 --port-file ")) + .count() + } + + fn log(&self) -> String { + fs::read_to_string(self.dir.daemon_log()).unwrap_or_default() + } + } + + 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; + } + } + + fn alive(pid: u32) -> bool { + kill(Pid::from_raw(i32::try_from(pid).unwrap()), None).is_ok() + } + + #[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", || { + read_endpoint(&setup.dir.endpoint()).is_some() + && setup.pond.recorded().iter().any(|r| r.path == SEARCH_PATH) + }) + .await; + let endpoint = read_endpoint(&setup.dir.endpoint()).unwrap(); + assert!(alive(endpoint.pid)); + drop(listener); + endpoint + }; + let ((first, second), endpoint) = 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_eq!(endpoint.pond_version, "pond 9.9.9"); + assert!( + !setup.dir.endpoint().exists(), + "endpoint outlived its serve" + ); + assert!(!alive(endpoint.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 + ); + } + + #[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 { + wait_until("the endpoint", || { + read_endpoint(&setup.dir.endpoint()).is_some() + }) + .await; + let mut endpoint = read_endpoint(&setup.dir.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!( + read_endpoint(&setup.dir.endpoint()).unwrap().token, + "successor" + ); + } + + #[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("exited unexpectedly"), + "{}", + setup.log() + ); + assert!(!setup.dir.endpoint().exists()); + 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 pid_file = setup.sandbox.path("pid"); + let pond = setup.fake_pond( + false, + &format!("echo $$ > '{}'; exec sleep 30", pid_file.display()), + ); + let _listener = UnixListener::bind(&setup.socket).unwrap(); + setup.own(&pond).await.unwrap(); + assert!(setup.log().contains("did not listen"), "{}", setup.log()); + let pid = fs::read_to_string(pid_file).unwrap(); + assert!(!alive(pid.trim().parse().unwrap())); + assert!(!setup.dir.endpoint().exists()); + } + + #[tokio::test] + async fn start_spawns_an_owner_only_when_needed() { + let setup = Setup::new().await; + let spawned = std::cell::Cell::new(0); + let spawn = || { + spawned.set(spawned.get() + 1); + Ok(()) + }; + + start(&setup.dir, spawn).await.unwrap(); + assert_eq!(spawned.get(), 1, "no endpoint"); + + fs::write(setup.dir.endpoint(), "{not json").unwrap(); + start(&setup.dir, spawn).await.unwrap(); + assert_eq!(spawned.get(), 2, "malformed endpoint"); + + let held = try_lock(&setup.dir.lock()).unwrap().unwrap(); + start(&setup.dir, spawn).await.unwrap(); + assert_eq!(spawned.get(), 2, "an owner holds the lock"); + drop(held); + + let port = setup + .pond + .base_url + .rsplit(':') + .next() + .unwrap() + .parse() + .unwrap(); + let live = Endpoint { + port, + pid: 1, + token: "t".to_owned(), + pond_version: "pond".to_owned(), + }; + write_endpoint(&setup.dir.endpoint(), &live).unwrap(); + start(&setup.dir, spawn).await.unwrap(); + assert_eq!(spawned.get(), 2, "live endpoint is adopted"); + assert!(setup.log().contains("adopted")); + } + + #[test] + fn tokens_differ() { + assert_ne!(random_token(), random_token()); + assert_eq!(random_token().len(), 32); + } } diff --git a/packages/herdr-pond/src/main.rs b/packages/herdr-pond/src/main.rs index 95e55515..22957589 100644 --- a/packages/herdr-pond/src/main.rs +++ b/packages/herdr-pond/src/main.rs @@ -16,6 +16,9 @@ mod serve; mod types; use std::process::ExitCode; +use std::sync::Arc; + +use crate::types::{DeskContext, DeskExit}; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); @@ -28,7 +31,7 @@ fn main() -> ExitCode { "hook" => hook::run(rest), "serve-daemon" => daemon::run(rest), _ => Err(anyhow::anyhow!( - "usage: herdr-pond open|tui|hook|serve-daemon" + "usage: herdr-pond open|tui|hook [--worker ]|serve-daemon [--owner]" )), }; match result { @@ -40,7 +43,16 @@ fn main() -> ExitCode { } } -/// Runs the desk, then performs a jump only after it has restored the terminal. +/// 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<()> { - anyhow::bail!("not implemented") + let context = DeskContext { + project: herdr::context_project(), + }; + 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 index 6f7ef6ea..b4c80b11 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -1,3 +1,499 @@ //! Finding a usable `pond serve` for the desk: this herdr server's published //! endpoint, else a desk-owned fallback child (plan 5.7), both vetted by the -//! capability probe (plan 5.8). Owner: agent B. +//! capability probe (plan 5.8). The per-server state layout and the serve +//! spawn/teardown are shared with the daemon. + +use std::fs; +use std::net::SocketAddr; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; +use serde::{Deserialize, Serialize}; + +use crate::api::{SQL_PATH, post, sql_request}; +use crate::config::{Config, log_line, open_log, write_atomic}; +use crate::herdr; +use crate::types::{ApiError, READY_SQL, SqlResponse}; + +/// Store open (seconds on S3) happens before `pond serve` binds. +pub(crate) const PORT_DEADLINE: Duration = Duration::from_secs(180); +const PROBE_DEADLINE: Duration = Duration::from_secs(5); +const PROBE_TIMEOUT_SECS: u64 = 5; +const FALLBACK_GRACE: Duration = Duration::from_secs(2); +const PORT_POLL: Duration = Duration::from_millis(100); + +/// `STATE_DIR/serve//`: 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 { + 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 port_file(&self, owner: &str) -> PathBuf { + self.root.join(format!("{owner}.port")) + } + + fn desk_log(&self) -> PathBuf { + self.root.join("desk-serve.log") + } +} + +/// 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. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Endpoint { + pub port: u16, + pub pid: u32, + pub token: String, + pub pond_version: String, +} + +impl Endpoint { + pub(crate) fn base_url(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } +} + +/// A missing or malformed record is no record. +pub(crate) fn read_endpoint(path: &Path) -> Option { + 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) +} + +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() +} + +/// `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(client: &reqwest::Client, base_url: &str) -> Result<(), ApiError> { + let request = sql_request(READY_SQL.to_owned(), 1, PROBE_TIMEOUT_SECS); + let response: SqlResponse = post(client, base_url, SQL_PATH, &request, PROBE_DEADLINE).await?; + if response.rows.is_empty() { + return Err(ApiError::Decode(format!( + "{base_url} answered the readiness probe with no rows" + ))); + } + Ok(()) +} + +/// `pond serve` bound to loopback on a free port. `--host` is explicit +/// because an inherited `POND_HOST` would otherwise rebind it; stdio goes to +/// `log` because serve's output would corrupt the TUI or pin a herdr slot. +pub(crate) fn spawn_serve(pond: &Path, port_file: &Path, log: &Path) -> std::io::Result { + let _ = fs::remove_file(port_file); + let out = open_log(log)?; + let err = out.try_clone()?; + Command::new(pond) + .args(["serve", "--host", "127.0.0.1", "--port", "0", "--port-file"]) + .arg(port_file) + .stdin(Stdio::null()) + .stdout(out) + .stderr(err) + .spawn() +} + +/// The base URL from a `--port-file` (`host:port`, written atomically after bind). +pub(crate) fn read_port_file(path: &Path) -> Option { + fs::read_to_string(path).ok()?.trim().parse().ok() +} + +/// SIGTERM, a bounded wait, then SIGKILL and reap: serve's own drain bounds +/// only the HTTP side, not process teardown. +pub(crate) 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(Duration::from_millis(25)); + } + 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 ServeChild { + child: Child, + port_file: PathBuf, + base_url: String, +} + +impl Drop for ServeChild { + fn drop(&mut self) { + terminate(&mut self.child, FALLBACK_GRACE); + let _ = fs::remove_file(&self.port_file); + } +} + +/// 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 { + Ok(Self { + dir: ServeDir::from_env()?, + config_dir: herdr::config_dir()?, + }) + } +} + +pub(crate) struct Connection { + pub base_url: String, + pub fallback: Option, +} + +/// 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( + client: &reqwest::Client, + origin: &Origin, + fallback: Option, +) -> Result { + if let Some(endpoint) = read_endpoint(&origin.dir.endpoint()) { + let base_url = endpoint.base_url(); + if probe(client, &base_url).await.is_ok() { + return Ok(Connection { + base_url, + fallback: None, + }); + } + } + if let Some(fallback) = fallback + && probe(client, &fallback.base_url).await.is_ok() + { + return Ok(Connection { + base_url: fallback.base_url.clone(), + fallback: Some(fallback), + }); + } + let fallback = spawn_fallback(origin).await?; + probe(client, &fallback.base_url).await?; + Ok(Connection { + base_url: fallback.base_url.clone(), + fallback: Some(fallback), + }) +} + +async fn spawn_fallback(origin: &Origin) -> Result { + let log = origin.dir.desk_log(); + let pond = Config::load(&origin.config_dir, &log) + .resolve_pond(&origin.config_dir) + .map_err(|error| ApiError::Unreachable(format!("{error:#}")))?; + let port_file = origin + .dir + .port_file(&format!("desk.{}", std::process::id())); + log_line(&log, &format!("desk: starting fallback {}", pond.display())); + let child = spawn_serve(&pond, &port_file, &log).map_err(|error| { + ApiError::Unreachable(format!("cannot start {}: {error}", pond.display())) + })?; + let mut serve = ServeChild { + child, + port_file, + base_url: String::new(), + }; + let started = Instant::now(); + loop { + if let Some(addr) = read_port_file(&serve.port_file) { + serve.base_url = format!("http://{addr}"); + return Ok(serve); + } + if let Ok(Some(status)) = serve.child.try_wait() { + return Err(ApiError::Unreachable(format!( + "pond serve exited ({status}) before listening - see {}", + log.display() + ))); + } + if started.elapsed() > PORT_DEADLINE { + return Err(ApiError::Unreachable(format!( + "pond serve did not listen within {}s - see {}", + PORT_DEADLINE.as_secs(), + log.display() + ))); + } + tokio::time::sleep(PORT_POLL).await; + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use super::*; + use crate::api::client; + use crate::fake_pond::{FakePond, Reply, Sandbox, golden, write_script}; + + fn endpoint(port: u16, token: &str) -> Endpoint { + Endpoint { + port, + pid: 1, + token: token.to_owned(), + pond_version: "pond 0.20.0".to_owned(), + } + } + + fn port_of(base_url: &str) -> u16 { + base_url.rsplit(':').next().unwrap().parse().unwrap() + } + + /// A port nothing listens on: bound, then released. + fn dead_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() + } + + async fn ready_pond() -> FakePond { + FakePond::with_sql( + vec![("SELECT 1", Reply::json(golden::SQL_READY))], + Reply::json(golden::SEARCH), + ) + .await + } + + /// A fake `pond serve` that records its argv, prints to both streams, + /// publishes `addr` through `--port-file` and stays up. + fn fake_serve(sandbox: &Sandbox, addr: &str) -> Origin { + let pond = write_script( + &sandbox.path("bin/pond"), + &format!( + r#"printf '%s\n' "$*" >> '{calls}' +echo "serve stdout"; echo "serve stderr" >&2 +eval "port_file=\${{$#}}" +printf '%s' '{addr}' > "$port_file.tmp" && mv "$port_file.tmp" "$port_file" +exec sleep 30"#, + calls = sandbox.path("calls").display(), + ), + ); + sandbox.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); + Origin { + dir: ServeDir::new(&sandbox.state_dir(), &sandbox.path("herdr.sock")), + config_dir: sandbox.config_dir(), + } + } + + fn alive(pid: u32) -> bool { + kill(Pid::from_raw(i32::try_from(pid).unwrap()), None).is_ok() + } + + #[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_is_removed_only_by_its_owner() { + let sandbox = Sandbox::new(); + let path = sandbox.path("state/endpoint"); + write_endpoint(&path, &endpoint(9, "mine")).unwrap(); + assert_eq!(read_endpoint(&path), Some(endpoint(9, "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_endpoint_or_port_file_is_absent() { + let sandbox = Sandbox::new(); + let path = sandbox.path("endpoint"); + for text in ["", "{", r#"{"port":"x"}"#, r#"{"port":1,"pid":2}"#] { + fs::write(&path, text).unwrap(); + assert_eq!(read_endpoint(&path), None, "{text}"); + assert!(!remove_endpoint_if_owned(&path, "t")); + } + fs::write(&path, "127.0.0.1:54321\n").unwrap(); + assert_eq!( + read_port_file(&path), + Some("127.0.0.1:54321".parse().unwrap()) + ); + fs::write(&path, "127.0.0.1:").unwrap(); + assert_eq!(read_port_file(&path), None); + } + + #[tokio::test] + async fn probe_tells_ready_from_old_from_dead() { + let client = client().unwrap(); + let ready = ready_pond().await; + probe(&client, &ready.base_url).await.unwrap(); + assert!(ready.recorded()[0].body.contains(r#""limit":1"#)); + + let old = FakePond::start(|_, _| Reply::plain(404, "")).await; + assert_eq!( + probe(&client, &old.base_url).await, + Err(ApiError::PondTooOld) + ); + + let dead = format!("http://127.0.0.1:{}", dead_port()); + assert!(matches!( + probe(&client, &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 = fake_serve(&sandbox, "127.0.0.1:1"); + write_endpoint( + &origin.dir.endpoint(), + &endpoint(port_of(&pond.base_url), "t"), + ) + .unwrap(); + let connection = connect(&client().unwrap(), &origin, None).await.unwrap(); + assert_eq!(connection.base_url, pond.base_url); + 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.base_url.trim_start_matches("http://")); + write_endpoint(&origin.dir.endpoint(), &endpoint(dead_port(), "t")).unwrap(); + + let connection = connect(&client().unwrap(), &origin, None).await.unwrap(); + assert_eq!(connection.base_url, pond.base_url); + let fallback = connection.fallback.expect("fallback child"); + let calls = fs::read_to_string(sandbox.path("calls")).unwrap(); + assert!( + calls.starts_with("serve --host 127.0.0.1 --port 0 --port-file "), + "{calls}" + ); + let log = fs::read_to_string(origin.dir.desk_log()).unwrap(); + assert!(log.contains("serve stdout") && log.contains("serve stderr")); + + let pid = fallback.child.id(); + let port_file = fallback.port_file.clone(); + assert!(alive(pid)); + drop(fallback); + assert!(!alive(pid), "fallback serve survived the desk"); + assert!(!port_file.exists()); + } + + #[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.base_url.trim_start_matches("http://")); + let client = client().unwrap(); + let first = connect(&client, &origin, None).await.unwrap(); + let pid = first.fallback.as_ref().unwrap().child.id(); + let second = connect(&client, &origin, first.fallback).await.unwrap(); + assert_eq!(second.fallback.as_ref().unwrap().child.id(), pid); + assert_eq!( + fs::read_to_string(sandbox.path("calls")) + .unwrap() + .lines() + .count(), + 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 = Origin { + dir: ServeDir::new(&sandbox.state_dir(), &sandbox.path("herdr.sock")), + config_dir: sandbox.config_dir(), + }; + let Err(ApiError::Unreachable(reason)) = connect(&client().unwrap(), &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 missing_pond_names_the_config_key() { + let sandbox = Sandbox::new(); + sandbox.write_config("pond_bin = \"/nonexistent/pond\"\n"); + let origin = Origin { + dir: ServeDir::new(&sandbox.state_dir(), &sandbox.path("herdr.sock")), + config_dir: sandbox.config_dir(), + }; + let Err(ApiError::Unreachable(reason)) = connect(&client().unwrap(), &origin, None).await + else { + panic!("expected Unreachable"); + }; + assert!(reason.contains("pond_bin"), "{reason}"); + } +} From 56da0aa7554852100257c73de080c34806870b34 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 04:37:23 +0000 Subject: [PATCH 05/41] docs(herdr-pond): README - prerequisites, build and link, keybinding, config --- packages/herdr-pond/README.md | 65 +++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/herdr-pond/README.md diff --git a/packages/herdr-pond/README.md b/packages/herdr-pond/README.md new file mode 100644 index 00000000..4c633f7b --- /dev/null +++ b/packages/herdr-pond/README.md @@ -0,0 +1,65 @@ +# 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 with `/v1/x/sql` and `pond serve --port-file`. With an older pond the desk says so and names 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 again focuses the open 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, bound to `127.0.0.1` on a free port, and stops it when that server exits. It is a personal localhost server; 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 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. +- 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. +- 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. +- 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. + +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. From 02d9d67f46785934e829c7ebe5a4ddab183cc477 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 04:39:29 +0000 Subject: [PATCH 06/41] fix(herdr-pond): report herdr CLI failures as herdr errors, not pond serve --- packages/herdr-pond/src/api.rs | 6 +++--- packages/herdr-pond/src/types.rs | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs index 78593ff7..595744d3 100644 --- a/packages/herdr-pond/src/api.rs +++ b/packages/herdr-pond/src/api.rs @@ -260,8 +260,8 @@ impl Api for HttpApi { Box::pin(async move { let panes = tokio::task::spawn_blocking(move || herdr.pane_list(None)) .await - .map_err(|error| ApiError::Unreachable(format!("herdr pane list: {error}")))? - .map_err(|error| ApiError::Unreachable(format!("{error:#}")))?; + .map_err(|error| ApiError::Herdr(format!("pane list: {error}")))? + .map_err(|error| ApiError::Herdr(format!("{error:#}")))?; Ok(herdr::live_agents(panes)) }) } @@ -576,7 +576,7 @@ mod tests { assert!(live[0].matches("abc")); write_script(&sandbox.path("bin/herdr"), "echo boom >&2; exit 1"); - let Err(ApiError::Unreachable(reason)) = api.live_agents().await else { + 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/types.rs b/packages/herdr-pond/src/types.rs index c4fe59cb..2287d655 100644 --- a/packages/herdr-pond/src/types.rs +++ b/packages/herdr-pond/src/types.rs @@ -144,6 +144,8 @@ pub(crate) enum ApiError { Unreachable(String), /// The response did not match the contract. Decode(String), + /// A herdr CLI call failed. + Herdr(String), } impl fmt::Display for ApiError { @@ -156,6 +158,7 @@ impl fmt::Display for ApiError { ), Self::Unreachable(reason) => write!(f, "pond serve unreachable: {reason}"), Self::Decode(reason) => write!(f, "unexpected response from pond: {reason}"), + Self::Herdr(reason) => write!(f, "herdr: {reason}"), } } } From 101353823bc005d28637d62ba38e3d0b521bb590 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 06:10:46 +0000 Subject: [PATCH 07/41] refactor(herdr-pond): dedupe helpers and trim unread wire fields Shared helpers replace repeated blocks: SqlResponse::into_rows, SqlRequest::new / SearchRequest::new().within() owning protocol_version and the from_date format, ListingScope::recent, serve::live_endpoint, Config::pond, config::log_stdio and ensure_parent, one runtime builder and usage string in main.rs, and the test fixtures (fake serve script, dead port, endpoint, sandbox origin/lines) in fake_pond. Fields nothing reads are gone: Endpoint.pid and pond_version (and the extra pond --version spawn), LiveAgent.agent, and the SQL/search response fields the desk never shows. --- packages/herdr-pond/src/api.rs | 112 +++++--------------- packages/herdr-pond/src/config.rs | 30 ++++-- packages/herdr-pond/src/daemon.rs | 151 +++++++-------------------- packages/herdr-pond/src/desk/app.rs | 63 +++++------ packages/herdr-pond/src/desk/mod.rs | 17 +-- packages/herdr-pond/src/desk/ui.rs | 24 ++--- packages/herdr-pond/src/fake_pond.rs | 107 +++++++++++++++++-- packages/herdr-pond/src/herdr.rs | 31 ++---- packages/herdr-pond/src/hook.rs | 76 +++++--------- packages/herdr-pond/src/main.rs | 14 ++- packages/herdr-pond/src/serve.rs | 138 +++++++----------------- packages/herdr-pond/src/types.rs | 120 ++++++++++++--------- 12 files changed, 383 insertions(+), 500 deletions(-) diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs index 595744d3..1576262c 100644 --- a/packages/herdr-pond/src/api.rs +++ b/packages/herdr-pond/src/api.rs @@ -12,8 +12,8 @@ use crate::herdr::{self, Herdr}; use crate::serve::{self, Origin, ServeChild}; use crate::types::{ Api, ApiError, ApiFuture, Cursor, ErrorEnvelope, ListingScope, LiveAgent, PAGE_ROWS, - PREVIEW_ROWS, PROTOCOL_VERSION, SearchRequest, SearchResponse, SessionDetail, SessionRow, - SqlRequest, SqlResponse, TranscriptMessage, TranscriptPage, hydrate_sql, listing_sql, page_sql, + PREVIEW_ROWS, SearchRequest, SearchResponse, SessionDetail, SessionRow, SqlRequest, + SqlResponse, TranscriptMessage, TranscriptPage, hydrate_sql, listing_sql, page_sql, preview_sql, }; @@ -37,17 +37,6 @@ pub(crate) fn client() -> anyhow::Result { .build()?) } -/// `limit` is always the query's own SQL `LIMIT`, so the server's default -/// 100-row cap never cuts a page. -pub(crate) fn sql_request(query: String, limit: usize, timeout_seconds: u64) -> SqlRequest { - SqlRequest { - protocol_version: PROTOCOL_VERSION, - query, - limit: Some(limit), - timeout_seconds: Some(timeout_seconds), - } -} - pub(crate) fn sql_deadline(timeout_seconds: u64) -> Duration { Duration::from_secs(timeout_seconds) + CLIENT_SLACK } @@ -112,14 +101,6 @@ fn decode(path: &str, status: u16, body: &str) -> Result(response: SqlResponse) -> Result, ApiError> { - response - .rows - .into_iter() - .map(|row| serde_json::from_value(row).map_err(|error| ApiError::Decode(error.to_string()))) - .collect() -} - /// The resolved serve. `base_url` stays unset until the first call, so the /// desk's loading state covers a cold fallback spawn. #[derive(Default)] @@ -202,7 +183,7 @@ impl HttpApi { limit: usize, timeout_seconds: u64, ) -> Result { - let request = sql_request(query, limit, timeout_seconds); + let request = SqlRequest::new(query, limit, timeout_seconds); self.post(SQL_PATH, &request, sql_deadline(timeout_seconds)) .await } @@ -216,7 +197,9 @@ impl Api for HttpApi { } else { QUERY_TIMEOUT_SECS }; - rows(self.sql(listing_sql(&scope), scope.limit, timeout).await?) + self.sql(listing_sql(&scope), scope.limit, timeout) + .await? + .into_rows() }) } @@ -226,10 +209,9 @@ impl Api for HttpApi { return Ok(Vec::new()); } let query = hydrate_sql(&session_ids); - rows( - self.sql(query, session_ids.len(), QUERY_TIMEOUT_SECS) - .await?, - ) + self.sql(query, session_ids.len(), QUERY_TIMEOUT_SECS) + .await? + .into_rows() }) } @@ -240,7 +222,9 @@ impl Api for HttpApi { fn preview(&self, session_id: String) -> ApiFuture<'_, Vec> { Box::pin(async move { let query = preview_sql(&session_id); - rows(self.sql(query, PREVIEW_ROWS, QUERY_TIMEOUT_SECS).await?) + self.sql(query, PREVIEW_ROWS, QUERY_TIMEOUT_SECS) + .await? + .into_rows() }) } @@ -250,7 +234,7 @@ impl Api for HttpApi { let response = self.sql(query, PAGE_ROWS, QUERY_TIMEOUT_SECS).await?; Ok(TranscriptPage { truncated: response.truncated, - messages: rows(response)?, + messages: response.into_rows()?, }) }) } @@ -271,12 +255,11 @@ impl Api for HttpApi { mod tests { #![allow(clippy::expect_used, clippy::unwrap_used)] - use chrono::{DateTime, Utc}; - use super::*; - use crate::fake_pond::{FakePond, Reply, Sandbox, golden, write_script}; - use crate::serve::{Endpoint, ServeDir, write_endpoint}; - use crate::types::{ProjectFilter, SearchFilters}; + use crate::fake_pond::{ + FakePond, Reply, Sandbox, dead_url, endpoint, golden, ts, write_script, + }; + use crate::serve::write_endpoint; /// An api pinned to `base_url`, re-resolving through `sandbox`'s state. /// `pond_bin` points nowhere, so no re-resolution can reach a real pond. @@ -287,10 +270,7 @@ mod tests { )); HttpApi { client: client().unwrap(), - origin: Ok(Origin { - dir: ServeDir::new(&sandbox.state_dir(), &sandbox.path("herdr.sock")), - config_dir: sandbox.config_dir(), - }), + origin: Ok(sandbox.origin()), herdr: Herdr::new(sandbox.path("bin/herdr")), link: Mutex::new(Link { base_url: Some(base_url.to_owned()), @@ -306,19 +286,6 @@ mod tests { .collect() } - fn dead_url() -> String { - let port = std::net::TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port(); - format!("http://127.0.0.1:{port}") - } - - fn ts(raw: &str) -> DateTime { - raw.parse().unwrap() - } - #[tokio::test] async fn listing_sends_its_sql_and_limit() { let sandbox = Sandbox::new(); @@ -422,16 +389,13 @@ mod tests { let sandbox = Sandbox::new(); let pond = FakePond::with_sql(Vec::new(), Reply::json(golden::SEARCH_OUT_OF_SCOPE)).await; let api = api_at(&pond.base_url, &sandbox); + let scope = ListingScope { + project: Some("/pj/pond".to_owned()), + since: None, + limit: 1, + }; let response = api - .search(SearchRequest { - protocol_version: PROTOCOL_VERSION, - query: "timer".to_owned(), - filters: SearchFilters { - project: Some(ProjectFilter::Contains("/pj/pond".to_owned())), - from_date: None, - }, - limit: 20, - }) + .search(SearchRequest::new("timer".to_owned(), 20).within(&scope)) .await .unwrap(); assert_eq!(response.searchable_in_scope, 0); @@ -466,14 +430,7 @@ mod tests { assert_eq!(code, "validation_failed"); assert!(message.starts_with("sql error: query exceeded the 30s limit")); - let rejected = api - .search(SearchRequest { - protocol_version: PROTOCOL_VERSION, - query: "x".to_owned(), - filters: SearchFilters::default(), - limit: 1, - }) - .await; + let rejected = api.search(SearchRequest::new("x".to_owned(), 1)).await; assert_eq!( rejected.unwrap_err(), ApiError::Rejected { @@ -497,7 +454,7 @@ mod tests { let pond = FakePond::start(|_, _| Reply::json(golden::SQL_EMPTY).delayed(Duration::from_secs(5))) .await; - let request = sql_request(preview_sql("s"), PREVIEW_ROWS, 1); + let request = SqlRequest::new(preview_sql("s"), PREVIEW_ROWS, 1); let result: Result = post( &client().unwrap(), &pond.base_url, @@ -524,21 +481,8 @@ mod tests { ) .await; let api = api_at(&dead_url(), &sandbox); - let port = replacement - .base_url - .rsplit(':') - .next() - .unwrap() - .parse() - .unwrap(); - let endpoint = Endpoint { - port, - pid: 1, - token: "t".to_owned(), - pond_version: "pond".to_owned(), - }; - let dir = ServeDir::new(&sandbox.state_dir(), &sandbox.path("herdr.sock")); - write_endpoint(&dir.endpoint(), &endpoint).unwrap(); + let dir = sandbox.origin().dir; + write_endpoint(&dir.endpoint(), &endpoint(replacement.port(), "t")).unwrap(); let messages = api.preview("s1".to_owned()).await.unwrap(); assert_eq!(messages.len(), 2); diff --git a/packages/herdr-pond/src/config.rs b/packages/herdr-pond/src/config.rs index e5d228ca..ecf576ea 100644 --- a/packages/herdr-pond/src/config.rs +++ b/packages/herdr-pond/src/config.rs @@ -6,6 +6,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, Write}; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use anyhow::{Context, bail}; use nix::errno::Errno; @@ -59,6 +60,11 @@ impl Config { }) } + /// [`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. @@ -104,15 +110,21 @@ fn is_executable(path: &Path) -> bool { /// 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 { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } + ensure_parent(path)?; if fs::metadata(path).is_ok_and(|meta| meta.len() > LOG_CAP_BYTES) { OpenOptions::new().write(true).open(path)?.set_len(0)?; } OpenOptions::new().create(true).append(true).open(path) } +/// 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) { @@ -124,9 +136,7 @@ pub(crate) fn log_line(path: &Path, message: &str) { /// 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>> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } + ensure_parent(path)?; let file = OpenOptions::new() .create(true) .truncate(false) @@ -141,9 +151,7 @@ pub(crate) fn try_lock(path: &Path) -> io::Result>> { /// Temp file + rename, so a reader never sees a half-written file. pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } + ensure_parent(path)?; let mut temp = path.as_os_str().to_owned(); temp.push(format!(".tmp.{}", std::process::id())); let temp = PathBuf::from(temp); @@ -153,6 +161,10 @@ pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { }) } +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)] diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index 3705d879..ffd37d15 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -9,23 +9,22 @@ use std::collections::hash_map::RandomState; use std::hash::BuildHasher; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Command}; use std::time::{Duration, Instant}; use anyhow::bail; use chrono::Utc; -use crate::api::{SEARCH_PATH, SQL_PATH, client, post, sql_deadline, sql_request}; -use crate::config::{Config, log_line, try_lock}; -use crate::herdr; +use crate::api::{QUERY_TIMEOUT_SECS, SEARCH_PATH, SQL_PATH, client, post, sql_deadline}; +use crate::config::{log_line, try_lock}; use crate::serve::{ - Endpoint, PORT_DEADLINE, ServeDir, probe, read_endpoint, read_port_file, + Endpoint, PORT_DEADLINE, ServeDir, live_endpoint, probe, read_port_file, remove_endpoint_if_owned, spawn_serve, terminate, write_endpoint, }; use crate::types::{ - LISTING_ROWS, LISTING_WINDOW_DAYS, ListingScope, PROTOCOL_VERSION, SearchFilters, - SearchRequest, SearchResponse, SqlResponse, listing_sql, + LISTING_ROWS, ListingScope, SearchRequest, SearchResponse, SqlRequest, SqlResponse, listing_sql, }; +use crate::{herdr, runtime}; struct Timing { tick: Duration, @@ -56,16 +55,10 @@ pub(crate) fn run(args: &[String]) -> anyhow::Result<()> { herdr::detach(); owner() } - _ => bail!("usage: herdr-pond serve-daemon [--owner]"), + _ => bail!(crate::USAGE), } } -fn runtime() -> std::io::Result { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() -} - /// The startup hook: exits at once, failures go to `daemon.log`. fn startup() { let Ok(dir) = ServeDir::from_env() else { @@ -93,12 +86,10 @@ async fn start( let Some(lock) = try_lock(&dir.lock())? else { return Ok(()); }; - if let Some(endpoint) = read_endpoint(&dir.endpoint()) - && probe(&client()?, &endpoint.base_url()).await.is_ok() - { + if let Some(base_url) = live_endpoint(&client()?, dir).await { log_line( &dir.daemon_log(), - &format!("adopted live endpoint {}", endpoint.base_url()), + &format!("adopted live endpoint {base_url}"), ); return Ok(()); } @@ -113,8 +104,7 @@ fn owner() -> anyhow::Result<()> { let config_dir = herdr::config_dir()?; let log = dir.daemon_log(); runtime()?.block_on(own(&dir, &socket, &TIMING, || { - let config = Config::load(&config_dir, &log); - herdr::resolve_pond_or_toast(&config, &config_dir, &state_dir, &log) + herdr::resolve_pond_or_toast(&config_dir, &state_dir, &log) })) } @@ -129,26 +119,22 @@ async fn own( return Ok(()); }; let client = client()?; - if let Some(endpoint) = read_endpoint(&dir.endpoint()) - && probe(&client, &endpoint.base_url()).await.is_ok() - { + if live_endpoint(&client, dir).await.is_some() { return Ok(()); } let Some(pond) = resolve_pond() else { return Ok(()); }; - let pond_version = pond_version(&pond); let port_file = dir.port_file("owner"); let mut child = spawn_serve(&pond, &port_file, &log)?; log_line( &log, - &format!("owner: started {pond_version} (pid {})", child.id()), + &format!("owner: started {} (pid {})", pond.display(), child.id()), ); let serve = Serve { client, child: &mut child, port_file: &port_file, - pond_version, socket, log: &log, }; @@ -162,18 +148,6 @@ async fn own( Ok(()) } -fn pond_version(pond: &Path) -> String { - Command::new(pond) - .arg("--version") - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output() - .ok() - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) - .filter(|version| !version.is_empty()) - .unwrap_or_else(|| "unknown".to_owned()) -} - fn random_token() -> String { let state = RandomState::new(); format!( @@ -187,7 +161,6 @@ struct Serve<'a> { client: reqwest::Client, child: &'a mut Child, port_file: &'a Path, - pond_version: String, socket: &'a Path, log: &'a Path, } @@ -230,9 +203,7 @@ impl Serve<'_> { } let endpoint = Endpoint { port: addr.port(), - pid: self.child.id(), token: random_token(), - pond_version: self.pond_version.clone(), }; if let Err(error) = write_endpoint(&dir.endpoint(), &endpoint) { break format!("cannot publish the endpoint: {error}"); @@ -266,24 +237,14 @@ impl Serve<'_> { /// cost lands here instead of on the first desk open. Failure is not fatal. async fn warm_up(client: reqwest::Client, base_url: String, log: PathBuf, deadline: Duration) { let started = Instant::now(); - let scope = ListingScope { - project: None, - since: Some(Utc::now() - chrono::TimeDelta::days(LISTING_WINDOW_DAYS)), - limit: LISTING_ROWS, - }; - let listing = sql_request( - listing_sql(&scope), + let listing = SqlRequest::new( + listing_sql(&ListingScope::recent(None, Utc::now())), LISTING_ROWS, - crate::api::QUERY_TIMEOUT_SECS, + QUERY_TIMEOUT_SECS, ); - let search = SearchRequest { - protocol_version: PROTOCOL_VERSION, - query: WARMUP_QUERY.to_owned(), - filters: SearchFilters::default(), - limit: 1, - }; + let search = SearchRequest::new(WARMUP_QUERY.to_owned(), 1); let result = tokio::time::timeout(deadline, async { - let deadline = sql_deadline(crate::api::QUERY_TIMEOUT_SECS); + let deadline = sql_deadline(QUERY_TIMEOUT_SECS); post::<_, SqlResponse>(&client, &base_url, SQL_PATH, &listing, deadline).await?; post::<_, SearchResponse>(&client, &base_url, SEARCH_PATH, &search, deadline).await }) @@ -309,11 +270,9 @@ mod tests { use std::fs; use std::os::unix::net::UnixListener; - use nix::sys::signal::kill; - use nix::unistd::Pid; - use super::*; - use crate::fake_pond::{FakePond, Reply, Sandbox, golden, write_script}; + use crate::fake_pond::{FakePond, Reply, Sandbox, alive, endpoint, golden}; + use crate::serve::read_endpoint; const FAST: Timing = Timing { tick: Duration::from_millis(20), @@ -351,28 +310,11 @@ mod tests { } } - /// A fake `pond` whose `serve` publishes the fake server's address, - /// then runs `after` (default: stays up). + /// A fake `pond serve` that publishes the fake server's address when + /// `publish`, then runs `after`. fn fake_pond(&self, publish: bool, after: &str) -> PathBuf { - let publish = if publish { - format!( - r#"printf '%s' '{}' > "$port_file.tmp" && mv "$port_file.tmp" "$port_file""#, - self.pond.base_url.trim_start_matches("http://") - ) - } else { - String::new() - }; - write_script( - &self.sandbox.path("bin/pond"), - &format!( - r#"[ "$1" = --version ] && {{ echo 'pond 9.9.9'; exit 0; }} -printf '%s\n' "$*" >> '{calls}' -eval "port_file=\${{$#}}" -{publish} -{after}"#, - calls = self.sandbox.path("calls").display(), - ), - ) + let addr = publish.then(|| self.pond.addr()); + self.sandbox.fake_serve(addr, after) } async fn own(&self, pond: &Path) -> anyhow::Result<()> { @@ -381,9 +323,9 @@ eval "port_file=\${{$#}}" } fn serve_calls(&self) -> usize { - fs::read_to_string(self.sandbox.path("calls")) - .unwrap_or_default() - .lines() + self.sandbox + .lines("calls") + .iter() .filter(|line| line.starts_with("serve --host 127.0.0.1 --port 0 --port-file ")) .count() } @@ -401,10 +343,6 @@ eval "port_file=\${{$#}}" } } - fn alive(pid: u32) -> bool { - kill(Pid::from_raw(i32::try_from(pid).unwrap()), None).is_ok() - } - #[tokio::test] async fn one_owner_serves_until_herdr_goes_away() { let setup = Setup::new().await; @@ -416,12 +354,10 @@ eval "port_file=\${{$#}}" && setup.pond.recorded().iter().any(|r| r.path == SEARCH_PATH) }) .await; - let endpoint = read_endpoint(&setup.dir.endpoint()).unwrap(); - assert!(alive(endpoint.pid)); + assert!(alive(setup.sandbox.serve_pid())); drop(listener); - endpoint }; - let ((first, second), endpoint) = tokio::join!( + let ((first, second), ()) = tokio::join!( async { tokio::join!(setup.own(&pond), setup.own(&pond)) }, herdr_stops ); @@ -429,12 +365,14 @@ eval "port_file=\${{$#}}" second.unwrap(); assert_eq!(setup.serve_calls(), 1, "{}", setup.log()); - assert_eq!(endpoint.pond_version, "pond 9.9.9"); assert!( !setup.dir.endpoint().exists(), "endpoint outlived its serve" ); - assert!(!alive(endpoint.pid), "pond serve outlived herdr"); + 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); @@ -486,16 +424,11 @@ eval "port_file=\${{$#}}" #[tokio::test] async fn a_serve_that_never_listens_is_killed_at_the_deadline() { let setup = Setup::new().await; - let pid_file = setup.sandbox.path("pid"); - let pond = setup.fake_pond( - false, - &format!("echo $$ > '{}'; exec sleep 30", pid_file.display()), - ); + 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 listen"), "{}", setup.log()); - let pid = fs::read_to_string(pid_file).unwrap(); - assert!(!alive(pid.trim().parse().unwrap())); + assert!(!alive(setup.sandbox.serve_pid())); assert!(!setup.dir.endpoint().exists()); } @@ -520,21 +453,7 @@ eval "port_file=\${{$#}}" assert_eq!(spawned.get(), 2, "an owner holds the lock"); drop(held); - let port = setup - .pond - .base_url - .rsplit(':') - .next() - .unwrap() - .parse() - .unwrap(); - let live = Endpoint { - port, - pid: 1, - token: "t".to_owned(), - pond_version: "pond".to_owned(), - }; - write_endpoint(&setup.dir.endpoint(), &live).unwrap(); + write_endpoint(&setup.dir.endpoint(), &endpoint(setup.pond.port(), "t")).unwrap(); start(&setup.dir, spawn).await.unwrap(); assert_eq!(spawned.get(), 2, "live endpoint is adopted"); assert!(setup.log().contains("adopted")); diff --git a/packages/herdr-pond/src/desk/app.rs b/packages/herdr-pond/src/desk/app.rs index b7e0fb2e..2c0ee008 100644 --- a/packages/herdr-pond/src/desk/app.rs +++ b/packages/herdr-pond/src/desk/app.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use std::time::Duration; -use chrono::{DateTime, TimeDelta, Utc}; +use chrono::{DateTime, Utc}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::layout::{Rect, Size}; use ratatui::text::Line; @@ -14,8 +14,7 @@ use unicode_width::UnicodeWidthStr; use super::ui; use crate::types::{ - ApiError, Cursor, DeskContext, DeskExit, LISTING_ROWS, LISTING_WINDOW_DAYS, ListingScope, - LiveAgent, PAGE_ROWS, PROTOCOL_VERSION, ProjectFilter, SearchFilters, SearchRequest, + ApiError, Cursor, DeskContext, DeskExit, ListingScope, LiveAgent, PAGE_ROWS, SearchRequest, SearchResponse, SessionDetail, SessionRow, TranscriptMessage, TranscriptPage, }; @@ -34,7 +33,7 @@ pub(super) enum Lane { } impl Lane { - pub(super) const COUNT: usize = 6; + 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 @@ -218,10 +217,8 @@ impl Pager { self.width = width; self.lines.clear(); self.starts.clear(); - for message in &self.messages { - self.starts.push(self.lines.len()); - self.lines.extend(ui::message_lines(message, width)); - } + let messages = std::mem::take(&mut self.messages); + self.append(messages); self.offset = self.starts.get(anchor).copied().unwrap_or(0); } @@ -306,15 +303,14 @@ impl App { } pub(super) fn scope(&self) -> ListingScope { - ListingScope { - project: if self.all_projects { - None - } else { - self.context.project.clone() - }, - since: (!self.all_time).then(|| self.now - TimeDelta::days(LISTING_WINDOW_DAYS)), - limit: LISTING_ROWS, + let project = (!self.all_projects) + .then(|| self.context.project.clone()) + .flatten(); + let mut scope = ListingScope::recent(project, self.now); + if self.all_time { + scope.since = None; } + scope } pub(super) fn listing(&self) -> Option<&[SessionRow]> { @@ -352,7 +348,7 @@ impl App { } } - fn state_mut(&mut self) -> &mut ListState { + pub(super) fn state_mut(&mut self) -> &mut ListState { if self.search.is_some() { &mut self.search_state } else { @@ -434,25 +430,15 @@ impl App { self.fetch(Call::Live, Duration::ZERO), ]; if let Some(query) = self.search.as_ref().map(|s| s.query.clone()) { - effects.push(self.fetch(Call::Search(self.search_request(query)), Duration::ZERO)); + effects.push(self.fetch_search(query, Duration::ZERO)); } effects.extend(self.preview_selected(Duration::ZERO)); effects } - fn search_request(&self, query: String) -> SearchRequest { - let scope = self.scope(); - SearchRequest { - protocol_version: PROTOCOL_VERSION, - query, - filters: SearchFilters { - project: scope.project.map(ProjectFilter::Contains), - from_date: scope - .since - .map(|since| since.format("%Y-%m-%d").to_string()), - }, - limit: SEARCH_LIMIT, - } + fn fetch_search(&mut self, query: String, delay: Duration) -> Effect { + let request = SearchRequest::new(query, SEARCH_LIMIT).within(&self.scope()); + self.fetch(Call::Search(request), delay) } pub(super) fn on_event(&mut self, event: &Event) -> Vec { @@ -684,7 +670,7 @@ impl App { self.search_state = ListState::default(); } } - effects.push(self.fetch(Call::Search(self.search_request(query)), SEARCH_DEBOUNCE)); + effects.push(self.fetch_search(query, SEARCH_DEBOUNCE)); effects } @@ -722,7 +708,7 @@ impl App { if let Some(search) = &mut self.search { search.response = None; let query = search.query.clone(); - effects.push(self.fetch(Call::Search(self.search_request(query)), Duration::ZERO)); + effects.push(self.fetch_search(query, Duration::ZERO)); } effects.extend(self.selection_changed()); effects @@ -757,7 +743,7 @@ impl App { .details .get(&id) .and_then(|detail| detail.title.as_deref()) - .map_or_else(|| "(no user message)".to_owned(), ui::one_line); + .map_or_else(|| ui::NO_TITLE.to_owned(), ui::one_line); let width = usize::from(self.pager_viewport().width); self.pager = Some(Pager::new(id, title, width)); self.load_more() @@ -772,7 +758,7 @@ impl App { .filter(|search| search.response.is_none()) .map(|search| search.query.clone()); if let Some(query) = stale_search { - effects.push(self.fetch(Call::Search(self.search_request(query)), Duration::ZERO)); + effects.push(self.fetch_search(query, Duration::ZERO)); } effects.extend(self.selection_changed()); effects @@ -945,9 +931,12 @@ mod tests { use ratatui::buffer::Buffer; use ratatui::style::Style; + use chrono::TimeDelta; + use super::*; - use crate::desk::tests::{MockApi, app, key, message, now, press, screen, settle}; + use crate::desk::tests::{MockApi, app, 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); @@ -1429,7 +1418,7 @@ mod tests { 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 = crate::desk::tests::sql_rows::(golden::SQL_PAGE); + let rows = sql_rows::(golden::SQL_PAGE); app.apply(page_reply(request, rows, false)); let screen_text = screen(&mut app); assert!( diff --git a/packages/herdr-pond/src/desk/mod.rs b/packages/herdr-pond/src/desk/mod.rs index 551c63a5..d6b9f12a 100644 --- a/packages/herdr-pond/src/desk/mod.rs +++ b/packages/herdr-pond/src/desk/mod.rs @@ -26,9 +26,7 @@ const SPINNER_TICK: Duration = Duration::from_millis(100); /// Builds its own current-thread runtime and owns the terminal until it /// returns; the terminal is restored on every return path. pub(crate) fn run(api: Arc, context: DeskContext) -> anyhow::Result { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; + let runtime = crate::runtime()?; let mut terminal = ratatui::try_init().inspect_err(|_| ratatui::restore())?; let result = runtime.block_on(async { let mut terminate = signal(SignalKind::terminate())?; @@ -188,12 +186,10 @@ pub(super) mod tests { } pub(in crate::desk) fn sql_rows(body: &str) -> Vec { - let response: SqlResponse = serde_json::from_str(body).unwrap(); - response - .rows - .into_iter() - .map(|row| serde_json::from_value(row).unwrap()) - .collect() + serde_json::from_str::(body) + .unwrap() + .into_rows() + .unwrap() } pub(in crate::desk) fn message(id: &str, ts: DateTime, text: &str) -> TranscriptMessage { @@ -228,7 +224,6 @@ pub(super) mod tests { transcript: sql_rows(golden::SQL_PAGE), live: vec![LiveAgent { pane_id: "p7".to_owned(), - agent: Some("claude".to_owned()), session: "s-live".to_owned(), }], ..Self::default() @@ -280,9 +275,7 @@ pub(super) mod tests { .map_or(Duration::ZERO, |delay| delay(&request.query)); let response = self.search.clone().unwrap_or_else(|| SearchResponse { sessions: Vec::new(), - matched_total: 0, searchable_in_scope: 100, - has_more: false, }); self.reply(Call::Search(request), delay, Ok(response)) } diff --git a/packages/herdr-pond/src/desk/ui.rs b/packages/herdr-pond/src/desk/ui.rs index d9082c92..5e8b641d 100644 --- a/packages/herdr-pond/src/desk/ui.rs +++ b/packages/herdr-pond/src/desk/ui.rs @@ -13,7 +13,7 @@ use ratatui::widgets::{ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::app::{App, Lane}; -use crate::types::{SearchSession, SessionRow, TranscriptMessage}; +use crate::types::{LISTING_WINDOW_DAYS, SearchSession, SessionRow, TranscriptMessage}; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const TAB_STOP: usize = 4; @@ -23,6 +23,7 @@ const AGE: usize = 4; const COUNT: usize = 7; /// The preview wraps on every frame, so one huge message must not reach it whole. 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 { @@ -134,7 +135,7 @@ fn window_label(app: &App) -> String { if app.all_time { "all time".to_owned() } else { - format!("last {} days", crate::types::LISTING_WINDOW_DAYS) + format!("last {LISTING_WINDOW_DAYS} days") } } @@ -212,12 +213,7 @@ fn render_rows(frame: &mut Frame, app: &mut App, area: Rect) { .highlight_spacing(HighlightSpacing::Always) .highlight_style(Style::new().reversed()) .scroll_padding(1); - let state = if app.search.is_some() { - &mut app.search_state - } else { - &mut app.listing_state - }; - frame.render_stateful_widget(list, area, state); + frame.render_stateful_widget(list, area, app.state_mut()); } /// The rows of the current view, or the sentence that stands in for them. @@ -283,7 +279,7 @@ fn listing_item(app: &App, row: &SessionRow) -> ListItem<'static> { Some(detail) => detail .title .as_deref() - .map_or_else(|| "(no user message)".dim(), |t| Span::raw(one_line(t))), + .map_or_else(|| NO_TITLE.dim(), |t| Span::raw(one_line(t))), None => "...".dim(), }; ListItem::new(Line::from(vec![ @@ -365,14 +361,16 @@ fn footer(app: &App) -> Line<'static> { }; let mut spans = Vec::new(); if app.is_loading() { - spans.push( - Span::raw(format!("{} ", SPINNER[app.spinner % SPINNER.len()])).fg(Color::Yellow), - ); + 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; @@ -406,7 +404,7 @@ fn render_pager(frame: &mut Frame, app: &App) { ); } let status = if app.lane_loading(Lane::Page) { - format!("{} loading", SPINNER[app.spinner % SPINNER.len()]) + format!("{} loading", spinner_frame(app)) } else if pager.eof { "end".to_owned() } else { diff --git a/packages/herdr-pond/src/fake_pond.rs b/packages/herdr-pond/src/fake_pond.rs index 60f375b7..c8f96c83 100644 --- a/packages/herdr-pond/src/fake_pond.rs +++ b/packages/herdr-pond/src/fake_pond.rs @@ -11,9 +11,16 @@ 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::TcpListener; +use crate::api::{SEARCH_PATH, SQL_PATH}; +use crate::config::CONFIG_FILE; +use crate::serve::{Endpoint, Origin, ServeDir}; + /// 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}"#; @@ -55,10 +62,10 @@ pub(crate) mod golden { /// One canned reply, chosen per request by [`FakePond`]'s router closure. #[derive(Debug, Clone)] pub(crate) struct Reply { - pub status: u16, - pub body: String, - pub content_type: &'static str, - pub delay: Duration, + status: u16, + body: String, + content_type: &'static str, + delay: Duration, } impl Reply { @@ -99,7 +106,7 @@ type Router = dyn Fn(&str, &str) -> Reply + Send + Sync; /// body)`. Every request is recorded, so tests can assert on the SQL sent. pub(crate) struct FakePond { pub base_url: String, - pub requests: Arc>>, + requests: Arc>>, task: tokio::task::JoinHandle<()>, } @@ -132,14 +139,14 @@ impl FakePond { /// 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 { - "/v1/x/sql" => routes + SQL_PATH => routes .iter() .find(|(needle, _)| body.contains(needle)) .map_or_else( || Reply::status(400, golden::SQL_ERROR), |(_, reply)| reply.clone(), ), - "/v1/search" => search.clone(), + SEARCH_PATH => search.clone(), _ => Reply::plain(404, ""), }) .await @@ -148,6 +155,15 @@ impl FakePond { pub(crate) fn recorded(&self) -> Vec { self.requests.lock().unwrap().clone() } + + pub(crate) fn port(&self) -> u16 { + self.base_url.rsplit(':').next().unwrap().parse().unwrap() + } + + /// The `host:port` a fake serve publishes through `--port-file`. + pub(crate) fn addr(&self) -> &str { + self.base_url.trim_start_matches("http://") + } } impl Drop for FakePond { @@ -241,7 +257,54 @@ impl Sandbox { 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.toml"), text).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 { + 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, publishes + /// `addr` through `--port-file` when given, then runs `after`. + pub(crate) fn fake_serve(&self, addr: Option<&str>, after: &str) -> PathBuf { + let publish = addr.map_or_else(String::new, |addr| { + format!( + r#"printf '%s' '{addr}' > "$port_file.tmp" && mv "$port_file.tmp" "$port_file""# + ) + }); + let pond = write_script( + &self.path("bin/pond"), + &format!( + r#"printf '%s\n' "$*" >> '{calls}' +echo $$ > '{pid}' +echo "serve stdout"; echo "serve stderr" >&2 +eval "port_file=\${{$#}}" +{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() } } @@ -251,6 +314,34 @@ impl Drop for Sandbox { } } +pub(crate) fn ts(raw: &str) -> DateTime { + raw.parse().unwrap() +} + +pub(crate) fn alive(pid: u32) -> bool { + kill(Pid::from_raw(i32::try_from(pid).unwrap()), None).is_ok() +} + +/// A port nothing listens on: bound, then released. +pub(crate) fn dead_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +pub(crate) fn dead_url() -> String { + format!("http://127.0.0.1:{}", dead_port()) +} + +pub(crate) fn endpoint(port: u16, token: &str) -> Endpoint { + Endpoint { + port, + token: token.to_owned(), + } +} + /// Writes an executable `/bin/sh` script, the stand-in for `pond` or `herdr`. pub(crate) fn write_script(path: &Path, body: &str) -> PathBuf { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); diff --git a/packages/herdr-pond/src/herdr.rs b/packages/herdr-pond/src/herdr.rs index f5f4d982..ede796cc 100644 --- a/packages/herdr-pond/src/herdr.rs +++ b/packages/herdr-pond/src/herdr.rs @@ -8,7 +8,7 @@ use std::process::{Command, Stdio}; use anyhow::{Context, bail}; use serde::Deserialize; -use crate::config::{Config, log_line, open_log}; +use crate::config::{Config, log_line, log_stdio}; use crate::types::LiveAgent; const PLUGIN_ID: &str = "pond"; @@ -17,7 +17,7 @@ const DESK_ENTRYPOINT: &str = "desk"; const DESK_LABEL: &str = "pond desk"; /// A plugin-runtime path herdr sets for every plugin process. -pub(crate) fn plugin_env(var: &str) -> anyhow::Result { +fn plugin_env(var: &str) -> anyhow::Result { std::env::var_os(var) .filter(|value| !value.is_empty()) .map(PathBuf::from) @@ -59,12 +59,8 @@ fn project_from_context(json: &str) -> Option { /// 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<()> { - let out = open_log(log).with_context(|| format!("opening {}", log.display()))?; - let err = out.try_clone()?; - command - .stdin(Stdio::null()) - .stdout(out) - .stderr(err) + log_stdio(&mut command, log) + .with_context(|| format!("opening {}", log.display()))? .spawn() .with_context(|| format!("spawning {:?}", command.get_program()))?; Ok(()) @@ -78,13 +74,12 @@ pub(crate) fn detach() { /// 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: &Config, config_dir: &Path, state_dir: &Path, log: &Path, ) -> Option { let marker = state_dir.join("pond-missing.toasted"); - match config.resolve_pond(config_dir) { + match Config::pond(config_dir, log) { Ok(pond) => { let _ = fs::remove_file(&marker); Some(pond) @@ -105,8 +100,6 @@ pub(crate) struct Pane { #[serde(default)] pub label: Option, #[serde(default)] - pub agent: Option, - #[serde(default)] pub agent_session: Option, } @@ -124,7 +117,6 @@ pub(crate) fn live_agents(panes: Vec) -> Vec { Some(LiveAgent { session: pane.agent_session?.value, pane_id: pane.pane_id, - agent: pane.agent, }) }) .collect() @@ -253,14 +245,6 @@ esac"#, Herdr::new(bin) } - fn calls(sandbox: &Sandbox) -> Vec { - fs::read_to_string(sandbox.path("calls")) - .unwrap_or_default() - .lines() - .map(str::to_owned) - .collect() - } - #[test] fn context_prefers_the_focused_pane_cwd() { let both = r#"{"workspace_cwd":"/w","focused_pane_cwd":"/p","focused_pane_id":"x"}"#; @@ -280,7 +264,6 @@ esac"#, live, vec![LiveAgent { pane_id: "wD:pS".to_owned(), - agent: Some("claude".to_owned()), session: "0a1d69bd".to_owned(), }] ); @@ -293,7 +276,7 @@ esac"#, .open_desk(Some("wD")) .unwrap(); assert_eq!( - calls(&sandbox), + sandbox.lines("calls"), ["pane list --workspace wD", "plugin pane focus wD:pU"] ); } @@ -314,7 +297,7 @@ esac"#, fake_herdr(&sandbox, panes, focus_exit) .open_desk(None) .unwrap(); - assert_eq!(calls(&sandbox), expected); + assert_eq!(sandbox.lines("calls"), expected); } } diff --git a/packages/herdr-pond/src/hook.rs b/packages/herdr-pond/src/hook.rs index 2b690248..0b9c1b8c 100644 --- a/packages/herdr-pond/src/hook.rs +++ b/packages/herdr-pond/src/hook.rs @@ -9,13 +9,13 @@ use std::fs::{self, OpenOptions}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Command; use std::time::{Duration, Instant}; use anyhow::bail; use serde::Deserialize; -use crate::config::{Config, log_line, open_log, try_lock}; +use crate::config::{Config, log_line, log_stdio, try_lock}; use crate::herdr; const SYNC_LOG: &str = "sync.log"; @@ -45,7 +45,7 @@ pub(crate) fn run(args: &[String]) -> anyhow::Result<()> { herdr::detach(); worker(adapter) } - _ => bail!("usage: herdr-pond hook [--worker ]"), + _ => bail!(crate::USAGE), } } @@ -129,8 +129,7 @@ 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 config = Config::load(&config_dir, &log); - let Some(pond) = herdr::resolve_pond_or_toast(&config, &config_dir, &state_dir, &log) else { + let Some(pond) = herdr::resolve_pond_or_toast(&config_dir, &state_dir, &log) else { return Ok(()); }; work(adapter, &state_dir, &pond, COALESCE) @@ -165,15 +164,8 @@ fn work(adapter: &str, state_dir: &Path, pond: &Path, coalesce: Duration) -> any /// instead of exiting "skipped" and silently dropping the idle event. fn sync(adapter: &str, pond: &Path, log: &Path) { let started = Instant::now(); - let status = open_log(log).and_then(|out| { - let err = out.try_clone()?; - Command::new(pond) - .args(["sync", adapter, "-q"]) - .stdin(Stdio::null()) - .stdout(out) - .stderr(err) - .status() - }); + 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()), @@ -192,6 +184,7 @@ 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; @@ -200,6 +193,13 @@ mod tests { 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!( @@ -226,14 +226,6 @@ echo "end $2" >> '{events}'"#, ) } - fn lines(path: &Path) -> Vec { - fs::read_to_string(path) - .unwrap_or_default() - .lines() - .map(str::to_owned) - .collect() - } - fn wait_for(what: &str, condition: impl Fn() -> bool) { let deadline = Instant::now() + Duration::from_secs(20); while !condition() { @@ -287,7 +279,7 @@ echo "end $2" >> '{events}'"#, } fn events(&self) -> Vec { - lines(&self.sandbox.path("events")) + self.sandbox.lines("events") } fn has_event(&self, event: &str) -> bool { @@ -391,12 +383,10 @@ echo "end $2" >> '{events}'"#, 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!( - lines(&harness.sandbox.path("calls")) - .iter() - .all(|call| call == "sync claude-code -q"), - "{:?}", - lines(&harness.sandbox.path("calls")) + calls.iter().all(|call| call == "sync claude-code -q"), + "{calls:?}" ); } @@ -419,12 +409,7 @@ echo "end $2" >> '{events}'"#, fn self_exec(role: &str, sandbox: &Sandbox) -> Command { let mut command = Command::new(std::env::current_exe().unwrap()); command - .args([ - "--exact", - "hook::tests::self_exec_role", - "--test-threads=1", - "-q", - ]) + .args(SELF_EXEC_ARGS) .env_clear() .env("PATH", std::env::var_os("PATH").unwrap_or_default()) .env(ROLE, role) @@ -454,14 +439,7 @@ echo "end $2" >> '{events}'"#, 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([ - "--exact", - "hook::tests::self_exec_role", - "--test-threads=1", - "-q", - ]) - .env(ROLE, "worker"); + command.args(SELF_EXEC_ARGS).env(ROLE, "worker"); herdr::spawn_detached(command, &log) }) .unwrap(); @@ -491,14 +469,16 @@ echo "end $2" >> '{events}'"#, let eof_after = started.elapsed(); assert!(hook.wait().unwrap().success(), "{text}{stderr_text}"); - let events = sandbox.path("events"); + let ended = || { + sandbox + .lines("events") + .contains(&"end claude-code".to_owned()) + }; assert!( - !lines(&events).contains(&"end claude-code".to_owned()), + !ended(), "pipes stayed open for the whole sync ({eof_after:?})" ); - wait_for("the detached sync to finish", || { - lines(&events).contains(&"end claude-code".to_owned()) - }); - assert_eq!(lines(&sandbox.path("calls")), ["sync claude-code -q"]); + 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 index 22957589..77a37b09 100644 --- a/packages/herdr-pond/src/main.rs +++ b/packages/herdr-pond/src/main.rs @@ -20,6 +20,8 @@ use std::sync::Arc; use crate::types::{DeskContext, DeskExit}; +const USAGE: &str = "usage: herdr-pond open|tui|hook [--worker ]|serve-daemon [--owner]"; + fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let (command, rest) = args @@ -30,9 +32,7 @@ fn main() -> ExitCode { "tui" => desk_main(), "hook" => hook::run(rest), "serve-daemon" => daemon::run(rest), - _ => Err(anyhow::anyhow!( - "usage: herdr-pond open|tui|hook [--worker ]|serve-daemon [--owner]" - )), + _ => Err(anyhow::anyhow!(USAGE)), }; match result { Ok(()) => ExitCode::SUCCESS, @@ -43,6 +43,14 @@ fn main() -> ExitCode { } } +/// 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::Builder::new_current_thread() + .enable_all() + .build() +} + /// 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. diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index b4c80b11..7284d756 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -7,17 +7,17 @@ use std::fs; use std::net::SocketAddr; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Command}; use std::time::{Duration, Instant}; use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; use serde::{Deserialize, Serialize}; -use crate::api::{SQL_PATH, post, sql_request}; -use crate::config::{Config, log_line, open_log, write_atomic}; +use crate::api::{SQL_PATH, post}; +use crate::config::{Config, log_line, log_stdio, write_atomic}; use crate::herdr; -use crate::types::{ApiError, READY_SQL, SqlResponse}; +use crate::types::{ApiError, READY_SQL, SqlRequest, SqlResponse}; /// Store open (seconds on S3) happens before `pond serve` binds. pub(crate) const PORT_DEADLINE: Duration = Duration::from_secs(180); @@ -85,9 +85,7 @@ fn sockhash(socket: &Path) -> String { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct Endpoint { pub port: u16, - pub pid: u32, pub token: String, - pub pond_version: String, } impl Endpoint { @@ -111,10 +109,16 @@ pub(crate) fn remove_endpoint_if_owned(path: &Path, token: &str) -> bool { && fs::remove_file(path).is_ok() } +/// The published endpoint's base URL, if it answers the probe. +pub(crate) async fn live_endpoint(client: &reqwest::Client, dir: &ServeDir) -> Option { + let base_url = read_endpoint(&dir.endpoint())?.base_url(); + probe(client, &base_url).await.ok().map(|()| base_url) +} + /// `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(client: &reqwest::Client, base_url: &str) -> Result<(), ApiError> { - let request = sql_request(READY_SQL.to_owned(), 1, PROBE_TIMEOUT_SECS); + let request = SqlRequest::new(READY_SQL.to_owned(), 1, PROBE_TIMEOUT_SECS); let response: SqlResponse = post(client, base_url, SQL_PATH, &request, PROBE_DEADLINE).await?; if response.rows.is_empty() { return Err(ApiError::Decode(format!( @@ -129,15 +133,13 @@ pub(crate) async fn probe(client: &reqwest::Client, base_url: &str) -> Result<() /// `log` because serve's output would corrupt the TUI or pin a herdr slot. pub(crate) fn spawn_serve(pond: &Path, port_file: &Path, log: &Path) -> std::io::Result { let _ = fs::remove_file(port_file); - let out = open_log(log)?; - let err = out.try_clone()?; - Command::new(pond) - .args(["serve", "--host", "127.0.0.1", "--port", "0", "--port-file"]) - .arg(port_file) - .stdin(Stdio::null()) - .stdout(out) - .stderr(err) - .spawn() + log_stdio( + Command::new(pond) + .args(["serve", "--host", "127.0.0.1", "--port", "0", "--port-file"]) + .arg(port_file), + log, + )? + .spawn() } /// The base URL from a `--port-file` (`host:port`, written atomically after bind). @@ -208,14 +210,11 @@ pub(crate) async fn connect( origin: &Origin, fallback: Option, ) -> Result { - if let Some(endpoint) = read_endpoint(&origin.dir.endpoint()) { - let base_url = endpoint.base_url(); - if probe(client, &base_url).await.is_ok() { - return Ok(Connection { - base_url, - fallback: None, - }); - } + if let Some(base_url) = live_endpoint(client, &origin.dir).await { + return Ok(Connection { + base_url, + fallback: None, + }); } if let Some(fallback) = fallback && probe(client, &fallback.base_url).await.is_ok() @@ -235,8 +234,7 @@ pub(crate) async fn connect( async fn spawn_fallback(origin: &Origin) -> Result { let log = origin.dir.desk_log(); - let pond = Config::load(&origin.config_dir, &log) - .resolve_pond(&origin.config_dir) + let pond = Config::pond(&origin.config_dir, &log) .map_err(|error| ApiError::Unreachable(format!("{error:#}")))?; let port_file = origin .dir @@ -279,29 +277,9 @@ mod tests { use super::*; use crate::api::client; - use crate::fake_pond::{FakePond, Reply, Sandbox, golden, write_script}; - - fn endpoint(port: u16, token: &str) -> Endpoint { - Endpoint { - port, - pid: 1, - token: token.to_owned(), - pond_version: "pond 0.20.0".to_owned(), - } - } - - fn port_of(base_url: &str) -> u16 { - base_url.rsplit(':').next().unwrap().parse().unwrap() - } - - /// A port nothing listens on: bound, then released. - fn dead_port() -> u16 { - std::net::TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port() - } + use crate::fake_pond::{ + FakePond, Reply, Sandbox, alive, dead_port, dead_url, endpoint, golden, write_script, + }; async fn ready_pond() -> FakePond { FakePond::with_sql( @@ -311,29 +289,10 @@ mod tests { .await } - /// A fake `pond serve` that records its argv, prints to both streams, - /// publishes `addr` through `--port-file` and stays up. + /// A fake serve publishing `addr` that stays up. fn fake_serve(sandbox: &Sandbox, addr: &str) -> Origin { - let pond = write_script( - &sandbox.path("bin/pond"), - &format!( - r#"printf '%s\n' "$*" >> '{calls}' -echo "serve stdout"; echo "serve stderr" >&2 -eval "port_file=\${{$#}}" -printf '%s' '{addr}' > "$port_file.tmp" && mv "$port_file.tmp" "$port_file" -exec sleep 30"#, - calls = sandbox.path("calls").display(), - ), - ); - sandbox.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); - Origin { - dir: ServeDir::new(&sandbox.state_dir(), &sandbox.path("herdr.sock")), - config_dir: sandbox.config_dir(), - } - } - - fn alive(pid: u32) -> bool { - kill(Pid::from_raw(i32::try_from(pid).unwrap()), None).is_ok() + sandbox.fake_serve(Some(addr), "exec sleep 30"); + sandbox.origin() } #[test] @@ -393,9 +352,8 @@ exec sleep 30"#, Err(ApiError::PondTooOld) ); - let dead = format!("http://127.0.0.1:{}", dead_port()); assert!(matches!( - probe(&client, &dead).await, + probe(&client, &dead_url()).await, Err(ApiError::Unreachable(_)) )); } @@ -405,11 +363,7 @@ exec sleep 30"#, let sandbox = Sandbox::new(); let pond = ready_pond().await; let origin = fake_serve(&sandbox, "127.0.0.1:1"); - write_endpoint( - &origin.dir.endpoint(), - &endpoint(port_of(&pond.base_url), "t"), - ) - .unwrap(); + write_endpoint(&origin.dir.endpoint(), &endpoint(pond.port(), "t")).unwrap(); let connection = connect(&client().unwrap(), &origin, None).await.unwrap(); assert_eq!(connection.base_url, pond.base_url); assert!(connection.fallback.is_none()); @@ -420,16 +374,16 @@ exec sleep 30"#, 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.base_url.trim_start_matches("http://")); + let origin = fake_serve(&sandbox, pond.addr()); write_endpoint(&origin.dir.endpoint(), &endpoint(dead_port(), "t")).unwrap(); let connection = connect(&client().unwrap(), &origin, None).await.unwrap(); assert_eq!(connection.base_url, pond.base_url); let fallback = connection.fallback.expect("fallback child"); - let calls = fs::read_to_string(sandbox.path("calls")).unwrap(); + let calls = sandbox.lines("calls"); assert!( - calls.starts_with("serve --host 127.0.0.1 --port 0 --port-file "), - "{calls}" + calls[0].starts_with("serve --host 127.0.0.1 --port 0 --port-file "), + "{calls:?}" ); let log = fs::read_to_string(origin.dir.desk_log()).unwrap(); assert!(log.contains("serve stdout") && log.contains("serve stderr")); @@ -446,19 +400,13 @@ exec sleep 30"#, async fn a_live_fallback_is_kept_on_reconnect() { let sandbox = Sandbox::new(); let pond = ready_pond().await; - let origin = fake_serve(&sandbox, pond.base_url.trim_start_matches("http://")); + let origin = fake_serve(&sandbox, pond.addr()); let client = client().unwrap(); let first = connect(&client, &origin, None).await.unwrap(); let pid = first.fallback.as_ref().unwrap().child.id(); let second = connect(&client, &origin, first.fallback).await.unwrap(); assert_eq!(second.fallback.as_ref().unwrap().child.id(), pid); - assert_eq!( - fs::read_to_string(sandbox.path("calls")) - .unwrap() - .lines() - .count(), - 1 - ); + assert_eq!(sandbox.lines("calls").len(), 1); } #[tokio::test] @@ -466,10 +414,7 @@ exec sleep 30"#, 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 = Origin { - dir: ServeDir::new(&sandbox.state_dir(), &sandbox.path("herdr.sock")), - config_dir: sandbox.config_dir(), - }; + let origin = sandbox.origin(); let Err(ApiError::Unreachable(reason)) = connect(&client().unwrap(), &origin, None).await else { panic!("expected Unreachable"); @@ -486,10 +431,7 @@ exec sleep 30"#, async fn missing_pond_names_the_config_key() { let sandbox = Sandbox::new(); sandbox.write_config("pond_bin = \"/nonexistent/pond\"\n"); - let origin = Origin { - dir: ServeDir::new(&sandbox.state_dir(), &sandbox.path("herdr.sock")), - config_dir: sandbox.config_dir(), - }; + let origin = sandbox.origin(); let Err(ApiError::Unreachable(reason)) = connect(&client().unwrap(), &origin, None).await else { panic!("expected Unreachable"); diff --git a/packages/herdr-pond/src/types.rs b/packages/herdr-pond/src/types.rs index 2287d655..25793bcd 100644 --- a/packages/herdr-pond/src/types.rs +++ b/packages/herdr-pond/src/types.rs @@ -6,7 +6,8 @@ use std::fmt; use std::future::Future; use std::pin::Pin; -use chrono::{DateTime, SecondsFormat, Utc}; +use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; pub(crate) const PROTOCOL_VERSION: u16 = 1; @@ -37,11 +38,22 @@ pub(crate) trait Api: Send + Sync { pub(crate) struct ListingScope { /// Exact project path; sessions in its subdirectories match too. pub project: Option, - /// `None` is the all-time listing - the slow query family. + /// `None` is the all-time listing, an unbounded scan of every message. pub since: Option>, pub limit: usize, } +impl ListingScope { + /// The opening view: the last [`LISTING_WINDOW_DAYS`], at most [`LISTING_ROWS`]. + pub(crate) fn recent(project: Option, now: DateTime) -> Self { + Self { + project, + since: Some(now - TimeDelta::days(LISTING_WINDOW_DAYS)), + limit: LISTING_ROWS, + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize)] pub(crate) struct SessionRow { pub session_id: String, @@ -100,7 +112,6 @@ impl Cursor { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LiveAgent { pub pane_id: String, - pub agent: Option, /// herdr's `agent_session` value: a session id, or a path whose file name /// contains one. pub session: String, @@ -169,23 +180,42 @@ impl std::error::Error for ApiError {} #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct SqlRequest { - pub protocol_version: u16, + protocol_version: u16, pub query: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout_seconds: Option, + /// 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 columns: Vec, pub rows: Vec, - pub row_count: usize, pub truncated: bool, - pub elapsed_ms: u64, +} + +impl SqlResponse { + pub(crate) fn into_rows(self) -> Result, 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)] @@ -201,12 +231,34 @@ pub(crate) struct ErrorBody { #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct SearchRequest { - pub protocol_version: u16, + 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, + } + } + + /// The listing's 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")] @@ -224,18 +276,15 @@ pub(crate) enum ProjectFilter { #[derive(Debug, Clone, PartialEq, Deserialize)] pub(crate) struct SearchResponse { pub sessions: Vec, - pub matched_total: usize, /// 0 means the filters excluded everything before retrieval - distinct /// from "nothing matched". #[serde(default)] pub searchable_in_scope: usize, - pub has_more: bool, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub(crate) struct SearchSession { pub session_id: String, - pub project: String, pub source_agent: String, pub session_messages_count: usize, pub matched_message_count: usize, @@ -244,17 +293,14 @@ pub(crate) struct SearchSession { #[derive(Debug, Clone, PartialEq, Deserialize)] pub(crate) struct SearchMatch { - pub message_id: String, - pub role: String, pub timestamp: DateTime, pub text: String, - pub score: f64, } // ---- SQL ---- // -// Discipline (read-latency campaign): every query carries an explicit LIMIT, -// since the server's row caps apply only after full collection; the listing +// 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. @@ -350,11 +396,7 @@ mod tests { #![allow(clippy::expect_used, clippy::unwrap_used)] use super::*; - use crate::fake_pond::golden; - - fn ts(raw: &str) -> DateTime { - raw.parse().unwrap() - } + use crate::fake_pond::{golden, ts}; #[test] fn quote_escapes_single_quotes() { @@ -419,40 +461,23 @@ mod tests { #[test] fn golden_sql_response_decodes() { - let response: SqlResponse = serde_json::from_str(golden::SQL_LISTING).unwrap(); - let rows: Vec = response - .rows - .into_iter() - .map(serde_json::from_value) - .collect::>() - .unwrap(); + let decode = |body| serde_json::from_str::(body).unwrap(); + let rows: Vec = 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")); - let details: SqlResponse = serde_json::from_str(golden::SQL_HYDRATE).unwrap(); - let details: Vec = details - .rows - .into_iter() - .map(serde_json::from_value) - .collect::>() - .unwrap(); + let details: Vec = decode(golden::SQL_HYDRATE).into_rows().unwrap(); assert_eq!(details[1].host, None); assert_eq!(details[1].title, None); - let page: SqlResponse = serde_json::from_str(golden::SQL_PAGE).unwrap(); - let messages: Vec = page - .rows - .into_iter() - .map(serde_json::from_value) - .collect::>() - .unwrap(); + let messages: Vec = 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[0].role, "user"); + 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(); @@ -463,7 +488,6 @@ mod tests { fn live_agent_matches_id_or_path() { let by_id = LiveAgent { pane_id: "p1".to_owned(), - agent: Some("claude".to_owned()), session: "abc".to_owned(), }; let by_path = LiveAgent { From 6b9c60fcbe5c77246eb821020379c926b89fc53c Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 06:16:44 +0000 Subject: [PATCH 08/41] fix(herdr-pond): harden the serve lifecycle and the desk's failover Owner daemon: SIGTERM/SIGINT/SIGHUP run the same teardown as herdr leaving; a live endpoint with a free owner lock is an unsupervised serve, so a fresh owner replaces its record and never signals it; herdr counts as gone only after misses spanning a live handoff; the capability probe is retried while a fresh serve settles and waits past its server budget; the warm-up search gets the rest of the warm-up budget instead of the SQL deadline; daemon.log is capped on every liveness check, not only on open. Desk: serve resolution runs in a task the api owns, so a lane abort no longer kills a half-open fallback serve; only a refused connection fails over, a timeout is reported as an error; a success re-arms failover; serve teardown runs off the async thread, and the api is dropped after the terminal is restored. A pond that rejects --port-file (clap exit 2) is reported as too old in the desk and in daemon.log. --- packages/herdr-pond/src/api.rs | 304 +++++++++++------ packages/herdr-pond/src/config.rs | 9 +- packages/herdr-pond/src/daemon.rs | 495 ++++++++++++++++++---------- packages/herdr-pond/src/desk/mod.rs | 27 +- packages/herdr-pond/src/main.rs | 19 ++ packages/herdr-pond/src/serve.rs | 214 ++++++++---- packages/herdr-pond/src/types.rs | 9 +- 7 files changed, 730 insertions(+), 347 deletions(-) diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs index 1576262c..a1fd583b 100644 --- a/packages/herdr-pond/src/api.rs +++ b/packages/herdr-pond/src/api.rs @@ -2,6 +2,8 @@ //! (`/v1/x/sql`, `/v1/search`), plus herdr's pane list for live agents. //! Tested against [`crate::fake_pond`]. +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use serde::Serialize; @@ -9,7 +11,7 @@ use serde::de::DeserializeOwned; use tokio::sync::Mutex; use crate::herdr::{self, Herdr}; -use crate::serve::{self, Origin, ServeChild}; +use crate::serve::{self, Fallback, Origin}; use crate::types::{ Api, ApiError, ApiFuture, Cursor, ErrorEnvelope, ListingScope, LiveAgent, PAGE_ROWS, PREVIEW_ROWS, SearchRequest, SearchResponse, SessionDetail, SessionRow, SqlRequest, @@ -65,8 +67,9 @@ where decode(path, status, &body) } -/// reqwest's own message is only "error sending request"; the cause chain -/// says refused vs timed out. +/// Only a failed connect 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", so the cause chain is appended. fn transport(error: reqwest::Error) -> ApiError { let mut message = error.to_string(); let mut source = std::error::Error::source(&error); @@ -75,7 +78,11 @@ fn transport(error: reqwest::Error) -> ApiError { message.push_str(&cause.to_string()); source = cause.source(); } - ApiError::Unreachable(message) + if error.is_connect() { + ApiError::Unreachable(message) + } else { + ApiError::Request(message) + } } fn decode(path: &str, status: u16, body: &str) -> Result { @@ -106,30 +113,44 @@ fn decode(path: &str, status: u16, body: &str) -> Result, - fallback: Option, - failed_over: bool, + fallback: Option, } -pub(crate) struct HttpApi { +/// A URL that just refused a connection, and why. +struct Stale { + url: String, + reason: String, +} + +/// 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 { client: reqwest::Client, /// 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, - herdr: Herdr, link: Mutex, + /// Set by a failover, cleared by the next successful request: while set, + /// a refused connection is an error instead of another failover. + failed_over: AtomicBool, } -impl HttpApi { - pub(crate) fn from_env() -> anyhow::Result { - Ok(Self { - client: client()?, - origin: Origin::from_env().map_err(|error| format!("{error:#}")), - herdr: Herdr::from_env(), - link: Mutex::default(), - }) - } - - async fn resolve(&self, link: &mut Link) -> Result { +impl Resolver { + /// Runs with `link` locked, so concurrent callers queue behind one + /// resolution and then reuse its result. + async fn resolve(&self, stale: Option) -> Result { + let mut link = self.link.lock().await; + if let Some(current) = &link.base_url { + match &stale { + None => return Ok(current.clone()), + Some(stale) if *current != stale.url => return Ok(current.clone()), + Some(stale) if self.failed_over.load(Ordering::Relaxed) => { + return Err(ApiError::Unreachable(stale.reason.clone())); + } + Some(_) => {} + } + } let origin = self .origin .as_ref() @@ -137,44 +158,70 @@ impl HttpApi { let connection = serve::connect(&self.client, origin, link.fallback.take()).await?; link.fallback = connection.fallback; link.base_url = Some(connection.base_url.clone()); + if let Some(stale) = stale { + if connection.base_url == stale.url { + return Err(ApiError::Unreachable(stale.reason)); + } + self.failed_over.store(true, Ordering::Relaxed); + } Ok(connection.base_url) } +} + +pub(crate) struct HttpApi { + resolver: Arc, + herdr: Herdr, +} + +impl HttpApi { + pub(crate) fn from_env() -> anyhow::Result { + Ok(Self { + resolver: Arc::new(Resolver { + client: client()?, + origin: Origin::from_env().map_err(|error| format!("{error:#}")), + link: Mutex::default(), + failed_over: AtomicBool::new(false), + }), + 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.base_url.clone(); + if let Some(url) = current { + return Ok(url); + } + } + 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. The first time a serve becomes unreachable + /// 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; after that, errors stand. + /// and the request retried there once; a second refusal in a row, with + /// no success in between, stands as an error. async fn post(&self, path: &str, body: &B, deadline: Duration) -> Result where B: Serialize + ?Sized + Sync, T: DeserializeOwned, { - let url = { - let mut link = self.link.lock().await; - match link.base_url.clone() { - Some(url) => url, - None => self.resolve(&mut link).await?, + let client = &self.resolver.client; + let url = self.resolve(None).await?; + let result = match post(client, &url, path, body, deadline).await { + Err(ApiError::Unreachable(reason)) => { + let retry = self.resolve(Some(Stale { url, reason })).await?; + post(client, &retry, path, body, deadline).await } + other => other, }; - let reason = match post(&self.client, &url, path, body, deadline).await { - Err(ApiError::Unreachable(reason)) => reason, - other => return other, - }; - let retry = { - let mut link = self.link.lock().await; - match link.base_url.clone() { - Some(current) if current != url => current, - _ if link.failed_over => return Err(ApiError::Unreachable(reason)), - _ => { - let fresh = self.resolve(&mut link).await?; - if fresh == url { - return Err(ApiError::Unreachable(reason)); - } - link.failed_over = true; - fresh - } - } - }; - post(&self.client, &retry, path, body, deadline).await + if result.is_ok() { + self.resolver.failed_over.store(false, Ordering::Relaxed); + } + result } async fn sql( @@ -261,22 +308,42 @@ mod tests { }; use crate::serve::write_endpoint; - /// An api pinned to `base_url`, re-resolving through `sandbox`'s state. - /// `pond_bin` points nowhere, so no re-resolution can reach a real pond. + /// An api resolving through `sandbox`'s state, pinned to `base_url` if given. + fn api(sandbox: &Sandbox, base_url: Option<&str>) -> HttpApi { + HttpApi { + resolver: Arc::new(Resolver { + client: client().unwrap(), + origin: Ok(sandbox.origin()), + link: Mutex::new(Link { + base_url: base_url.map(str::to_owned), + fallback: None, + }), + failed_over: AtomicBool::new(false), + }), + herdr: Herdr::new(sandbox.path("bin/herdr")), + } + } + + /// Pinned to `base_url`, with a `pond_bin` that points nowhere, so no + /// re-resolution can reach a real pond. fn api_at(base_url: &str, sandbox: &Sandbox) -> HttpApi { sandbox.write_config(&format!( "pond_bin = \"{}\"\n", sandbox.path("bin/no-pond").display() )); - HttpApi { - client: client().unwrap(), - origin: Ok(sandbox.origin()), - herdr: Herdr::new(sandbox.path("bin/herdr")), - link: Mutex::new(Link { - base_url: Some(base_url.to_owned()), - ..Link::default() - }), - } + api(sandbox, Some(base_url)) + } + + /// 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 { @@ -450,60 +517,107 @@ mod tests { } #[tokio::test] - async fn a_stalled_server_is_unreachable() { - let pond = + 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 request = SqlRequest::new(preview_sql("s"), PREVIEW_ROWS, 1); - let result: Result = post( - &client().unwrap(), - &pond.base_url, - SQL_PATH, - &request, - Duration::from_millis(200), + let ready = preview_pond().await; + write_endpoint( + &sandbox.origin().dir.endpoint(), + &endpoint(ready.port(), "t"), ) - .await; - let Err(ApiError::Unreachable(reason)) = result else { - panic!("expected Unreachable, got {result:?}"); + .unwrap(); + let api = api_at(&stalled.base_url, &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_dead_serve_fails_over_once() { + async fn a_success_rearms_failover() { let sandbox = Sandbox::new(); - let replacement = FakePond::with_sql( - vec![ - ("SELECT 1", Reply::json(golden::SQL_READY)), - ("DESC LIMIT", Reply::json(golden::SQL_PAGE)), - ], - Reply::json(golden::SEARCH), - ) - .await; + let endpoint_path = sandbox.origin().dir.endpoint(); + let first = preview_pond().await; + write_endpoint(&endpoint_path, &endpoint(first.port(), "t")).unwrap(); let api = api_at(&dead_url(), &sandbox); - let dir = sandbox.origin().dir; - write_endpoint(&dir.endpoint(), &endpoint(replacement.port(), "t")).unwrap(); + assert_eq!(api.preview("s1".to_owned()).await.unwrap().len(), 2); - let messages = api.preview("s1".to_owned()).await.unwrap(); - assert_eq!(messages.len(), 2); - - drop(replacement); + drop(first); tokio::time::sleep(Duration::from_millis(50)).await; - std::fs::remove_file(dir.endpoint()).unwrap(); - let pond = write_script(&sandbox.path("bin/pond"), "exit 9"); - sandbox.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); + let second = preview_pond().await; + write_endpoint(&endpoint_path, &endpoint(second.port(), "t")).unwrap(); + assert_eq!(api.preview("s1".to_owned()).await.unwrap().len(), 2); + assert_eq!( + second.recorded().len(), + 2, + "probe, then the retried preview" + ); + } + + #[tokio::test] + async fn a_refusal_right_after_a_failover_stands() { + let sandbox = Sandbox::new(); + let ready = preview_pond().await; + write_endpoint( + &sandbox.origin().dir.endpoint(), + &endpoint(ready.port(), "t"), + ) + .unwrap(); + let api = api_at(&dead_url(), &sandbox); + api.resolver.failed_over.store(true, Ordering::Relaxed); assert!(matches!( api.preview("s1".to_owned()).await, Err(ApiError::Unreachable(_)) )); - assert!( - !sandbox - .state_dir() - .join("serve") - .read_dir() - .unwrap() - .any(|entry| { entry.unwrap().path().join("desk-serve.log").exists() }), - "a second failover spawned a fallback" + assert!(ready.recorded().is_empty(), "failed over twice in a row"); + } + + #[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 "port_file=\${{$#}}" +printf '%s' '{addr}' > "$port_file.tmp" && mv "$port_file.tmp" "$port_file" +exec sleep 30"#, + calls = sandbox.path("calls").display(), + addr = pond.addr(), + ), + ); + 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" ); } diff --git a/packages/herdr-pond/src/config.rs b/packages/herdr-pond/src/config.rs index ecf576ea..3b96fcdf 100644 --- a/packages/herdr-pond/src/config.rs +++ b/packages/herdr-pond/src/config.rs @@ -111,10 +111,17 @@ fn is_executable(path: &Path) -> bool { /// 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)?; } - OpenOptions::new().create(true).append(true).open(path) + Ok(()) } /// Points every stdio end of `command` at /dev/null or `log`: an inherited diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index ffd37d15..0f90e736 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -1,35 +1,46 @@ //! The per-herdr-server `pond serve` owner: startup hook and detached -//! watchdog (plan 5.6). +//! 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 serve exists per herdr server, and it never outlives that server. +//! 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::{Child, Command}; +use std::process::Command; use std::time::{Duration, Instant}; use anyhow::bail; use chrono::Utc; use crate::api::{QUERY_TIMEOUT_SECS, SEARCH_PATH, SQL_PATH, client, post, sql_deadline}; -use crate::config::{log_line, try_lock}; +use crate::config::{cap_log, log_line, try_lock}; use crate::serve::{ - Endpoint, PORT_DEADLINE, ServeDir, live_endpoint, probe, read_port_file, - remove_endpoint_if_owned, spawn_serve, terminate, write_endpoint, + Endpoint, PORT_DEADLINE, ServeChild, ServeDir, live_endpoint, probe, remove_endpoint_if_owned, + retire, write_endpoint, }; use crate::types::{ - LISTING_ROWS, ListingScope, SearchRequest, SearchResponse, SqlRequest, SqlResponse, listing_sql, + ApiError, LISTING_ROWS, ListingScope, SearchRequest, SearchResponse, SqlRequest, SqlResponse, + listing_sql, }; -use crate::{herdr, runtime}; +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, port_deadline: Duration, + /// A serve that has just bound may still be settling, so a failed + /// capability probe is retried for this long. + probe_window: Duration, + probe_retry: Duration, /// The historical 47-300s cold FTS load is paid here, not by the desk. warmup_deadline: Duration, grace: Duration, @@ -38,7 +49,10 @@ struct Timing { const TIMING: Timing = Timing { tick: Duration::from_millis(500), liveness_every: Duration::from_secs(20), + handoff_window: Duration::from_secs(10), port_deadline: PORT_DEADLINE, + probe_window: Duration::from_secs(30), + probe_retry: Duration::from_secs(5), warmup_deadline: Duration::from_secs(300), grace: Duration::from_secs(10), }; @@ -65,35 +79,22 @@ fn startup() { return; }; let log = dir.daemon_log(); - let result = runtime().map_err(anyhow::Error::from).and_then(|runtime| { - runtime.block_on(start(&dir, || { - let mut command = Command::new(std::env::current_exe()?); - command.args(["serve-daemon", "--owner"]); - herdr::spawn_detached(command, &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 one is alive (lock held) or the published -/// endpoint still answers the probe (adopted). -async fn start( - dir: &ServeDir, - spawn_owner: impl FnOnce() -> anyhow::Result<()>, -) -> anyhow::Result<()> { - let Some(lock) = try_lock(&dir.lock())? else { - return Ok(()); - }; - if let Some(base_url) = live_endpoint(&client()?, dir).await { - log_line( - &dir.daemon_log(), - &format!("adopted live endpoint {base_url}"), - ); +/// 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(()); } - drop(lock); spawn_owner() } @@ -103,47 +104,61 @@ fn owner() -> anyhow::Result<()> { let state_dir = herdr::state_dir()?; let config_dir = herdr::config_dir()?; let log = dir.daemon_log(); - runtime()?.block_on(own(&dir, &socket, &TIMING, || { - herdr::resolve_pond_or_toast(&config_dir, &state_dir, &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(()); }; let client = client()?; - if live_endpoint(&client, dir).await.is_some() { - return Ok(()); + if let Some(base_url) = live_endpoint(&client, dir).await { + log_line( + &log, + &format!( + "owner: {base_url} answers but no owner supervises it (a dead owner's orphan, \ + or another process on that port) - starting a fresh serve" + ), + ); } let Some(pond) = resolve_pond() else { return Ok(()); }; - let port_file = dir.port_file("owner"); - let mut child = spawn_serve(&pond, &port_file, &log)?; + let mut serve = ServeChild::spawn(&pond, dir.port_file("owner"), log.clone(), timing.grace)?; log_line( &log, - &format!("owner: started {} (pid {})", pond.display(), child.id()), + &format!("owner: started {} (pid {})", pond.display(), serve.id()), ); - let serve = Serve { - client, - child: &mut child, - port_file: &port_file, - socket, - log: &log, + let mut token = None; + let reason = tokio::select! { + reason = supervise(&mut serve, &client, dir, timing, &mut token) => reason, + reason = herdr_gone(socket, &log, timing) => reason, + signal = shutdown => format!("received {signal}"), }; - let token = serve.supervise(dir, timing).await; - terminate(&mut child, timing.grace); + log_line(&log, &format!("owner: stopping - {reason}")); + let _ = retire(serve).await; if let Some(token) = token { remove_endpoint_if_owned(&dir.endpoint(), &token); } - let _ = std::fs::remove_file(&port_file); log_line(&log, "owner: stopped"); Ok(()) } @@ -157,85 +172,102 @@ fn random_token() -> String { ) } -struct Serve<'a> { - client: reqwest::Client, - child: &'a mut Child, - port_file: &'a Path, - socket: &'a Path, - log: &'a Path, +/// Publishes the endpoint once serve listens and passes the probe, warms it +/// up, and returns why the owner must stop. `token` is set on publish. +async fn supervise( + serve: &mut ServeChild, + client: &reqwest::Client, + dir: &ServeDir, + timing: &Timing, + token: &mut Option, +) -> String { + let addr = match serve.listening(timing.port_deadline).await { + Ok(addr) => addr, + Err(error) => return error.to_string(), + }; + let base_url = format!("http://{addr}"); + if let Err(reason) = probe_until_ready(serve, client, &base_url, timing).await { + return reason; + } + let endpoint = Endpoint { + port: addr.port(), + token: random_token(), + }; + if let Err(error) = write_endpoint(&dir.endpoint(), &endpoint) { + return format!("cannot publish the endpoint: {error}"); + } + let log = dir.daemon_log(); + log_line(&log, &format!("owner: published {base_url}")); + *token = Some(endpoint.token); + let ((), reason) = tokio::join!( + warm_up(client, &base_url, &log, timing.warmup_deadline), + exited(serve, timing.tick) + ); + reason } -impl Serve<'_> { - /// Watches the child, herdr and the port deadline until one ends the - /// owner; publishes the endpoint once serve listens and passes the probe. - /// Returns the published token, if any. - async fn supervise(self, dir: &ServeDir, timing: &Timing) -> Option { - let started = Instant::now(); - let mut next_liveness = started + timing.liveness_every; - let mut herdr_missed = false; - let mut token = None; - let mut warmup = None; - let reason = loop { - match self.child.try_wait() { - Ok(Some(status)) => break format!("pond serve exited unexpectedly ({status})"), - Err(error) => break format!("cannot watch pond serve: {error}"), - Ok(None) => {} - } - // Two misses a tick apart, so a live handoff's socket swap is not a death. - if Instant::now() >= next_liveness { - match UnixStream::connect(self.socket) { - Err(error) if herdr_missed => break format!("herdr server is gone ({error})"), - Err(_) => { - herdr_missed = true; - next_liveness = Instant::now() + timing.tick; - } - Ok(_) => { - herdr_missed = false; - next_liveness = Instant::now() + timing.liveness_every; - } - } +/// Retries within `probe_window`, except for a pond too old for the desk, +/// which no retry fixes. +async fn probe_until_ready( + serve: &mut ServeChild, + client: &reqwest::Client, + base_url: &str, + timing: &Timing, +) -> Result<(), String> { + let started = Instant::now(); + loop { + match probe(client, base_url).await { + Ok(()) => return Ok(()), + Err(error @ ApiError::PondTooOld) => return Err(error.to_string()), + Err(error) if started.elapsed() >= timing.probe_window => { + return Err(format!("capability probe failed: {error}")); } - if token.is_none() { - if let Some(addr) = read_port_file(self.port_file) { - let base_url = format!("http://{addr}"); - if let Err(error) = probe(&self.client, &base_url).await { - break format!("capability probe failed: {error}"); - } - let endpoint = Endpoint { - port: addr.port(), - token: random_token(), - }; - if let Err(error) = write_endpoint(&dir.endpoint(), &endpoint) { - break format!("cannot publish the endpoint: {error}"); - } - log_line(self.log, &format!("owner: published {base_url}")); - warmup = Some(tokio::spawn(warm_up( - self.client.clone(), - base_url, - self.log.to_path_buf(), - timing.warmup_deadline, - ))); - token = Some(endpoint.token); - } else if started.elapsed() > timing.port_deadline { - break format!( - "pond serve did not listen within {}s", - timing.port_deadline.as_secs() - ); + Err(_) => {} + } + if let Some(reason) = serve.exited() { + return Err(reason); + } + tokio::time::sleep(timing.probe_retry).await; + } +} + +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})"); } } - tokio::time::sleep(timing.tick).await; - }; - log_line(self.log, &format!("owner: stopping - {reason}")); - if let Some(warmup) = warmup { - warmup.abort(); } - token + 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. Failure is not fatal. -async fn warm_up(client: reqwest::Client, base_url: String, log: PathBuf, deadline: Duration) { +/// cost lands here instead of on the first desk open. The search gets what +/// is left of `budget`. Failure is not fatal. +async fn warm_up(client: &reqwest::Client, base_url: &str, log: &Path, budget: Duration) { let started = Instant::now(); let listing = SqlRequest::new( listing_sql(&ListingScope::recent(None, Utc::now())), @@ -243,19 +275,19 @@ async fn warm_up(client: reqwest::Client, base_url: String, log: PathBuf, deadli QUERY_TIMEOUT_SECS, ); let search = SearchRequest::new(WARMUP_QUERY.to_owned(), 1); - let result = tokio::time::timeout(deadline, async { - let deadline = sql_deadline(QUERY_TIMEOUT_SECS); - post::<_, SqlResponse>(&client, &base_url, SQL_PATH, &listing, deadline).await?; - post::<_, SearchResponse>(&client, &base_url, SEARCH_PATH, &search, deadline).await - }) + let listing_deadline = sql_deadline(QUERY_TIMEOUT_SECS); + let result = async { + post::<_, SqlResponse>(client, base_url, SQL_PATH, &listing, listing_deadline).await?; + let search_deadline = budget.saturating_sub(started.elapsed()); + post::<_, SearchResponse>(client, base_url, SEARCH_PATH, &search, search_deadline).await + } .await; let outcome = match result { - Ok(Ok(_)) => "done".to_owned(), - Ok(Err(error)) => format!("failed: {error}"), - Err(_) => format!("gave up after {}s", deadline.as_secs()), + Ok(_) => "done".to_owned(), + Err(error) => format!("failed: {error}"), }; log_line( - &log, + log, &format!( "owner: warm-up {outcome} ({:.1}s)", started.elapsed().as_secs_f64() @@ -269,6 +301,8 @@ mod tests { 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}; @@ -277,7 +311,10 @@ mod tests { const FAST: Timing = Timing { tick: Duration::from_millis(20), liveness_every: Duration::from_millis(100), + handoff_window: Duration::from_millis(300), port_deadline: Duration::from_millis(500), + probe_window: Duration::from_millis(300), + probe_retry: Duration::from_millis(20), warmup_deadline: Duration::from_secs(5), grace: Duration::from_secs(2), }; @@ -291,15 +328,20 @@ mod tests { impl Setup { async fn new() -> Self { - let sandbox = Sandbox::new(); - let pond = FakePond::with_sql( - vec![ - ("SELECT 1", Reply::json(golden::SQL_READY)), - ("GROUP BY session_id", Reply::json(golden::SQL_LISTING)), - ], - Reply::json(golden::SEARCH), + 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, ) - .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 { @@ -318,8 +360,16 @@ mod tests { } 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)).await + own(&self.dir, &self.socket, &FAST, move || Some(pond), shutdown).await } fn serve_calls(&self) -> usize { @@ -333,6 +383,14 @@ mod tests { fn log(&self) -> String { fs::read_to_string(self.dir.daemon_log()).unwrap_or_default() } + + fn endpoint(&self) -> Option { + read_endpoint(&self.dir.endpoint()) + } + + async fn published(&self) { + wait_until("the endpoint", || self.endpoint().is_some()).await; + } } async fn wait_until(what: &str, condition: impl Fn() -> bool) { @@ -350,7 +408,7 @@ mod tests { let listener = UnixListener::bind(&setup.socket).unwrap(); let herdr_stops = async { wait_until("the endpoint and warm-up", || { - read_endpoint(&setup.dir.endpoint()).is_some() + setup.endpoint().is_some() && setup.pond.recorded().iter().any(|r| r.path == SEARCH_PATH) }) .await; @@ -365,10 +423,7 @@ mod tests { second.unwrap(); assert_eq!(setup.serve_calls(), 1, "{}", setup.log()); - assert!( - !setup.dir.endpoint().exists(), - "endpoint outlived its serve" - ); + assert!(setup.endpoint().is_none(), "endpoint outlived its serve"); assert!( !alive(setup.sandbox.serve_pid()), "pond serve outlived herdr" @@ -383,41 +438,144 @@ mod tests { ); } + #[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 { - wait_until("the endpoint", || { - read_endpoint(&setup.dir.endpoint()).is_some() - }) - .await; - let mut endpoint = read_endpoint(&setup.dir.endpoint()).unwrap(); + 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!( - read_endpoint(&setup.dir.endpoint()).unwrap().token, - "successor" - ); + assert_eq!(setup.endpoint().unwrap().token, "successor"); } #[tokio::test] - async fn a_dying_serve_ends_the_owner_without_restart() { + async fn an_unsupervised_live_endpoint_is_replaced() { let setup = Setup::new().await; - let pond = setup.fake_pond(true, "sleep 0.3; exit 1"); + let orphan = FakePond::with_sql( + vec![("SELECT 1", Reply::json(golden::SQL_READY))], + Reply::json(golden::SEARCH), + ) + .await; + write_endpoint(&setup.dir.endpoint(), &endpoint(orphan.port(), "orphan")).unwrap(); + let pond = setup.fake_pond(true, "exec sleep 30"); + let listener = UnixListener::bind(&setup.socket).unwrap(); + let herdr_stops = async { + wait_until("the fresh endpoint", || { + setup.endpoint().is_some_and(|e| e.token != "orphan") + }) + .await; + assert_eq!(setup.endpoint().unwrap().port, setup.pond.port()); + drop(listener); + }; + let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); + owner.unwrap(); + assert_eq!(setup.serve_calls(), 1); + assert!( + setup.log().contains("no owner supervises"), + "{}", + setup.log() + ); + } + + #[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("probe failed"), "{}", setup.log()); + } + + #[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(); assert!( - setup.log().contains("exited unexpectedly"), + setup.log().contains("capability probe failed"), "{}", setup.log() ); - assert!(!setup.dir.endpoint().exists()); + 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); } @@ -429,34 +587,35 @@ mod tests { setup.own(&pond).await.unwrap(); assert!(setup.log().contains("did not listen"), "{}", setup.log()); assert!(!alive(setup.sandbox.serve_pid())); - assert!(!setup.dir.endpoint().exists()); + assert!(setup.endpoint().is_none()); } #[tokio::test] - async fn start_spawns_an_owner_only_when_needed() { + async fn a_pond_without_port_file_is_named_too_old() { let setup = Setup::new().await; + let pond = setup.fake_pond(false, "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()); + } + + #[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); - start(&setup.dir, spawn).await.unwrap(); - assert_eq!(spawned.get(), 1, "no endpoint"); - - fs::write(setup.dir.endpoint(), "{not json").unwrap(); - start(&setup.dir, spawn).await.unwrap(); - assert_eq!(spawned.get(), 2, "malformed endpoint"); - - let held = try_lock(&setup.dir.lock()).unwrap().unwrap(); - start(&setup.dir, spawn).await.unwrap(); - assert_eq!(spawned.get(), 2, "an owner holds the lock"); + let held = try_lock(&dir.lock()).unwrap().unwrap(); + start(&dir, spawn).unwrap(); + assert_eq!(spawned.get(), 1, "an owner holds the lock"); drop(held); - - write_endpoint(&setup.dir.endpoint(), &endpoint(setup.pond.port(), "t")).unwrap(); - start(&setup.dir, spawn).await.unwrap(); - assert_eq!(spawned.get(), 2, "live endpoint is adopted"); - assert!(setup.log().contains("adopted")); } #[test] diff --git a/packages/herdr-pond/src/desk/mod.rs b/packages/herdr-pond/src/desk/mod.rs index d6b9f12a..423ee8f0 100644 --- a/packages/herdr-pond/src/desk/mod.rs +++ b/packages/herdr-pond/src/desk/mod.rs @@ -14,7 +14,6 @@ use crossterm::event::{Event, EventStream}; use futures_util::{Stream, StreamExt}; use ratatui::Terminal; use ratatui::backend::Backend; -use tokio::signal::unix::{SignalKind, signal}; use tokio::sync::mpsc; use tokio::task::AbortHandle; @@ -22,24 +21,32 @@ 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. +/// 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, context: DeskContext) -> anyhow::Result { let runtime = crate::runtime()?; let mut terminal = ratatui::try_init().inspect_err(|_| ratatui::restore())?; let result = runtime.block_on(async { - let mut terminate = signal(SignalKind::terminate())?; - let mut hangup = signal(SignalKind::hangup())?; - let shutdown = async move { - tokio::select! { - _ = terminate.recv() => {} - _ = hangup.recv() => {} - } + let shutdown = crate::shutdown_signal()?; + let shutdown = async { + shutdown.await; }; - event_loop(&mut terminal, api, context, EventStream::new(), shutdown).await + event_loop( + &mut terminal, + Arc::clone(&api), + context, + EventStream::new(), + shutdown, + ) + .await }); ratatui::restore(); + runtime.shutdown_timeout(EXIT_GRACE); + drop(api); result } diff --git a/packages/herdr-pond/src/main.rs b/packages/herdr-pond/src/main.rs index 77a37b09..9db463c4 100644 --- a/packages/herdr-pond/src/main.rs +++ b/packages/herdr-pond/src/main.rs @@ -15,9 +15,12 @@ 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 ]|serve-daemon [--owner]"; @@ -51,6 +54,22 @@ fn runtime() -> std::io::Result { .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> { + 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. diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index 7284d756..cc49ed29 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -14,17 +14,20 @@ use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; use serde::{Deserialize, Serialize}; -use crate::api::{SQL_PATH, post}; +use crate::api::{SQL_PATH, post, 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 PORT_DEADLINE: Duration = Duration::from_secs(180); -const PROBE_DEADLINE: Duration = Duration::from_secs(5); const PROBE_TIMEOUT_SECS: u64 = 5; const FALLBACK_GRACE: Duration = Duration::from_secs(2); const PORT_POLL: Duration = Duration::from_millis(100); +const TERMINATE_POLL: Duration = Duration::from_millis(25); +/// clap's usage-error exit: a pond from before `--port-file` rejects the flag +/// with it, before binding anything. +const USAGE_ERROR_EXIT: i32 = 2; /// `STATE_DIR/serve//`: herdr keys plugin state by plugin id only, /// so two herdr servers on one machine share the state dir - everything a @@ -119,7 +122,8 @@ pub(crate) async fn live_endpoint(client: &reqwest::Client, dir: &ServeDir) -> O /// for the desk. A 405 from `/v1/search` would prove neither. pub(crate) async fn probe(client: &reqwest::Client, base_url: &str) -> Result<(), ApiError> { let request = SqlRequest::new(READY_SQL.to_owned(), 1, PROBE_TIMEOUT_SECS); - let response: SqlResponse = post(client, base_url, SQL_PATH, &request, PROBE_DEADLINE).await?; + let deadline = sql_deadline(PROBE_TIMEOUT_SECS); + let response: SqlResponse = post(client, base_url, SQL_PATH, &request, deadline).await?; if response.rows.is_empty() { return Err(ApiError::Decode(format!( "{base_url} answered the readiness probe with no rows" @@ -128,28 +132,103 @@ pub(crate) async fn probe(client: &reqwest::Client, base_url: &str) -> Result<() Ok(()) } -/// `pond serve` bound to loopback on a free port. `--host` is explicit -/// because an inherited `POND_HOST` would otherwise rebind it; stdio goes to -/// `log` because serve's output would corrupt the TUI or pin a herdr slot. -pub(crate) fn spawn_serve(pond: &Path, port_file: &Path, log: &Path) -> std::io::Result { - let _ = fs::remove_file(port_file); - log_stdio( - Command::new(pond) - .args(["serve", "--host", "127.0.0.1", "--port", "0", "--port-file"]) - .arg(port_file), - log, - )? - .spawn() -} - -/// The base URL from a `--port-file` (`host:port`, written atomically after bind). +/// The address from a `--port-file` (`host:port`, written atomically after bind). pub(crate) fn read_port_file(path: &Path) -> Option { fs::read_to_string(path).ok()?.trim().parse().ok() } +/// A spawned `pond serve`, terminated (and its port file removed) on drop. +/// Termination blocks for up to `grace`, so async code drops one through +/// [`retire`]. +pub(crate) struct ServeChild { + child: Child, + port_file: PathBuf, + log: PathBuf, + grace: Duration, +} + +impl ServeChild { + /// `pond serve` bound to loopback on a free port. `--host` is explicit + /// because an inherited `POND_HOST` would otherwise rebind it; stdio goes + /// to `log` because serve's output would corrupt the TUI or pin a herdr slot. + pub(crate) fn spawn( + pond: &Path, + port_file: PathBuf, + log: PathBuf, + grace: Duration, + ) -> std::io::Result { + let _ = fs::remove_file(&port_file); + let child = log_stdio( + Command::new(pond) + .args(["serve", "--host", "127.0.0.1", "--port", "0", "--port-file"]) + .arg(&port_file), + &log, + )? + .spawn()?; + Ok(Self { + child, + port_file, + log, + grace, + }) + } + + pub(crate) fn id(&self) -> u32 { + self.child.id() + } + + /// Why serve is gone, once it has exited. + pub(crate) fn exited(&mut self) -> Option { + 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 bind and publish its port. + pub(crate) async fn listening(&mut self, deadline: Duration) -> Result { + let started = Instant::now(); + loop { + if let Some(addr) = read_port_file(&self.port_file) { + return Ok(addr); + } + if let Ok(Some(status)) = self.child.try_wait() { + if status.code() == Some(USAGE_ERROR_EXIT) { + return Err(ApiError::PondTooOld); + } + return Err(ApiError::Unreachable(format!( + "pond serve exited ({status}) before listening - see {}", + self.log.display() + ))); + } + if started.elapsed() > deadline { + return Err(ApiError::Unreachable(format!( + "pond serve did not listen within {}s - see {}", + deadline.as_secs(), + self.log.display() + ))); + } + tokio::time::sleep(PORT_POLL).await; + } + } +} + +impl Drop for ServeChild { + fn drop(&mut self) { + terminate(&mut self.child, self.grace); + let _ = fs::remove_file(&self.port_file); + } +} + +/// 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. -pub(crate) fn terminate(child: &mut Child, grace: Duration) { +fn terminate(child: &mut Child, grace: Duration) { if !matches!(child.try_wait(), Ok(None)) { return; } @@ -161,7 +240,7 @@ pub(crate) fn terminate(child: &mut Child, grace: Duration) { if !matches!(child.try_wait(), Ok(None)) { return; } - std::thread::sleep(Duration::from_millis(25)); + std::thread::sleep(TERMINATE_POLL); } let _ = child.kill(); let _ = child.wait(); @@ -169,19 +248,11 @@ pub(crate) fn terminate(child: &mut Child, grace: Duration) { /// 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 ServeChild { - child: Child, - port_file: PathBuf, +pub(crate) struct Fallback { + serve: ServeChild, base_url: String, } -impl Drop for ServeChild { - fn drop(&mut self) { - terminate(&mut self.child, FALLBACK_GRACE); - let _ = fs::remove_file(&self.port_file); - } -} - /// Where the desk finds its serve: this herdr server's state plus the plugin /// config that names `pond`. pub(crate) struct Origin { @@ -200,7 +271,7 @@ impl Origin { pub(crate) struct Connection { pub base_url: String, - pub fallback: Option, + pub fallback: Option, } /// The daemon's endpoint when it probes live, else the desk's existing @@ -208,31 +279,38 @@ pub(crate) struct Connection { pub(crate) async fn connect( client: &reqwest::Client, origin: &Origin, - fallback: Option, + fallback: Option, ) -> Result { if let Some(base_url) = live_endpoint(client, &origin.dir).await { + if let Some(fallback) = fallback { + retire(fallback.serve); + } return Ok(Connection { base_url, fallback: None, }); } - if let Some(fallback) = fallback - && probe(client, &fallback.base_url).await.is_ok() - { - return Ok(Connection { - base_url: fallback.base_url.clone(), - fallback: Some(fallback), - }); + if let Some(fallback) = fallback { + if probe(client, &fallback.base_url).await.is_ok() { + return Ok(Connection { + base_url: fallback.base_url.clone(), + fallback: Some(fallback), + }); + } + retire(fallback.serve); } let fallback = spawn_fallback(origin).await?; - probe(client, &fallback.base_url).await?; + if let Err(error) = probe(client, &fallback.base_url).await { + retire(fallback.serve); + return Err(error); + } Ok(Connection { base_url: fallback.base_url.clone(), fallback: Some(fallback), }) } -async fn spawn_fallback(origin: &Origin) -> Result { +async fn spawn_fallback(origin: &Origin) -> Result { let log = origin.dir.desk_log(); let pond = Config::pond(&origin.config_dir, &log) .map_err(|error| ApiError::Unreachable(format!("{error:#}")))?; @@ -240,34 +318,18 @@ async fn spawn_fallback(origin: &Origin) -> Result { .dir .port_file(&format!("desk.{}", std::process::id())); log_line(&log, &format!("desk: starting fallback {}", pond.display())); - let child = spawn_serve(&pond, &port_file, &log).map_err(|error| { + let mut serve = ServeChild::spawn(&pond, port_file, log, FALLBACK_GRACE).map_err(|error| { ApiError::Unreachable(format!("cannot start {}: {error}", pond.display())) })?; - let mut serve = ServeChild { - child, - port_file, - base_url: String::new(), - }; - let started = Instant::now(); - loop { - if let Some(addr) = read_port_file(&serve.port_file) { - serve.base_url = format!("http://{addr}"); - return Ok(serve); - } - if let Ok(Some(status)) = serve.child.try_wait() { - return Err(ApiError::Unreachable(format!( - "pond serve exited ({status}) before listening - see {}", - log.display() - ))); - } - if started.elapsed() > PORT_DEADLINE { - return Err(ApiError::Unreachable(format!( - "pond serve did not listen within {}s - see {}", - PORT_DEADLINE.as_secs(), - log.display() - ))); + match serve.listening(PORT_DEADLINE).await { + Ok(addr) => Ok(Fallback { + serve, + base_url: format!("http://{addr}"), + }), + Err(error) => { + retire(serve); + Err(error) } - tokio::time::sleep(PORT_POLL).await; } } @@ -388,8 +450,8 @@ mod tests { let log = fs::read_to_string(origin.dir.desk_log()).unwrap(); assert!(log.contains("serve stdout") && log.contains("serve stderr")); - let pid = fallback.child.id(); - let port_file = fallback.port_file.clone(); + let pid = fallback.serve.id(); + let port_file = fallback.serve.port_file.clone(); assert!(alive(pid)); drop(fallback); assert!(!alive(pid), "fallback serve survived the desk"); @@ -403,9 +465,9 @@ mod tests { let origin = fake_serve(&sandbox, pond.addr()); let client = client().unwrap(); let first = connect(&client, &origin, None).await.unwrap(); - let pid = first.fallback.as_ref().unwrap().child.id(); + let pid = first.fallback.as_ref().unwrap().serve.id(); let second = connect(&client, &origin, first.fallback).await.unwrap(); - assert_eq!(second.fallback.as_ref().unwrap().child.id(), pid); + assert_eq!(second.fallback.as_ref().unwrap().serve.id(), pid); assert_eq!(sandbox.lines("calls").len(), 1); } @@ -427,6 +489,18 @@ mod tests { ); } + #[tokio::test] + async fn a_pond_that_rejects_port_file_is_too_old() { + let sandbox = Sandbox::new(); + let pond = write_script( + &sandbox.path("bin/pond"), + "echo \"error: unexpected argument '--port-file' found\" >&2; exit 2", + ); + sandbox.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); + let result = connect(&client().unwrap(), &sandbox.origin(), None).await; + assert!(matches!(result, Err(ApiError::PondTooOld))); + } + #[tokio::test] async fn missing_pond_names_the_config_key() { let sandbox = Sandbox::new(); diff --git a/packages/herdr-pond/src/types.rs b/packages/herdr-pond/src/types.rs index 25793bcd..e29d62dd 100644 --- a/packages/herdr-pond/src/types.rs +++ b/packages/herdr-pond/src/types.rs @@ -149,10 +149,12 @@ pub(crate) enum ApiError { Pond { code: String, message: String }, /// A non-envelope rejection (axum's plain-text JSON/route errors). Rejected { status: u16, body: String }, - /// `/v1/x/sql` is missing: the installed pond predates the endpoint. + /// The installed pond predates `/v1/x/sql` or `pond serve --port-file`. PondTooOld, - /// Refused, timed out, or no serve could be started. + /// Connection refused (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. @@ -165,9 +167,10 @@ impl fmt::Display for ApiError { 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 has no /v1/x/sql - upgrade pond (`brew upgrade pond` / `cargo install pond-db`)", + "this pond is too old for the desk (needs /v1/x/sql and `pond serve --port-file`) - 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}"), } From c2fc25690015027c425a46a362321ae5a5c8f2a0 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 06:19:19 +0000 Subject: [PATCH 09/41] perf(herdr-pond): single-flight desk lanes and bounded herdr calls A refresh joins requests already in flight for the same scope or query instead of restarting them, and toggling back to a cached scope lets the in-flight listing land in the cache. Previews are clipped and sanitized once when cached (the cache is bounded), the spinner redraws only where it shows, and a resize re-wraps the pager once at the next draw. Every herdr CLI call is killed after 3s, and desk exit waits at most 500ms for blocking work. --- packages/herdr-pond/src/desk/app.rs | 200 +++++++++++++++++++++++----- packages/herdr-pond/src/desk/mod.rs | 2 +- packages/herdr-pond/src/desk/ui.rs | 17 +-- packages/herdr-pond/src/herdr.rs | 94 +++++++++++-- 4 files changed, 257 insertions(+), 56 deletions(-) diff --git a/packages/herdr-pond/src/desk/app.rs b/packages/herdr-pond/src/desk/app.rs index 2c0ee008..36f670bf 100644 --- a/packages/herdr-pond/src/desk/app.rs +++ b/packages/herdr-pond/src/desk/app.rs @@ -14,8 +14,8 @@ use unicode_width::UnicodeWidthStr; use super::ui; use crate::types::{ - ApiError, Cursor, DeskContext, DeskExit, ListingScope, LiveAgent, PAGE_ROWS, SearchRequest, - SearchResponse, SessionDetail, SessionRow, TranscriptMessage, TranscriptPage, + ApiError, Cursor, DeskContext, DeskExit, LISTING_ROWS, ListingScope, LiveAgent, PAGE_ROWS, + SearchRequest, SearchResponse, SessionDetail, SessionRow, TranscriptMessage, TranscriptPage, }; pub(super) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(150); @@ -67,6 +67,14 @@ impl Call { 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, + } + } } #[derive(Debug)] @@ -102,10 +110,10 @@ pub(super) enum Effect { Exit(DeskExit), } -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default)] struct LaneState { generation: u64, - loading: bool, + in_flight: Option, } #[derive(Debug, Default)] @@ -254,6 +262,8 @@ pub(super) struct App { 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, at draw. + resized: bool, } impl App { @@ -263,7 +273,7 @@ impl App { context, size, epoch: 0, - lanes: [LaneState::default(); Lane::COUNT], + lanes: Default::default(), all_projects: false, all_time: false, listings: HashMap::new(), @@ -282,6 +292,7 @@ impl App { fatal: None, spinner: 0, dirty: true, + resized: false, } } @@ -289,14 +300,22 @@ impl App { self.refresh() } - pub(super) fn is_loading(&self) -> bool { - self.lanes.iter().any(|lane| lane.loading) + pub(super) fn lane_loading(&self, lane: Lane) -> bool { + self.lanes[lane as usize].in_flight.is_some() } - pub(super) fn lane_loading(&self, lane: Lane) -> bool { - self.lanes[lane as usize].loading + /// 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; @@ -391,22 +410,31 @@ impl App { ui::pager_areas(self.area()).text } - fn fetch(&mut self, call: Call, delay: Duration) -> Effect { + /// 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.loading = true; - Effect::Fetch { + 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.loading = false; + state.in_flight = None; Effect::Cancel(lane) } @@ -425,18 +453,19 @@ impl App { fn refresh(&mut self) -> Vec { self.previews.clear(); - let mut effects = vec![ - self.fetch(Call::Listing(self.scope()), Duration::ZERO), - self.fetch(Call::Live, Duration::ZERO), - ]; + 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.push(self.fetch_search(query, Duration::ZERO)); + 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) -> Effect { + fn fetch_search(&mut self, query: String, delay: Duration) -> Option { let request = SearchRequest::new(query, SEARCH_LIMIT).within(&self.scope()); self.fetch(Call::Search(request), delay) } @@ -452,14 +481,22 @@ impl App { fn resize(&mut self, width: u16, height: u16) -> Vec { self.size = Size::new(width, height); self.dirty = true; + self.resized = true; + let mut effects: Vec = self.hydrate_visible().into_iter().collect(); + effects.extend(self.load_more()); + effects + } + + /// Re-wraps the pager for the latest size, once per drawn frame. + pub(super) fn relayout(&mut self) { + if !std::mem::take(&mut self.resized) { + return; + } 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)); } - let mut effects: Vec = self.hydrate_visible().into_iter().collect(); - effects.extend(self.load_more()); - effects } fn on_key(&mut self, key: KeyEvent) -> Vec { @@ -632,7 +669,7 @@ impl App { return None; } self.hydrated.extend(missing.iter().cloned()); - Some(self.fetch(Call::Hydrate(missing), Duration::ZERO)) + self.fetch(Call::Hydrate(missing), Duration::ZERO) } fn preview_selected(&mut self, delay: Duration) -> Option { @@ -641,7 +678,7 @@ impl App { .filter(|id| self.preview_open && !self.previews.contains_key(*id)) .map(str::to_owned); match wanted { - Some(id) => Some(self.fetch(Call::Preview(id), delay)), + Some(id) => self.fetch(Call::Preview(id), delay), None => self .lane_loading(Lane::Preview) .then(|| self.cancel(Lane::Preview)), @@ -670,7 +707,7 @@ impl App { self.search_state = ListState::default(); } } - effects.push(self.fetch_search(query, SEARCH_DEBOUNCE)); + effects.extend(self.fetch_search(query, SEARCH_DEBOUNCE)); effects } @@ -698,17 +735,14 @@ impl App { } let mut effects = self.transition(); if self.listing().is_some() { - if self.lane_loading(Lane::Listing) { - effects.push(self.cancel(Lane::Listing)); - } self.restore_listing_selection(selected.as_deref()); } else { - effects.push(self.fetch(Call::Listing(self.scope()), Duration::ZERO)); + effects.extend(self.fetch(Call::Listing(self.scope()), Duration::ZERO)); } if let Some(search) = &mut self.search { search.response = None; let query = search.query.clone(); - effects.push(self.fetch_search(query, Duration::ZERO)); + effects.extend(self.fetch_search(query, Duration::ZERO)); } effects.extend(self.selection_changed()); effects @@ -758,7 +792,7 @@ impl App { .filter(|search| search.response.is_none()) .map(|search| search.query.clone()); if let Some(query) = stale_search { - effects.push(self.fetch_search(query, Duration::ZERO)); + effects.extend(self.fetch_search(query, Duration::ZERO)); } effects.extend(self.selection_changed()); effects @@ -781,7 +815,7 @@ impl App { session_id: pager.session_id.clone(), after: pager.next_cursor(), }; - vec![self.fetch(call, Duration::ZERO)] + self.fetch(call, Duration::ZERO).into_iter().collect() } pub(super) fn apply(&mut self, msg: Msg) -> Vec { @@ -791,7 +825,7 @@ impl App { { return Vec::new(); } - self.lanes[lane as usize].loading = false; + 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), @@ -819,7 +853,17 @@ impl App { } match result { Ok(messages) => { - self.previews.insert(id, 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), @@ -1115,6 +1159,84 @@ mod tests { 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.all_time); + settle(&mut app, &api, vec![listing]); + assert!(!app.all_time, "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(), "t".to_owned(), 80)); + assert!(!app.spinner_visible(), "the pager shows only its own load"); + app.load_more(); + assert!(app.spinner_visible()); + } + #[test] fn project_toggle_widens_listing_and_search() { let api = MockApi::golden(); @@ -1462,14 +1584,20 @@ mod tests { 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" ); - let screen_text = screen(&mut app); assert!(screen_text.contains("message 10"), "{screen_text}"); } diff --git a/packages/herdr-pond/src/desk/mod.rs b/packages/herdr-pond/src/desk/mod.rs index 423ee8f0..0bfcce32 100644 --- a/packages/herdr-pond/src/desk/mod.rs +++ b/packages/herdr-pond/src/desk/mod.rs @@ -90,7 +90,7 @@ where Some(Err(_)) | None => return Ok(DeskExit::Quit), }, Some(msg) = rx.recv() => app.apply(msg), - _ = spinner.tick(), if app.is_loading() => { + _ = spinner.tick(), if app.spinner_visible() => { app.tick(); Vec::new() } diff --git a/packages/herdr-pond/src/desk/ui.rs b/packages/herdr-pond/src/desk/ui.rs index 5e8b641d..952e2ba4 100644 --- a/packages/herdr-pond/src/desk/ui.rs +++ b/packages/herdr-pond/src/desk/ui.rs @@ -22,7 +22,7 @@ const ADAPTER: usize = 12; const AGE: usize = 4; const COUNT: usize = 7; /// The preview wraps on every frame, so one huge message must not reach it whole. -const PREVIEW_CHARS: usize = 2000; +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"; @@ -91,6 +91,7 @@ pub(super) fn pager_areas(area: Rect) -> PagerAreas { } pub(super) fn render(frame: &mut Frame, app: &mut App) { + app.relayout(); if let Some(message) = &app.fatal { render_fatal(frame, message); } else if app.pager.is_some() { @@ -329,16 +330,11 @@ fn render_preview(frame: &mut Frame, app: &App, area: Rect) { Some(messages) => messages .iter() .flat_map(|message| { - let clipped: String = message.text.chars().take(PREVIEW_CHARS).collect(); 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( - sanitize(&clipped) - .split('\n') - .map(|l| Line::raw(l.to_owned())), - ); + lines.extend(message.text.split('\n').map(|l| Line::raw(l.to_owned()))); lines.push(Line::default()); lines }) @@ -360,7 +356,7 @@ fn footer(app: &App) -> Line<'static> { "/ search enter open space preview p projects t time r refresh q quit" }; let mut spans = Vec::new(); - if app.is_loading() { + if app.spinner_visible() { spans.push(Span::raw(format!("{} ", spinner_frame(app))).fg(Color::Yellow)); } spans.push(Span::raw(help).dim()); @@ -546,6 +542,11 @@ pub(super) fn sanitize(text: &str) -> String { 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::()) +} + /// A clean single line: sanitized, whitespace runs collapsed. pub(super) fn one_line(text: &str) -> String { sanitize(text) diff --git a/packages/herdr-pond/src/herdr.rs b/packages/herdr-pond/src/herdr.rs index ede796cc..d2c008b0 100644 --- a/packages/herdr-pond/src/herdr.rs +++ b/packages/herdr-pond/src/herdr.rs @@ -2,8 +2,11 @@ //! `notification show`) and the plugin runtime env (plan 5.2, 5.4). use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; use anyhow::{Context, bail}; use serde::Deserialize; @@ -15,6 +18,9 @@ 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 { @@ -125,6 +131,7 @@ pub(crate) fn live_agents(panes: Vec) -> Vec { #[derive(Debug, Clone)] pub(crate) struct Herdr { bin: PathBuf, + deadline: Duration, } impl Herdr { @@ -133,26 +140,47 @@ impl Herdr { } pub(crate) fn new(bin: PathBuf) -> Self { - Self { bin } + Self { + bin, + deadline: CALL_DEADLINE, + } } - /// Runs one CLI call and returns its `result` object. herdr reports - /// errors on stderr with a nonzero exit, never in the stdout JSON. + /// Runs one CLI call, killed past the deadline, 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 { - let output = Command::new(&self.bin) + let command = args.iter().take(3).copied().collect::>().join(" "); + let mut child = Command::new(&self.bin) .args(args) .stdin(Stdio::null()) - .output() + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() .with_context(|| format!("running {}", self.bin.display()))?; - let command = args.iter().take(3).copied().collect::>().join(" "); - if !output.status.success() { + let stdout = drain(child.stdout.take()); + let stderr = drain(child.stderr.take()); + let started = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if started.elapsed() > self.deadline { + let _ = child.kill(); + let _ = child.wait(); + bail!("herdr {command} timed out after {:?}", self.deadline); + } + std::thread::sleep(CALL_POLL); + }; + let stdout = stdout.join().unwrap_or_default(); + if !status.success() { + let stderr = stderr.join().unwrap_or_default(); bail!( - "herdr {command} failed ({}): {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() + "herdr {command} failed ({status}): {}", + String::from_utf8_lossy(&stderr).trim() ); } - let mut response: serde_json::Value = serde_json::from_slice(&output.stdout) + let mut response: serde_json::Value = serde_json::from_slice(&stdout) .with_context(|| format!("herdr {command} printed no JSON response"))?; Ok(response["result"].take()) } @@ -204,6 +232,18 @@ impl Herdr { } } +/// 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) -> JoinHandle> { + std::thread::spawn(move || { + let mut bytes = Vec::new(); + if let Some(mut pipe) = pipe { + let _ = pipe.read_to_end(&mut bytes); + } + bytes + }) +} + /// 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<()> { @@ -301,6 +341,38 @@ esac"#, } } + #[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 a_large_reply_is_read_whole() { + let sandbox = Sandbox::new(); + let panes: Vec = (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(); From 711e3d379d147341ee40eef197f37e5eb882ada7 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 06:20:10 +0000 Subject: [PATCH 10/41] docs(herdr-pond): plain module docs, named layout constants, exact prerequisites The README names the pond change the plugin needs (#311) and says where the too-old message appears; module docs drop the ambiguous plan section refs in favor of one pointer in the crate doc; the all-time loading line and type docs drop internal jargon; the desk's layout breakpoints are named constants; moon's compiled sources no longer list the manifest or the bin symlink, neither of which affects the build. --- packages/herdr-pond/README.md | 4 ++-- packages/herdr-pond/moon.yml | 2 -- packages/herdr-pond/src/api.rs | 2 +- packages/herdr-pond/src/config.rs | 4 ++-- packages/herdr-pond/src/desk/ui.rs | 13 ++++++++----- packages/herdr-pond/src/herdr.rs | 2 +- packages/herdr-pond/src/hook.rs | 2 +- packages/herdr-pond/src/main.rs | 3 ++- packages/herdr-pond/src/serve.rs | 6 +++--- 9 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/herdr-pond/README.md b/packages/herdr-pond/README.md index 4c633f7b..5411209e 100644 --- a/packages/herdr-pond/README.md +++ b/packages/herdr-pond/README.md @@ -8,7 +8,7 @@ A [herdr](https://herdr.dev) plugin for pond: ## 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 with `/v1/x/sql` and `pond serve --port-file`. With an older pond the desk says so and names the upgrade command. +- A pond release that includes `POST /v1/x/sql` and `pond serve --port-file` ([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 @@ -33,7 +33,7 @@ type = "plugin_action" command = "pond.desk" ``` -Then run `herdr server reload-config`. Pressing the key again focuses the open desk instead of opening a second one. +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 diff --git a/packages/herdr-pond/moon.yml b/packages/herdr-pond/moon.yml index d5aeb884..dcd17e5f 100644 --- a/packages/herdr-pond/moon.yml +++ b/packages/herdr-pond/moon.yml @@ -10,8 +10,6 @@ fileGroups: sources: - 'src/**/*' - 'Cargo.toml' - - 'herdr-plugin.toml' - - 'bin/**/*' - '/Cargo.toml' - '/Cargo.lock' - '/rust-toolchain.toml' diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs index a1fd583b..495b9612 100644 --- a/packages/herdr-pond/src/api.rs +++ b/packages/herdr-pond/src/api.rs @@ -1,4 +1,4 @@ -//! The HTTP [`Api`](crate::types::Api) implementation over `pond serve` +//! The HTTP [`Api`] implementation over `pond serve` //! (`/v1/x/sql`, `/v1/search`), plus herdr's pane list for live agents. //! Tested against [`crate::fake_pond`]. diff --git a/packages/herdr-pond/src/config.rs b/packages/herdr-pond/src/config.rs index 3b96fcdf..f106e5e8 100644 --- a/packages/herdr-pond/src/config.rs +++ b/packages/herdr-pond/src/config.rs @@ -1,6 +1,6 @@ //! The plugin's own files: `HERDR_PLUGIN_CONFIG_DIR/config.toml` (re-read per -//! run, malformed falls back to defaults - plan 5.3) and the state-dir logs, -//! locks and atomic writes every headless leg shares. +//! 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}; diff --git a/packages/herdr-pond/src/desk/ui.rs b/packages/herdr-pond/src/desk/ui.rs index 952e2ba4..87f892f7 100644 --- a/packages/herdr-pond/src/desk/ui.rs +++ b/packages/herdr-pond/src/desk/ui.rs @@ -21,6 +21,9 @@ const MACHINE: usize = 10; 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; /// 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)"; @@ -45,7 +48,7 @@ pub(super) fn desk_areas(area: Rect, preview: bool) -> DeskAreas { ])); let (list, preview) = if !preview { (body, None) - } else if body.width >= 100 { + } else if body.width >= SIDE_BY_SIDE_MIN_WIDTH { let [list, preview] = body.layout(&Layout::horizontal([Constraint::Fill(1); 2])); (list, Some(preview)) } else { @@ -242,9 +245,9 @@ fn row_items(app: &App) -> Result>, String> { }; } match app.listing() { - None if app.lane_loading(Lane::Listing) && app.all_time => Err( - "loading the all-time listing - the slow query family, this can take ~15s".to_owned(), - ), + None if app.lane_loading(Lane::Listing) && app.all_time => { + 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!( @@ -421,7 +424,7 @@ fn render_pager(frame: &mut Frame, app: &App) { fn render_toast(frame: &mut Frame, text: &str) { let area = frame.area(); - let width = area.width.min(60); + let width = area.width.min(TOAST_MAX_WIDTH); let inner = usize::from(width.saturating_sub(2)).max(1); let lines = u16::try_from(textwrap::wrap(text, inner).len()).unwrap_or(u16::MAX); let height = lines.saturating_add(2).min(area.height); diff --git a/packages/herdr-pond/src/herdr.rs b/packages/herdr-pond/src/herdr.rs index d2c008b0..227470d7 100644 --- a/packages/herdr-pond/src/herdr.rs +++ b/packages/herdr-pond/src/herdr.rs @@ -1,5 +1,5 @@ //! Every herdr CLI call (`pane list`, `agent focus`, `plugin pane open|focus`, -//! `notification show`) and the plugin runtime env (plan 5.2, 5.4). +//! `notification show`) and the plugin runtime env. use std::fs; use std::io::Read; diff --git a/packages/herdr-pond/src/hook.rs b/packages/herdr-pond/src/hook.rs index 0b9c1b8c..e12adebd 100644 --- a/packages/herdr-pond/src/hook.rs +++ b/packages/herdr-pond/src/hook.rs @@ -1,5 +1,5 @@ //! Sync-on-idle: the millisecond event hook and its detached per-adapter -//! worker (plan 5.5). +//! worker. //! //! No idle event may be dropped, so the worker runs trailing-edge: the hook //! creates `pending.`; the worker deletes it just before each diff --git a/packages/herdr-pond/src/main.rs b/packages/herdr-pond/src/main.rs index 9db463c4..2dafd367 100644 --- a/packages/herdr-pond/src/main.rs +++ b/packages/herdr-pond/src/main.rs @@ -1,4 +1,5 @@ -//! herdr plugin for pond: sync-on-idle and a read-only session desk. +//! 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, diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index cc49ed29..dd52aa87 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -1,7 +1,7 @@ //! Finding a usable `pond serve` for the desk: this herdr server's published -//! endpoint, else a desk-owned fallback child (plan 5.7), both vetted by the -//! capability probe (plan 5.8). The per-server state layout and the serve -//! spawn/teardown are shared with the daemon. +//! 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::net::SocketAddr; From e1f1db7eb569f5360b8e1ff3ee63fd39932214aa Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 06:42:40 +0000 Subject: [PATCH 11/41] fix(herdr-pond): one port file per fallback spawn, too-old only on a rejected --port-file A retiring fallback removed the shared desk..port on drop, which could delete its successor's freshly published file; each spawn now owns desk...port. clap exits 2 for any usage error, so PondTooOld is now reported only when this child's log output names --port-file; other early exits name the log. --- packages/herdr-pond/src/daemon.rs | 23 +++++++- packages/herdr-pond/src/serve.rs | 89 ++++++++++++++++++++++++++----- 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index 0f90e736..8a4e4d6c 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -593,13 +593,34 @@ mod tests { #[tokio::test] async fn a_pond_without_port_file_is_named_too_old() { let setup = Setup::new().await; - let pond = setup.fake_pond(false, "exit 2"); + let pond = setup.fake_pond( + false, + "echo \"error: unexpected argument '--port-file' 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(); diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index dd52aa87..161054c9 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -8,6 +8,7 @@ use std::net::SocketAddr; 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}; @@ -25,8 +26,8 @@ const PROBE_TIMEOUT_SECS: u64 = 5; const FALLBACK_GRACE: Duration = Duration::from_secs(2); const PORT_POLL: Duration = Duration::from_millis(100); const TERMINATE_POLL: Duration = Duration::from_millis(25); -/// clap's usage-error exit: a pond from before `--port-file` rejects the flag -/// with it, before binding anything. +/// clap's usage-error exit, for any bad flag or env value; only a rejection +/// naming `--port-file` marks a pond from before the flag. const USAGE_ERROR_EXIT: i32 = 2; /// `STATE_DIR/serve//`: herdr keys plugin state by plugin id only, @@ -144,6 +145,8 @@ pub(crate) struct ServeChild { child: Child, port_file: PathBuf, log: PathBuf, + /// Where this child's output starts in the shared `log`. + log_start: u64, grace: Duration, } @@ -158,17 +161,18 @@ impl ServeChild { grace: Duration, ) -> std::io::Result { let _ = fs::remove_file(&port_file); - let child = log_stdio( - Command::new(pond) - .args(["serve", "--host", "127.0.0.1", "--port", "0", "--port-file"]) - .arg(&port_file), - &log, - )? - .spawn()?; + let mut command = Command::new(pond); + command + .args(["serve", "--host", "127.0.0.1", "--port", "0", "--port-file"]) + .arg(&port_file); + log_stdio(&mut command, &log)?; + let log_start = fs::metadata(&log).map_or(0, |meta| meta.len()); + let child = command.spawn()?; Ok(Self { child, port_file, log, + log_start, grace, }) } @@ -194,7 +198,7 @@ impl ServeChild { return Ok(addr); } if let Ok(Some(status)) = self.child.try_wait() { - if status.code() == Some(USAGE_ERROR_EXIT) { + if status.code() == Some(USAGE_ERROR_EXIT) && self.rejected_port_file() { return Err(ApiError::PondTooOld); } return Err(ApiError::Unreachable(format!( @@ -212,6 +216,15 @@ impl ServeChild { tokio::time::sleep(PORT_POLL).await; } } + + fn rejected_port_file(&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("--port-file")) + }) + } } impl Drop for ServeChild { @@ -311,12 +324,17 @@ pub(crate) async fn connect( } async fn spawn_fallback(origin: &Origin) -> Result { + // One file per spawn: a retiring fallback removes its own on drop, while + // its successor may already have published 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 port_file = origin - .dir - .port_file(&format!("desk.{}", std::process::id())); + let port_file = origin.dir.port_file(&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, port_file, log, FALLBACK_GRACE).map_err(|error| { ApiError::Unreachable(format!("cannot start {}: {error}", pond.display())) @@ -501,6 +519,51 @@ mod tests { 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 '\" >&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 '--port-file' found\n").unwrap(); + let Err(ApiError::Unreachable(reason)) = connect(&client().unwrap(), &origin, None).await + else { + panic!("expected Unreachable"); + }; + assert!(reason.contains("desk-serve.log"), "{reason}"); + } + + #[tokio::test] + async fn a_retired_fallback_keeps_its_successors_port_file() { + let sandbox = Sandbox::new(); + let client = client().unwrap(); + let origin = sandbox.origin(); + let first_pond = ready_pond().await; + sandbox.fake_serve(Some(first_pond.addr()), "trap '' TERM; exec sleep 30"); + let first = connect(&client, &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.addr()), "exec sleep 30"); + let second = connect(&client, &origin, first.fallback).await.unwrap(); + assert_eq!(second.base_url, second_pond.base_url); + let port_file = second.fallback.as_ref().unwrap().serve.port_file.clone(); + + 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!(port_file.exists(), "the retired fallback removed it"); + } + #[tokio::test] async fn missing_pond_names_the_config_key() { let sandbox = Sandbox::new(); From 8fda64951c6c5481880e241b6dc62cbb4f143c5a Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 06:42:40 +0000 Subject: [PATCH 12/41] fix(herdr-pond): fetch more pager lines after the resize rewrap resize decided load_more against the old wrap once the rewrap moved to draw time, so widening could leave the pager short with no fetch until a key. relayout now returns the post-rewrap load_more, and the loop performs it before drawing. --- packages/herdr-pond/src/desk/app.rs | 35 +++++++++++++++++++++++------ packages/herdr-pond/src/desk/mod.rs | 2 ++ packages/herdr-pond/src/desk/ui.rs | 1 - 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/herdr-pond/src/desk/app.rs b/packages/herdr-pond/src/desk/app.rs index 36f670bf..7fd35041 100644 --- a/packages/herdr-pond/src/desk/app.rs +++ b/packages/herdr-pond/src/desk/app.rs @@ -262,7 +262,8 @@ pub(super) struct App { 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, at draw. + /// Set by a resize, so a burst of them re-wraps the pager once, before + /// the next frame. resized: bool, } @@ -482,21 +483,21 @@ impl App { self.size = Size::new(width, height); self.dirty = true; self.resized = true; - let mut effects: Vec = self.hydrate_visible().into_iter().collect(); - effects.extend(self.load_more()); - effects + self.hydrate_visible().into_iter().collect() } - /// Re-wraps the pager for the latest size, once per drawn frame. - pub(super) fn relayout(&mut self) { + /// 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; + 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 { @@ -1601,6 +1602,26 @@ mod tests { 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(), "t".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); diff --git a/packages/herdr-pond/src/desk/mod.rs b/packages/herdr-pond/src/desk/mod.rs index 0bfcce32..fa65be3d 100644 --- a/packages/herdr-pond/src/desk/mod.rs +++ b/packages/herdr-pond/src/desk/mod.rs @@ -72,6 +72,7 @@ where 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); @@ -370,6 +371,7 @@ pub(super) mod tests { } 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(); diff --git a/packages/herdr-pond/src/desk/ui.rs b/packages/herdr-pond/src/desk/ui.rs index 87f892f7..016277ff 100644 --- a/packages/herdr-pond/src/desk/ui.rs +++ b/packages/herdr-pond/src/desk/ui.rs @@ -94,7 +94,6 @@ pub(super) fn pager_areas(area: Rect) -> PagerAreas { } pub(super) fn render(frame: &mut Frame, app: &mut App) { - app.relayout(); if let Some(message) = &app.fatal { render_fatal(frame, message); } else if app.pager.is_some() { From dd5c1c5b37106dec943a64a6fa9d2ba6089f3253 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 06:42:40 +0000 Subject: [PATCH 13/41] fix(herdr-pond): make desk failover per call The sticky failed_over flag was cleared only by a success, so a failover whose retry timed out left every later refusal an error without re-resolving. Each call now retries once on a refused connection. --- packages/herdr-pond/src/api.rs | 90 +++++++++++++--------------------- 1 file changed, 33 insertions(+), 57 deletions(-) diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs index 495b9612..93857d65 100644 --- a/packages/herdr-pond/src/api.rs +++ b/packages/herdr-pond/src/api.rs @@ -3,7 +3,6 @@ //! Tested against [`crate::fake_pond`]. use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use serde::Serialize; @@ -131,9 +130,6 @@ struct Resolver { /// on first use rather than before the desk can draw. origin: Result, link: Mutex, - /// Set by a failover, cleared by the next successful request: while set, - /// a refused connection is an error instead of another failover. - failed_over: AtomicBool, } impl Resolver { @@ -141,15 +137,10 @@ impl Resolver { /// resolution and then reuse its result. async fn resolve(&self, stale: Option) -> Result { let mut link = self.link.lock().await; - if let Some(current) = &link.base_url { - match &stale { - None => return Ok(current.clone()), - Some(stale) if *current != stale.url => return Ok(current.clone()), - Some(stale) if self.failed_over.load(Ordering::Relaxed) => { - return Err(ApiError::Unreachable(stale.reason.clone())); - } - Some(_) => {} - } + if let Some(current) = &link.base_url + && stale.as_ref().is_none_or(|stale| *current != stale.url) + { + return Ok(current.clone()); } let origin = self .origin @@ -158,11 +149,10 @@ impl Resolver { let connection = serve::connect(&self.client, origin, link.fallback.take()).await?; link.fallback = connection.fallback; link.base_url = Some(connection.base_url.clone()); - if let Some(stale) = stale { - if connection.base_url == stale.url { - return Err(ApiError::Unreachable(stale.reason)); - } - self.failed_over.store(true, Ordering::Relaxed); + if let Some(stale) = stale + && connection.base_url == stale.url + { + return Err(ApiError::Unreachable(stale.reason)); } Ok(connection.base_url) } @@ -180,7 +170,6 @@ impl HttpApi { client: client()?, origin: Origin::from_env().map_err(|error| format!("{error:#}")), link: Mutex::default(), - failed_over: AtomicBool::new(false), }), herdr: Herdr::from_env(), }) @@ -202,8 +191,8 @@ impl HttpApi { /// 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 second refusal in a row, with - /// no success in between, stands as an error. + /// 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, path: &str, body: &B, deadline: Duration) -> Result where B: Serialize + ?Sized + Sync, @@ -211,17 +200,13 @@ impl HttpApi { { let client = &self.resolver.client; let url = self.resolve(None).await?; - let result = match post(client, &url, path, body, deadline).await { + match post(client, &url, path, body, deadline).await { Err(ApiError::Unreachable(reason)) => { let retry = self.resolve(Some(Stale { url, reason })).await?; post(client, &retry, path, body, deadline).await } other => other, - }; - if result.is_ok() { - self.resolver.failed_over.store(false, Ordering::Relaxed); } - result } async fn sql( @@ -318,7 +303,6 @@ mod tests { base_url: base_url.map(str::to_owned), fallback: None, }), - failed_over: AtomicBool::new(false), }), herdr: Herdr::new(sandbox.path("bin/herdr")), } @@ -544,42 +528,34 @@ mod tests { } #[tokio::test] - async fn a_success_rearms_failover() { + async fn a_failed_retry_does_not_wedge_failover() { let sandbox = Sandbox::new(); let endpoint_path = sandbox.origin().dir.endpoint(); - let first = preview_pond().await; - write_endpoint(&endpoint_path, &endpoint(first.port(), "t")).unwrap(); + 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.port(), "t")).unwrap(); let api = api_at(&dead_url(), &sandbox); - assert_eq!(api.preview("s1".to_owned()).await.unwrap().len(), 2); + 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(first); + drop(stalling); tokio::time::sleep(Duration::from_millis(50)).await; - let second = preview_pond().await; - write_endpoint(&endpoint_path, &endpoint(second.port(), "t")).unwrap(); - assert_eq!(api.preview("s1".to_owned()).await.unwrap().len(), 2); - assert_eq!( - second.recorded().len(), - 2, - "probe, then the retried preview" - ); - } - - #[tokio::test] - async fn a_refusal_right_after_a_failover_stands() { - let sandbox = Sandbox::new(); let ready = preview_pond().await; - write_endpoint( - &sandbox.origin().dir.endpoint(), - &endpoint(ready.port(), "t"), - ) - .unwrap(); - let api = api_at(&dead_url(), &sandbox); - api.resolver.failed_over.store(true, Ordering::Relaxed); - assert!(matches!( - api.preview("s1".to_owned()).await, - Err(ApiError::Unreachable(_)) - )); - assert!(ready.recorded().is_empty(), "failed over twice in a row"); + write_endpoint(&endpoint_path, &endpoint(ready.port(), "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] From 01ff9b4c81b5323e4db5b24a83fbf2dbaf712e92 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 06:42:40 +0000 Subject: [PATCH 14/41] fix(herdr-pond): bound herdr call output drain and kill on wait errors A process left behind by herdr could hold the pipes open and block the reader joins forever; the drain now waits only out the call deadline and abandons a stuck reader. A try_wait error kills and reaps the child. --- packages/herdr-pond/src/herdr.rs | 70 ++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/packages/herdr-pond/src/herdr.rs b/packages/herdr-pond/src/herdr.rs index 227470d7..be1fa48c 100644 --- a/packages/herdr-pond/src/herdr.rs +++ b/packages/herdr-pond/src/herdr.rs @@ -5,7 +5,7 @@ use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::thread::JoinHandle; +use std::sync::mpsc::{self, Receiver}; use std::time::{Duration, Instant}; use anyhow::{Context, bail}; @@ -146,9 +146,9 @@ impl Herdr { } } - /// Runs one CLI call, killed past the deadline, and returns its `result` - /// object. herdr reports errors on stderr with a nonzero exit, never in - /// the stdout JSON. + /// 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 { let command = args.iter().take(3).copied().collect::>().join(" "); let mut child = Command::new(&self.bin) @@ -162,24 +162,39 @@ impl Herdr { let stderr = drain(child.stderr.take()); let started = Instant::now(); let status = loop { - if let Some(status) = child.try_wait()? { - break status; - } - if started.elapsed() > self.deadline { - let _ = child.kill(); - let _ = child.wait(); - bail!("herdr {command} timed out after {:?}", self.deadline); - } - std::thread::sleep(CALL_POLL); + 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>| { + pipe.recv_timeout(self.deadline.saturating_sub(started.elapsed())) }; - let stdout = stdout.join().unwrap_or_default(); if !status.success() { - let stderr = stderr.join().unwrap_or_default(); + 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()) @@ -234,14 +249,16 @@ impl Herdr { /// 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) -> JoinHandle> { +fn drain(pipe: Option) -> Receiver> { + 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); } - bytes - }) + let _ = sender.send(bytes); + }); + receiver } /// The `open` action: herdr sets `HERDR_WORKSPACE_ID` from the invocation @@ -355,6 +372,23 @@ esac"#, 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(); From 40b04685e137f669ce26a481a6a3939ca5ffaf20 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 06:42:40 +0000 Subject: [PATCH 15/41] docs(plans): herdr-pond plan - endpoint record, unsupervised endpoints, per-spawn port files --- docs/plans/2609-24-herdr-pond-v1-desk-plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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..144e5f2c 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -173,15 +173,15 @@ 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: `{port, token}` - written atomically, temp + rename), `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). Probe `endpoint` under the lock; live: it is unsupervised (a dead owner's orphan, or another process on that port) - log it and start a fresh serve that replaces the record. Spawn `pond serve --host 127.0.0.1 --port 0 --port-file /serve//owner.port` 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 prints after bind since #311, but `--port-file` stays the readiness signal; 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 --host 127.0.0.1 --port 0 --port-file /serve//desk...port` (one file 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 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`. ### 5.8 The capability probe (shared by 5.6/5.7) From 8db1247d989ebfa58358b225b8af1952f1964497 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 18:35:27 +0000 Subject: [PATCH 16/41] refactor(herdr-pond): talk to pond serve over a Unix socket pond replaces `serve --port-file` with `serve --socket` (#311). The daemon and the desk fallback now spawn `pond serve --socket` at serve//owner.sock and desk...sock, never --host/--port, with POND_HOST/POND_PORT stripped (clap counts env values as given, so an inherited one would conflict with --socket). Readiness is the socket existing and the SELECT 1 probe passing over it, within the same 180s deadline; a pond rejecting --socket with exit 2 is PondTooOld. The client is reqwest's ClientBuilder::unix_socket (no new dependency or feature), one client per socket, Host: localhost. The endpoint record is {socket, token}. A missing or refusing socket is is_connect and fails over; timeouts stay request errors. FakePond now serves on a UnixListener and the fake pond scripts answer by symlinking --socket to it. --- docs/plans/2609-24-herdr-pond-v1-desk-plan.md | 10 +- packages/herdr-pond/README.md | 6 +- packages/herdr-pond/src/api.rs | 241 ++++++++------ packages/herdr-pond/src/daemon.rs | 148 ++++----- packages/herdr-pond/src/fake_pond.rs | 107 ++++--- packages/herdr-pond/src/main.rs | 2 +- packages/herdr-pond/src/serve.rs | 300 ++++++++++-------- packages/herdr-pond/src/types.rs | 4 +- 8 files changed, 463 insertions(+), 355 deletions(-) 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 144e5f2c..f89fdeac 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -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, token}` - 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}` - written atomically, temp + rename), `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: 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). Probe `endpoint` under the lock; live: it is unsupervised (a dead owner's orphan, or another process on that port) - log it and start a fresh serve that replaces the record. Spawn `pond serve --host 127.0.0.1 --port 0 --port-file /serve//owner.port` 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 prints after bind since #311, but `--port-file` stays the readiness signal; 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. +2. **`--owner`** (the detached watchdog): re-acquire the flock, non-blocking, and HOLD it for the process lifetime (lost: exit - another owner won). Probe `endpoint` under the lock; live: it is unsupervised (a dead owner's orphan) - log it and start a fresh serve that replaces the record. Remove any leftover `owner.sock` (a dead serve's, or that orphan's, so only the new child can answer there), then spawn `pond serve --socket /serve//owner.sock` as a waited-on child - never `--host`/`--port`, and with `POND_HOST`/`POND_PORT` stripped from its env (clap counts an env value as given, so an inherited one would conflict with `--socket`) - 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 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 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: `--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 /serve//desk...port` (one file 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 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 - 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) diff --git a/packages/herdr-pond/README.md b/packages/herdr-pond/README.md index 5411209e..a3ad0135 100644 --- a/packages/herdr-pond/README.md +++ b/packages/herdr-pond/README.md @@ -8,7 +8,7 @@ A [herdr](https://herdr.dev) plugin for pond: ## 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 --port-file` ([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. +- 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 @@ -48,8 +48,8 @@ herdr's PATH is fixed when the herdr server starts. If `pond` is not on it, set ## How it runs -- Each herdr server starts one `pond serve` in the background at startup, bound to `127.0.0.1` on a free port, and stops it when that server exits. It is a personal localhost server; 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 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. +- 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. - 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. - 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. - The transcript view is conversation only: user and assistant text. Tool calls and results stay reachable through `pond_sql` and `pond_get_session`. diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs index 93857d65..ce01bf86 100644 --- a/packages/herdr-pond/src/api.rs +++ b/packages/herdr-pond/src/api.rs @@ -1,7 +1,8 @@ -//! The HTTP [`Api`] implementation over `pond serve` +//! 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; @@ -21,6 +22,8 @@ use crate::types::{ 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 @@ -30,57 +33,72 @@ 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); -/// Loopback only: an inherited `HTTP_PROXY` must never see desk traffic. -pub(crate) fn client() -> anyhow::Result { - Ok(reqwest::Client::builder() - .no_proxy() - .connect_timeout(CONNECT_TIMEOUT) - .build()?) -} - pub(crate) fn sql_deadline(timeout_seconds: u64) -> Duration { Duration::from_secs(timeout_seconds) + CLIENT_SLACK } -/// One request against a known base URL. -pub(crate) async fn post( - client: &reqwest::Client, - base_url: &str, - path: &str, - body: &B, - deadline: Duration, -) -> Result -where - B: Serialize + ?Sized, - T: DeserializeOwned, -{ - let response = client - .post(format!("{base_url}{path}")) - .json(body) - .timeout(deadline) - .send() - .await - .map_err(transport)?; - let status = response.status().as_u16(); - let body = response.text().await.map_err(transport)?; - decode(path, status, &body) +/// One `pond serve --socket` path and a client bound to it. +#[derive(Debug, Clone)] +pub(crate) struct Socket { + pub path: PathBuf, + client: reqwest::Client, } -/// Only a failed connect 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", so the cause chain is appended. -fn transport(error: reqwest::Error) -> ApiError { - let mut message = error.to_string(); - 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) +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) + } } } @@ -107,17 +125,17 @@ fn decode(path: &str, status: u16, body: &str) -> Result, + serve: Option, fallback: Option, } -/// A URL that just refused a connection, and why. +/// A socket that just refused a connection, and why. struct Stale { - url: String, + socket: PathBuf, reason: String, } @@ -125,7 +143,6 @@ struct Stale { /// 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 { - client: reqwest::Client, /// 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, @@ -135,10 +152,12 @@ struct Resolver { impl Resolver { /// Runs with `link` locked, so concurrent callers queue behind one /// resolution and then reuse its result. - async fn resolve(&self, stale: Option) -> Result { + async fn resolve(&self, stale: Option) -> Result { let mut link = self.link.lock().await; - if let Some(current) = &link.base_url - && stale.as_ref().is_none_or(|stale| *current != stale.url) + if let Some(current) = &link.serve + && stale + .as_ref() + .is_none_or(|stale| current.path != stale.socket) { return Ok(current.clone()); } @@ -146,15 +165,15 @@ impl Resolver { .origin .as_ref() .map_err(|error| ApiError::Unreachable(error.clone()))?; - let connection = serve::connect(&self.client, origin, link.fallback.take()).await?; + let connection = serve::connect(origin, link.fallback.take()).await?; link.fallback = connection.fallback; - link.base_url = Some(connection.base_url.clone()); + link.serve = Some(connection.socket.clone()); if let Some(stale) = stale - && connection.base_url == stale.url + && connection.socket.path == stale.socket { return Err(ApiError::Unreachable(stale.reason)); } - Ok(connection.base_url) + Ok(connection.socket) } } @@ -164,23 +183,22 @@ pub(crate) struct HttpApi { } impl HttpApi { - pub(crate) fn from_env() -> anyhow::Result { - Ok(Self { + pub(crate) fn from_env() -> Self { + Self { resolver: Arc::new(Resolver { - client: client()?, 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 { + async fn resolve(&self, stale: Option) -> Result { if stale.is_none() { - let current = self.resolver.link.lock().await.base_url.clone(); - if let Some(url) = current { - return Ok(url); + let current = self.resolver.link.lock().await.serve.clone(); + if let Some(socket) = current { + return Ok(socket); } } let resolver = Arc::clone(&self.resolver); @@ -193,17 +211,22 @@ impl HttpApi { /// 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, path: &str, body: &B, deadline: Duration) -> Result + async fn post(&self, route: &str, body: &B, deadline: Duration) -> Result where B: Serialize + ?Sized + Sync, T: DeserializeOwned, { - let client = &self.resolver.client; - let url = self.resolve(None).await?; - match post(client, &url, path, body, deadline).await { + let socket = self.resolve(None).await?; + match socket.post(route, body, deadline).await { Err(ApiError::Unreachable(reason)) => { - let retry = self.resolve(Some(Stale { url, reason })).await?; - post(client, &retry, path, body, deadline).await + let stale = Stale { + socket: socket.path, + reason, + }; + self.resolve(Some(stale)) + .await? + .post(route, body, deadline) + .await } other => other, } @@ -289,18 +312,18 @@ mod tests { use super::*; use crate::fake_pond::{ - FakePond, Reply, Sandbox, dead_url, endpoint, golden, ts, write_script, + 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 `base_url` if given. - fn api(sandbox: &Sandbox, base_url: Option<&str>) -> HttpApi { + /// An api resolving through `sandbox`'s state, pinned to `serve` if given. + fn api(sandbox: &Sandbox, serve: Option) -> HttpApi { HttpApi { resolver: Arc::new(Resolver { - client: client().unwrap(), origin: Ok(sandbox.origin()), link: Mutex::new(Link { - base_url: base_url.map(str::to_owned), + serve, fallback: None, }), }), @@ -308,14 +331,14 @@ mod tests { } } - /// Pinned to `base_url`, with a `pond_bin` that points nowhere, so no + /// Pinned to `serve`, with a `pond_bin` that points nowhere, so no /// re-resolution can reach a real pond. - fn api_at(base_url: &str, sandbox: &Sandbox) -> HttpApi { + fn api_at(serve: Socket, sandbox: &Sandbox) -> HttpApi { sandbox.write_config(&format!( "pond_bin = \"{}\"\n", sandbox.path("bin/no-pond").display() )); - api(sandbox, Some(base_url)) + api(sandbox, Some(serve)) } /// Answers the probe and the preview query. @@ -345,7 +368,7 @@ mod tests { Reply::json(golden::SEARCH), ) .await; - let api = api_at(&pond.base_url, &sandbox); + 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")), @@ -363,6 +386,7 @@ mod tests { 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); @@ -380,7 +404,7 @@ mod tests { Reply::json(golden::SEARCH), ) .await; - let api = api_at(&pond.base_url, &sandbox); + let api = api_at(pond.connect(), &sandbox); assert!(api.hydrate(Vec::new()).await.unwrap().is_empty()); assert!(pond.recorded().is_empty(), "empty hydrate sent a request"); @@ -413,7 +437,7 @@ mod tests { Reply::json(golden::SEARCH), ) .await; - let api = api_at(&pond.base_url, &sandbox); + 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(); @@ -439,7 +463,7 @@ mod tests { 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.base_url, &sandbox); + let api = api_at(pond.connect(), &sandbox); let scope = ListingScope { project: Some("/pj/pond".to_owned()), since: None, @@ -472,7 +496,7 @@ mod tests { _ => Reply::plain(404, ""), }) .await; - let api = api_at(&pond.base_url, &sandbox); + let api = api_at(pond.connect(), &sandbox); let Err(ApiError::Pond { code, message }) = api.preview("preview_error".to_owned()).await else { @@ -509,10 +533,10 @@ mod tests { let ready = preview_pond().await; write_endpoint( &sandbox.origin().dir.endpoint(), - &endpoint(ready.port(), "t"), + &endpoint(&ready.socket, "t"), ) .unwrap(); - let api = api_at(&stalled.base_url, &sandbox); + 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)) @@ -527,6 +551,35 @@ mod tests { ); } + #[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(); @@ -542,8 +595,8 @@ mod tests { Reply::json(golden::SEARCH), ) .await; - write_endpoint(&endpoint_path, &endpoint(stalling.port(), "t")).unwrap(); - let api = api_at(&dead_url(), &sandbox); + 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)) @@ -553,7 +606,7 @@ mod tests { drop(stalling); tokio::time::sleep(Duration::from_millis(50)).await; let ready = preview_pond().await; - write_endpoint(&endpoint_path, &endpoint(ready.port(), "t")).unwrap(); + 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"); } @@ -567,11 +620,11 @@ mod tests { &format!( r#"printf '%s\n' "$*" >> '{calls}' sleep 0.3 -eval "port_file=\${{$#}}" -printf '%s' '{addr}' > "$port_file.tmp" && mv "$port_file.tmp" "$port_file" +eval "socket=\${{$#}}" +ln -s '{target}' "$socket" exec sleep 30"#, calls = sandbox.path("calls").display(), - addr = pond.addr(), + target = pond.socket.display(), ), ); sandbox.write_config(&format!("pond_bin = \"{}\"\n", script.display())); @@ -604,7 +657,7 @@ exec sleep 30"#, &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(&dead_url(), &sandbox); + let api = api_at(missing_socket(), &sandbox); let live = api.live_agents().await.unwrap(); assert_eq!(live.len(), 1); assert!(live[0].matches("abc")); diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index 8a4e4d6c..bb14ff2f 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -17,15 +17,14 @@ use std::time::{Duration, Instant}; use anyhow::bail; use chrono::Utc; -use crate::api::{QUERY_TIMEOUT_SECS, SEARCH_PATH, SQL_PATH, client, post, sql_deadline}; +use crate::api::{QUERY_TIMEOUT_SECS, SEARCH_PATH, SQL_PATH, Socket, sql_deadline}; use crate::config::{cap_log, log_line, try_lock}; use crate::serve::{ - Endpoint, PORT_DEADLINE, ServeChild, ServeDir, live_endpoint, probe, remove_endpoint_if_owned, + Endpoint, READY_DEADLINE, ServeChild, ServeDir, live_endpoint, remove_endpoint_if_owned, retire, write_endpoint, }; use crate::types::{ - ApiError, LISTING_ROWS, ListingScope, SearchRequest, SearchResponse, SqlRequest, SqlResponse, - listing_sql, + LISTING_ROWS, ListingScope, SearchRequest, SearchResponse, SqlRequest, SqlResponse, listing_sql, }; use crate::{herdr, runtime, shutdown_signal}; @@ -36,11 +35,7 @@ struct Timing { /// for the old one to close before binding: only misses spanning longer /// than this mean herdr is gone. handoff_window: Duration, - port_deadline: Duration, - /// A serve that has just bound may still be settling, so a failed - /// capability probe is retried for this long. - probe_window: Duration, - probe_retry: Duration, + ready_deadline: Duration, /// The historical 47-300s cold FTS load is paid here, not by the desk. warmup_deadline: Duration, grace: Duration, @@ -50,9 +45,7 @@ const TIMING: Timing = Timing { tick: Duration::from_millis(500), liveness_every: Duration::from_secs(20), handoff_window: Duration::from_secs(10), - port_deadline: PORT_DEADLINE, - probe_window: Duration::from_secs(30), - probe_retry: Duration::from_secs(5), + ready_deadline: READY_DEADLINE, warmup_deadline: Duration::from_secs(300), grace: Duration::from_secs(10), }; @@ -130,27 +123,27 @@ async fn own( let Some(_lock) = try_lock(&dir.lock())? else { return Ok(()); }; - let client = client()?; - if let Some(base_url) = live_endpoint(&client, dir).await { + if let Some(orphan) = live_endpoint(dir).await { log_line( &log, &format!( - "owner: {base_url} answers but no owner supervises it (a dead owner's orphan, \ - or another process on that port) - starting a fresh serve" + "owner: {} answers but no owner supervises it (a dead owner's orphan) - \ + starting a fresh serve", + orphan.path.display() ), ); } let Some(pond) = resolve_pond() else { return Ok(()); }; - let mut serve = ServeChild::spawn(&pond, dir.port_file("owner"), log.clone(), timing.grace)?; + let mut serve = ServeChild::spawn(&pond, dir.socket("owner"), log.clone(), timing.grace)?; log_line( &log, &format!("owner: started {} (pid {})", pond.display(), serve.id()), ); let mut token = None; let reason = tokio::select! { - reason = supervise(&mut serve, &client, dir, timing, &mut token) => reason, + reason = supervise(&mut serve, dir, timing, &mut token) => reason, reason = herdr_gone(socket, &log, timing) => reason, signal = shutdown => format!("received {signal}"), }; @@ -172,65 +165,38 @@ fn random_token() -> String { ) } -/// Publishes the endpoint once serve listens and passes the probe, warms it +/// Publishes the endpoint once serve answers the capability probe, warms it /// up, and returns why the owner must stop. `token` is set on publish. async fn supervise( serve: &mut ServeChild, - client: &reqwest::Client, dir: &ServeDir, timing: &Timing, token: &mut Option, ) -> String { - let addr = match serve.listening(timing.port_deadline).await { - Ok(addr) => addr, + let socket = match serve.ready(timing.ready_deadline).await { + Ok(socket) => socket, Err(error) => return error.to_string(), }; - let base_url = format!("http://{addr}"); - if let Err(reason) = probe_until_ready(serve, client, &base_url, timing).await { - return reason; - } let endpoint = Endpoint { - port: addr.port(), + socket: socket.path.clone(), token: random_token(), }; if let Err(error) = write_endpoint(&dir.endpoint(), &endpoint) { return format!("cannot publish the endpoint: {error}"); } let log = dir.daemon_log(); - log_line(&log, &format!("owner: published {base_url}")); + log_line( + &log, + &format!("owner: published {}", endpoint.socket.display()), + ); *token = Some(endpoint.token); let ((), reason) = tokio::join!( - warm_up(client, &base_url, &log, timing.warmup_deadline), + warm_up(&socket, &log, timing.warmup_deadline), exited(serve, timing.tick) ); reason } -/// Retries within `probe_window`, except for a pond too old for the desk, -/// which no retry fixes. -async fn probe_until_ready( - serve: &mut ServeChild, - client: &reqwest::Client, - base_url: &str, - timing: &Timing, -) -> Result<(), String> { - let started = Instant::now(); - loop { - match probe(client, base_url).await { - Ok(()) => return Ok(()), - Err(error @ ApiError::PondTooOld) => return Err(error.to_string()), - Err(error) if started.elapsed() >= timing.probe_window => { - return Err(format!("capability probe failed: {error}")); - } - Err(_) => {} - } - if let Some(reason) = serve.exited() { - return Err(reason); - } - tokio::time::sleep(timing.probe_retry).await; - } -} - async fn exited(serve: &mut ServeChild, tick: Duration) -> String { loop { if let Some(reason) = serve.exited() { @@ -267,7 +233,7 @@ async fn herdr_gone(socket: &Path, log: &Path, timing: &Timing) -> String { /// 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 search gets what /// is left of `budget`. Failure is not fatal. -async fn warm_up(client: &reqwest::Client, base_url: &str, log: &Path, budget: Duration) { +async fn warm_up(socket: &Socket, log: &Path, budget: Duration) { let started = Instant::now(); let listing = SqlRequest::new( listing_sql(&ListingScope::recent(None, Utc::now())), @@ -277,9 +243,13 @@ async fn warm_up(client: &reqwest::Client, base_url: &str, log: &Path, budget: D let search = SearchRequest::new(WARMUP_QUERY.to_owned(), 1); let listing_deadline = sql_deadline(QUERY_TIMEOUT_SECS); let result = async { - post::<_, SqlResponse>(client, base_url, SQL_PATH, &listing, listing_deadline).await?; + socket + .post::<_, SqlResponse>(SQL_PATH, &listing, listing_deadline) + .await?; let search_deadline = budget.saturating_sub(started.elapsed()); - post::<_, SearchResponse>(client, base_url, SEARCH_PATH, &search, search_deadline).await + socket + .post::<_, SearchResponse>(SEARCH_PATH, &search, search_deadline) + .await } .await; let outcome = match result { @@ -305,16 +275,14 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; - use crate::fake_pond::{FakePond, Reply, Sandbox, alive, endpoint, golden}; + use crate::fake_pond::{FakePond, Reply, Sandbox, alive, endpoint, golden, stale_socket}; use crate::serve::read_endpoint; const FAST: Timing = Timing { tick: Duration::from_millis(20), liveness_every: Duration::from_millis(100), handoff_window: Duration::from_millis(300), - port_deadline: Duration::from_millis(500), - probe_window: Duration::from_millis(300), - probe_retry: Duration::from_millis(20), + ready_deadline: Duration::from_secs(1), warmup_deadline: Duration::from_secs(5), grace: Duration::from_secs(2), }; @@ -352,11 +320,11 @@ mod tests { } } - /// A fake `pond serve` that publishes the fake server's address when + /// 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 addr = publish.then(|| self.pond.addr()); - self.sandbox.fake_serve(addr, after) + let target = publish.then_some(self.pond.socket.as_path()); + self.sandbox.fake_serve(target, after) } async fn own(&self, pond: &Path) -> anyhow::Result<()> { @@ -376,10 +344,16 @@ mod tests { self.sandbox .lines("calls") .iter() - .filter(|line| line.starts_with("serve --host 127.0.0.1 --port 0 --port-file ")) + .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() } @@ -424,6 +398,10 @@ mod tests { 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!( !alive(setup.sandbox.serve_pid()), "pond serve outlived herdr" @@ -495,6 +473,8 @@ mod tests { assert_eq!(setup.endpoint().unwrap().token, "successor"); } + /// The orphan still answers at `owner.sock`, so the fresh serve's + /// readiness must come from the fresh serve, not from the orphan. #[tokio::test] async fn an_unsupervised_live_endpoint_is_replaced() { let setup = Setup::new().await; @@ -503,7 +483,10 @@ mod tests { Reply::json(golden::SEARCH), ) .await; - write_endpoint(&setup.dir.endpoint(), &endpoint(orphan.port(), "orphan")).unwrap(); + 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(); + write_endpoint(&setup.dir.endpoint(), &endpoint(&owner_socket, "orphan")).unwrap(); let pond = setup.fake_pond(true, "exec sleep 30"); let listener = UnixListener::bind(&setup.socket).unwrap(); let herdr_stops = async { @@ -511,12 +494,14 @@ mod tests { setup.endpoint().is_some_and(|e| e.token != "orphan") }) .await; - assert_eq!(setup.endpoint().unwrap().port, setup.pond.port()); + assert_eq!(setup.endpoint().unwrap().socket, owner_socket); drop(listener); }; let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); owner.unwrap(); 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"), "{}", @@ -558,10 +543,10 @@ mod tests { 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!( - setup.log().contains("capability probe failed"), - "{}", - setup.log() + 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())); @@ -585,17 +570,34 @@ mod tests { 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 listen"), "{}", setup.log()); + 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_port_file_is_named_too_old() { + 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 '--port-file' found\" >&2; exit 2", + "echo \"error: unexpected argument '--socket' found\" >&2; exit 2", ); let _listener = UnixListener::bind(&setup.socket).unwrap(); setup.own(&pond).await.unwrap(); diff --git a/packages/herdr-pond/src/fake_pond.rs b/packages/herdr-pond/src/fake_pond.rs index c8f96c83..5f3c3818 100644 --- a/packages/herdr-pond/src/fake_pond.rs +++ b/packages/herdr-pond/src/fake_pond.rs @@ -1,7 +1,8 @@ -//! A canned-response stand-in for `pond serve`, so the HTTP client is tested -//! against real bytes on a real 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. +//! 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)] @@ -15,12 +16,24 @@ use chrono::{DateTime, Utc}; use nix::sys::signal::kill; use nix::unistd::Pid; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; +use tokio::net::{UnixListener, UnixStream}; -use crate::api::{SEARCH_PATH, SQL_PATH}; +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}"#; @@ -97,15 +110,17 @@ impl Reply { #[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; -/// A loopback HTTP/1.1 server answering each request through `router(path, -/// body)`. Every request is recorded, so tests can assert on the SQL sent. +/// 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 base_url: String, + pub socket: PathBuf, requests: Arc>>, task: tokio::task::JoinHandle<()>, } @@ -114,8 +129,8 @@ impl FakePond { pub(crate) async fn start( router: impl Fn(&str, &str) -> Reply + Send + Sync + 'static, ) -> Self { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); + let socket = temp_path("fake"); + let listener = UnixListener::bind(&socket).unwrap(); let requests = Arc::new(Mutex::new(Vec::new())); let router: Arc = Arc::new(router); let recorded = Arc::clone(&requests); @@ -129,7 +144,7 @@ impl FakePond { } }); Self { - base_url, + socket, requests, task, } @@ -156,24 +171,20 @@ impl FakePond { self.requests.lock().unwrap().clone() } - pub(crate) fn port(&self) -> u16 { - self.base_url.rsplit(':').next().unwrap().parse().unwrap() - } - - /// The `host:port` a fake serve publishes through `--port-file`. - pub(crate) fn addr(&self) -> &str { - self.base_url.trim_start_matches("http://") + 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: tokio::net::TcpStream, + mut stream: UnixStream, router: Arc, recorded: Arc>>, ) { @@ -209,9 +220,19 @@ async fn serve_one( .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, 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{}", @@ -232,12 +253,7 @@ pub(crate) struct Sandbox { impl Sandbox { pub(crate) fn new() -> Self { - static NEXT: AtomicUsize = AtomicUsize::new(0); - let root = std::env::temp_dir().join(format!( - "herdr-pond-test-{}-{}", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); + let root = temp_path("test"); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).unwrap(); Self { root } @@ -277,13 +293,12 @@ impl Sandbox { } /// 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, publishes - /// `addr` through `--port-file` when given, then runs `after`. - pub(crate) fn fake_serve(&self, addr: Option<&str>, after: &str) -> PathBuf { - let publish = addr.map_or_else(String::new, |addr| { - format!( - r#"printf '%s' '{addr}' > "$port_file.tmp" && mv "$port_file.tmp" "$port_file""# - ) + /// in `calls` and its pid in `pid`, prints to both streams, 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"), @@ -291,7 +306,7 @@ impl Sandbox { r#"printf '%s\n' "$*" >> '{calls}' echo $$ > '{pid}' echo "serve stdout"; echo "serve stderr" >&2 -eval "port_file=\${{$#}}" +eval "socket=\${{$#}}" {publish} {after}"#, calls = self.path("calls").display(), @@ -322,22 +337,22 @@ pub(crate) fn alive(pid: u32) -> bool { kill(Pid::from_raw(i32::try_from(pid).unwrap()), None).is_ok() } -/// A port nothing listens on: bound, then released. -pub(crate) fn dead_port() -> u16 { - std::net::TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port() +/// A socket path with nothing at it (connect fails with ENOENT). +pub(crate) fn missing_socket() -> Socket { + Socket::new(temp_path("missing")).unwrap() } -pub(crate) fn dead_url() -> String { - format!("http://127.0.0.1:{}", dead_port()) +/// 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() } -pub(crate) fn endpoint(port: u16, token: &str) -> Endpoint { +pub(crate) fn endpoint(socket: &Path, token: &str) -> Endpoint { Endpoint { - port, + socket: socket.to_path_buf(), token: token.to_owned(), } } diff --git a/packages/herdr-pond/src/main.rs b/packages/herdr-pond/src/main.rs index 2dafd367..b706d805 100644 --- a/packages/herdr-pond/src/main.rs +++ b/packages/herdr-pond/src/main.rs @@ -78,7 +78,7 @@ fn desk_main() -> anyhow::Result<()> { let context = DeskContext { project: herdr::context_project(), }; - let api = Arc::new(api::HttpApi::from_env()?); + 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 index 161054c9..9cd0bc0c 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -4,7 +4,6 @@ //! shared with the daemon. use std::fs; -use std::net::SocketAddr; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::process::{Child, Command}; @@ -15,20 +14,23 @@ use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; use serde::{Deserialize, Serialize}; -use crate::api::{SQL_PATH, post, sql_deadline}; +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 PORT_DEADLINE: Duration = Duration::from_secs(180); +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 PORT_POLL: Duration = Duration::from_millis(100); +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 `--port-file` marks a pond from before the flag. +/// naming `--socket` marks a pond from before the flag. const USAGE_ERROR_EXIT: i32 = 2; +/// Bind env `pond serve` reads for `--host`/`--port`: clap counts an env value +/// as given, so an inherited one would conflict with `--socket`. +const BIND_ENV: [&str; 2] = ["POND_HOST", "POND_PORT"]; /// `STATE_DIR/serve//`: herdr keys plugin state by plugin id only, /// so two herdr servers on one machine share the state dir - everything a @@ -61,8 +63,8 @@ impl ServeDir { self.root.join("daemon.log") } - pub(crate) fn port_file(&self, owner: &str) -> PathBuf { - self.root.join(format!("{owner}.port")) + pub(crate) fn socket(&self, owner: &str) -> PathBuf { + self.root.join(format!("{owner}.sock")) } fn desk_log(&self) -> PathBuf { @@ -88,16 +90,10 @@ fn sockhash(socket: &Path) -> String { /// so an exiting owner never removes a successor's record. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct Endpoint { - pub port: u16, + pub socket: PathBuf, pub token: String, } -impl Endpoint { - pub(crate) fn base_url(&self) -> String { - format!("http://127.0.0.1:{}", self.port) - } -} - /// A missing or malformed record is no record. pub(crate) fn read_endpoint(path: &Path) -> Option { serde_json::from_slice(&fs::read(path).ok()?).ok() @@ -113,37 +109,33 @@ pub(crate) fn remove_endpoint_if_owned(path: &Path, token: &str) -> bool { && fs::remove_file(path).is_ok() } -/// The published endpoint's base URL, if it answers the probe. -pub(crate) async fn live_endpoint(client: &reqwest::Client, dir: &ServeDir) -> Option { - let base_url = read_endpoint(&dir.endpoint())?.base_url(); - probe(client, &base_url).await.ok().map(|()| base_url) +/// The published endpoint's socket, if it answers the probe. +pub(crate) async fn live_endpoint(dir: &ServeDir) -> Option { + let socket = Socket::new(read_endpoint(&dir.endpoint())?.socket).ok()?; + probe(&socket).await.ok().map(|()| 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(client: &reqwest::Client, base_url: &str) -> Result<(), ApiError> { +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 = post(client, base_url, SQL_PATH, &request, deadline).await?; + let response: SqlResponse = socket.post(SQL_PATH, &request, deadline).await?; if response.rows.is_empty() { return Err(ApiError::Decode(format!( - "{base_url} answered the readiness probe with no rows" + "{} answered the readiness probe with no rows", + socket.path.display() ))); } Ok(()) } -/// The address from a `--port-file` (`host:port`, written atomically after bind). -pub(crate) fn read_port_file(path: &Path) -> Option { - fs::read_to_string(path).ok()?.trim().parse().ok() -} - -/// A spawned `pond serve`, terminated (and its port file removed) on drop. +/// 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, - port_file: PathBuf, + socket: PathBuf, log: PathBuf, /// Where this child's output starts in the shared `log`. log_start: u64, @@ -151,26 +143,24 @@ pub(crate) struct ServeChild { } impl ServeChild { - /// `pond serve` bound to loopback on a free port. `--host` is explicit - /// because an inherited `POND_HOST` would otherwise rebind it; stdio goes - /// to `log` because serve's output would corrupt the TUI or pin a herdr slot. + /// `pond serve --socket `; stdio goes to `log` because serve's + /// output would corrupt the TUI or pin a herdr slot. A leftover socket at + /// the path - a dead serve's, or an unsupervised orphan's - is removed + /// first, so only this child can answer there. pub(crate) fn spawn( pond: &Path, - port_file: PathBuf, + socket: PathBuf, log: PathBuf, grace: Duration, ) -> std::io::Result { - let _ = fs::remove_file(&port_file); - let mut command = Command::new(pond); - command - .args(["serve", "--host", "127.0.0.1", "--port", "0", "--port-file"]) - .arg(&port_file); + 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, - port_file, + socket, log, log_start, grace, @@ -190,15 +180,23 @@ impl ServeChild { } } - /// Waits for serve to bind and publish its port. - pub(crate) async fn listening(&mut self, deadline: Duration) -> Result { + /// 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 { + let socket = Socket::new(self.socket.clone())?; let started = Instant::now(); + let mut last_probe = String::new(); loop { - if let Some(addr) = read_port_file(&self.port_file) { - return Ok(addr); + if self.socket.exists() { + match probe(&socket).await { + Ok(()) => return Ok(socket), + Err(ApiError::PondTooOld) => return Err(ApiError::PondTooOld), + Err(error) => last_probe = format!(" (last probe: {error})"), + } } if let Ok(Some(status)) = self.child.try_wait() { - if status.code() == Some(USAGE_ERROR_EXIT) && self.rejected_port_file() { + if status.code() == Some(USAGE_ERROR_EXIT) && self.rejected_socket_flag() { return Err(ApiError::PondTooOld); } return Err(ApiError::Unreachable(format!( @@ -208,21 +206,22 @@ impl ServeChild { } if started.elapsed() > deadline { return Err(ApiError::Unreachable(format!( - "pond serve did not listen within {}s - see {}", + "pond serve did not answer on {} within {}s{last_probe} - see {}", + self.socket.display(), deadline.as_secs(), self.log.display() ))); } - tokio::time::sleep(PORT_POLL).await; + tokio::time::sleep(READY_POLL).await; } } - fn rejected_port_file(&self) -> bool { + 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("--port-file")) + .is_some_and(|output| String::from_utf8_lossy(output).contains("--socket")) }) } } @@ -230,8 +229,17 @@ impl ServeChild { impl Drop for ServeChild { fn drop(&mut self) { terminate(&mut self.child, self.grace); - let _ = fs::remove_file(&self.port_file); + let _ = fs::remove_file(&self.socket); + } +} + +fn serve_command(pond: &Path, socket: &Path) -> Command { + let mut command = Command::new(pond); + command.args(["serve", "--socket"]).arg(socket); + for var in BIND_ENV { + command.env_remove(var); } + command } /// Drops `serve` on the blocking pool; await the handle to know it is gone. @@ -263,7 +271,7 @@ fn terminate(child: &mut Child, grace: Duration) { /// graceful exit. A SIGKILLed desk orphans it (accepted v1 risk, README). pub(crate) struct Fallback { serve: ServeChild, - base_url: String, + socket: Socket, } /// Where the desk finds its serve: this herdr server's state plus the plugin @@ -283,67 +291,59 @@ impl Origin { } pub(crate) struct Connection { - pub base_url: String, + pub socket: Socket, pub fallback: Option, } /// 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( - client: &reqwest::Client, origin: &Origin, fallback: Option, ) -> Result { - if let Some(base_url) = live_endpoint(client, &origin.dir).await { + if let Some(socket) = live_endpoint(&origin.dir).await { if let Some(fallback) = fallback { retire(fallback.serve); } return Ok(Connection { - base_url, + socket, fallback: None, }); } if let Some(fallback) = fallback { - if probe(client, &fallback.base_url).await.is_ok() { + if probe(&fallback.socket).await.is_ok() { return Ok(Connection { - base_url: fallback.base_url.clone(), + socket: fallback.socket.clone(), fallback: Some(fallback), }); } retire(fallback.serve); } let fallback = spawn_fallback(origin).await?; - if let Err(error) = probe(client, &fallback.base_url).await { - retire(fallback.serve); - return Err(error); - } Ok(Connection { - base_url: fallback.base_url.clone(), + socket: fallback.socket.clone(), fallback: Some(fallback), }) } async fn spawn_fallback(origin: &Origin) -> Result { - // One file per spawn: a retiring fallback removes its own on drop, while - // its successor may already have published there. + // 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 port_file = origin.dir.port_file(&format!( + 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, port_file, log, FALLBACK_GRACE).map_err(|error| { + let mut serve = ServeChild::spawn(&pond, socket, log, FALLBACK_GRACE).map_err(|error| { ApiError::Unreachable(format!("cannot start {}: {error}", pond.display())) })?; - match serve.listening(PORT_DEADLINE).await { - Ok(addr) => Ok(Fallback { - serve, - base_url: format!("http://{addr}"), - }), + match serve.ready(READY_DEADLINE).await { + Ok(socket) => Ok(Fallback { serve, socket }), Err(error) => { retire(serve); Err(error) @@ -356,9 +356,9 @@ mod tests { #![allow(clippy::expect_used, clippy::unwrap_used)] use super::*; - use crate::api::client; use crate::fake_pond::{ - FakePond, Reply, Sandbox, alive, dead_port, dead_url, endpoint, golden, write_script, + FakePond, Reply, Sandbox, alive, endpoint, golden, missing_socket, stale_socket, + write_script, }; async fn ready_pond() -> FakePond { @@ -369,9 +369,9 @@ mod tests { .await } - /// A fake serve publishing `addr` that stays up. - fn fake_serve(sandbox: &Sandbox, addr: &str) -> Origin { - sandbox.fake_serve(Some(addr), "exec sleep 30"); + /// 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() } @@ -389,11 +389,17 @@ mod tests { } #[test] - fn endpoint_is_removed_only_by_its_owner() { + fn endpoint_round_trips_and_is_removed_only_by_its_owner() { let sandbox = Sandbox::new(); let path = sandbox.path("state/endpoint"); - write_endpoint(&path, &endpoint(9, "mine")).unwrap(); - assert_eq!(read_endpoint(&path), Some(endpoint(9, "mine"))); + 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"}) + ); + 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")); @@ -402,50 +408,45 @@ mod tests { } #[test] - fn malformed_endpoint_or_port_file_is_absent() { + fn malformed_or_port_endpoint_is_absent() { let sandbox = Sandbox::new(); let path = sandbox.path("endpoint"); - for text in ["", "{", r#"{"port":"x"}"#, r#"{"port":1,"pid":2}"#] { + for text in [ + "", + "{", + r#"{"socket":1,"token":"t"}"#, + r#"{"port":1,"token":"t"}"#, + ] { fs::write(&path, text).unwrap(); assert_eq!(read_endpoint(&path), None, "{text}"); assert!(!remove_endpoint_if_owned(&path, "t")); } - fs::write(&path, "127.0.0.1:54321\n").unwrap(); - assert_eq!( - read_port_file(&path), - Some("127.0.0.1:54321".parse().unwrap()) - ); - fs::write(&path, "127.0.0.1:").unwrap(); - assert_eq!(read_port_file(&path), None); } #[tokio::test] async fn probe_tells_ready_from_old_from_dead() { - let client = client().unwrap(); let ready = ready_pond().await; - probe(&client, &ready.base_url).await.unwrap(); + 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(&client, &old.base_url).await, - Err(ApiError::PondTooOld) - ); + assert_eq!(probe(&old.connect()).await, Err(ApiError::PondTooOld)); - assert!(matches!( - probe(&client, &dead_url()).await, - Err(ApiError::Unreachable(_)) - )); + 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 = fake_serve(&sandbox, "127.0.0.1:1"); - write_endpoint(&origin.dir.endpoint(), &endpoint(pond.port(), "t")).unwrap(); - let connection = connect(&client().unwrap(), &origin, None).await.unwrap(); - assert_eq!(connection.base_url, pond.base_url); + 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"); } @@ -454,37 +455,77 @@ mod tests { 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.addr()); - write_endpoint(&origin.dir.endpoint(), &endpoint(dead_port(), "t")).unwrap(); + 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(&client().unwrap(), &origin, None).await.unwrap(); - assert_eq!(connection.base_url, pond.base_url); + 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!( - calls[0].starts_with("serve --host 127.0.0.1 --port 0 --port-file "), - "{calls:?}" + 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(); - let port_file = fallback.serve.port_file.clone(); assert!(alive(pid)); drop(fallback); assert!(!alive(pid), "fallback serve survived the desk"); - assert!(!port_file.exists()); + assert!(fs::symlink_metadata(&socket).is_err(), "socket 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, BIND_ENV); + } + + #[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.addr()); - let client = client().unwrap(); - let first = connect(&client, &origin, None).await.unwrap(); + 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(&client, &origin, first.fallback).await.unwrap(); + 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); } @@ -495,8 +536,7 @@ mod tests { 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(&client().unwrap(), &origin, None).await - else { + let Err(ApiError::Unreachable(reason)) = connect(&origin, None).await else { panic!("expected Unreachable"); }; assert!(reason.contains("desk-serve.log"), "{reason}"); @@ -508,14 +548,14 @@ mod tests { } #[tokio::test] - async fn a_pond_that_rejects_port_file_is_too_old() { + 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 '--port-file' found\" >&2; exit 2", + "echo \"error: unexpected argument '--socket' found\" >&2; exit 2", ); sandbox.write_config(&format!("pond_bin = \"{}\"\n", pond.display())); - let result = connect(&client().unwrap(), &sandbox.origin(), None).await; + let result = connect(&sandbox.origin(), None).await; assert!(matches!(result, Err(ApiError::PondTooOld))); } @@ -530,30 +570,29 @@ mod tests { let origin = sandbox.origin(); let log = origin.dir.desk_log(); fs::create_dir_all(log.parent().unwrap()).unwrap(); - fs::write(&log, "error: unexpected argument '--port-file' found\n").unwrap(); - let Err(ApiError::Unreachable(reason)) = connect(&client().unwrap(), &origin, None).await - else { + 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_port_file() { + async fn a_retired_fallback_keeps_its_successors_socket() { let sandbox = Sandbox::new(); - let client = client().unwrap(); let origin = sandbox.origin(); let first_pond = ready_pond().await; - sandbox.fake_serve(Some(first_pond.addr()), "trap '' TERM; exec sleep 30"); - let first = connect(&client, &origin, None).await.unwrap(); + 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.addr()), "exec sleep 30"); - let second = connect(&client, &origin, first.fallback).await.unwrap(); - assert_eq!(second.base_url, second_pond.base_url); - let port_file = second.fallback.as_ref().unwrap().serve.port_file.clone(); + 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) { @@ -561,7 +600,7 @@ mod tests { tokio::time::sleep(Duration::from_millis(20)).await; } tokio::time::sleep(Duration::from_millis(200)).await; - assert!(port_file.exists(), "the retired fallback removed it"); + assert!(socket.exists(), "the retired fallback removed it"); } #[tokio::test] @@ -569,8 +608,7 @@ mod tests { let sandbox = Sandbox::new(); sandbox.write_config("pond_bin = \"/nonexistent/pond\"\n"); let origin = sandbox.origin(); - let Err(ApiError::Unreachable(reason)) = connect(&client().unwrap(), &origin, None).await - else { + 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 index e29d62dd..b27e8c7e 100644 --- a/packages/herdr-pond/src/types.rs +++ b/packages/herdr-pond/src/types.rs @@ -149,7 +149,7 @@ pub(crate) enum ApiError { 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 --port-file`. + /// The installed pond predates `/v1/x/sql` or `pond serve --socket`. PondTooOld, /// Connection refused (the serve is gone), or no serve could be started. Unreachable(String), @@ -167,7 +167,7 @@ impl fmt::Display for ApiError { 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 --port-file`) - upgrade pond (`brew upgrade pond` / `cargo install pond-db`)", + "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}"), From 021a0b44760a4a885103f0f0cdfbbf65a2ef264a Mon Sep 17 00:00:00 2001 From: Misha Kolesnik Date: Fri, 25 Sep 2026 18:53:32 +0000 Subject: [PATCH 17/41] perf(herdr-pond): narrow concurrent desk hydration, a disk cache, origin hosts and whole-corpus search The one-shot hydration read the wide options and search_text columns for every row of the page's sessions (2.2-6.8s on the live store). It is now three page-scoped, LIMITed queries on their own lanes, running concurrently and rendered as each lands: - titles: first non-empty user message with search_text kept out of WHERE (late materialization reads it only for user rows) - stats: narrow COUNT(*) + MIN(timestamp), the whole-session count and start - hosts: the JSON getter read only at each session's first timestamp, so the machine column is the session's origin host The listing also returns its in-window COUNT(*) and MIN(timestamp). An in-window count is shown as the whole-session count only when the window provably reaches the session's start (all-time listing, or a start already known), since a session resumed inside the window has an in-window first row too. The desk keeps titles, hosts, starts, counts (per last_ts) and the last listing per scope in desk-cache.json in the plugin state dir (bounded, atomic write, unreadable = empty and logged to desk.log), paints it before pond answers, and hydrates only what the cache lacks. The machine column shows the short host name, `this` for this machine and fits the widest name in view. Typed search covers the whole corpus by default; p / t narrow it to this project / the last 14 days, and the header names the active scope. --- packages/herdr-pond/Cargo.toml | 2 +- packages/herdr-pond/src/api.rs | 90 +++- packages/herdr-pond/src/desk/app.rs | 576 ++++++++++++++++++++++---- packages/herdr-pond/src/desk/cache.rs | 346 ++++++++++++++++ packages/herdr-pond/src/desk/mod.rs | 147 +++++-- packages/herdr-pond/src/desk/ui.rs | 153 +++++-- packages/herdr-pond/src/fake_pond.rs | 24 +- packages/herdr-pond/src/main.rs | 4 + packages/herdr-pond/src/types.rs | 176 +++++++- 9 files changed, 1310 insertions(+), 208 deletions(-) create mode 100644 packages/herdr-pond/src/desk/cache.rs diff --git a/packages/herdr-pond/Cargo.toml b/packages/herdr-pond/Cargo.toml index a749110f..dab4bcd1 100644 --- a/packages/herdr-pond/Cargo.toml +++ b/packages/herdr-pond/Cargo.toml @@ -15,7 +15,7 @@ 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"] } +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"] } diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs index ce01bf86..664a85e6 100644 --- a/packages/herdr-pond/src/api.rs +++ b/packages/herdr-pond/src/api.rs @@ -14,9 +14,9 @@ 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, SessionDetail, SessionRow, SqlRequest, - SqlResponse, TranscriptMessage, TranscriptPage, hydrate_sql, listing_sql, page_sql, - preview_sql, + 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"; @@ -242,6 +242,20 @@ impl HttpApi { 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 { @@ -258,18 +272,24 @@ impl Api for HttpApi { }) } - fn hydrate(&self, session_ids: Vec) -> ApiFuture<'_, Vec> { + fn titles(&self, session_ids: Vec) -> ApiFuture<'_, Vec> { Box::pin(async move { - if session_ids.is_empty() { - return Ok(Vec::new()); - } - let query = hydrate_sql(&session_ids); - self.sql(query, session_ids.len(), QUERY_TIMEOUT_SECS) - .await? - .into_rows() + 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 }) } @@ -397,28 +417,52 @@ mod tests { } #[tokio::test] - async fn hydrate_reads_omitted_nulls_as_none() { + async fn hydration_sends_three_bounded_queries_and_reads_omitted_nulls_as_none() { let sandbox = Sandbox::new(); let pond = FakePond::with_sql( - vec![("COUNT(*)", Reply::json(golden::SQL_HYDRATE))], + 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.hydrate(Vec::new()).await.unwrap().is_empty()); - assert!(pond.recorded().is_empty(), "empty hydrate sent a request"); + 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 details = api.hydrate(ids.clone()).await.unwrap(); - assert_eq!(details[0].host.as_deref(), Some("ws-pond-01")); + 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!( - (details[1].title.clone(), details[1].host.clone()), - (None, None) + titles.unwrap()[0].title.as_deref(), + Some("fix the timer re-arm") ); - assert_eq!(details[1].message_count, 3); - let body = &sent(&pond)[0]; - assert_eq!(body["query"], hydrate_sql(&ids)); - assert_eq!(body["limit"], 2); + 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] diff --git a/packages/herdr-pond/src/desk/app.rs b/packages/herdr-pond/src/desk/app.rs index 7fd35041..bdee50b0 100644 --- a/packages/herdr-pond/src/desk/app.rs +++ b/packages/herdr-pond/src/desk/app.rs @@ -12,20 +12,24 @@ use ratatui::text::Line; use ratatui::widgets::ListState; use unicode_width::UnicodeWidthStr; +use super::cache::{Host, Known, SavedListing, Snapshot}; use super::ui; use crate::types::{ ApiError, Cursor, DeskContext, DeskExit, LISTING_ROWS, ListingScope, LiveAgent, PAGE_ROWS, - SearchRequest, SearchResponse, SessionDetail, SessionRow, TranscriptMessage, TranscriptPage, + 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)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(super) enum Lane { Listing, - Hydrate, + Titles, + Stats, + Hosts, Live, Search, Preview, @@ -46,7 +50,9 @@ impl Lane { #[derive(Debug, Clone, PartialEq)] pub(super) enum Call { Listing(ListingScope), - Hydrate(Vec), + Titles(Vec), + Stats(Vec), + Hosts(Vec), Live, Search(SearchRequest), Preview(String), @@ -60,7 +66,9 @@ impl Call { pub(super) fn lane(&self) -> Lane { match self { Self::Listing(_) => Lane::Listing, - Self::Hydrate(_) => Lane::Hydrate, + Self::Titles(_) => Lane::Titles, + Self::Stats(_) => Lane::Stats, + Self::Hosts(_) => Lane::Hosts, Self::Live => Lane::Live, Self::Search(_) => Lane::Search, Self::Preview(_) => Lane::Preview, @@ -80,7 +88,9 @@ impl Call { #[derive(Debug)] pub(super) enum Reply { Listing(Result, ApiError>), - Hydrate(Result, ApiError>), + Titles(Result, ApiError>), + Stats(Result, ApiError>), + Hosts(Result, ApiError>), Live(Result, ApiError>), Search(Result), Preview(Result, ApiError>), @@ -244,11 +254,17 @@ pub(super) struct App { lanes: [LaneState; Lane::COUNT], pub(super) all_projects: bool, pub(super) all_time: bool, - listings: HashMap<(bool, bool), Vec>, - pub(super) details: HashMap, - /// Requested since the last listing fetch, so a refresh re-hydrates counts - /// while the old details stay on screen. - hydrated: HashSet, + /// Typed search covers everything unless narrowed to the project or the + /// listing window. + pub(super) search_project: bool, + pub(super) search_recent: bool, + /// 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, + /// Asked per hydration lane since the last listing landed, so an id the + /// server has no row for is not asked again until the next refresh. + requested: HashMap>, pub(super) live: Vec, pub(super) listing_state: ListState, pub(super) search_state: ListState, @@ -277,9 +293,11 @@ impl App { lanes: Default::default(), all_projects: false, all_time: false, - listings: HashMap::new(), - details: HashMap::new(), - hydrated: HashSet::new(), + search_project: false, + search_recent: false, + listings: Vec::new(), + known: HashMap::new(), + requested: HashMap::new(), live: Vec::new(), listing_state: ListState::default(), search_state: ListState::default(), @@ -297,8 +315,22 @@ impl App { } } + /// 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 { - self.refresh() + let mut effects = self.refresh(); + effects.extend(self.hydrate_visible()); + effects } pub(super) fn lane_loading(&self, lane: Lane) -> bool { @@ -333,10 +365,24 @@ impl App { scope } + pub(super) fn search_scope(&self) -> ListingScope { + let project = self + .search_project + .then(|| self.context.project.clone()) + .flatten(); + let mut scope = ListingScope::recent(project, self.now); + if !self.search_recent { + scope.since = None; + } + scope + } + pub(super) fn listing(&self) -> Option<&[SessionRow]> { + let key = scope_key(&self.scope()); self.listings - .get(&scope_key(&self.scope())) - .map(Vec::as_slice) + .iter() + .find(|listing| (&listing.project, listing.all_time) == (&key.0, key.1)) + .map(|listing| listing.rows.as_slice()) } pub(super) fn rows_len(&self) -> usize { @@ -346,7 +392,7 @@ impl App { } } - fn id_at(&self, index: usize) -> Option<&str> { + pub(super) fn id_at(&self, index: usize) -> Option<&str> { match &self.search { Some(search) => search .response @@ -467,7 +513,7 @@ impl App { } fn fetch_search(&mut self, query: String, delay: Duration) -> Option { - let request = SearchRequest::new(query, SEARCH_LIMIT).within(&self.scope()); + let request = SearchRequest::new(query, SEARCH_LIMIT).within(&self.search_scope()); self.fetch(Call::Search(request), delay) } @@ -483,7 +529,7 @@ impl App { self.size = Size::new(width, height); self.dirty = true; self.resized = true; - self.hydrate_visible().into_iter().collect() + self.hydrate_visible() } /// Re-wraps the pager for the latest size, then fetches more if the new @@ -646,31 +692,77 @@ impl App { } fn selection_changed(&mut self) -> Vec { - let mut effects: Vec = self.hydrate_visible().into_iter().collect(); + let mut effects = self.hydrate_visible(); effects.extend(self.preview_selected(PREVIEW_DEBOUNCE)); effects } - /// One hydrate per visible page: the rows around the selection that this - /// listing has not asked about yet, never the whole listing. - fn hydrate_visible(&mut self) -> Option { - if self.lane_loading(Lane::Hydrate) { - return None; - } + /// 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. A host is read at the session's first message, so it + /// waits for the stats that find that start when the listing cannot. + fn hydrate_visible(&mut self) -> Vec { let len = self.rows_len(); let selected = self.selected_index().unwrap_or(0); let height = usize::from(ui::desk_areas(self.area(), self.preview_open).list.height).max(1); - let window = selected.saturating_sub(height)..(selected + height + 1).min(len); - let missing: Vec = window + let window: Vec = (selected.saturating_sub(height) + ..(selected + height + 1).min(len)) .filter_map(|index| self.id_at(index)) - .filter(|id| !self.hydrated.contains(*id)) .map(str::to_owned) .collect(); - if missing.is_empty() { + let listing = self.search.is_none(); + let titles = self.unknown(Lane::Titles, &window, |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(Lane::Titles, titles.clone(), Call::Titles(titles)), + self.request(Lane::Stats, stats.clone(), Call::Stats(stats)), + self.request( + Lane::Hosts, + hosts.iter().map(|start| start.session_id.clone()).collect(), + 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.requested.get(&lane); + ids.iter() + .filter(|id| asked.is_none_or(|asked| !asked.contains(*id))) + .filter(|id| self.known.get(*id).is_none_or(&need)) + .cloned() + .collect() + } + + fn request(&mut self, lane: Lane, ids: Vec, call: Call) -> Option { + if ids.is_empty() { return None; } - self.hydrated.extend(missing.iter().cloned()); - self.fetch(Call::Hydrate(missing), Duration::ZERO) + self.requested.entry(lane).or_default().extend(ids); + self.fetch(call, Duration::ZERO) } fn preview_selected(&mut self, delay: Duration) -> Option { @@ -720,16 +812,30 @@ impl App { 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 scopes. 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(); + if projects { + self.search_project = !self.search_project; + } else { + self.search_recent = !self.search_recent; + } + let mut effects = self.transition(); + effects.extend(self.fetch_search(query, Duration::ZERO)); + return effects; + } let selected = self.selected_listing_id(); if projects { - if self.context.project.is_none() { - self.toast = Some( - "the desk was opened without a project: already showing all projects" - .to_owned(), - ); - return Vec::new(); - } self.all_projects = !self.all_projects; } else { self.all_time = !self.all_time; @@ -740,11 +846,6 @@ impl App { } else { effects.extend(self.fetch(Call::Listing(self.scope()), Duration::ZERO)); } - if let Some(search) = &mut self.search { - search.response = None; - let query = search.query.clone(); - effects.extend(self.fetch_search(query, Duration::ZERO)); - } effects.extend(self.selection_changed()); effects } @@ -775,9 +876,9 @@ impl App { })]; } let title = self - .details + .known .get(&id) - .and_then(|detail| detail.title.as_deref()) + .and_then(|known| known.title().flatten()) .map_or_else(|| ui::NO_TITLE.to_owned(), ui::one_line); let width = usize::from(self.pager_viewport().width); self.pager = Some(Pager::new(id, title, width)); @@ -830,11 +931,35 @@ impl App { self.dirty = true; match (msg.call, msg.reply) { (Call::Listing(scope), Reply::Listing(result)) => self.on_listing(&scope, result), - (Call::Hydrate(_), Reply::Hydrate(result)) => match result { - Ok(details) => { - self.details - .extend(details.into_iter().map(|d| (d.session_id.clone(), d))); - self.hydrate_visible().into_iter().collect() + (Call::Titles(ids), Reply::Titles(result)) => match result { + Ok(rows) => { + let mut titles: HashMap> = + ids.into_iter().map(|id| (id, None)).collect(); + titles.extend(rows.into_iter().map(|row| (row.session_id, row.title))); + for (id, title) in titles { + self.known.entry(id).or_default().set_title(title); + } + self.hydrate_visible() + } + Err(error) => self.toast(&error), + }, + (Call::Stats(_), Reply::Stats(result)) => match result { + Ok(rows) => { + for row in rows { + let known = self.known.entry(row.session_id).or_default(); + known.set_stats(row.message_count, row.first_ts); + } + self.hydrate_visible() + } + Err(error) => self.toast(&error), + }, + (Call::Hosts(_), Reply::Hosts(result)) => match result { + Ok(rows) => { + for row in rows { + self.known.entry(row.session_id).or_default().host = + Some(row.host.map_or(Host::Unstamped, Host::Stamped)); + } + self.hydrate_visible() } Err(error) => self.toast(&error), }, @@ -889,16 +1014,28 @@ impl App { ) -> Vec { match result { Ok(rows) => { - let current = scope_key(scope) == scope_key(&self.scope()); + 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 == scope_key(&self.scope()); let selected = self.selected_listing_id(); - self.listings.insert(scope_key(scope), rows); + self.listings + .retain(|listing| (&listing.project, listing.all_time) != (&key.0, key.1)); + self.listings.push(SavedListing { + project: key.0, + all_time: key.1, + saved_at: self.now, + rows, + }); if !current { return Vec::new(); } self.fatal = None; - self.hydrated.clear(); + self.requested.clear(); self.restore_listing_selection(selected.as_deref()); - self.hydrate_visible().into_iter().collect() + self.hydrate_visible() } Err(error) => { if self.listings.is_empty() @@ -960,10 +1097,10 @@ impl App { } } -/// Listings are cached per toggle state, not per timestamp, so toggling back -/// is instant even though `since` moves with the clock. -fn scope_key(scope: &ListingScope) -> (bool, bool) { - (scope.project.is_none(), scope.since.is_none()) +/// Listings are cached per project and window kind, not per timestamp, so +/// toggling back is instant even though `since` moves with the clock. +fn scope_key(scope: &ListingScope) -> (Option, bool) { + (scope.project.clone(), scope.since.is_none()) } #[cfg(test)] @@ -979,7 +1116,9 @@ mod tests { use chrono::TimeDelta; use super::*; - use crate::desk::tests::{MockApi, app, key, message, now, press, screen, settle, sql_rows}; + 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}; @@ -994,11 +1133,24 @@ mod tests { 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() } @@ -1026,11 +1178,24 @@ mod tests { 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!( - calls[2], - Call::Hydrate(vec!["s-live".to_owned(), "s-old".to_owned()]) + 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" ); - assert_eq!(calls.len(), 3, "exactly one hydrate for the page"); let screen = screen(&mut app); assert!(screen.contains("msgs = whole-session counts"), "{screen}"); assert!(screen.contains("● ws-pond-01 claude-code"), "{screen}"); @@ -1048,18 +1213,211 @@ mod tests { }; let mut app = opened(&api, 100, 10); let first = api.calls().into_iter().find_map(|call| match call { - Call::Hydrate(ids) => Some(ids), + Call::Titles(ids) => Some(ids), _ => None, }); assert!(first.unwrap().len() < 20, "never the whole listing"); press(&mut app, &api, KeyCode::End); - let Some(Call::Hydrate(ids)) = api.calls().pop() else { + 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 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.requested.clear(); + for id in ["s-live", "s-old"] { + app.known + .entry(id.to_owned()) + .or_default() + .set_stats(5, now() - TimeDelta::days(1)); + } + 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(); @@ -1238,33 +1596,85 @@ mod tests { 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 project_toggle_widens_listing_and_search() { + fn search_covers_everything_until_p_or_t_narrow_it() { let api = MockApi::golden(); - let mut app = opened(&api, 100, 12); + let mut app = opened(&api, 140, 12); type_query(&mut app, &api, "timer"); - let Some(Call::Search(scoped)) = api.calls().pop() else { - panic!("typing searches"); - }; + 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!( - scoped.filters.project, + project.filters.project, Some(ProjectFilter::Contains("/home/me/pj/pond".to_owned())) ); - assert_eq!(scoped.filters.from_date.as_deref(), Some("2026-09-11")); + 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::Enter); + 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')); - let calls = api.calls(); + assert!(screen(&mut app).contains("| searching all projects, last 14 days |")); + + press(&mut app, &api, KeyCode::Esc); assert!( - calls + !app.all_projects && !app.all_time, + "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())) + .any(|call| matches!(call, Call::Listing(s) if s.project.is_none())), + "p in the listing widens the listing" ); - let Some(Call::Search(wide)) = calls.iter().rev().find(|c| matches!(c, Call::Search(_))) - else { - panic!("the search follows the scope"); - }; - assert_eq!(wide.filters.project, None); + } + + #[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] @@ -1277,7 +1687,7 @@ mod tests { type_query(&mut app, &empty_scope, "timer"); let screen_text = screen(&mut app); assert!( - screen_text.contains("nothing searchable in scope"), + screen_text.contains("nothing searchable in scope (searching everything)"), "{screen_text}" ); diff --git a/packages/herdr-pond/src/desk/cache.rs b/packages/herdr-pond/src/desk/cache.rs new file mode 100644 index 00000000..60e87046 --- /dev/null +++ b/packages/herdr-pond/src/desk/cache.rs @@ -0,0 +1,346 @@ +//! What the desk knows about sessions, and the bounded file that carries it +//! between opens (`desk-cache.json` in the plugin state dir). The desk paints +//! from it before the first listing lands and hydrates only what it lacks. 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; + +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; + +/// Titles and hosts never change once read; counts and a missing title hold +/// only for the `last_ts` they were read at. +#[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)] + pub(super) first_ts: Option>, + #[serde(default)] + count: Option, + #[serde(default)] + title: Option, + #[serde(default)] + pub(super) 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 for 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 set_stats(&mut self, messages: u64, first_ts: DateTime<Utc>) { + self.first_ts = Some(first_ts); + self.count = Some(Counted { + last_ts: self.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 = Some(Counted { + last_ts: self.last_ts, + messages: 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>, +} + +#[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::default(), + Err(error) => { + log_line( + &state_dir.join(LOG_FILE), + &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)); + if let Err(error) = written { + log_line( + &state_dir.join(LOG_FILE), + &format!("cannot write {}: {error}", path.display()), + ); + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + 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(), + }) + .collect(); + save(&dir, Snapshot::new(sessions, listings)); + + let loaded = load(&dir); + 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()); + } +} diff --git a/packages/herdr-pond/src/desk/mod.rs b/packages/herdr-pond/src/desk/mod.rs index fa65be3d..0df04887 100644 --- a/packages/herdr-pond/src/desk/mod.rs +++ b/packages/herdr-pond/src/desk/mod.rs @@ -1,7 +1,9 @@ //! The session desk TUI: a runtime that owns the terminal and performs the -//! effects of the pure reducers in `app`, and `ui` to draw their state. +//! 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; @@ -51,11 +53,36 @@ pub(crate) fn run(api: Arc<dyn Api>, context: DeskContext) -> anyhow::Result<Des } /// Ends on quit, jump, `shutdown`, or the event stream ending - a closed or -/// failing stream means the pane is gone. +/// 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> @@ -66,7 +93,6 @@ where { let (tx, mut rx) = mpsc::unbounded_channel(); let mut runner = Runner::new(api, tx); - let mut app = App::new(context, Utc::now(), terminal.size()?); let mut spinner = tokio::time::interval(SPINNER_TICK); spinner.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); tokio::pin!(shutdown); @@ -79,7 +105,7 @@ where } } if app.dirty { - terminal.draw(|frame| ui::render(frame, &mut app))?; + terminal.draw(|frame| ui::render(frame, app))?; app.dirty = false; } effects = tokio::select! { @@ -157,7 +183,9 @@ impl Runner { async fn call_api(api: &dyn Api, call: &Call) -> Reply { match call.clone() { Call::Listing(scope) => Reply::Listing(api.list_sessions(scope).await), - Call::Hydrate(ids) => Reply::Hydrate(api.hydrate(ids).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), @@ -182,11 +210,11 @@ pub(super) mod tests { use serde::de::DeserializeOwned; use super::*; - use crate::fake_pond::golden; + use crate::fake_pond::{Sandbox, golden}; use crate::types::{ ApiError, ApiFuture, Cursor, ListingScope, LiveAgent, PAGE_ROWS, PREVIEW_ROWS, - SearchRequest, SearchResponse, SessionDetail, SessionRow, SqlResponse, TranscriptMessage, - TranscriptPage, + SearchRequest, SearchResponse, SessionHost, SessionRow, SessionStart, SessionStats, + SessionTitle, SqlResponse, TranscriptMessage, TranscriptPage, }; pub(in crate::desk) fn now() -> DateTime<Utc> { @@ -214,7 +242,10 @@ pub(super) mod tests { #[derive(Default)] pub(in crate::desk) struct MockApi { pub(in crate::desk) sessions: Vec<SessionRow>, - pub(in crate::desk) details: Vec<SessionDetail>, + 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>, @@ -228,7 +259,9 @@ pub(super) mod tests { pub(in crate::desk) fn golden() -> Self { Self { sessions: sql_rows(golden::SQL_LISTING), - details: sql_rows(golden::SQL_HYDRATE), + 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(), @@ -267,14 +300,38 @@ pub(super) mod tests { self.reply(Call::Listing(scope), Duration::ZERO, result) } - fn hydrate(&self, session_ids: Vec<String>) -> ApiFuture<'_, Vec<SessionDetail>> { - let details = self - .details + 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(|d| session_ids.contains(&d.session_id)) + .filter(|row| { + starts + .iter() + .any(|start| start.session_id == row.session_id) + }) .cloned() .collect(); - self.reply(Call::Hydrate(session_ids), Duration::ZERO, Ok(details)) + self.reply(Call::Hosts(starts), Duration::ZERO, Ok(rows)) } fn search(&self, request: SearchRequest) -> ApiFuture<'_, SearchResponse> { @@ -323,11 +380,16 @@ pub(super) mod tests { } } - pub(in crate::desk) fn app(width: u16, height: u16) -> App { - let context = DeskContext { + pub(in crate::desk) fn context() -> DeskContext { + DeskContext { project: Some("/home/me/pj/pond".to_owned()), - }; - App::new(context, now(), Size::new(width, height)) + 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 @@ -489,18 +551,51 @@ pub(super) mod tests { 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), - DeskContext::default(), - events, - shutdown, + 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; - (exit, buffer_text(terminal.backend())) + 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] diff --git a/packages/herdr-pond/src/desk/ui.rs b/packages/herdr-pond/src/desk/ui.rs index 016277ff..4bead181 100644 --- a/packages/herdr-pond/src/desk/ui.rs +++ b/packages/herdr-pond/src/desk/ui.rs @@ -13,11 +13,18 @@ use ratatui::widgets::{ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::app::{App, Lane}; +use super::cache::Host; use crate::types::{LISTING_WINDOW_DAYS, SearchSession, SessionRow, TranscriptMessage}; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const TAB_STOP: usize = 4; -const MACHINE: usize = 10; +/// The machine column fits the widest name in view, between these bounds; +/// below [`SIDE_BY_SIDE_MIN_WIDTH`] it keeps to the narrow cap. +const MACHINE_MIN: usize = 7; +const MACHINE_NARROW: usize = 10; +const MACHINE_WIDE: usize = 16; +const THIS_MACHINE: &str = "this"; +const UNSTAMPED: &str = "local?"; const ADAPTER: usize = 12; const AGE: usize = 4; const COUNT: usize = 7; @@ -126,8 +133,9 @@ 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); - frame.render_widget(Paragraph::new(column_header()).dim(), areas.columns); - render_rows(frame, app, areas.list); + 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); } @@ -142,26 +150,41 @@ fn window_label(app: &App) -> String { } } +/// The typed-search scope in words: the whole corpus unless `p` or `t` +/// narrowed it. +pub(super) 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 scope = app.scope(); - let count = match &app.search { - Some(search) => search.response.as_ref().map_or_else( - || "searching".to_owned(), - |r| format!("{} sessions match", r.sessions.len()), + 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 => app.listing().map_or_else( - || "loading".to_owned(), - |rows| format!("{} sessions", rows.len()), + 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(format!( - " | {count} | {} | {} | msgs = whole-session counts", - 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) { @@ -190,18 +213,18 @@ fn render_input(frame: &mut Frame, app: &App, area: Rect) { } } -fn column_header() -> String { +fn column_header(machine: usize) -> String { format!( " {} {} {:>AGE$} {:>COUNT$} title", - fit("machine", MACHINE), + fit("machine", machine), fit("adapter", ADAPTER), "age", "msgs" ) } -fn render_rows(frame: &mut Frame, app: &mut App, area: Rect) { - let items = match row_items(app) { +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( @@ -220,7 +243,7 @@ fn render_rows(frame: &mut Frame, app: &mut App, area: Rect) { } /// The rows of the current view, or the sentence that stands in for them. -fn row_items(app: &App) -> Result<Vec<ListItem<'static>>, String> { +fn row_items(app: &App, machine: usize) -> Result<Vec<ListItem<'static>>, String> { let project = app .scope() .project @@ -229,8 +252,8 @@ fn row_items(app: &App) -> Result<Vec<ListItem<'static>>, String> { 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 ({project}, {}) excluded every message before search ran - p all projects, t all time", - window_label(app) + "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", @@ -239,7 +262,7 @@ fn row_items(app: &App) -> Result<Vec<ListItem<'static>>, String> { Some(response) => Ok(response .sessions .iter() - .map(|session| search_item(app, session)) + .map(|session| search_item(app, session, machine)) .collect()), }; } @@ -253,20 +276,56 @@ fn row_items(app: &App) -> Result<Vec<ListItem<'static>>, String> { "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)).collect()), + Some(rows) => Ok(rows + .iter() + .map(|row| listing_item(app, row, machine)) + .collect()), } } -fn machine(app: &App, session_id: &str) -> Span<'static> { - match app.details.get(session_id) { - Some(detail) => match &detail.host { - Some(host) => Span::raw(fit(host, MACHINE)), - None => Span::raw(fit("local?", MACHINE)).dim(), - }, - None => Span::raw(fit("", MACHINE)), +/// 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| known.host.as_ref()) + { + 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(""), } } +pub(super) fn machine_width(app: &App, list_width: u16) -> usize { + let cap = if list_width >= SIDE_BY_SIDE_MIN_WIDTH { + MACHINE_WIDE + } else { + MACHINE_NARROW + }; + (0..app.rows_len()) + .filter_map(|index| app.id_at(index)) + .map(|id| machine_label(app, id).width()) + .max() + .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) @@ -275,19 +334,19 @@ fn glyph(app: &App, session_id: &str) -> Span<'static> { } } -fn listing_item(app: &App, row: &SessionRow) -> ListItem<'static> { - let detail = app.details.get(&row.session_id); - let count = detail.map_or_else(String::new, |d| d.message_count.to_string()); - let title = match detail { - Some(detail) => detail - .title - .as_deref() - .map_or_else(|| NO_TITLE.dim(), |t| Span::raw(one_line(t))), +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(app, &row.session_id, machine_width), Span::raw(format!( " {} {:>AGE$} {:>COUNT$} ", fit(&row.source_agent, ADAPTER), @@ -298,7 +357,7 @@ fn listing_item(app: &App, row: &SessionRow) -> ListItem<'static> { ])) } -fn search_item(app: &App, session: &SearchSession) -> ListItem<'static> { +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 @@ -310,7 +369,7 @@ fn search_item(app: &App, session: &SearchSession) -> ListItem<'static> { ); ListItem::new(Line::from(vec![ glyph(app, &session.session_id), - machine(app, &session.session_id), + machine(app, &session.session_id, machine_width), Span::raw(format!( " {} {:>AGE$} {:>COUNT$} {snippet}", fit(&session.source_agent, ADAPTER), @@ -354,6 +413,8 @@ fn render_preview(frame: &mut Frame, app: &App, area: Rect) { fn footer(app: &App) -> Line<'static> { let help = if app.typing { "enter done esc clear up/down select" + } else if app.search.is_some() { + "/ edit esc back enter open space preview p project/everything t 14 days/any q quit" } else { "/ search enter open space preview p projects t time r refresh q quit" }; diff --git a/packages/herdr-pond/src/fake_pond.rs b/packages/herdr-pond/src/fake_pond.rs index 5f3c3818..2f639273 100644 --- a/packages/herdr-pond/src/fake_pond.rs +++ b/packages/herdr-pond/src/fake_pond.rs @@ -38,17 +38,27 @@ fn temp_path(kind: &str) -> PathBuf { 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","source_agent","project"],"rows":[ - {"session_id":"s-live","last_ts":"2026-09-25T04:00:02.384123Z","source_agent":"claude-code","project":"/home/me/pj/pond"}, - {"session_id":"s-old","last_ts":"2026-09-23T19:29:20.100000Z","source_agent":"codex-cli","project":"/home/me/pj/pond/packages/pond"} + 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}"#; - /// Nulls are omitted: `s-old` has no user message and no host stamp. - pub(crate) const SQL_HYDRATE: &str = r#"{"columns":["session_id","message_count","title","host"],"rows":[ - {"session_id":"s-live","message_count":94,"title":"fix the timer re-arm","host":"ws-pond-01"}, - {"session_id":"s-old","message_count":3} + /// `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"],"rows":[ + {"session_id":"s-live","message_count":94,"first_ts":"2026-09-24T21:10:00.000000Z"}, + {"session_id":"s-old","message_count":3,"first_ts":"2026-09-23T19:20:00.000000Z"} ],"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"}, diff --git a/packages/herdr-pond/src/main.rs b/packages/herdr-pond/src/main.rs index b706d805..073bb04c 100644 --- a/packages/herdr-pond/src/main.rs +++ b/packages/herdr-pond/src/main.rs @@ -77,6 +77,10 @@ fn shutdown_signal() -> std::io::Result<impl Future<Output = &'static str>> { 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)? { diff --git a/packages/herdr-pond/src/types.rs b/packages/herdr-pond/src/types.rs index b27e8c7e..439bda99 100644 --- a/packages/herdr-pond/src/types.rs +++ b/packages/herdr-pond/src/types.rs @@ -2,8 +2,10 @@ //! [`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}; @@ -21,9 +23,13 @@ pub(crate) type ApiFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApiErro /// 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>>; - /// One row per id that exists; order is unspecified. Empty input returns - /// empty without a request. - fn hydrate(&self, session_ids: Vec<String>) -> ApiFuture<'_, Vec<SessionDetail>>; + /// The page-scoped hydration queries: at most one row per id, order + /// unspecified, and empty input returns empty without a request. A + /// session with no user message has no title row. + fn titles(&self, session_ids: Vec<String>) -> ApiFuture<'_, Vec<SessionTitle>>; + fn stats(&self, session_ids: Vec<String>) -> ApiFuture<'_, Vec<SessionStats>>; + /// Each session's origin host, read from its first message only. + 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>>; @@ -54,22 +60,43 @@ impl ListingScope { } } -#[derive(Debug, Clone, PartialEq, Deserialize)] +/// `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 SessionDetail { +pub(crate) struct SessionTitle { pub session_id: String, - /// Whole-session count, not limited to the listing window. - pub message_count: u64, /// First non-empty user message, clipped server-side. #[serde(default)] pub title: Option<String>, +} + +/// Whole-session, whatever the listing window. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct SessionStats { + pub session_id: String, + pub message_count: u64, + pub first_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>, @@ -133,6 +160,10 @@ impl LiveAgent { 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 @@ -250,7 +281,7 @@ impl SearchRequest { } } - /// The listing's scope as search filters; `from_date` is a calendar day. + /// 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), @@ -333,7 +364,8 @@ pub(crate) fn listing_sql(scope: &ListingScope) -> String { )); } format!( - "SELECT session_id, MAX(timestamp) AS last_ts, MIN(source_agent) AS source_agent, \ + "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 "), @@ -341,22 +373,53 @@ pub(crate) fn listing_sql(scope: &ListingScope) -> String { ) } -pub(crate) fn hydrate_sql(session_ids: &[String]) -> String { - let ids = session_ids +/// `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. +pub(crate) fn stats_sql(session_ids: &[String]) -> String { + format!( + "SELECT session_id, COUNT(*) AS message_count, MIN(timestamp) AS first_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(|id| quote(id)) + .map(|start| format!("TIMESTAMP {}", timestamp_literal(start.first_ts))) + .collect::<BTreeSet<_>>() + .into_iter() .collect::<Vec<_>>() .join(", "); format!( - "SELECT session_id, COUNT(*) AS message_count, \ - substr(first_value(search_text ORDER BY timestamp, message_id) \ - FILTER (WHERE role = 'user' AND search_text <> ''), 1, {TITLE_CHARS}) AS title, \ - MAX(json_get_string(options, 'pond', 'ingest', 'host', 'hostname')) AS host \ - FROM messages WHERE session_id IN ({ids}) GROUP BY session_id LIMIT {}", - session_ids.len() + "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 \ @@ -416,9 +479,62 @@ mod tests { 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")); + 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 { @@ -452,14 +568,24 @@ mod tests { 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), - hydrate_sql(&["a".to_owned()]), + 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] @@ -469,9 +595,15 @@ mod tests { assert_eq!(rows.len(), 2); assert_eq!(rows[0].last_ts, ts("2026-09-25T04:00:02.384123Z")); - let details: Vec<SessionDetail> = decode(golden::SQL_HYDRATE).into_rows().unwrap(); - assert_eq!(details[1].host, None); - assert_eq!(details[1].title, None); + 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); let messages: Vec<TranscriptMessage> = decode(golden::SQL_PAGE).into_rows().unwrap(); assert_eq!(messages[0].timestamp, messages[1].timestamp); From c0b0b2b40d143fe17232b6eee23d0a14e014ff75 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 18:53:33 +0000 Subject: [PATCH 18/41] docs(plans): herdr-pond plan - Unix-socket serve replaces --host 127.0.0.1 and --port-file --- docs/plans/2609-24-herdr-pond-v1-desk-plan.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 f89fdeac..840059e7 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -11,8 +11,8 @@ This document is self-contained for fresh implementation agents. Where it cites 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). 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 <path>`, created owner-only 0600, #311) with `POND_HOST`/`POND_PORT` stripped from its env (clap counts an env value as given, which would conflict with `--socket`), 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 <adapter> -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<usize>, timeout_seconds: Option<u64> }`, `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 <path>`**: 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 <path>` (#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. @@ -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,7 +250,7 @@ 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). +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/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. 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. From 639da18dbb9ae38e80427d8e906fdba9b938cdc9 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 18:55:46 +0000 Subject: [PATCH 19/41] docs(plans): park the rowmap session_summaries() table function --- docs/plans/2609-24-herdr-pond-v1-desk-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 840059e7..ceac59f6 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -265,4 +265,4 @@ PR order: PR1 (pond) first - independently reviewable, rides the release train; - **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. - **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. From 01ed23a546d7bea3a0a55d4a85a60547009de06b Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:21:57 +0000 Subject: [PATCH 20/41] fix(herdr-pond): one serve spawn per failed resolution, successor sockets, socket-path limit A failed `pond serve` resolution now answers every caller queued behind it (and any caller within a second) with its error, instead of each one spawning another serve and paying another store open. A socket that refused is used again when `connect` just probed it live, since a successor owner reuses `owner.sock`. A socket path past the platform's sun_path limit is refused before pond is spawned, naming the state dir, rather than failing at bind after a full store open. --- packages/herdr-pond/src/api.rs | 106 +++++++++++++++++++++++-------- packages/herdr-pond/src/serve.rs | 34 ++++++++++ 2 files changed, 112 insertions(+), 28 deletions(-) diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs index 664a85e6..f732b0a4 100644 --- a/packages/herdr-pond/src/api.rs +++ b/packages/herdr-pond/src/api.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde::Serialize; use serde::de::DeserializeOwned; @@ -125,18 +125,17 @@ fn decode<T: DeserializeOwned>(path: &str, status: u16, body: &str) -> Result<T, } } +/// 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<Socket>, fallback: Option<Fallback>, -} - -/// A socket that just refused a connection, and why. -struct Stale { - socket: PathBuf, - reason: String, + failed: Option<(Instant, ApiError)>, } /// Owned by the api and by each resolution task, so resolution survives the @@ -151,29 +150,38 @@ struct Resolver { impl Resolver { /// Runs with `link` locked, so concurrent callers queue behind one - /// resolution and then reuse its result. - async fn resolve(&self, stale: Option<Stale>) -> Result<Socket, ApiError> { + /// 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<PathBuf>) -> Result<Socket, ApiError> { let mut link = self.link.lock().await; if let Some(current) = &link.serve - && stale - .as_ref() - .is_none_or(|stale| current.path != stale.socket) + && 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()))?; - let connection = serve::connect(origin, link.fallback.take()).await?; - link.fallback = connection.fallback; - link.serve = Some(connection.socket.clone()); - if let Some(stale) = stale - && connection.socket.path == stale.socket - { - return Err(ApiError::Unreachable(stale.reason)); + 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) + } } - Ok(connection.socket) } } @@ -194,7 +202,7 @@ impl HttpApi { } /// The current serve, else a resolution run in its own task. - async fn resolve(&self, stale: Option<Stale>) -> Result<Socket, ApiError> { + async fn resolve(&self, stale: Option<PathBuf>) -> Result<Socket, ApiError> { if stale.is_none() { let current = self.resolver.link.lock().await.serve.clone(); if let Some(socket) = current { @@ -218,12 +226,8 @@ impl HttpApi { { let socket = self.resolve(None).await?; match socket.post(route, body, deadline).await { - Err(ApiError::Unreachable(reason)) => { - let stale = Stale { - socket: socket.path, - reason, - }; - self.resolve(Some(stale)) + Err(ApiError::Unreachable(_)) => { + self.resolve(Some(socket.path)) .await? .post(route, body, deadline) .await @@ -344,7 +348,7 @@ mod tests { origin: Ok(sandbox.origin()), link: Mutex::new(Link { serve, - fallback: None, + ..Link::default() }), }), herdr: Herdr::new(sandbox.path("bin/herdr")), @@ -694,6 +698,52 @@ exec sleep 30"#, ); } + #[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(); diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index 9cd0bc0c..56d5bc34 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -31,6 +31,12 @@ const USAGE_ERROR_EXIT: i32 = 2; /// Bind env `pond serve` reads for `--host`/`--port`: clap counts an env value /// as given, so an inherited one would conflict with `--socket`. const BIND_ENV: [&str; 2] = ["POND_HOST", "POND_PORT"]; +/// `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 @@ -153,6 +159,18 @@ impl ServeChild { 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)?; @@ -494,6 +512,22 @@ mod tests { assert_eq!(removed, BIND_ENV); } + #[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"); + } + #[tokio::test] async fn a_socket_that_refuses_is_not_ready() { let sandbox = Sandbox::new(); From 6b38ceccd24021a7b7770db44696664180c9c31d Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:22:05 +0000 Subject: [PATCH 21/41] refactor(herdr-pond): stop stripping POND_HOST/POND_PORT from the serve env pond's `--socket` ignores those env vars (#311), so the strip guarded against nothing. --- packages/herdr-pond/src/serve.rs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index 56d5bc34..77d5232d 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -28,9 +28,6 @@ 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; -/// Bind env `pond serve` reads for `--host`/`--port`: clap counts an env value -/// as given, so an inherited one would conflict with `--socket`. -const BIND_ENV: [&str; 2] = ["POND_HOST", "POND_PORT"]; /// `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")] @@ -254,9 +251,6 @@ impl Drop for ServeChild { fn serve_command(pond: &Path, socket: &Path) -> Command { let mut command = Command::new(pond); command.args(["serve", "--socket"]).arg(socket); - for var in BIND_ENV { - command.env_remove(var); - } command } @@ -500,16 +494,10 @@ mod tests { } #[test] - fn serve_gets_only_the_socket_and_never_the_bind_env() { + fn serve_gets_only_the_socket() { 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, BIND_ENV); } #[test] From a4cb1979dd233348707465c93fd3ababfc57e634 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:22:14 +0000 Subject: [PATCH 22/41] fix(herdr-pond): desk cache, hydration and scope fixes Correctness: - Stats carry the `MAX(timestamp)` they counted up to, so a count read before a newer listing no longer shows or persists as current. - `desk-cache.json` is written owner-only (it holds prompt titles, project paths and hosts), and a cache of another format version is logged to desk.log instead of dropped silently. - The fatal error screen keys on the failed scope having no listing, not on the whole cache being empty. - A failed hydration un-asks its ids, so their rows are asked again. - Toggling to a listing restored from disk shows it at once and refetches it; listings fetched during this run still toggle back instantly. Folded in, since they touch the same paths: - Titles are asked only where they render (listing rows, pager header); the pager header reads the title at draw time. - The hydration window snaps to page-sized blocks, so holding j sends no per-keypress requests. - Listing lookups compare borrowed scope keys and `machine_width` walks the rows directly, instead of cloning per row per frame. - One `Narrowing` type and `scope_for` serve both the listing and search filters; one `on_hydration` handler, `Known::set_host`, and `request` deriving its lane and ids from the call. --- packages/herdr-pond/src/config.rs | 24 +- packages/herdr-pond/src/desk/app.rs | 462 +++++++++++++++++++------- packages/herdr-pond/src/desk/cache.rs | 109 +++++- packages/herdr-pond/src/desk/ui.rs | 62 ++-- packages/herdr-pond/src/fake_pond.rs | 6 +- packages/herdr-pond/src/serve.rs | 2 +- packages/herdr-pond/src/types.rs | 25 +- 7 files changed, 508 insertions(+), 182 deletions(-) diff --git a/packages/herdr-pond/src/config.rs b/packages/herdr-pond/src/config.rs index f106e5e8..b202a19f 100644 --- a/packages/herdr-pond/src/config.rs +++ b/packages/herdr-pond/src/config.rs @@ -4,7 +4,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, Write}; -use std::os::unix::fs::PermissionsExt; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -156,13 +156,21 @@ pub(crate) fn try_lock(path: &Path) -> io::Result<Option<Flock<File>>> { } } -/// Temp file + rename, so a reader never sees a half-written file. -pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { +/// 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); - fs::write(&temp, contents)?; + 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); }) @@ -285,9 +293,13 @@ mod tests { fn atomic_write_replaces_whole_file() { let sandbox = Sandbox::new(); let path = sandbox.path("state/endpoint"); - write_atomic(&path, b"one").unwrap(); - write_atomic(&path, b"two").unwrap(); + 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/desk/app.rs b/packages/herdr-pond/src/desk/app.rs index bdee50b0..8a580fc6 100644 --- a/packages/herdr-pond/src/desk/app.rs +++ b/packages/herdr-pond/src/desk/app.rs @@ -12,7 +12,7 @@ use ratatui::text::Line; use ratatui::widgets::ListState; use unicode_width::UnicodeWidthStr; -use super::cache::{Host, Known, SavedListing, Snapshot}; +use super::cache::{Known, SavedListing, Snapshot}; use super::ui; use crate::types::{ ApiError, Cursor, DeskContext, DeskExit, LISTING_ROWS, ListingScope, LiveAgent, PAGE_ROWS, @@ -83,6 +83,18 @@ impl Call { _ => 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)] @@ -124,6 +136,27 @@ pub(super) enum Effect { struct LaneState { generation: u64, in_flight: Option<Call>, + /// 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<String>, +} + +/// 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)] @@ -184,7 +217,6 @@ pub(super) struct Search { #[derive(Debug)] pub(super) struct Pager { pub(super) session_id: String, - pub(super) title: String, messages: Vec<TranscriptMessage>, starts: Vec<usize>, pub(super) lines: Vec<Line<'static>>, @@ -194,10 +226,9 @@ pub(super) struct Pager { } impl Pager { - fn new(session_id: String, title: String, width: usize) -> Self { + fn new(session_id: String, width: usize) -> Self { Self { session_id, - title, messages: Vec::new(), starts: Vec::new(), lines: Vec::new(), @@ -252,19 +283,14 @@ pub(super) struct App { pub(super) size: Size, epoch: u64, lanes: [LaneState; Lane::COUNT], - pub(super) all_projects: bool, - pub(super) all_time: bool, - /// Typed search covers everything unless narrowed to the project or the - /// listing window. - pub(super) search_project: bool, - pub(super) search_recent: bool, + /// 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<SavedListing>, pub(super) known: HashMap<String, Known>, - /// Asked per hydration lane since the last listing landed, so an id the - /// server has no row for is not asked again until the next refresh. - requested: HashMap<Lane, HashSet<String>>, pub(super) live: Vec<LiveAgent>, pub(super) listing_state: ListState, pub(super) search_state: ListState, @@ -291,13 +317,16 @@ impl App { size, epoch: 0, lanes: Default::default(), - all_projects: false, - all_time: false, - search_project: false, - search_recent: false, + listing_filter: Narrowing { + project: true, + recent: true, + }, + search_filter: Narrowing { + project: false, + recent: false, + }, listings: Vec::new(), known: HashMap::new(), - requested: HashMap::new(), live: Vec::new(), listing_state: ListState::default(), search_state: ListState::default(), @@ -354,35 +383,45 @@ impl App { self.dirty = true; } - pub(super) fn scope(&self) -> ListingScope { - let project = (!self.all_projects) + 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 self.all_time { + 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 { - let project = self - .search_project - .then(|| self.context.project.clone()) + 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(); - let mut scope = ListingScope::recent(project, self.now); - if !self.search_recent { - scope.since = None; - } - scope + (project, !filter.recent) } - pub(super) fn listing(&self) -> Option<&[SessionRow]> { - let key = scope_key(&self.scope()); + fn saved_listing(&self) -> Option<&SavedListing> { + let key = self.listing_key(); self.listings .iter() - .find(|listing| (&listing.project, listing.all_time) == (&key.0, key.1)) - .map(|listing| listing.rows.as_slice()) + .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 { @@ -699,30 +738,37 @@ impl App { /// 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. A host is read at the session's first message, so it - /// waits for the stats that find that start when the listing cannot. + /// 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<Effect> { let len = self.rows_len(); - let selected = self.selected_index().unwrap_or(0); let height = usize::from(ui::desk_areas(self.area(), self.preview_open).list.height).max(1); - let window: Vec<String> = (selected.saturating_sub(height) - ..(selected + height + 1).min(len)) + let base = self.selected_index().unwrap_or(0) / height * height; + let window: Vec<String> = (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 titles = self.unknown(Lane::Titles, &window, |known| known.title().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()) + || (known.host().is_none() && known.first_ts().is_none()) }); let hosts: Vec<SessionStart> = self .unknown(Lane::Hosts, &window, |known| { - known.host.is_none() && known.first_ts.is_some() + known.host().is_none() && known.first_ts().is_some() }) .into_iter() .filter_map(|id| { - let first_ts = self.known.get(&id)?.first_ts?; + let first_ts = self.known.get(&id)?.first_ts()?; Some(SessionStart { session_id: id, first_ts, @@ -730,13 +776,9 @@ impl App { }) .collect(); [ - self.request(Lane::Titles, titles.clone(), Call::Titles(titles)), - self.request(Lane::Stats, stats.clone(), Call::Stats(stats)), - self.request( - Lane::Hosts, - hosts.iter().map(|start| start.session_id.clone()).collect(), - Call::Hosts(hosts), - ), + self.request(Call::Titles(titles)), + self.request(Call::Stats(stats)), + self.request(Call::Hosts(hosts)), ] .into_iter() .flatten() @@ -749,22 +791,29 @@ impl App { if self.lane_loading(lane) { return Vec::new(); } - let asked = self.requested.get(&lane); + let asked = &self.lanes[lane as usize].asked; ids.iter() - .filter(|id| asked.is_none_or(|asked| !asked.contains(*id))) + .filter(|id| !asked.contains(*id)) .filter(|id| self.known.get(*id).is_none_or(&need)) .cloned() .collect() } - fn request(&mut self, lane: Lane, ids: Vec<String>, call: Call) -> Option<Effect> { + fn request(&mut self, call: Call) -> Option<Effect> { + let ids: Vec<String> = call.ids().into_iter().map(str::to_owned).collect(); if ids.is_empty() { return None; } - self.requested.entry(lane).or_default().extend(ids); + 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<Effect> { let wanted = self .selected_id() @@ -814,7 +863,9 @@ impl App { /// `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 scopes. + /// 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<Effect> { if projects && self.context.project.is_none() { self.toast = Some( @@ -825,25 +876,19 @@ impl App { if let Some(search) = &mut self.search { search.response = None; let query = search.query.clone(); - if projects { - self.search_project = !self.search_project; - } else { - self.search_recent = !self.search_recent; - } + 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(); - if projects { - self.all_projects = !self.all_projects; - } else { - self.all_time = !self.all_time; - } + self.listing_filter.toggle(projects); let mut effects = self.transition(); - if self.listing().is_some() { + let fresh = self.saved_listing().map(|listing| listing.fresh); + if fresh.is_some() { self.restore_listing_selection(selected.as_deref()); - } else { + } + if fresh != Some(true) { effects.extend(self.fetch(Call::Listing(self.scope()), Duration::ZERO)); } effects.extend(self.selection_changed()); @@ -875,14 +920,11 @@ impl App { pane_id: agent.pane_id.clone(), })]; } - let title = self - .known - .get(&id) - .and_then(|known| known.title().flatten()) - .map_or_else(|| ui::NO_TITLE.to_owned(), ui::one_line); let width = usize::from(self.pager_viewport().width); - self.pager = Some(Pager::new(id, title, width)); - self.load_more() + 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<Effect> { @@ -931,38 +973,9 @@ impl App { self.dirty = true; match (msg.call, msg.reply) { (Call::Listing(scope), Reply::Listing(result)) => self.on_listing(&scope, result), - (Call::Titles(ids), Reply::Titles(result)) => match result { - Ok(rows) => { - let mut titles: HashMap<String, Option<String>> = - ids.into_iter().map(|id| (id, None)).collect(); - titles.extend(rows.into_iter().map(|row| (row.session_id, row.title))); - for (id, title) in titles { - self.known.entry(id).or_default().set_title(title); - } - self.hydrate_visible() - } - Err(error) => self.toast(&error), - }, - (Call::Stats(_), Reply::Stats(result)) => match result { - Ok(rows) => { - for row in rows { - let known = self.known.entry(row.session_id).or_default(); - known.set_stats(row.message_count, row.first_ts); - } - self.hydrate_visible() - } - Err(error) => self.toast(&error), - }, - (Call::Hosts(_), Reply::Hosts(result)) => match result { - Ok(rows) => { - for row in rows { - self.known.entry(row.session_id).or_default().host = - Some(row.host.map_or(Host::Unstamped, Host::Stamped)); - } - self.hydrate_visible() - } - Err(error) => self.toast(&error), - }, + (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; @@ -1019,27 +1032,28 @@ impl App { known.observe(row, scope.since); } let key = scope_key(scope); - let current = key == scope_key(&self.scope()); + let current = key == self.listing_key(); let selected = self.selected_listing_id(); - self.listings - .retain(|listing| (&listing.project, listing.all_time) != (&key.0, key.1)); + self.listings.retain(|listing| saved_key(listing) != key); self.listings.push(SavedListing { - project: key.0, - all_time: key.1, + 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.requested.clear(); + self.forget_asked(); self.restore_listing_selection(selected.as_deref()); self.hydrate_visible() } Err(error) => { - if self.listings.is_empty() - && matches!(error, ApiError::PondTooOld | ApiError::Unreachable(_)) + 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() @@ -1050,6 +1064,51 @@ impl App { } } + /// Learns what a hydration reply proves. A failed one 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<Effect> { + let learned = match reply { + Reply::Titles(result) => result.map(|rows| { + let mut titles: HashMap<String, Option<String>> = 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 asked = &mut self.lanes[call.lane() as usize].asked; + for id in call.ids() { + asked.remove(id); + } + self.toast(&error) + } + } + } + fn on_search(&mut self, query: &str, result: Result<SearchResponse, ApiError>) -> Vec<Effect> { let Some(search) = self.search.as_mut().filter(|search| search.query == query) else { return Vec::new(); @@ -1097,10 +1156,17 @@ impl App { } } -/// Listings are cached per project and window kind, not per timestamp, so -/// toggling back is instant even though `since` moves with the clock. -fn scope_key(scope: &ListingScope) -> (Option<String>, bool) { - (scope.project.clone(), scope.since.is_none()) +/// 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)] @@ -1230,6 +1296,56 @@ mod tests { 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()); + app.apply(Msg { + generation, + epoch, + call: call.clone(), + reply: Reply::Titles(Err(error.clone())), + }); + assert_eq!(app.toast, Some(error.to_string())); + 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 { @@ -1304,12 +1420,17 @@ mod tests { let api = MockApi::golden(); let mut app = opened(&api, 110, 10); app.known.clear(); - app.requested.clear(); + app.forget_asked(); for id in ["s-live", "s-old"] { app.known .entry(id.to_owned()) .or_default() - .set_stats(5, now() - TimeDelta::days(1)); + .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<Lane> = effects @@ -1447,6 +1568,63 @@ mod tests { 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_verbatim_toasts() { let mut app = opened(&MockApi::golden(), 100, 12); @@ -1550,9 +1728,9 @@ mod tests { !back.contains(&Effect::Cancel(Lane::Listing)), "toggling back cancelled the listing: {back:?}" ); - assert!(!app.all_time); + assert!(app.listing_filter.recent); settle(&mut app, &api, vec![listing]); - assert!(!app.all_time, "the late listing moved the view"); + assert!(app.listing_filter.recent, "the late listing moved the view"); let cached = app.on_event(&key(KeyCode::Char('t'))); assert!( !fetches(&cached) @@ -1590,7 +1768,7 @@ mod tests { app.spinner_visible(), "the desk footer shows the listing load" ); - app.pager = Some(Pager::new("s-old".to_owned(), "t".to_owned(), 80)); + 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()); @@ -1644,7 +1822,7 @@ mod tests { press(&mut app, &api, KeyCode::Esc); assert!( - !app.all_projects && !app.all_time, + app.listing_filter.project && app.listing_filter.recent, "the listing kept its default" ); press(&mut app, &api, KeyCode::Char('p')); @@ -1726,6 +1904,34 @@ mod tests { 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(); @@ -1873,7 +2079,7 @@ mod tests { } fn open_pager(app: &mut App) -> Effect { - app.pager = Some(Pager::new("s-old".to_owned(), "t".to_owned(), 80)); + app.pager = Some(Pager::new("s-old".to_owned(), 80)); let mut effects = app.load_more(); assert_eq!(effects.len(), 1); effects.remove(0) @@ -2016,7 +2222,7 @@ mod tests { 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(), "t".to_owned(), 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))) @@ -2035,7 +2241,11 @@ mod tests { #[test] fn the_pager_frame_is_the_viewport_slice() { let mut app = app(40, 6); - let mut pager = Pager::new("s-old".to_owned(), "fix it".to_owned(), 39); + 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 { diff --git a/packages/herdr-pond/src/desk/cache.rs b/packages/herdr-pond/src/desk/cache.rs index 60e87046..36cf4f7e 100644 --- a/packages/herdr-pond/src/desk/cache.rs +++ b/packages/herdr-pond/src/desk/cache.rs @@ -1,7 +1,6 @@ //! What the desk knows about sessions, and the bounded file that carries it -//! between opens (`desk-cache.json` in the plugin state dir). The desk paints -//! from it before the first listing lands and hydrates only what it lacks. A -//! missing or unreadable file is an empty cache, never an error. +//! 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; @@ -11,7 +10,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::config::{log_line, write_atomic}; -use crate::types::SessionRow; +use crate::types::{SessionRow, SessionStats}; const CACHE_FILE: &str = "desk-cache.json"; const LOG_FILE: &str = "desk.log"; @@ -19,24 +18,26 @@ const LOG_FILE: &str = "desk.log"; 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; counts and a missing title hold /// only for the `last_ts` they were read at. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub(super) struct Known { - /// The newest `last_ts` a listing reported. + /// The newest `last_ts` a listing or stats read reported. #[serde(default)] last_ts: Option<DateTime<Utc>>, /// 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)] - pub(super) first_ts: Option<DateTime<Utc>>, + first_ts: Option<DateTime<Utc>>, #[serde(default)] count: Option<Counted>, #[serde(default)] title: Option<Title>, #[serde(default)] - pub(super) host: Option<Host>, + host: Option<Host>, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -86,11 +87,31 @@ impl Known { )); } - pub(super) fn set_stats(&mut self, messages: u64, first_ts: DateTime<Utc>) { - self.first_ts = Some(first_ts); + 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; the newer count wins. + pub(super) fn set_stats(&mut self, stats: &SessionStats) { + self.first_ts = Some(stats.first_ts); + let seen = Some(stats.last_ts); + if self.count.is_some_and(|counted| counted.last_ts > seen) { + return; + } + self.last_ts = self.last_ts.max(seen); self.count = Some(Counted { - last_ts: self.last_ts, - messages, + last_ts: seen, + messages: stats.message_count, }); } @@ -124,6 +145,9 @@ pub(super) struct SavedListing { 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)] @@ -169,12 +193,23 @@ pub(super) fn load(state_dir: &Path) -> Snapshot { Err(error) if error.kind() == ErrorKind::NotFound => return Snapshot::default(), Err(error) => Err(error.to_string()), }; + let log = state_dir.join(LOG_FILE); match loaded { Ok(snapshot) if snapshot.version == VERSION => snapshot, - Ok(_) => Snapshot::default(), + Ok(snapshot) => { + log_line( + &log, + &format!( + "ignoring {}: version {}, expected {VERSION}", + path.display(), + snapshot.version + ), + ); + Snapshot::default() + } Err(error) => { log_line( - &state_dir.join(LOG_FILE), + &log, &format!("ignoring unreadable {}: {error}", path.display()), ); Snapshot::default() @@ -187,7 +222,7 @@ pub(super) fn save(state_dir: &Path, mut snapshot: Snapshot) { 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)); + .and_then(|json| write_atomic(&path, &json, CACHE_MODE)); if let Err(error) = written { log_line( &state_dir.join(LOG_FILE), @@ -200,6 +235,8 @@ pub(super) fn save(state_dir: &Path, mut snapshot: Snapshot) { mod tests { #![allow(clippy::expect_used, clippy::unwrap_used)] + use std::os::unix::fs::PermissionsExt; + use chrono::TimeDelta; use super::*; @@ -305,11 +342,18 @@ mod tests { 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!( @@ -342,5 +386,42 @@ mod tests { ) .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 newer read moves the session on"); } } diff --git a/packages/herdr-pond/src/desk/ui.rs b/packages/herdr-pond/src/desk/ui.rs index 4bead181..cedd9f55 100644 --- a/packages/herdr-pond/src/desk/ui.rs +++ b/packages/herdr-pond/src/desk/ui.rs @@ -13,16 +13,18 @@ use ratatui::widgets::{ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::app::{App, Lane}; -use super::cache::Host; +use super::cache::{Host, Known}; use crate::types::{LISTING_WINDOW_DAYS, SearchSession, SessionRow, TranscriptMessage}; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const TAB_STOP: usize = 4; -/// The machine column fits the widest name in view, between these bounds; -/// below [`SIDE_BY_SIDE_MIN_WIDTH`] it keeps to the narrow cap. +/// 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; @@ -143,7 +145,7 @@ fn render_desk(frame: &mut Frame, app: &mut App) { } fn window_label(app: &App) -> String { - if app.all_time { + if !app.listing_filter.recent { "all time".to_owned() } else { format!("last {LISTING_WINDOW_DAYS} days") @@ -152,7 +154,7 @@ fn window_label(app: &App) -> String { /// The typed-search scope in words: the whole corpus unless `p` or `t` /// narrowed it. -pub(super) fn search_label(app: &App) -> String { +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") @@ -267,7 +269,7 @@ fn row_items(app: &App, machine: usize) -> Result<Vec<ListItem<'static>>, String }; } match app.listing() { - None if app.lane_loading(Lane::Listing) && app.all_time => { + 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()), @@ -295,11 +297,7 @@ fn machine_label<'a>(app: &'a App, session_id: &str) -> Span<'a> { .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| known.host.as_ref()) - { + 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(), @@ -307,18 +305,28 @@ fn machine_label<'a>(app: &'a App, session_id: &str) -> Span<'a> { } } -pub(super) fn machine_width(app: &App, list_width: u16) -> usize { - let cap = if list_width >= SIDE_BY_SIDE_MIN_WIDTH { +fn machine_width(app: &App, list_width: u16) -> usize { + let cap = if list_width >= MACHINE_WIDE_LIST_MIN { MACHINE_WIDE } else { MACHINE_NARROW }; - (0..app.rows_len()) - .filter_map(|index| app.id_at(index)) - .map(|id| machine_label(app, id).width()) - .max() - .unwrap_or(0) - .clamp(MACHINE_MIN, cap) + 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> { @@ -412,11 +420,14 @@ fn render_preview(frame: &mut Frame, app: &App, area: Rect) { fn footer(app: &App) -> Line<'static> { let help = if app.typing { - "enter done esc clear up/down select" + "enter done esc clear up/down select".to_owned() } else if app.search.is_some() { - "/ edit esc back enter open space preview p project/everything t 14 days/any q quit" + 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" + "/ search enter open space preview p projects t time r refresh q quit".to_owned() }; let mut spans = Vec::new(); if app.spinner_visible() { @@ -435,9 +446,14 @@ fn render_pager(frame: &mut Frame, app: &App) { 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![ - Span::raw(pager.title.clone()).bold(), + title, Span::raw(format!(" | {}", pager.session_id)).dim(), ])), areas.header, diff --git a/packages/herdr-pond/src/fake_pond.rs b/packages/herdr-pond/src/fake_pond.rs index 2f639273..52d04744 100644 --- a/packages/herdr-pond/src/fake_pond.rs +++ b/packages/herdr-pond/src/fake_pond.rs @@ -48,9 +48,9 @@ pub(crate) mod golden { {"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"],"rows":[ - {"session_id":"s-live","message_count":94,"first_ts":"2026-09-24T21:10:00.000000Z"}, - {"session_id":"s-old","message_count":3,"first_ts":"2026-09-23T19:20:00.000000Z"} + 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. diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index 77d5232d..c10f1406 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -104,7 +104,7 @@ pub(crate) fn read_endpoint(path: &Path) -> Option<Endpoint> { 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) + write_atomic(path, &json, 0o666) } pub(crate) fn remove_endpoint_if_owned(path: &Path, token: &str) -> bool { diff --git a/packages/herdr-pond/src/types.rs b/packages/herdr-pond/src/types.rs index 439bda99..1fde55d3 100644 --- a/packages/herdr-pond/src/types.rs +++ b/packages/herdr-pond/src/types.rs @@ -23,9 +23,9 @@ pub(crate) type ApiFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApiErro /// 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: at most one row per id, order - /// unspecified, and empty input returns empty without a request. A - /// session with no user message has no title row. + // The page-scoped hydration queries: at most one row per id, order + // unspecified, and empty input returns empty without a request. + /// A session with no user message has no row. fn titles(&self, session_ids: Vec<String>) -> ApiFuture<'_, Vec<SessionTitle>>; fn stats(&self, session_ids: Vec<String>) -> ApiFuture<'_, Vec<SessionStats>>; /// Each session's origin host, read from its first message only. @@ -80,12 +80,13 @@ pub(crate) struct SessionTitle { pub title: Option<String>, } -/// Whole-session, whatever the listing window. +/// 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)] @@ -182,7 +183,8 @@ pub(crate) enum ApiError { Rejected { status: u16, body: String }, /// The installed pond predates `/v1/x/sql` or `pond serve --socket`. PondTooOld, - /// Connection refused (the serve is gone), or no serve could be started. + /// 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), @@ -386,11 +388,13 @@ pub(crate) fn titles_sql(session_ids: &[String]) -> String { ) } -/// Narrow columns only: the whole-session count and start. +/// 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 \ - FROM messages WHERE session_id IN ({}) GROUP BY session_id LIMIT {}", + "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() ) @@ -503,7 +507,9 @@ mod tests { 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")); + 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")); @@ -604,6 +610,7 @@ mod tests { 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); From 01fb5295aa5ae02ddc703a5287912d35e275069d Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:22:22 +0000 Subject: [PATCH 23/41] test(herdr-pond): dry-run fake scripts past ETXTBSY, assert a real daemon probe outcome A script another test's fork still holds open for writing fails to exec with ETXTBSY; `write_script` now execs it in a dry-run mode until that succeeds. The settle-retry daemon test asserted on log text that no longer exists; it now checks the probes were retried and never gave up. --- packages/herdr-pond/src/daemon.rs | 12 +++++++++++- packages/herdr-pond/src/fake_pond.rs | 22 +++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index bb14ff2f..88c3f339 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -534,7 +534,17 @@ mod tests { }; let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); owner.unwrap(); - assert!(!setup.log().contains("probe failed"), "{}", setup.log()); + 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] diff --git a/packages/herdr-pond/src/fake_pond.rs b/packages/herdr-pond/src/fake_pond.rs index 52d04744..05bb6f84 100644 --- a/packages/herdr-pond/src/fake_pond.rs +++ b/packages/herdr-pond/src/fake_pond.rs @@ -368,9 +368,29 @@ pub(crate) fn endpoint(socket: &Path, token: &str) -> Endpoint { } /// 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{body}\n")).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"; From d9694edc9e27776864bb548ae44f3a3de0855f86 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:22:22 +0000 Subject: [PATCH 24/41] docs(herdr-pond): README covers desk.log, desk-cache.json, the machine label and search scope --- packages/herdr-pond/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/herdr-pond/README.md b/packages/herdr-pond/README.md index a3ad0135..d3bb55c9 100644 --- a/packages/herdr-pond/README.md +++ b/packages/herdr-pond/README.md @@ -51,7 +51,9 @@ herdr's PATH is fixed when the herdr server starts. If `pond` is not on it, set - Each herdr server starts one `pond serve` in the background at startup, listening on a Unix socket in the plugin state dir (`serve/<hash>/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. - 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. -- 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. +- 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 @@ -61,5 +63,6 @@ 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/<hash>/daemon.log` - the per-server serve's lifecycle and output. - `serve/<hash>/desk-serve.log` - a desk-started serve's output. +- `desk.log` - the desk's own notes: a `desk-cache.json` it could not read or write. 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. From d0f7497f29bc138f37a6bef3ccc2a3be56db45f8 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:22:22 +0000 Subject: [PATCH 25/41] docs(plans): herdr-pond desk plan matches the socket transport, hydration lanes and cache The data plane is HTTP on an owner-only Unix socket, and pond ignores POND_HOST/POND_PORT beside `--socket`. 6.2 lists every lane and the view-lane vs data-lane rule; 6.3 describes the three concurrent page-scoped hydration lanes, desk-cache.json and whole-corpus search. Only a refused or missing socket fails over; a timeout is an error. --- docs/plans/2609-24-herdr-pond-v1-desk-plan.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 ceac59f6..4253696f 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -9,9 +9,9 @@ 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 serves only on a Unix socket (`pond serve --socket <path>`, created owner-only 0600, #311) with `POND_HOST`/`POND_PORT` stripped from its env (clap counts an env value as given, which would conflict with `--socket`), 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. +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 <path>`, created owner-only 0600, #311; pond ignores `POND_HOST`/`POND_PORT` beside `--socket`, so inherited TCP settings cannot conflict), 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 <adapter> -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). @@ -176,7 +176,7 @@ Failures go to `sync.log` (with a size cap - truncate at ~1 MiB), never stderr ( 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/<sockhash>/` 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}` - written atomically, temp + rename), `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: 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). Probe `endpoint` under the lock; live: it is unsupervised (a dead owner's orphan) - log it and start a fresh serve that replaces the record. Remove any leftover `owner.sock` (a dead serve's, or that orphan's, so only the new child can answer there), then spawn `pond serve --socket <state>/serve/<sockhash>/owner.sock` as a waited-on child - never `--host`/`--port`, and with `POND_HOST`/`POND_PORT` stripped from its env (clap counts an env value as given, so an inherited one would conflict with `--socket`) - 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 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 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. +2. **`--owner`** (the detached watchdog): re-acquire the flock, non-blocking, and HOLD it for the process lifetime (lost: exit - another owner won). Probe `endpoint` under the lock; live: it is unsupervised (a dead owner's orphan) - log it and start a fresh serve that replaces the record. Remove any leftover `owner.sock` (a dead serve's, or that orphan's, so only the new child can answer there), then spawn `pond serve --socket <state>/serve/<sockhash>/owner.sock` as a waited-on child - never `--host`/`--port` - 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 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 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: `--socket <PATH>` (#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`) @@ -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<AbortHandle> }` 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<AbortHandle> }` 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 (surface its enriched text verbatim in the toast), 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 (<page>)`-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 (<page>)`-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. @@ -251,7 +251,7 @@ PR order: PR1 (pond) first - independently reviewable, rides the release train; 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 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/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. +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. From efb0cf17807ca3be529adc32f76be069467dfc75 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:32:02 +0000 Subject: [PATCH 26/41] fix(herdr-pond): strip POND_HOST/POND_PORT from the serve env again pond ignores them beside --socket, but clap still parses POND_PORT as a u16 when it is set, so a malformed ambient value (a Kubernetes service link such as tcp://10.0.0.1:9797) fails serve with a usage error that never names --socket. --- packages/herdr-pond/src/serve.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index c10f1406..29e89ae5 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -251,6 +251,9 @@ impl Drop for ServeChild { 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 } @@ -494,10 +497,16 @@ mod tests { } #[test] - fn serve_gets_only_the_socket() { + 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] From 81dc6b8cbf0c8fe49645dcd405510242bc4c0275 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:32:51 +0000 Subject: [PATCH 27/41] fix(herdr-pond): a stats read no longer moves last_ts, so a missing title stands Advancing last_ts from stats invalidated Title::Missing, flipping a no-user-message session back to '...' with no re-ask. last_ts is fed by listings only again; a count holds while its own last_ts is at or past the listing's, and neither a listing row nor a stats read replaces a count that saw newer activity. --- packages/herdr-pond/src/desk/cache.rs | 50 +++++++++++++++++---------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/packages/herdr-pond/src/desk/cache.rs b/packages/herdr-pond/src/desk/cache.rs index 36cf4f7e..5d4ce78a 100644 --- a/packages/herdr-pond/src/desk/cache.rs +++ b/packages/herdr-pond/src/desk/cache.rs @@ -21,11 +21,11 @@ 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; counts and a missing title hold -/// only for the `last_ts` they were read at. +/// 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 or stats read reported. + /// The newest `last_ts` a listing reported. #[serde(default)] last_ts: Option<DateTime<Utc>>, /// The session's first message: its host is the origin host, and a @@ -65,7 +65,7 @@ impl Known { /// The whole-session count, if one is known for the current `last_ts`. pub(super) fn count(&self) -> Option<u64> { self.count - .filter(|counted| counted.last_ts == self.last_ts) + .filter(|counted| counted.last_ts >= self.last_ts) .map(|counted| counted.messages) } @@ -101,18 +101,17 @@ impl Known { } /// The count holds for the activity the stats read saw, which a listing - /// that landed meanwhile may already have moved past; the newer count wins. + /// that landed meanwhile may already have moved past. pub(super) fn set_stats(&mut self, stats: &SessionStats) { self.first_ts = Some(stats.first_ts); - let seen = Some(stats.last_ts); - if self.count.is_some_and(|counted| counted.last_ts > seen) { - return; + 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 }); } - self.last_ts = self.last_ts.max(seen); - self.count = Some(Counted { - last_ts: seen, - messages: stats.message_count, - }); } /// Takes what a listing row proves. The row counts only its window, so @@ -131,10 +130,7 @@ impl Known { .first_ts .is_some_and(|first| since.is_none_or(|since| first >= since)); if whole { - self.count = Some(Counted { - last_ts: self.last_ts, - messages: row.message_count, - }); + self.count_as_of(self.last_ts, row.message_count); } } } @@ -422,6 +418,24 @@ mod tests { 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 newer read moves the session on"); + 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)); } } From cc64ab46f6dcb78935eada03679fdc8774f7f004 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:32:51 +0000 Subject: [PATCH 28/41] fix(herdr-pond): a page reply asks again for a pager title that failed open() cannot ask for the title while the titles lane is busy, and if that request fails the header stayed '...'. A successful page reply now re-runs hydration; the hydration error path still does not, since the resolver answers a failed resolution from cache and would loop. --- packages/herdr-pond/src/desk/app.rs | 68 ++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/herdr-pond/src/desk/app.rs b/packages/herdr-pond/src/desk/app.rs index 8a580fc6..1a4413b8 100644 --- a/packages/herdr-pond/src/desk/app.rs +++ b/packages/herdr-pond/src/desk/app.rs @@ -1135,6 +1135,8 @@ impl App { }) 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; @@ -1144,12 +1146,14 @@ impl App { .to_owned(), ); } - Vec::new() + self.hydrate_visible() } Ok(page) => { pager.eof = page.messages.len() < PAGE_ROWS && !page.truncated; pager.append(page.messages); - self.load_more() + let mut effects = self.load_more(); + effects.extend(self.hydrate_visible()); + effects } Err(error) => self.toast(&error), } @@ -2106,6 +2110,66 @@ mod tests { } } + #[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); From 4d6ddc2f262b037c4a22b3eea3768f13ba0bbcf4 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:32:51 +0000 Subject: [PATCH 29/41] docs(herdr-pond): self-contained rustdoc for each hydration method --- packages/herdr-pond/src/types.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/herdr-pond/src/types.rs b/packages/herdr-pond/src/types.rs index 1fde55d3..9f4eca9b 100644 --- a/packages/herdr-pond/src/types.rs +++ b/packages/herdr-pond/src/types.rs @@ -23,12 +23,13 @@ pub(crate) type ApiFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApiErro /// 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: at most one row per id, order - // unspecified, and empty input returns empty without a request. - /// A session with no user message has no row. + // 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. + /// 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`]. From 20adb14e164a2f005f5ecfc393ddade0cb4579b4 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 19:32:51 +0000 Subject: [PATCH 30/41] docs(plans): the serve env strip stays - clap parses POND_PORT beside --socket --- docs/plans/2609-24-herdr-pond-v1-desk-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 4253696f..c02d0558 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -11,7 +11,7 @@ This document is self-contained for fresh implementation agents. Where it cites 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`, 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 serves only on a Unix socket (`pond serve --socket <path>`, created owner-only 0600, #311; pond ignores `POND_HOST`/`POND_PORT` beside `--socket`, so inherited TCP settings cannot conflict), 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. +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 <path>`, 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 <adapter> -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). @@ -176,7 +176,7 @@ Failures go to `sync.log` (with a size cap - truncate at ~1 MiB), never stderr ( 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/<sockhash>/` 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}` - written atomically, temp + rename), `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: 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). Probe `endpoint` under the lock; live: it is unsupervised (a dead owner's orphan) - log it and start a fresh serve that replaces the record. Remove any leftover `owner.sock` (a dead serve's, or that orphan's, so only the new child can answer there), then spawn `pond serve --socket <state>/serve/<sockhash>/owner.sock` as a waited-on child - never `--host`/`--port` - 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 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 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. +2. **`--owner`** (the detached watchdog): re-acquire the flock, non-blocking, and HOLD it for the process lifetime (lost: exit - another owner won). Probe `endpoint` under the lock; live: it is unsupervised (a dead owner's orphan) - log it and start a fresh serve that replaces the record. Remove any leftover `owner.sock` (a dead serve's, or that orphan's, so only the new child can answer there), then spawn `pond serve --socket <state>/serve/<sockhash>/owner.sock` as a waited-on child - 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 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 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: `--socket <PATH>` (#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`) From f0a75891ca5f0863e1844284e41cea4ccd5a4a20 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 20:22:03 +0000 Subject: [PATCH 31/41] fix(herdr-pond): the warm-up listing gets the warm-up budget, not the desk's 25s On a cold store the 14-day listing takes over 25s, so warm-up failed with pond's timeout and never warmed the serve, and the first desk open, cold too, timed out as well. The listing now sends the whole budget as timeout_seconds (capped at pond's 600) with the matching client deadline; the search keeps what is left. --- packages/herdr-pond/src/api.rs | 2 +- packages/herdr-pond/src/daemon.rs | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/herdr-pond/src/api.rs b/packages/herdr-pond/src/api.rs index f732b0a4..3c036e1b 100644 --- a/packages/herdr-pond/src/api.rs +++ b/packages/herdr-pond/src/api.rs @@ -28,7 +28,7 @@ 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. -pub(crate) const QUERY_TIMEOUT_SECS: u64 = 25; +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); diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index 88c3f339..f8434879 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -17,7 +17,7 @@ use std::time::{Duration, Instant}; use anyhow::bail; use chrono::Utc; -use crate::api::{QUERY_TIMEOUT_SECS, SEARCH_PATH, SQL_PATH, Socket, sql_deadline}; +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, live_endpoint, remove_endpoint_if_owned, @@ -51,6 +51,8 @@ const TIMING: Timing = Timing { }; const WARMUP_QUERY: &str = "session"; +/// pond clamps `timeout_seconds` to this. +const MAX_TIMEOUT_SECS: u64 = 600; pub(crate) fn run(args: &[String]) -> anyhow::Result<()> { match args { @@ -231,17 +233,19 @@ async fn herdr_gone(socket: &Path, log: &Path, timing: &Timing) -> String { } /// 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 search gets what -/// is left of `budget`. Failure is not fatal. +/// 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().clamp(1, MAX_TIMEOUT_SECS); let listing = SqlRequest::new( listing_sql(&ListingScope::recent(None, Utc::now())), LISTING_ROWS, - QUERY_TIMEOUT_SECS, + timeout_seconds, ); let search = SearchRequest::new(WARMUP_QUERY.to_owned(), 1); - let listing_deadline = sql_deadline(QUERY_TIMEOUT_SECS); + let listing_deadline = sql_deadline(timeout_seconds); let result = async { socket .post::<_, SqlResponse>(SQL_PATH, &listing, listing_deadline) @@ -414,6 +418,12 @@ mod tests { "{}", 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] From f08a7a3d7734dc0f603a710220082b8f9e4c1057 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 20:22:03 +0000 Subject: [PATCH 32/41] fix(herdr-pond): hydration failures go to desk.log, toasts stay compact A failed titles/stats/hosts lookup no longer pops a toast (the user asked for none of it): it becomes an Effect::Log line in desk.log and its ids are still un-asked, so a later move retries. Toasts show a pond envelope error only up to its first ';' or '. ' - the rest is agent-directed recovery advice - and are capped at three lines with an ellipsis. The fatal screen keeps the full error text. --- packages/herdr-pond/README.md | 2 +- packages/herdr-pond/src/desk/app.rs | 53 +++++++++++++++++++++------ packages/herdr-pond/src/desk/cache.rs | 18 +++++---- packages/herdr-pond/src/desk/mod.rs | 32 +++++++++++++--- packages/herdr-pond/src/desk/ui.rs | 42 ++++++++++++++++++--- 5 files changed, 118 insertions(+), 29 deletions(-) diff --git a/packages/herdr-pond/README.md b/packages/herdr-pond/README.md index d3bb55c9..1b08fbd6 100644 --- a/packages/herdr-pond/README.md +++ b/packages/herdr-pond/README.md @@ -63,6 +63,6 @@ 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/<hash>/daemon.log` - the per-server serve's lifecycle and output. - `serve/<hash>/desk-serve.log` - a desk-started serve's output. -- `desk.log` - the desk's own notes: a `desk-cache.json` it could not read or write. +- `desk.log` - what the desk did not show you: a `desk-cache.json` it could not read or write, and failed background lookups of titles, counts and hosts (the rows are retried as you move). 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/src/desk/app.rs b/packages/herdr-pond/src/desk/app.rs index 1a4413b8..27a8d33f 100644 --- a/packages/herdr-pond/src/desk/app.rs +++ b/packages/herdr-pond/src/desk/app.rs @@ -129,6 +129,8 @@ pub(super) enum Effect { call: Call, }, Cancel(Lane), + /// A line for `desk.log`: a background failure not worth a toast. + Log(String), Exit(DeskExit), } @@ -1016,7 +1018,7 @@ impl App { } fn toast(&mut self, error: &ApiError) -> Vec<Effect> { - self.toast = Some(error.to_string()); + self.toast = Some(ui::error_text(error)); Vec::new() } @@ -1064,8 +1066,9 @@ impl App { } } - /// Learns what a hydration reply proves. A failed one un-asks its ids, - /// so their rows are asked again instead of waiting for the next listing. + /// 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<Effect> { let learned = match reply { Reply::Titles(result) => result.map(|rows| { @@ -1100,11 +1103,16 @@ impl App { 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 call.ids() { - asked.remove(id); + for id in &ids { + asked.remove(*id); } - self.toast(&error) + vec![Effect::Log(format!( + "desk: {:?} for {} sessions failed: {error}", + call.lane(), + ids.len() + ))] } } } @@ -1340,13 +1348,17 @@ mod tests { panic!("no titles asked"); }; let error = ApiError::Request("timed out".to_owned()); - app.apply(Msg { + let failed = app.apply(Msg { generation, epoch, call: call.clone(), reply: Reply::Titles(Err(error.clone())), }); - assert_eq!(app.toast, Some(error.to_string())); + 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)); } @@ -1630,11 +1642,13 @@ mod tests { } #[test] - fn later_errors_are_verbatim_toasts() { + 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 30s limit".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()), @@ -1642,13 +1656,30 @@ mod tests { }; press(&mut app, &failing, KeyCode::Char('r')); assert_eq!(app.fatal, None, "a loaded desk keeps its rows"); - assert_eq!(app.toast, Some(error.to_string())); + let compact = "pond validation_failed: sql error: query exceeded the 25s limit"; + assert_eq!(app.toast.as_deref(), Some(compact)); 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)); } + #[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 { diff --git a/packages/herdr-pond/src/desk/cache.rs b/packages/herdr-pond/src/desk/cache.rs index 5d4ce78a..997f7534 100644 --- a/packages/herdr-pond/src/desk/cache.rs +++ b/packages/herdr-pond/src/desk/cache.rs @@ -189,12 +189,11 @@ pub(super) fn load(state_dir: &Path) -> Snapshot { Err(error) if error.kind() == ErrorKind::NotFound => return Snapshot::default(), Err(error) => Err(error.to_string()), }; - let log = state_dir.join(LOG_FILE); match loaded { Ok(snapshot) if snapshot.version == VERSION => snapshot, Ok(snapshot) => { - log_line( - &log, + log( + state_dir, &format!( "ignoring {}: version {}, expected {VERSION}", path.display(), @@ -204,8 +203,8 @@ pub(super) fn load(state_dir: &Path) -> Snapshot { Snapshot::default() } Err(error) => { - log_line( - &log, + log( + state_dir, &format!("ignoring unreadable {}: {error}", path.display()), ); Snapshot::default() @@ -220,13 +219,18 @@ pub(super) fn save(state_dir: &Path, mut snapshot: Snapshot) { .map_err(std::io::Error::other) .and_then(|json| write_atomic(&path, &json, CACHE_MODE)); if let Err(error) = written { - log_line( - &state_dir.join(LOG_FILE), + 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)] diff --git a/packages/herdr-pond/src/desk/mod.rs b/packages/herdr-pond/src/desk/mod.rs index 0df04887..380ad653 100644 --- a/packages/herdr-pond/src/desk/mod.rs +++ b/packages/herdr-pond/src/desk/mod.rs @@ -8,6 +8,7 @@ mod ui; use std::future::Future; use std::io; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -92,7 +93,7 @@ where S: Stream<Item = io::Result<Event>> + Unpin, { let (tx, mut rx) = mpsc::unbounded_channel(); - let mut runner = Runner::new(api, tx); + 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); @@ -131,14 +132,17 @@ 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>) -> Self { + fn new(api: Arc<dyn Api>, tx: mpsc::UnboundedSender<Msg>, state_dir: Option<PathBuf>) -> Self { Self { api, tx, tasks: Default::default(), + state_dir, } } @@ -152,6 +156,11 @@ impl Runner { 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, @@ -416,7 +425,7 @@ pub(super) mod tests { reply, })); } - Effect::Cancel(_) => {} + Effect::Cancel(_) | Effect::Log(_) => {} Effect::Exit(exit) => return Some(exit), } } @@ -488,7 +497,7 @@ pub(super) mod tests { 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); + 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); @@ -523,7 +532,7 @@ pub(super) mod tests { ..MockApi::golden() }); let (tx, mut rx) = mpsc::unbounded_channel(); - let mut runner = Runner::new(Arc::clone(&api) as Arc<dyn Api>, tx); + 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"); @@ -637,6 +646,19 @@ pub(super) mod tests { 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); diff --git a/packages/herdr-pond/src/desk/ui.rs b/packages/herdr-pond/src/desk/ui.rs index cedd9f55..9ceca698 100644 --- a/packages/herdr-pond/src/desk/ui.rs +++ b/packages/herdr-pond/src/desk/ui.rs @@ -14,7 +14,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::app::{App, Lane}; use super::cache::{Host, Known}; -use crate::types::{LISTING_WINDOW_DAYS, SearchSession, SessionRow, TranscriptMessage}; +use crate::types::{ApiError, LISTING_WINDOW_DAYS, SearchSession, SessionRow, TranscriptMessage}; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const TAB_STOP: usize = 4; @@ -33,6 +33,8 @@ 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)"; @@ -498,12 +500,43 @@ fn render_pager(frame: &mut Frame, app: &App) { ); } +/// 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 lines = u16::try_from(textwrap::wrap(text, inner).len()).unwrap_or(u16::MAX); - let height = lines.saturating_add(2).min(area.height); + 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), @@ -512,8 +545,7 @@ fn render_toast(frame: &mut Frame, text: &str) { }; frame.render_widget(Clear, toast); frame.render_widget( - Paragraph::new(text.to_owned()) - .wrap(Wrap { trim: true }) + Paragraph::new(lines.into_iter().map(Line::from).collect::<Vec<_>>()) .block(Block::bordered().title(" esc dismiss ").fg(Color::Red)), toast, ); From abede2070d4cba5c7a63e532d4e9161522113cc5 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 20:28:00 +0000 Subject: [PATCH 33/41] fix(herdr-pond): a desk fallback removes pond's <socket>.lock on teardown pond serve --socket (#311 at 33a232c) holds a lifetime lock on <socket>.lock and never removes it. Fallback sockets are unique per spawn, so each one left a desk.<pid>.<n>.sock.lock behind for good; a fallback's teardown now removes it once the child has exited. The owner's owner.sock.lock is shared by successive owners and stays. The pre-spawn socket unlink stays too: pond clears a dead serve's socket itself, but an unsupervised orphan's still answers and would pass the fresh child's readiness probe. --- docs/plans/2609-24-herdr-pond-v1-desk-plan.md | 2 +- packages/herdr-pond/src/daemon.rs | 6 +- packages/herdr-pond/src/fake_pond.rs | 4 +- packages/herdr-pond/src/serve.rs | 63 +++++++++++++++++-- 4 files changed, 67 insertions(+), 8 deletions(-) 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 c02d0558..763a01e3 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -181,7 +181,7 @@ Startup hooks are one-shot commands run at server startup AND live handoff (boot ### 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 --socket <state>/serve/<sockhash>/desk.<pid>.<n>.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 - 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. +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 <state>/serve/<sockhash>/desk.<pid>.<n>.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 `<socket>.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) diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index f8434879..8b153c78 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -280,7 +280,7 @@ mod tests { use super::*; use crate::fake_pond::{FakePond, Reply, Sandbox, alive, endpoint, golden, stale_socket}; - use crate::serve::read_endpoint; + use crate::serve::{read_endpoint, socket_lock}; const FAST: Timing = Timing { tick: Duration::from_millis(20), @@ -406,6 +406,10 @@ mod tests { 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" diff --git a/packages/herdr-pond/src/fake_pond.rs b/packages/herdr-pond/src/fake_pond.rs index 05bb6f84..0758fb36 100644 --- a/packages/herdr-pond/src/fake_pond.rs +++ b/packages/herdr-pond/src/fake_pond.rs @@ -303,7 +303,8 @@ impl Sandbox { } /// 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, and when + /// in `calls` and its pid in `pid`, prints to both streams, creates the + /// `<socket>.lock` pond keeps beside its socket, 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 { @@ -317,6 +318,7 @@ impl Sandbox { echo $$ > '{pid}' echo "serve stdout"; echo "serve stderr" >&2 eval "socket=\${{$#}}" +: > "$socket.lock" {publish} {after}"#, calls = self.path("calls").display(), diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index 29e89ae5..934570d2 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -75,6 +75,14 @@ impl ServeDir { } } +/// 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 { @@ -143,13 +151,16 @@ pub(crate) struct ServeChild { /// 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 - a dead serve's, or an unsupervised orphan's - is removed - /// first, so only this child can answer there. + /// 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, @@ -179,9 +190,17 @@ impl ServeChild { 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() } @@ -245,6 +264,9 @@ 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)); + } } } @@ -354,9 +376,11 @@ async fn spawn_fallback(origin: &Origin) -> Result<Fallback, ApiError> { 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())) - })?; + 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) => { @@ -494,6 +518,7 @@ mod tests { 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] @@ -525,6 +550,34 @@ mod tests { 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(); From f492eed2e5b977a0924ee38a8c70189daf9aebfe Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 20:34:47 +0000 Subject: [PATCH 34/41] fix(herdr-pond): a new owner stops a dead owner's serve before starting its own pond serve's lifetime lock on owner.sock.lock refuses a fresh serve while a dead owner's orphan still runs, so the owner could no longer replace it. The endpoint record now carries the serve's pid; an owner finding a live record with no owner SIGTERMs that pid, waits the grace period, then SIGKILLs, and only then unlinks the socket and spawns. Only a record whose socket just answered the probe is signalled (the lock makes the answering process the recorded child), and on Linux /proc/<pid>/cmdline must also name the socket. A record without a pid reads as absent; a termination that fails is logged and the owner exits. The fake pond now takes the lock like pond, so the orphan test fails without the termination. --- docs/plans/2609-24-herdr-pond-v1-desk-plan.md | 2 +- packages/herdr-pond/README.md | 1 + packages/herdr-pond/src/daemon.rs | 152 ++++++++++++++++-- packages/herdr-pond/src/fake_pond.rs | 12 +- packages/herdr-pond/src/serve.rs | 24 +-- 5 files changed, 166 insertions(+), 25 deletions(-) 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 763a01e3..a79e75b7 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -176,7 +176,7 @@ Failures go to `sync.log` (with a size cap - truncate at ~1 MiB), never stderr ( 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/<sockhash>/` 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}` - written atomically, temp + rename), `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: 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). Probe `endpoint` under the lock; live: it is unsupervised (a dead owner's orphan) - log it and start a fresh serve that replaces the record. Remove any leftover `owner.sock` (a dead serve's, or that orphan's, so only the new child can answer there), then spawn `pond serve --socket <state>/serve/<sockhash>/owner.sock` as a waited-on child - 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 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 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. +2. **`--owner`** (the detached watchdog): re-acquire the flock, non-blocking, and HOLD it for the process lifetime (lost: exit - another owner won). Probe `endpoint` under the lock; live: it is unsupervised (a dead owner's orphan), and pond's lifetime lock on `owner.sock.lock` would refuse a fresh serve while it runs - so log it, SIGTERM the pid the record names, wait up to the grace period for it to exit, then SIGKILL. Only a record whose socket just answered the probe is ever signalled: the lock makes the process answering there the recorded child, and a live process's pid cannot be reused (on Linux `/proc/<pid>/cmdline` must also name the socket). A record without a pid is no record; a termination that fails (EPERM, 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 <state>/serve/<sockhash>/owner.sock` as a waited-on child - 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 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 write `endpoint` atomically with a fresh random token and the child's pid, 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 <PATH>` (#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`) diff --git a/packages/herdr-pond/README.md b/packages/herdr-pond/README.md index 1b08fbd6..297c7949 100644 --- a/packages/herdr-pond/README.md +++ b/packages/herdr-pond/README.md @@ -50,6 +50,7 @@ herdr's PATH is fixed when the herdr server starts. If `pond` is not on it, set - Each herdr server starts one `pond serve` in the background at startup, listening on a Unix socket in the plugin state dir (`serve/<hash>/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 and starts a fresh one. - 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. diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index 8b153c78..888b6582 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -16,6 +16,9 @@ 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}; @@ -125,15 +128,20 @@ async fn own( let Some(_lock) = try_lock(&dir.lock())? else { return Ok(()); }; - if let Some(orphan) = live_endpoint(dir).await { + if let Some((orphan, _)) = live_endpoint(dir).await { log_line( &log, &format!( - "owner: {} answers but no owner supervises it (a dead owner's orphan) - \ - starting a fresh serve", - orphan.path.display() + "owner: {} (pid {}) answers but no owner supervises it (a dead owner's \ + orphan) - stopping it for a fresh serve", + orphan.socket.display(), + orphan.pid ), ); + if let Err(error) = stop_orphan(&orphan, timing).await { + log_line(&log, &format!("owner: cannot stop the orphan - {error}")); + return Ok(()); + } } let Some(pond) = resolve_pond() else { return Ok(()); @@ -167,6 +175,50 @@ fn random_token() -> String { ) } +/// Stops the serve a live record names. Called only while its socket answers +/// the probe: pond's lifetime lock makes the process answering there the one +/// that took the path, which is the recorded child, and a live process's pid +/// cannot have been reused. +async fn stop_orphan(orphan: &Endpoint, timing: &Timing) -> Result<(), String> { + let pid = i32::try_from(orphan.pid) + .ok() + .filter(|pid| *pid > 1) + .map(Pid::from_raw) + .ok_or_else(|| format!("the record names no usable pid ({})", orphan.pid))?; + #[cfg(target_os = "linux")] + 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] { + 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 kill(pid, None) == Err(Errno::ESRCH) { + return Ok(()); + } + tokio::time::sleep(timing.tick).await; + } + } + Err(format!("pid {pid} survived SIGKILL")) +} + +/// Whether `pid`'s command line names `socket`, so a pid the record got +/// wrong is never signalled. +#[cfg(target_os = "linux")] +fn serves_at(pid: Pid, socket: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + let needle = socket.as_os_str().as_bytes(); + std::fs::read(format!("/proc/{pid}/cmdline")) + .is_ok_and(|cmdline| cmdline.windows(needle.len()).any(|window| window == needle)) +} + /// Publishes the endpoint once serve answers the capability probe, warms it /// up, and returns why the owner must stop. `token` is set on publish. async fn supervise( @@ -182,6 +234,7 @@ async fn supervise( let endpoint = Endpoint { socket: socket.path.clone(), token: random_token(), + pid: serve.id(), }; if let Err(error) = write_endpoint(&dir.endpoint(), &endpoint) { return format!("cannot publish the endpoint: {error}"); @@ -279,7 +332,9 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; - use crate::fake_pond::{FakePond, Reply, Sandbox, alive, endpoint, golden, stale_socket}; + use crate::fake_pond::{ + FakePond, Reply, Sandbox, alive, endpoint, golden, stale_socket, write_script, + }; use crate::serve::{read_endpoint, socket_lock}; const FAST: Timing = Timing { @@ -487,8 +542,45 @@ mod tests { assert_eq!(setup.endpoint().unwrap().token, "successor"); } - /// The orphan still answers at `owner.sock`, so the fresh serve's - /// readiness must come from the fresh serve, not from the orphan. + /// A dead owner's serve, detached so it is nobody's child here: it takes + /// `owner.sock`'s lock as pond does and answers there through `target`. + fn orphan_serve(setup: &Setup, target: &Path) -> u32 { + let script = write_script( + &setup.sandbox.path("bin/orphan"), + &format!( + r#"eval "socket=\${{$#}}" +echo $$ > "$socket.lock" +ln -s '{}' "$socket" +sleep 30; :"#, + target.display() + ), + ); + 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 deadline = Instant::now() + Duration::from_secs(5); + while fs::symlink_metadata(&owner_socket).is_err() { + assert!(Instant::now() < deadline, "the orphan never answered"); + 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; @@ -498,9 +590,12 @@ mod tests { ) .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(); - write_endpoint(&setup.dir.endpoint(), &endpoint(&owner_socket, "orphan")).unwrap(); + let orphan_pid = orphan_serve(&setup, &orphan.socket); + 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 { @@ -508,11 +603,14 @@ mod tests { setup.endpoint().is_some_and(|e| e.token != "orphan") }) .await; - assert_eq!(setup.endpoint().unwrap().socket, owner_socket); + 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()); @@ -523,6 +621,38 @@ mod tests { ); } + #[tokio::test] + async fn a_record_that_does_not_answer_is_never_signalled() { + let setup = Setup::new().await; + let owner_socket = setup.owner_socket(); + stale_socket(&owner_socket); + // Its command line names the socket, so only the liveness gate spares it. + let mut bystander = Command::new("/bin/sh") + .args(["-c", "sleep 30; :"]) + .arg(&owner_socket) + .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 { + wait_until("the fresh endpoint", || { + setup.endpoint().is_some_and(|e| e.token != "dead") + }) + .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 a_probe_failing_while_serve_settles_is_retried() { let unready = Arc::new(AtomicUsize::new(2)); diff --git a/packages/herdr-pond/src/fake_pond.rs b/packages/herdr-pond/src/fake_pond.rs index 0758fb36..64e718c5 100644 --- a/packages/herdr-pond/src/fake_pond.rs +++ b/packages/herdr-pond/src/fake_pond.rs @@ -303,8 +303,9 @@ impl Sandbox { } /// 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, creates the - /// `<socket>.lock` pond keeps beside its socket, and when + /// 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 { @@ -318,7 +319,10 @@ impl Sandbox { echo $$ > '{pid}' echo "serve stdout"; echo "serve stderr" >&2 eval "socket=\${{$#}}" -: > "$socket.lock" +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(), @@ -362,10 +366,12 @@ pub(crate) fn stale_socket(path: &Path) -> Socket { 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, } } diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index 934570d2..f1d205cc 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -98,11 +98,13 @@ fn sockhash(socket: &Path) -> String { } /// The published record of a daemon-owned serve. The token names the owner, -/// so an exiting owner never removes a successor's record. +/// 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. @@ -120,10 +122,11 @@ pub(crate) fn remove_endpoint_if_owned(path: &Path, token: &str) -> bool { && fs::remove_file(path).is_ok() } -/// The published endpoint's socket, if it answers the probe. -pub(crate) async fn live_endpoint(dir: &ServeDir) -> Option<Socket> { - let socket = Socket::new(read_endpoint(&dir.endpoint())?.socket).ok()?; - probe(&socket).await.ok().map(|()| socket) +/// 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 @@ -338,7 +341,7 @@ 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((_, socket)) = live_endpoint(&origin.dir).await { if let Some(fallback) = fallback { retire(fallback.serve); } @@ -436,7 +439,7 @@ mod tests { 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"}) + 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")); @@ -447,14 +450,15 @@ mod tests { } #[test] - fn malformed_or_port_endpoint_is_absent() { + 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"}"#, - r#"{"port":1,"token":"t"}"#, + 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}"); From 6a96a69e2c19084edd5383b2e848e15438774847 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 22:57:18 +0000 Subject: [PATCH 35/41] chore(skills): refresh kasetto.lock for the pond-add-adapter skill edit in #317 --- kasetto.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasetto.lock b/kasetto.lock index ba2c1a7b..0750d169 100644 --- a/kasetto.lock +++ b/kasetto.lock @@ -2,7 +2,7 @@ version: 3 skills: ./skills::pond-add-adapter: destination: .claude/skills/pond-add-adapter,.agents/skills/pond-add-adapter - hash: 2ec3e9ecd8cf4c51ed67485f13e9bd3ee954018e6735f96a48f89dc5bf7ef96e + hash: ba552acbbe49164a81a130e669ba9e9501832b014f8faf2a7b85a2ff5004dae0 skill: pond-add-adapter description: Playbook for adding a new source-agent adapter to pond - spec the format from the upstream writer, capture a sandboxed fixture, implement the bidirectional codec, and prove conformance. Use when adding an adapter under packages/pond/src/adapter/ or reworking an existing one. source: ./skills From 07d36f724d6d1a763e784f22c8aaba4601b59ec3 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 22:57:25 +0000 Subject: [PATCH 36/41] fix(herdr-pond): publish the serve record at spawn and stop lock-holding orphans The owner now writes `{socket, token, pid}` right after spawning, so the record always names the process holding pond's owner.sock.lock; desks already treat a record whose socket does not answer as absent. `pond` is resolved before any orphan is touched, so a working serve is never stopped for a replacement that cannot spawn. An orphan is stopped when its socket answers or, on Linux, when its pid is alive with the socket as an argv element (still opening its store, or wedged); its record is removed once it is gone. On Linux a zombie or reused pid (argv no longer naming the socket) counts as gone during the wait and is never SIGKILLed. Off Linux a non-answering record is never signalled; a fresh serve that then fails to start names the recorded pid to kill. The warm-up listing sends its budget unclamped: pond clamps server-side. --- packages/herdr-pond/src/daemon.rs | 279 +++++++++++++++++++++--------- 1 file changed, 195 insertions(+), 84 deletions(-) diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index 888b6582..52194eb2 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -23,7 +23,7 @@ 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, live_endpoint, remove_endpoint_if_owned, + Endpoint, READY_DEADLINE, ServeChild, ServeDir, probe, read_endpoint, remove_endpoint_if_owned, retire, write_endpoint, }; use crate::types::{ @@ -54,8 +54,6 @@ const TIMING: Timing = Timing { }; const WARMUP_QUERY: &str = "session"; -/// pond clamps `timeout_seconds` to this. -const MAX_TIMEOUT_SECS: u64 = 600; pub(crate) fn run(args: &[String]) -> anyhow::Result<()> { match args { @@ -128,40 +126,71 @@ async fn own( let Some(_lock) = try_lock(&dir.lock())? else { return Ok(()); }; - if let Some((orphan, _)) = live_endpoint(dir).await { - log_line( - &log, - &format!( - "owner: {} (pid {}) answers but no owner supervises it (a dead owner's \ - orphan) - stopping it for a fresh serve", - orphan.socket.display(), - orphan.pid - ), - ); - if let Err(error) = stop_orphan(&orphan, timing).await { - log_line(&log, &format!("owner: cannot stop the orphan - {error}")); - 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 mut refusal_hint = String::new(); + if let Some(orphan) = read_endpoint(&dir.endpoint()) { + let answers = match Socket::new(orphan.socket.clone()) { + Ok(socket) => probe(&socket).await.is_ok(), + Err(_) => false, + }; + if answers || (cfg!(target_os = "linux") && 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); + } else if !cfg!(target_os = "linux") { + refusal_hint = format!( + " - if pid {} (the last recorded serve) still runs it holds the socket \ + lock: kill it", + orphan.pid + ); + } + } let mut serve = ServeChild::spawn(&pond, dir.socket("owner"), log.clone(), timing.grace)?; - log_line( - &log, - &format!("owner: started {} (pid {})", pond.display(), serve.id()), - ); - let mut token = None; - let reason = tokio::select! { - reason = supervise(&mut serve, dir, timing, &mut token) => reason, - reason = herdr_gone(socket, &log, timing) => reason, - signal = shutdown => format!("received {signal}"), + // Published at spawn, so the record always names the process holding + // pond's lock on the socket; desks treat it as absent until it answers. + let record = Endpoint { + socket: dir.socket("owner"), + 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, &refusal_hint) => reason, + reason = herdr_gone(socket, &log, timing) => reason, + signal = shutdown => format!("received {signal}"), + } + } }; log_line(&log, &format!("owner: stopping - {reason}")); let _ = retire(serve).await; - if let Some(token) = token { - remove_endpoint_if_owned(&dir.endpoint(), &token); - } + remove_endpoint_if_owned(&dir.endpoint(), &record.token); log_line(&log, "owner: stopped"); Ok(()) } @@ -175,17 +204,11 @@ fn random_token() -> String { ) } -/// Stops the serve a live record names. Called only while its socket answers -/// the probe: pond's lifetime lock makes the process answering there the one -/// that took the path, which is the recorded child, and a live process's pid -/// cannot have been reused. +/// Stops the serve a record names. The record is published at spawn, so the +/// process holding pond's lock on its socket is the recorded child. async fn stop_orphan(orphan: &Endpoint, timing: &Timing) -> Result<(), String> { - let pid = i32::try_from(orphan.pid) - .ok() - .filter(|pid| *pid > 1) - .map(Pid::from_raw) + let pid = record_pid(orphan) .ok_or_else(|| format!("the record names no usable pid ({})", orphan.pid))?; - #[cfg(target_os = "linux")] if !serves_at(pid, &orphan.socket) { return Err(format!( "pid {pid} is not a pond serve on {}", @@ -193,6 +216,9 @@ async fn stop_orphan(orphan: &Endpoint, timing: &Timing) -> Result<(), String> { )); } for signal in [Signal::SIGTERM, Signal::SIGKILL] { + if !still_serving(orphan) { + return Ok(()); + } match kill(pid, signal) { Ok(()) => {} Err(Errno::ESRCH) => return Ok(()), @@ -200,7 +226,7 @@ async fn stop_orphan(orphan: &Endpoint, timing: &Timing) -> Result<(), String> { } let deadline = Instant::now() + timing.grace; while Instant::now() < deadline { - if kill(pid, None) == Err(Errno::ESRCH) { + if !still_serving(orphan) { return Ok(()); } tokio::time::sleep(timing.tick).await; @@ -209,44 +235,52 @@ async fn stop_orphan(orphan: &Endpoint, timing: &Timing) -> Result<(), String> { Err(format!("pid {pid} survived SIGKILL")) } -/// Whether `pid`'s command line names `socket`, so a pid the record got -/// wrong is never signalled. +fn record_pid(record: &Endpoint) -> Option<Pid> { + 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. On Linux +/// a zombie's argv 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 has `socket` as one argument, so a pid the +/// record got wrong is never signalled. #[cfg(target_os = "linux")] fn serves_at(pid: Pid, socket: &Path) -> bool { use std::os::unix::ffi::OsStrExt; let needle = socket.as_os_str().as_bytes(); std::fs::read(format!("/proc/{pid}/cmdline")) - .is_ok_and(|cmdline| cmdline.windows(needle.len()).any(|window| window == needle)) + .is_ok_and(|cmdline| cmdline.split(|byte| *byte == 0).any(|arg| arg == needle)) } -/// Publishes the endpoint once serve answers the capability probe, warms it -/// up, and returns why the owner must stop. `token` is set on publish. +/// Without `/proc` a command line cannot be checked. +#[cfg(not(target_os = "linux"))] +fn serves_at(_pid: Pid, _socket: &Path) -> bool { + true +} + +/// Waits for serve to answer the capability probe, warms it up, and returns +/// why the owner must stop. `refusal_hint` names the fix when a serve that +/// never answers may have been refused by a live orphan's lock. async fn supervise( serve: &mut ServeChild, - dir: &ServeDir, + log: &Path, timing: &Timing, - token: &mut Option<String>, + refusal_hint: &str, ) -> String { let socket = match serve.ready(timing.ready_deadline).await { Ok(socket) => socket, - Err(error) => return error.to_string(), + Err(error) => return format!("{error}{refusal_hint}"), }; - let endpoint = Endpoint { - socket: socket.path.clone(), - token: random_token(), - pid: serve.id(), - }; - if let Err(error) = write_endpoint(&dir.endpoint(), &endpoint) { - return format!("cannot publish the endpoint: {error}"); - } - let log = dir.daemon_log(); - log_line( - &log, - &format!("owner: published {}", endpoint.socket.display()), - ); - *token = Some(endpoint.token); + log_line(log, &format!("owner: ready on {}", socket.path.display())); let ((), reason) = tokio::join!( - warm_up(&socket, &log, timing.warmup_deadline), + warm_up(&socket, log, timing.warmup_deadline), exited(serve, timing.tick) ); reason @@ -291,7 +325,7 @@ async fn herdr_gone(socket: &Path, log: &Path, timing: &Timing) -> String { /// 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().clamp(1, MAX_TIMEOUT_SECS); + let timeout_seconds = budget.as_secs(); let listing = SqlRequest::new( listing_sql(&ListingScope::recent(None, Utc::now())), LISTING_ROWS, @@ -335,7 +369,7 @@ mod tests { use crate::fake_pond::{ FakePond, Reply, Sandbox, alive, endpoint, golden, stale_socket, write_script, }; - use crate::serve::{read_endpoint, socket_lock}; + use crate::serve::socket_lock; const FAST: Timing = Timing { tick: Duration::from_millis(20), @@ -421,8 +455,9 @@ mod tests { read_endpoint(&self.dir.endpoint()) } + /// Serve answered the probe; the record was published at spawn. async fn published(&self) { - wait_until("the endpoint", || self.endpoint().is_some()).await; + wait_until("a ready serve", || self.log().contains("owner: ready on")).await; } } @@ -543,16 +578,19 @@ mod tests { } /// A dead owner's serve, detached so it is nobody's child here: it takes - /// `owner.sock`'s lock as pond does and answers there through `target`. - fn orphan_serve(setup: &Setup, target: &Path) -> u32 { + /// `owner.sock`'s lock as pond does and, given a `target`, answers there + /// through it. + fn orphan_serve(setup: &Setup, target: Option<&Path>) -> u32 { + 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#"eval "socket=\${{$#}}" echo $$ > "$socket.lock" -ln -s '{}' "$socket" -sleep 30; :"#, - target.display() +{publish} +sleep 30; :"# ), ); let owner_socket = setup.owner_socket(); @@ -571,9 +609,14 @@ sleep 30; :"#, .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(&owner_socket).is_err() { - assert!(Instant::now() < deadline, "the orphan never answered"); + while fs::symlink_metadata(&started).is_err() { + assert!(Instant::now() < deadline, "the orphan never started"); std::thread::sleep(Duration::from_millis(10)); } pid @@ -590,7 +633,7 @@ sleep 30; :"#, ) .await; let owner_socket = setup.owner_socket(); - let orphan_pid = orphan_serve(&setup, &orphan.socket); + let orphan_pid = orphan_serve(&setup, Some(&orphan.socket)); let record = Endpoint { pid: orphan_pid, ..endpoint(&owner_socket, "orphan") @@ -599,10 +642,7 @@ sleep 30; :"#, let pond = setup.fake_pond(true, "exec sleep 30"); let listener = UnixListener::bind(&setup.socket).unwrap(); let herdr_stops = async { - wait_until("the fresh endpoint", || { - setup.endpoint().is_some_and(|e| e.token != "orphan") - }) - .await; + setup.published().await; let fresh = setup.endpoint().unwrap(); assert_eq!(fresh.socket, owner_socket); assert_eq!(fresh.pid, setup.sandbox.serve_pid()); @@ -626,10 +666,8 @@ sleep 30; :"#, let setup = Setup::new().await; let owner_socket = setup.owner_socket(); stale_socket(&owner_socket); - // Its command line names the socket, so only the liveness gate spares it. let mut bystander = Command::new("/bin/sh") .args(["-c", "sleep 30; :"]) - .arg(&owner_socket) .spawn() .unwrap(); let record = Endpoint { @@ -640,10 +678,7 @@ sleep 30; :"#, let pond = setup.fake_pond(true, "exec sleep 30"); let listener = UnixListener::bind(&setup.socket).unwrap(); let herdr_stops = async { - wait_until("the fresh endpoint", || { - setup.endpoint().is_some_and(|e| e.token != "dead") - }) - .await; + setup.published().await; drop(listener); }; let (owner, ()) = tokio::join!(setup.own(&pond), herdr_stops); @@ -653,6 +688,82 @@ sleep 30; :"#, bystander.wait().unwrap(); } + /// Still opening its store, an orphan holds the lock without answering. + #[cfg(target_os = "linux")] + #[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); + 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. + #[cfg(target_os = "linux")] + #[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)); From 6fd7fc2c2ee3a3de194d92a4eacd6ad52ee8b634 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 22:57:25 +0000 Subject: [PATCH 37/41] fix(herdr-pond): a clipped toast leaves its full error in desk.log --- packages/herdr-pond/src/desk/app.rs | 19 +++++++++++++++++-- packages/herdr-pond/src/desk/cache.rs | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/herdr-pond/src/desk/app.rs b/packages/herdr-pond/src/desk/app.rs index 27a8d33f..2c3dd0a2 100644 --- a/packages/herdr-pond/src/desk/app.rs +++ b/packages/herdr-pond/src/desk/app.rs @@ -1017,9 +1017,17 @@ impl App { } } + /// A clipped toast leaves its full text in `desk.log`. fn toast(&mut self, error: &ApiError) -> Vec<Effect> { - self.toast = Some(ui::error_text(error)); - Vec::new() + let shown = ui::error_text(error); + let full = error.to_string(); + let effects = if shown == full { + Vec::new() + } else { + vec![Effect::Log(format!("desk: {full}"))] + }; + self.toast = Some(shown); + effects } fn on_listing( @@ -1658,10 +1666,17 @@ mod tests { 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 clipped 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 whole = ApiError::Request("timed out".to_owned()); + assert!(app.toast(&whole).is_empty(), "nothing was clipped"); } #[test] diff --git a/packages/herdr-pond/src/desk/cache.rs b/packages/herdr-pond/src/desk/cache.rs index 997f7534..21f9463d 100644 --- a/packages/herdr-pond/src/desk/cache.rs +++ b/packages/herdr-pond/src/desk/cache.rs @@ -62,7 +62,7 @@ pub(super) enum Host { } impl Known { - /// The whole-session count, if one is known for the current `last_ts`. + /// 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) From 9475639a75b00290764d7ac8f7cbf5426828f840 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 22:57:25 +0000 Subject: [PATCH 38/41] docs(plans): the serve record is published at spawn, orphan stops, clipped toasts --- docs/plans/2609-24-herdr-pond-v1-desk-plan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 a79e75b7..eb78a46c 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -173,10 +173,10 @@ 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/<sockhash>/` 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}` - written atomically, temp + rename), `owner.sock` (the serve's Unix socket), `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/<sockhash>/` 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: 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). Probe `endpoint` under the lock; live: it is unsupervised (a dead owner's orphan), and pond's lifetime lock on `owner.sock.lock` would refuse a fresh serve while it runs - so log it, SIGTERM the pid the record names, wait up to the grace period for it to exit, then SIGKILL. Only a record whose socket just answered the probe is ever signalled: the lock makes the process answering there the recorded child, and a live process's pid cannot be reused (on Linux `/proc/<pid>/cmdline` must also name the socket). A record without a pid is no record; a termination that fails (EPERM, 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 <state>/serve/<sockhash>/owner.sock` as a waited-on child - 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 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 write `endpoint` atomically with a fresh random token and the child's pid, 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. +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: 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 (Linux only) one whose pid is alive with the socket as an argument in `/proc/<pid>/cmdline` - still opening its store, or wedged. Log it, SIGTERM the recorded pid, wait up to the grace period, then SIGKILL, then remove its record. The record is written at spawn, so the process holding the lock is the recorded child; on Linux the cmdline must also name the socket before any signal, and a zombie or reused pid (cmdline no longer naming it) counts as gone. Off Linux a non-answering record is never signalled; if the fresh serve is then refused, the failure log names the recorded pid to kill. A record without a pid is no record; a termination that fails (EPERM, cmdline 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 <state>/serve/<sockhash>/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 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 <PATH>` (#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`) @@ -199,7 +199,7 @@ One `Lane { gen: u64, task: Option<AbortHandle> }` per request kind: search (150 ### 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/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. +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 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 (<page>)`-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. @@ -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. 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. From 0dd435066322641d6eb385c42e98359597fd55a6 Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 23:11:57 +0000 Subject: [PATCH 39/41] fix(herdr-pond): one command-line check for orphans on every unix, and a stricter readiness Off Linux the orphan check was a stub that always matched, so a non-answering record was never stopped there and a refusal hint stood in. The command line now comes from /proc/<pid>/cmdline on Linux and a bounded `ps -ww -o args= -p <pid>` elsewhere, and one pure matcher requires the whole `--socket <path>` argument (never split on whitespace: macOS paths hold spaces). Non-answering records are handled the same everywhere and the hint is gone. A record aimed at any socket but this dir's owner.sock is ignored, so a corrupt one cannot steer a signal. `ready()` checks the child's exit before probing and accepts an answer only while the child still runs. Tests: the matcher's edge cases, SIGKILL after a TERM-ignoring orphan's grace, a record aimed elsewhere, and the macOS-portable orphan tests no longer gated to Linux. --- packages/herdr-pond/src/daemon.rs | 229 ++++++++++++++++++++++++------ packages/herdr-pond/src/serve.rs | 17 ++- 2 files changed, 194 insertions(+), 52 deletions(-) diff --git a/packages/herdr-pond/src/daemon.rs b/packages/herdr-pond/src/daemon.rs index 52194eb2..0a468e94 100644 --- a/packages/herdr-pond/src/daemon.rs +++ b/packages/herdr-pond/src/daemon.rs @@ -131,13 +131,17 @@ async fn own( let Some(pond) = resolve_pond() else { return Ok(()); }; - let mut refusal_hint = String::new(); - if let Some(orphan) = read_endpoint(&dir.endpoint()) { + 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 || (cfg!(target_os = "linux") && still_serving(&orphan)) { + if answers || still_serving(&orphan) { log_line( &log, &format!( @@ -153,19 +157,14 @@ async fn own( return Ok(()); } remove_endpoint_if_owned(&dir.endpoint(), &orphan.token); - } else if !cfg!(target_os = "linux") { - refusal_hint = format!( - " - if pid {} (the last recorded serve) still runs it holds the socket \ - lock: kill it", - orphan.pid - ); } } - let mut serve = ServeChild::spawn(&pond, dir.socket("owner"), log.clone(), timing.grace)?; - // Published at spawn, so the record always names the process holding - // pond's lock on the socket; desks treat it as absent until it answers. + 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: dir.socket("owner"), + socket: owner_socket, token: random_token(), pid: serve.id(), }; @@ -182,7 +181,7 @@ async fn own( ), ); tokio::select! { - reason = supervise(&mut serve, &log, timing, &refusal_hint) => reason, + reason = supervise(&mut serve, &log, timing) => reason, reason = herdr_gone(socket, &log, timing) => reason, signal = shutdown => format!("received {signal}"), } @@ -204,8 +203,8 @@ fn random_token() -> String { ) } -/// Stops the serve a record names. The record is published at spawn, so the -/// process holding pond's lock on its socket is the recorded child. +/// 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))?; @@ -242,41 +241,84 @@ fn record_pid(record: &Endpoint) -> Option<Pid> { .map(Pid::from_raw) } -/// Whether the recorded pid still runs as the serve on its socket. On Linux -/// a zombie's argv is empty and a reused pid's differs, so neither counts. +/// 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 has `socket` as one argument, so a pid the -/// record got wrong is never signalled. -#[cfg(target_os = "linux")] +/// 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 { - use std::os::unix::ffi::OsStrExt; - let needle = socket.as_os_str().as_bytes(); - std::fs::read(format!("/proc/{pid}/cmdline")) - .is_ok_and(|cmdline| cmdline.split(|byte| *byte == 0).any(|arg| arg == needle)) + 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::<Vec<_>>() + .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() } -/// Without `/proc` a command line cannot be checked. #[cfg(not(target_os = "linux"))] -fn serves_at(_pid: Pid, _socket: &Path) -> bool { - true +const PS_DEADLINE: Duration = Duration::from_secs(2); + +/// Whether a command line passes `--socket <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. `refusal_hint` names the fix when a serve that -/// never answers may have been refused by a live orphan's lock. -async fn supervise( - serve: &mut ServeChild, - log: &Path, - timing: &Timing, - refusal_hint: &str, -) -> String { +/// 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 format!("{error}{refusal_hint}"), + Err(error) => return error.to_string(), }; log_line(log, &format!("owner: ready on {}", socket.path.display())); let ((), reason) = tokio::join!( @@ -579,15 +621,17 @@ mod tests { /// 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. - fn orphan_serve(setup: &Setup, target: Option<&Path>) -> u32 { + /// 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#"eval "socket=\${{$#}}" + r#"{trap} +eval "socket=\${{$#}}" echo $$ > "$socket.lock" {publish} sleep 30; :"# @@ -633,7 +677,7 @@ sleep 30; :"# ) .await; let owner_socket = setup.owner_socket(); - let orphan_pid = orphan_serve(&setup, Some(&orphan.socket)); + let orphan_pid = orphan_serve(&setup, Some(&orphan.socket), false); let record = Endpoint { pid: orphan_pid, ..endpoint(&owner_socket, "orphan") @@ -662,7 +706,7 @@ sleep 30; :"# } #[tokio::test] - async fn a_record_that_does_not_answer_is_never_signalled() { + 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); @@ -688,13 +732,109 @@ sleep 30; :"# 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. - #[cfg(target_os = "linux")] #[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); + let orphan_pid = orphan_serve(&setup, None, false); let record = Endpoint { pid: orphan_pid, ..endpoint(&owner_socket, "orphan") @@ -714,7 +854,6 @@ sleep 30; :"# /// An answering record whose pid is not a serve on its socket is never /// signalled, and no serve is spawned beside it. - #[cfg(target_os = "linux")] #[tokio::test] async fn an_answering_record_naming_another_process_is_left_alone() { let setup = Setup::new().await; diff --git a/packages/herdr-pond/src/serve.rs b/packages/herdr-pond/src/serve.rs index f1d205cc..40f2ceb5 100644 --- a/packages/herdr-pond/src/serve.rs +++ b/packages/herdr-pond/src/serve.rs @@ -225,13 +225,6 @@ impl ServeChild { let started = Instant::now(); let mut last_probe = String::new(); loop { - if self.socket.exists() { - match probe(&socket).await { - Ok(()) => return Ok(socket), - Err(ApiError::PondTooOld) => return Err(ApiError::PondTooOld), - Err(error) => last_probe = format!(" (last probe: {error})"), - } - } if let Ok(Some(status)) = self.child.try_wait() { if status.code() == Some(USAGE_ERROR_EXIT) && self.rejected_socket_flag() { return Err(ApiError::PondTooOld); @@ -241,6 +234,16 @@ impl ServeChild { 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 {}", From 5dad7d8fc40ca228cc6725c5044bef26ff5c769d Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 23:11:58 +0000 Subject: [PATCH 40/41] fix(herdr-pond): every toast's full error goes to desk.log Toasts are clipped to a first clause and capped at three lines when drawn, which can drop e.g. a 'see <log>' pointer from an unclipped error; desk.log now holds the whole text of every toast. --- packages/herdr-pond/src/desk/app.rs | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/herdr-pond/src/desk/app.rs b/packages/herdr-pond/src/desk/app.rs index 2c3dd0a2..db8c5879 100644 --- a/packages/herdr-pond/src/desk/app.rs +++ b/packages/herdr-pond/src/desk/app.rs @@ -1017,17 +1017,11 @@ impl App { } } - /// A clipped toast leaves its full text in `desk.log`. + /// The toast is clipped and capped when drawn, so `desk.log` keeps the + /// whole error. fn toast(&mut self, error: &ApiError) -> Vec<Effect> { - let shown = ui::error_text(error); - let full = error.to_string(); - let effects = if shown == full { - Vec::new() - } else { - vec![Effect::Log(format!("desk: {full}"))] - }; - self.toast = Some(shown); - effects + self.toast = Some(ui::error_text(error)); + vec![Effect::Log(format!("desk: {error}"))] } fn on_listing( @@ -1669,14 +1663,18 @@ mod tests { assert_eq!( app.toast(&error), [Effect::Log(format!("desk: {error}"))], - "the clipped text reaches desk.log" + "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 whole = ApiError::Request("timed out".to_owned()); - assert!(app.toast(&whole).is_empty(), "nothing was clipped"); + let unclipped = ApiError::Request("timed out".to_owned()); + assert_eq!( + app.toast(&unclipped), + [Effect::Log(format!("desk: {unclipped}"))], + "every toast reaches desk.log" + ); } #[test] From c64af0fd2094a20abdc8104d749defa284e41a2b Mon Sep 17 00:00:00 2001 From: Misha Kolesnik <misha@kolesnik.io> Date: Fri, 25 Sep 2026 23:11:58 +0000 Subject: [PATCH 41/41] docs(herdr-pond): orphan stops by command line on every unix, desk.log holds every toast --- docs/plans/2609-24-herdr-pond-v1-desk-plan.md | 2 +- packages/herdr-pond/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 eb78a46c..a029c9c8 100644 --- a/docs/plans/2609-24-herdr-pond-v1-desk-plan.md +++ b/docs/plans/2609-24-herdr-pond-v1-desk-plan.md @@ -176,7 +176,7 @@ Failures go to `sync.log` (with a size cap - truncate at ~1 MiB), never stderr ( 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/<sockhash>/` 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: 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: 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 (Linux only) one whose pid is alive with the socket as an argument in `/proc/<pid>/cmdline` - still opening its store, or wedged. Log it, SIGTERM the recorded pid, wait up to the grace period, then SIGKILL, then remove its record. The record is written at spawn, so the process holding the lock is the recorded child; on Linux the cmdline must also name the socket before any signal, and a zombie or reused pid (cmdline no longer naming it) counts as gone. Off Linux a non-answering record is never signalled; if the fresh serve is then refused, the failure log names the recorded pid to kill. A record without a pid is no record; a termination that fails (EPERM, cmdline 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 <state>/serve/<sockhash>/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 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. +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 <owner.sock>` - still opening its store, or wedged. The command line comes from `/proc/<pid>/cmdline` on Linux and `ps -ww -o args= -p <pid>` 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 <state>/serve/<sockhash>/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 <PATH>` (#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`) diff --git a/packages/herdr-pond/README.md b/packages/herdr-pond/README.md index 297c7949..2819ac5f 100644 --- a/packages/herdr-pond/README.md +++ b/packages/herdr-pond/README.md @@ -50,7 +50,7 @@ herdr's PATH is fixed when the herdr server starts. If `pond` is not on it, set - Each herdr server starts one `pond serve` in the background at startup, listening on a Unix socket in the plugin state dir (`serve/<hash>/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 and starts a fresh one. +- 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. @@ -64,6 +64,6 @@ 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/<hash>/daemon.log` - the per-server serve's lifecycle and output. - `serve/<hash>/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, and failed background lookups of titles, counts and hosts (the rows are retried as you move). +- `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.