From 4c3f81b4fa2a02f58782b5155d9a3395fe3b2fec Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Apr 2026 02:48:50 +0900 Subject: [PATCH] =?UTF-8?q?crates=E3=81=AE=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 458 ++++++++++++++---- Cargo.toml | 9 +- crates/{insomnia => daemon}/Cargo.toml | 6 +- crates/{insomnia-daemon => daemon}/src/lib.rs | 0 crates/insomnia-core/Cargo.toml | 20 - crates/insomnia-core/src/lib.rs | 9 - crates/insomnia-daemon/Cargo.toml | 10 - crates/insomnia/src/main.rs | 3 - crates/manifest/Cargo.toml | 12 + .../src/manifest.rs => manifest/src/lib.rs} | 18 +- .../{insomnia-core => manifest}/src/scope.rs | 0 crates/pod/Cargo.toml | 26 + .../examples/pod_cli.rs | 4 +- crates/pod/examples/pod_protocol.rs | 99 ++++ crates/pod/src/controller.rs | 329 +++++++++++++ crates/pod/src/lib.rs | 15 + crates/{insomnia-core => pod}/src/pod.rs | 9 +- crates/pod/src/runtime_dir.rs | 194 ++++++++ crates/pod/src/shared_state.rs | 149 ++++++ crates/pod/src/socket_server.rs | 102 ++++ crates/pod/tests/controller_test.rs | 429 ++++++++++++++++ crates/protocol/Cargo.toml | 9 + crates/protocol/src/lib.rs | 140 ++++++ crates/provider/Cargo.toml | 10 + .../src/provider.rs => provider/src/lib.rs} | 20 +- crates/tui/Cargo.toml | 12 + crates/tui/src/app.rs | 227 +++++++++ crates/tui/src/client.rs | 50 ++ crates/tui/src/main.rs | 217 +++++++++ crates/tui/src/ui.rs | 90 ++++ docs/llm_client_reqs.md | 17 - docs/persistence.md | 89 ---- docs/pod-protocol.md | 253 +--------- docs/test-fixtures.md | 158 ------ 34 files changed, 2524 insertions(+), 669 deletions(-) rename crates/{insomnia => daemon}/Cargo.toml (55%) rename crates/{insomnia-daemon => daemon}/src/lib.rs (100%) delete mode 100644 crates/insomnia-core/Cargo.toml delete mode 100644 crates/insomnia-core/src/lib.rs delete mode 100644 crates/insomnia-daemon/Cargo.toml delete mode 100644 crates/insomnia/src/main.rs create mode 100644 crates/manifest/Cargo.toml rename crates/{insomnia-core/src/manifest.rs => manifest/src/lib.rs} (90%) rename crates/{insomnia-core => manifest}/src/scope.rs (100%) create mode 100644 crates/pod/Cargo.toml rename crates/{insomnia-core => pod}/examples/pod_cli.rs (94%) create mode 100644 crates/pod/examples/pod_protocol.rs create mode 100644 crates/pod/src/controller.rs create mode 100644 crates/pod/src/lib.rs rename crates/{insomnia-core => pod}/src/pod.rs (95%) create mode 100644 crates/pod/src/runtime_dir.rs create mode 100644 crates/pod/src/shared_state.rs create mode 100644 crates/pod/src/socket_server.rs create mode 100644 crates/pod/tests/controller_test.rs create mode 100644 crates/protocol/Cargo.toml create mode 100644 crates/protocol/src/lib.rs create mode 100644 crates/provider/Cargo.toml rename crates/{insomnia-core/src/provider.rs => provider/src/lib.rs} (77%) create mode 100644 crates/tui/Cargo.toml create mode 100644 crates/tui/src/app.rs create mode 100644 crates/tui/src/client.rs create mode 100644 crates/tui/src/main.rs create mode 100644 crates/tui/src/ui.rs delete mode 100644 docs/llm_client_reqs.md delete mode 100644 docs/persistence.md delete mode 100644 docs/test-fixtures.md diff --git a/Cargo.lock b/Cargo.lock index 09cd91c1..6e27ff8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -108,6 +114,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.59" @@ -170,6 +191,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -186,6 +221,74 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "daemon" +version = "0.1.0" +dependencies = [ + "manifest", + "protocol", + "tokio", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -209,6 +312,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "equivalent" version = "1.0.2" @@ -427,6 +536,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -645,6 +756,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -679,36 +796,25 @@ dependencies = [ ] [[package]] -name = "insomnia" -version = "0.1.0" +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ - "insomnia-core", - "insomnia-daemon", - "tokio", + "rustversion", ] [[package]] -name = "insomnia-core" -version = "0.1.0" +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "dotenv", - "llm-worker", - "llm-worker-persistence", - "serde", - "tempfile", - "thiserror", - "tokio", - "toml 0.8.23", - "uuid", -] - -[[package]] -name = "insomnia-daemon" -version = "0.1.0" -dependencies = [ - "insomnia-core", - "llm-worker-persistence", - "tokio", + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -733,6 +839,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -769,6 +884,12 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -843,6 +964,24 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "manifest" +version = "0.1.0" +dependencies = [ + "serde", + "tempfile", + "toml", +] + [[package]] name = "matchers" version = "0.2.0" @@ -871,6 +1010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -990,6 +1130,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1008,6 +1154,27 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "pod" +version = "0.1.0" +dependencies = [ + "async-trait", + "dotenv", + "futures", + "llm-worker", + "llm-worker-persistence", + "manifest", + "protocol", + "provider", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "toml", + "uuid", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1036,6 +1203,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "provider" +version = "0.1.0" +dependencies = [ + "llm-worker", + "manifest", + "thiserror", +] + [[package]] name = "quote" version = "1.0.45" @@ -1051,6 +1235,27 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1151,6 +1356,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1160,7 +1378,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1203,6 +1421,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "schannel" version = "0.1.29" @@ -1326,15 +1550,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -1359,6 +1574,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" @@ -1397,12 +1633,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1455,7 +1719,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1568,18 +1832,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -1588,20 +1840,11 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.1", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", + "winnow", ] [[package]] @@ -1613,35 +1856,15 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -1772,7 +1995,18 @@ dependencies = [ "serde_json", "target-triple", "termcolor", - "toml 1.1.2+spec-1.1.0", + "toml", +] + +[[package]] +name = "tui" +version = "0.1.0" +dependencies = [ + "crossterm", + "protocol", + "ratatui", + "serde_json", + "tokio", ] [[package]] @@ -1781,6 +2015,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1986,6 +2249,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -1995,6 +2274,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2083,15 +2368,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index a5aee71a..38ca9700 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,15 @@ [workspace] resolver = "2" members = [ - "crates/insomnia", - "crates/insomnia-core", - "crates/insomnia-daemon", + "crates/daemon", "crates/llm-worker", "crates/llm-worker-macros", "crates/llm-worker-persistence", + "crates/manifest", + "crates/pod", + "crates/protocol", + "crates/provider", + "crates/tui", ] [workspace.package] diff --git a/crates/insomnia/Cargo.toml b/crates/daemon/Cargo.toml similarity index 55% rename from crates/insomnia/Cargo.toml rename to crates/daemon/Cargo.toml index a10bb47f..71494bae 100644 --- a/crates/insomnia/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "insomnia" +name = "daemon" version = "0.1.0" edition.workspace = true license.workspace = true [dependencies] -insomnia-core = { path = "../insomnia-core" } -insomnia-daemon = { path = "../insomnia-daemon" } +manifest = { path = "../manifest" } +protocol = { path = "../protocol" } tokio = { version = "1.49", features = ["full"] } diff --git a/crates/insomnia-daemon/src/lib.rs b/crates/daemon/src/lib.rs similarity index 100% rename from crates/insomnia-daemon/src/lib.rs rename to crates/daemon/src/lib.rs diff --git a/crates/insomnia-core/Cargo.toml b/crates/insomnia-core/Cargo.toml deleted file mode 100644 index c516301b..00000000 --- a/crates/insomnia-core/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "insomnia-core" -version = "0.1.0" -edition.workspace = true -license.workspace = true - -[dependencies] -llm-worker = { path = "../llm-worker" } -llm-worker-persistence = { path = "../llm-worker-persistence" } -serde = { version = "1.0", features = ["derive"] } -toml = "0.8" -uuid = { version = "1", features = ["v7", "serde"] } -thiserror = "2.0" -tokio = { version = "1.49", features = ["fs"] } - -[dev-dependencies] -tokio = { version = "1.49", features = ["macros", "rt-multi-thread"] } -tempfile = "3.24" -dotenv = "0.15" -llm-worker-persistence = { path = "../llm-worker-persistence" } diff --git a/crates/insomnia-core/src/lib.rs b/crates/insomnia-core/src/lib.rs deleted file mode 100644 index 52aa4488..00000000 --- a/crates/insomnia-core/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod manifest; -pub mod pod; -pub mod provider; -pub mod scope; - -pub use manifest::{PodManifest, ProviderConfig, ProviderKind}; -pub use pod::{Pod, PodError, PodId, PodRunResult, apply_worker_manifest, new_pod_id}; -pub use provider::build_client; -pub use scope::Scope; diff --git a/crates/insomnia-daemon/Cargo.toml b/crates/insomnia-daemon/Cargo.toml deleted file mode 100644 index 1f87257c..00000000 --- a/crates/insomnia-daemon/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "insomnia-daemon" -version = "0.1.0" -edition.workspace = true -license.workspace = true - -[dependencies] -insomnia-core = { path = "../insomnia-core" } -llm-worker-persistence = { path = "../llm-worker-persistence" } -tokio = { version = "1.49", features = ["full"] } diff --git a/crates/insomnia/src/main.rs b/crates/insomnia/src/main.rs deleted file mode 100644 index a9afc230..00000000 --- a/crates/insomnia/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("insomnia"); -} diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml new file mode 100644 index 00000000..2e17cfb9 --- /dev/null +++ b/crates/manifest/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "manifest" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } +toml = "1.1.2" + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/insomnia-core/src/manifest.rs b/crates/manifest/src/lib.rs similarity index 90% rename from crates/insomnia-core/src/manifest.rs rename to crates/manifest/src/lib.rs index 7d414ee8..3658fc09 100644 --- a/crates/insomnia-core/src/manifest.rs +++ b/crates/manifest/src/lib.rs @@ -1,12 +1,16 @@ +mod scope; + +pub use scope::Scope; + use std::path::PathBuf; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// Declarative configuration for a Pod. /// /// Parsed from a TOML manifest file. Describes the provider, model, /// system prompt, and optional directory scope. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct PodManifest { pub pod: PodMeta, pub provider: ProviderConfig, @@ -16,13 +20,13 @@ pub struct PodManifest { } /// Pod metadata. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct PodMeta { pub name: String, } /// LLM provider configuration. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderConfig { pub kind: ProviderKind, pub model: String, @@ -35,7 +39,7 @@ pub struct ProviderConfig { } /// Supported LLM providers. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProviderKind { Anthropic, @@ -45,7 +49,7 @@ pub enum ProviderKind { } /// Worker-level configuration embedded in the manifest. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkerManifest { #[serde(default)] pub system_prompt: Option, @@ -56,7 +60,7 @@ pub struct WorkerManifest { } /// Directory scope configuration. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScopeConfig { pub root: PathBuf, } diff --git a/crates/insomnia-core/src/scope.rs b/crates/manifest/src/scope.rs similarity index 100% rename from crates/insomnia-core/src/scope.rs rename to crates/manifest/src/scope.rs diff --git a/crates/pod/Cargo.toml b/crates/pod/Cargo.toml new file mode 100644 index 00000000..524a7cff --- /dev/null +++ b/crates/pod/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "pod" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +llm-worker = { version = "0.2.1", path = "../llm-worker" } +llm-worker-persistence = { version = "0.1.0", path = "../llm-worker-persistence" } +manifest = { version = "0.1.0", path = "../manifest" } +protocol = { version = "0.1.0", path = "../protocol" } +provider = { version = "0.1.0", path = "../provider" } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +thiserror = "2.0" +tokio = { version = "1.49", features = ["fs", "io-util", "net", "sync"] } +toml = "1.1.2" +uuid = { version = "1.23.0", features = ["v7", "serde"] } + +[dev-dependencies] +async-trait = "0.1.89" +dotenv = "0.15.0" +futures = "0.3.32" +llm-worker-persistence = { path = "../llm-worker-persistence" } +tempfile = "3.27.0" +tokio = { version = "1.49", features = ["macros", "rt-multi-thread", "time"] } diff --git a/crates/insomnia-core/examples/pod_cli.rs b/crates/pod/examples/pod_cli.rs similarity index 94% rename from crates/insomnia-core/examples/pod_cli.rs rename to crates/pod/examples/pod_cli.rs index aa98da64..9842fd49 100644 --- a/crates/insomnia-core/examples/pod_cli.rs +++ b/crates/pod/examples/pod_cli.rs @@ -8,10 +8,10 @@ //! //! ```bash //! echo "ANTHROPIC_API_KEY=your-key" > .env -//! cargo run -p insomnia-core --example pod_cli +//! cargo run -p pod --example pod_cli //! ``` -use insomnia_core::{Pod, PodManifest, PodRunResult}; +use pod::{Pod, PodManifest, PodRunResult}; use llm_worker_persistence::FsStore; const MANIFEST_TOML: &str = r#" diff --git a/crates/pod/examples/pod_protocol.rs b/crates/pod/examples/pod_protocol.rs new file mode 100644 index 00000000..516eb6c8 --- /dev/null +++ b/crates/pod/examples/pod_protocol.rs @@ -0,0 +1,99 @@ +//! Pod Protocol example: control a Pod via PodHandle and stream events. +//! +//! ```bash +//! echo "ANTHROPIC_API_KEY=your-key" > .env +//! cargo run -p pod --example pod_protocol +//! ``` + +use pod::{Event, Method, PodController, PodManifest}; +use llm_worker_persistence::FsStore; + +const MANIFEST_TOML: &str = r#" +[pod] +name = "protocol-demo" + +[provider] +kind = "anthropic" +model = "claude-sonnet-4-20250514" +api_key_env = "ANTHROPIC_API_KEY" + +[worker] +system_prompt = "You are a concise assistant. Reply in one or two sentences." +max_tokens = 256 +"#; + +#[tokio::main] +async fn main() -> Result<(), Box> { + dotenv::dotenv().ok(); + + let manifest = PodManifest::from_toml(MANIFEST_TOML)?; + let tmp = tempfile::tempdir()?; + let store = FsStore::new(tmp.path()).await?; + let pod = pod::Pod::from_manifest(manifest, store, None).await?; + + let runtime_tmp = tempfile::tempdir()?; + let handle = PodController::spawn(pod, runtime_tmp.path()).await?; + + // Check initial status via shared state + println!("[shared_state] {}", handle.shared_state.status_json()); + + // Check runtime directory files + println!("[runtime_dir] {:?}", handle.runtime_dir.path()); + + // Spawn event listener + let mut rx = handle.subscribe(); + let shared = handle.shared_state.clone(); + let listener = tokio::spawn(async move { + while let Ok(event) = rx.recv().await { + match &event { + Event::TurnStart { turn } => { + println!("[turn {turn}] start"); + } + Event::TextDelta { text } => { + print!("{text}"); + } + Event::TextDone { .. } => { + println!(); + } + Event::TurnEnd { turn, result } => { + println!("[turn {turn}] end ({result:?})"); + println!("[shared_state] {}", shared.status_json()); + } + Event::ToolCallStart { name, .. } => { + println!("[tool] {name}"); + } + Event::Usage { + input_tokens, + output_tokens, + } => { + println!( + "[usage] in={} out={}", + input_tokens.unwrap_or(0), + output_tokens.unwrap_or(0) + ); + } + Event::Error { code, message } => { + println!("[error] {code:?}: {message}"); + } + _ => {} + } + } + }); + + // Send a run method + handle + .send(Method::Run { + input: "What is the capital of France?".into(), + }) + .await?; + + // Wait for completion + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + println!("\n[shared_state] final: {}", handle.shared_state.status_json()); + println!("[history] {} bytes", handle.shared_state.history_json().len()); + + drop(handle); + let _ = listener.await; + + Ok(()) +} diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs new file mode 100644 index 00000000..86bda0e6 --- /dev/null +++ b/crates/pod/src/controller.rs @@ -0,0 +1,329 @@ +use std::path::Path; +use std::sync::Arc; + +use llm_worker::hook::ToolCall; +use llm_worker::llm_client::client::LlmClient; +use llm_worker::subscriber::WorkerSubscriber; +use llm_worker::timeline::event::{ErrorEvent, UsageEvent}; +use llm_worker::timeline::{TextBlockEvent, ToolUseBlockEvent}; +use llm_worker::WorkerError; +use llm_worker_persistence::Store; +use tokio::sync::{broadcast, mpsc}; + +use crate::pod::{Pod, PodRunResult, PodError}; +use protocol::{ErrorCode, Event, Method, TurnResult}; +use crate::runtime_dir::RuntimeDir; +use crate::shared_state::{PodSharedState, PodStatus}; +use crate::socket_server::SocketServer; + +// --------------------------------------------------------------------------- +// PodHandle — client-facing, Clone-able +// --------------------------------------------------------------------------- + +#[derive(Clone)] +pub struct PodHandle { + method_tx: mpsc::Sender, + event_tx: broadcast::Sender, + pub shared_state: Arc, + pub runtime_dir: Arc, +} + +impl PodHandle { + pub async fn send(&self, method: Method) -> Result<(), mpsc::error::SendError> { + self.method_tx.send(method).await + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + + /// Broadcast an event to all listeners (including socket clients). + pub fn send_event(&self, event: Event) -> Result> { + self.event_tx.send(event) + } +} + +// --------------------------------------------------------------------------- +// PodController — actor that owns a Pod +// --------------------------------------------------------------------------- + +pub struct PodController; + +impl PodController { + pub async fn spawn( + mut pod: Pod, + runtime_base: &Path, + ) -> Result + where + C: LlmClient + 'static, + St: Store + 'static, + { + let (method_tx, mut method_rx) = mpsc::channel::(32); + let (event_tx, _) = broadcast::channel::(256); + + let manifest_toml = toml::to_string_pretty(pod.manifest()).unwrap_or_default(); + let shared_state = Arc::new(PodSharedState::new( + pod.manifest().pod.name.clone(), + pod.session_id(), + manifest_toml.clone(), + )); + + // Create runtime directory and write initial files + let runtime_dir = RuntimeDir::create(runtime_base, &pod.manifest().pod.name).await?; + runtime_dir.write_manifest(&manifest_toml).await?; + runtime_dir.write_status(&shared_state).await?; + runtime_dir.write_history(&shared_state).await?; + let runtime_dir = Arc::new(runtime_dir); + + let handle = PodHandle { + method_tx, + event_tx: event_tx.clone(), + shared_state: shared_state.clone(), + runtime_dir: runtime_dir.clone(), + }; + + // Start socket server (lives as a background task, cleaned up on drop via RuntimeDir) + let _socket_server = SocketServer::start(&handle).await?; + // Keep the server alive by moving it into the controller task + // (it will be dropped when the task ends) + + // Register the event bridge subscriber on the worker + let bridge = EventBridgeSubscriber { + event_tx: event_tx.clone(), + }; + pod.session_mut().worker.subscribe(bridge); + + // Clone cancel sender before moving pod + let cancel_tx = pod.session_mut().worker.cancel_sender(); + + tokio::spawn(async move { + // Hold socket server alive for the lifetime of the controller task + let _socket_server = _socket_server; + + loop { + let method = match method_rx.recv().await { + Some(m) => m, + None => break, + }; + + match method { + Method::Run { input } => { + if shared_state.get_status() != PodStatus::Idle { + let _ = event_tx.send(Event::Error { + code: ErrorCode::AlreadyRunning, + message: "Pod is already executing a turn".into(), + }); + continue; + } + shared_state.set_status(PodStatus::Running); + let _ = runtime_dir.write_status(&shared_state).await; + + let new_status = run_with_cancel_support( + pod.run(&input), + &mut method_rx, + &event_tx, + &cancel_tx, + &shared_state, + ) + .await; + + let items = pod.session_mut().worker.history().to_vec(); + shared_state.update_history(items); + shared_state.set_status(new_status); + let _ = runtime_dir.write_status(&shared_state).await; + let _ = runtime_dir.write_history(&shared_state).await; + } + + Method::Resume => { + if shared_state.get_status() != PodStatus::Paused { + let _ = event_tx.send(Event::Error { + code: ErrorCode::NotPaused, + message: "Pod is not paused".into(), + }); + continue; + } + shared_state.set_status(PodStatus::Running); + let _ = runtime_dir.write_status(&shared_state).await; + + let new_status = run_with_cancel_support( + pod.resume(), + &mut method_rx, + &event_tx, + &cancel_tx, + &shared_state, + ) + .await; + + let items = pod.session_mut().worker.history().to_vec(); + shared_state.update_history(items); + shared_state.set_status(new_status); + let _ = runtime_dir.write_status(&shared_state).await; + let _ = runtime_dir.write_history(&shared_state).await; + } + + Method::Cancel => { + let _ = event_tx.send(Event::Error { + code: ErrorCode::NotRunning, + message: "Pod is not running".into(), + }); + } + } + } + }); + + Ok(handle) + } +} + +/// Runs a Pod future while concurrently processing incoming methods. +/// Only `Cancel` is handled during execution; `Run` and `Resume` get errors. +async fn run_with_cancel_support( + pod_future: F, + method_rx: &mut mpsc::Receiver, + event_tx: &broadcast::Sender, + cancel_tx: &mpsc::Sender<()>, + shared_state: &Arc, +) -> PodStatus +where + F: std::future::Future>, +{ + tokio::pin!(pod_future); + + loop { + tokio::select! { + result = &mut pod_future => { + return match result { + Ok(r) => match r { + PodRunResult::Finished => PodStatus::Idle, + PodRunResult::Paused => PodStatus::Paused, + }, + Err(e) => { + let code = worker_error_code(&e); + let _ = event_tx.send(Event::Error { + code, + message: e.to_string(), + }); + PodStatus::Idle + } + }; + } + method = method_rx.recv() => { + match method { + Some(Method::Cancel) => { + let _ = cancel_tx.try_send(()); + } + Some(Method::Run { .. } | Method::Resume) => { + let _ = event_tx.send(Event::Error { + code: ErrorCode::AlreadyRunning, + message: "Pod is already executing a turn".into(), + }); + } + None => { + let _ = cancel_tx.try_send(()); + shared_state.set_status(PodStatus::Idle); + return PodStatus::Idle; + } + } + } + } + } +} + +fn worker_error_code(e: &PodError) -> ErrorCode { + match e { + PodError::Session(se) => { + use llm_worker_persistence::SessionError; + match se { + SessionError::Worker(we) => match we { + WorkerError::Tool(_) => ErrorCode::ToolError, + WorkerError::Client(_) => ErrorCode::ProviderError, + _ => ErrorCode::Internal, + }, + _ => ErrorCode::Internal, + } + } + PodError::Provider(_) => ErrorCode::ProviderError, + _ => ErrorCode::Internal, + } +} + +// --------------------------------------------------------------------------- +// EventBridgeSubscriber — bridges Worker events to broadcast channel +// --------------------------------------------------------------------------- + +struct EventBridgeSubscriber { + event_tx: broadcast::Sender, +} + +impl WorkerSubscriber for EventBridgeSubscriber { + type TextBlockScope = (); + type ToolUseBlockScope = (); + + fn on_turn_start(&mut self, turn: usize) { + let _ = self.event_tx.send(Event::TurnStart { turn }); + } + + fn on_turn_end(&mut self, turn: usize) { + let _ = self.event_tx.send(Event::TurnEnd { + turn, + result: TurnResult::Finished, + }); + } + + fn on_text_block(&mut self, _scope: &mut (), event: &TextBlockEvent) { + match event { + TextBlockEvent::Delta(text) => { + let _ = self.event_tx.send(Event::TextDelta { + text: text.clone(), + }); + } + TextBlockEvent::Start(_) | TextBlockEvent::Stop(_) => {} + } + } + + fn on_text_complete(&mut self, text: &str) { + let _ = self.event_tx.send(Event::TextDone { + text: text.to_owned(), + }); + } + + fn on_tool_use_block(&mut self, _scope: &mut (), event: &ToolUseBlockEvent) { + match event { + ToolUseBlockEvent::Start(start) => { + let _ = self.event_tx.send(Event::ToolCallStart { + id: start.id.clone(), + name: start.name.clone(), + }); + } + ToolUseBlockEvent::InputJsonDelta(json) => { + let _ = self.event_tx.send(Event::ToolCallArgsDelta { + id: String::new(), + json: json.clone(), + }); + } + ToolUseBlockEvent::Stop(_) => {} + } + } + + fn on_tool_call_complete(&mut self, call: &ToolCall) { + let _ = self.event_tx.send(Event::ToolCallDone { + id: call.id.clone(), + name: call.name.clone(), + arguments: call.input.to_string(), + }); + } + + fn on_usage(&mut self, event: &UsageEvent) { + let _ = self.event_tx.send(Event::Usage { + input_tokens: event.input_tokens, + output_tokens: event.output_tokens, + }); + } + + fn on_error(&mut self, event: &ErrorEvent) { + let _ = self.event_tx.send(Event::Error { + code: ErrorCode::ProviderError, + message: event.message.clone(), + }); + } +} diff --git a/crates/pod/src/lib.rs b/crates/pod/src/lib.rs new file mode 100644 index 00000000..b7eb0391 --- /dev/null +++ b/crates/pod/src/lib.rs @@ -0,0 +1,15 @@ +pub mod controller; +pub mod runtime_dir; +pub mod shared_state; +pub mod socket_server; + +mod pod; + +pub use controller::{PodController, PodHandle}; +pub use manifest::{PodManifest, ProviderConfig, ProviderKind, Scope}; +pub use pod::{Pod, PodError, PodId, PodRunResult, apply_worker_manifest, new_pod_id}; +pub use protocol::{ErrorCode, Event, Method, TurnResult}; +pub use provider::{ProviderError, build_client}; +pub use runtime_dir::RuntimeDir; +pub use shared_state::{PodSharedState, PodStatus}; +pub use socket_server::SocketServer; diff --git a/crates/insomnia-core/src/pod.rs b/crates/pod/src/pod.rs similarity index 95% rename from crates/insomnia-core/src/pod.rs rename to crates/pod/src/pod.rs index 0c3a100e..8b0f0dab 100644 --- a/crates/insomnia-core/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -5,8 +5,7 @@ use llm_worker_persistence::{ Session, SessionConfig, SessionError, SessionId, Store, StoreError, }; -use crate::manifest::{PodManifest, WorkerManifest}; -use crate::scope::Scope; +use manifest::{PodManifest, Scope, WorkerManifest}; /// Pod identifier. UUID v7 (time-ordered). pub type PodId = uuid::Uuid; @@ -117,7 +116,7 @@ impl Pod, St> { store: St, scope: Option, ) -> Result { - let client = crate::provider::build_client(&manifest.provider)?; + let client = provider::build_client(&manifest.provider)?; let mut worker = Worker::new(client); apply_worker_manifest(&mut worker, &manifest.worker); let session = Session::new(worker, store, SessionConfig::default()).await?; @@ -175,6 +174,6 @@ pub enum PodError { #[error("scope violation: {path} is outside the allowed directory")] ScopeViolation { path: String }, - #[error("provider configuration error: {0}")] - ProviderConfig(String), + #[error(transparent)] + Provider(#[from] provider::ProviderError), } diff --git a/crates/pod/src/runtime_dir.rs b/crates/pod/src/runtime_dir.rs new file mode 100644 index 00000000..59a8f3ea --- /dev/null +++ b/crates/pod/src/runtime_dir.rs @@ -0,0 +1,194 @@ +use std::io; +use std::path::{Path, PathBuf}; + +use tokio::fs; + +use crate::shared_state::PodSharedState; + +/// Manages the Pod's runtime directory on tmpfs. +/// +/// ```text +/// $XDG_RUNTIME_DIR/insomnia/{pod_name}/ +/// ├── pid +/// ├── status.json +/// ├── manifest.toml +/// ├── history.json +/// └── sock (created by socket listener, not by RuntimeDir) +/// ``` +/// +/// Files are written atomically (write tmp → rename). +/// The directory is removed on drop. +pub struct RuntimeDir { + path: PathBuf, +} + +impl RuntimeDir { + /// Create the runtime directory and write the PID file. + pub async fn create(base: &Path, pod_name: &str) -> Result { + let path = base.join(pod_name); + fs::create_dir_all(&path).await?; + + let pid = std::process::id().to_string(); + fs::write(path.join("pid"), pid.as_bytes()).await?; + + Ok(Self { path }) + } + + /// Create in the default base directory. + /// + /// Uses `$XDG_RUNTIME_DIR/insomnia/` if available, + /// otherwise falls back to `~/.insomnia/run/`. + pub async fn create_default(pod_name: &str) -> Result { + let base = default_base()?; + Self::create(&base, pod_name).await + } + + /// Write status.json atomically. + pub async fn write_status(&self, state: &PodSharedState) -> Result<(), io::Error> { + let content = state.status_json(); + atomic_write(&self.path.join("status.json"), content.as_bytes()).await + } + + /// Write manifest.toml (typically once at startup). + pub async fn write_manifest(&self, toml: &str) -> Result<(), io::Error> { + atomic_write(&self.path.join("manifest.toml"), toml.as_bytes()).await + } + + /// Write history.json atomically. + pub async fn write_history(&self, state: &PodSharedState) -> Result<(), io::Error> { + let content = state.history_json(); + atomic_write(&self.path.join("history.json"), content.as_bytes()).await + } + + /// Path to this Pod's runtime directory. + pub fn path(&self) -> &Path { + &self.path + } + + /// Path where the Unix socket should be created. + pub fn socket_path(&self) -> PathBuf { + self.path.join("sock") + } +} + +impl Drop for RuntimeDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// Atomic write: write to a temp file, then rename. +async fn atomic_write(target: &Path, content: &[u8]) -> Result<(), io::Error> { + let tmp = target.with_extension("tmp"); + fs::write(&tmp, content).await?; + fs::rename(&tmp, target).await?; + Ok(()) +} + +/// Resolve the default base directory for runtime data. +fn default_base() -> Result { + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + Ok(PathBuf::from(runtime_dir).join("insomnia")) + } else if let Ok(home) = std::env::var("HOME") { + Ok(PathBuf::from(home).join(".insomnia").join("run")) + } else { + Err(io::Error::new( + io::ErrorKind::NotFound, + "neither XDG_RUNTIME_DIR nor HOME is set", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shared_state::{PodSharedState, PodStatus}; + + fn test_state() -> PodSharedState { + PodSharedState::new( + "test-pod".into(), + llm_worker_persistence::new_session_id(), + "[pod]\nname = \"test-pod\"".into(), + ) + } + + #[tokio::test] + async fn creates_directory_and_pid() { + let tmp = tempfile::tempdir().unwrap(); + let rt = RuntimeDir::create(tmp.path(), "my-pod").await.unwrap(); + + assert!(rt.path().join("pid").exists()); + let pid = std::fs::read_to_string(rt.path().join("pid")).unwrap(); + assert_eq!(pid, std::process::id().to_string()); + } + + #[tokio::test] + async fn write_status_creates_file() { + let tmp = tempfile::tempdir().unwrap(); + let rt = RuntimeDir::create(tmp.path(), "my-pod").await.unwrap(); + let state = test_state(); + + rt.write_status(&state).await.unwrap(); + + let content = std::fs::read_to_string(rt.path().join("status.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed["state"], "idle"); + assert_eq!(parsed["pod_name"], "test-pod"); + } + + #[tokio::test] + async fn write_status_reflects_changes() { + let tmp = tempfile::tempdir().unwrap(); + let rt = RuntimeDir::create(tmp.path(), "my-pod").await.unwrap(); + let state = test_state(); + + state.set_status(PodStatus::Running); + rt.write_status(&state).await.unwrap(); + + let content = std::fs::read_to_string(rt.path().join("status.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed["state"], "running"); + } + + #[tokio::test] + async fn write_manifest_creates_file() { + let tmp = tempfile::tempdir().unwrap(); + let rt = RuntimeDir::create(tmp.path(), "my-pod").await.unwrap(); + + rt.write_manifest("[pod]\nname = \"test\"").await.unwrap(); + + let content = std::fs::read_to_string(rt.path().join("manifest.toml")).unwrap(); + assert_eq!(content, "[pod]\nname = \"test\""); + } + + #[tokio::test] + async fn write_history_creates_file() { + let tmp = tempfile::tempdir().unwrap(); + let rt = RuntimeDir::create(tmp.path(), "my-pod").await.unwrap(); + let state = test_state(); + + rt.write_history(&state).await.unwrap(); + + let content = std::fs::read_to_string(rt.path().join("history.json")).unwrap(); + assert_eq!(content, "[]"); + } + + #[tokio::test] + async fn socket_path() { + let tmp = tempfile::tempdir().unwrap(); + let rt = RuntimeDir::create(tmp.path(), "my-pod").await.unwrap(); + assert_eq!(rt.socket_path(), rt.path().join("sock")); + } + + #[tokio::test] + async fn drop_removes_directory() { + let tmp = tempfile::tempdir().unwrap(); + let dir_path; + { + let rt = RuntimeDir::create(tmp.path(), "my-pod").await.unwrap(); + dir_path = rt.path().to_owned(); + assert!(dir_path.exists()); + } + assert!(!dir_path.exists()); + } +} diff --git a/crates/pod/src/shared_state.rs b/crates/pod/src/shared_state.rs new file mode 100644 index 00000000..1bf09121 --- /dev/null +++ b/crates/pod/src/shared_state.rs @@ -0,0 +1,149 @@ +use std::sync::RwLock; + +use llm_worker::llm_client::types::Item; +use llm_worker_persistence::SessionId; +use serde::{Deserialize, Serialize}; + +/// Shared state between PodController and runtime directory. +/// +/// Controller updates this in-memory; RuntimeDir writes it to disk. +/// Wrapped in `Arc` for sharing. +pub struct PodSharedState { + pub pod_name: String, + pub session_id: SessionId, + pub manifest_toml: String, + pub status: RwLock, + pub history: RwLock>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PodStatus { + Idle, + Running, + Paused, +} + +impl PodSharedState { + pub fn new( + pod_name: String, + session_id: SessionId, + manifest_toml: String, + ) -> Self { + Self { + pod_name, + session_id, + manifest_toml, + status: RwLock::new(PodStatus::Idle), + history: RwLock::new(Vec::new()), + } + } + + pub fn set_status(&self, status: PodStatus) { + if let Ok(mut s) = self.status.write() { + *s = status; + } + } + + pub fn get_status(&self) -> PodStatus { + self.status.read().map(|s| *s).unwrap_or(PodStatus::Idle) + } + + pub fn update_history(&self, items: Vec) { + if let Ok(mut h) = self.history.write() { + *h = items; + } + } + + /// Serialize status as JSON. + pub fn status_json(&self) -> String { + let status = self.get_status(); + serde_json::json!({ + "state": status, + "session_id": self.session_id.to_string(), + "pod_name": self.pod_name, + }) + .to_string() + } + + /// Serialize history as JSON. + pub fn history_json(&self) -> String { + if let Ok(h) = self.history.read() { + serde_json::to_string(&*h).unwrap_or_else(|_| "[]".into()) + } else { + "[]".into() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use llm_worker::llm_client::types::{ContentPart, Item, Role}; + + fn test_state() -> PodSharedState { + PodSharedState::new( + "test-pod".into(), + llm_worker_persistence::new_session_id(), + "[pod]\nname = \"test-pod\"".into(), + ) + } + + #[test] + fn initial_status_is_idle() { + let state = test_state(); + assert_eq!(state.get_status(), PodStatus::Idle); + } + + #[test] + fn set_and_get_status() { + let state = test_state(); + state.set_status(PodStatus::Running); + assert_eq!(state.get_status(), PodStatus::Running); + state.set_status(PodStatus::Paused); + assert_eq!(state.get_status(), PodStatus::Paused); + } + + #[test] + fn status_json_contains_fields() { + let state = test_state(); + let json = state.status_json(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["state"], "idle"); + assert_eq!(parsed["pod_name"], "test-pod"); + assert!(parsed["session_id"].is_string()); + } + + #[test] + fn status_json_reflects_changes() { + let state = test_state(); + state.set_status(PodStatus::Running); + let json = state.status_json(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["state"], "running"); + } + + #[test] + fn history_json_empty_initially() { + let state = test_state(); + assert_eq!(state.history_json(), "[]"); + } + + #[test] + fn history_json_after_update() { + let state = test_state(); + let items = vec![Item::Message { + id: None, + role: Role::Assistant, + content: vec![ContentPart::Text { + text: "Hello".into(), + }], + status: None, + }]; + state.update_history(items); + let json = state.history_json(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed.is_array()); + assert_eq!(parsed[0]["role"], "assistant"); + } +} diff --git a/crates/pod/src/socket_server.rs b/crates/pod/src/socket_server.rs new file mode 100644 index 00000000..87293b6a --- /dev/null +++ b/crates/pod/src/socket_server.rs @@ -0,0 +1,102 @@ +use std::io; +use std::path::PathBuf; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixListener; +use tokio::task::JoinHandle; + +use crate::controller::PodHandle; +use protocol::{Event, Method}; + +/// Unix socket server for Pod Protocol. +/// +/// Listens on the Pod's runtime directory socket path. +/// Each client connection gets bidirectional JSONL: +/// - Client writes Method lines → forwarded to PodController +/// - Pod events → written as Event lines to all connected clients +pub struct SocketServer { + _accept_task: JoinHandle<()>, + path: PathBuf, +} + +impl SocketServer { + /// Start listening on the PodHandle's socket path. + pub async fn start(handle: &PodHandle) -> Result { + let path = handle.runtime_dir.socket_path(); + + // Remove stale socket file if it exists + let _ = tokio::fs::remove_file(&path).await; + + let listener = UnixListener::bind(&path)?; + let handle = handle.clone(); + + let _accept_task = tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((stream, _)) => { + let handle = handle.clone(); + tokio::spawn(handle_connection(stream, handle)); + } + Err(_) => break, + } + } + }); + + Ok(Self { + _accept_task, + path, + }) + } + + /// The socket file path. + pub fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl Drop for SocketServer { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) { + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + let mut rx = handle.subscribe(); + + // Event writer: broadcast events → socket + let write_task = tokio::spawn(async move { + while let Ok(event) = rx.recv().await { + if let Ok(line) = event.to_json_line() { + let mut buf = line.into_bytes(); + buf.push(b'\n'); + if writer.write_all(&buf).await.is_err() { + break; + } + } + } + }); + + // Method reader: socket → controller + while let Ok(Some(line)) = lines.next_line().await { + if line.is_empty() { + continue; + } + match Method::from_json_line(&line) { + Ok(method) => { + let _ = handle.send(method).await; + } + Err(e) => { + // Send parse error back as an event + let _ = handle.send_event(Event::Error { + code: protocol::ErrorCode::Internal, + message: format!("invalid method: {e}"), + }); + } + } + } + + // Client disconnected — stop the write task + write_task.abort(); +} diff --git a/crates/pod/tests/controller_test.rs b/crates/pod/tests/controller_test.rs new file mode 100644 index 00000000..45714337 --- /dev/null +++ b/crates/pod/tests/controller_test.rs @@ -0,0 +1,429 @@ +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use futures::Stream; +use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; +use llm_worker::llm_client::{ClientError, LlmClient, Request}; +use llm_worker::Worker; +use llm_worker_persistence::FsStore; + +use pod::{ + Event, Method, Pod, PodController, PodManifest, PodStatus, +}; + +// --------------------------------------------------------------------------- +// Mock LLM Client +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct MockClient { + responses: Arc>>, + call_count: Arc, +} + +impl MockClient { + fn new(events: Vec) -> Self { + Self { + responses: Arc::new(vec![events]), + call_count: Arc::new(AtomicUsize::new(0)), + } + } +} + +#[async_trait] +impl LlmClient for MockClient { + async fn stream( + &self, + _request: Request, + ) -> Result> + Send>>, ClientError> + { + let count = self.call_count.fetch_add(1, Ordering::SeqCst); + if count >= self.responses.len() { + return Err(ClientError::Api { + status: Some(500), + code: Some("mock".into()), + message: "No more responses".into(), + }); + } + let events = self.responses[count].clone(); + let stream = futures::stream::iter(events.into_iter().map(Ok)); + Ok(Box::pin(stream)) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn simple_text_events() -> Vec { + vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "Hello"), + LlmEvent::text_delta(0, " World"), + LlmEvent::text_block_stop(0, None), + LlmEvent::Status(StatusEvent { + status: ResponseStatus::Completed, + }), + ] +} + +const MANIFEST_TOML: &str = r#" +[pod] +name = "test-pod" + +[provider] +kind = "anthropic" +model = "test-model" + +[worker] +max_tokens = 100 +"#; + +async fn make_pod(client: MockClient) -> Pod { + let manifest = PodManifest::from_toml(MANIFEST_TOML).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let store = FsStore::new(tmp.path()).await.unwrap(); + // Leak tempdir to keep it alive + std::mem::forget(tmp); + let worker = Worker::new(client); + Pod::new(manifest, worker, store, None).await.unwrap() +} + +use pod::PodHandle; + +async fn spawn_controller(pod: Pod) -> PodHandle { + let tmp = tempfile::tempdir().unwrap(); + let runtime_base = tmp.path().to_owned(); + // Leak tempdir so it survives the test + std::mem::forget(tmp); + PodController::spawn(pod, &runtime_base).await.unwrap() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn shared_state_starts_idle() { + let client = MockClient::new(simple_text_events()); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + + assert_eq!(handle.shared_state.get_status(), PodStatus::Idle); +} + +#[tokio::test] +async fn run_updates_shared_state_to_idle_after_completion() { + let client = MockClient::new(simple_text_events()); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + + handle + .send(Method::Run { + input: "Hello".into(), + }) + .await + .unwrap(); + + // Wait for the run to complete + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + assert_eq!(handle.shared_state.get_status(), PodStatus::Idle); +} + +#[tokio::test] +async fn run_populates_history() { + let client = MockClient::new(simple_text_events()); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + + handle + .send(Method::Run { + input: "Hello".into(), + }) + .await + .unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let history = handle.shared_state.history_json(); + assert_ne!(history, "[]"); + let parsed: serde_json::Value = serde_json::from_str(&history).unwrap(); + assert!(parsed.is_array()); + assert!(parsed.as_array().unwrap().len() >= 2); // user + assistant +} + +#[tokio::test] +async fn events_are_broadcast() { + let client = MockClient::new(simple_text_events()); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + let mut rx = handle.subscribe(); + + handle + .send(Method::Run { + input: "Hello".into(), + }) + .await + .unwrap(); + + let mut saw_turn_start = false; + let mut saw_text_delta = false; + let mut saw_text_done = false; + let mut saw_turn_end = false; + + // Collect events with a timeout + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + tokio::select! { + event = rx.recv() => { + match event { + Ok(Event::TurnStart { .. }) => saw_turn_start = true, + Ok(Event::TextDelta { .. }) => saw_text_delta = true, + Ok(Event::TextDone { .. }) => saw_text_done = true, + Ok(Event::TurnEnd { .. }) => { + saw_turn_end = true; + break; + } + Err(_) => break, + _ => {} + } + } + _ = tokio::time::sleep_until(deadline) => break, + } + } + + assert!(saw_turn_start, "should see turn_start"); + assert!(saw_text_delta, "should see text_delta"); + assert!(saw_text_done, "should see text_done"); + assert!(saw_turn_end, "should see turn_end"); +} + +#[tokio::test] +async fn double_run_returns_error() { + // Create a client that streams slowly + let events = vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "slow..."), + // No stop/completed — the stream will end but without proper completion + ]; + let client = MockClient::new(events); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + let mut rx = handle.subscribe(); + + // Send first run + handle + .send(Method::Run { + input: "first".into(), + }) + .await + .unwrap(); + + // Immediately send second run (should get error) + handle + .send(Method::Run { + input: "second".into(), + }) + .await + .unwrap(); + + // Look for the error event + let mut saw_already_running = false; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + tokio::select! { + event = rx.recv() => { + match event { + Ok(Event::Error { code, .. }) => { + if code == pod::ErrorCode::AlreadyRunning { + saw_already_running = true; + break; + } + } + Err(_) => break, + _ => {} + } + } + _ = tokio::time::sleep_until(deadline) => break, + } + } + + assert!(saw_already_running, "should see already_running error"); +} + +#[tokio::test] +async fn resume_without_pause_returns_error() { + let client = MockClient::new(simple_text_events()); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + let mut rx = handle.subscribe(); + + handle.send(Method::Resume).await.unwrap(); + + let mut saw_not_paused = false; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + loop { + tokio::select! { + event = rx.recv() => { + match event { + Ok(Event::Error { code, .. }) if code == pod::ErrorCode::NotPaused => { + saw_not_paused = true; + break; + } + Err(_) => break, + _ => {} + } + } + _ = tokio::time::sleep_until(deadline) => break, + } + } + + assert!(saw_not_paused, "should see not_paused error"); +} + +#[tokio::test] +async fn cancel_without_run_returns_error() { + let client = MockClient::new(simple_text_events()); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + let mut rx = handle.subscribe(); + + handle.send(Method::Cancel).await.unwrap(); + + let mut saw_not_running = false; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + loop { + tokio::select! { + event = rx.recv() => { + match event { + Ok(Event::Error { code, .. }) if code == pod::ErrorCode::NotRunning => { + saw_not_running = true; + break; + } + Err(_) => break, + _ => {} + } + } + _ = tokio::time::sleep_until(deadline) => break, + } + } + + assert!(saw_not_running, "should see not_running error"); +} + +#[tokio::test] +async fn status_json_reflects_pod_name() { + let client = MockClient::new(simple_text_events()); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + + let json = handle.shared_state.status_json(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["pod_name"], "test-pod"); +} + +// --------------------------------------------------------------------------- +// Socket transport tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn socket_run_receives_events() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::UnixStream; + + let client = MockClient::new(simple_text_events()); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + + // Give the socket server a moment to bind + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let sock_path = handle.runtime_dir.socket_path(); + let stream = UnixStream::connect(&sock_path).await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + + // Send run method via socket + writer + .write_all(b"{\"method\":\"run\",\"params\":{\"input\":\"Hello\"}}\n") + .await + .unwrap(); + + // Collect events + let mut saw_turn_start = false; + let mut saw_text_delta = false; + let mut saw_turn_end = false; + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + tokio::select! { + line = lines.next_line() => { + match line { + Ok(Some(line)) => { + let parsed: serde_json::Value = serde_json::from_str(&line).unwrap(); + match parsed["event"].as_str() { + Some("turn_start") => saw_turn_start = true, + Some("text_delta") => saw_text_delta = true, + Some("turn_end") => { + saw_turn_end = true; + break; + } + _ => {} + } + } + _ => break, + } + } + _ = tokio::time::sleep_until(deadline) => break, + } + } + + assert!(saw_turn_start, "should see turn_start via socket"); + assert!(saw_text_delta, "should see text_delta via socket"); + assert!(saw_turn_end, "should see turn_end via socket"); +} + +#[tokio::test] +async fn socket_invalid_method_returns_error() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::UnixStream; + + let client = MockClient::new(simple_text_events()); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let sock_path = handle.runtime_dir.socket_path(); + let stream = UnixStream::connect(&sock_path).await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + + // Send garbage + writer.write_all(b"{\"bad\":\"json\"}\n").await.unwrap(); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + let mut saw_error = false; + loop { + tokio::select! { + line = lines.next_line() => { + match line { + Ok(Some(line)) => { + let parsed: serde_json::Value = serde_json::from_str(&line).unwrap(); + if parsed["event"] == "error" { + saw_error = true; + break; + } + } + _ => break, + } + } + _ = tokio::time::sleep_until(deadline) => break, + } + } + + assert!(saw_error, "should see error for invalid method"); +} diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml new file mode 100644 index 00000000..ce38afeb --- /dev/null +++ b/crates/protocol/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "protocol" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs new file mode 100644 index 00000000..97a52676 --- /dev/null +++ b/crates/protocol/src/lib.rs @@ -0,0 +1,140 @@ +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Method (Client → Pod via Unix Socket) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params", rename_all = "snake_case")] +pub enum Method { + Run { input: String }, + Resume, + Cancel, +} + +impl Method { + pub fn from_json_line(line: &str) -> Result { + serde_json::from_str(line) + } +} + +// --------------------------------------------------------------------------- +// Event (Pod → Client via Unix Socket broadcast) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", content = "data", rename_all = "snake_case")] +pub enum Event { + TurnStart { + turn: usize, + }, + TurnEnd { + turn: usize, + result: TurnResult, + }, + TextDelta { + text: String, + }, + TextDone { + text: String, + }, + ToolCallStart { + id: String, + name: String, + }, + ToolCallArgsDelta { + id: String, + json: String, + }, + ToolCallDone { + id: String, + name: String, + arguments: String, + }, + ToolResult { + id: String, + output: String, + is_error: bool, + }, + Usage { + input_tokens: Option, + output_tokens: Option, + }, + Error { + code: ErrorCode, + message: String, + }, +} + +impl Event { + pub fn to_json_line(&self) -> Result { + serde_json::to_string(self) + } +} + +// --------------------------------------------------------------------------- +// Supporting types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnResult { + Finished, + Paused, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + AlreadyRunning, + NotRunning, + NotPaused, + ProviderError, + ToolError, + Internal, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn method_run_json_roundtrip() { + let json = r#"{"method":"run","params":{"input":"Hello"}}"#; + let method = Method::from_json_line(json).unwrap(); + assert!(matches!(method, Method::Run { ref input } if input == "Hello")); + + let serialized = serde_json::to_string(&method).unwrap(); + assert_eq!(serialized, json); + } + + #[test] + fn method_without_params() { + let json = r#"{"method":"resume"}"#; + let method = Method::from_json_line(json).unwrap(); + assert!(matches!(method, Method::Resume)); + } + + #[test] + fn event_text_delta_format() { + let event = Event::TextDelta { + text: "Hello".into(), + }; + let json = event.to_json_line().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["event"], "text_delta"); + assert_eq!(parsed["data"]["text"], "Hello"); + } + + #[test] + fn event_error_format() { + let event = Event::Error { + code: ErrorCode::AlreadyRunning, + message: "Pod is already executing a turn".into(), + }; + let json = event.to_json_line().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["event"], "error"); + assert_eq!(parsed["data"]["code"], "already_running"); + } +} diff --git a/crates/provider/Cargo.toml b/crates/provider/Cargo.toml new file mode 100644 index 00000000..3a365b78 --- /dev/null +++ b/crates/provider/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "provider" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +llm-worker = { version = "0.2.1", path = "../llm-worker" } +manifest = { version = "0.1.0", path = "../manifest" } +thiserror = "2.0" diff --git a/crates/insomnia-core/src/provider.rs b/crates/provider/src/lib.rs similarity index 77% rename from crates/insomnia-core/src/provider.rs rename to crates/provider/src/lib.rs index 5ecf8958..c68de706 100644 --- a/crates/insomnia-core/src/provider.rs +++ b/crates/provider/src/lib.rs @@ -4,24 +4,30 @@ use llm_worker::llm_client::providers::gemini::GeminiClient; use llm_worker::llm_client::providers::ollama::OllamaClient; use llm_worker::llm_client::providers::openai::OpenAIClient; -use crate::manifest::{ProviderConfig, ProviderKind}; -use crate::pod::PodError; +use manifest::{ProviderConfig, ProviderKind}; + +/// Errors from provider client construction. +#[derive(Debug, thiserror::Error)] +pub enum ProviderError { + #[error("provider configuration error: {0}")] + Config(String), +} /// Build an [`LlmClient`] from a [`ProviderConfig`]. /// /// Resolves the API key from the environment variable specified in the config. -pub fn build_client(config: &ProviderConfig) -> Result, PodError> { +pub fn build_client(config: &ProviderConfig) -> Result, ProviderError> { let api_key = config .api_key_env .as_deref() .map(std::env::var) .transpose() - .map_err(|e| PodError::ProviderConfig(format!("env var: {e}")))?; + .map_err(|e| ProviderError::Config(format!("env var: {e}")))?; match config.kind { ProviderKind::Anthropic => { let key = api_key.ok_or_else(|| { - PodError::ProviderConfig("anthropic requires api_key_env".into()) + ProviderError::Config("anthropic requires api_key_env".into()) })?; let mut client = AnthropicClient::new(key, &config.model); if let Some(ref url) = config.base_url { @@ -31,7 +37,7 @@ pub fn build_client(config: &ProviderConfig) -> Result, PodEr } ProviderKind::Openai => { let key = api_key.ok_or_else(|| { - PodError::ProviderConfig("openai requires api_key_env".into()) + ProviderError::Config("openai requires api_key_env".into()) })?; let mut client = OpenAIClient::new(key, &config.model); if let Some(ref url) = config.base_url { @@ -41,7 +47,7 @@ pub fn build_client(config: &ProviderConfig) -> Result, PodEr } ProviderKind::Gemini => { let key = api_key.ok_or_else(|| { - PodError::ProviderConfig("gemini requires api_key_env".into()) + ProviderError::Config("gemini requires api_key_env".into()) })?; let mut client = GeminiClient::new(key, &config.model); if let Some(ref url) = config.base_url { diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 00000000..e8ecf914 --- /dev/null +++ b/crates/tui/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "tui" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +protocol = { path = "../protocol" } +ratatui = "0.29" +crossterm = "0.28" +tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } +serde_json = "1.0" diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs new file mode 100644 index 00000000..43d31c60 --- /dev/null +++ b/crates/tui/src/app.rs @@ -0,0 +1,227 @@ +use protocol::{Event, Method}; + +pub struct App { + pub pod_name: String, + pub connected: bool, + pub messages: Vec, + pub current_text: String, + pub input: String, + pub cursor: usize, + pub scroll: u16, + pub quit: bool, +} + +pub struct Message { + pub kind: MessageKind, + pub content: String, +} + +#[derive(Clone, Copy)] +pub enum MessageKind { + User, + Assistant, + Tool, + Error, + Status, +} + +impl App { + pub fn new(pod_name: String) -> Self { + Self { + pod_name, + connected: false, + messages: Vec::new(), + current_text: String::new(), + input: String::new(), + cursor: 0, + scroll: 0, + quit: false, + } + } + + pub fn submit_input(&mut self) -> Option { + let text = self.input.trim().to_owned(); + if text.is_empty() { + return None; + } + self.messages.push(Message { + kind: MessageKind::User, + content: text.clone(), + }); + self.input.clear(); + self.cursor = 0; + self.scroll_to_bottom(); + Some(Method::Run { input: text }) + } + + pub fn handle_pod_event(&mut self, event: Event) { + match event { + Event::TurnStart { turn } => { + self.push_status(format!("[turn {turn}] start")); + } + Event::TextDelta { text } => { + self.current_text.push_str(&text); + } + Event::TextDone { .. } => { + let text = std::mem::take(&mut self.current_text); + if !text.is_empty() { + self.messages.push(Message { + kind: MessageKind::Assistant, + content: text, + }); + self.scroll_to_bottom(); + } + } + Event::TurnEnd { turn, result } => { + // Flush any remaining text delta + if !self.current_text.is_empty() { + let text = std::mem::take(&mut self.current_text); + self.messages.push(Message { + kind: MessageKind::Assistant, + content: text, + }); + } + self.push_status(format!("[turn {turn}] end ({result:?})")); + } + Event::ToolCallStart { name, .. } => { + self.messages.push(Message { + kind: MessageKind::Tool, + content: format!("[tool] {name}"), + }); + self.scroll_to_bottom(); + } + Event::ToolCallDone { + name, arguments, .. + } => { + self.messages.push(Message { + kind: MessageKind::Tool, + content: format!("[tool] {name} done ({} bytes)", arguments.len()), + }); + self.scroll_to_bottom(); + } + Event::ToolResult { + output, is_error, .. + } => { + let prefix = if is_error { "[tool error]" } else { "[tool result]" }; + let display = if output.len() > 200 { + format!("{}...", &output[..200]) + } else { + output + }; + self.messages.push(Message { + kind: MessageKind::Tool, + content: format!("{prefix} {display}"), + }); + self.scroll_to_bottom(); + } + Event::Usage { + input_tokens, + output_tokens, + } => { + self.push_status(format!( + "[usage] in={} out={}", + input_tokens.unwrap_or(0), + output_tokens.unwrap_or(0), + )); + } + Event::Error { code, message } => { + self.messages.push(Message { + kind: MessageKind::Error, + content: format!("[{code:?}] {message}"), + }); + self.scroll_to_bottom(); + } + Event::ToolCallArgsDelta { .. } => {} + } + } + + pub fn insert_char(&mut self, c: char) { + self.input.insert(self.cursor, c); + self.cursor += c.len_utf8(); + } + + pub fn delete_char_before(&mut self) { + if self.cursor > 0 { + let prev = self.input[..self.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + self.input.drain(prev..self.cursor); + self.cursor = prev; + } + } + + pub fn delete_char_after(&mut self) { + if self.cursor < self.input.len() { + let next = self.input[self.cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| self.cursor + i) + .unwrap_or(self.input.len()); + self.input.drain(self.cursor..next); + } + } + + pub fn move_cursor_left(&mut self) { + if self.cursor > 0 { + self.cursor = self.input[..self.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + } + } + + pub fn move_cursor_right(&mut self) { + if self.cursor < self.input.len() { + self.cursor = self.input[self.cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| self.cursor + i) + .unwrap_or(self.input.len()); + } + } + + pub fn move_cursor_home(&mut self) { + self.cursor = 0; + } + + pub fn move_cursor_end(&mut self) { + self.cursor = self.input.len(); + } + + pub fn scroll_up(&mut self) { + self.scroll = self.scroll.saturating_sub(3); + } + + pub fn scroll_down(&mut self) { + self.scroll = self.scroll.saturating_add(3); + } + + /// Total visible lines (for rendering the in-progress text as part of output). + pub fn display_lines(&self) -> Vec<(&MessageKind, &str)> { + let mut lines: Vec<(&MessageKind, &str)> = self + .messages + .iter() + .map(|m| (&m.kind, m.content.as_str())) + .collect(); + if !self.current_text.is_empty() { + lines.push((&MessageKind::Assistant, &self.current_text)); + } + lines + } + + fn push_status(&mut self, content: String) { + self.messages.push(Message { + kind: MessageKind::Status, + content, + }); + self.scroll_to_bottom(); + } + + fn scroll_to_bottom(&mut self) { + // Will be clamped during rendering + self.scroll = u16::MAX; + } +} diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs new file mode 100644 index 00000000..632234e1 --- /dev/null +++ b/crates/tui/src/client.rs @@ -0,0 +1,50 @@ +use std::io; +use std::path::Path; + +use protocol::{Event, Method}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::sync::mpsc; + +pub struct PodClient { + writer: tokio::io::WriteHalf, + event_rx: mpsc::Receiver, +} + +impl PodClient { + pub async fn connect(path: &Path) -> Result { + let stream = UnixStream::connect(path).await?; + let (reader, writer) = tokio::io::split(stream); + + let (event_tx, event_rx) = mpsc::channel::(256); + + tokio::spawn(async move { + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.is_empty() { + continue; + } + if let Ok(event) = serde_json::from_str::(&line) { + if event_tx.send(event).await.is_err() { + break; + } + } + } + }); + + Ok(Self { writer, event_rx }) + } + + pub async fn send(&mut self, method: &Method) -> Result<(), io::Error> { + let json = serde_json::to_string(method) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + self.writer.write_all(json.as_bytes()).await?; + self.writer.write_all(b"\n").await?; + self.writer.flush().await?; + Ok(()) + } + + pub async fn next_event(&mut self) -> Option { + self.event_rx.recv().await + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs new file mode 100644 index 00000000..47a57309 --- /dev/null +++ b/crates/tui/src/main.rs @@ -0,0 +1,217 @@ +mod app; +mod client; +mod ui; + +use std::io; +use std::path::PathBuf; + +use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::{execute}; +use protocol::Method; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +use crate::app::App; +use crate::client::PodClient; + +fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { + if let Some(p) = override_path { + return p; + } + if let Ok(rd) = std::env::var("XDG_RUNTIME_DIR") { + PathBuf::from(rd).join("insomnia").join(pod_name).join("sock") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home) + .join(".insomnia") + .join("run") + .join(pod_name) + .join("sock") + } else { + PathBuf::from("/tmp") + .join("insomnia") + .join(pod_name) + .join("sock") + } +} + +fn parse_args() -> (String, Option) { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("usage: tui [--socket ]"); + std::process::exit(1); + } + let pod_name = args[1].clone(); + let socket = args + .windows(2) + .find(|w| w[0] == "--socket") + .map(|w| PathBuf::from(&w[1])); + (pod_name, socket) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let (pod_name, socket_override) = parse_args(); + let socket_path = resolve_socket(&pod_name, socket_override); + + // Install panic hook to restore terminal + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = terminal::disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + original_hook(info); + })); + + // Setup terminal + terminal::enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(pod_name); + + // Connect to pod + match PodClient::connect(&socket_path).await { + Ok(client) => { + app.connected = true; + run_loop(&mut terminal, &mut app, client).await?; + } + Err(e) => { + app.messages.push(app::Message { + kind: app::MessageKind::Error, + content: format!("Failed to connect to {}: {e}", socket_path.display()), + }); + // Show error and wait for quit + run_disconnected(&mut terminal, &mut app)?; + } + } + + // Restore terminal + terminal::disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + + Ok(()) +} + +async fn run_loop( + terminal: &mut Terminal>, + app: &mut App, + mut client: PodClient, +) -> Result<(), Box> { + loop { + terminal.draw(|f| ui::draw(f, app))?; + + if app.quit { + break; + } + + tokio::select! { + // Terminal input + _ = tokio::task::spawn_blocking(|| event::poll(std::time::Duration::from_millis(50))) => { + while event::poll(std::time::Duration::ZERO)? { + if let TermEvent::Key(key) = event::read()? { + if let Some(method) = handle_key(app, key) { + client.send(&method).await?; + } + if app.quit { + break; + } + } + } + } + // Pod events + event = client.next_event() => { + match event { + Some(ev) => app.handle_pod_event(ev), + None => { + app.connected = false; + app.messages.push(app::Message { + kind: app::MessageKind::Error, + content: "Connection lost".into(), + }); + } + } + } + } + } + + Ok(()) +} + +fn run_disconnected( + terminal: &mut Terminal>, + app: &mut App, +) -> Result<(), Box> { + loop { + terminal.draw(|f| ui::draw(f, app))?; + + if event::poll(std::time::Duration::from_millis(100))? { + if let TermEvent::Key(key) = event::read()? { + match key.code { + KeyCode::Esc => break, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break, + _ => {} + } + } + } + } + Ok(()) +} + +fn handle_key(app: &mut App, key: KeyEvent) -> Option { + match key.code { + KeyCode::Esc => { + app.quit = true; + None + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.quit = true; + None + } + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(Method::Resume) + } + KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(Method::Cancel) + } + KeyCode::Enter => app.submit_input(), + KeyCode::Backspace => { + app.delete_char_before(); + None + } + KeyCode::Delete => { + app.delete_char_after(); + None + } + KeyCode::Left => { + app.move_cursor_left(); + None + } + KeyCode::Right => { + app.move_cursor_right(); + None + } + KeyCode::Home => { + app.move_cursor_home(); + None + } + KeyCode::End => { + app.move_cursor_end(); + None + } + KeyCode::PageUp => { + app.scroll_up(); + None + } + KeyCode::PageDown => { + app.scroll_down(); + None + } + KeyCode::Char(c) => { + app.insert_char(c); + None + } + _ => None, + } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs new file mode 100644 index 00000000..590668ad --- /dev/null +++ b/crates/tui/src/ui.rs @@ -0,0 +1,90 @@ +use ratatui::layout::{Constraint, Layout, Position}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; + +use crate::app::{App, MessageKind}; + +pub fn draw(frame: &mut Frame, app: &mut App) { + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(3), + ]) + .split(frame.area()); + + draw_status_bar(frame, app, chunks[0]); + draw_output(frame, app, chunks[1]); + draw_input(frame, app, chunks[2]); +} + +fn draw_status_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let conn_style = if app.connected { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + }; + let conn_text = if app.connected { + "connected" + } else { + "disconnected" + }; + + let line = Line::from(vec![ + Span::styled(&app.pod_name, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" | "), + Span::styled(conn_text, conn_style), + ]); + + frame.render_widget(Paragraph::new(line), area); +} + +fn draw_output(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { + let display = app.display_lines(); + + let lines: Vec = display + .iter() + .flat_map(|(kind, content)| { + let style = kind_style(kind); + content.lines().map(move |l| Line::from(Span::styled(l.to_owned(), style))) + }) + .collect(); + + let total = lines.len() as u16; + let visible = area.height.saturating_sub(2); // block borders + let max_scroll = total.saturating_sub(visible); + if app.scroll > max_scroll { + app.scroll = max_scroll; + } + + let block = Block::default().borders(Borders::ALL); + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((app.scroll, 0)); + + frame.render_widget(paragraph, area); +} + +fn draw_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let display = format!("> {}", app.input); + let block = Block::default().borders(Borders::ALL).title("Input"); + let paragraph = Paragraph::new(display).block(block); + frame.render_widget(paragraph, area); + + // Cursor position: "> " is 2 chars, plus cursor offset in the input + let cursor_x = area.x + 1 + 2 + app.input[..app.cursor].chars().count() as u16; + let cursor_y = area.y + 1; + frame.set_cursor_position(Position::new(cursor_x, cursor_y)); +} + +fn kind_style(kind: &MessageKind) -> Style { + match kind { + MessageKind::User => Style::default().fg(Color::Green), + MessageKind::Assistant => Style::default().fg(Color::White), + MessageKind::Tool => Style::default().fg(Color::Cyan), + MessageKind::Error => Style::default().fg(Color::Red), + MessageKind::Status => Style::default().fg(Color::DarkGray), + } +} diff --git a/docs/llm_client_reqs.md b/docs/llm_client_reqs.md deleted file mode 100644 index fec2d913..00000000 --- a/docs/llm_client_reqs.md +++ /dev/null @@ -1,17 +0,0 @@ -# insomniaが求めるllmクライアントライブラリ - -- 前提: - a. userメッセージを追加しなくてもagentの途中ママ投げれば、AIはそれを自身の生成途中と認識して普通に継続する。 - b. KVキャッシュは速度・効率の面で有利で、基本的にコンテキストを重ねた後での事後的なコンテキストの改変はKVキャッシュヒット率を大幅に下げる - c. ツール・フックの基本的なスキーマ自動化を提供する - -以上を前提とし、私が求める性能: -- メッセージの送信と生成のResume、一時停止/再開が必要。 -- 暗黙的にキャッシュを保証し、キャッシュを破壊しうる操作を明示的にブロックせずとも、いつの間にかキャッシュ破壊してた状態にはしたくない。 - -## 上層の要件 - -おそらく最下層の抽象化レイヤーではなく、その上でやるべき事 - -- Hooksの実装 - - 送信コンテンツの操作やエージェントの生成にしたがって発生するイベントを適切に処理できる仕組み diff --git a/docs/persistence.md b/docs/persistence.md deleted file mode 100644 index 8887cfce..00000000 --- a/docs/persistence.md +++ /dev/null @@ -1,89 +0,0 @@ -# 永続化設計 - -## 概要 - -`llm-worker-persistence` クレートは、`llm-worker` の `Worker` セッション状態を -JSONL append-only ログとして永続化する。ログを読み込んで集約することで Worker 状態を復元する。 - -## 設計方針 - -- **JSONL append-only ログ**: 1セッション = 1つの `.jsonl` ファイル。書き込みは末尾追記のみ。 -- **Pause/正常終了で構造に差異なし**: Worker の状態は Pause 時も正常終了時も同じ形 - (`history: Vec` + `turn_count` + `request_config`)。 - `resume()` は「ユーザー入力を追加せず `run_turn_loop()` に再入する」だけなので、 - 復元に必要なのは history の中身であり、前回の終了理由ではない。 - `RunOutcome` の `Finished`/`Paused` 区分は監査用メタデータであり、状態復元の分岐には使わない。 -- **クレート分離**: `llm-worker` は永続化を知らない。`Session` ラッパーが外から Worker を包む。 - -## 命名規約 - -| 名前 | 用途 | -|---|---| -| **SessionLog / LogEntry** | 状態復元用の構造化された記録(永続化の本体) | -| **EventTrace / TraceEntry** | デバッグ用の生ストリームイベント全録(オプション、デフォルト OFF) | - -## クレート構成 - -``` -llm-worker-persistence → llm-worker → llm-worker-macros -``` - -`llm-worker-persistence` は `llm-worker` に依存するが、逆方向の依存はない。 - -## ファイル配置 - -``` -{root}/{session_id}.jsonl -- セッションログ -{root}/{session_id}.trace.jsonl -- イベントトレース(デバッグ時のみ) -``` - -`SessionId` は UUID v7(`uuid` クレート)。タイムスタンプ埋め込みで辞書順 = 時系列順。 - -## LogEntry - -各エントリは Worker の特定の状態変更に対応する: - -| エントリ | Worker 上の対応箇所 | collect_state での効果 | -|---|---|---| -| `SessionStart` | セッション開始 / fork | system_prompt, config, history を初期化 | -| `UserInput` | `worker.rs:229` | history に追加 | -| `AssistantItems` | `worker.rs:1040-1041` | history に追加 | -| `ToolResults` | `worker.rs:897-900, 1072-1076` | history に追加 | -| `HookInjectedItems` | `worker.rs:1055` | history に追加 | -| `TurnEnd` | `worker.rs:1033` | turn_count を更新 | -| `CacheLocked` | `Worker::lock()` | locked_prefix_len を設定 | -| `CacheUnlocked` | `Worker::unlock()` | locked_prefix_len を 0 に | -| `RunOutcome` | `run()` / `resume()` 終了時 | interrupted フラグのみ(監査用) | -| `ConfigChanged` | `set_*` メソッド群 | config を更新 | - -## Session ラッパー - -```rust -pub struct Session { - pub worker: Worker, - store: St, - session_id: SessionId, -} -``` - -- `Session::new()` — SessionStart を書き込み -- `Session::run()` — Worker::run() の前後で history を比較、差分をログ記録 -- `Session::resume()` — 同上 -- `Session::restore()` — ログを読み込み、状態を集約して Worker を再構築 -- `Session::fork()` — 現在の history をシードにした新セッションを作成 -- `Session::fork_at()` — 任意のログ地点から分岐 - -## Store trait - -```rust -pub trait Store: Send + Sync { - fn append(&self, id: SessionId, entry: &LogEntry) -> impl Future<...> + Send; - fn read_all(&self, id: SessionId) -> impl Future<...> + Send; - fn list_sessions(&self) -> impl Future<...> + Send; - fn create_session(&self, id: SessionId, entries: &[LogEntry]) -> impl Future<...> + Send; - fn exists(&self, id: SessionId) -> impl Future<...> + Send; - fn append_trace(&self, id: SessionId, entry: &TraceEntry) -> impl Future<...> + Send; -} -``` - -初期実装は `FsStore`(ファイルシステム JSONL)。RPITIT 使用、`async_trait` 不要。 diff --git a/docs/pod-protocol.md b/docs/pod-protocol.md index 11c8b8d2..bb2a25f4 100644 --- a/docs/pod-protocol.md +++ b/docs/pod-protocol.md @@ -1,13 +1,6 @@ -# Pod Protocol 仕様 +# Pod Protocol -## 概要 - -Pod の制御・監視に使う JSONL ベースのメッセージプロトコル。 - -トランスポートに依存しない。CLI は Pod を直接制御し、daemon は Unix socket 上でこのプロトコルを中継する。 - -- **フレーミング**: 1行 = 1 JSON オブジェクト(`\n` 区切り) -- **方向**: 双方向。クライアントはメソッドを送信し、Pod はイベントを emit する +Pod の制御・監視に使う JSONL ベースのメッセージプロトコル。トランスポートに依存しない。 ``` CLI → Pod Protocol (直接呼び出し) @@ -15,242 +8,12 @@ Native App → Pod Protocol (直接呼び出し) Web → 中央バックエンド → daemon (Unix socket) → Pod Protocol ``` -## 設計原則 +## 設計判断 -- リクエストとレスポンスの紐付けはしない。Pod は1つであり、Pod の状態遷移(イベント)を見れば何が起きているか分かる -- イベントは全リスナーに broadcast される。読み取り専用の監視も、操作側も同じストリームを受け取る -- 操作の競合は先勝ち。run 中に別の run が来たらエラーイベントを返す +- **リクエストとレスポンスの紐付けはしない**: Pod は1つであり、Pod の状態遷移(イベント)を見れば何が起きているか分かる +- **イベントは全リスナーに broadcast**: 読み取り専用の監視も、操作側も同じストリームを受け取る +- **操作の競合は先勝ち**: run 中に別の run が来たらエラーイベントを返す -## メッセージ形式 +## daemon -### クライアント → Pod(メソッド) - -```json -{"method": "", "params": {<...>}} -``` - -`params` はメソッドごとに異なる。省略可能な場合は `params` フィールド自体を省略できる。 - -### Pod → クライアント(イベント) - -```json -{"event": "", "data": {<...>}} -``` - -全リスナーに broadcast される。 - -## メソッド一覧 - -### `run` - -ユーザー入力を送信し、LLM ターンを開始する。 - -```json -{"method": "run", "params": {"input": "What is the capital of France?"}} -``` - -Pod が既に実行中の場合、エラーイベントが返る。 - -### `resume` - -Paused 状態から再開する。 - -```json -{"method": "resume"} -``` - -### `cancel` - -実行中のターンをキャンセルする。 - -```json -{"method": "cancel"} -``` - -### `get_status` - -Pod の現在の状態を要求する。応答は `status` イベントとして返る。 - -```json -{"method": "get_status"} -``` - -### `get_history` - -会話履歴を要求する。応答は `history` イベントとして返る。 - -```json -{"method": "get_history"} -``` - -## イベント一覧 - -### ターン制御 - -#### `turn_start` - -LLM ターンの開始。 - -```json -{"event": "turn_start", "data": {"turn": 1}} -``` - -#### `turn_end` - -LLM ターンの完了。 - -```json -{"event": "turn_end", "data": {"turn": 1, "result": "finished"}} -``` - -`result`: `"finished"` | `"paused"` - -### ストリーミング - -#### `text_delta` - -テキスト応答の差分。 - -```json -{"event": "text_delta", "data": {"text": "The capital"}} -``` - -#### `text_done` - -テキストブロックの完了。全文を含む。 - -```json -{"event": "text_done", "data": {"text": "The capital of France is Paris."}} -``` - -#### `thinking_delta` - -思考プロセスの差分(extended thinking 対応モデル)。 - -```json -{"event": "thinking_delta", "data": {"text": "Let me consider..."}} -``` - -#### `thinking_done` - -思考ブロックの完了。 - -```json -{"event": "thinking_done", "data": {"text": "..."}} -``` - -### ツール - -#### `tool_call_start` - -ツール呼び出しの開始。 - -```json -{"event": "tool_call_start", "data": {"id": "call_123", "name": "search"}} -``` - -#### `tool_call_args_delta` - -ツール引数の JSON 差分(ストリーミング中)。 - -```json -{"event": "tool_call_args_delta", "data": {"id": "call_123", "json": "{\"query\":"}} -``` - -#### `tool_call_done` - -ツール呼び出しの引数確定。 - -```json -{"event": "tool_call_done", "data": {"id": "call_123", "name": "search", "arguments": "{\"query\": \"Paris\"}"}} -``` - -#### `tool_result` - -ツール実行結果。 - -```json -{"event": "tool_result", "data": {"id": "call_123", "output": "Paris is the capital...", "is_error": false}} -``` - -### 状態 - -#### `status` - -`get_status` への応答、または状態変化時に送信。 - -```json -{"event": "status", "data": {"state": "idle", "session_id": "019d6e91-...", "pod_name": "hello-pod"}} -``` - -`state`: `"idle"` | `"running"` | `"paused"` - -#### `history` - -`get_history` への応答。 - -```json -{"event": "history", "data": {"items": [...]}} -``` - -`items` は llm-worker の `Item` 配列をそのまま JSON シリアライズしたもの。 - -### メタ - -#### `usage` - -トークン使用量。 - -```json -{"event": "usage", "data": {"input_tokens": 25, "output_tokens": 150}} -``` - -#### `error` - -エラー通知。 - -```json -{"event": "error", "data": {"code": "already_running", "message": "Pod is already executing a turn"}} -``` - -エラーコード: -- `already_running` — run 中に run が来た -- `not_running` — run していないのに resume/cancel が来た -- `not_paused` — paused でないのに resume が来た -- `provider_error` — LLM プロバイダからのエラー -- `tool_error` — ツール実行エラー -- `internal` — 内部エラー - -## リスナーのライフサイクル - -1. リスナーが登録される(直接呼び出しなら関数登録、daemon 経由なら socket 接続) -2. 登録直後から Pod のイベントが流れ始める(購読手続き不要) -3. クライアントはメソッドを任意のタイミングで送信できる -4. リスナーの解除は登録解除または接続切断で行う - -## トランスポート: daemon (Unix socket) - -daemon は Pod Protocol を Unix domain socket 上で中継する薄い層。 - -- クライアントが socket に接続するとリスナーとして登録される -- メソッドは socket 経由で Pod に転送される -- 切断時にリスナーリストから除外するだけでクリーンアップ完了 - -## イベントと llm-worker の対応 - -| イベント | llm-worker ソース | -|---------------|-----------------| -| `turn_start` | Subscriber `on_turn_start` | -| `turn_end` | Subscriber `on_turn_end` + `WorkerResult` | -| `text_delta` | `TextBlockEvent::Delta` | -| `text_done` | Subscriber `on_text_complete` | -| `thinking_delta` | `ThinkingBlockEvent::Delta` | -| `thinking_done` | `ThinkingBlockEvent::Stop` | -| `tool_call_start` | `ToolUseBlockEvent::Start` | -| `tool_call_args_delta` | `ToolUseBlockEvent::InputJsonDelta` | -| `tool_call_done` | Subscriber `on_tool_call_complete` | -| `tool_result` | `PostToolCall` hook | -| `usage` | `UsageEvent` | -| `error` | `ErrorEvent` / `WorkerError` | -| `status` | Pod 状態(Pod 層が管理) | -| `history` | `Worker::history()` | +daemon は Pod Protocol を Unix domain socket 上で中継する薄い層。接続=リスナー登録、切断=リスナー解除。それだけ。 diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md deleted file mode 100644 index c72f94bf..00000000 --- a/docs/test-fixtures.md +++ /dev/null @@ -1,158 +0,0 @@ -# テスト Fixture 仕様 - -## 概要 - -テスト用 fixture は、実 API のストリーミング応答を JSONL 形式で録画したファイル。 -`MockLlmClient::from_fixture()` でロードし、API キー不要・決定的なテスト実行を実現する。 - -## ファイル形式 - -``` -{メタデータ行: JSON} -{イベント行: JSON} -{イベント行: JSON} -... -``` - -- **1行目**: メタデータ (`timestamp`, `model`, `description`) -- **2行目以降**: 録画イベント (`elapsed_ms`, `event_type`, `data`) - - `data` フィールドに `Event` の JSON 文字列が入る - -## ファイル配置 - -``` -crates/llm-worker/tests/fixtures/ - anthropic/ - simple_text.jsonl - tool_call.jsonl - long_text.jsonl - openai/ - simple_text.jsonl - tool_call.jsonl - long_text.jsonl - gemini/ - simple_text.jsonl - tool_call.jsonl - long_text.jsonl - ollama/ - simple_text.jsonl - tool_call.jsonl - long_text.jsonl -``` - -## シナリオ定義 - -### simple_text - -単純なテキスト応答。 - -| 項目 | 値 | -|---|---| -| ファイル名 | `simple_text.jsonl` | -| system prompt | `"You are a helpful assistant. Be very concise."` | -| user message | `"Say hello in one word."` | -| max_tokens | 50 | -| ツール | なし | - -**期待パターン**: -- `BlockStart(Text)` が1つ以上 -- `BlockDelta(Text)` が1つ以上 -- `BlockStop(Text)` が1つ以上 -- 応答が短い(1単語程度) - -**用途**: 基本的なストリーミング動作、Timeline テキスト収集、Worker の単純な run 完了 - -### tool_call - -ツール呼び出しを含む応答。 - -| 項目 | 値 | -|---|---| -| ファイル名 | `tool_call.jsonl` | -| system prompt | `"You are a helpful assistant. Use tools when appropriate."` | -| user message | `"What's the weather in Tokyo? Use the get_weather tool."` | -| max_tokens | 200 | -| ツール | `get_weather(city: string)` | - -**期待パターン**: -- `BlockStart(ToolUse)` を含む -- ToolUse ブロック内に `tool_call_id`, `name: "get_weather"` がある -- tool input JSON に `"city"` キーを含む - -**用途**: ToolCallCollector、Worker のツール実行フロー、Session の ToolResults ログ記録 - -### long_text - -長文テキスト応答。 - -| 項目 | 値 | -|---|---| -| ファイル名 | `long_text.jsonl` | -| system prompt | `"You are a creative writer."` | -| user message | `"Write a short story about a robot discovering a garden. It should be at least 300 words."` | -| max_tokens | 1000 | -| ツール | なし | - -**期待パターン**: -- `BlockDelta(Text)` が複数(ストリーミングチャンク) -- 最終テキストが 300 語以上 - -**用途**: ストリーミングの分割配信検証、Subscriber のデルタ受信テスト - -## 共通検証項目 - -全 fixture に対して以下を検証する(`assert_*` ヘルパー関数群): - -- `assert_events_deserialize` — 全イベントが `Event` にデシリアライズできる -- `assert_event_sequence` — BlockStart → BlockDelta → BlockStop の基本シーケンス -- `assert_usage_tokens` — `Usage` イベントが含まれる -- `assert_timeline_integration` — Timeline に流してテキスト収集できる - -## 録画手順 - -### 前提 - -- API キーが環境変数に設定されていること -- `crates/llm-worker` ディレクトリで実行 - -### コマンド - -```bash -# 単一シナリオ録画 -ANTHROPIC_API_KEY=... cargo run --example record_test_fixtures -- -s simple_text - -# 全シナリオ録画 -ANTHROPIC_API_KEY=... cargo run --example record_test_fixtures -- --all - -# プロバイダー指定 -OPENAI_API_KEY=... cargo run --example record_test_fixtures -- --all -c openai -GEMINI_API_KEY=... cargo run --example record_test_fixtures -- --all -c gemini - -# モデル指定 -ANTHROPIC_API_KEY=... cargo run --example record_test_fixtures -- --all -m claude-sonnet-4-20250514 -``` - -### シナリオ定義の場所 - -`crates/llm-worker/examples/record_test_fixtures/scenarios.rs` - -新しいシナリオを追加する場合はこのファイルに `TestScenario` を追加し、 -`scenarios()` 関数の返り値に含める。 - -## 録画後の確認チェックリスト - -録画後、テストに組み込む前に以下を手動確認する: - -- [ ] JSONL の各行が valid JSON か(`jq . < fixture.jsonl` で確認) -- [ ] 1行目にメタデータ(`timestamp`, `model`, `description`)が入っているか -- [ ] simple_text: `BlockStart` → `BlockDelta` → `BlockStop` シーケンスがあるか -- [ ] tool_call: `BlockStart` に `"block_type":"ToolUse"` を含むか -- [ ] long_text: `BlockDelta` が複数行あるか(ストリーミング分割の確認) -- [ ] 各 fixture に `Usage` イベントが含まれるか -- [ ] エラーイベントが混入していないか - -## 注意事項 - -- fixture は API の応答に依存するため、モデルバージョンアップで再録画が必要になることがある -- 録画の自動化(CI での定期録画等)は行わない。手動実行 + 目視確認のフロー -- fixture が存在しない場合、対応するテストは skip される(`if !fixture_path.exists() { return; }` パターン)