diff --git a/Cargo.lock b/Cargo.lock index 56c6148..6535387 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -82,6 +93,26 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -94,6 +125,120 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -116,6 +261,23 @@ dependencies = [ "syn", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -128,6 +290,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-config" +version = "1.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.2", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.17.0" @@ -150,6 +355,379 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-runtime" +version = "1.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c9b9de216a988dd54b754a82a7660cfe14cee4f6782ae4524470972fa0ccb39" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.2", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-bedrockruntime" +version = "1.135.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e74b780f2f36912bae71b4f4f8ed9a0a88832b4681a1add3caf5ca25dbc8ab2d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "http-body-util", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.102.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c82b3ac19f1431854f7ace3a7531674633e286bfdde21976893bfee36fd493b" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.104.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321000d2b4c5519ee573f73167f612efd7329322d9b26969ad1979f0427f1913" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.107.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0d328ba962af23ecfa3c9f23b98d3d35e325fa218d7f13d17a6bf522f8a560" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.2", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.14", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.10.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.2", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.2", +] + +[[package]] +name = "aws-smithy-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -161,10 +739,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.10.1", "hyper-util", "itoa", "matchit", @@ -192,8 +770,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -220,6 +798,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -256,18 +844,65 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -283,6 +918,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.64" @@ -321,6 +965,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -370,6 +1024,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -400,6 +1060,21 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.11.0" @@ -444,6 +1119,24 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -494,6 +1187,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.23.0" @@ -590,8 +1301,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -653,6 +1377,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -669,6 +1420,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -786,6 +1558,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -814,6 +1599,10 @@ name = "futures-timer" version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" +dependencies = [ + "gloo-timers", + "send_wrapper", +] [[package]] name = "futures-util" @@ -899,6 +1688,11 @@ name = "gitzi" version = "0.1.0" dependencies = [ "anyhow", + "aws-config", + "aws-credential-types", + "aws-sdk-bedrockruntime", + "aws-sdk-sso", + "aws-sdk-ssooidc", "axum", "base64", "chrono", @@ -907,10 +1701,12 @@ dependencies = [ "git2", "glob", "jsonwebtoken", + "keyring", "notify", "proptest", "ratatui", "reqwest 0.12.28", + "rig-bedrock", "rig-core", "serde", "serde_json", @@ -922,6 +1718,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "webbrowser", ] [[package]] @@ -930,6 +1727,37 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.14" @@ -941,7 +1769,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.2", "indexmap", "slab", "tokio", @@ -978,6 +1806,56 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.2" @@ -988,6 +1866,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -995,7 +1884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.2", ] [[package]] @@ -1006,8 +1895,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "pin-project-lite", ] @@ -1023,6 +1912,39 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.10.1" @@ -1033,9 +1955,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.14", + "http 1.4.2", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1045,18 +1967,34 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", - "hyper", + "http 1.4.2", + "hyper 1.10.1", "hyper-util", - "rustls", + "rustls 0.23.40", + "rustls-native-certs", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots 1.0.7", ] @@ -1071,14 +2009,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.2", + "http-body 1.0.1", + "hyper 1.10.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -1272,6 +2210,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instability" version = "0.3.12" @@ -1397,6 +2345,27 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "keyring" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fef88805a7ddbc8f9cf52bfa8dba90b3f80dff23a1e1533cd4f1d8e8d448fc8" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -1537,6 +2506,15 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1592,6 +2570,12 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nom" version = "7.1.3" @@ -1630,6 +2614,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1640,6 +2638,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1655,6 +2662,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1664,6 +2693,31 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1697,6 +2751,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1768,12 +2844,43 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1873,8 +2980,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", - "socket2", + "rustls 0.23.40", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -1894,7 +3001,7 @@ dependencies = [ "rand 0.9.4", "ring", "rustc-hash", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -1912,7 +3019,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -2067,6 +3174,18 @@ dependencies = [ "syn", ] +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2078,6 +3197,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -2095,25 +3220,25 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.10.1", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -2137,12 +3262,12 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.14", + "http 1.4.2", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.10.1", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", @@ -2151,14 +3276,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -2170,11 +3295,33 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rig-bedrock" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf23704f3bd6536965423f7ecff05c06c7ed03671da04f38090f026a5fef24e" +dependencies = [ + "async-stream", + "aws-config", + "aws-sdk-bedrockruntime", + "aws-smithy-types", + "base64", + "nanoid", + "rig-core", + "rig-derive", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "rig-core" -version = "0.38.2" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "557f11c26c2c2ea61d9cb843ce5adff138cc5785f58ff4b87d779de8acaa32ae" +checksum = "80a4bc7a93b329c4e1a66d5fd211d79990e7331e3c701f057c29f135f548686d" dependencies = [ "as-any", "async-stream", @@ -2185,7 +3332,8 @@ dependencies = [ "futures", "futures-timer", "glob", - "http", + "http 1.4.2", + "indexmap", "mime", "mime_guess", "nanoid", @@ -2206,9 +3354,9 @@ dependencies = [ [[package]] name = "rig-derive" -version = "0.38.2" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "643eb495acbd4bd5976164cd068f186d534f741def8d5d052f860266b98d07f8" +checksum = "5531bfa887b371eab658a92de7db35003370bbeee208ff5e68bbb81a5ae92d3d" dependencies = [ "convert_case", "deluxe", @@ -2275,6 +3423,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.40" @@ -2285,7 +3445,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -2323,10 +3483,10 @@ dependencies = [ "jni", "log", "once_cell", - "rustls", + "rustls 0.23.40", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki", + "rustls-webpki 0.103.13", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -2339,6 +3499,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -2424,6 +3594,35 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2 0.10.9", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2453,6 +3652,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -2518,6 +3723,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2546,8 +3762,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2636,6 +3874,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -2880,7 +4128,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -2896,13 +4144,23 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.40", "tokio", ] @@ -2914,10 +4172,10 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tungstenite", "webpki-roots 0.26.11", ] @@ -3028,8 +4286,8 @@ dependencies = [ "bitflags 2.13.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "pin-project-lite", "tower", "tower-layer", @@ -3137,11 +4395,11 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.2", "httparse", "log", "rand 0.9.4", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "sha1", "thiserror 2.0.18", @@ -3154,6 +4412,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -3225,6 +4494,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -3273,6 +4548,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -3460,6 +4741,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" @@ -3559,6 +4856,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -3937,6 +5247,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.3" @@ -3960,6 +5276,78 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -4045,3 +5433,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.3", +] diff --git a/Cargo.toml b/Cargo.toml index 16b0448..1b1b83a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,8 @@ serde = { version = "1", features = ["derive"] } toml = "1" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "v7", "serde"] } -rig = { package = "rig-core", version = "0.38", features = ["derive"] } +rig = { package = "rig-core", version = "0.39", features = ["derive"] } +rig-bedrock = "0.39" git2 = { version = "0.21", default-features = false, features = ["vendored-libgit2"] } clap = { version = "4", features = ["derive"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] } @@ -27,6 +28,13 @@ serde_json = "1" base64 = "0.22" jsonwebtoken = "9" glob = "0.3" +keyring = "4" +aws-config = "1" +aws-credential-types = "1" +aws-sdk-bedrockruntime = "1" +aws-sdk-sso = "1" +aws-sdk-ssooidc = "1" +webbrowser = "1" axum = { version = "0.8", features = ["macros"] } ratatui = { version = "0.29", optional = true } diff --git a/docs/ADR/002-bootstrap-setup-or-use-gate.md b/docs/ADR/002-bootstrap-setup-or-use-gate.md new file mode 100644 index 0000000..f8a322d --- /dev/null +++ b/docs/ADR/002-bootstrap-setup-or-use-gate.md @@ -0,0 +1,137 @@ +# ADR-002: Bootstrap Is a Backend-Owned "Setup or Use" Gate + +**Status:** Accepted — implemented +**Date:** 2026-06-27 + +> **Implementation:** `crate::setup` (gate, `SetupState`, shared discover/ +> activate), the daemon setup phase in `crate::daemon` (`run_setup` + socket +> commands `setup_state` / `setup_rescan` / `setup_select` streaming +> `SetupState`), `Config::fallback_provider` + read-and-report `Config::load`, +> the TUI thin renderer (`draw_setup` + the reconnecting `daemon_client` phase +> probe), and the fallback-brain recovery path in `Dispatcher::run_main_agent_turn`. + +## Context + +First-run discovery currently happens inside `Config::load`: on a missing +config file it synchronously runs `bootstrap::run()`, probes for local model +servers / SSO sessions, and writes a config with everything `enabled = false` +and no agents. Two problems follow from this: + +1. **File existence is the wrong signal.** Discovery only runs when the config + file is absent. A user who starts gitzi before installing an LLM (or before + `aws sso login`) gets a config file written once; on every subsequent run + the file exists, so we never re-check, and the app loads into a board with + zero working agents — a dead app that silently does nothing. + +2. **There is no good model we can ship inside the app.** So we cannot assume a + guaranteed local fallback agent exists to *conversationally* walk the user + through setup. The zero-state cannot be agent-driven (chicken-and-egg: the + agent that would run setup needs the provider that setup creates). + +The earlier mental model — "the discovery scan is non-blocking, so the app can +never block loading" — conflated two independent properties: the *scan* should +be fast and time-boxed, but the *gate* ("do we have a working LLM?") should +absolutely stop the user from entering a useless board. Non-blocking scan does +not imply always-load. + +## Decision + +Treat the entire experience as a binary state owned by the backend (daemon): +**setup an LLM** or **use an LLM**. There is no degraded in-between. The logic +lives in the daemon; the TUI is a thin renderer so frontends stay portable. + +### The gate is binary, evaluated every load + +On startup the daemon checks whether config resolves to an enabled provider +that exists. If not, it enters **setup mode**: the dispatcher idles, no agents +come alive, and the frontend shows setup the entire time. The gate is not +gated on file existence — it is re-evaluated every run. + +### Backend-owned setup state machine + +The daemon owns a bootstrap state machine and publishes its state over the +existing event bus: + +``` +Loading → NeedsProvider { candidates } → Error { messages, can_rescan } → Ready +``` + +Inbound commands from the frontend: `SelectProvider`, `Rescan`. Discovery, +activation, and validation all run in the daemon, asynchronously, so the +splash reflects real progress instead of blocking the process. + +- **Loading** — the time-boxed scan (LM Studio / Ollama / SSO) runs. +- **NeedsProvider** — discovered candidates are offered; the user picks one to + activate. This is the *only* human-facing setup step. Everything else (other + roles, pipeline agents) is deferred until after a valid provider exists. +- **Error** — if the scan finds nothing, or activation fails, every error + message is displayed with the ability to rescan. This is a real terminal + state of the gate, not a silent fall-through to the board. +- **Ready** — a valid provider exists; agents come alive and the chat + interface is presented. + +### The fallback LLM is the control-plane brain + +The provider activated at bootstrap is persisted as a *distinguished* fallback +provider, separate from whatever `main` is later pointed at (e.g. Bedrock). Its +job is narrow and specific: + +1. Power the setup experience itself. +2. When `main`'s own provider is absent or not responding, run the "your main + provider isn't working — what do you want to do?" conversation. + +It is **not** a transparent failover for the user's real work. We never quietly +answer a `main` prompt with the fallback. If there is no valid LLM at all, we +are not in "use" — we are back in setup. The recovery conversation is itself +LLM-driven *by the fallback brain*, not a fixed menu. + +### `Config::load` stops discovering + +`Config::load` becomes pure read-and-report. The scan/activate/validate logic +moves into the daemon's setup phase. Load reporting "nothing valid" is a normal +outcome that drives the state machine into setup mode rather than an error. + +### One implementation, two entry points + +`gitzi_rediscover_providers` / `gitzi_activate_provider` become thin wrappers +over the same backend setup logic. The pre-agent TUI path (during bootstrap) +and the post-bootstrap agent-driven path share a single implementation, so +there is one source of truth for "discover" and "activate". + +## Consequences + +- The TUI is a switch over `SetupState` (splash / picker / error+rescan / chat) + plus relaying `SelectProvider` / `Rescan`. No setup logic in the frontend — + it can be ported to other frontends by re-rendering the same backend state. +- The daemon must support running with no live agents (setup mode) as a + first-class state, not an error. +- A new distinguished "fallback provider" concept is added to config, with its + own activation and persistence, separate from per-agent `provider` fields. +- Discovery moving out of `Config::load` means load no longer has side effects + (no config rewrite to seed disabled providers); seeding happens in the setup + phase instead. +- Every startup pays a scan only when the gate is unsatisfied; once a valid + provider exists, startup goes straight to `Ready`. + +## Alternatives Considered + +1. **Transparent per-request failover to the fallback** — Rejected. It hides a + broken `main` from the user and silently changes which model does their + work. The binary setup/use model keeps the user in control: a broken `main` + triggers an explicit recovery conversation, not a silent swap. + +2. **Ship a guaranteed local fallback model so setup can be conversational from + the zero-state** — Rejected. There is no model good enough to embed in the + app. Setup must therefore be backend-logic-driven (no LLM required) with the + TUI as a plain renderer. + +3. **Keep setup orchestration in the TUI** — Rejected. "The human interacts + here" is not "the logic lives here." Putting discovery/activation/validation + in the frontend would have to be reimplemented for every future frontend. + The backend owns the state machine; the frontend renders it. + +4. **Gate on config-file existence (status quo)** — Rejected. It only checks + once, so a user who sets up an LLM after first run is never re-discovered, + and a user whose provider later breaks is dropped into a dead board. The + gate must be re-evaluated every load against whether a provider actually + resolves. diff --git a/kb/configuration.md b/kb/configuration.md index b7e77ed..9a280b6 100644 --- a/kb/configuration.md +++ b/kb/configuration.md @@ -31,18 +31,42 @@ Per-column work-in-progress limit overrides. Columns not listed keep built-in de - Type: table (column name → integer) - Example: `coding = 2` +### `fallback_provider` +The distinguished "control-plane" provider chosen during bootstrap setup +(see ADR-002). It powers the setup experience and the recovery conversation +when the main agent's own provider is absent or not responding. Set +automatically on the first provider activation; names a key in `[providers]`. +- Type: string (optional) +- You normally never edit this by hand — it's written during setup. + ### `[providers.]` -Named LLM provider endpoints. -- `api_url`: OpenAI-compatible endpoint URL -- `api_key`: API key (plaintext — this file is never committed) +Named LLM provider endpoints. On first run gitzi has no valid provider and +enters **setup mode** (ADR-002): the daemon scans for LM Studio / Ollama / AWS +SSO sessions and the TUI shows a one-step picker to activate one. The chosen +provider is wired into the main agent and recorded as `fallback_provider`. +Providers discovered later are recorded here with `enabled = false` — use the +main agent's `gitzi_rediscover_providers`/`gitzi_activate_provider` chat tools to +see what's available and turn one on, rather than editing this file by hand. +- `kind`: `"openai-compatible"` (default) or `"bedrock"` +- `api_url`: OpenAI-compatible endpoint URL. Unused for `bedrock`. +- `api_key`: API key for OpenAI-compatible providers — plaintext or a + `keyring:/` pointer (gitzi migrates plaintext keys into the OS + keyring automatically on load). +- `region`: AWS region, for `bedrock` providers. +- `sso_start_url`, `sso_account_id`, `sso_role_name`: AWS SSO identifiers for `bedrock` + providers, filled in during activation. Credentials are handed to the AWS SDK + in-process via `crate::aws_sso::SsoCredentialsProvider` — gitzi never writes to + `~/.aws/config` or any other cloud CLI's config files. +- `model_id`: Bedrock model ID, e.g. `"anthropic.claude-sonnet-4-6-v1:0"`. +- `enabled`: Whether this provider is actually wired into any agent. Discovered + providers default to `false` until explicitly activated. ### `[[agents]]` Agent definitions. Each entry defines a role with its model and behavior. -- `role`: One of: main, prioritizer, designer, coder, reviewer, tester, auditor, infrarian +- `role`: One of: main, prioritizer, designer, coder, reviewer, auditor, infrarian - `model`: Model identifier passed to the API - `api_url`: Direct endpoint (overrides provider) - `provider`: Reference a named provider -- `system_prompt`: Override the built-in system prompt ### `[[repos]]` Per-repository configuration overrides. diff --git a/plan.md b/plan.md index 2bef4f5..55c88d2 100644 --- a/plan.md +++ b/plan.md @@ -925,31 +925,43 @@ First-time experience when a user runs `gitzi` with no existing `~/.gitzi/`. ### First-run flow ``` -loader/spinner (auto-discover LLM providers + repos) - → generate config.toml - → if multiple providers: onboarding selection UI - → launch TUI (Status panel shows discovery results) +quick non-blocking scan (discover LLM providers + repos) + → generate config.toml (providers recorded, none enabled/wired) + → launch TUI immediately (Status panel shows discovery results) → main agent: "So what are we going to do next?" ``` +Discovery never starts a server, loads a model, or opens a browser for SSO login — +onboarding must never block waiting on any of those. Every provider found is written to +`[providers.*]` with `enabled = false` and not wired into `[[agents]]`; every role falls +back to the local `claude` CLI subprocess until the user explicitly activates a provider +through the main agent's `gitzi_rediscover_providers`/`gitzi_activate_provider` tools +(see `src/bootstrap.rs`, `src/agent/main_agent.rs`). + ### Provider discovery priority order -1. **CLI-based LLMs already running** (highest — zero friction) -2. **Installed but not running** — show guidance, do NOT auto-start -3. **Not installed** — skip +Priority only affects *display* sort on the Status panel — discovery never auto-starts, +auto-loads a model, or auto-activates a provider: + +1. **Running with a model loaded** — zero friction to activate +2. **Running but no model loaded** +3. **Installed but not running** +4. **Not installed** — skipped, not recorded -| Provider | Binary | Detection | Start | -|----------|--------|-----------|-------| -| LM Studio | `~/.lmstudio/bin/lms` | `lms server status` | `lms server start` | -| Ollama | `ollama` | `ollama list` / port 11434 | `ollama serve` | -| Claude CLI | `claude` | `which claude` | N/A (API key) | -| OpenCode | `opencode` | `which opencode` | TBD | -| Goose | `goose` | `which goose` | TBD | -| Aider | `aider` | `which aider` | TBD | +| Provider | Detected via | Kind | +|----------|---------------|------| +| LM Studio | `~/.lmstudio/bin/lms` exists; port 1234 + `/v1/models` for running/model-loaded | `openai-compatible` | +| Ollama | `which ollama`; port 11434 + `/v1/models` for running/model-loaded | `openai-compatible` | +| AWS Bedrock | `[sso-session NAME]` blocks in `~/.aws/config` (one candidate per session), else a generic candidate if the `aws` CLI is installed | `bedrock` | -For "installed but not running": display guidance on Status panel ("X is installed but -not running. Please start it and load a model."). For "running but no model loaded": -use `/v1/models` HTTP endpoint to check, then force-load first downloaded text model. +Claude CLI/OpenCode/Goose/Aider are not auto-discovered providers — they remain +available as the implicit fallback (the local `claude` CLI subprocess) for any role not +wired to a provider. + +For "installed but not running": Status panel shows "X is installed but not running. +Start it and load a model, then ask me to rediscover providers." For "running but no +model loaded": same guidance, using `/v1/models` to check what's loaded (more reliable +than the CLI) — never force-loads a model. ### Config three-layer architecture @@ -972,11 +984,17 @@ Resolution: `Final = hardcoded ?? user_config ?? default` - Invalid roles → silently stripped, config.toml rewritten - Removed fields: `default_agent`, `test_command`, `system_prompt` in `[[agents]]` -### Selection logic +### Activation logic + +Nothing is enabled or wired into `[[agents]]` by discovery — activation is always an +explicit, user-initiated step via chat: -- Single provider found → auto-use, no question -- Multiple in same category → ask user which to use (onboarding selection flow) -- Prefer: already-running > needs-start, local > API-key-required +- 1 or many providers found → all listed, none enabled; the user picks which (if any) to + activate. +- Activating an OpenAI-compatible provider (LM Studio, Ollama) is immediate. +- Activating Bedrock walks the user through AWS SSO device-authorization login, then + account and role selection, across multiple `gitzi_activate_provider` calls. +- A `gitzi` restart is required after activation for the rewired agent to take effect. ### Status panel states diff --git a/src/agent/bedrock_agent.rs b/src/agent/bedrock_agent.rs new file mode 100644 index 0000000..d84357c --- /dev/null +++ b/src/agent/bedrock_agent.rs @@ -0,0 +1,73 @@ +use aws_config::BehaviorVersion; +use rig::client::CompletionClient; +use rig::completion::Prompt; +use rig_bedrock::client::Client; +use crate::aws_sso::SsoCredentialsProvider; +use crate::error::{GitziError, Result}; +use crate::model::Task; +use super::backend::{AgentBackend, AgentResult, RunContext}; +use super::prompt::{build_task_content, DEFAULT_PREAMBLE}; + +/// Runs a pipeline agent against AWS Bedrock, authenticating via +/// [`SsoCredentialsProvider`] — credentials are handed to the AWS SDK +/// directly, in-process; gitzi never writes a profile to `~/.aws/config`. +pub struct BedrockAgent { + region: String, + sso_start_url: String, + sso_account_id: String, + sso_role_name: String, + model_id: String, + system_prompt: Option, +} + +impl BedrockAgent { + pub fn new( + region: impl Into, + sso_start_url: impl Into, + sso_account_id: impl Into, + sso_role_name: impl Into, + model_id: impl Into, + system_prompt: Option, + ) -> Self { + Self { + region: region.into(), + sso_start_url: sso_start_url.into(), + sso_account_id: sso_account_id.into(), + sso_role_name: sso_role_name.into(), + model_id: model_id.into(), + system_prompt, + } + } +} + +impl AgentBackend for BedrockAgent { + async fn run(&self, task: &Task, ctx: &RunContext) -> Result { + let credentials_provider = SsoCredentialsProvider::new( + &self.region, + &self.sso_start_url, + &self.sso_account_id, + &self.sso_role_name, + ); + let sdk_config = aws_config::defaults(BehaviorVersion::latest()) + .region(aws_config::Region::new(self.region.clone())) + .credentials_provider(credentials_provider) + .load() + .await; + let aws_client = aws_sdk_bedrockruntime::Client::new(&sdk_config); + let client = Client::from(aws_client); + + let agent = client + .agent(&self.model_id) + .preamble(self.system_prompt.as_deref().unwrap_or(DEFAULT_PREAMBLE)) + .build(); + + let prompt = build_task_content(task, ctx.resume_summary.as_deref(), &ctx.answered_questions); + + let response: String = agent + .prompt(prompt.as_str()) + .await + .map_err(|e| GitziError::AgentFailed(e.to_string()))?; + + Ok(AgentResult::Success { output: response }) + } +} diff --git a/src/agent/coding_tools.rs b/src/agent/coding_tools.rs index 2347fb6..fff69c5 100644 --- a/src/agent/coding_tools.rs +++ b/src/agent/coding_tools.rs @@ -187,23 +187,21 @@ fn resolve_path(relative: &str, root: &Path) -> Option { // Prevent path traversal above the worktree root match path.canonicalize() { Ok(canonical) => { - if let Ok(root_canonical) = root.canonicalize() { - if canonical.starts_with(&root_canonical) { - return Some(canonical); - } + if let Ok(root_canonical) = root.canonicalize() + && canonical.starts_with(&root_canonical) + { + return Some(canonical); } None } Err(_) => { // File might not exist yet (for write_file) — check parent - if let Some(parent) = path.parent() { - if let Ok(parent_canonical) = parent.canonicalize() { - if let Ok(root_canonical) = root.canonicalize() { - if parent_canonical.starts_with(&root_canonical) { - return Some(path); - } - } - } + if let Some(parent) = path.parent() + && let Ok(parent_canonical) = parent.canonicalize() + && let Ok(root_canonical) = root.canonicalize() + && parent_canonical.starts_with(&root_canonical) + { + return Some(path); } None } diff --git a/src/agent/main_agent.rs b/src/agent/main_agent.rs index 31031f2..9327141 100644 --- a/src/agent/main_agent.rs +++ b/src/agent/main_agent.rs @@ -287,6 +287,49 @@ fn main_agent_tools() -> Vec { }), }, }, + OaiTool { + r#type: "function", + function: OaiFunctionDef { + name: "gitzi_rediscover_providers", + description: "Quickly re-scan this machine for LLM providers (LM Studio, Ollama) \ + and AWS Bedrock/SSO access, merging any newly-found ones into \ + config.toml as disabled candidates. Use this when the user asks what \ + model providers are available, or after they've installed/started \ + something new. Returns every known provider with its status so you \ + can present the list and ask which one(s) to activate with \ + gitzi_activate_provider.", + parameters: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + }, + OaiTool { + r#type: "function", + function: OaiFunctionDef { + name: "gitzi_activate_provider", + description: "Activate a discovered provider so agents actually use it. For \ + OpenAI-compatible providers (LM Studio, Ollama) this is immediate. \ + For Bedrock providers, this may take multiple calls: the first call \ + kicks off (or resumes) an AWS SSO browser login and returns a \ + verification code; once the user confirms login in the browser, call \ + again to get the list of AWS accounts (pass account_id once chosen), \ + then the list of roles in that account (pass role_name once chosen) — \ + the final call with both account_id and role_name validates the \ + credentials and activates the provider. A gitzi restart is required \ + for the newly-wired agent to take effect; always tell the user this.", + parameters: json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "The provider's name, as shown by gitzi_rediscover_providers." }, + "account_id": { "type": "string", "description": "Bedrock only. AWS account ID chosen from the list returned by a previous call." }, + "role_name": { "type": "string", "description": "Bedrock only. AWS SSO role/permission-set name chosen from the list returned by a previous call." } + }, + "required": ["name"] + }), + }, + }, OaiTool { r#type: "function", function: OaiFunctionDef { diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 5c9ac4e..2a07331 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,4 +1,5 @@ pub mod backend; +mod bedrock_agent; pub mod classifier; pub mod claude_code; pub mod coding_agent; @@ -9,11 +10,12 @@ mod prompt; mod rig_agent; pub use backend::{AgentBackend, AgentResult, RunContext}; +pub use bedrock_agent::BedrockAgent; pub use claude_code::ClaudeCodeCli; pub use main_agent::{ChatTurn, MainAgent, OaiMessage, OaiTool, ToolCallRequest}; pub use rig_agent::RigAgent; -use crate::config::{AgentDef, Config}; +use crate::config::{AgentDef, Config, ProviderKind}; use crate::dispatcher::AgentRole; use crate::error::Result; use crate::model::Task; @@ -24,6 +26,8 @@ pub enum PipelineAgent { ClaudeCode(ClaudeCodeCli), /// Talks directly to a `[providers.*]` entry over its OpenAI-compatible API (e.g. LM Studio). Rig(RigAgent), + /// Talks to AWS Bedrock via a `[providers.*]` entry with `kind = "bedrock"`. + Bedrock(BedrockAgent), /// Direct LLM tool-calling loop with sandboxed filesystem tools. CodingLoop(AgentDef), } @@ -33,6 +37,7 @@ impl AgentBackend for PipelineAgent { match self { PipelineAgent::ClaudeCode(b) => b.run(task, ctx).await, PipelineAgent::Rig(b) => b.run(task, ctx).await, + PipelineAgent::Bedrock(b) => b.run(task, ctx).await, PipelineAgent::CodingLoop(def) => { let task_prompt = format!( "Task: {}\n\nDescription: {}\n\nWork in the current directory. \ @@ -51,30 +56,57 @@ impl AgentBackend for PipelineAgent { } /// Build a runnable pipeline agent from its definition. If `def.provider` names -/// an entry in `config.providers`, the agent talks to that endpoint directly via -/// `rig`; otherwise it falls back to the local `claude` CLI subprocess. +/// an enabled entry in `config.providers`, the agent talks to that endpoint +/// directly via `rig` (OpenAI-compatible) or `rig-bedrock` (AWS Bedrock); +/// otherwise it falls back to the local `claude` CLI subprocess. A provider +/// that exists but isn't `enabled` (discovered but not yet activated) is +/// treated the same as no provider at all. pub fn build_agent(config: &Config, def: &AgentDef) -> PipelineAgent { match def.provider.as_ref().and_then(|name| config.providers.get(name)) { - Some(provider) => { + Some(provider) if provider.enabled => { // System prompt comes from the hardcoded role default, not config. let role_prompt = AgentRole::all() .iter() .find(|r| r.to_string() == def.role) .map(|r| r.default_system_prompt().to_string()); - PipelineAgent::Rig(RigAgent::new( - provider.api_url.clone(), - provider.api_key.clone(), - def.model.clone(), - role_prompt, - )) + match provider.kind { + ProviderKind::Bedrock => PipelineAgent::Bedrock(BedrockAgent::new( + provider.region.clone().unwrap_or_default(), + provider.sso_start_url.clone().unwrap_or_default(), + provider.sso_account_id.clone().unwrap_or_default(), + provider.sso_role_name.clone().unwrap_or_default(), + provider.model_id.clone().unwrap_or_else(|| def.model.clone()), + role_prompt, + )), + ProviderKind::OpenaiCompatible => PipelineAgent::Rig(RigAgent::new( + provider.api_url.clone(), + Config::resolve_provider_api_key(provider), + def.model.clone(), + role_prompt, + )), + } } - None => PipelineAgent::CodingLoop(def.clone()), + _ => PipelineAgent::CodingLoop(def.clone()), } } -/// Build the main chat harness agent from its definition. -pub fn build_main_agent(def: &AgentDef) -> MainAgent { - MainAgent::new(def) +/// Build the main chat harness agent from its definition, resolving a named +/// `[providers.*]` reference into the concrete endpoint URL the harness talks +/// to. Without this the harness would silently fall back to its localhost +/// default even after the user activated a provider (e.g. Ollama on :11434). +/// The harness speaks OpenAI-compatible HTTP; Bedrock-backed main agents are +/// not yet supported here (pipeline agents handle Bedrock separately). +pub fn build_main_agent(config: &Config, def: &AgentDef) -> MainAgent { + let mut def = def.clone(); + if def.api_url.is_none() + && let Some(provider) = def.provider.as_ref().and_then(|n| config.providers.get(n)) + && provider.enabled + && provider.kind == ProviderKind::OpenaiCompatible + && !provider.api_url.is_empty() + { + def.api_url = Some(provider.api_url.clone()); + } + MainAgent::new(&def) } #[cfg(test)] @@ -98,7 +130,7 @@ mod tests { "lmstudio".to_string(), ProviderDef { api_url: "http://localhost:1234/v1".to_string(), - api_key: String::new(), + ..ProviderDef::default() }, )]), ..Config::default() @@ -115,4 +147,44 @@ mod tests { assert!(matches!(build_agent(&config, &def), PipelineAgent::CodingLoop(_))); } + + #[test] + fn build_agent_with_disabled_provider_falls_back_to_coding_loop() { + let config = Config { + providers: HashMap::from([( + "lmstudio".to_string(), + ProviderDef { + api_url: "http://localhost:1234/v1".to_string(), + enabled: false, + ..ProviderDef::default() + }, + )]), + ..Config::default() + }; + let def = AgentDef { provider: Some("lmstudio".to_string()), ..AgentDef::default() }; + + assert!(matches!(build_agent(&config, &def), PipelineAgent::CodingLoop(_))); + } + + #[test] + fn build_agent_with_bedrock_provider_uses_bedrock() { + let config = Config { + providers: HashMap::from([( + "bedrock".to_string(), + ProviderDef { + kind: crate::config::ProviderKind::Bedrock, + region: Some("us-east-1".to_string()), + sso_start_url: Some("https://example.awsapps.com/start".to_string()), + sso_account_id: Some("123456789012".to_string()), + sso_role_name: Some("AdministratorAccess".to_string()), + model_id: Some("anthropic.claude-sonnet-4-6-v1:0".to_string()), + ..ProviderDef::default() + }, + )]), + ..Config::default() + }; + let def = AgentDef { provider: Some("bedrock".to_string()), ..AgentDef::default() }; + + assert!(matches!(build_agent(&config, &def), PipelineAgent::Bedrock(_))); + } } diff --git a/src/aws_sso.rs b/src/aws_sso.rs new file mode 100644 index 0000000..8fbe705 --- /dev/null +++ b/src/aws_sso.rs @@ -0,0 +1,500 @@ +//! AWS IAM Identity Center (SSO) login, mirroring `aws sso login`'s device +//! authorization flow, plus the account/role listing and role-credential +//! exchange needed to turn a logged-in SSO session into temporary AWS +//! credentials for Bedrock. +//! +//! The SSO access token is cached in the OS keyring (via `crate::secrets`) +//! keyed by the SSO start URL, so gitzi only needs to re-open a browser when +//! that token actually expires — its lifetime is set by the org's IAM +//! Identity Center session-duration setting (15 minutes to 90 days, 8 hours +//! by default), not by gitzi. The OIDC client registration (`client_id`/ +//! `client_secret` from `register_client`) is cached the same way, keyed by +//! start URL, since AWS fixes its lifetime at 90 days and recommends reusing +//! it rather than re-registering on every login. Short-lived role credentials +//! are never cached on disk — [`SsoCredentialsProvider`] re-exchanges them via +//! `get_role_credentials` whenever the AWS SDK's own identity cache decides +//! the previous set has expired. +//! +//! gitzi never writes to `~/.aws/config`/`~/.aws/credentials` or any other +//! cloud CLI's config files — [`SsoCredentialsProvider`] hands credentials to +//! the AWS SDK directly, in-process, via `aws_config`'s +//! `credentials_provider()` builder method. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::time::{Duration, UNIX_EPOCH}; + +use aws_credential_types::provider::{error::CredentialsError, future, ProvideCredentials}; +use aws_credential_types::Credentials as AwsCredentials; + +use crate::error::{GitziError, Result}; +use crate::secrets; + +const CLIENT_NAME: &str = "gitzi"; +// `secrets::service_name` isn't `const fn`, so these mirror its output +// ("gitzi//") literally rather than calling it. +const SSO_TOKEN_SERVICE: &str = "gitzi/aws/sso-token"; +const SSO_CLIENT_SERVICE: &str = "gitzi/aws/sso-client"; + +/// A cached SSO access token plus its expiry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SsoToken { + pub access_token: String, + pub expires_at: DateTime, +} + +impl SsoToken { + pub fn is_expired(&self) -> bool { + Utc::now() >= self.expires_at + } +} + +/// A cached OIDC client registration from `register_client`. AWS fixes +/// `clientSecretExpiresAt` at 90 days regardless of the org's session-duration +/// setting, and recommends persisting it for reuse rather than re-registering +/// on every login. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClientRegistration { + client_id: String, + client_secret: String, + expires_at: DateTime, +} + +impl ClientRegistration { + fn is_expired(&self) -> bool { + Utc::now() >= self.expires_at + } +} + +/// Account assigned to the logged-in user, as returned by `list_accounts`. +#[derive(Debug, Clone)] +pub struct SsoAccount { + pub account_id: String, + pub account_name: String, + pub email_address: String, +} + +/// A permission-set role assigned to the user within one account. +#[derive(Debug, Clone)] +pub struct SsoRole { + pub role_name: String, + pub account_id: String, +} + +/// Temporary AWS credentials exchanged for an SSO role. +#[derive(Debug, Clone)] +pub struct RoleCredentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: String, + /// Milliseconds since the Unix epoch, matching the SSO API's wire format. + pub expiration_ms: i64, +} + +/// Device-authorization state returned by `start_device_login`; complete the +/// flow by passing it to `poll_for_token`. +pub struct PendingLogin { + client_id: String, + client_secret: String, + device_code: String, + interval_secs: u64, + expires_at: DateTime, + region: String, + pub user_code: String, + pub verification_uri_complete: String, +} + +async fn sdk_config_no_creds(region: &str) -> aws_config::SdkConfig { + aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(aws_config::Region::new(region.to_string())) + .no_credentials() + .load() + .await +} + +async fn ssooidc_client(region: &str) -> aws_sdk_ssooidc::Client { + aws_sdk_ssooidc::Client::new(&sdk_config_no_creds(region).await) +} + +async fn sso_client(region: &str) -> aws_sdk_sso::Client { + aws_sdk_sso::Client::new(&sdk_config_no_creds(region).await) +} + +/// Start the SSO device-authorization flow: register an OIDC client with +/// the service, request a device code for `start_url`, and open the +/// verification URL in the user's default browser. Returns a `PendingLogin` +/// to complete via `poll_for_token`; show `user_code` to the user in case +/// the browser doesn't open automatically. +pub async fn start_device_login(region: &str, start_url: &str) -> Result { + let client = ssooidc_client(region).await; + + let (client_id, client_secret) = match load_client_registration(start_url) { + Some(reg) => (reg.client_id, reg.client_secret), + None => { + let registered = client + .register_client() + .client_name(CLIENT_NAME) + .client_type("public") + .scopes("sso:account:access") + .send() + .await + .map_err(|e| GitziError::Config(format!("AWS SSO register_client failed: {e}")))?; + + let client_id = registered + .client_id() + .ok_or_else(|| GitziError::Config("AWS SSO register_client returned no client_id".into()))? + .to_string(); + let client_secret = registered + .client_secret() + .ok_or_else(|| GitziError::Config("AWS SSO register_client returned no client_secret".into()))? + .to_string(); + let expires_at = DateTime::from_timestamp(registered.client_secret_expires_at(), 0) + .ok_or_else(|| { + GitziError::Config("AWS SSO register_client returned an invalid expiry".into()) + })?; + + store_client_registration( + start_url, + &ClientRegistration { + client_id: client_id.clone(), + client_secret: client_secret.clone(), + expires_at, + }, + )?; + + (client_id, client_secret) + } + }; + + let device_auth = client + .start_device_authorization() + .client_id(&client_id) + .client_secret(&client_secret) + .start_url(start_url) + .send() + .await + .map_err(|e| GitziError::Config(format!("AWS SSO start_device_authorization failed: {e}")))?; + + let device_code = device_auth + .device_code() + .ok_or_else(|| { + GitziError::Config("AWS SSO start_device_authorization returned no device_code".into()) + })? + .to_string(); + let user_code = device_auth.user_code().unwrap_or_default().to_string(); + let verification_uri_complete = device_auth + .verification_uri_complete() + .or_else(|| device_auth.verification_uri()) + .unwrap_or_default() + .to_string(); + let interval_secs = device_auth.interval().max(1) as u64; + let expires_at = Utc::now() + chrono::Duration::seconds(device_auth.expires_in() as i64); + + let _ = webbrowser::open(&verification_uri_complete); + + Ok(PendingLogin { + client_id, + client_secret, + device_code, + interval_secs, + expires_at, + region: region.to_string(), + user_code, + verification_uri_complete, + }) +} + +/// Poll `create_token` until the user approves the device in their browser, +/// respecting the server's requested polling interval and backing off on +/// `SlowDownException`. On success, caches the access token in the OS +/// keyring under `start_url` and returns it. +pub async fn poll_for_token(pending: &PendingLogin, start_url: &str) -> Result { + use aws_sdk_ssooidc::operation::create_token::CreateTokenError; + + let client = ssooidc_client(&pending.region).await; + let mut interval = pending.interval_secs; + + loop { + if Utc::now() >= pending.expires_at { + return Err(GitziError::Config( + "AWS SSO device code expired before login was approved".into(), + )); + } + + let result = client + .create_token() + .client_id(&pending.client_id) + .client_secret(&pending.client_secret) + .grant_type("urn:ietf:params:oauth:grant-type:device_code") + .device_code(&pending.device_code) + .send() + .await; + + match result { + Ok(output) => { + let access_token = output + .access_token() + .ok_or_else(|| { + GitziError::Config("AWS SSO create_token returned no access_token".into()) + })? + .to_string(); + let expires_at = Utc::now() + chrono::Duration::seconds(output.expires_in() as i64); + let token = SsoToken { access_token, expires_at }; + store_token(start_url, &token)?; + return Ok(token); + } + Err(err) => match err.into_service_error() { + CreateTokenError::AuthorizationPendingException(_) => { + tokio::time::sleep(std::time::Duration::from_secs(interval)).await; + } + CreateTokenError::SlowDownException(_) => { + interval += 5; + tokio::time::sleep(std::time::Duration::from_secs(interval)).await; + } + CreateTokenError::ExpiredTokenException(_) => { + return Err(GitziError::Config( + "AWS SSO device code expired before login was approved".into(), + )); + } + other => return Err(GitziError::Config(format!("AWS SSO create_token failed: {other}"))), + }, + } + } +} + +/// Cache an SSO access token in the OS keyring, keyed by SSO start URL. +pub fn store_token(start_url: &str, token: &SsoToken) -> Result<()> { + let serialized = serde_json::to_string(token) + .map_err(|e| GitziError::Config(format!("failed to serialize SSO token: {e}")))?; + secrets::store_secret(SSO_TOKEN_SERVICE, start_url, &serialized)?; + Ok(()) +} + +/// Load a previously cached SSO access token for `start_url`, if any and if +/// not expired. +pub fn load_token(start_url: &str) -> Option { + let pointer = format!("keyring:{SSO_TOKEN_SERVICE}/{start_url}"); + let raw = secrets::resolve_secret(&pointer).ok()?; + let token: SsoToken = serde_json::from_str(&raw).ok()?; + if token.is_expired() { None } else { Some(token) } +} + +/// Cache an OIDC client registration in the OS keyring, keyed by SSO start URL. +fn store_client_registration(start_url: &str, reg: &ClientRegistration) -> Result<()> { + let serialized = serde_json::to_string(reg) + .map_err(|e| GitziError::Config(format!("failed to serialize SSO client registration: {e}")))?; + secrets::store_secret(SSO_CLIENT_SERVICE, start_url, &serialized)?; + Ok(()) +} + +/// Load a previously cached OIDC client registration for `start_url`, if any +/// and if not expired. +fn load_client_registration(start_url: &str) -> Option { + let pointer = format!("keyring:{SSO_CLIENT_SERVICE}/{start_url}"); + let raw = secrets::resolve_secret(&pointer).ok()?; + let reg: ClientRegistration = serde_json::from_str(&raw).ok()?; + if reg.is_expired() { None } else { Some(reg) } +} + +/// List the AWS accounts assigned to the user behind `access_token`. +pub async fn list_accounts(region: &str, access_token: &str) -> Result> { + let client = sso_client(region).await; + let mut accounts = Vec::new(); + let mut next_token: Option = None; + + loop { + let mut req = client.list_accounts().access_token(access_token); + if let Some(token) = &next_token { + req = req.next_token(token); + } + let output = req + .send() + .await + .map_err(|e| GitziError::Config(format!("AWS SSO list_accounts failed: {e}")))?; + + for account in output.account_list() { + accounts.push(SsoAccount { + account_id: account.account_id().unwrap_or_default().to_string(), + account_name: account.account_name().unwrap_or_default().to_string(), + email_address: account.email_address().unwrap_or_default().to_string(), + }); + } + + next_token = output.next_token().map(str::to_string); + if next_token.is_none() { + break; + } + } + + Ok(accounts) +} + +/// List the permission-set roles the user has within `account_id`. +pub async fn list_account_roles( + region: &str, + access_token: &str, + account_id: &str, +) -> Result> { + let client = sso_client(region).await; + let mut roles = Vec::new(); + let mut next_token: Option = None; + + loop { + let mut req = client + .list_account_roles() + .access_token(access_token) + .account_id(account_id); + if let Some(token) = &next_token { + req = req.next_token(token); + } + let output = req + .send() + .await + .map_err(|e| GitziError::Config(format!("AWS SSO list_account_roles failed: {e}")))?; + + for role in output.role_list() { + roles.push(SsoRole { + role_name: role.role_name().unwrap_or_default().to_string(), + account_id: role.account_id().unwrap_or_default().to_string(), + }); + } + + next_token = output.next_token().map(str::to_string); + if next_token.is_none() { + break; + } + } + + Ok(roles) +} + +/// Exchange the SSO session for temporary credentials scoped to one role in +/// one account. Call this fresh every time credentials are needed — the +/// result is short-lived (typically ~1 hour) and not cached. +pub async fn get_role_credentials( + region: &str, + access_token: &str, + account_id: &str, + role_name: &str, +) -> Result { + let client = sso_client(region).await; + + let output = client + .get_role_credentials() + .access_token(access_token) + .account_id(account_id) + .role_name(role_name) + .send() + .await + .map_err(|e| GitziError::Config(format!("AWS SSO get_role_credentials failed: {e}")))?; + + let creds = output + .role_credentials() + .ok_or_else(|| GitziError::Config("AWS SSO get_role_credentials returned no credentials".into()))?; + + Ok(RoleCredentials { + access_key_id: creds.access_key_id().unwrap_or_default().to_string(), + secret_access_key: creds.secret_access_key().unwrap_or_default().to_string(), + session_token: creds.session_token().unwrap_or_default().to_string(), + expiration_ms: creds.expiration(), + }) +} + +/// Hands AWS-Bedrock-bound SDK clients temporary role credentials directly, +/// in-process — no `credential_process` subprocess, no `~/.aws/config` entry. +/// Pass this to `aws_config`'s `.credentials_provider()`; the SDK's own +/// identity cache calls `provide_credentials` again once the previous +/// credentials approach their `expiration_ms`, so callers don't need to +/// re-fetch or cache role credentials themselves. +/// +/// Reuses the cached SSO access token (see module docs) and falls back to a +/// fresh device-authorization login (opening a browser) only if that token is +/// missing or expired — same as every other AWS SSO entry point in this +/// module. +#[derive(Debug, Clone)] +pub struct SsoCredentialsProvider { + region: String, + start_url: String, + account_id: String, + role_name: String, +} + +impl SsoCredentialsProvider { + pub fn new( + region: impl Into, + start_url: impl Into, + account_id: impl Into, + role_name: impl Into, + ) -> Self { + Self { + region: region.into(), + start_url: start_url.into(), + account_id: account_id.into(), + role_name: role_name.into(), + } + } + + async fn load(&self) -> Result { + let token = match load_token(&self.start_url) { + Some(token) => token, + None => { + let pending = start_device_login(&self.region, &self.start_url).await?; + poll_for_token(&pending, &self.start_url).await? + } + }; + + let creds = get_role_credentials( + &self.region, + &token.access_token, + &self.account_id, + &self.role_name, + ) + .await?; + + let expires_after = UNIX_EPOCH + Duration::from_millis(creds.expiration_ms.max(0) as u64); + Ok(AwsCredentials::new( + creds.access_key_id, + creds.secret_access_key, + Some(creds.session_token), + Some(expires_after), + "gitzi-sso", + )) + } +} + +impl ProvideCredentials for SsoCredentialsProvider { + fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a> + where + Self: 'a, + { + future::ProvideCredentials::new(async move { + self.load() + .await + .map_err(|e| CredentialsError::provider_error(e.to_string())) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sso_token_is_expired_detects_past_expiry() { + let token = SsoToken { + access_token: "tok".to_string(), + expires_at: Utc::now() - chrono::Duration::seconds(1), + }; + assert!(token.is_expired()); + } + + #[test] + fn sso_token_is_expired_false_for_future_expiry() { + let token = SsoToken { + access_token: "tok".to_string(), + expires_at: Utc::now() + chrono::Duration::hours(1), + }; + assert!(!token.is_expired()); + } + +} diff --git a/src/bootstrap.rs b/src/bootstrap.rs index da7b8b6..d1f3043 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -1,44 +1,74 @@ -//! First-run bootstrapper: discovers LLM providers, scans for repos, generates config. +//! Environment scanner: quickly probes for available LLM infrastructure and +//! cloud credentials. [`discover_providers`] is the scan reused by the daemon's +//! bootstrap setup phase ([`crate::setup`]) and by the `gitzi_rediscover_providers` +//! agent tool. +//! +//! This is a *quick, non-blocking* scan — it never starts servers, loads +//! models, or opens a browser for SSO login. Every provider it finds is +//! surfaced as a candidate; activation (which wires a provider into the main +//! agent) is always an explicit user step driven by the setup gate (ADR-002). +//! This avoids onboarding ever getting stuck waiting on a server to start or a +//! model to load. +//! +//! [`run`] (force-regenerate a `config.toml` from a scan) remains available for +//! the `gitzi generate-config` command, but is no longer invoked by +//! `Config::load` — loading is pure read-and-report (ADR-002). use std::path::PathBuf; use tracing::info; -use crate::config::{AgentDef, Config, ProviderDef, WipLimits, atomic_write}; +use crate::config::{Config, ProviderDef, ProviderKind, WipLimits, atomic_write}; use crate::state::home; -/// A discovered LLM provider. +/// A discovered LLM/model provider, surfaced for the user to choose from. #[derive(Debug, Clone)] pub struct DiscoveredProvider { pub name: String, + pub kind: ProviderKind, + /// Base URL for OpenAI-compatible providers. pub api_url: String, - /// Whether the server process is running (port is open). + /// AWS region, for Bedrock providers. + pub region: Option, + /// AWS SSO start URL, for Bedrock providers (if found in `~/.aws/config`). + pub sso_start_url: Option, + /// Whether the server process is running (port is open). N/A for Bedrock. pub running: bool, - /// Whether at least one model is loaded and ready to serve. + /// Whether at least one model is loaded and ready to serve. N/A for Bedrock. pub model_loaded: bool, /// Whether the binary/CLI is installed on the system. pub installed: bool, } /// Run the full bootstrap: discover providers, discover repos, generate config. -/// Returns the generated config. +/// Returns the generated config. Never auto-wires a provider into `[[agents]]` +/// or starts/loads anything — see module docs. pub fn run() -> crate::error::Result { info!("bootstrapping gitzi — discovering environment..."); - // 1. Discover LLM providers let providers = discover_providers(); - info!("found {} LLM provider(s)", providers.len()); + info!("found {} provider candidate(s)", providers.len()); - // 2. Pick the best provider (first running one, or first installed) - let chosen = providers.first().cloned(); - - // 3. Discover repos let repo_paths = discover_repo_paths(); info!("found {} repo path(s)", repo_paths.len()); - // 4. Build config + let config = build_config_from_discovery(&providers, repo_paths); + + let path = home::global_config_file(); + home::ensure_dirs()?; + write_config_with_comments(&path, &config)?; + + info!("config written to {}", path.display()); + Ok(config) +} + +/// Build a `Config` from discovery results: every provider is recorded +/// disabled, and `[[agents]]` is always left empty (every role falls back +/// to the local `claude` CLI until the user explicitly activates a +/// provider). Split out from `run()` so it can be tested without touching +/// the real `~/.gitzi/` or `~/.aws/` on disk. +fn build_config_from_discovery(providers: &[DiscoveredProvider], repo_paths: Vec) -> Config { let mut config = Config::default(); - // Set WIP limits to 1 for all agent columns let mut overrides = std::collections::HashMap::new(); overrides.insert("designing".to_string(), 1); overrides.insert("coding".to_string(), 1); @@ -47,62 +77,53 @@ pub fn run() -> crate::error::Result { overrides.insert("deploying".to_string(), 1); config.wip_limits = WipLimits { overrides }; - // Set providers config.providers.clear(); - for provider in &providers { - config.providers.insert( - provider.name.clone(), - ProviderDef { - api_url: provider.api_url.clone(), - api_key: String::new(), - }, - ); + for provider in providers { + let mut def = ProviderDef { + kind: provider.kind, + enabled: false, + ..ProviderDef::default() + }; + match provider.kind { + ProviderKind::OpenaiCompatible => { + def.api_url = provider.api_url.clone(); + } + ProviderKind::Bedrock => { + def.region = provider.region.clone(); + def.sso_start_url = provider.sso_start_url.clone(); + } + } + config.providers.insert(provider.name.clone(), def); } - // Set agents (all roles pointing to the chosen provider) - let provider_name = chosen.as_ref().map(|p| p.name.clone()); - let roles = [ - "main", "prioritizer", "designer", "coder", - "reviewer", "auditor", "infrarian", - ]; - config.agents = roles.iter().map(|role| AgentDef { - role: role.to_string(), - model: "local-model".to_string(), - api_url: None, - provider: provider_name.clone(), - }).collect(); - - // Set repo_paths config.repo_paths = repo_paths; - - // 5. Write config - let path = home::global_config_file(); - home::ensure_dirs()?; - write_config_with_comments(&path, &config)?; - - info!("config written to {}", path.display()); - Ok(config) + config } -/// Discover available LLM providers on this machine. -/// Priority: running processes first, then installed-but-not-running. +/// Quickly scan this machine for LLM providers and AWS Bedrock access. +/// Every check here is fast (short timeouts, no process spawning that +/// blocks longer than a couple seconds) — this never starts a server, loads +/// a model, or initiates an SSO login. pub fn discover_providers() -> Vec { let mut providers = Vec::new(); // LM Studio let lms_path = dirs::home_dir().map(|h| h.join(".lmstudio/bin/lms")); - if let Some(ref path) = lms_path { - if path.exists() { - let running = check_port_open(1234); - let model_loaded = running && has_models_loaded("http://localhost:1234/v1"); - providers.push(DiscoveredProvider { - name: "lmstudio".to_string(), - api_url: "http://localhost:1234/v1".to_string(), - running, - model_loaded, - installed: true, - }); - } + if let Some(ref path) = lms_path + && path.exists() + { + let running = check_port_open(1234); + let model_loaded = running && has_models_loaded("http://localhost:1234/v1"); + providers.push(DiscoveredProvider { + name: "lmstudio".to_string(), + kind: ProviderKind::OpenaiCompatible, + api_url: "http://localhost:1234/v1".to_string(), + region: None, + sso_start_url: None, + running, + model_loaded, + installed: true, + }); } // Ollama @@ -111,13 +132,48 @@ pub fn discover_providers() -> Vec { let model_loaded = running && has_models_loaded("http://localhost:11434/v1"); providers.push(DiscoveredProvider { name: "ollama".to_string(), + kind: ProviderKind::OpenaiCompatible, api_url: "http://localhost:11434/v1".to_string(), + region: None, + sso_start_url: None, running, model_loaded, installed: true, }); } + // AWS Bedrock — surfaced if the AWS CLI is present, or if `~/.aws/config` + // already has SSO sessions configured (one candidate per session). + let aws_cli_installed = which("aws"); + let sso_sessions = discover_aws_sso_sessions(); + if sso_sessions.is_empty() { + if aws_cli_installed { + providers.push(DiscoveredProvider { + name: "bedrock".to_string(), + kind: ProviderKind::Bedrock, + api_url: String::new(), + region: None, + sso_start_url: None, + running: false, + model_loaded: false, + installed: true, + }); + } + } else { + for (session_name, start_url, region) in sso_sessions { + providers.push(DiscoveredProvider { + name: format!("bedrock-{session_name}"), + kind: ProviderKind::Bedrock, + api_url: String::new(), + region: if region.is_empty() { None } else { Some(region) }, + sso_start_url: Some(start_url), + running: false, + model_loaded: false, + installed: true, + }); + } + } + // Sort: running+model first, then running-no-model, then installed-not-running providers.sort_by(|a, b| { let score = |p: &DiscoveredProvider| -> u8 { @@ -129,6 +185,62 @@ pub fn discover_providers() -> Vec { providers } +/// Parse `~/.aws/config` for `[sso-session NAME]` blocks, returning +/// `(session_name, sso_start_url, sso_region)` for each one that has a +/// start URL set. +fn discover_aws_sso_sessions() -> Vec<(String, String, String)> { + match dirs::home_dir() { + Some(h) => parse_aws_sso_sessions(&h.join(".aws/config")), + None => Vec::new(), + } +} + +/// Parse SSO sessions out of an `~/.aws/config`-formatted file at `path`. +/// Split out from `discover_aws_sso_sessions` so it can be tested against a +/// temp file instead of mutating the process-wide `$HOME`. +fn parse_aws_sso_sessions(path: &std::path::Path) -> Vec<(String, String, String)> { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(_) => return Vec::new(), + }; + + let mut sessions = Vec::new(); + let mut current: Option = None; + let mut start_url = String::new(); + let mut region = String::new(); + + let flush = |current: &mut Option, start_url: &mut String, region: &mut String, sessions: &mut Vec<(String, String, String)>| { + if let Some(name) = current.take() + && !start_url.is_empty() + { + sessions.push((name, start_url.clone(), region.clone())); + } + start_url.clear(); + region.clear(); + }; + + for line in text.lines() { + let line = line.trim(); + if let Some(name) = line.strip_prefix("[sso-session ").and_then(|s| s.strip_suffix(']')) { + flush(&mut current, &mut start_url, &mut region, &mut sessions); + current = Some(name.trim().to_string()); + } else if line.starts_with('[') { + flush(&mut current, &mut start_url, &mut region, &mut sessions); + } else if current.is_some() + && let Some((key, val)) = line.split_once('=') + { + match key.trim() { + "sso_start_url" => start_url = val.trim().to_string(), + "sso_region" => region = val.trim().to_string(), + _ => {} + } + } + } + flush(&mut current, &mut start_url, &mut region, &mut sessions); + + sessions +} + /// Check if a provider has at least one model loaded via the /v1/models endpoint. fn has_models_loaded(base_url: &str) -> bool { let url = format!("{}/models", base_url.trim_end_matches('/')); @@ -152,8 +264,10 @@ fn has_models_loaded(base_url: &str) -> bool { } } -/// Attempt to load a model for the given provider. -/// Returns true if a model is now available. +/// Attempt to load a model for the given provider. Not called automatically +/// during boot (that's what got onboarding stuck before) — only invoked +/// explicitly when the user activates a provider via the main agent's +/// `gitzi_activate_provider` tool. Returns true if a model is now available. pub fn ensure_model_loaded(provider: &DiscoveredProvider) -> bool { if provider.model_loaded { return true; @@ -243,14 +357,12 @@ pub fn discover_repo_paths() -> Vec { } // Also check if cwd contains a .git - if let Ok(cwd) = std::env::current_dir() { - if cwd.join(".git").is_dir() { - if let Some(parent) = cwd.parent() { - if !found_parents.iter().any(|p| p == parent) { - found_parents.push(parent.to_path_buf()); - } - } - } + if let Ok(cwd) = std::env::current_dir() + && cwd.join(".git").is_dir() + && let Some(parent) = cwd.parent() + && !found_parents.iter().any(|p| p == parent) + { + found_parents.push(parent.to_path_buf()); } // Convert to glob patterns @@ -319,6 +431,14 @@ fn write_config_with_comments( out, "# Each role handles one pipeline stage. Set model and provider only." ).unwrap(); + writeln!( + out, + "# Empty by default: every role falls back to the local `claude` CLI." + ).unwrap(); + writeln!( + out, + "# Ask the main agent to activate a discovered provider below to wire it in." + ).unwrap(); writeln!(out, "# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") .unwrap(); writeln!(out).unwrap(); @@ -342,17 +462,36 @@ fn write_config_with_comments( writeln!(out, "# Providers").unwrap(); writeln!( out, - "# LLM endpoints. api_key is plaintext — never commit this file." + "# Discovered LLM endpoints and cloud credentials. All start disabled —" + ).unwrap(); + writeln!( + out, + "# ask the main agent to activate the one(s) you want to use." + ).unwrap(); + writeln!( + out, + "# api_key, if set, is a keyring: pointer, not a plaintext secret." ).unwrap(); writeln!(out, "# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") .unwrap(); writeln!(out).unwrap(); for (name, provider) in &config.providers { writeln!(out, "[providers.{name}]").unwrap(); - writeln!(out, "api_url = \"{}\"", provider.api_url).unwrap(); + if provider.kind == crate::config::ProviderKind::Bedrock { + writeln!(out, "kind = \"bedrock\"").unwrap(); + if let Some(ref region) = provider.region { + writeln!(out, "region = \"{region}\"").unwrap(); + } + if let Some(ref start_url) = provider.sso_start_url { + writeln!(out, "sso_start_url = \"{start_url}\"").unwrap(); + } + } else if !provider.api_url.is_empty() { + writeln!(out, "api_url = \"{}\"", provider.api_url).unwrap(); + } if !provider.api_key.is_empty() { writeln!(out, "api_key = \"{}\"", provider.api_key).unwrap(); } + writeln!(out, "enabled = {}", provider.enabled).unwrap(); writeln!(out).unwrap(); } writeln!(out).unwrap(); @@ -404,3 +543,83 @@ fn write_config_with_comments( atomic_write(path, &out) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_config_from_discovery_never_auto_wires_agents() { + // Bootstrap must leave `[[agents]]` empty and every provider + // disabled regardless of what's discovered — activation is always + // an explicit, user-driven step. + let providers = vec![ + DiscoveredProvider { + name: "lmstudio".to_string(), + kind: ProviderKind::OpenaiCompatible, + api_url: "http://localhost:1234/v1".to_string(), + region: None, + sso_start_url: None, + running: true, + model_loaded: true, + installed: true, + }, + DiscoveredProvider { + name: "bedrock-mycompany".to_string(), + kind: ProviderKind::Bedrock, + api_url: String::new(), + region: Some("us-east-1".to_string()), + sso_start_url: Some("https://mycompany.awsapps.com/start".to_string()), + running: false, + model_loaded: false, + installed: true, + }, + ]; + + let config = build_config_from_discovery(&providers, vec!["/home/user/git/*".to_string()]); + + assert!(config.agents.is_empty()); + assert_eq!(config.providers.len(), 2); + assert!(config.providers.values().all(|p| !p.enabled)); + assert_eq!( + config.providers["bedrock-mycompany"].region.as_deref(), + Some("us-east-1") + ); + assert_eq!( + config.providers["bedrock-mycompany"].sso_start_url.as_deref(), + Some("https://mycompany.awsapps.com/start") + ); + assert_eq!(config.providers["lmstudio"].api_url, "http://localhost:1234/v1"); + } + + #[test] + fn parse_aws_sso_sessions_reads_config_file() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("config"); + std::fs::write( + &config_path, + "[sso-session mycompany]\n\ + sso_start_url = https://mycompany.awsapps.com/start\n\ + sso_region = us-east-1\n\ + sso_registration_scopes = sso:account:access\n\ + \n\ + [profile dev]\n\ + sso_session = mycompany\n\ + region = us-east-1\n", + ) + .unwrap(); + + let sessions = parse_aws_sso_sessions(&config_path); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].0, "mycompany"); + assert_eq!(sessions[0].1, "https://mycompany.awsapps.com/start"); + assert_eq!(sessions[0].2, "us-east-1"); + } + + #[test] + fn parse_aws_sso_sessions_returns_empty_for_missing_file() { + let sessions = parse_aws_sso_sessions(std::path::Path::new("/nonexistent/.aws/config")); + assert!(sessions.is_empty()); + } +} diff --git a/src/config.rs b/src/config.rs index 43b3124..5b673c8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::path::Path; use serde::{Deserialize, Serialize}; +use tracing::warn; use crate::dispatcher::AgentRole; use crate::error::{GitziError, Result}; @@ -37,12 +38,78 @@ pub struct ResolvedRepoConfig { pub main_branch: String, } -/// An LLM provider definition (e.g. LM Studio, Ollama, OpenAI-compatible endpoint). +/// Which kind of model server a `[providers.*]` entry talks to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderKind { + /// Any server speaking the OpenAI chat-completions wire format (LM Studio, + /// Ollama, etc.) — reached over `api_url` with `api_key`. + #[default] + OpenaiCompatible, + /// AWS Bedrock, reached through an in-process credentials provider (see + /// `crate::aws_sso::SsoCredentialsProvider`) rather than a URL/key pair. + Bedrock, +} + +/// An LLM provider definition (e.g. LM Studio, Ollama, AWS Bedrock). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderDef { + #[serde(default)] + pub kind: ProviderKind, + + /// Base URL for OpenAI-compatible providers. Unused for Bedrock. + #[serde(default, skip_serializing_if = "String::is_empty")] pub api_url: String, + /// API key for OpenAI-compatible providers. May be plaintext (legacy + /// configs) or a `keyring:/` pointer — always resolve + /// with `crate::secrets::resolve_secret` before use. Unused for Bedrock. #[serde(default, skip_serializing_if = "String::is_empty")] pub api_key: String, + + /// AWS region for Bedrock (e.g. "us-east-1"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + /// AWS SSO start URL — re-used to resume/refresh login and to key the + /// keyring entry holding the SSO access token. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sso_start_url: Option, + /// AWS account ID chosen during SSO role-credential exchange. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sso_account_id: Option, + /// AWS SSO permission-set/role name chosen during role-credential exchange. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sso_role_name: Option, + /// Bedrock model ID, e.g. "anthropic.claude-sonnet-4-6-v1:0". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + + /// Whether this provider is actually wired up for use. Providers found + /// during discovery are recorded here so they show up for the user to + /// choose from, but stay `enabled = false` (and unreferenced by any + /// agent) until explicitly activated — see `gitzi_rediscover_providers` + /// and `gitzi_activate_provider`. + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for ProviderDef { + fn default() -> Self { + Self { + kind: ProviderKind::default(), + api_url: String::new(), + api_key: String::new(), + region: None, + sso_start_url: None, + sso_account_id: None, + sso_role_name: None, + model_id: None, + enabled: true, + } + } } /// Per-column WIP limit overrides, as configured in `config.toml`: @@ -107,7 +174,7 @@ fn default_providers() -> HashMap { HashMap::from([ ("lmstudio".to_string(), ProviderDef { api_url: "http://localhost:1234/v1".to_string(), - api_key: String::new(), + ..ProviderDef::default() }), ]) } @@ -121,6 +188,14 @@ pub struct Config { #[serde(default = "default_providers")] pub providers: HashMap, + /// The distinguished "control-plane" provider established during bootstrap + /// setup (ADR-002). It powers the setup experience and the recovery + /// conversation when the main agent's own provider is absent or not + /// responding. Set on first provider activation; names a key in + /// `providers`. `None` until the user completes setup. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_provider: Option, + /// All agent definitions. Override built-in defaults for any role. #[serde(default)] pub agents: Vec, @@ -141,6 +216,7 @@ impl Default for Config { Self { wip_limits: WipLimits::default(), providers: default_providers(), + fallback_provider: None, agents: Vec::new(), integrations: HashMap::new(), repo_paths: Vec::new(), @@ -156,7 +232,11 @@ impl Config { pub fn load(_repo_root: &Path) -> Result { let path = crate::state::home::global_config_file(); if !path.exists() { - return Ok(crate::bootstrap::run()?); + // First run: `load` is pure read-and-report (ADR-002). Discovery is + // owned by the daemon's setup phase, not by config loading, so we + // return defaults here rather than scanning + writing. The setup + // gate then evaluates to "needs setup" and drives discovery. + return Ok(Self::default()); } let text = std::fs::read_to_string(&path)?; let mut config: Self = toml::from_str(&text)?; @@ -168,7 +248,7 @@ impl Config { valid_roles.push("verifier".to_string()); let before_len = config.agents.len(); config.agents.retain(|a| valid_roles.contains(&a.role)); - let mut dirty = config.agents.len() != before_len; + let roles_changed = config.agents.len() != before_len; // Strip unknown WIP column names (e.g. removed "testing" column). let wip_before = config.wip_limits.overrides.len(); @@ -179,11 +259,11 @@ impl Config { } valid }); - if config.wip_limits.overrides.len() != wip_before { - dirty = true; - } + let wip_changed = config.wip_limits.overrides.len() != wip_before; + + let secrets_changed = config.migrate_secrets(); - if dirty { + if roles_changed || wip_changed || secrets_changed { let _ = config.write(_repo_root); } @@ -191,6 +271,33 @@ impl Config { Ok(config) } + /// Move any plaintext `api_key` values into the OS keyring, replacing + /// them in-memory with `keyring:/` pointers. Returns + /// `true` if anything changed (caller should persist the rewrite). + /// Values already in pointer form, or providers that don't use + /// `api_key` (Bedrock), are left untouched. + fn migrate_secrets(&mut self) -> bool { + let mut changed = false; + for (name, provider) in self.providers.iter_mut() { + if provider.kind != ProviderKind::OpenaiCompatible || provider.api_key.is_empty() + || crate::secrets::is_pointer(&provider.api_key) + { + continue; + } + let service = crate::secrets::service_name("llm", "api-key"); + match crate::secrets::store_secret(&service, name, &provider.api_key) { + Ok(pointer) => { + provider.api_key = pointer; + changed = true; + } + Err(e) => { + warn!(provider = %name, error = %e, "failed to migrate api_key into the OS keyring — leaving it as plaintext in config.toml"); + } + } + } + changed + } + pub fn write(&self, _repo_root: &Path) -> Result<()> { let path = crate::state::home::global_config_file(); if let Some(parent) = path.parent() { @@ -244,6 +351,17 @@ impl Config { self.providers.get(name) } + /// Resolve a provider's `api_key` field to its real secret value + /// (following a `keyring:` pointer if present). Falls back to the raw + /// stored value on lookup failure so callers degrade rather than panic; + /// the underlying connection attempt will simply fail with a clear error. + pub fn resolve_provider_api_key(provider: &ProviderDef) -> String { + crate::secrets::resolve_secret(&provider.api_key).unwrap_or_else(|e| { + warn!(error = %e, "failed to resolve provider api_key from keyring"); + provider.api_key.clone() + }) + } + /// Resolve config for a specific repo path. Falls back to global defaults. pub fn repo_config(&self, repo_path: &str) -> ResolvedRepoConfig { let repo = self.repos.iter().find(|r| r.path == repo_path); @@ -465,7 +583,7 @@ mod tests { "lmstudio".to_string(), ProviderDef { api_url: "http://localhost:1234/v1".to_string(), - api_key: String::new(), + ..ProviderDef::default() }, )]), ..Config::default() @@ -482,6 +600,7 @@ mod tests { ProviderDef { api_url: "http://localhost:1234/v1".to_string(), api_key: "sk-test".to_string(), + ..ProviderDef::default() }, )]), ..Config::default() @@ -493,4 +612,43 @@ mod tests { assert_eq!(provider.api_url, "http://localhost:1234/v1"); assert_eq!(provider.api_key, "sk-test"); } + + #[test] + fn bedrock_provider_round_trips_through_toml() { + let config = Config { + providers: HashMap::from([( + "bedrock".to_string(), + ProviderDef { + kind: ProviderKind::Bedrock, + region: Some("us-east-1".to_string()), + sso_start_url: Some("https://example.awsapps.com/start".to_string()), + model_id: Some("anthropic.claude-sonnet-4-6-v1:0".to_string()), + enabled: false, + ..ProviderDef::default() + }, + )]), + ..Config::default() + }; + + let text = toml::to_string_pretty(&config).unwrap(); + let parsed: Config = toml::from_str(&text).unwrap(); + let provider = parsed.providers.get("bedrock").unwrap(); + assert_eq!(provider.kind, ProviderKind::Bedrock); + assert_eq!(provider.region.as_deref(), Some("us-east-1")); + assert_eq!(provider.sso_start_url.as_deref(), Some("https://example.awsapps.com/start")); + assert!(!provider.enabled); + } + + #[test] + fn legacy_provider_toml_without_new_fields_still_parses() { + // Configs written before this change have no `kind`/`enabled` keys. + let text = r#" + [providers.lmstudio] + api_url = "http://localhost:1234/v1" + "#; + let config: Config = toml::from_str(text).unwrap(); + let provider = config.providers.get("lmstudio").unwrap(); + assert_eq!(provider.kind, ProviderKind::OpenaiCompatible); + assert!(provider.enabled); + } } diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 3d2aa05..8166f30 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -4,9 +4,12 @@ use std::time::Duration; use anyhow::Result; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::{broadcast, Mutex as TokioMutex, Notify}; use tracing::{error, info, warn}; +use crate::config::Config; use crate::dispatcher::Dispatcher; +use crate::setup::{self, SetupState}; use crate::state::home; // ── Socket paths ───────────────────────────────────────────────────────────── @@ -82,6 +85,249 @@ pub async fn serve(dispatcher: Arc) -> Result<()> { } } +// ── Bootstrap setup phase (ADR-002) ────────────────────────────────────────── + +/// Shared state for the setup phase, held by the accept loop and every client +/// handler. All discovery/activation logic lives in `crate::setup`; this just +/// publishes state and routes the frontend's selections into it. +struct SetupSession { + /// Current state, queryable on demand via `setup_state`. + state: TokioMutex, + /// Broadcasts every state change to `subscribe`d frontends. + tx: broadcast::Sender, + /// The config under construction — mutated by activations, persisted on success. + config: TokioMutex, + /// Fired once the gate is satisfied; the accept loop stops on it. + ready: Notify, +} + +impl SetupSession { + async fn set_state(&self, state: SetupState) { + *self.state.lock().await = state.clone(); + let _ = self.tx.send(state); + } +} + +/// Run the bootstrap "setup or use" phase on the daemon socket until a valid +/// control-plane provider exists, then return the now-ready `Config` (with +/// `main` bound to the activated provider). The dispatcher is only built from +/// the value this returns, so no pipeline agents come alive until the gate +/// clears (ADR-002). +pub async fn run_setup(initial: Config) -> Result { + let path = socket_path(); + if path.exists() { + let _ = std::fs::remove_file(&path); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let listener = UnixListener::bind(&path)?; + info!("entering setup mode — no LLM provider configured yet"); + + let (tx, _) = broadcast::channel(16); + let session = Arc::new(SetupSession { + state: TokioMutex::new(SetupState::Loading), + tx, + config: TokioMutex::new(initial), + ready: Notify::new(), + }); + + // Kick off the initial scan in the background so the splash shows promptly. + spawn_scan(Arc::clone(&session)); + + // Accept connections until the gate clears. + loop { + tokio::select! { + accepted = listener.accept() => { + match accepted { + Ok((stream, _)) => { + tokio::spawn(handle_setup_client(stream, Arc::clone(&session))); + } + Err(e) => error!("setup: failed to accept connection: {e}"), + } + } + _ = session.ready.notified() => break, + } + } + + // Tear down the setup listener so the real server can rebind the socket. + drop(listener); + let _ = std::fs::remove_file(&path); + + let config = session.config.lock().await.clone(); + info!("setup complete — a control-plane provider is configured"); + Ok(config) +} + +/// Run the (blocking) environment scan on a background task and publish the +/// resulting `NeedsProvider`/`Error` state. Discovered providers are merged +/// into the session config and persisted so activation has entries to work on. +fn spawn_scan(session: Arc) { + tokio::spawn(async move { + session.set_state(SetupState::Loading).await; + let cfg = session.config.lock().await.clone(); + let scanned = tokio::task::spawn_blocking(move || { + let mut cfg = cfg; + let state = setup::scan_to_state(&mut cfg); + (state, cfg) + }) + .await; + match scanned { + Ok((state, cfg)) => { + { + let mut guard = session.config.lock().await; + *guard = cfg; + let _ = guard.write(std::path::Path::new(".")); + } + session.set_state(state).await; + } + Err(e) => { + session + .set_state(SetupState::Error { + messages: vec![format!("scan failed: {e}")], + can_rescan: true, + }) + .await; + } + } + }); +} + +#[derive(serde::Deserialize)] +struct SetupSelect { + name: String, + #[serde(default)] + account_id: Option, + #[serde(default)] + role_name: Option, +} + +async fn handle_setup_client(stream: UnixStream, session: Arc) { + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + + while let Ok(Some(line)) = lines.next_line().await { + let trimmed = line.trim(); + + if trimmed == "subscribe" { + stream_setup_state(&session, &mut writer).await; + return; + } + + let response = match trimmed { + "ping" => "pong".to_string(), + "setup_state" => { + let state = session.state.lock().await.clone(); + serde_json::to_string(&state).unwrap_or_else(|e| format!("error: {e}")) + } + "setup_rescan" => { + spawn_scan(Arc::clone(&session)); + let state = session.state.lock().await.clone(); + serde_json::to_string(&state).unwrap_or_else(|e| format!("error: {e}")) + } + cmd if cmd.starts_with("setup_select ") => { + let payload = cmd.strip_prefix("setup_select ").unwrap().trim(); + handle_setup_select(&session, payload).await + } + // Any board-protocol command means a frontend connected expecting + // the live server. Tell it we're still in setup so it renders the + // setup screen instead of hanging on a board snapshot. + _ => "{\"setup\":true}".to_string(), + }; + + if writer.write_all(format!("{response}\n").as_bytes()).await.is_err() { + break; + } + } +} + +/// Activate the selected provider. On success persist the config; if the gate +/// is now satisfied, publish `Ready` and wake the accept loop. Returns a JSON +/// envelope `{ok, message, done}` for the caller. +async fn handle_setup_select(session: &Arc, payload: &str) -> String { + let sel: SetupSelect = match serde_json::from_str(payload) { + Ok(s) => s, + Err(e) => return format!("{{\"ok\":false,\"message\":\"invalid selection: {e}\",\"done\":false}}"), + }; + + let result = { + let mut config = session.config.lock().await; + setup::activate(&mut config, &sel.name, sel.account_id, sel.role_name).await + }; + + match result { + Ok(setup::ActivationOutcome::Activated { message }) => { + let ready = { + let config = session.config.lock().await; + let _ = config.write(std::path::Path::new(".")); + setup::gate_ready(&config) + }; + if ready { + session.set_state(SetupState::Ready).await; + session.ready.notify_one(); + json_ok(&message, true) + } else { + json_ok(&message, false) + } + } + Ok(setup::ActivationOutcome::NeedsMoreInput { message }) => json_ok(&message, false), + Err(e) => { + let msg = e.to_string(); + // Surface the failure on the state stream too, keeping rescan available. + session + .set_state(SetupState::Error { + messages: vec![msg.clone()], + can_rescan: true, + }) + .await; + format!( + "{{\"ok\":false,\"message\":{},\"done\":false}}", + serde_json::to_string(&msg).unwrap_or_else(|_| "\"error\"".to_string()) + ) + } + } +} + +fn json_ok(message: &str, done: bool) -> String { + format!( + "{{\"ok\":true,\"message\":{},\"done\":{done}}}", + serde_json::to_string(message).unwrap_or_else(|_| "\"\"".to_string()) + ) +} + +/// Stream JSON-encoded `SetupState` lines to a subscribed frontend until the +/// connection closes. Emits the current state immediately so a late subscriber +/// is never left blank. +async fn stream_setup_state( + session: &Arc, + writer: &mut tokio::net::unix::OwnedWriteHalf, +) { + use tokio::sync::broadcast::error::RecvError; + + let mut rx = session.tx.subscribe(); + // Send current state first. + { + let state = session.state.lock().await.clone(); + if let Ok(json) = serde_json::to_string(&state) + && writer.write_all(format!("{json}\n").as_bytes()).await.is_err() + { + return; + } + } + loop { + match rx.recv().await { + Ok(state) => { + let Ok(json) = serde_json::to_string(&state) else { continue }; + if writer.write_all(format!("{json}\n").as_bytes()).await.is_err() { + break; + } + } + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, + } + } +} + async fn handle_client(stream: UnixStream, dispatcher: Arc) { let (reader, mut writer) = stream.into_split(); let mut lines = BufReader::new(reader).lines(); @@ -98,6 +344,11 @@ async fn handle_client(stream: UnixStream, dispatcher: Arc) { let response = match trimmed { "ping" => "pong".to_string(), "status" => "running".to_string(), + // The live server is only reached once the bootstrap gate has + // cleared, so setup is always Ready here. Frontends query this + // first to decide between the setup screen and the board (ADR-002). + "setup_state" => serde_json::to_string(&SetupState::Ready) + .unwrap_or_else(|_| "{\"state\":\"ready\"}".to_string()), "peek_review" => handle_peek_review(&dispatcher).await, "board" => handle_board(&dispatcher).await, "epics" => handle_epics(&dispatcher).await, @@ -808,10 +1059,10 @@ fn parse_chat_ctx(encoded: &str) -> (String, String) { if let Some(ref col) = ctx.selected_column { parts.push(format!("column={col}")); } - if let Some(ref title) = ctx.selected_task_title { - if let Some(ref id) = ctx.selected_task_id { - parts.push(format!("selected_task=\"{title}\" ({id})")); - } + if let Some(ref title) = ctx.selected_task_title + && let Some(ref id) = ctx.selected_task_id + { + parts.push(format!("selected_task=\"{title}\" ({id})")); } if ctx.pending_questions > 0 { parts.push(format!("pending_questions={}", ctx.pending_questions)); diff --git a/src/dispatcher/agent_pool.rs b/src/dispatcher/agent_pool.rs index 3bd24e7..4c11369 100644 --- a/src/dispatcher/agent_pool.rs +++ b/src/dispatcher/agent_pool.rs @@ -85,6 +85,7 @@ impl AgentPool { /// 5. On pass: WIP check → advance task to next buffer, emit TaskStageChanged then AgentCompleted /// 6. On fail: retry once with verifier feedback, escalate to human review if still failing /// 7. On blocked: set blocked flag, emit AgentBlocked, sleep until unblocked + #[allow(clippy::too_many_arguments)] pub fn spawn( event_bus: Arc, board: Arc>, @@ -431,7 +432,7 @@ async fn handle_agent_result( } else { warn!(%role, task_id = %task.id, "retry also failed verification — escalating"); escalate_verification_failure( - handle, event_bus, task, review_queue, &reason, &store, + handle, event_bus, task, review_queue, &reason, store, ).await; } } diff --git a/src/dispatcher/mod.rs b/src/dispatcher/mod.rs index 14e1021..80e4e23 100644 --- a/src/dispatcher/mod.rs +++ b/src/dispatcher/mod.rs @@ -238,6 +238,11 @@ pub struct Dispatcher { pub chat_history: Arc>>, /// The main coordination agent that drives the chat interface. pub main_agent: MainAgent, + /// The distinguished fallback "control-plane" brain (ADR-002). Built only + /// when a fallback provider is configured *and* distinct from main's own + /// provider, so it can run the recovery conversation when main is down. + /// `None` when main already is the fallback (recovery couldn't help). + pub fallback_agent: Option, /// Token store for MCP sub-agent authorization. pub token_store: Arc, /// Persistence layer for tasks, epics, and review items. @@ -468,10 +473,9 @@ impl Dispatcher { repo_config.main_branch ); crate::state::repo_cache::increment_commits( - &repo_path.file_name() + repo_path.file_name() .and_then(|n| n.to_str()) .unwrap_or("default") - .to_string() ); } Ok(MergeOutcome::Skipped(reason)) => { @@ -711,6 +715,86 @@ impl Dispatcher { Ok(persisted) } + /// Re-scan this machine for LLM providers and AWS Bedrock/SSO access, + /// merging any newly-found ones into `~/.gitzi/config.toml` as disabled + /// candidates (never auto-activated — see `bootstrap` module docs). + /// Reads and writes the config file directly rather than `self.config`, + /// since the latter is an immutable snapshot for the life of the daemon. + /// Returns a human-readable summary for the main agent to relay. + pub async fn gitzi_rediscover_providers(&self) -> anyhow::Result { + let gitzi_home = crate::state::home::gitzi_home(); + let mut config = Config::load(&gitzi_home)?; + + // Shared scan + merge logic lives in `crate::setup` so the agent tool + // and the daemon's bootstrap setup phase stay in lockstep (ADR-002). + let discovered = crate::bootstrap::discover_providers(); + let added = crate::setup::merge_discovered(&mut config, &discovered); + + if !added.is_empty() { + config.write(&gitzi_home)?; + info!(added = ?added, "rediscovered new providers — merged into config.toml as disabled"); + } + + if discovered.is_empty() { + return Ok("No LLM providers or AWS Bedrock access discovered on this machine.".to_string()); + } + + let lines: Vec = discovered.iter().map(|p| { + let enabled = config.providers.get(&p.name).map(|d| d.enabled).unwrap_or(false); + let status = if p.model_loaded { + "running, model loaded" + } else if p.running { + "running, no model loaded" + } else if p.installed { + "installed, not running" + } else { + "not installed" + }; + let kind = match p.kind { + crate::config::ProviderKind::OpenaiCompatible => "openai-compatible", + crate::config::ProviderKind::Bedrock => "bedrock", + }; + format!("- {} [{kind}] {status}, enabled={enabled}", p.name) + }).collect(); + + let added_note = if added.is_empty() { + String::new() + } else { + format!("\n\nNewly discovered: {}", added.join(", ")) + }; + + Ok(format!("Discovered providers:\n{}{added_note}", lines.join("\n"))) + } + + /// Activate a discovered provider so agents actually use it. For + /// OpenAI-compatible providers this is immediate. For Bedrock, this may + /// span multiple calls — see the `gitzi_activate_provider` tool + /// description. Operates on `~/.gitzi/config.toml` directly; the caller + /// must tell the user a gitzi restart is needed for the rewired agent to + /// take effect (the running daemon's `self.config` is immutable). + pub async fn gitzi_activate_provider( + &self, + name: &str, + account_id: Option, + role_name: Option, + ) -> anyhow::Result { + let gitzi_home = crate::state::home::gitzi_home(); + let mut config = Config::load(&gitzi_home)?; + + // Activation logic is shared with the daemon's bootstrap setup phase — + // both drive `crate::setup::activate` so there is one source of truth + // for "enable a provider and wire it into main" (ADR-002). + match crate::setup::activate(&mut config, name, account_id, role_name).await? { + crate::setup::ActivationOutcome::Activated { message } => { + config.write(&gitzi_home)?; + Ok(format!( + "ok: {message} Restart gitzi for this to take effect." + )) + } + crate::setup::ActivationOutcome::NeedsMoreInput { message } => Ok(message), + } + } + /// Boot the dispatcher: load tasks, build board, spawn agents, emit BootComplete, /// and signal agents whose columns contain work. pub async fn start(config: Config) -> anyhow::Result { @@ -808,9 +892,11 @@ impl Dispatcher { Arc::new(Mutex::new(messages)) }; - // 11. Build main agent from config + // 11. Build main agent from config, plus the fallback control-plane + // brain used to drive recovery when main's own provider is down. let main_agent_def = config.resolve_agent("main"); - let main_agent = build_main_agent(&main_agent_def); + let main_agent = build_main_agent(&config, &main_agent_def); + let fallback_agent = build_fallback_agent(&config); let dispatcher = Self { event_bus, @@ -822,6 +908,7 @@ impl Dispatcher { wip_waiting, chat_history, main_agent, + fallback_agent, token_store, store, chat_stack: Mutex::new(Vec::new()), @@ -1067,13 +1154,38 @@ impl Dispatcher { true, ); - // 6. Tool-calling loop + // 6. Tool-calling loop. If main's provider is down, fall back *once* to + // the control-plane brain to run a recovery conversation (ADR-002) — + // never a silent swap for the user's real request. + let mut active_agent = &self.main_agent; + let mut recovered = false; let final_response = loop { - let (raw_assistant, turn) = self - .main_agent - .turn(&messages, &tools) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + let (raw_assistant, turn) = match active_agent.turn(&messages, &tools).await { + Ok(t) => t, + Err(e) => { + if !recovered + && let Some(fallback) = self.fallback_agent.as_ref() + { + warn!(error = %e, "main provider failed — handing off to the fallback brain for recovery"); + recovered = true; + active_agent = fallback; + messages.push(OaiMessage { + role: "system".to_string(), + content: Some(format!( + "The main model provider is not responding ({e}). You are the \ + fallback assistant. Tell the user plainly that their main model \ + is unavailable, then ask what they want to do — retry, switch to \ + a different provider, or keep going with you. Do not attempt their \ + original request as if nothing happened." + )), + tool_calls: vec![], + tool_call_id: None, + }); + continue; + } + return Err(anyhow::anyhow!("{e}")); + } + }; match turn { ChatTurn::Text(text) => break text, @@ -1347,6 +1459,34 @@ impl Dispatcher { format!("ok: fork closed — {summary}") } + "gitzi_rediscover_providers" => match self.gitzi_rediscover_providers().await { + Ok(summary) => summary, + Err(e) => format!("error: {e}"), + }, + + "gitzi_activate_provider" => { + let name = args + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + if name.is_empty() { + return "error: missing required argument: name".to_string(); + } + let account_id = args + .get("account_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let role_name = args + .get("role_name") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + match self.gitzi_activate_provider(&name, account_id, role_name).await { + Ok(summary) => summary, + Err(e) => format!("error: {e}"), + } + } + "gitzi_search_kb" => { let query = args .get("query") @@ -1502,6 +1642,37 @@ impl Dispatcher { } } +/// Point the `main` role's `[[agents]]` entry at `provider_name`, creating +/// the entry if one doesn't exist yet. Other roles keep falling back to +/// `main` (or the local `claude` CLI) per the agent-resolution rules in +/// `config.rs` — only `main` is rewired here. +/// Build the fallback control-plane brain (ADR-002): a chat agent bound to the +/// distinguished `fallback_provider`, used to run the recovery conversation +/// when main's own provider is down. Returns `None` unless the fallback is set, +/// enabled, OpenAI-compatible, and *distinct* from main's provider — if main +/// already is the fallback, falling back couldn't help. +fn build_fallback_agent(config: &Config) -> Option { + let fallback = config.fallback_provider.as_ref()?; + let provider = config.providers.get(fallback)?; + if !provider.enabled || provider.kind != crate::config::ProviderKind::OpenaiCompatible { + return None; + } + let main_def = config.resolve_agent("main"); + if main_def.provider.as_deref() == Some(fallback.as_str()) { + return None; + } + let def = crate::config::AgentDef { + role: "main".to_string(), + provider: Some(fallback.clone()), + api_url: None, + model: provider + .model_id + .clone() + .unwrap_or_else(|| main_def.model.clone()), + }; + Some(build_main_agent(config, &def)) +} + #[cfg(test)] mod tests { use super::*; @@ -1591,4 +1762,68 @@ mod tests { assert_eq!(col.agent_role(), Some(*role)); } } + + // ── Fallback control-plane brain (ADR-002) ────────────────────────────── + + fn openai_provider(url: &str) -> crate::config::ProviderDef { + crate::config::ProviderDef { + api_url: url.to_string(), + enabled: true, + ..crate::config::ProviderDef::default() + } + } + + #[test] + fn fallback_agent_none_when_fallback_is_also_main() { + // Main is bound to the same provider as the fallback — recovery via the + // fallback couldn't help, so there's no separate brain. + let config = Config { + providers: std::collections::HashMap::from([( + "local".to_string(), + openai_provider("http://localhost:1234/v1"), + )]), + agents: vec![crate::config::AgentDef { + role: "main".to_string(), + provider: Some("local".to_string()), + ..crate::config::AgentDef::default() + }], + fallback_provider: Some("local".to_string()), + ..Config::default() + }; + assert!(build_fallback_agent(&config).is_none()); + } + + #[test] + fn fallback_agent_built_when_distinct_from_main() { + // Main points at one provider, the fallback brain at another — the + // fallback is built and aimed at the fallback provider's endpoint. + let config = Config { + providers: std::collections::HashMap::from([ + ("remote".to_string(), openai_provider("http://remote:8080/v1")), + ("local".to_string(), openai_provider("http://localhost:11434/v1")), + ]), + agents: vec![crate::config::AgentDef { + role: "main".to_string(), + provider: Some("remote".to_string()), + ..crate::config::AgentDef::default() + }], + fallback_provider: Some("local".to_string()), + ..Config::default() + }; + let fallback = build_fallback_agent(&config).expect("distinct fallback should build"); + assert_eq!(fallback.base_url(), "http://localhost:11434/v1"); + } + + #[test] + fn fallback_agent_none_when_provider_disabled() { + let mut provider = openai_provider("http://localhost:11434/v1"); + provider.enabled = false; + let config = Config { + providers: std::collections::HashMap::from([("local".to_string(), provider)]), + agents: Vec::new(), + fallback_provider: Some("local".to_string()), + ..Config::default() + }; + assert!(build_fallback_agent(&config).is_none()); + } } diff --git a/src/error_relay.rs b/src/error_relay.rs index 84fd8fe..264c11e 100644 --- a/src/error_relay.rs +++ b/src/error_relay.rs @@ -43,7 +43,7 @@ where let mut visitor = MessageVisitor(String::new()); event.record(&mut visitor); let message = if visitor.0.is_empty() { - format!("{}", event.metadata().target()) + event.metadata().target().to_string() } else { visitor.0 }; diff --git a/src/git/ops.rs b/src/git/ops.rs index 08c4941..4291b82 100644 --- a/src/git/ops.rs +++ b/src/git/ops.rs @@ -411,12 +411,12 @@ fn create_pull_request( .current_dir(repo_path) .output(); - if let Ok(output) = gh_result { - if output.status.success() { - let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - tracing::info!(url = %url, "PR created via gh CLI"); - return Ok(MergeOutcome::Merged); - } + if let Ok(output) = gh_result + && output.status.success() + { + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + tracing::info!(url = %url, "PR created via gh CLI"); + return Ok(MergeOutcome::Merged); } // Try glab mr create (GitLab CLI) @@ -428,12 +428,12 @@ fn create_pull_request( .current_dir(repo_path) .output(); - if let Ok(output) = glab_result { - if output.status.success() { - let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - tracing::info!(url = %url, "MR created via glab CLI"); - return Ok(MergeOutcome::Merged); - } + if let Ok(output) = glab_result + && output.status.success() + { + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + tracing::info!(url = %url, "MR created via glab CLI"); + return Ok(MergeOutcome::Merged); } // Neither CLI available — branch is pushed, user can create PR manually diff --git a/src/lib.rs b/src/lib.rs index 8c4c070..e281eb6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod agent; +pub mod aws_sso; pub mod bootstrap; pub mod cli; pub mod config; @@ -13,6 +14,8 @@ pub mod mcp; pub mod model; pub mod pipeline; pub mod runner; +pub mod secrets; +pub mod setup; pub mod state; pub mod verifier; diff --git a/src/main.rs b/src/main.rs index e83d18d..7f88fad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -189,39 +189,39 @@ async fn cmd_log() -> Result<()> { frame.render_widget(Paragraph::new(visible), inner); })?; - if ct_event::poll(std::time::Duration::from_millis(50))? { - if let Event::Key(key) = ct_event::read()? { - if key.kind != KeyEventKind::Press { - continue; + if ct_event::poll(std::time::Duration::from_millis(50))? + && let Event::Key(key) = ct_event::read()? + { + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Char('q') => break, + KeyCode::Char('f') => { + auto_follow = !auto_follow; } - match key.code { - KeyCode::Char('q') => break, - KeyCode::Char('f') => { - auto_follow = !auto_follow; - } - KeyCode::Up | KeyCode::Char('k') => { - auto_follow = false; - scroll_offset = scroll_offset.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - auto_follow = false; - if scroll_offset < log_lines.len().saturating_sub(1) { - scroll_offset += 1; - } - } - KeyCode::PageUp => { - auto_follow = false; - scroll_offset = scroll_offset.saturating_sub(20); + KeyCode::Up | KeyCode::Char('k') => { + auto_follow = false; + scroll_offset = scroll_offset.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + auto_follow = false; + if scroll_offset < log_lines.len().saturating_sub(1) { + scroll_offset += 1; } - KeyCode::PageDown => { - if scroll_offset + 20 >= log_lines.len() { - auto_follow = true; - } - scroll_offset = - (scroll_offset + 20).min(log_lines.len().saturating_sub(1)); + } + KeyCode::PageUp => { + auto_follow = false; + scroll_offset = scroll_offset.saturating_sub(20); + } + KeyCode::PageDown => { + if scroll_offset + 20 >= log_lines.len() { + auto_follow = true; } - _ => {} + scroll_offset = + (scroll_offset + 20).min(log_lines.len().saturating_sub(1)); } + _ => {} } } } @@ -241,7 +241,16 @@ async fn cmd_daemon() -> Result<()> { home::ensure_dirs()?; let gitzi_home = home::gitzi_home(); - let config = Config::load(&gitzi_home).context("Failed to load config")?; + let mut config = Config::load(&gitzi_home).context("Failed to load config")?; + + // ADR-002: the experience is binary — set up an LLM or use one. If no valid + // control-plane provider exists, enter setup mode on the socket and don't + // build the dispatcher (so no agents come alive) until the gate clears. + if !gitzi::setup::gate_ready(&config) { + config = daemon::run_setup(config) + .await + .context("setup phase failed")?; + } // Populate repo cache from config globs if !config.repo_paths.is_empty() { diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 0000000..b05096b --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,112 @@ +//! Secure secret storage for config values that would otherwise sit in +//! plaintext in `~/.gitzi/config.toml` (provider API keys, cloud SSO/OAuth +//! tokens). +//! +//! Secrets are stored in the OS keyring (Keychain on macOS, Credential +//! Manager on Windows, Secret Service on Linux via the `keyring` crate) and +//! referenced from config.toml by a pointer string of the form +//! `keyring:/`. `resolve_secret` transparently follows the +//! pointer; anything that isn't a `keyring:` pointer is treated as a raw +//! value (so plaintext values from older configs still work until migrated). +//! +//! None of the three backing stores support real hierarchy or structured +//! values — each is just a flat `(service, account) -> secret string` map +//! (Linux's Secret Service has "collections", but the `keyring` crate doesn't +//! expose them, and Windows/macOS have no equivalent). So every entry must +//! follow one naming convention, built with [`service_name`]: +//! +//! ```text +//! service = "gitzi//" e.g. "gitzi/aws/sso-token", "gitzi/llm/api-key" +//! account = e.g. an SSO start URL, a tenant ID, a provider name +//! ``` +//! +//! `domain` is the cloud or category (`aws`, `gcp`, `azure`, `llm`, ...); +//! `kind` distinguishes secrets with different lifetimes or purposes within +//! that domain (e.g. a short-lived SSO access token vs. a 90-day-lived OIDC +//! client registration). Keeping `account` tied to the resource's own +//! identity — not gitzi's local provider name — lets multiple gitzi-config +//! providers that share one underlying login (e.g. two Bedrock providers on +//! the same SSO org) share one cached credential instead of duplicating it. +//! When one logical secret has multiple fields (a token plus its expiry, a +//! client ID plus its secret), serialize them together as one JSON blob +//! under a single entry rather than splitting across several. + +use crate::error::{GitziError, Result}; + +const POINTER_PREFIX: &str = "keyring:"; + +/// Build a `service` string per gitzi's keyring naming convention — see the +/// module docs. Use this instead of hand-rolling service strings so every +/// secret type stays self-describing and consistent across clouds/providers. +pub fn service_name(domain: &str, kind: &str) -> String { + format!("gitzi/{domain}/{kind}") +} + +/// Store `value` in the OS keyring under `service`/`account` and return the +/// `keyring:/` pointer to put in config.toml in its place. +pub fn store_secret(service: &str, account: &str, value: &str) -> Result { + let entry = keyring::Entry::new(service, account) + .map_err(|e| GitziError::Config(format!("keyring error for {service}/{account}: {e}")))?; + entry + .set_password(value) + .map_err(|e| GitziError::Config(format!("failed to store secret {service}/{account}: {e}")))?; + Ok(format!("{POINTER_PREFIX}{service}/{account}")) +} + +/// Resolve a config value to its real secret. If `value` is a `keyring:` +/// pointer, looks it up in the OS keyring. Otherwise returns it unchanged +/// (covers plaintext values from configs written before secure storage). +pub fn resolve_secret(value: &str) -> Result { + match value.strip_prefix(POINTER_PREFIX) { + Some(rest) => { + let (service, account) = rest.split_once('/').ok_or_else(|| { + GitziError::Config(format!("malformed keyring pointer: {value}")) + })?; + let entry = keyring::Entry::new(service, account).map_err(|e| { + GitziError::Config(format!("keyring error for {service}/{account}: {e}")) + })?; + entry.get_password().map_err(|e| { + GitziError::Config(format!("keyring lookup failed for {value}: {e}")) + }) + } + None => Ok(value.to_string()), + } +} + +/// True if `value` is already a `keyring:` pointer (as opposed to plaintext). +pub fn is_pointer(value: &str) -> bool { + value.starts_with(POINTER_PREFIX) +} + +/// Remove the secret a pointer refers to. No-op (and no error) for plaintext +/// values or pointers that don't resolve to an existing entry. +pub fn delete_secret(value: &str) { + if let Some(rest) = value.strip_prefix(POINTER_PREFIX) + && let Some((service, account)) = rest.split_once('/') + && let Ok(entry) = keyring::Entry::new(service, account) + { + let _ = entry.delete_credential(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_secret_passes_through_plaintext() { + assert_eq!(resolve_secret("sk-plain").unwrap(), "sk-plain"); + } + + #[test] + fn is_pointer_detects_keyring_prefix() { + assert!(is_pointer("keyring:gitzi-provider-foo/api-key")); + assert!(!is_pointer("sk-plain")); + } + + #[test] + fn resolve_secret_rejects_malformed_pointer() { + let err = resolve_secret("keyring:no-slash-here").unwrap_err(); + assert!(err.to_string().contains("malformed")); + } +} diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..3e7d1fd --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,411 @@ +//! Backend-owned bootstrap "setup or use" gate (ADR-002). +//! +//! The experience is binary: **setup an LLM** or **use an LLM**. There is no +//! degraded in-between. The daemon evaluates the gate on startup; until a valid +//! control-plane provider exists it stays in *setup mode* (dispatcher idle, no +//! pipeline agents signalled) and publishes the current [`SetupState`] for the +//! frontend to render. The frontend is a thin renderer — all discovery, +//! activation, and validation logic lives here, so it can be reused by any +//! frontend and by the post-bootstrap agent tools alike. + +use serde::{Deserialize, Serialize}; + +use crate::bootstrap::{discover_providers, DiscoveredProvider}; +use crate::config::{Config, ProviderDef, ProviderKind}; + +/// A provider candidate offered to the user during setup. This is the +/// render-ready projection of a [`DiscoveredProvider`] plus its current +/// enabled state in config — everything the frontend needs to draw the picker. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderCandidate { + /// Provider name (the `[providers.]` key). + pub name: String, + /// `"openai-compatible"` or `"bedrock"`. + pub kind: String, + /// Human-readable status, e.g. `"running, model loaded"`. + pub status: String, + /// True when this candidate can be activated in a single step right now + /// (an OpenAI-compatible server that's running with a model loaded). Bedrock + /// and not-yet-running servers still need follow-up steps. + pub ready: bool, +} + +impl ProviderCandidate { + fn from_discovered(p: &DiscoveredProvider) -> Self { + let status = if p.model_loaded { + "running, model loaded".to_string() + } else if p.running { + "running, no model loaded".to_string() + } else if p.installed { + "installed, not running".to_string() + } else { + "not installed".to_string() + }; + let kind = match p.kind { + ProviderKind::OpenaiCompatible => "openai-compatible", + ProviderKind::Bedrock => "bedrock", + }; + ProviderCandidate { + name: p.name.clone(), + kind: kind.to_string(), + status, + ready: p.kind == ProviderKind::OpenaiCompatible && p.model_loaded, + } + } +} + +/// The backend-owned setup state, published to the frontend over the event bus +/// and queryable on demand. The frontend switches on this: splash / picker / +/// error+rescan / app. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum SetupState { + /// Scan / validation in progress — the frontend shows a loading splash. + Loading, + /// No valid provider yet; offer discovered candidates to activate. The only + /// human-facing setup step. + NeedsProvider { candidates: Vec }, + /// The scan found nothing, or an activation failed. Every error is listed; + /// `can_rescan` tells the frontend to offer a re-scan. + Error { messages: Vec, can_rescan: bool }, + /// A valid control-plane provider exists; the app is usable. + Ready, +} + +/// Does the main chat agent resolve to an enabled provider? +fn main_provider_enabled(config: &Config) -> bool { + let def = config.resolve_agent("main"); + match def.provider { + Some(name) => config.providers.get(&name).map(|d| d.enabled).unwrap_or(false), + None => false, + } +} + +/// Is the distinguished fallback provider set and enabled? +fn fallback_enabled(config: &Config) -> bool { + match &config.fallback_provider { + Some(name) => config.providers.get(name).map(|d| d.enabled).unwrap_or(false), + None => false, + } +} + +/// The gate: is there a working control-plane LLM? True when either the main +/// agent is bound to an enabled provider, or the distinguished fallback +/// provider is enabled. This is re-evaluated on every load — file existence is +/// never the signal (ADR-002). +pub fn gate_ready(config: &Config) -> bool { + main_provider_enabled(config) || fallback_enabled(config) +} + +/// Run the (blocking) environment scan, merging anything new into `config` as +/// disabled entries, and project the results into the frontend-facing +/// [`SetupState`] the daemon should publish next. Returns `NeedsProvider` when +/// candidates were found, or `Error` (with a rescannable "nothing found" +/// message) when the machine has no LLM to offer. `config` is mutated with the +/// merged providers; the caller persists. +pub fn scan_to_state(config: &mut Config) -> SetupState { + let discovered = discover_providers(); + if discovered.is_empty() { + return SetupState::Error { + messages: vec![ + "No LLM provider found on this machine. Start LM Studio or Ollama, \ + or run `aws sso login`, then rescan." + .to_string(), + ], + can_rescan: true, + }; + } + let candidates = discovered + .iter() + .map(ProviderCandidate::from_discovered) + .collect(); + merge_discovered(config, &discovered); + SetupState::NeedsProvider { candidates } +} + +/// Merge freshly discovered providers into `config` as disabled entries, +/// skipping any already present. Returns the names that were added. +pub fn merge_discovered(config: &mut Config, discovered: &[DiscoveredProvider]) -> Vec { + let mut added = Vec::new(); + for provider in discovered { + if config.providers.contains_key(&provider.name) { + continue; + } + let mut def = ProviderDef { + kind: provider.kind, + enabled: false, + ..ProviderDef::default() + }; + match provider.kind { + ProviderKind::OpenaiCompatible => { + def.api_url = provider.api_url.clone(); + } + ProviderKind::Bedrock => { + def.region = provider.region.clone(); + def.sso_start_url = provider.sso_start_url.clone(); + } + } + config.providers.insert(provider.name.clone(), def); + added.push(provider.name.clone()); + } + added +} + +/// Bind the `main` agent to `provider_name`, creating the entry if absent. +/// Also records the provider as the distinguished fallback (control-plane +/// brain) if no fallback has been chosen yet. +pub fn wire_main_agent(config: &mut Config, provider_name: &str) { + if let Some(agent) = config.agents.iter_mut().find(|a| a.role == "main") { + agent.provider = Some(provider_name.to_string()); + agent.api_url = None; + } else { + config.agents.push(crate::config::AgentDef { + role: "main".to_string(), + provider: Some(provider_name.to_string()), + ..crate::config::AgentDef::default() + }); + } + if config.fallback_provider.is_none() { + config.fallback_provider = Some(provider_name.to_string()); + } +} + +/// Outcome of an activation attempt. Activation may need several round-trips +/// for Bedrock (SSO login → choose account → choose role), so a successful +/// call can still be a request for more input rather than a finished state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ActivationOutcome { + /// The provider is now enabled and wired into the main agent. + Activated { message: String }, + /// Activation needs another call with more arguments (Bedrock multi-step). + NeedsMoreInput { message: String }, +} + +/// Activate a discovered provider in `config` (mutating it in place; the caller +/// persists). For OpenAI-compatible providers this is immediate. For Bedrock it +/// drives the AWS SSO device flow and may return [`ActivationOutcome::NeedsMoreInput`] +/// to request account/role selection. Shared by the daemon setup phase and the +/// `gitzi_activate_provider` agent tool. +pub async fn activate( + config: &mut Config, + name: &str, + account_id: Option, + role_name: Option, +) -> anyhow::Result { + let mut provider = config.providers.get(name).cloned().ok_or_else(|| { + anyhow::anyhow!("no provider named '{name}' — run a rescan first") + })?; + + if provider.kind == ProviderKind::OpenaiCompatible { + provider.enabled = true; + config.providers.insert(name.to_string(), provider); + wire_main_agent(config, name); + return Ok(ActivationOutcome::Activated { + message: format!("Activated '{name}' and wired it into the main agent."), + }); + } + + // Bedrock: AWS SSO login → account → role → validate. + let region = provider.region.clone().ok_or_else(|| { + anyhow::anyhow!("provider '{name}' has no AWS region configured") + })?; + let start_url = provider.sso_start_url.clone().ok_or_else(|| { + anyhow::anyhow!("provider '{name}' has no sso_start_url configured") + })?; + + let token = match crate::aws_sso::load_token(&start_url) { + Some(t) => t, + None => { + let pending = crate::aws_sso::start_device_login(®ion, &start_url).await?; + crate::aws_sso::poll_for_token(&pending, &start_url).await? + } + }; + + let Some(account_id) = account_id else { + let accounts = crate::aws_sso::list_accounts(®ion, &token.access_token).await?; + if accounts.is_empty() { + anyhow::bail!("AWS SSO login succeeded but no accounts are assigned to this user"); + } + let listing = accounts + .iter() + .map(|a| format!("- {} ({}) <{}>", a.account_id, a.account_name, a.email_address)) + .collect::>() + .join("\n"); + return Ok(ActivationOutcome::NeedsMoreInput { + message: format!( + "AWS SSO login confirmed. Choose an account and activate '{name}' again with account_id set:\n{listing}" + ), + }); + }; + + let Some(role_name) = role_name else { + let roles = + crate::aws_sso::list_account_roles(®ion, &token.access_token, &account_id).await?; + if roles.is_empty() { + anyhow::bail!("no SSO roles assigned to account '{account_id}'"); + } + let listing = roles + .iter() + .map(|r| format!("- {}", r.role_name)) + .collect::>() + .join("\n"); + return Ok(ActivationOutcome::NeedsMoreInput { + message: format!( + "Choose a role and activate '{name}' again with account_id='{account_id}' and role_name set:\n{listing}" + ), + }); + }; + + // Validate the chosen account/role actually exchange for credentials. + crate::aws_sso::get_role_credentials(®ion, &token.access_token, &account_id, &role_name) + .await?; + + provider.enabled = true; + provider.sso_account_id = Some(account_id.clone()); + provider.sso_role_name = Some(role_name.clone()); + if provider.model_id.is_none() { + provider.model_id = Some("anthropic.claude-sonnet-4-6-v1:0".to_string()); + } + config.providers.insert(name.to_string(), provider); + wire_main_agent(config, name); + + Ok(ActivationOutcome::Activated { + message: format!( + "Activated Bedrock provider '{name}' (account {account_id}, role {role_name}) and wired it into the main agent." + ), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{AgentDef, ProviderDef}; + use std::collections::HashMap; + + fn provider(enabled: bool) -> ProviderDef { + ProviderDef { + api_url: "http://localhost:1234/v1".to_string(), + enabled, + ..ProviderDef::default() + } + } + + #[test] + fn gate_not_ready_on_default_config() { + // Default config has a disabled-by-discovery model but no activated + // fallback and no main binding — must require setup. + let config = Config { + providers: HashMap::from([("lmstudio".to_string(), provider(false))]), + agents: Vec::new(), + fallback_provider: None, + ..Config::default() + }; + assert!(!gate_ready(&config)); + } + + #[test] + fn gate_ready_when_main_bound_to_enabled_provider() { + let config = Config { + providers: HashMap::from([("lmstudio".to_string(), provider(true))]), + agents: vec![AgentDef { + role: "main".to_string(), + provider: Some("lmstudio".to_string()), + ..AgentDef::default() + }], + fallback_provider: None, + ..Config::default() + }; + assert!(gate_ready(&config)); + } + + #[test] + fn gate_ready_when_fallback_enabled_even_without_main_binding() { + let config = Config { + providers: HashMap::from([("lmstudio".to_string(), provider(true))]), + agents: Vec::new(), + fallback_provider: Some("lmstudio".to_string()), + ..Config::default() + }; + assert!(gate_ready(&config)); + } + + #[test] + fn gate_not_ready_when_provider_bound_but_disabled() { + let config = Config { + providers: HashMap::from([("lmstudio".to_string(), provider(false))]), + agents: vec![AgentDef { + role: "main".to_string(), + provider: Some("lmstudio".to_string()), + ..AgentDef::default() + }], + fallback_provider: Some("lmstudio".to_string()), + ..Config::default() + }; + assert!(!gate_ready(&config)); + } + + #[tokio::test] + async fn activate_openai_provider_enables_wires_and_sets_fallback() { + let mut config = Config { + providers: HashMap::from([("lmstudio".to_string(), provider(false))]), + agents: Vec::new(), + fallback_provider: None, + ..Config::default() + }; + + let outcome = activate(&mut config, "lmstudio", None, None).await.unwrap(); + assert!(matches!(outcome, ActivationOutcome::Activated { .. })); + assert!(config.providers["lmstudio"].enabled); + assert_eq!(config.fallback_provider.as_deref(), Some("lmstudio")); + assert_eq!( + config.resolve_agent("main").provider.as_deref(), + Some("lmstudio") + ); + // The gate now passes — the app is usable. + assert!(gate_ready(&config)); + } + + #[tokio::test] + async fn activate_unknown_provider_errors() { + let mut config = Config::default(); + let err = activate(&mut config, "does-not-exist", None, None) + .await + .unwrap_err(); + assert!(err.to_string().contains("does-not-exist")); + } + + #[test] + fn merge_discovered_adds_only_new_as_disabled() { + let mut config = Config { + providers: HashMap::from([("lmstudio".to_string(), provider(true))]), + ..Config::default() + }; + let discovered = vec![ + DiscoveredProvider { + name: "lmstudio".to_string(), + kind: ProviderKind::OpenaiCompatible, + api_url: "http://localhost:1234/v1".to_string(), + region: None, + sso_start_url: None, + running: true, + model_loaded: true, + installed: true, + }, + DiscoveredProvider { + name: "ollama".to_string(), + kind: ProviderKind::OpenaiCompatible, + api_url: "http://localhost:11434/v1".to_string(), + region: None, + sso_start_url: None, + running: false, + model_loaded: false, + installed: true, + }, + ]; + let added = merge_discovered(&mut config, &discovered); + assert_eq!(added, vec!["ollama".to_string()]); + // Existing enabled provider untouched; new one disabled. + assert!(config.providers["lmstudio"].enabled); + assert!(!config.providers["ollama"].enabled); + } +} diff --git a/src/state/review.rs b/src/state/review.rs index 9e84cf5..d3532cf 100644 --- a/src/state/review.rs +++ b/src/state/review.rs @@ -197,7 +197,7 @@ mod tests { fn arb_datetime() -> impl Strategy> { (0i64..2_000_000_000).prop_map(|secs| { - DateTime::from_timestamp(secs, 0).unwrap_or_else(|| Utc::now()) + DateTime::from_timestamp(secs, 0).unwrap_or_else(Utc::now) }) } diff --git a/src/state/store.rs b/src/state/store.rs index 70e8702..411e9ac 100644 --- a/src/state/store.rs +++ b/src/state/store.rs @@ -82,6 +82,12 @@ pub struct InMemoryStore { reviews: Mutex>, } +impl Default for InMemoryStore { + fn default() -> Self { + Self::new() + } +} + impl InMemoryStore { pub fn new() -> Self { Self { diff --git a/src/tui/app.rs b/src/tui/app.rs index 380a648..e9774d2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -192,6 +192,15 @@ pub struct App { pub editor_target_id: Option, /// Whether we're editing an epic or task pub editor_target_type: Option, + + /// Current bootstrap setup state (ADR-002). `None` until the first state + /// arrives or once the app is past setup; `Some(non-Ready)` means the + /// frontend renders the setup screen instead of the board. + pub setup_state: Option, + /// Selected candidate index in the setup provider picker. + pub setup_selected: usize, + /// Last activation message/error to surface beneath the picker. + pub setup_message: Option, } /// Commands sent from the TUI event loop to the daemon client task. @@ -204,6 +213,10 @@ pub enum DaemonCommand { RefreshEpics, RefreshQueueLen, UpdateEntity { id: String, target: EditorTarget, title: String, description: Option }, + /// Activate the named provider during bootstrap setup (ADR-002). + SetupSelect { name: String }, + /// Re-run the environment scan during bootstrap setup. + SetupRescan, } /// Describes what the user is currently looking at in the TUI. @@ -238,6 +251,10 @@ pub enum DaemonMessage { SwitchPanel(String), Connected, Disconnected(String), + /// Bootstrap setup state pushed from the daemon (ADR-002). + SetupState(crate::setup::SetupState), + /// Result of a setup_select / activation attempt (message to surface). + SetupMessage(String), } impl App { @@ -267,9 +284,70 @@ impl App { editor_esc_warned: false, editor_target_id: None, editor_target_type: None, + // Start on the setup splash; the daemon's first state (Ready or a + // setup state) flips us to the board or the picker within a tick. + setup_state: Some(crate::setup::SetupState::Loading), + setup_selected: 0, + setup_message: None, + } + } + + // ── Bootstrap setup (ADR-002) ─────────────────────────────────────────── + + /// True while the bootstrap gate is unsatisfied — the setup screen owns the + /// whole UI and normal board/chat input is suppressed. + pub fn in_setup(&self) -> bool { + !matches!(self.setup_state, None | Some(crate::setup::SetupState::Ready)) + } + + /// Apply a fresh setup state from the daemon, clamping the picker selection. + pub fn apply_setup_state(&mut self, state: crate::setup::SetupState) { + if let crate::setup::SetupState::NeedsProvider { candidates } = &state { + if candidates.is_empty() { + self.setup_selected = 0; + } else if self.setup_selected >= candidates.len() { + self.setup_selected = candidates.len() - 1; + } + } + self.setup_state = Some(state); + } + + /// Candidates currently offered, if we're on the picker. + pub fn setup_candidates(&self) -> &[crate::setup::ProviderCandidate] { + match &self.setup_state { + Some(crate::setup::SetupState::NeedsProvider { candidates }) => candidates, + _ => &[], + } + } + + pub fn setup_move_up(&mut self) { + if self.setup_selected > 0 { + self.setup_selected -= 1; + } + } + + pub fn setup_move_down(&mut self) { + let n = self.setup_candidates().len(); + if n > 0 && self.setup_selected < n - 1 { + self.setup_selected += 1; + } + } + + /// Activate the highlighted provider candidate. + pub fn setup_select(&mut self) { + if let Some(candidate) = self.setup_candidates().get(self.setup_selected) { + let name = candidate.name.clone(); + self.setup_message = Some(format!("Activating {name}…")); + let _ = self.cmd_tx.send(DaemonCommand::SetupSelect { name }); } } + /// Request a fresh environment scan. + pub fn setup_rescan(&mut self) { + self.setup_message = Some("Rescanning…".to_string()); + let _ = self.cmd_tx.send(DaemonCommand::SetupRescan); + } + /// Apply a board snapshot from the daemon. pub fn apply_board_snapshot(&mut self, columns: Vec) { self.board.clear(); @@ -559,21 +637,20 @@ impl App { pub fn enter_editor(&mut self) { match self.panel { Panel::Epic => { - if let Some(epic_status) = self.current_epic_status() { - if let Some(epic) = + if let Some(epic_status) = self.current_epic_status() + && let Some(epic) = self.epics.iter().find(|e| e.title == epic_status.title) - { - let desc = epic.description.as_deref().unwrap_or(""); - self.editor_buffer = format!( - "# Title\n{}\n\n# Description\n{}", - epic.title, desc - ); - self.editor_target_id = Some(epic.id.clone()); - self.editor_target_type = Some(EditorTarget::Epic); - self.editor_focused = true; - self.editor_dirty = false; - self.editor_esc_warned = false; - } + { + let desc = epic.description.as_deref().unwrap_or(""); + self.editor_buffer = format!( + "# Title\n{}\n\n# Description\n{}", + epic.title, desc + ); + self.editor_target_id = Some(epic.id.clone()); + self.editor_target_type = Some(EditorTarget::Epic); + self.editor_focused = true; + self.editor_dirty = false; + self.editor_esc_warned = false; } } Panel::Task => { diff --git a/src/tui/daemon_client.rs b/src/tui/daemon_client.rs index 02bb10c..afef6c8 100644 --- a/src/tui/daemon_client.rs +++ b/src/tui/daemon_client.rs @@ -1,9 +1,11 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; +use tokio::time::{sleep, Duration}; use tracing::warn; use crate::daemon::socket_path; +use crate::setup::SetupState; use crate::state::chat::{ChatMessage as StoredMessage, Role}; use super::app::{BoardColumn, ChatEntry, DaemonCommand, DaemonMessage, EditorTarget}; @@ -17,13 +19,163 @@ pub fn spawn( msg_rx } +/// Which phase the daemon is in, probed via the `setup_state` command. The +/// daemon serves a bootstrap *setup* phase on the socket before the dispatcher +/// exists, then the live *board* server once the gate clears (ADR-002). +enum Phase { + Setup, + Board, + Unreachable, +} + +/// Outer reconnect loop. Probes the daemon's phase and runs the matching +/// sub-protocol, reconnecting across the setup→board handoff (the setup server +/// drops its listener when the gate clears, briefly making the socket +/// unreachable before the live server rebinds). async fn run_client( mut cmd_rx: mpsc::UnboundedReceiver, msg_tx: mpsc::UnboundedSender, ) { - // Connect to daemon let path = socket_path(); - let stream = match UnixStream::connect(&path).await { + loop { + match probe_phase(&path).await { + Phase::Unreachable => { + let _ = msg_tx.send(DaemonMessage::Disconnected("connecting…".to_string())); + sleep(Duration::from_millis(300)).await; + } + Phase::Setup => { + let _ = msg_tx.send(DaemonMessage::Connected); + run_setup_phase(&path, &mut cmd_rx, &msg_tx).await; + // Returns when Ready is observed or the connection drops — either + // way, loop back to re-probe (the live server may now be up). + } + Phase::Board => { + let _ = msg_tx.send(DaemonMessage::Connected); + run_board_phase(&path, &mut cmd_rx, &msg_tx).await; + let _ = msg_tx.send(DaemonMessage::Disconnected("reconnecting…".to_string())); + // Brief backoff so a fast-failing board protocol can't hot-spin. + sleep(Duration::from_millis(200)).await; + } + } + } +} + +/// Connect and ask `setup_state` to learn which phase the daemon is in. +async fn probe_phase(path: &std::path::Path) -> Phase { + let stream = match UnixStream::connect(path).await { + Ok(s) => s, + Err(_) => return Phase::Unreachable, + }; + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + if writer.write_all(b"setup_state\n").await.is_err() { + return Phase::Unreachable; + } + match lines.next_line().await { + Ok(Some(line)) => match serde_json::from_str::(&line) { + Ok(SetupState::Ready) => Phase::Board, + Ok(_) => Phase::Setup, + // Unknown/legacy response — treat as the live board server. + Err(_) => Phase::Board, + }, + _ => Phase::Unreachable, + } +} + +// ── Setup phase (ADR-002) ───────────────────────────────────────────────────── + +/// Render-and-relay loop for the bootstrap setup phase: subscribe to streamed +/// `SetupState`, and relay the user's provider selection / rescan back. Returns +/// once `Ready` is observed (so the outer loop reconnects to the board server) +/// or the connection drops. +async fn run_setup_phase( + path: &std::path::Path, + cmd_rx: &mut mpsc::UnboundedReceiver, + msg_tx: &mpsc::UnboundedSender, +) { + // Command connection. + let cmd_stream = match UnixStream::connect(path).await { + Ok(s) => s, + Err(_) => return, + }; + let (cmd_reader, mut cmd_writer) = cmd_stream.into_split(); + let mut cmd_lines = BufReader::new(cmd_reader).lines(); + + // Subscription connection — streams SetupState (current state first). + let sub_stream = match UnixStream::connect(path).await { + Ok(s) => s, + Err(_) => return, + }; + let (sub_reader, mut sub_writer) = sub_stream.into_split(); + if sub_writer.write_all(b"subscribe\n").await.is_err() { + return; + } + let mut sub_lines = BufReader::new(sub_reader).lines(); + + loop { + tokio::select! { + sub = sub_lines.next_line() => { + match sub { + Ok(Some(line)) => { + if let Ok(state) = serde_json::from_str::(&line) { + let ready = matches!(state, SetupState::Ready); + let _ = msg_tx.send(DaemonMessage::SetupState(state)); + if ready { + return; + } + } + } + _ => return, + } + } + cmd = cmd_rx.recv() => { + let Some(cmd) = cmd else { return }; + match cmd { + DaemonCommand::SetupSelect { name } => { + let payload = serde_json::json!({ + "name": name, + "account_id": serde_json::Value::Null, + "role_name": serde_json::Value::Null, + }); + let line = format!("setup_select {payload}\n"); + if cmd_writer.write_all(line.as_bytes()).await.is_err() { + return; + } + if let Ok(Some(resp)) = cmd_lines.next_line().await + && let Ok(v) = serde_json::from_str::(&resp) + && let Some(msg) = v.get("message").and_then(|m| m.as_str()) + { + let _ = msg_tx.send(DaemonMessage::SetupMessage(msg.to_string())); + } + } + DaemonCommand::SetupRescan => { + if cmd_writer.write_all(b"setup_rescan\n").await.is_err() { + return; + } + // Ack is the current state JSON; the subscribe stream + // delivers the post-scan state, so just drain it. + let _ = cmd_lines.next_line().await; + } + // Board commands are meaningless during setup — drop them. + _ => {} + } + } + } + } +} + +// ── Board phase ─────────────────────────────────────────────────────────────── + +async fn run_board_phase( + path: &std::path::Path, + cmd_rx: &mut mpsc::UnboundedReceiver, + msg_tx: &mpsc::UnboundedSender, +) { + // Tell the frontend setup is behind us so it renders the board, not the + // splash, even if it never saw a Ready over a setup subscription. + let _ = msg_tx.send(DaemonMessage::SetupState(SetupState::Ready)); + + let stream = match UnixStream::connect(path).await { Ok(s) => s, Err(e) => { let _ = msg_tx.send(DaemonMessage::Disconnected(format!("connect failed: {e}"))); @@ -31,8 +183,6 @@ async fn run_client( } }; - let _ = msg_tx.send(DaemonMessage::Connected); - let (reader, mut writer) = stream.into_split(); let mut lines = BufReader::new(reader).lines(); @@ -49,7 +199,7 @@ async fn run_client( } // Now open a second connection for subscription (subscribe holds the connection) - let sub_stream = match UnixStream::connect(&path).await { + let sub_stream = match UnixStream::connect(path).await { Ok(s) => s, Err(e) => { warn!("subscribe connect failed: {e}"); @@ -257,6 +407,8 @@ async fn run_client( // Read response but don't block the loop on failure if let Ok(Some(_resp)) = lines.next_line().await {} } + // Setup commands are meaningless once on the board — drop them. + DaemonCommand::SetupSelect { .. } | DaemonCommand::SetupRescan => {} } } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8b03f46..6e5f57a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -104,6 +104,12 @@ async fn run_event_loop( DaemonMessage::ForkClosed { id } => { app.apply_fork_closed(&id); } + DaemonMessage::SetupState(state) => { + app.apply_setup_state(state); + } + DaemonMessage::SetupMessage(msg) => { + app.setup_message = Some(msg); + } } } @@ -123,6 +129,19 @@ async fn run_event_loop( break; } + // Bootstrap setup (ADR-002) owns all input until the gate clears: pick a + // provider to activate, or rescan. Nothing else is reachable. + if app.in_setup() { + match key.code { + KeyCode::Up => app.setup_move_up(), + KeyCode::Down => app.setup_move_down(), + KeyCode::Enter => app.setup_select(), + KeyCode::Char('r') | KeyCode::Char('R') => app.setup_rescan(), + _ => {} + } + continue; + } + // Global: Ctrl+Up/Down switch panels and focus them (works in any state) if key.modifiers.contains(KeyModifiers::CONTROL) { match key.code { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 65c2d35..a4bebcb 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -9,6 +9,12 @@ use crate::dispatcher::Column; use super::app::{App, Panel, column_order, column_abbrev}; pub fn draw(frame: &mut Frame, app: &App) { + // Bootstrap setup (ADR-002) owns the whole screen until the gate clears. + if app.in_setup() { + draw_setup(frame, app, frame.area()); + return; + } + let area = frame.area(); let [header_area, body_area, footer_area] = Layout::vertical([ Constraint::Length(1), @@ -21,6 +27,111 @@ pub fn draw(frame: &mut Frame, app: &App) { draw_footer(frame, app, footer_area); } +// -- Bootstrap setup screen (ADR-002) --------------------------------------- + +/// Full-screen setup renderer. A thin switch over the daemon-owned +/// `SetupState`: splash while loading, a provider picker, or an error with a +/// rescan hint. All logic lives in the backend; this only draws. +fn draw_setup(frame: &mut Frame, app: &App, area: Rect) { + use crate::setup::SetupState; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" gitzi setup "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let [title_area, body_area, msg_area, footer_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Fill(1), + Constraint::Length(2), + Constraint::Length(1), + ]).areas(inner); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Set up a language model to get started", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ))), + title_area, + ); + + let (body, footer): (Vec, &str) = match &app.setup_state { + Some(SetupState::Loading) | None => ( + vec![ + Line::from(""), + Line::from(Span::styled( + " Discovering LLM providers on this machine…", + Style::default().fg(Color::Gray), + )), + ], + "Ctrl+Q quit", + ), + Some(SetupState::NeedsProvider { candidates }) => { + let mut lines = vec![Line::from(Span::styled( + " Choose a provider to activate:", + Style::default().fg(Color::Gray), + )), Line::from("")]; + for (i, c) in candidates.iter().enumerate() { + let selected = i == app.setup_selected; + let marker = if selected { "▶ " } else { " " }; + let style = if selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default().fg(Color::White) + }; + let ready = if c.ready { "" } else { " (needs setup)" }; + lines.push(Line::from(Span::styled( + format!("{marker}{} [{}] {}{ready}", c.name, c.kind, c.status), + style, + ))); + } + (lines, "↑/↓ select Enter activate r rescan Ctrl+Q quit") + } + Some(SetupState::Error { messages, can_rescan }) => { + let mut lines = vec![Line::from(Span::styled( + " Setup can't continue yet:", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )), Line::from("")]; + for m in messages { + lines.push(Line::from(Span::styled( + format!(" {m}"), + Style::default().fg(Color::Red), + ))); + } + let footer = if *can_rescan { + "r rescan Ctrl+Q quit" + } else { + "Ctrl+Q quit" + }; + (lines, footer) + } + Some(SetupState::Ready) => (vec![Line::from(" Ready — launching…")], "Ctrl+Q quit"), + }; + + frame.render_widget(Paragraph::new(body).wrap(Wrap { trim: false }), body_area); + + if let Some(msg) = &app.setup_message { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" {msg}"), + Style::default().fg(Color::Yellow), + ))) + .wrap(Wrap { trim: false }), + msg_area, + ); + } + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + footer, + Style::default().fg(Color::DarkGray), + ))), + footer_area, + ); +} + // -- Body (conditionally includes fork strip) ------------------------------- fn draw_body(frame: &mut Frame, app: &App, area: Rect) { diff --git a/tests/agent_blocked_flow_prop.rs b/tests/agent_blocked_flow_prop.rs index cc4f757..0248d26 100644 --- a/tests/agent_blocked_flow_prop.rs +++ b/tests/agent_blocked_flow_prop.rs @@ -23,7 +23,6 @@ fn arb_role() -> impl Strategy { Just(AgentRole::Designer), Just(AgentRole::Coder), Just(AgentRole::Reviewer), - Just(AgentRole::Tester), Just(AgentRole::Auditor), Just(AgentRole::Infrarian), ] diff --git a/tests/agent_blocked_prop.rs b/tests/agent_blocked_prop.rs index 4c46886..0c8fb37 100644 --- a/tests/agent_blocked_prop.rs +++ b/tests/agent_blocked_prop.rs @@ -11,7 +11,6 @@ fn arb_role() -> impl Strategy { Just(AgentRole::Designer), Just(AgentRole::Coder), Just(AgentRole::Reviewer), - Just(AgentRole::Tester), Just(AgentRole::Auditor), Just(AgentRole::Infrarian), ] diff --git a/tests/agent_priority_pick_prop.rs b/tests/agent_priority_pick_prop.rs index bb9e0c9..81dd475 100644 --- a/tests/agent_priority_pick_prop.rs +++ b/tests/agent_priority_pick_prop.rs @@ -19,7 +19,6 @@ fn arb_work_column() -> impl Strategy { Just(Column::Designing), Just(Column::Coding), Just(Column::Reviewing), - Just(Column::Testing), Just(Column::Auditing), Just(Column::Deploying), ] diff --git a/tests/board_wip_props.rs b/tests/board_wip_props.rs index 9caa13e..f98eb7c 100644 --- a/tests/board_wip_props.rs +++ b/tests/board_wip_props.rs @@ -7,21 +7,18 @@ use gitzi::dispatcher::Column; use gitzi::model::task::{Stage, Task}; use proptest::prelude::*; -/// Strategy that generates an arbitrary `Stage` value (all 17 variants). +/// Strategy that generates an arbitrary `Stage` value (all 14 variants). fn arb_stage() -> impl Strategy { prop_oneof![ Just(Stage::Backlog), Just(Stage::InProgress), Just(Stage::WaitingForReview), - Just(Stage::InTesting), Just(Stage::Prioritized), Just(Stage::Designing), Just(Stage::CodingBuffer), Just(Stage::Coding), Just(Stage::ReviewBuffer), Just(Stage::Reviewing), - Just(Stage::TestBuffer), - Just(Stage::Testing), Just(Stage::SecurityAuditBuffer), Just(Stage::Auditing), Just(Stage::DeploymentBuffer), @@ -109,7 +106,7 @@ proptest! { /// - allows(col, count) returns true iff count < limit #[test] fn wip_limit_enforcement( - col_idx in 0usize..13, + col_idx in 0usize..11, count in 0u32..100 ) { let wip = WipLimits::default(); @@ -143,7 +140,7 @@ proptest! { /// Property 4 (boundary): allows(col, 0) is always true for every column /// (even finite limits, since 0 < any positive limit). #[test] - fn wip_allows_zero_is_always_true(col_idx in 0usize..13) { + fn wip_allows_zero_is_always_true(col_idx in 0usize..11) { let wip = WipLimits::default(); let col = Column::all()[col_idx]; prop_assert!( @@ -155,7 +152,7 @@ proptest! { /// Property 4 (at-limit): For finite-limit columns, allows(col, limit) is always false. #[test] - fn wip_blocks_at_limit(col_idx in 0usize..13) { + fn wip_blocks_at_limit(col_idx in 0usize..11) { let wip = WipLimits::default(); let col = Column::all()[col_idx]; diff --git a/tests/boot_signal_props.rs b/tests/boot_signal_props.rs index f5ae1d9..2e80bc4 100644 --- a/tests/boot_signal_props.rs +++ b/tests/boot_signal_props.rs @@ -49,7 +49,6 @@ fn arb_work_column() -> impl Strategy { Just(Column::Designing), Just(Column::Coding), Just(Column::Reviewing), - Just(Column::Testing), Just(Column::Auditing), Just(Column::Deploying), ] diff --git a/tests/dispatch_event_roundtrip_prop.rs b/tests/dispatch_event_roundtrip_prop.rs index ca3e5bf..21bdf11 100644 --- a/tests/dispatch_event_roundtrip_prop.rs +++ b/tests/dispatch_event_roundtrip_prop.rs @@ -17,8 +17,6 @@ fn arb_column() -> impl Strategy { Just(Column::Coding), Just(Column::ReviewBuffer), Just(Column::Reviewing), - Just(Column::TestBuffer), - Just(Column::Testing), Just(Column::SecurityAuditBuffer), Just(Column::Auditing), Just(Column::DeploymentBuffer), @@ -34,7 +32,6 @@ fn arb_agent_role() -> impl Strategy { Just(AgentRole::Designer), Just(AgentRole::Coder), Just(AgentRole::Reviewer), - Just(AgentRole::Tester), Just(AgentRole::Auditor), Just(AgentRole::Infrarian), ] diff --git a/tests/dispatcher_column_props.rs b/tests/dispatcher_column_props.rs index 0369090..4a3bd70 100644 --- a/tests/dispatcher_column_props.rs +++ b/tests/dispatcher_column_props.rs @@ -6,21 +6,18 @@ use gitzi::dispatcher::Column; use gitzi::model::task::{Stage, Task}; use proptest::prelude::*; -/// Strategy that generates an arbitrary `Stage` value (all 17 variants). +/// Strategy that generates an arbitrary `Stage` value (all 14 variants). fn arb_stage() -> impl Strategy { prop_oneof![ Just(Stage::Backlog), Just(Stage::InProgress), Just(Stage::WaitingForReview), - Just(Stage::InTesting), Just(Stage::Prioritized), Just(Stage::Designing), Just(Stage::CodingBuffer), Just(Stage::Coding), Just(Stage::ReviewBuffer), Just(Stage::Reviewing), - Just(Stage::TestBuffer), - Just(Stage::Testing), Just(Stage::SecurityAuditBuffer), Just(Stage::Auditing), Just(Stage::DeploymentBuffer), @@ -59,7 +56,7 @@ proptest! { /// Property 1 (extended): For column-aligned stages, `Stage::from(col)` round-trips /// through `to_column()` back to the same column. #[test] - fn column_aligned_stage_roundtrips(col_idx in 0usize..13) { + fn column_aligned_stage_roundtrips(col_idx in 0usize..11) { let col = Column::all()[col_idx]; let stage: Stage = col.into(); let mapped_col = stage.to_column(); @@ -99,7 +96,7 @@ proptest! { /// - `prev()` of the later equals the earlier /// This proves the ordering forms a strict total order (a chain). #[test] - fn adjacent_columns_linked_by_next_prev(idx in 0usize..12) { + fn adjacent_columns_linked_by_next_prev(idx in 0usize..10) { let all = Column::all(); let earlier = all[idx]; let later = all[idx + 1]; diff --git a/tests/dispatcher_state_props.rs b/tests/dispatcher_state_props.rs index 5c23e02..a24c21b 100644 --- a/tests/dispatcher_state_props.rs +++ b/tests/dispatcher_state_props.rs @@ -33,7 +33,6 @@ fn all_buffer_columns() -> Vec { vec![ Column::CodingBuffer, Column::ReviewBuffer, - Column::TestBuffer, Column::SecurityAuditBuffer, Column::DeploymentBuffer, ] @@ -44,7 +43,6 @@ fn arb_buffer_column() -> impl Strategy { prop_oneof![ Just(Column::CodingBuffer), Just(Column::ReviewBuffer), - Just(Column::TestBuffer), Just(Column::SecurityAuditBuffer), Just(Column::DeploymentBuffer), ] @@ -58,7 +56,6 @@ fn arb_work_column_with_next_buffer() -> impl Strategy { Just(Column::Designing), Just(Column::Coding), Just(Column::Reviewing), - Just(Column::Testing), Just(Column::Auditing), Just(Column::Deploying), ] @@ -78,7 +75,7 @@ async fn build_test_dispatcher(tasks: Vec) -> Dispatcher { let agent_pool = AgentPool::inert(); let main_agent_def = config.resolve_agent("main"); - let main_agent = gitzi::agent::build_main_agent(&main_agent_def); + let main_agent = gitzi::agent::build_main_agent(&config, &main_agent_def); let store: std::sync::Arc = std::sync::Arc::new(gitzi::state::store::InMemoryStore::new()); @@ -93,6 +90,7 @@ async fn build_test_dispatcher(tasks: Vec) -> Dispatcher { wip_waiting, chat_history: Arc::new(Mutex::new(vec![])), main_agent, + fallback_agent: None, token_store, store, chat_stack: Mutex::new(Vec::new()), diff --git a/tests/e2e_pipeline.rs b/tests/e2e_pipeline.rs index 14e9e10..8ea59b4 100644 --- a/tests/e2e_pipeline.rs +++ b/tests/e2e_pipeline.rs @@ -60,6 +60,7 @@ fn test_config(port: u16) -> Config { ProviderDef { api_url: api_url.clone(), api_key: String::new(), + ..ProviderDef::default() }, )]), agents: vec![ @@ -133,6 +134,7 @@ async fn create_epic_and_task_via_dispatcher() { "Implement login".to_string(), Some("Add OAuth2 login endpoint".to_string()), None, + None, ) .await .unwrap(); diff --git a/tests/event_ordering_prop.rs b/tests/event_ordering_prop.rs index b54ca20..eaf364c 100644 --- a/tests/event_ordering_prop.rs +++ b/tests/event_ordering_prop.rs @@ -19,7 +19,6 @@ fn arb_work_column() -> impl Strategy { Just(Column::Designing), Just(Column::Coding), Just(Column::Reviewing), - Just(Column::Testing), Just(Column::Auditing), Just(Column::Deploying), ] diff --git a/tests/history_roundtrip_prop.rs b/tests/history_roundtrip_prop.rs index 0c6a39f..a4acade 100644 --- a/tests/history_roundtrip_prop.rs +++ b/tests/history_roundtrip_prop.rs @@ -23,15 +23,12 @@ fn arb_stage() -> impl Strategy { Just(Stage::Backlog), Just(Stage::InProgress), Just(Stage::WaitingForReview), - Just(Stage::InTesting), Just(Stage::Prioritized), Just(Stage::Designing), Just(Stage::CodingBuffer), Just(Stage::Coding), Just(Stage::ReviewBuffer), Just(Stage::Reviewing), - Just(Stage::TestBuffer), - Just(Stage::Testing), Just(Stage::SecurityAuditBuffer), Just(Stage::Auditing), Just(Stage::DeploymentBuffer), diff --git a/tests/mcp_api_tests.rs b/tests/mcp_api_tests.rs index 9e17487..d400009 100644 --- a/tests/mcp_api_tests.rs +++ b/tests/mcp_api_tests.rs @@ -39,7 +39,7 @@ async fn build_test_app() -> (axum::Router, Arc) { let agent_pool = AgentPool::inert(); let main_agent_def = config.resolve_agent("main"); - let main_agent = build_main_agent(&main_agent_def); + let main_agent = build_main_agent(&config, &main_agent_def); let store: std::sync::Arc = std::sync::Arc::new(gitzi::state::store::InMemoryStore::new()); @@ -54,6 +54,7 @@ async fn build_test_app() -> (axum::Router, Arc) { wip_waiting, chat_history: Arc::new(Mutex::new(vec![])), main_agent, + fallback_agent: None, token_store: Arc::clone(&token_store), store, chat_stack: Mutex::new(Vec::new()), diff --git a/tests/pipeline_transitions.rs b/tests/pipeline_transitions.rs index 43ac459..b3fce37 100644 --- a/tests/pipeline_transitions.rs +++ b/tests/pipeline_transitions.rs @@ -8,7 +8,6 @@ fn valid_happy_path() { Stage::Prioritized, Stage::InProgress, Stage::WaitingForReview, - Stage::InTesting, Stage::Done, ]; for window in path.windows(2) { @@ -21,11 +20,6 @@ fn rejection_is_valid() { validate_transition(&Stage::WaitingForReview, &Stage::InProgress).unwrap(); } -#[test] -fn test_failure_is_valid() { - validate_transition(&Stage::InTesting, &Stage::InProgress).unwrap(); -} - #[test] fn skipping_stages_is_invalid() { assert!(validate_transition(&Stage::Backlog, &Stage::InProgress).is_err()); @@ -35,7 +29,7 @@ fn skipping_stages_is_invalid() { #[test] fn backwards_is_invalid_except_rejection() { - assert!(validate_transition(&Stage::Done, &Stage::InTesting).is_err()); + assert!(validate_transition(&Stage::Done, &Stage::WaitingForReview).is_err()); assert!(validate_transition(&Stage::InProgress, &Stage::Prioritized).is_err()); } diff --git a/tests/rejection_feedback_prop.rs b/tests/rejection_feedback_prop.rs index c253c3b..90b937a 100644 --- a/tests/rejection_feedback_prop.rs +++ b/tests/rejection_feedback_prop.rs @@ -27,7 +27,6 @@ fn arb_buffer_column() -> impl Strategy { prop_oneof![ Just(Column::CodingBuffer), Just(Column::ReviewBuffer), - Just(Column::TestBuffer), Just(Column::SecurityAuditBuffer), Just(Column::DeploymentBuffer), ] @@ -51,7 +50,7 @@ async fn build_test_dispatcher(tasks: Vec) -> Dispatcher { let agent_pool = AgentPool::inert(); let main_agent_def = config.resolve_agent("main"); - let main_agent = gitzi::agent::build_main_agent(&main_agent_def); + let main_agent = gitzi::agent::build_main_agent(&config, &main_agent_def); let store: std::sync::Arc = std::sync::Arc::new(gitzi::state::store::InMemoryStore::new()); @@ -66,6 +65,7 @@ async fn build_test_dispatcher(tasks: Vec) -> Dispatcher { wip_waiting, chat_history: Arc::new(Mutex::new(vec![])), main_agent, + fallback_agent: None, token_store, store, chat_stack: Mutex::new(Vec::new()), diff --git a/tests/review_queue_props.rs b/tests/review_queue_props.rs index f73f186..e52bf38 100644 --- a/tests/review_queue_props.rs +++ b/tests/review_queue_props.rs @@ -7,18 +7,17 @@ use gitzi::dispatcher::review_queue::{HumanReviewItem, HumanReviewQueue, ReviewI use gitzi::dispatcher::Column; use proptest::prelude::*; -/// The 5 buffer columns in pipeline order (left to right on the board). -const BUFFER_COLUMNS: [Column; 5] = [ +/// The 4 buffer columns in pipeline order (left to right on the board). +const BUFFER_COLUMNS: [Column; 4] = [ Column::CodingBuffer, Column::ReviewBuffer, - Column::TestBuffer, Column::SecurityAuditBuffer, Column::DeploymentBuffer, ]; /// Strategy for an arbitrary buffer column. fn arb_buffer_column() -> impl Strategy { - (0usize..5).prop_map(|i| BUFFER_COLUMNS[i]) + (0usize..4).prop_map(|i| BUFFER_COLUMNS[i]) } /// Strategy for an arbitrary timestamp (seconds from epoch, spread out to avoid collisions). diff --git a/tests/state_roundtrip.rs b/tests/state_roundtrip.rs index a1a5922..7702b55 100644 --- a/tests/state_roundtrip.rs +++ b/tests/state_roundtrip.rs @@ -14,7 +14,6 @@ fn stage_kebab_case_roundtrip() { (Stage::Prioritized, "prioritized"), (Stage::InProgress, "in-progress"), (Stage::WaitingForReview, "waiting-for-review"), - (Stage::InTesting, "in-testing"), (Stage::Done, "done"), ]; for (stage, expected) in cases { @@ -163,8 +162,6 @@ fn new_column_stages_kebab_case_roundtrip() { (Stage::Coding, "coding"), (Stage::ReviewBuffer, "review-buffer"), (Stage::Reviewing, "reviewing"), - (Stage::TestBuffer, "test-buffer"), - (Stage::Testing, "testing"), (Stage::SecurityAuditBuffer, "security-audit-buffer"), (Stage::Auditing, "auditing"), (Stage::DeploymentBuffer, "deployment-buffer"), diff --git a/tests/wip_limit_blocking_prop.rs b/tests/wip_limit_blocking_prop.rs index 13df3c3..a78f8f4 100644 --- a/tests/wip_limit_blocking_prop.rs +++ b/tests/wip_limit_blocking_prop.rs @@ -16,7 +16,6 @@ fn arb_work_column_with_next() -> impl Strategy { Just(Column::Designing), Just(Column::Coding), Just(Column::Reviewing), - Just(Column::Testing), Just(Column::Auditing), Just(Column::Deploying), ] diff --git a/tests/wip_release_signal_prop.rs b/tests/wip_release_signal_prop.rs index 165d480..2e3877a 100644 --- a/tests/wip_release_signal_prop.rs +++ b/tests/wip_release_signal_prop.rs @@ -20,7 +20,6 @@ fn arb_work_column() -> impl Strategy { Just(Column::Designing), Just(Column::Coding), Just(Column::Reviewing), - Just(Column::Testing), Just(Column::Auditing), Just(Column::Deploying), ] @@ -33,7 +32,6 @@ fn arb_role() -> impl Strategy { Just(AgentRole::Designer), Just(AgentRole::Coder), Just(AgentRole::Reviewer), - Just(AgentRole::Tester), Just(AgentRole::Auditor), Just(AgentRole::Infrarian), ] @@ -76,7 +74,7 @@ proptest! { let agent_pool = AgentPool::inert(); let main_agent_def = config.resolve_agent("main"); - let main_agent = gitzi::agent::build_main_agent(&main_agent_def); + let main_agent = gitzi::agent::build_main_agent(&config, &main_agent_def); let store: std::sync::Arc = std::sync::Arc::new(gitzi::state::store::InMemoryStore::new()); @@ -91,6 +89,7 @@ proptest! { wip_waiting: Arc::clone(&wip_waiting), chat_history: Arc::new(Mutex::new(vec![])), main_agent, + fallback_agent: None, token_store, store, chat_stack: Mutex::new(Vec::new()), @@ -168,7 +167,7 @@ proptest! { let agent_pool = AgentPool::inert(); let main_agent_def = config.resolve_agent("main"); - let main_agent = gitzi::agent::build_main_agent(&main_agent_def); + let main_agent = gitzi::agent::build_main_agent(&config, &main_agent_def); let store: std::sync::Arc = std::sync::Arc::new(gitzi::state::store::InMemoryStore::new()); @@ -183,6 +182,7 @@ proptest! { wip_waiting: Arc::clone(&wip_waiting), chat_history: Arc::new(Mutex::new(vec![])), main_agent, + fallback_agent: None, token_store, store, chat_stack: Mutex::new(Vec::new()), diff --git a/todo.md b/todo.md index ad3d858..ce4acf8 100644 --- a/todo.md +++ b/todo.md @@ -426,12 +426,51 @@ Decision: TBD — needs further design. ## Bootstrapping -- [ ] Implement first-run bootstrapper TUI flow (loader/spinner during discovery) -- [ ] Add onboarding selection UI when multiple providers found +- [x] Implement first-run bootstrapper flow (`src/bootstrap.rs`) — quick, non-blocking + scan; no loader/spinner needed since it never starts servers, loads models, or + blocks on SSO login before the TUI launches +- [x] Status panel: first-run view shows providers + repos with summaries +- [x] Activation flow via main agent chat tools (`gitzi_rediscover_providers`, + `gitzi_activate_provider`) instead of a separate blocking onboarding selection UI +- [ ] Detect installed-but-not-running providers and surface per-provider guidance text + directly on the Status panel (today this status is only relayed through + `gitzi_rediscover_providers`'s chat response) - [ ] Add `design` field to Task model (markdown content from Designer agent) - [ ] Worktree cleanup: delete `~/.gitzi/tmp/tasks//` when task reaches Done - [ ] Reviewer rejection → direct to Coding (not CodingBuffer), respects WIP limit - [ ] `gitzi skill install/uninstall SKILL` CLI commands - [ ] User-authored tools in `~/.gitzi/tools/` (exposed to agents as tool name = filename) -- [ ] Status panel: first-run view shows providers + repos with summaries -- [ ] Detect installed-but-not-running providers and display guidance on Status panel + +--- + +## Multi-cloud credential providers (Azure, GCP) + +AWS Bedrock support (`src/aws_sso.rs`) is the first cloud provider; Azure (Azure OpenAI / +Azure AI Foundry) and GCP (Vertex AI) should follow the same shape — drive an interactive +login, cache whatever the SDK returns in the OS keyring under `gitzi//...` +(`secrets::service_name`), never cache the short-lived bearer/role token. + +- [ ] Azure OpenAI / Azure AI Foundry provider + login flow +- [ ] GCP Vertex AI provider + login flow + +### Implementation notes — candidate crates + +| Cloud | Drives a *new* interactive login (≈ `aws_sso.rs`) | Reads an *existing* CLI login (≈ bootstrap.rs's AWS-cache detection) | +|---|---|---| +| Azure | [`azure-identity-helpers`](https://github.com/demoray/azure-identity-helpers) (unofficial, v0.2.0) — `DeviceCodeCredential` implements the device-code flow on top of official [`azure_identity`](https://github.com/azure/azure-sdk-for-rust) (v1.0.0, Microsoft-official) | `azure-identity-helpers`'s `AzureAuthCliCredential` / `default_azure_credential`, or `azure_identity`'s `AzureCliCredential` | +| GCP | [`yup-oauth2`](https://github.com/dermesser/yup-oauth2) (v12.1.2, mature) — implements the actual OAuth2 device/installed-app flows | [`gcp_auth`](https://github.com/djc/gcp_auth) (v0.12.7) — reads `GOOGLE_APPLICATION_CREDENTIALS`, `gcloud auth application-default login`'s cached file, the metadata server, or shells out to `gcloud`; **no Windows support**, drives no login of its own | + +Architectural differences from AWS to account for when designing these (confirmed from +crate source, not just docs): + +- **No dynamic client self-registration.** AWS SSO's `register_client` (cached at + `gitzi/aws/sso-client`) has no Azure/GCP equivalent — both require a pre-registered + app/client ID configured ahead of time. Nothing analogous to cache there. +- **No mandatory role-exchange step.** AWS needs SSO login *then* a separate + `get_role_credentials` (STS-style) call to scope to an account/role. Azure and GCP skip + that — the OAuth access token from login is usable directly as the API bearer token; + scoping happens server-side via Azure RBAC role assignments or (optionally) GCP + service-account impersonation, not a client-side exchange call. +- Both crates keep tokens **in-memory only** — gitzi would own persisting whatever they + return into the keyring (`gitzi/azure/oauth-token` keyed by tenant ID, `gitzi/gcp/oauth-token` + keyed by account email), same shape as `aws_sso.rs`'s `store_token`/`load_token`.