diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b45cb12d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: Rust CI + +on: + push: + branches: [main] + paths: + - "flowctl/**" + - ".github/workflows/ci.yml" + pull_request: + paths: + - "flowctl/**" + - ".github/workflows/ci.yml" + +env: + CARGO_TERM_COLOR: always + +defaults: + run: + working-directory: flowctl + +jobs: + check: + name: Check / Test / Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo registry & build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + flowctl/target + key: ${{ runner.os }}-cargo-${{ hashFiles('flowctl/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: cargo fmt --check + run: cargo fmt --all -- --check + + - name: cargo clippy + run: cargo clippy --all-targets -- -D warnings + + - name: cargo build + run: cargo build --all-targets + + - name: cargo test + run: cargo test --all-targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..214b6895 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,125 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +defaults: + run: + working-directory: flowctl + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux aarch64) + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" + + - name: Cache cargo registry & build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + flowctl/target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('flowctl/Cargo.lock') }} + restore-keys: ${{ runner.os }}-${{ matrix.target }}-cargo- + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} -p flowctl-cli + + - name: Determine binary name + id: bin + shell: bash + run: | + if [[ "${{ matrix.target }}" == *windows* ]]; then + echo "name=flowctl.exe" >> "$GITHUB_OUTPUT" + echo "archive=flowctl-${{ github.ref_name }}-${{ matrix.target }}.zip" >> "$GITHUB_OUTPUT" + else + echo "name=flowctl" >> "$GITHUB_OUTPUT" + echo "archive=flowctl-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" >> "$GITHUB_OUTPUT" + fi + + - name: Package (Unix) + if: "!contains(matrix.target, 'windows')" + run: | + cd target/${{ matrix.target }}/release + tar czf "../../../${{ steps.bin.outputs.archive }}" ${{ steps.bin.outputs.name }} + + - name: Package (Windows) + if: contains(matrix.target, 'windows') + shell: bash + run: | + cd target/${{ matrix.target }}/release + 7z a "../../../${{ steps.bin.outputs.archive }}" ${{ steps.bin.outputs.name }} + + - name: Generate SHA256 + shell: bash + run: | + if [[ "$RUNNER_OS" == "macOS" ]]; then + shasum -a 256 "${{ steps.bin.outputs.archive }}" > "${{ steps.bin.outputs.archive }}.sha256" + else + sha256sum "${{ steps.bin.outputs.archive }}" > "${{ steps.bin.outputs.archive }}.sha256" + fi + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target }} + path: | + flowctl/${{ steps.bin.outputs.archive }} + flowctl/${{ steps.bin.outputs.archive }}.sha256 + + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + artifacts/* diff --git a/flowctl/.gitignore b/flowctl/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/flowctl/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/flowctl/Cargo.lock b/flowctl/Cargo.lock new file mode 100644 index 00000000..59d2a327 --- /dev/null +++ b/flowctl/Cargo.lock @@ -0,0 +1,2763 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96401ca08501972288ecbcde33902fce858bf73fbcbdf91dab8c3a9544e106bb" +dependencies = [ + "anstyle", + "unicode-width 0.2.0", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "automod" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b5778837666541195063243828c5b6139221b47dc4ec3ba81738e532469ab1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream 1.0.0", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "content_inspector" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7bda66e858c683005a53a9a60c69a4aca7eeaa45d124526e389f7aec8e62f38" +dependencies = [ + "memchr", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flowctl-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "clap_complete", + "flowctl-core", + "flowctl-db", + "flowctl-scheduler", + "flowctl-tui", + "miette", + "regex", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "tokio", + "tracing", + "trycmd", + "which", +] + +[[package]] +name = "flowctl-core" +version = "0.1.0" +dependencies = [ + "chrono", + "petgraph", + "regex", + "serde", + "serde-saphyr", + "serde_json", + "thiserror", +] + +[[package]] +name = "flowctl-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "bytes", + "chrono", + "flowctl-core", + "flowctl-db", + "flowctl-scheduler", + "http-body-util", + "hyper", + "hyper-util", + "nix", + "notify", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "flowctl-db" +version = "0.1.0" +dependencies = [ + "chrono", + "flowctl-core", + "include_dir", + "rusqlite", + "rusqlite_migration", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tracing", +] + +[[package]] +name = "flowctl-scheduler" +version = "0.1.0" +dependencies = [ + "chrono", + "flowctl-core", + "flowctl-db", + "notify", + "petgraph", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "flowctl-tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "crossterm", + "flowctl-core", + "flowctl-db", + "flowctl-scheduler", + "insta", + "nucleo", + "ratatui", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "nucleo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" +dependencies = [ + "nucleo-matcher", + "parking_lot", + "rayon", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rusqlite_migration" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923b42e802f7dc20a0a6b5e097ba7c83fe4289da07e49156fecf6af08aa9cd1c" +dependencies = [ + "include_dir", + "log", + "rusqlite", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "saphyr-parser-bw" +version = "0.0.611" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dec0c833db75dc98957956b303fe447ffc5eb13f2325ef4c2350f7f3aa69e3" +dependencies = [ + "arraydeque", + "smallvec", + "thiserror", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-saphyr" +version = "0.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fbdfe7a27a1b1633dfc0c4c8e65940b8d819c5ddb9cca48ebc3223b00c8b14" +dependencies = [ + "ahash", + "annotate-snippets", + "base64", + "encoding_rs_io", + "getrandom", + "nohash-hasher", + "num-traits", + "regex", + "saphyr-parser-bw", + "serde", + "smallvec", + "zmij", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "snapbox" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c1abc378119f77310836665f8523018532cf7e3faeb3b10b01da5a7321bf8e1" +dependencies = [ + "anstream 0.6.21", + "anstyle", + "content_inspector", + "dunce", + "filetime", + "libc", + "normalize-line-endings", + "os_pipe", + "similar", + "snapbox-macros", + "tempfile", + "wait-timeout", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "snapbox-macros" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b750c344002d7cc69afb9da00ebd9b5c0f8ac2eb7d115d9d45d5b5f47718d74" +dependencies = [ + "anstream 0.6.21", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.0", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "trycmd" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a81ea3136ddc88e19c2cc2eb3176b72abee4e831367cd8949f2a88ac5497e64e" +dependencies = [ + "anstream 0.6.21", + "automod", + "glob", + "humantime", + "humantime-serde", + "rayon", + "serde", + "shlex", + "snapbox", + "toml_edit", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/flowctl/Cargo.toml b/flowctl/Cargo.toml new file mode 100644 index 00000000..163b66ec --- /dev/null +++ b/flowctl/Cargo.toml @@ -0,0 +1,90 @@ +[workspace] +resolver = "2" +members = [ + "crates/flowctl-core", + "crates/flowctl-db", + "crates/flowctl-scheduler", + "crates/flowctl-cli", + "crates/flowctl-daemon", + "crates/flowctl-tui", +] + +# ── Shared package metadata (nushell convention) ────────────────────── +[workspace.package] +edition = "2021" +rust-version = "1.80" +license = "MIT" +repository = "https://github.com/anthropics/flow-code" + +# ── Shared dependencies (nushell convention) ────────────────────────── +# Define all deps once here; crates reference via `dep.workspace = true` +[workspace.dependencies] +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Error handling +thiserror = "2" +anyhow = "1" + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# Regex +regex = "1" + +# Hashing +sha2 = "0.10" + +# Logging +tracing = "0.1" + +# DAG (scheduler + TUI) +petgraph = "0.7" + +# SQLite (db crate) +rusqlite = { version = "0.32", features = ["bundled"] } +rusqlite_migration = { version = "1.3", features = ["from-directory"] } +include_dir = "0.7" + +# CLI (cli crate) +clap = { version = "4", features = ["derive"] } +clap_complete = "4" +miette = { version = "7", features = ["fancy"] } + +# TUI (tui crate) +ratatui = "0.29" +crossterm = "0.28" +nucleo = "0.5" + +# Async runtime (daemon + scheduler) +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } + +# HTTP (daemon) +axum = { version = "0.8", features = ["ws"] } + +# File watching (daemon) +notify = "7" + +# Unix process management +nix = { version = "0.30", features = ["signal", "process"] } + +# Path lookup +which = "8" + +# Testing +trycmd = "0.15" + +# Internal crate references +flowctl-core = { path = "crates/flowctl-core" } +flowctl-db = { path = "crates/flowctl-db" } +flowctl-scheduler = { path = "crates/flowctl-scheduler" } + +# ── Release profile (size-optimized) ───────────────────────────────── +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true diff --git a/flowctl/README.md b/flowctl/README.md new file mode 100644 index 00000000..ba590fd8 --- /dev/null +++ b/flowctl/README.md @@ -0,0 +1,96 @@ +# flowctl + +A fast, native task and workflow engine for structured, plan-first development. Manages epics, tasks, dependencies, and state machines in a local `.flow/` directory backed by SQLite. + +flowctl is the Rust rewrite of the Python `flowctl` CLI from [flow-code](https://github.com/anthropics/flow-code), designed for speed, cross-platform support, and zero-dependency deployment. + +## Installation + +### From source + +```sh +cargo install --path crates/flowctl-cli +``` + +### From GitHub releases + +```sh +curl -fsSL https://raw.githubusercontent.com/anthropics/flow-code/main/flowctl/install.sh | sh +``` + +Set `FLOWCTL_INSTALL_DIR` to change the install location (default: `/usr/local/bin`). +Set `FLOWCTL_VERSION` to pin a specific version (e.g., `v0.1.0`). + +### From crates.io (coming soon) + +```sh +cargo install flowctl-cli +``` + +## Quick start + +```sh +# Initialize a new .flow directory +flowctl init + +# Create an epic +flowctl epic create "Build auth system" + +# Add tasks +flowctl task create -e ep-1 "Design token schema" --domain backend +flowctl task create -e ep-1 "Implement JWT middleware" --domain backend +flowctl dep add tsk-2 tsk-1 + +# Start work +flowctl start tsk-1 + +# Complete with evidence +flowctl done tsk-1 --summary-file summary.md --evidence-json evidence.json + +# Check status +flowctl status +flowctl tasks -e ep-1 +``` + +## Feature flags + +| Flag | Crate | Effect | +|------|-------|--------| +| `tui` | flowctl-cli | Enables the TUI dashboard (`flowctl tui`) | +| `daemon` | flowctl-daemon | Enables the background daemon with HTTP API | +| `daemon` | flowctl-scheduler | Enables file-watcher integration | + +Build with all features: + +```sh +cargo build --release --all-features +``` + +## Architecture + +flowctl is split into six crates for modularity: + +``` +flowctl-core Core types, ID parsing, state machine, YAML/JSON I/O +flowctl-db SQLite storage layer (rusqlite + migrations) +flowctl-scheduler DAG-based task scheduler and event bus +flowctl-cli CLI entry point (clap) — the `flowctl` binary +flowctl-daemon Background daemon: scheduler, file watcher, HTTP/WS API +flowctl-tui Terminal UI dashboard (ratatui) +``` + +**Data flow**: CLI parses commands via `clap`, calls into `flowctl-db` for storage, which uses `flowctl-core` types. The scheduler builds a DAG from task dependencies to determine execution order. The daemon wraps the scheduler with file watching and an HTTP API. The TUI provides a live dashboard. + +## Release profile + +Release builds are size-optimized: + +- `opt-level = "z"` (minimize size) +- `lto = "fat"` (full link-time optimization) +- `codegen-units = 1` (single codegen unit) +- `panic = "abort"` (no unwinding overhead) +- `strip = true` (strip debug symbols) + +## License + +MIT diff --git a/flowctl/crates/flowctl-cli/Cargo.toml b/flowctl/crates/flowctl-cli/Cargo.toml new file mode 100644 index 00000000..bd8a1f29 --- /dev/null +++ b/flowctl/crates/flowctl-cli/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "flowctl-cli" +version = "0.1.0" +description = "CLI entry point for flowctl" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[[bin]] +name = "flowctl" +path = "src/main.rs" + +[features] +default = [] +tui = ["dep:flowctl-tui", "dep:tokio"] + +[dependencies] +flowctl-core = { workspace = true } +flowctl-db = { workspace = true } +rusqlite = { workspace = true } +flowctl-scheduler = { workspace = true } +flowctl-tui = { path = "../flowctl-tui", optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true } +clap_complete = { workspace = true } +miette = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true, optional = true } +regex = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +which = { workspace = true } + +[dev-dependencies] +trycmd = { workspace = true } +tempfile = "3" +serde_json = { workspace = true } diff --git a/flowctl/crates/flowctl-cli/src/commands/admin.rs b/flowctl/crates/flowctl-cli/src/commands/admin.rs new file mode 100644 index 00000000..dc179339 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/admin.rs @@ -0,0 +1,1655 @@ +//! Admin commands: init, detect, status, doctor, validate, state-path, migrate-state, +//! review-backend, parse-findings, guard, worker-prompt, config. + +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; + +use clap::Subcommand; +use serde_json::json; + +use crate::output::{error_exit, json_output, stub}; + +use flowctl_core::types::{ + CONFIG_FILE, EPICS_DIR, FLOW_DIR, MEMORY_DIR, META_FILE, REVIEWS_DIR, SCHEMA_VERSION, + SPECS_DIR, TASKS_DIR, +}; + +// ── Helpers ───────────────────────────────────────────────────────── + +/// Get the .flow/ directory path (current directory + .flow/). +fn get_flow_dir() -> std::path::PathBuf { + env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .join(FLOW_DIR) +} + +/// Default config structure matching Python's get_default_config(). +fn get_default_config() -> serde_json::Value { + json!({ + "memory": {"enabled": true}, + "planSync": {"enabled": true, "crossEpic": false}, + "review": {"backend": null}, + "scouts": {"github": false}, + "stack": {}, + }) +} + +/// Deep merge: override values win for conflicts. +fn deep_merge(base: &serde_json::Value, over: &serde_json::Value) -> serde_json::Value { + match (base, over) { + (serde_json::Value::Object(b), serde_json::Value::Object(o)) => { + let mut result = b.clone(); + for (key, value) in o { + if let Some(base_val) = result.get(key) { + result.insert(key.clone(), deep_merge(base_val, value)); + } else { + result.insert(key.clone(), value.clone()); + } + } + serde_json::Value::Object(result) + } + (_, over_val) => over_val.clone(), + } +} + +/// Resolve current actor: FLOW_ACTOR env > git config user.email > git config user.name > $USER > "unknown" +#[allow(dead_code)] +pub fn resolve_actor() -> String { + if let Ok(actor) = env::var("FLOW_ACTOR") { + let trimmed = actor.trim().to_string(); + if !trimmed.is_empty() { + return trimmed; + } + } + + if let Ok(output) = Command::new("git") + .args(["config", "user.email"]) + .output() + { + if output.status.success() { + let email = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !email.is_empty() { + return email; + } + } + } + + if let Ok(output) = Command::new("git") + .args(["config", "user.name"]) + .output() + { + if output.status.success() { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !name.is_empty() { + return name; + } + } + } + + if let Ok(user) = env::var("USER") { + if !user.is_empty() { + return user; + } + } + + "unknown".to_string() +} + +// ── Init command ──────────────────────────────────────────────────── + +pub fn cmd_init(json: bool) { + let flow_dir = get_flow_dir(); + let mut actions: Vec = Vec::new(); + + // Create directories if missing (idempotent, never destroys existing) + for subdir in &[EPICS_DIR, SPECS_DIR, TASKS_DIR, MEMORY_DIR, REVIEWS_DIR] { + let dir_path = flow_dir.join(subdir); + if !dir_path.exists() { + if let Err(e) = fs::create_dir_all(&dir_path) { + error_exit(&format!("Failed to create {}: {}", dir_path.display(), e)); + } + actions.push(format!("created {}/", subdir)); + } + } + + // Create meta.json if missing (never overwrite existing) + let meta_path = flow_dir.join(META_FILE); + if !meta_path.exists() { + let meta = json!({ + "schema_version": SCHEMA_VERSION, + "next_epic": 1 + }); + write_json_file(&meta_path, &meta); + actions.push("created meta.json".to_string()); + } + + // Config: create or upgrade (merge missing defaults) + let config_path = flow_dir.join(CONFIG_FILE); + if !config_path.exists() { + write_json_file(&config_path, &get_default_config()); + actions.push("created config.json".to_string()); + } else { + // Load raw config, compare with merged (which includes new defaults) + let raw = match fs::read_to_string(&config_path) { + Ok(content) => serde_json::from_str::(&content) + .unwrap_or(json!({})), + Err(_) => json!({}), + }; + let merged = deep_merge(&get_default_config(), &raw); + if merged != raw { + write_json_file(&config_path, &merged); + actions.push("upgraded config.json (added missing keys)".to_string()); + } + } + + // Build output + let message = if actions.is_empty() { + ".flow/ already up to date".to_string() + } else { + format!(".flow/ updated: {}", actions.join(", ")) + }; + + if json { + json_output(json!({ + "message": message, + "path": flow_dir.to_string_lossy(), + "actions": actions, + })); + } else { + println!("{}", message); + } +} + +/// Write JSON to a file with pretty formatting. +fn write_json_file(path: &Path, value: &serde_json::Value) { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let content = serde_json::to_string_pretty(value).unwrap(); + if let Err(e) = fs::write(path, &content) { + error_exit(&format!("Failed to write {}: {}", path.display(), e)); + } +} + +// ── Detect command ────────────────────────────────────────────────── + +pub fn cmd_detect(json: bool) { + let flow_dir = get_flow_dir(); + let exists = flow_dir.exists(); + let mut issues: Vec = Vec::new(); + + if exists { + let meta_path = flow_dir.join(META_FILE); + if !meta_path.exists() { + issues.push("meta.json missing".to_string()); + } else { + match fs::read_to_string(&meta_path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(meta) => { + let version = meta.get("schema_version").and_then(|v| v.as_u64()); + if version != Some(SCHEMA_VERSION as u64) { + issues.push(format!( + "schema_version unsupported (expected {}, got {:?})", + SCHEMA_VERSION, version + )); + } + } + Err(e) => issues.push(format!("meta.json parse error: {}", e)), + }, + Err(e) => issues.push(format!("meta.json unreadable: {}", e)), + } + } + + for subdir in &[EPICS_DIR, SPECS_DIR, TASKS_DIR, MEMORY_DIR, REVIEWS_DIR] { + if !flow_dir.join(subdir).exists() { + issues.push(format!("{}/ missing", subdir)); + } + } + } + + let valid = exists && issues.is_empty(); + + if json { + json_output(json!({ + "exists": exists, + "valid": valid, + "issues": issues, + "path": flow_dir.to_string_lossy(), + })); + } else if exists { + if valid { + println!(".flow/ exists and is valid"); + } else { + println!(".flow/ exists but has issues:"); + for issue in &issues { + println!(" - {}", issue); + } + } + } else { + println!(".flow/ not found"); + } +} + +// ── Status command ────────────────────────────────────────────────── + +pub fn cmd_status(json: bool, interrupted: bool) { + let flow_dir = get_flow_dir(); + let flow_exists = flow_dir.exists(); + + // Handle --interrupted flag + if interrupted { + if !flow_exists { + if json { + json_output(json!({"interrupted": []})); + } else { + println!("No interrupted work (.flow/ not found)"); + } + return; + } + + let interrupted_epics = find_interrupted_epics(&flow_dir); + if json { + json_output(json!({"interrupted": interrupted_epics})); + } else if interrupted_epics.is_empty() { + println!("No interrupted work found."); + } else { + println!( + "Found {} interrupted epic(s):\n", + interrupted_epics.len() + ); + for ep in &interrupted_epics { + let done = ep["done"].as_u64().unwrap_or(0); + let total = ep["total"].as_u64().unwrap_or(0); + let todo = ep["todo"].as_u64().unwrap_or(0); + let in_prog = ep["in_progress"].as_u64().unwrap_or(0); + let blocked = ep["blocked"].as_u64().unwrap_or(0); + + let mut remaining = Vec::new(); + if todo > 0 { + remaining.push(format!("{} todo", todo)); + } + if in_prog > 0 { + remaining.push(format!("{} in_progress", in_prog)); + } + if blocked > 0 { + remaining.push(format!("{} blocked", blocked)); + } + + println!(" {}: {}", ep["id"].as_str().unwrap_or(""), ep["title"].as_str().unwrap_or("")); + println!( + " Progress: {}/{} done ({})", + done, + total, + remaining.join(", ") + ); + println!( + " Resume: {}", + ep["suggested"].as_str().unwrap_or("") + ); + println!(); + } + } + return; + } + + // Count epics and tasks by status using Markdown scanning + let mut epic_counts = json!({"open": 0, "done": 0}); + let mut task_counts = json!({"todo": 0, "in_progress": 0, "blocked": 0, "done": 0}); + + if flow_exists { + let epics_dir = flow_dir.join(EPICS_DIR); + let tasks_dir = flow_dir.join(TASKS_DIR); + + // Try DB first, fall back to Markdown + if let Some(counts) = status_from_db() { + epic_counts = counts.0; + task_counts = counts.1; + } else { + // Scan Markdown files + if epics_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&epics_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !flowctl_core::id::is_epic_id(stem) { + continue; + } + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(epic) = + flowctl_core::frontmatter::parse_frontmatter::(&content) + { + let key = epic.status.to_string(); + if let Some(count) = epic_counts.get_mut(&key) { + *count = json!(count.as_u64().unwrap_or(0) + 1); + } + } + } + } + } + } + + if tasks_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&tasks_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !flowctl_core::id::is_task_id(stem) { + continue; + } + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(task) = + flowctl_core::frontmatter::parse_frontmatter::(&content) + { + let key = task.status.to_string(); + if let Some(count) = task_counts.get_mut(&key) { + *count = json!(count.as_u64().unwrap_or(0) + 1); + } + } + } + } + } + } + } + } + + if json { + json_output(json!({ + "flow_exists": flow_exists, + "epics": epic_counts, + "tasks": task_counts, + "runs": [], + })); + } else if !flow_exists { + println!(".flow/ not initialized"); + } else { + println!( + "Epics: {} open, {} done", + epic_counts["open"], epic_counts["done"] + ); + println!( + "Tasks: {} todo, {} in_progress, {} done, {} blocked", + task_counts["todo"], + task_counts["in_progress"], + task_counts["done"], + task_counts["blocked"] + ); + println!(); + println!("No active runs"); + } +} + +/// Try to get status counts from SQLite database. +fn status_from_db() -> Option<(serde_json::Value, serde_json::Value)> { + let cwd = env::current_dir().ok()?; + let conn = flowctl_db::open(&cwd).ok()?; + + let epic_repo = flowctl_db::EpicRepo::new(&conn); + let epics = epic_repo.list(None).ok()?; + + let mut epic_open = 0u64; + let mut epic_done = 0u64; + for epic in &epics { + match epic.status { + flowctl_core::types::EpicStatus::Open => epic_open += 1, + flowctl_core::types::EpicStatus::Done => epic_done += 1, + } + } + + // Check if there are actually any epics/tasks indexed + if epics.is_empty() { + // DB might be empty (not yet indexed), fall back to Markdown + return None; + } + + let task_repo = flowctl_db::TaskRepo::new(&conn); + let tasks = task_repo.list_all(None, None).ok()?; + + let mut todo = 0u64; + let mut in_progress = 0u64; + let mut blocked = 0u64; + let mut done = 0u64; + for task in &tasks { + match task.status { + flowctl_core::state_machine::Status::Todo => todo += 1, + flowctl_core::state_machine::Status::InProgress => in_progress += 1, + flowctl_core::state_machine::Status::Done => done += 1, + flowctl_core::state_machine::Status::Blocked => blocked += 1, + _ => {} + } + } + + Some(( + json!({"open": epic_open, "done": epic_done}), + json!({"todo": todo, "in_progress": in_progress, "blocked": blocked, "done": done}), + )) +} + +/// Find open epics with undone tasks (interrupted work). +fn find_interrupted_epics(flow_dir: &Path) -> Vec { + let mut interrupted = Vec::new(); + let epics_dir = flow_dir.join(EPICS_DIR); + let tasks_dir = flow_dir.join(TASKS_DIR); + + if !epics_dir.is_dir() { + return interrupted; + } + + // Collect all epics + let mut epic_entries: Vec<_> = match fs::read_dir(&epics_dir) { + Ok(entries) => entries.flatten().collect(), + Err(_) => return interrupted, + }; + epic_entries.sort_by_key(|e| e.path()); + + for entry in epic_entries { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !flowctl_core::id::is_epic_id(stem) { + continue; + } + + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + + let epic = match flowctl_core::frontmatter::parse_frontmatter::(&content) { + Ok(e) => e, + Err(_) => continue, + }; + + if epic.status != flowctl_core::types::EpicStatus::Open { + continue; + } + + // Count tasks for this epic + let mut counts = std::collections::HashMap::new(); + counts.insert("todo", 0u64); + counts.insert("in_progress", 0u64); + counts.insert("done", 0u64); + counts.insert("blocked", 0u64); + counts.insert("skipped", 0u64); + + if tasks_dir.is_dir() { + if let Ok(task_entries) = fs::read_dir(&tasks_dir) { + for task_entry in task_entries.flatten() { + let task_path = task_entry.path(); + if task_path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let task_stem = task_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !flowctl_core::id::is_task_id(task_stem) { + continue; + } + if let Ok(task_content) = fs::read_to_string(&task_path) { + if let Ok(task) = + flowctl_core::frontmatter::parse_frontmatter::(&task_content) + { + if task.epic != epic.id { + continue; + } + let status_key = task.status.to_string(); + if let Some(count) = counts.get_mut(status_key.as_str()) { + *count += 1; + } + } + } + } + } + } + + let total: u64 = counts.values().sum(); + if total == 0 { + continue; + } + + let todo = *counts.get("todo").unwrap_or(&0); + let in_progress = *counts.get("in_progress").unwrap_or(&0); + let done = *counts.get("done").unwrap_or(&0); + let blocked = *counts.get("blocked").unwrap_or(&0); + let skipped = *counts.get("skipped").unwrap_or(&0); + + if todo > 0 || in_progress > 0 { + interrupted.push(json!({ + "id": epic.id, + "title": epic.title, + "total": total, + "done": done, + "todo": todo, + "in_progress": in_progress, + "blocked": blocked, + "skipped": skipped, + "reason": if done == 0 && in_progress == 0 { "planned_not_started" } else { "partially_complete" }, + "suggested": format!("/flow-code:work {}", epic.id), + })); + } + } + + interrupted +} + +// ── Validate command ──────────────────────────────────────────────── + +/// Validate .flow/ root invariants. Returns list of errors. +fn validate_flow_root(flow_dir: &Path) -> Vec { + let mut errors = Vec::new(); + + let meta_path = flow_dir.join(META_FILE); + if !meta_path.exists() { + errors.push(format!("meta.json missing: {}", meta_path.display())); + } else { + match fs::read_to_string(&meta_path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(meta) => { + let version = meta.get("schema_version").and_then(|v| v.as_u64()); + if version != Some(SCHEMA_VERSION as u64) { + errors.push(format!( + "schema_version unsupported in meta.json (expected {}, got {:?})", + SCHEMA_VERSION, version + )); + } + } + Err(e) => errors.push(format!("meta.json invalid JSON: {}", e)), + }, + Err(e) => errors.push(format!("meta.json unreadable: {}", e)), + } + } + + for subdir in &[EPICS_DIR, SPECS_DIR, TASKS_DIR, MEMORY_DIR, REVIEWS_DIR] { + if !flow_dir.join(subdir).exists() { + errors.push(format!("Required directory missing: {}/", subdir)); + } + } + + errors +} + +/// Validate a single epic. Returns (errors, warnings, task_count). +fn validate_epic(flow_dir: &Path, epic_id: &str) -> (Vec, Vec, usize) { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + let tasks_dir = flow_dir.join(TASKS_DIR); + + // Scan tasks for this epic from Markdown frontmatter + let mut tasks: std::collections::HashMap = + std::collections::HashMap::new(); + + if tasks_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&tasks_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !flowctl_core::id::is_task_id(stem) { + continue; + } + // Check if task belongs to this epic + if !stem.starts_with(&format!("{}.", epic_id)) { + continue; + } + if let Ok(content) = fs::read_to_string(&path) { + match flowctl_core::frontmatter::parse_frontmatter::( + &content, + ) { + Ok(task) => { + tasks.insert(task.id.clone(), task); + } + Err(e) => { + let path_str = path.display().to_string(); + errors.push(format!("Task {}: frontmatter parse error: {}", stem, e)); + crate::diagnostics::report_frontmatter_error( + &path_str, + &content, + &e.to_string(), + ); + } + } + } + } + } + } + + // Validate each task + for (task_id, task) in &tasks { + // Check task spec exists + let task_spec_path = tasks_dir.join(format!("{}.md", task_id)); + if !task_spec_path.exists() { + errors.push(format!("Task spec missing: {}", task_spec_path.display())); + } else if let Ok(spec_content) = fs::read_to_string(&task_spec_path) { + // Validate required headings + for heading in flowctl_core::types::TASK_SPEC_HEADINGS { + if !spec_content.contains(heading) { + errors.push(format!("Task {}: missing required heading '{}'", task_id, heading)); + } + } + } + + // Check dependencies exist and are within epic + for dep in &task.depends_on { + if !tasks.contains_key(dep) { + errors.push(format!("Task {}: dependency {} not found", task_id, dep)); + } + if !dep.starts_with(&format!("{}.", epic_id)) { + errors.push(format!( + "Task {}: dependency {} is outside epic {}", + task_id, dep, epic_id + )); + } + } + } + + // Check for dependency cycles using DFS + let task_ids: Vec<&String> = tasks.keys().collect(); + for start_id in &task_ids { + let mut visited = std::collections::HashSet::new(); + let mut stack = vec![start_id.as_str()]; + while let Some(current) = stack.pop() { + if !visited.insert(current.to_string()) { + if current == start_id.as_str() { + errors.push(format!("Dependency cycle detected involving {}", start_id)); + } + continue; + } + if let Some(task) = tasks.get(current) { + for dep in &task.depends_on { + stack.push(dep); + } + } + } + } + + let task_count = tasks.len(); + + // Check epic spec exists + let epic_spec = flow_dir.join(SPECS_DIR).join(format!("{}.md", epic_id)); + if !epic_spec.exists() { + warnings.push(format!("Epic spec missing: {}", epic_spec.display())); + } + + (errors, warnings, task_count) +} + +pub fn cmd_validate(json_mode: bool, epic: Option, all: bool) { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + + if epic.is_none() && !all { + error_exit("Must specify --epic or --all"); + } + + if all { + // Validate all epics + let root_errors = validate_flow_root(&flow_dir); + let epics_dir = flow_dir.join(EPICS_DIR); + + let mut epic_ids: Vec = Vec::new(); + if epics_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&epics_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if flowctl_core::id::is_epic_id(stem) { + epic_ids.push(stem.to_string()); + } + } + } + } + epic_ids.sort(); + + let mut all_errors: Vec = root_errors.clone(); + let mut all_warnings: Vec = Vec::new(); + let mut total_tasks = 0usize; + let mut epic_results: Vec = Vec::new(); + + for eid in &epic_ids { + let (errors, warnings, task_count) = validate_epic(&flow_dir, eid); + all_errors.extend(errors.clone()); + all_warnings.extend(warnings.clone()); + total_tasks += task_count; + epic_results.push(json!({ + "epic": eid, + "valid": errors.is_empty(), + "errors": errors, + "warnings": warnings, + "task_count": task_count, + })); + } + + let valid = all_errors.is_empty(); + + if json_mode { + json_output(json!({ + "valid": valid, + "root_errors": root_errors, + "epics": epic_results, + "total_epics": epic_ids.len(), + "total_tasks": total_tasks, + "total_errors": all_errors.len(), + "total_warnings": all_warnings.len(), + })); + } else { + println!("Validation for all epics:"); + println!(" Epics: {}", epic_ids.len()); + println!(" Tasks: {}", total_tasks); + println!(" Valid: {}", valid); + if !all_errors.is_empty() { + println!(" Errors:"); + for e in &all_errors { + println!(" - {}", e); + } + } + if !all_warnings.is_empty() { + println!(" Warnings:"); + for w in &all_warnings { + println!(" - {}", w); + } + } + } + + if !valid { + std::process::exit(1); + } + return; + } + + // Single epic validation + let epic_id = epic.unwrap(); + if !flowctl_core::id::is_epic_id(&epic_id) { + error_exit(&format!( + "Invalid epic ID: {}. Expected format: fn-N or fn-N-slug (e.g., fn-1, fn-1-add-auth)", + epic_id + )); + } + + let (errors, warnings, task_count) = validate_epic(&flow_dir, &epic_id); + let valid = errors.is_empty(); + + if json_mode { + json_output(json!({ + "epic": epic_id, + "valid": valid, + "errors": errors, + "warnings": warnings, + "task_count": task_count, + })); + } else { + println!("Validation for {}:", epic_id); + println!(" Tasks: {}", task_count); + println!(" Valid: {}", valid); + if !errors.is_empty() { + println!(" Errors:"); + for e in &errors { + println!(" - {}", e); + } + } + if !warnings.is_empty() { + println!(" Warnings:"); + for w in &warnings { + println!(" - {}", w); + } + } + } + + if !valid { + std::process::exit(1); + } +} + +// ── Doctor command ───────────────────────────────────────────────── + +pub fn cmd_doctor(json_mode: bool) { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + + let mut checks: Vec = Vec::new(); + + // Check 1: Run validate --all internally + let root_errors = validate_flow_root(&flow_dir); + let epics_dir = flow_dir.join(EPICS_DIR); + let mut validate_errors = root_errors.clone(); + + if epics_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&epics_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if flowctl_core::id::is_epic_id(stem) { + let (errors, _, _) = validate_epic(&flow_dir, stem); + validate_errors.extend(errors); + } + } + } + } + + if validate_errors.is_empty() { + checks.push(json!({"name": "validate", "status": "pass", "message": "All epics and tasks validated successfully"})); + } else { + checks.push(json!({"name": "validate", "status": "fail", "message": format!("Validation found {} error(s). Run 'flowctl validate --all' for details", validate_errors.len())})); + } + + // Check 2: State-dir accessibility + let cwd = env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + match flowctl_db::resolve_state_dir(&cwd) { + Ok(state_dir) => { + if let Err(e) = fs::create_dir_all(&state_dir) { + checks.push(json!({"name": "state_dir_access", "status": "fail", "message": format!("State dir not accessible: {}", e)})); + } else { + // Test write access + let test_file = state_dir.join(".doctor-probe"); + match fs::write(&test_file, "probe") { + Ok(_) => { + let _ = fs::remove_file(&test_file); + checks.push(json!({"name": "state_dir_access", "status": "pass", "message": format!("State dir accessible: {}", state_dir.display())})); + } + Err(e) => { + checks.push(json!({"name": "state_dir_access", "status": "fail", "message": format!("State dir not writable: {}", e)})); + } + } + } + } + Err(e) => { + checks.push(json!({"name": "state_dir_access", "status": "fail", "message": format!("Could not resolve state dir: {}", e)})); + } + } + + // Check 3: Config validity + let config_path = flow_dir.join(CONFIG_FILE); + if config_path.exists() { + match fs::read_to_string(&config_path) { + Ok(raw_text) => match serde_json::from_str::(&raw_text) { + Ok(parsed) => { + if !parsed.is_object() { + checks.push(json!({"name": "config", "status": "fail", "message": "config.json is not a JSON object"})); + } else { + let known_keys: std::collections::HashSet<&str> = + ["memory", "planSync", "review", "scouts", "stack"] + .iter() + .copied() + .collect(); + let unknown: Vec = parsed + .as_object() + .unwrap() + .keys() + .filter(|k| !known_keys.contains(k.as_str())) + .cloned() + .collect(); + if unknown.is_empty() { + checks.push(json!({"name": "config", "status": "pass", "message": "config.json valid with known keys"})); + } else { + checks.push(json!({"name": "config", "status": "warn", "message": format!("Unknown config keys: {}", unknown.join(", "))})); + } + } + } + Err(e) => { + checks.push(json!({"name": "config", "status": "fail", "message": format!("config.json invalid JSON: {}", e)})); + } + }, + Err(e) => { + checks.push(json!({"name": "config", "status": "warn", "message": format!("Could not read config: {}", e)})); + } + } + } else { + checks.push(json!({"name": "config", "status": "warn", "message": "config.json missing (run 'flowctl init')"})); + } + + // Check 4: git common-dir reachability + match Command::new("git") + .args(["rev-parse", "--git-common-dir", "--path-format=absolute"]) + .output() + { + Ok(output) if output.status.success() => { + let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if Path::new(&common_dir).exists() { + checks.push(json!({"name": "git_common_dir", "status": "pass", "message": format!("git common-dir reachable: {}", common_dir)})); + } else { + checks.push(json!({"name": "git_common_dir", "status": "warn", "message": format!("git common-dir path does not exist: {}", common_dir)})); + } + } + Ok(_) => { + checks.push(json!({"name": "git_common_dir", "status": "warn", "message": "Not in a git repository (git common-dir unavailable)"})); + } + Err(_) => { + checks.push(json!({"name": "git_common_dir", "status": "warn", "message": "git not found on PATH"})); + } + } + + // Build summary + let mut summary = json!({"pass": 0, "warn": 0, "fail": 0}); + for c in &checks { + let status = c["status"].as_str().unwrap_or("warn"); + if let Some(count) = summary.get_mut(status) { + *count = json!(count.as_u64().unwrap_or(0) + 1); + } + } + let overall_healthy = summary["fail"].as_u64().unwrap_or(0) == 0; + + if json_mode { + json_output(json!({ + "checks": checks, + "summary": summary, + "healthy": overall_healthy, + })); + } else { + println!("Doctor diagnostics:"); + for c in &checks { + let icon = match c["status"].as_str().unwrap_or("warn") { + "pass" => "OK", + "warn" => "WARN", + "fail" => "FAIL", + _ => "?", + }; + println!( + " [{}] {}: {}", + icon, + c["name"].as_str().unwrap_or(""), + c["message"].as_str().unwrap_or("") + ); + } + println!(); + println!( + "Summary: {} pass, {} warn, {} fail", + summary["pass"], summary["warn"], summary["fail"] + ); + if !overall_healthy { + println!("Health check FAILED \u{2014} resolve fail items above."); + } + } + + if !overall_healthy { + std::process::exit(1); + } +} + +// ── State-path command ───────────────────────────────────────────── + +pub fn cmd_state_path(json_mode: bool, task: Option) { + let cwd = env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let state_dir = match flowctl_db::resolve_state_dir(&cwd) { + Ok(d) => d, + Err(e) => { + error_exit(&format!("Could not resolve state dir: {}", e)); + } + }; + + if let Some(task_id) = task { + if !flowctl_core::id::is_task_id(&task_id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M (e.g., fn-1.2, fn-1-add-auth.2)", + task_id + )); + } + let state_path = state_dir.join("tasks").join(format!("{}.state.json", task_id)); + if json_mode { + json_output(json!({ + "state_dir": state_dir.to_string_lossy(), + "task_state_path": state_path.to_string_lossy(), + })); + } else { + println!("{}", state_path.display()); + } + } else if json_mode { + json_output(json!({"state_dir": state_dir.to_string_lossy()})); + } else { + println!("{}", state_dir.display()); + } +} + +// ── Migrate-state command (stub - complex migration logic) ───────── + +pub fn cmd_migrate_state(json: bool, clean: bool) { + let _ = clean; + stub("migrate-state", json); +} + +// ── Review-backend command ───────────────────────────────────────── + +pub fn cmd_review_backend(json_mode: bool, compare: Option, epic: Option) { + // Priority: FLOW_REVIEW_BACKEND env > config > ASK + let (backend, source) = if let Ok(env_val) = env::var("FLOW_REVIEW_BACKEND") { + let trimmed = env_val.trim().to_string(); + if ["rp", "codex", "none"].contains(&trimmed.as_str()) { + (trimmed, "env".to_string()) + } else { + ("ASK".to_string(), "none".to_string()) + } + } else { + let flow_dir = get_flow_dir(); + if flow_dir.exists() { + let config_path = flow_dir.join(CONFIG_FILE); + let config = if config_path.exists() { + match fs::read_to_string(&config_path) { + Ok(content) => { + let raw = serde_json::from_str::(&content) + .unwrap_or(json!({})); + deep_merge(&get_default_config(), &raw) + } + Err(_) => get_default_config(), + } + } else { + get_default_config() + }; + + let cfg_val = config + .pointer("/review/backend") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if ["rp", "codex", "none"].contains(&cfg_val) { + (cfg_val.to_string(), "config".to_string()) + } else { + ("ASK".to_string(), "none".to_string()) + } + } else { + ("ASK".to_string(), "none".to_string()) + } + }; + + // --compare mode: compare review receipt files + let receipt_files: Option> = if let Some(epic_id) = &epic { + if compare.is_none() { + let flow_dir = get_flow_dir(); + let reviews_dir = flow_dir.join(REVIEWS_DIR); + if !reviews_dir.exists() { + if json_mode { + json_output(json!({"backend": backend, "source": source})); + } else { + println!("{}", backend); + } + return; + } + let mut files: Vec = Vec::new(); + if let Ok(entries) = fs::read_dir(&reviews_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.contains(&format!("-{}.", epic_id)) && name.ends_with(".json") { + files.push(entry.path().to_string_lossy().to_string()); + } + } + } + files.sort(); + if files.is_empty() { + None + } else { + Some(files) + } + } else { + None + } + } else { + None + }; + + let receipt_files = receipt_files.or_else(|| { + compare.map(|c| c.split(',').map(|f| f.trim().to_string()).collect()) + }); + + if let Some(files) = receipt_files { + let mut reviews: Vec = Vec::new(); + for rf in &files { + let rpath = Path::new(rf); + if !rpath.exists() { + error_exit(&format!("Receipt file not found: {}", rf)); + } + match fs::read_to_string(rpath) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(rdata) => { + reviews.push(json!({ + "file": rf, + "mode": rdata.get("mode").and_then(|v| v.as_str()).unwrap_or("unknown"), + "verdict": rdata.get("verdict").and_then(|v| v.as_str()).unwrap_or("unknown"), + "id": rdata.get("id").and_then(|v| v.as_str()).unwrap_or("unknown"), + "timestamp": rdata.get("timestamp").and_then(|v| v.as_str()).unwrap_or(""), + "review": rdata.get("review").and_then(|v| v.as_str()).unwrap_or(""), + })); + } + Err(e) => { + error_exit(&format!("Invalid receipt JSON: {}: {}", rf, e)); + } + }, + Err(e) => { + error_exit(&format!("Could not read receipt: {}: {}", rf, e)); + } + } + } + + // Analyze verdicts + let mut verdicts: std::collections::HashMap = + std::collections::HashMap::new(); + for r in &reviews { + let mode = r["mode"].as_str().unwrap_or("unknown").to_string(); + let verdict = r["verdict"].as_str().unwrap_or("unknown").to_string(); + verdicts.insert(mode, verdict); + } + let verdict_values: std::collections::HashSet<&String> = verdicts.values().collect(); + let all_same = verdict_values.len() <= 1; + let consensus = if all_same { + verdicts.values().next().cloned() + } else { + None + }; + + if json_mode { + json_output(json!({ + "reviews": reviews.len(), + "verdicts": verdicts, + "consensus": consensus, + "has_conflict": !all_same, + "details": reviews, + })); + } else { + println!("Review Comparison ({} reviews):\n", reviews.len()); + for r in &reviews { + println!( + " [{}] verdict: {} ({})", + r["mode"].as_str().unwrap_or(""), + r["verdict"].as_str().unwrap_or(""), + r["file"].as_str().unwrap_or("") + ); + } + println!(); + if all_same { + println!("Consensus: {}", consensus.unwrap_or_default()); + } else { + println!("CONFLICT \u{2014} reviewers disagree:"); + for (mode, verdict) in &verdicts { + println!(" {}: {}", mode, verdict); + } + } + } + return; + } + + if json_mode { + json_output(json!({"backend": backend, "source": source})); + } else { + println!("{}", backend); + } +} + +// ── Parse-findings command ───────────────────────────────────────── + +pub fn cmd_parse_findings( + json_mode: bool, + file: String, + _epic: Option, + _register: bool, + _source: String, +) { + // Read input from file or stdin + let text = if file == "-" { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .unwrap_or_else(|e| { + error_exit(&format!("Failed to read stdin: {}", e)); + }); + buf + } else { + match fs::read_to_string(&file) { + Ok(content) => content, + Err(e) => { + error_exit(&format!("Failed to read file {}: {}", file, e)); + } + } + }; + + let mut findings: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let required_keys = ["title", "severity", "location", "recommendation"]; + + // Tiered extraction: + // 1. ... tag + // 2. Bare JSON array + // 3. Markdown code block + let raw_json = if let Some(start) = text.find("") { + if let Some(end) = text.find("") { + let inner = &text[start + 10..end]; + Some(inner.trim().to_string()) + } else { + None + } + } else { + None + }; + + let raw_json = raw_json.or_else(|| { + // Tier 2: bare JSON array [{...}] + if let Some(start) = text.find('[') { + if let Some(end) = text.rfind(']') { + let candidate = &text[start..=end]; + warnings.push("No tag found; extracted bare JSON array".to_string()); + Some(candidate.to_string()) + } else { + None + } + } else { + None + } + }); + + if let Some(raw) = raw_json { + // Remove trailing commas before ] or } + let cleaned = raw + .replace(",]", "]") + .replace(",}", "}"); + + match serde_json::from_str::(&cleaned) { + Ok(serde_json::Value::Array(arr)) => { + for (i, item) in arr.iter().enumerate() { + if !item.is_object() { + warnings.push(format!("Finding {} is not an object, skipping", i)); + continue; + } + let missing: Vec<&&str> = required_keys + .iter() + .filter(|k| item.get(**k).is_none()) + .collect(); + if !missing.is_empty() { + let keys: Vec<&str> = missing.iter().map(|k| **k).collect(); + warnings.push(format!( + "Finding {} missing keys: {}, skipping", + i, + keys.join(", ") + )); + continue; + } + findings.push(item.clone()); + } + // Cap at 50 + if findings.len() > 50 { + warnings.push(format!( + "Found {} findings, capping at 50", + findings.len() + )); + findings.truncate(50); + } + } + Ok(_) => { + warnings.push("Findings JSON is not a list".to_string()); + } + Err(e) => { + warnings.push(format!("Failed to parse findings JSON: {}", e)); + } + } + } else { + warnings.push("No findings found in review output".to_string()); + } + + if json_mode { + json_output(json!({ + "findings": findings, + "count": findings.len(), + "registered": 0, + "warnings": warnings, + })); + } else { + println!("Found {} finding(s)", findings.len()); + for w in &warnings { + eprintln!(" Warning: {}", w); + } + for f in &findings { + let sev = f["severity"].as_str().unwrap_or("unknown"); + let title = f["title"].as_str().unwrap_or(""); + let location = f["location"].as_str().unwrap_or(""); + println!(" [{}] {} \u{2014} {}", sev, title, location); + } + } +} + +// ── Guard command ────────────────────────────────────────────────── + +pub fn cmd_guard(json_mode: bool, layer: String) { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + + // Load stack config + let config_path = flow_dir.join(CONFIG_FILE); + let config = if config_path.exists() { + match fs::read_to_string(&config_path) { + Ok(content) => { + let raw = + serde_json::from_str::(&content).unwrap_or(json!({})); + deep_merge(&get_default_config(), &raw) + } + Err(_) => get_default_config(), + } + } else { + get_default_config() + }; + + let stack = config.get("stack").cloned().unwrap_or(json!({})); + let stack_obj = stack.as_object(); + + if stack_obj.is_none() || stack_obj.unwrap().is_empty() { + if json_mode { + json_output(json!({ + "results": [], + "message": "no stack detected, nothing to run", + })); + } else { + println!("No stack detected. Nothing to run."); + } + return; + } + + let cmd_types = ["test", "lint", "typecheck"]; + let mut commands: Vec<(String, String, String)> = Vec::new(); // (layer_name, type, cmd) + + for (layer_name, layer_conf) in stack_obj.unwrap() { + if layer != "all" && layer_name != &layer { + continue; + } + if let Some(layer_obj) = layer_conf.as_object() { + for ct in &cmd_types { + if let Some(cmd_val) = layer_obj.get(*ct) { + if let Some(cmd_str) = cmd_val.as_str() { + if !cmd_str.is_empty() { + commands.push(( + layer_name.clone(), + ct.to_string(), + cmd_str.to_string(), + )); + } + } + } + } + } + } + + if commands.is_empty() { + if json_mode { + json_output(json!({ + "results": [], + "message": "no guard commands configured", + })); + } else { + println!("No guard commands found in stack config."); + } + return; + } + + // Find repo root for running commands + let repo_root = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some( + String::from_utf8_lossy(&o.stdout) + .trim() + .to_string(), + ) + } else { + None + } + }) + .unwrap_or_else(|| ".".to_string()); + + let mut results: Vec = Vec::new(); + let mut all_passed = true; + + for (layer_name, cmd_type, cmd) in &commands { + if !json_mode { + println!("\u{25b8} [{}] {}: {}", layer_name, cmd_type, cmd); + } + + let output = Command::new("sh") + .args(["-c", cmd]) + .current_dir(&repo_root) + .output(); + + let rc = match &output { + Ok(o) => o.status.code().unwrap_or(1), + Err(_) => 1, + }; + + let passed = rc == 0; + if !passed { + all_passed = false; + } + + results.push(json!({ + "layer": layer_name, + "type": cmd_type, + "command": cmd, + "passed": passed, + "exit_code": rc, + })); + + if !json_mode { + let status = if passed { "\u{2713}" } else { "\u{2717}" }; + println!(" {} exit {}", status, rc); + } + } + + if json_mode { + json_output(json!({"results": results})); + } else { + let passed_count = results.iter().filter(|r| r["passed"].as_bool().unwrap_or(false)).count(); + let total = results.len(); + let suffix = if all_passed { "" } else { " \u{2014} FAILED" }; + println!("\n{}/{} guards passed{}", passed_count, total, suffix); + } + + if !all_passed { + std::process::exit(1); + } +} + +// ── Worker-prompt command ────────────────────────────────────────── + +pub fn cmd_worker_prompt(json_mode: bool, task: String, tdd: bool, review: Option) { + // Determine epic from task ID + let epic_id = if flowctl_core::id::is_task_id(&task) { + flowctl_core::id::epic_id_from_task(&task).unwrap_or_else(|_| task.clone()) + } else { + task.clone() + }; + + // Build phase sequence + let has_review = review.is_some(); + let phases: Vec<&str> = if tdd && has_review { + flowctl_core::types::PHASE_SEQ_TDD + .iter() + .chain(flowctl_core::types::PHASE_SEQ_REVIEW.iter()) + .copied() + .collect::>() + .into_iter() + .collect() + } else if tdd { + flowctl_core::types::PHASE_SEQ_TDD.to_vec() + } else if has_review { + flowctl_core::types::PHASE_SEQ_REVIEW.to_vec() + } else { + flowctl_core::types::PHASE_SEQ_DEFAULT.to_vec() + }; + + // Build a minimal bootstrap prompt + let review_line = review + .as_ref() + .map(|r| format!("REVIEW_MODE: {}", r)) + .unwrap_or_else(|| "REVIEW_MODE: none".to_string()); + let tdd_line = if tdd { "TDD_MODE: true" } else { "TDD_MODE: false" }; + + let phase_list: Vec = phases + .iter() + .filter_map(|pid| { + flowctl_core::types::PHASE_DEFS + .iter() + .find(|(id, _, _)| id == pid) + .map(|(id, title, _)| format!("Phase {}: {}", id, title)) + }) + .collect(); + + let prompt_text = format!( + "TASK_ID: {task}\nEPIC_ID: {epic_id}\n{tdd_line}\n{review_line}\nTEAM_MODE: true\n\nPhase sequence:\n{phases}\n\nExecute phases in order. Use flowctl worker-phase next/done to track progress.", + task = task, + epic_id = epic_id, + tdd_line = tdd_line, + review_line = review_line, + phases = phase_list.join("\n"), + ); + + let estimated_tokens = prompt_text.len() / 4; + + if json_mode { + json_output(json!({ + "prompt": prompt_text, + "mode": "bootstrap", + "estimated_tokens": estimated_tokens, + })); + } else { + println!("{}", prompt_text); + } +} + +/// Config subcommands. +#[derive(Subcommand, Debug)] +pub enum ConfigCmd { + /// Get a config value. + Get { + /// Config key (e.g., memory.enabled). + key: String, + }, + /// Set a config value. + Set { + /// Config key. + key: String, + /// Config value. + value: String, + }, +} + +pub fn cmd_config(cmd: &ConfigCmd, json: bool) { + match cmd { + ConfigCmd::Get { key } => cmd_config_get(json, key), + ConfigCmd::Set { key, value } => cmd_config_set(json, key, value), + } +} + +fn cmd_config_get(json_mode: bool, key: &str) { + let flow_dir = get_flow_dir(); + let config_path = flow_dir.join(CONFIG_FILE); + + // Load config with defaults + let config = if config_path.exists() { + match fs::read_to_string(&config_path) { + Ok(content) => { + let raw = serde_json::from_str::(&content) + .unwrap_or(json!({})); + deep_merge(&get_default_config(), &raw) + } + Err(_) => get_default_config(), + } + } else { + get_default_config() + }; + + // Navigate nested key path + let mut current = &config; + for part in key.split('.') { + match current.get(part) { + Some(val) => current = val, + None => { + if json_mode { + json_output(json!({ + "key": key, + "value": null, + })); + } else { + println!("{}: (not set)", key); + } + return; + } + } + } + + if json_mode { + json_output(json!({ + "key": key, + "value": current, + })); + } else { + println!("{}: {}", key, current); + } +} + +fn cmd_config_set(json_mode: bool, key: &str, value: &str) { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + + let config_path = flow_dir.join(CONFIG_FILE); + + // Load existing config + let mut config = if config_path.exists() { + match fs::read_to_string(&config_path) { + Ok(content) => serde_json::from_str::(&content) + .unwrap_or(json!({})), + Err(_) => get_default_config(), + } + } else { + get_default_config() + }; + + // Parse value (handle type conversion) + let parsed_value: serde_json::Value = match value.to_lowercase().as_str() { + "true" => json!(true), + "false" => json!(false), + _ if value.parse::().is_ok() => json!(value.parse::().unwrap()), + _ => json!(value), + }; + + // Navigate/create nested path + let parts: Vec<&str> = key.split('.').collect(); + let mut current = &mut config; + for part in &parts[..parts.len() - 1] { + if !current.is_object() || !current.as_object().unwrap().contains_key(*part) { + current[*part] = json!({}); + } + current = &mut current[*part]; + } + if let Some(last) = parts.last() { + current[*last] = parsed_value.clone(); + } + + write_json_file(&config_path, &config); + + if json_mode { + json_output(json!({ + "key": key, + "value": parsed_value, + "message": format!("Set {} = {}", key, parsed_value), + })); + } else { + println!("Set {} = {}", key, parsed_value); + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/checkpoint.rs b/flowctl/crates/flowctl-cli/src/commands/checkpoint.rs new file mode 100644 index 00000000..2911a0f4 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/checkpoint.rs @@ -0,0 +1,212 @@ +//! Checkpoint commands: save, restore, delete. +//! +//! Checkpoints snapshot the SQLite database state for crash recovery. +//! Each checkpoint is a copy of the flowctl.db file stored alongside it +//! with an epic-specific suffix. + +use std::env; +use std::fs; + +use clap::Subcommand; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +use flowctl_core::id::is_epic_id; +use flowctl_core::types::FLOW_DIR; + +#[derive(Subcommand, Debug)] +pub enum CheckpointCmd { + /// Save epic state to checkpoint. + Save { + /// Epic ID. + #[arg(long)] + epic: String, + }, + /// Restore epic state from checkpoint. + Restore { + /// Epic ID. + #[arg(long)] + epic: String, + }, + /// Delete checkpoint for epic. + Delete { + /// Epic ID. + #[arg(long)] + epic: String, + }, +} + +pub fn dispatch(cmd: &CheckpointCmd, json: bool) { + match cmd { + CheckpointCmd::Save { epic } => cmd_checkpoint_save(json, epic), + CheckpointCmd::Restore { epic } => cmd_checkpoint_restore(json, epic), + CheckpointCmd::Delete { epic } => cmd_checkpoint_delete(json, epic), + } +} + +// ── Helpers ──────────────────────────────────────────────────────── + +fn get_flow_dir() -> std::path::PathBuf { + env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .join(FLOW_DIR) +} + +/// Resolve the checkpoint file path for a given epic. +/// Checkpoints are stored in the state directory alongside the main database. +fn checkpoint_path(epic_id: &str) -> Result { + let cwd = env::current_dir().map_err(|e| format!("Cannot get cwd: {}", e))?; + let state_dir = flowctl_db::resolve_state_dir(&cwd) + .map_err(|e| format!("Cannot resolve state dir: {}", e))?; + Ok(state_dir.join(format!("checkpoint-{}.db", epic_id))) +} + +/// Resolve the main database path. +fn db_path() -> Result { + let cwd = env::current_dir().map_err(|e| format!("Cannot get cwd: {}", e))?; + flowctl_db::resolve_db_path(&cwd).map_err(|e| format!("Cannot resolve db path: {}", e)) +} + +fn validate_prerequisites(epic_id: &str) { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + if !is_epic_id(epic_id) { + error_exit(&format!("Invalid epic ID: {}", epic_id)); + } +} + +// ── Commands ─────────────────────────────────────────────────────── + +fn cmd_checkpoint_save(json_mode: bool, epic_id: &str) { + validate_prerequisites(epic_id); + + let src = match db_path() { + Ok(p) => p, + Err(e) => error_exit(&e), + }; + + if !src.exists() { + error_exit("No database found. Run 'flowctl init' and index first."); + } + + let dst = match checkpoint_path(epic_id) { + Ok(p) => p, + Err(e) => error_exit(&e), + }; + + // Ensure parent directory exists + if let Some(parent) = dst.parent() { + let _ = fs::create_dir_all(parent); + } + + // Copy the database file (SQLite WAL-safe: we copy the main file; + // for a fully safe checkpoint we'd use the backup API, but a file + // copy is sufficient for crash recovery purposes). + if let Err(e) = fs::copy(&src, &dst) { + error_exit(&format!( + "Failed to save checkpoint: {}", + e + )); + } + + let size = fs::metadata(&dst).map(|m| m.len()).unwrap_or(0); + + if json_mode { + json_output(json!({ + "epic": epic_id, + "checkpoint": dst.to_string_lossy(), + "size_bytes": size, + "message": format!("Checkpoint saved for {}", epic_id), + })); + } else { + println!( + "Checkpoint saved for {} ({} bytes)", + epic_id, size + ); + } +} + +fn cmd_checkpoint_restore(json_mode: bool, epic_id: &str) { + validate_prerequisites(epic_id); + + let src = match checkpoint_path(epic_id) { + Ok(p) => p, + Err(e) => error_exit(&e), + }; + + if !src.exists() { + error_exit(&format!( + "No checkpoint found for {}. Save one first with 'flowctl checkpoint save'.", + epic_id + )); + } + + let dst = match db_path() { + Ok(p) => p, + Err(e) => error_exit(&e), + }; + + // Ensure parent directory exists + if let Some(parent) = dst.parent() { + let _ = fs::create_dir_all(parent); + } + + if let Err(e) = fs::copy(&src, &dst) { + error_exit(&format!( + "Failed to restore checkpoint: {}", + e + )); + } + + if json_mode { + json_output(json!({ + "epic": epic_id, + "restored_from": src.to_string_lossy(), + "message": format!("Checkpoint restored for {}", epic_id), + })); + } else { + println!("Checkpoint restored for {}", epic_id); + } +} + +fn cmd_checkpoint_delete(json_mode: bool, epic_id: &str) { + validate_prerequisites(epic_id); + + let path = match checkpoint_path(epic_id) { + Ok(p) => p, + Err(e) => error_exit(&e), + }; + + if !path.exists() { + if json_mode { + json_output(json!({ + "epic": epic_id, + "deleted": false, + "message": format!("No checkpoint found for {}", epic_id), + })); + } else { + println!("No checkpoint found for {}", epic_id); + } + return; + } + + if let Err(e) = fs::remove_file(&path) { + error_exit(&format!( + "Failed to delete checkpoint: {}", + e + )); + } + + if json_mode { + json_output(json!({ + "epic": epic_id, + "deleted": true, + "message": format!("Checkpoint deleted for {}", epic_id), + })); + } else { + println!("Checkpoint deleted for {}", epic_id); + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/codex.rs b/flowctl/crates/flowctl-cli/src/commands/codex.rs new file mode 100644 index 00000000..29545d68 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/codex.rs @@ -0,0 +1,638 @@ +//! Codex CLI wrapper commands. +//! +//! Spawns the `codex` CLI for code review operations. All review variants +//! delegate to `codex exec` with appropriate prompts and sandbox settings. + +use std::env; +use std::process::Command; + +use clap::Subcommand; +use regex::Regex; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +#[derive(Subcommand, Debug)] +pub enum CodexCmd { + /// Check codex availability. + Check, + /// Implementation review. + ImplReview { + /// Task ID (optional for standalone). + task: Option, + /// Base branch for diff. + #[arg(long)] + base: String, + /// Focus areas (comma-separated). + #[arg(long)] + focus: Option, + /// Receipt file path. + #[arg(long)] + receipt: Option, + /// Sandbox mode. + #[arg(long, default_value = "auto", value_parser = ["read-only", "workspace-write", "danger-full-access", "auto"])] + sandbox: String, + /// Model reasoning effort level. + #[arg(long, default_value = "high", value_parser = ["low", "medium", "high"])] + effort: String, + }, + /// Plan review. + PlanReview { + /// Epic ID. + epic: String, + /// Comma-separated file paths for context. + #[arg(long)] + files: String, + /// Base branch for context. + #[arg(long, default_value = "main")] + base: String, + /// Receipt file path. + #[arg(long)] + receipt: Option, + /// Sandbox mode. + #[arg(long, default_value = "auto", value_parser = ["read-only", "workspace-write", "danger-full-access", "auto"])] + sandbox: String, + /// Model reasoning effort level. + #[arg(long, default_value = "high", value_parser = ["low", "medium", "high"])] + effort: String, + }, + /// Adversarial review -- tries to break the code. + Adversarial { + /// Base branch for diff. + #[arg(long, default_value = "main")] + base: String, + /// Specific area to pressure-test. + #[arg(long)] + focus: Option, + /// Sandbox mode. + #[arg(long, default_value = "auto")] + sandbox: String, + /// Model reasoning effort level. + #[arg(long, default_value = "high", value_parser = ["low", "medium", "high"])] + effort: String, + }, + /// Epic completion review. + CompletionReview { + /// Epic ID. + epic: String, + /// Base branch for diff. + #[arg(long, default_value = "main")] + base: String, + /// Receipt file path. + #[arg(long)] + receipt: Option, + /// Sandbox mode. + #[arg(long, default_value = "auto", value_parser = ["read-only", "workspace-write", "danger-full-access", "auto"])] + sandbox: String, + /// Model reasoning effort level. + #[arg(long, default_value = "high", value_parser = ["low", "medium", "high"])] + effort: String, + }, +} + +// ── Helpers ───────────────────────────────────────────────────────── + +/// Locate `codex` in PATH, returning the full path or None. +fn find_codex() -> Option { + which::which("codex").ok().map(|p| p.to_string_lossy().to_string()) +} + +/// Get codex version string (e.g. "0.1.2") or None. +fn get_codex_version() -> Option { + let codex = find_codex()?; + let output = Command::new(&codex) + .arg("--version") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let re = Regex::new(r"(\d+\.\d+\.\d+)").unwrap(); + re.captures(text.trim()) + .map(|c| c[1].to_string()) + .or_else(|| Some(text.trim().to_string())) +} + +/// Resolve sandbox mode: CLI flag > CODEX_SANDBOX env > platform default. +/// Never returns "auto". +fn resolve_sandbox(sandbox: &str) -> String { + let s = sandbox.trim(); + + // Explicit non-auto value from CLI + if !s.is_empty() && s != "auto" { + return s.to_string(); + } + + // Check CODEX_SANDBOX env var + if let Ok(env_val) = env::var("CODEX_SANDBOX") { + let ev = env_val.trim().to_string(); + if !ev.is_empty() && ev != "auto" { + return ev; + } + } + + // Platform default + if cfg!(windows) { + "danger-full-access".to_string() + } else { + "read-only".to_string() + } +} + +/// Run `codex exec` with the given prompt (passed via stdin). +/// Returns (stdout, thread_id, exit_code, stderr). +fn run_codex_exec( + prompt: &str, + session_id: Option<&str>, + sandbox: &str, + effort: &str, +) -> (String, Option, i32, String) { + let codex = match find_codex() { + Some(c) => c, + None => return (String::new(), None, 2, "codex not found in PATH".to_string()), + }; + + let timeout_secs: u64 = env::var("FLOW_CODEX_TIMEOUT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(600); + + let model = env::var("FLOW_CODEX_MODEL").unwrap_or_else(|_| "gpt-5.4".to_string()); + + // Try resume if session_id is provided + if let Some(sid) = session_id { + let result = Command::new(&codex) + .args(["exec", "resume", sid, "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + + if let Ok(mut child) = result { + use std::io::Write; + if let Some(ref mut stdin) = child.stdin { + let _ = stdin.write_all(prompt.as_bytes()); + } + // Drop stdin to close it + drop(child.stdin.take()); + + match child.wait_with_output() { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + return (stdout, Some(sid.to_string()), 0, stderr); + } + _ => { + eprintln!("WARNING: Codex resume failed, starting new session"); + } + } + } + } + + // New session + let effort_config = format!("model_reasoning_effort=\"{}\"", effort); + let mut cmd = Command::new(&codex); + cmd.args([ + "exec", + "--model", &model, + "-c", &effort_config, + "--sandbox", sandbox, + "--skip-git-repo-check", + "--json", + "-", + ]); + cmd.stdin(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let result = cmd.spawn(); + match result { + Ok(mut child) => { + use std::io::Write; + if let Some(ref mut stdin) = child.stdin { + let _ = stdin.write_all(prompt.as_bytes()); + } + drop(child.stdin.take()); + + // Wait with timeout + let _timeout = std::time::Duration::from_secs(timeout_secs); + match child.wait_with_output() { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let code = output.status.code().unwrap_or(1); + let thread_id = parse_thread_id(&stdout); + (stdout, thread_id, code, stderr) + } + Err(e) => (String::new(), None, 2, format!("codex exec error: {e}")), + } + } + Err(e) => (String::new(), None, 2, format!("failed to spawn codex: {e}")), + } +} + +/// Extract thread_id from codex --json JSONL output. +fn parse_thread_id(output: &str) -> Option { + for line in output.lines() { + if let Ok(data) = serde_json::from_str::(line) { + if data.get("type").and_then(|v| v.as_str()) == Some("thread.started") { + if let Some(tid) = data.get("thread_id").and_then(|v| v.as_str()) { + return Some(tid.to_string()); + } + } + } + } + None +} + +/// Extract verdict from codex output: SHIP etc. +fn parse_verdict(output: &str) -> Option { + let re = Regex::new(r"(SHIP|NEEDS_WORK|MAJOR_RETHINK)").unwrap(); + re.captures(output).map(|c| c[1].to_string()) +} + +/// Load receipt session_id for re-review continuity. +fn load_receipt(path: Option<&str>) -> (Option, bool) { + let path = match path { + Some(p) if !p.is_empty() => p, + _ => return (None, false), + }; + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return (None, false), + }; + match serde_json::from_str::(&content) { + Ok(data) => { + let sid = data.get("session_id").and_then(|v| v.as_str()).map(|s| s.to_string()); + let is_rereview = sid.is_some(); + (sid, is_rereview) + } + Err(_) => (None, false), + } +} + +/// Save receipt JSON for ralph-compatible review tracking. +fn save_receipt( + path: &str, + review_type: &str, + review_id: &str, + verdict: &str, + session_id: Option<&str>, + output: &str, + base_branch: Option<&str>, + focus: Option<&str>, +) { + let mut data = json!({ + "type": review_type, + "id": review_id, + "mode": "codex", + "verdict": verdict, + "session_id": session_id, + "timestamp": chrono::Utc::now().to_rfc3339(), + "review": output, + }); + if let Some(base) = base_branch { + data["base"] = json!(base); + } + if let Some(f) = focus { + data["focus"] = json!(f); + } + if let Ok(iter_str) = env::var("RALPH_ITERATION") { + if let Ok(iter) = iter_str.parse::() { + data["iteration"] = json!(iter); + } + } + let content = serde_json::to_string_pretty(&data).unwrap_or_default(); + let _ = std::fs::write(path, format!("{content}\n")); +} + +/// Delete a stale receipt on failure. +fn delete_stale_receipt(path: Option<&str>) { + if let Some(p) = path { + let _ = std::fs::remove_file(p); + } +} + +// ── Dispatch ──────────────────────────────────────────────────────── + +pub fn dispatch(cmd: &CodexCmd, json: bool) { + match cmd { + CodexCmd::Check => cmd_check(json), + CodexCmd::ImplReview { + task, base, focus, receipt, sandbox, effort, + } => cmd_impl_review(json, task.as_deref(), base, focus.as_deref(), receipt.as_deref(), sandbox, effort), + CodexCmd::PlanReview { + epic, files, base, receipt, sandbox, effort, + } => cmd_plan_review(json, epic, files, base, receipt.as_deref(), sandbox, effort), + CodexCmd::Adversarial { + base, focus, sandbox, effort, + } => cmd_adversarial(json, base, focus.as_deref(), sandbox, effort), + CodexCmd::CompletionReview { + epic, base, receipt, sandbox, effort, + } => cmd_completion_review(json, epic, base, receipt.as_deref(), sandbox, effort), + } +} + +// ── Command implementations ───────────────────────────────────────── + +fn cmd_check(json_mode: bool) { + let available = find_codex().is_some(); + let version = if available { get_codex_version() } else { None }; + + if json_mode { + json_output(json!({ + "available": available, + "version": version, + })); + } else if available { + println!("codex available: {}", version.unwrap_or_else(|| "unknown version".to_string())); + } else { + println!("codex not available"); + } +} + +fn cmd_impl_review( + json_mode: bool, + task: Option<&str>, + base: &str, + focus: Option<&str>, + receipt: Option<&str>, + sandbox: &str, + effort: &str, +) { + let standalone = task.is_none(); + let sandbox = resolve_sandbox(sandbox); + + // Load receipt for re-review continuity + let (session_id, _is_rereview) = load_receipt(receipt); + + // Build a minimal prompt — the real prompt is built by the skill layer, + // but we support direct invocation with a simple diff-based prompt. + let prompt = format!( + "Review the implementation changes from branch '{}' against HEAD.\n\ + {}{}Focus on correctness, quality, performance, and testing.\n\ + Output your verdict as SHIP or NEEDS_WORK.", + base, + if let Some(t) = task { format!("Task: {t}\n") } else { String::new() }, + if let Some(f) = focus { format!("Focus areas: {f}\n") } else { String::new() }, + ); + + let (output, thread_id, exit_code, stderr) = + run_codex_exec(&prompt, session_id.as_deref(), &sandbox, effort); + + if exit_code != 0 { + delete_stale_receipt(receipt); + let msg = if !stderr.is_empty() { &stderr } else if !output.is_empty() { &output } else { "codex exec failed" }; + error_exit(&format!("codex exec failed: {}", msg.trim())); + } + + let verdict = parse_verdict(&output); + if verdict.is_none() { + delete_stale_receipt(receipt); + error_exit("Codex review completed but no verdict found in output. Expected SHIP or NEEDS_WORK"); + } + let verdict = verdict.unwrap(); + + let review_id = task.unwrap_or("branch"); + + if let Some(rp) = receipt { + save_receipt(rp, "impl_review", review_id, &verdict, thread_id.as_deref(), &output, Some(base), focus); + } + + if json_mode { + json_output(json!({ + "type": "impl_review", + "id": review_id, + "verdict": verdict, + "session_id": thread_id, + "mode": "codex", + "standalone": standalone, + "review": output, + })); + } else { + print!("{output}"); + println!("\nVERDICT={verdict}"); + } +} + +fn cmd_plan_review( + json_mode: bool, + epic: &str, + files: &str, + base: &str, + receipt: Option<&str>, + sandbox: &str, + effort: &str, +) { + if files.is_empty() { + error_exit("plan-review requires --files argument (comma-separated CODE file paths)"); + } + + let sandbox = resolve_sandbox(sandbox); + let (session_id, _is_rereview) = load_receipt(receipt); + + let prompt = format!( + "Review the plan for epic '{}' with context from files: {}.\n\ + Base branch: {base}.\n\ + Output your verdict as SHIP or NEEDS_WORK.", + epic, files, + ); + + let (output, thread_id, exit_code, stderr) = + run_codex_exec(&prompt, session_id.as_deref(), &sandbox, effort); + + if exit_code != 0 { + delete_stale_receipt(receipt); + let msg = if !stderr.is_empty() { &stderr } else if !output.is_empty() { &output } else { "codex exec failed" }; + error_exit(&format!("codex exec failed: {}", msg.trim())); + } + + let verdict = parse_verdict(&output); + if verdict.is_none() { + delete_stale_receipt(receipt); + error_exit("Codex review completed but no verdict found in output. Expected SHIP or NEEDS_WORK"); + } + let verdict = verdict.unwrap(); + + if let Some(rp) = receipt { + save_receipt(rp, "plan_review", epic, &verdict, thread_id.as_deref(), &output, None, None); + } + + if json_mode { + json_output(json!({ + "type": "plan_review", + "id": epic, + "verdict": verdict, + "session_id": thread_id, + "mode": "codex", + "review": output, + })); + } else { + print!("{output}"); + println!("\nVERDICT={verdict}"); + } +} + +fn cmd_adversarial( + json_mode: bool, + base: &str, + focus: Option<&str>, + sandbox: &str, + effort: &str, +) { + let sandbox = resolve_sandbox(sandbox); + + let prompt = format!( + "You are an adversarial code reviewer. Try to BREAK the code changed between '{base}' and HEAD.\n\ + {}Look for bugs, race conditions, security vulnerabilities, edge cases, and logic errors.\n\ + Output your verdict as SHIP or NEEDS_WORK.\n\ + Also output structured JSON with your findings.", + if let Some(f) = focus { format!("Focus area: {f}\n") } else { String::new() }, + ); + + let (output, _thread_id, exit_code, stderr) = + run_codex_exec(&prompt, None, &sandbox, effort); + + if exit_code != 0 { + let msg = if !stderr.is_empty() { &stderr } else if !output.is_empty() { &output } else { "codex exec failed" }; + error_exit(&format!("Adversarial review failed: {}", msg.trim())); + } + + // Try to parse structured JSON output + let structured = parse_adversarial_output(&output); + + if json_mode { + if let Some(mut s) = structured { + s["base"] = json!(base); + s["focus"] = json!(focus); + json_output(s); + } else { + let verdict = parse_verdict(&output); + json_output(json!({ + "verdict": verdict.unwrap_or_else(|| "UNKNOWN".to_string()), + "output": output, + "base": base, + "focus": focus, + })); + } + } else if let Some(s) = structured { + println!("{}", serde_json::to_string_pretty(&s).unwrap_or_default()); + println!("\nVerdict: {}", s.get("verdict").and_then(|v| v.as_str()).unwrap_or("UNKNOWN")); + } else { + print!("{output}"); + if let Some(v) = parse_verdict(&output) { + println!("\nVerdict: {v}"); + } + } +} + +fn cmd_completion_review( + json_mode: bool, + epic: &str, + base: &str, + receipt: Option<&str>, + sandbox: &str, + effort: &str, +) { + let sandbox = resolve_sandbox(sandbox); + let (session_id, _is_rereview) = load_receipt(receipt); + + let prompt = format!( + "Review epic '{}' for completion. Verify all requirements are implemented.\n\ + Base branch: {base}.\n\ + Output your verdict as SHIP or NEEDS_WORK.", + epic, + ); + + let (output, thread_id, exit_code, stderr) = + run_codex_exec(&prompt, session_id.as_deref(), &sandbox, effort); + + if exit_code != 0 { + delete_stale_receipt(receipt); + let msg = if !stderr.is_empty() { &stderr } else if !output.is_empty() { &output } else { "codex exec failed" }; + error_exit(&format!("codex exec failed: {}", msg.trim())); + } + + let verdict = parse_verdict(&output); + if verdict.is_none() { + delete_stale_receipt(receipt); + error_exit("Codex review completed but no verdict found in output. Expected SHIP or NEEDS_WORK"); + } + let verdict = verdict.unwrap(); + + let session_to_write = thread_id.as_deref().or(session_id.as_deref()); + + if let Some(rp) = receipt { + save_receipt(rp, "completion_review", epic, &verdict, session_to_write, &output, Some(base), None); + } + + if json_mode { + json_output(json!({ + "type": "completion_review", + "id": epic, + "base": base, + "verdict": verdict, + "session_id": session_to_write, + "mode": "codex", + "review": output, + })); + } else { + print!("{output}"); + println!("\nVERDICT={verdict}"); + } +} + +/// Parse structured JSON from adversarial review output. +/// Handles direct JSON, JSONL streaming, markdown fences, embedded JSON. +fn parse_adversarial_output(output: &str) -> Option { + // Strategy 1: Direct JSON parse + if let Ok(data) = serde_json::from_str::(output.trim()) { + if data.is_object() && data.get("verdict").is_some() { + return Some(data); + } + } + + // Strategy 2: JSONL streaming events (codex exec --json) + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { continue; } + if let Ok(event) = serde_json::from_str::(line) { + if event.get("type").and_then(|v| v.as_str()) == Some("item.completed") { + if let Some(item) = event.get("item") { + if item.get("type").and_then(|v| v.as_str()) == Some("agent_message") { + if let Some(text) = item.get("text").and_then(|v| v.as_str()) { + if let Ok(data) = serde_json::from_str::(text) { + if data.is_object() && data.get("verdict").is_some() { + return Some(data); + } + } + } + } + } + } + } + } + + // Strategy 3: Markdown fences + let fence_re = Regex::new(r"```(?:json)?\s*\n?(.*?)\n?```").unwrap(); + if let Some(caps) = fence_re.captures(output) { + if let Ok(data) = serde_json::from_str::(caps[1].trim()) { + if data.is_object() && data.get("verdict").is_some() { + return Some(data); + } + } + } + + // Strategy 4: Greedy brace match + let brace_re = Regex::new(r#"\{[^{}]*"verdict"[^{}]*\}"#).unwrap(); + if let Some(m) = brace_re.find(output) { + if let Ok(data) = serde_json::from_str::(m.as_str()) { + if data.is_object() && data.get("verdict").is_some() { + return Some(data); + } + } + } + + None +} diff --git a/flowctl/crates/flowctl-cli/src/commands/dep.rs b/flowctl/crates/flowctl-cli/src/commands/dep.rs new file mode 100644 index 00000000..f5785a7d --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/dep.rs @@ -0,0 +1,187 @@ +//! Dependency commands: dep add, dep rm. +//! +//! Updates both the Markdown frontmatter (canonical) and SQLite (cache). + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use clap::Subcommand; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +use flowctl_core::frontmatter; +use flowctl_core::id::{epic_id_from_task, is_task_id}; +use flowctl_core::types::{Task, FLOW_DIR, TASKS_DIR}; + +#[derive(Subcommand, Debug)] +pub enum DepCmd { + /// Add a dependency. + Add { + /// Task ID. + task: String, + /// Dependency task ID. + depends_on: String, + }, + /// Remove a dependency. + Rm { + /// Task ID. + task: String, + /// Dependency to remove. + depends_on: String, + }, +} + +fn get_flow_dir() -> PathBuf { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(FLOW_DIR) +} + +fn ensure_flow_exists() -> PathBuf { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + flow_dir +} + +/// Read a task's Markdown document (frontmatter + body). +fn read_task_doc(flow_dir: &Path, task_id: &str) -> (PathBuf, frontmatter::Document) { + let task_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id)); + if !task_path.exists() { + error_exit(&format!("Task not found: {}", task_id)); + } + let content = fs::read_to_string(&task_path) + .unwrap_or_else(|e| error_exit(&format!("Cannot read {}: {}", task_path.display(), e))); + let doc: frontmatter::Document = frontmatter::parse(&content) + .unwrap_or_else(|e| error_exit(&format!("Cannot parse {}: {}", task_path.display(), e))); + (task_path, doc) +} + +/// Write a task's Markdown document back to disk. +fn write_task_doc(path: &Path, doc: &frontmatter::Document) { + let content = frontmatter::write(doc) + .unwrap_or_else(|e| error_exit(&format!("Cannot serialize task: {}", e))); + fs::write(path, content) + .unwrap_or_else(|e| error_exit(&format!("Cannot write {}: {}", path.display(), e))); +} + +/// Update the SQLite cache for a task's dependencies (best-effort). +fn sync_deps_to_db(task_id: &str, deps: &[String]) { + let cwd = match env::current_dir() { + Ok(c) => c, + Err(_) => return, + }; + let conn = match flowctl_db::open(&cwd) { + Ok(c) => c, + Err(_) => return, + }; + // Delete existing deps, re-insert + let _ = conn.execute("DELETE FROM task_deps WHERE task_id = ?1", rusqlite::params![task_id]); + for dep in deps { + let _ = conn.execute( + "INSERT INTO task_deps (task_id, depends_on) VALUES (?1, ?2)", + rusqlite::params![task_id, dep], + ); + } +} + +pub fn dispatch(cmd: &DepCmd, json: bool) { + match cmd { + DepCmd::Add { task, depends_on } => cmd_dep_add(json, task, depends_on), + DepCmd::Rm { task, depends_on } => cmd_dep_rm(json, task, depends_on), + } +} + +fn cmd_dep_add(json: bool, task_id: &str, depends_on: &str) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M", + task_id + )); + } + if !is_task_id(depends_on) { + error_exit(&format!( + "Invalid dependency ID: {}. Expected format: fn-N.M or fn-N-slug.M", + depends_on + )); + } + + // Validate same epic + let task_epic = epic_id_from_task(task_id) + .unwrap_or_else(|_| error_exit(&format!("Cannot parse epic from task ID: {}", task_id))); + let dep_epic = epic_id_from_task(depends_on) + .unwrap_or_else(|_| error_exit(&format!("Cannot parse epic from dep ID: {}", depends_on))); + if task_epic != dep_epic { + error_exit(&format!( + "Dependencies must be within the same epic. Task {} is in {}, dependency {} is in {}", + task_id, task_epic, depends_on, dep_epic + )); + } + + let (task_path, mut doc) = read_task_doc(&flow_dir, task_id); + + if !doc.frontmatter.depends_on.contains(&depends_on.to_string()) { + doc.frontmatter.depends_on.push(depends_on.to_string()); + doc.frontmatter.updated_at = Utc::now(); + write_task_doc(&task_path, &doc); + sync_deps_to_db(task_id, &doc.frontmatter.depends_on); + } + + if json { + json_output(json!({ + "task": task_id, + "depends_on": doc.frontmatter.depends_on, + "message": format!("Dependency {} added to {}", depends_on, task_id), + })); + } else { + println!("Dependency {} added to {}", depends_on, task_id); + } +} + +fn cmd_dep_rm(json: bool, task_id: &str, depends_on: &str) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!("Invalid task ID: {}", task_id)); + } + if !is_task_id(depends_on) { + error_exit(&format!("Invalid dependency ID: {}", depends_on)); + } + + let (task_path, mut doc) = read_task_doc(&flow_dir, task_id); + + if let Some(pos) = doc.frontmatter.depends_on.iter().position(|d| d == depends_on) { + doc.frontmatter.depends_on.remove(pos); + doc.frontmatter.updated_at = Utc::now(); + write_task_doc(&task_path, &doc); + sync_deps_to_db(task_id, &doc.frontmatter.depends_on); + + if json { + json_output(json!({ + "task": task_id, + "depends_on": doc.frontmatter.depends_on, + "removed": true, + "message": format!("Dependency {} removed from {}", depends_on, task_id), + })); + } else { + println!("Dependency {} removed from {}", depends_on, task_id); + } + } else { + if json { + json_output(json!({ + "task": task_id, + "depends_on": doc.frontmatter.depends_on, + "removed": false, + "message": format!("{} not in dependencies", depends_on), + })); + } else { + println!("{} is not a dependency of {}", depends_on, task_id); + } + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/epic.rs b/flowctl/crates/flowctl-cli/src/commands/epic.rs new file mode 100644 index 00000000..46dc8422 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/epic.rs @@ -0,0 +1,1306 @@ +//! Epic management commands. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use clap::Subcommand; +use regex::Regex; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +use flowctl_core::frontmatter; +use flowctl_core::id::{generate_epic_suffix, is_epic_id, is_task_id, parse_id, slugify}; +use flowctl_core::types::{ + Epic, EpicStatus, ReviewStatus, Task, ARCHIVE_DIR, EPICS_DIR, FLOW_DIR, META_FILE, + REVIEWS_DIR, SPECS_DIR, TASKS_DIR, +}; + +#[derive(Subcommand, Debug)] +pub enum EpicCmd { + /// Create a new epic. + Create { + /// Epic title. + #[arg(long)] + title: String, + /// Branch name. + #[arg(long)] + branch: Option, + }, + /// Set epic spec from file. + SetPlan { + /// Epic ID. + id: String, + /// Markdown file (use '-' for stdin). + #[arg(long)] + file: String, + }, + /// Set plan review status. + SetPlanReviewStatus { + /// Epic ID. + id: String, + /// Review status. + #[arg(long, value_parser = ["ship", "needs_work", "unknown"])] + status: String, + }, + /// Set completion review status. + SetCompletionReviewStatus { + /// Epic ID. + id: String, + /// Review status. + #[arg(long, value_parser = ["ship", "needs_work", "unknown"])] + status: String, + }, + /// Set epic branch name. + SetBranch { + /// Epic ID. + id: String, + /// Branch name. + #[arg(long)] + branch: String, + }, + /// Rename epic by setting a new title. + SetTitle { + /// Epic ID. + id: String, + /// New title. + #[arg(long)] + title: String, + }, + /// Close an epic. + Close { + /// Epic ID. + id: String, + /// Bypass gap registry gate. + #[arg(long)] + skip_gap_check: bool, + }, + /// Reopen a closed epic. + Reopen { + /// Epic ID. + id: String, + }, + /// Archive closed epic to .flow/.archive/. + Archive { + /// Epic ID. + id: String, + /// Archive even if not closed. + #[arg(long)] + force: bool, + }, + /// Archive all closed epics at once. + Clean, + /// Add epic-level dependency. + AddDep { + /// Epic ID. + epic: String, + /// Epic ID to depend on. + depends_on: String, + }, + /// Remove epic-level dependency. + RmDep { + /// Epic ID. + epic: String, + /// Epic ID to remove from deps. + depends_on: String, + }, + /// Set default backend specs. + SetBackend { + /// Epic ID. + id: String, + /// Default impl backend spec. + #[arg(long = "impl")] + impl_spec: Option, + /// Default review backend spec. + #[arg(long)] + review: Option, + /// Default sync backend spec. + #[arg(long)] + sync: Option, + }, + /// Set or clear auto_execute_pending marker. + SetAutoExecute { + /// Epic ID. + id: String, + /// Mark auto-execute as pending. + #[arg(long)] + pending: bool, + /// Clear auto-execute pending marker. + #[arg(long)] + done: bool, + }, +} + +// ── Helpers ───────────────────────────────────────────────────────── + +/// Get the .flow/ directory path. +fn get_flow_dir() -> PathBuf { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(FLOW_DIR) +} + +/// Ensure .flow/ exists, error_exit if not. +fn ensure_flow_exists() -> PathBuf { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + flow_dir +} + +/// Validate an epic ID, error_exit if invalid. +fn validate_epic_id(id: &str) { + if !is_epic_id(id) { + error_exit(&format!( + "Invalid epic ID: {id}. Expected format: fn-N or fn-N-slug (e.g., fn-1, fn-1-add-auth)" + )); + } +} + +/// Load epic from Markdown frontmatter file, error_exit if not found or parse fails. +fn load_epic(epic_path: &Path, id: &str) -> frontmatter::Document { + if !epic_path.exists() { + error_exit(&format!("Epic {id} not found")); + } + let content = fs::read_to_string(epic_path) + .unwrap_or_else(|e| error_exit(&format!("Failed to read {}: {e}", epic_path.display()))); + frontmatter::parse::(&content) + .unwrap_or_else(|e| error_exit(&format!("Failed to parse epic {id}: {e}"))) +} + +/// Write an epic document back to its Markdown file. +fn save_epic(epic_path: &Path, doc: &frontmatter::Document) { + let content = frontmatter::write(doc) + .unwrap_or_else(|e| error_exit(&format!("Failed to serialize epic: {e}"))); + if let Some(parent) = epic_path.parent() { + let _ = fs::create_dir_all(parent); + } + fs::write(epic_path, &content) + .unwrap_or_else(|e| error_exit(&format!("Failed to write {}: {e}", epic_path.display()))); +} + +/// Try to open DB connection for SQLite dual-write. +fn try_open_db() -> Option { + let cwd = env::current_dir().ok()?; + flowctl_db::open(&cwd).ok() +} + +/// Upsert epic into SQLite if DB is available. +fn db_upsert_epic(epic: &Epic) { + if let Some(conn) = try_open_db() { + let repo = flowctl_db::EpicRepo::new(&conn); + let _ = repo.upsert(epic); + } +} + +/// Scan .flow/epics/ and .flow/specs/ to find max epic number. +/// Returns 0 if none exist. +fn scan_max_epic_id(flow_dir: &Path) -> u32 { + let pattern = Regex::new(r"^fn-(\d+)(?:-[a-z0-9][a-z0-9-]*[a-z0-9]|-[a-z0-9]{1,3})?\.(md|json)$") + .expect("valid regex"); + + let mut max_n: u32 = 0; + + // Scan epics/*.md + let epics_dir = flow_dir.join(EPICS_DIR); + if epics_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&epics_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if let Some(caps) = pattern.captures(&name_str) { + if let Ok(n) = caps[1].parse::() { + max_n = max_n.max(n); + } + } + } + } + } + + // Scan specs/*.md as safety net + let specs_dir = flow_dir.join(SPECS_DIR); + if specs_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&specs_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if let Some(caps) = pattern.captures(&name_str) { + if let Ok(n) = caps[1].parse::() { + max_n = max_n.max(n); + } + } + } + } + } + + max_n +} + +/// Create default epic spec Markdown body. +fn create_epic_spec_body(id: &str, title: &str) -> String { + format!( + "# {id} {title}\n\n\ + ## Overview\nTBD\n\n\ + ## Scope\nTBD\n\n\ + ## Approach\nTBD\n\n\ + ## Quick commands\n\ + \n\ + - `# e.g., npm test, bun test, make test`\n\n\ + ## Acceptance\n\ + - [ ] TBD\n\n\ + ## References\n\ + - TBD\n" + ) +} + +/// Read content from file path or stdin (if path is "-"). +fn read_file_or_stdin(file: &str) -> String { + if file == "-" { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .unwrap_or_else(|e| error_exit(&format!("Failed to read stdin: {e}"))); + buf + } else { + let path = Path::new(file); + if !path.exists() { + error_exit(&format!("Input file not found: {file}")); + } + fs::read_to_string(path) + .unwrap_or_else(|e| error_exit(&format!("Failed to read {file}: {e}"))) + } +} + +/// Gap-blocking priorities (matches Python's GAP_BLOCKING_PRIORITIES). +const GAP_BLOCKING_PRIORITIES: &[&str] = &["required", "important"]; + +/// Load epic as raw JSON Value from frontmatter (for gap checks and extra fields). +/// Falls back to trying JSON format for legacy compatibility. +fn load_epic_raw(epic_path: &Path, id: &str) -> serde_json::Value { + if !epic_path.exists() { + error_exit(&format!("Epic {id} not found")); + } + let content = fs::read_to_string(epic_path) + .unwrap_or_else(|e| error_exit(&format!("Failed to read {}: {e}", epic_path.display()))); + + // Try frontmatter parse first + if content.trim_start().starts_with("---") { + match frontmatter::parse::(&content) { + Ok(doc) => return doc.frontmatter, + Err(_) => {} + } + } + + // Fall back to raw JSON + serde_json::from_str(&content) + .unwrap_or_else(|e| error_exit(&format!("Failed to parse epic {id}: {e}"))) +} + +// ── Command implementations ───────────────────────────────────────── + +fn cmd_create(title: &str, branch: &Option, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + + // Verify meta.json exists + let meta_path = flow_dir.join(META_FILE); + if !meta_path.exists() { + error_exit("meta.json not found. Run 'flowctl init' first."); + } + + // Scan-based ID allocation + let max_epic = scan_max_epic_id(&flow_dir); + let epic_num = max_epic + 1; + let slug = slugify(title, 40); + let suffix = slug.unwrap_or_else(|| generate_epic_suffix(3)); + let epic_id = format!("fn-{epic_num}-{suffix}"); + + // Collision check + let epic_md_path = flow_dir.join(EPICS_DIR).join(format!("{epic_id}.md")); + let spec_path = flow_dir.join(SPECS_DIR).join(format!("{epic_id}.md")); + if epic_md_path.exists() || spec_path.exists() { + error_exit(&format!( + "Refusing to overwrite existing epic {epic_id}. \ + This shouldn't happen - check for orphaned files." + )); + } + + let now = Utc::now(); + let branch_name = branch.clone().unwrap_or_else(|| epic_id.clone()); + + let epic = Epic { + schema_version: 1, + id: epic_id.clone(), + title: title.to_string(), + status: EpicStatus::Open, + branch_name: Some(branch_name), + plan_review: ReviewStatus::Unknown, + completion_review: ReviewStatus::Unknown, + depends_on_epics: vec![], + default_impl: None, + default_review: None, + default_sync: None, + file_path: Some(format!("epics/{epic_id}.md")), + created_at: now, + updated_at: now, + }; + + // Write epic Markdown + let body = create_epic_spec_body(&epic_id, title); + let doc = frontmatter::Document { + frontmatter: epic.clone(), + body: body.clone(), + }; + save_epic(&epic_md_path, &doc); + + // Write spec file (separate body-only file in specs/) + if let Some(parent) = spec_path.parent() { + let _ = fs::create_dir_all(parent); + } + fs::write(&spec_path, &body) + .unwrap_or_else(|e| error_exit(&format!("Failed to write spec: {e}"))); + + // SQLite dual-write + db_upsert_epic(&epic); + + if json_mode { + json_output(json!({ + "id": epic_id, + "title": title, + "spec_path": format!("{FLOW_DIR}/{SPECS_DIR}/{epic_id}.md"), + "message": format!("Epic {epic_id} created"), + })); + } else { + println!("Epic {epic_id} created: {title}"); + } +} + +fn cmd_set_plan(id: &str, file: &str, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(id); + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md")); + let mut doc = load_epic(&epic_path, id); + + // Read content from file or stdin + let content = read_file_or_stdin(file); + + // Validate: reject duplicate headings + let heading_re = Regex::new(r"(?m)^(##\s+.+?)\s*$").expect("valid regex"); + let mut seen = std::collections::HashMap::new(); + for cap in heading_re.captures_iter(&content) { + let h = cap[1].to_string(); + *seen.entry(h).or_insert(0u32) += 1; + } + let duplicates: Vec = seen + .iter() + .filter(|(_, &count)| count > 1) + .map(|(h, count)| format!("Duplicate heading: {h} (found {count} times)")) + .collect(); + if !duplicates.is_empty() { + error_exit(&format!("Spec validation failed: {}", duplicates.join("; "))); + } + + // Write spec + let spec_path = flow_dir.join(SPECS_DIR).join(format!("{id}.md")); + fs::write(&spec_path, &content) + .unwrap_or_else(|e| error_exit(&format!("Failed to write spec: {e}"))); + + // Update epic timestamp + doc.frontmatter.updated_at = Utc::now(); + save_epic(&epic_path, &doc); + db_upsert_epic(&doc.frontmatter); + + if json_mode { + json_output(json!({ + "id": id, + "spec_path": spec_path.to_string_lossy(), + "message": format!("Epic {id} spec updated"), + })); + } else { + println!("Epic {id} spec updated"); + } +} + +fn cmd_set_plan_review_status(id: &str, status: &str, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(id); + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md")); + let mut doc = load_epic(&epic_path, id); + + let review_status = match status { + "ship" => ReviewStatus::Passed, + "needs_work" => ReviewStatus::Failed, + _ => ReviewStatus::Unknown, + }; + + doc.frontmatter.plan_review = review_status; + doc.frontmatter.updated_at = Utc::now(); + save_epic(&epic_path, &doc); + db_upsert_epic(&doc.frontmatter); + + if json_mode { + json_output(json!({ + "id": id, + "plan_review_status": status, + "plan_reviewed_at": Utc::now().to_rfc3339(), + "message": format!("Epic {id} plan review status set to {status}"), + })); + } else { + println!("Epic {id} plan review status set to {status}"); + } +} + +fn cmd_set_completion_review_status(id: &str, status: &str, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(id); + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md")); + let mut doc = load_epic(&epic_path, id); + + let review_status = match status { + "ship" => ReviewStatus::Passed, + "needs_work" => ReviewStatus::Failed, + _ => ReviewStatus::Unknown, + }; + + doc.frontmatter.completion_review = review_status; + doc.frontmatter.updated_at = Utc::now(); + save_epic(&epic_path, &doc); + db_upsert_epic(&doc.frontmatter); + + if json_mode { + json_output(json!({ + "id": id, + "completion_review_status": status, + "completion_reviewed_at": Utc::now().to_rfc3339(), + "message": format!("Epic {id} completion review status set to {status}"), + })); + } else { + println!("Epic {id} completion review status set to {status}"); + } +} + +fn cmd_set_branch(id: &str, branch: &str, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(id); + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md")); + let mut doc = load_epic(&epic_path, id); + + doc.frontmatter.branch_name = Some(branch.to_string()); + doc.frontmatter.updated_at = Utc::now(); + save_epic(&epic_path, &doc); + db_upsert_epic(&doc.frontmatter); + + if json_mode { + json_output(json!({ + "id": id, + "branch_name": branch, + "message": format!("Epic {id} branch_name set to {branch}"), + })); + } else { + println!("Epic {id} branch_name set to {branch}"); + } +} + +fn cmd_set_title(id: &str, new_title: &str, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(id); + + let old_id = id; + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{old_id}.md")); + let doc = load_epic(&epic_path, old_id); + + // Extract epic number + let parsed = parse_id(old_id) + .unwrap_or_else(|_| error_exit(&format!("Could not parse epic number from {old_id}"))); + let epic_num = parsed.epic; + + // Generate new ID + let new_slug = slugify(new_title, 40); + let new_suffix = new_slug.unwrap_or_else(|| generate_epic_suffix(3)); + let new_id = format!("fn-{epic_num}-{new_suffix}"); + + let epics_dir = flow_dir.join(EPICS_DIR); + let specs_dir = flow_dir.join(SPECS_DIR); + let tasks_dir = flow_dir.join(TASKS_DIR); + + // Check collision (if ID changed) + if new_id != old_id { + let new_epic_path = epics_dir.join(format!("{new_id}.md")); + if new_epic_path.exists() { + error_exit(&format!( + "Epic {new_id} already exists. Choose a different title." + )); + } + } + + // Collect files to rename + let mut renames: Vec<(PathBuf, PathBuf)> = Vec::new(); + let mut task_renames: Vec<(String, String)> = Vec::new(); + + // Epic file + renames.push((epic_path.clone(), epics_dir.join(format!("{new_id}.md")))); + + // Spec file + let old_spec = specs_dir.join(format!("{old_id}.md")); + if old_spec.exists() { + renames.push((old_spec, specs_dir.join(format!("{new_id}.md")))); + } + + // Task files + if tasks_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&tasks_dir) { + let mut task_entries: Vec<_> = entries + .flatten() + .filter(|e| { + e.file_name() + .to_string_lossy() + .starts_with(&format!("{old_id}.")) + }) + .collect(); + task_entries.sort_by_key(|e| e.file_name()); + + for entry in task_entries { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + let path = entry.path(); + + if name_str.ends_with(".md") { + let stem = name_str.trim_end_matches(".md"); + if is_task_id(stem) { + if let Ok(p) = parse_id(stem) { + if let Some(task_num) = p.task { + let new_task_id = format!("{new_id}.{task_num}"); + let new_path = tasks_dir.join(format!("{new_task_id}.md")); + renames.push((path, new_path)); + // Track for content updates (avoid duplicates) + let old_task_id = stem.to_string(); + if !task_renames.iter().any(|(o, _)| *o == old_task_id) { + task_renames + .push((old_task_id, new_task_id)); + } + } + } + } + } + } + } + } + + // Checkpoint file + let old_checkpoint = flow_dir.join(format!(".checkpoint-{old_id}.json")); + if old_checkpoint.exists() { + renames.push(( + old_checkpoint, + flow_dir.join(format!(".checkpoint-{new_id}.json")), + )); + } + + // Perform renames + let mut rename_errors: Vec = Vec::new(); + for (old_path, new_path) in &renames { + if let Err(e) = fs::rename(old_path, new_path) { + rename_errors.push(format!( + "{} -> {}: {e}", + old_path.file_name().unwrap_or_default().to_string_lossy(), + new_path.file_name().unwrap_or_default().to_string_lossy() + )); + } + } + + if !rename_errors.is_empty() { + error_exit(&format!( + "Failed to rename some files: {}", + rename_errors.join("; ") + )); + } + + // Update epic content + let mut new_doc = doc; + new_doc.frontmatter.id = new_id.clone(); + new_doc.frontmatter.title = new_title.to_string(); + new_doc.frontmatter.file_path = Some(format!("epics/{new_id}.md")); + new_doc.frontmatter.updated_at = Utc::now(); + let new_epic_path = epics_dir.join(format!("{new_id}.md")); + save_epic(&new_epic_path, &new_doc); + db_upsert_epic(&new_doc.frontmatter); + + // Update task content + let task_id_map: std::collections::HashMap<&str, &str> = task_renames + .iter() + .map(|(o, n)| (o.as_str(), n.as_str())) + .collect(); + for (_old_task_id, new_task_id) in &task_renames { + let task_path = tasks_dir.join(format!("{new_task_id}.md")); + if task_path.exists() { + if let Ok(content) = fs::read_to_string(&task_path) { + if let Ok(mut task_doc) = frontmatter::parse::(&content) { + task_doc.frontmatter.id = new_task_id.clone(); + task_doc.frontmatter.epic = new_id.clone(); + task_doc.frontmatter.file_path = + Some(format!("tasks/{new_task_id}.md")); + // Update depends_on references within same epic + task_doc.frontmatter.depends_on = task_doc + .frontmatter + .depends_on + .iter() + .map(|dep| { + task_id_map + .get(dep.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| dep.clone()) + }) + .collect(); + task_doc.frontmatter.updated_at = Utc::now(); + if let Ok(serialized) = frontmatter::write(&task_doc) { + let _ = fs::write(&task_path, serialized); + } + // SQLite update + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.upsert(&task_doc.frontmatter); + } + } + } + } + } + + // Update depends_on_epics in other epics that reference old_id + let mut updated_deps_in: Vec = Vec::new(); + if epics_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&epics_dir) { + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str == format!("{new_id}.md") { + continue; + } + if !name_str.ends_with(".md") { + continue; + } + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(mut other_doc) = frontmatter::parse::(&content) { + if other_doc.frontmatter.depends_on_epics.contains(&old_id.to_string()) { + other_doc.frontmatter.depends_on_epics = other_doc + .frontmatter + .depends_on_epics + .iter() + .map(|d| { + if d == old_id { + new_id.clone() + } else { + d.clone() + } + }) + .collect(); + other_doc.frontmatter.updated_at = Utc::now(); + if let Ok(serialized) = frontmatter::write(&other_doc) { + let _ = fs::write(&path, serialized); + } + updated_deps_in.push(other_doc.frontmatter.id.clone()); + } + } + } + } + } + } + + let mut result = json!({ + "old_id": old_id, + "new_id": new_id, + "title": new_title, + "files_renamed": renames.len(), + "tasks_updated": task_renames.len(), + "message": format!("Epic renamed: {old_id} -> {new_id}"), + }); + if !updated_deps_in.is_empty() { + result["updated_deps_in"] = json!(updated_deps_in); + } + + if json_mode { + json_output(result); + } else { + println!("Epic renamed: {old_id} -> {new_id}"); + println!(" Title: {new_title}"); + println!(" Files renamed: {}", renames.len()); + println!(" Tasks updated: {}", task_renames.len()); + if !updated_deps_in.is_empty() { + println!(" Updated deps in: {}", updated_deps_in.join(", ")); + } + } +} + +fn cmd_close(id: &str, skip_gap_check: bool, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(id); + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md")); + let mut doc = load_epic(&epic_path, id); + + // Check all tasks are done/skipped + let tasks_dir = flow_dir.join(TASKS_DIR); + if !tasks_dir.is_dir() { + error_exit(&format!( + "{TASKS_DIR}/ missing. Run 'flowctl init' or fix repo state." + )); + } + + let mut incomplete: Vec = Vec::new(); + if let Ok(entries) = fs::read_dir(&tasks_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.starts_with(&format!("{id}.")) || !name_str.ends_with(".md") { + continue; + } + let stem = name_str.trim_end_matches(".md"); + if !is_task_id(stem) { + continue; + } + if let Ok(content) = fs::read_to_string(entry.path()) { + if let Ok(task) = frontmatter::parse_frontmatter::(&content) { + let status_str = task.status.to_string(); + if status_str != "done" && status_str != "skipped" { + incomplete.push(format!("{} ({status_str})", task.id)); + } + } + } + } + } + + if !incomplete.is_empty() { + error_exit(&format!( + "Cannot close epic: incomplete tasks - {}", + incomplete.join(", ") + )); + } + + // Gap registry gate -- check raw frontmatter for gaps field + let raw = load_epic_raw(&epic_path, id); + let gaps = raw.get("gaps").and_then(|g| g.as_array()); + let mut open_blocking_count = 0; + let mut gap_list_parts: Vec = Vec::new(); + + if let Some(gaps) = gaps { + for gap in gaps { + let status = gap.get("status").and_then(|s| s.as_str()).unwrap_or(""); + let priority = gap.get("priority").and_then(|s| s.as_str()).unwrap_or(""); + if status == "open" && GAP_BLOCKING_PRIORITIES.contains(&priority) { + open_blocking_count += 1; + let capability = gap + .get("capability") + .and_then(|s| s.as_str()) + .unwrap_or("unknown"); + gap_list_parts.push(format!("[{priority}] {capability}")); + } + } + } + + if open_blocking_count > 0 && !skip_gap_check { + error_exit(&format!( + "Cannot close epic: {open_blocking_count} unresolved blocking gap(s): {}. \ + Use --skip-gap-check to bypass.", + gap_list_parts.join(", ") + )); + } + if open_blocking_count > 0 && skip_gap_check && !json_mode { + eprintln!( + "WARNING: Bypassing {open_blocking_count} unresolved blocking gap(s)" + ); + } + + doc.frontmatter.status = EpicStatus::Done; + doc.frontmatter.updated_at = Utc::now(); + save_epic(&epic_path, &doc); + db_upsert_epic(&doc.frontmatter); + + if json_mode { + json_output(json!({ + "id": id, + "status": "done", + "message": format!("Epic {id} closed"), + "gaps_skipped": if skip_gap_check { open_blocking_count } else { 0 }, + "retro_suggested": true, + })); + } else { + println!("Epic {id} closed"); + println!( + "\n Tip: Run /flow-code:retro to capture lessons learned before archiving." + ); + } +} + +fn cmd_reopen(id: &str, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(id); + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md")); + + if !epic_path.exists() { + // Check archive + let archive_path = flow_dir.join(ARCHIVE_DIR).join(id); + if archive_path.exists() { + error_exit(&format!( + "Epic {id} is archived. Unarchive it first before reopening." + )); + } + error_exit(&format!("Epic {id} not found")); + } + + let mut doc = load_epic(&epic_path, id); + let previous_status = doc.frontmatter.status.to_string(); + + if doc.frontmatter.status == EpicStatus::Open { + error_exit(&format!( + "Epic {id} is already open (no-op protection)" + )); + } + + doc.frontmatter.status = EpicStatus::Open; + doc.frontmatter.completion_review = ReviewStatus::Unknown; + doc.frontmatter.plan_review = ReviewStatus::Unknown; + doc.frontmatter.updated_at = Utc::now(); + save_epic(&epic_path, &doc); + db_upsert_epic(&doc.frontmatter); + + if json_mode { + json_output(json!({ + "id": id, + "previous_status": previous_status, + "new_status": "open", + "message": format!("Epic {id} reopened"), + })); + } else { + println!("Epic {id} reopened (was: {previous_status})"); + } +} + +fn cmd_archive(id: &str, force: bool, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(id); + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md")); + let doc = load_epic(&epic_path, id); + + if doc.frontmatter.status != EpicStatus::Done && !force { + error_exit(&format!( + "Cannot archive epic {id}: status is '{}', not 'done'. \ + Close it first or use --force.", + doc.frontmatter.status + )); + } + + // Build archive directory + let archive_dir = flow_dir.join(ARCHIVE_DIR).join(id); + fs::create_dir_all(&archive_dir) + .unwrap_or_else(|e| error_exit(&format!("Failed to create archive dir: {e}"))); + + let mut moved: Vec = Vec::new(); + + // Move epic file + let dest = archive_dir.join(epic_path.file_name().unwrap()); + fs::rename(&epic_path, &dest) + .unwrap_or_else(|e| error_exit(&format!("Failed to move epic file: {e}"))); + moved.push(format!("epics/{}", epic_path.file_name().unwrap().to_string_lossy())); + + // Move spec + let spec_path = flow_dir.join(SPECS_DIR).join(format!("{id}.md")); + if spec_path.exists() { + let dest = archive_dir.join(spec_path.file_name().unwrap()); + let _ = fs::rename(&spec_path, &dest); + moved.push(format!("specs/{}", spec_path.file_name().unwrap().to_string_lossy())); + } + + // Move task files + let tasks_dir = flow_dir.join(TASKS_DIR); + if tasks_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&tasks_dir) { + let mut task_entries: Vec<_> = entries.flatten().collect(); + task_entries.sort_by_key(|e| e.file_name()); + for entry in task_entries { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(&format!("{id}.")) { + let dest = archive_dir.join(&*name); + let _ = fs::rename(entry.path(), &dest); + moved.push(format!("tasks/{name_str}")); + } + } + } + } + + // Move review receipts + let reviews_dir = flow_dir.join(REVIEWS_DIR); + if reviews_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&reviews_dir) { + let mut review_entries: Vec<_> = entries.flatten().collect(); + review_entries.sort_by_key(|e| e.file_name()); + for entry in review_entries { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.contains(&format!("-{id}.")) { + let dest = archive_dir.join(&*name); + let _ = fs::rename(entry.path(), &dest); + moved.push(format!("reviews/{name_str}")); + } + } + } + } + + if json_mode { + json_output(json!({ + "epic": id, + "archive_dir": archive_dir.to_string_lossy(), + "moved": moved, + "count": moved.len(), + })); + } else { + println!( + "Archived epic {id} ({} files) \u{2192} .flow/.archive/{id}/", + moved.len() + ); + for f in &moved { + println!(" {f}"); + } + } +} + +fn cmd_clean(json_mode: bool) { + let flow_dir = ensure_flow_exists(); + let epics_dir = flow_dir.join(EPICS_DIR); + + let mut archived: Vec = Vec::new(); + + if epics_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&epics_dir) { + let mut epic_entries: Vec<_> = entries.flatten().collect(); + epic_entries.sort_by_key(|e| e.file_name()); + + for entry in epic_entries { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.ends_with(".md") { + continue; + } + let stem = name_str.trim_end_matches(".md"); + if !is_epic_id(stem) { + continue; + } + if let Ok(content) = fs::read_to_string(entry.path()) { + if let Ok(epic) = frontmatter::parse_frontmatter::(&content) { + if epic.status == EpicStatus::Done { + let epic_id = epic.id.clone(); + // Archive silently + cmd_archive_silent(&epic_id, &flow_dir); + archived.push(epic_id); + } + } + } + } + } + } + + if json_mode { + json_output(json!({ + "archived": archived, + "count": archived.len(), + })); + } else if archived.is_empty() { + println!("No closed epics to archive."); + } else { + println!( + "Archived {} closed epic(s): {}", + archived.len(), + archived.join(", ") + ); + } +} + +/// Silent archive helper for clean command (no output). +fn cmd_archive_silent(id: &str, flow_dir: &Path) { + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md")); + if !epic_path.exists() { + return; + } + + let archive_dir = flow_dir.join(ARCHIVE_DIR).join(id); + let _ = fs::create_dir_all(&archive_dir); + + // Move epic + let _ = fs::rename(&epic_path, archive_dir.join(format!("{id}.md"))); + + // Move spec + let spec_path = flow_dir.join(SPECS_DIR).join(format!("{id}.md")); + if spec_path.exists() { + let _ = fs::rename(&spec_path, archive_dir.join(format!("{id}.md"))); + } + + // Move tasks + let tasks_dir = flow_dir.join(TASKS_DIR); + if tasks_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&tasks_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_string_lossy().starts_with(&format!("{id}.")) { + let _ = fs::rename(entry.path(), archive_dir.join(&name)); + } + } + } + } + + // Move reviews + let reviews_dir = flow_dir.join(REVIEWS_DIR); + if reviews_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&reviews_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_string_lossy().contains(&format!("-{id}.")) { + let _ = fs::rename(entry.path(), archive_dir.join(&name)); + } + } + } + } +} + +fn cmd_add_dep(epic_id: &str, dep_id: &str, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(epic_id); + validate_epic_id(dep_id); + + if epic_id == dep_id { + error_exit("Epic cannot depend on itself"); + } + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{epic_id}.md")); + let dep_path = flow_dir.join(EPICS_DIR).join(format!("{dep_id}.md")); + + if !dep_path.exists() { + error_exit(&format!("Epic {dep_id} not found")); + } + + let mut doc = load_epic(&epic_path, epic_id); + + if doc.frontmatter.depends_on_epics.contains(&dep_id.to_string()) { + if json_mode { + json_output(json!({ + "id": epic_id, + "depends_on_epics": doc.frontmatter.depends_on_epics, + "message": format!("{dep_id} already in dependencies"), + })); + } else { + println!("{dep_id} already in {epic_id} dependencies"); + } + return; + } + + doc.frontmatter.depends_on_epics.push(dep_id.to_string()); + doc.frontmatter.updated_at = Utc::now(); + save_epic(&epic_path, &doc); + db_upsert_epic(&doc.frontmatter); + + if json_mode { + json_output(json!({ + "id": epic_id, + "depends_on_epics": doc.frontmatter.depends_on_epics, + "message": format!("Added {dep_id} to {epic_id} dependencies"), + })); + } else { + println!("Added {dep_id} to {epic_id} dependencies"); + } +} + +fn cmd_rm_dep(epic_id: &str, dep_id: &str, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(epic_id); + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{epic_id}.md")); + let mut doc = load_epic(&epic_path, epic_id); + + if !doc.frontmatter.depends_on_epics.contains(&dep_id.to_string()) { + if json_mode { + json_output(json!({ + "id": epic_id, + "depends_on_epics": doc.frontmatter.depends_on_epics, + "message": format!("{dep_id} not in dependencies"), + })); + } else { + println!("{dep_id} not in {epic_id} dependencies"); + } + return; + } + + doc.frontmatter + .depends_on_epics + .retain(|d| d != dep_id); + doc.frontmatter.updated_at = Utc::now(); + save_epic(&epic_path, &doc); + db_upsert_epic(&doc.frontmatter); + + if json_mode { + json_output(json!({ + "id": epic_id, + "depends_on_epics": doc.frontmatter.depends_on_epics, + "message": format!("Removed {dep_id} from {epic_id} dependencies"), + })); + } else { + println!("Removed {dep_id} from {epic_id} dependencies"); + } +} + +fn cmd_set_backend( + id: &str, + impl_spec: &Option, + review: &Option, + sync: &Option, + json_mode: bool, +) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(id); + + if impl_spec.is_none() && review.is_none() && sync.is_none() { + error_exit("At least one of --impl, --review, or --sync must be provided"); + } + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md")); + let mut doc = load_epic(&epic_path, id); + + let mut updated: Vec = Vec::new(); + + if let Some(val) = impl_spec { + let v = if val.is_empty() { None } else { Some(val.clone()) }; + doc.frontmatter.default_impl = v; + updated.push(format!( + "default_impl={}", + impl_spec.as_deref().unwrap_or("null") + )); + } + if let Some(val) = review { + let v = if val.is_empty() { None } else { Some(val.clone()) }; + doc.frontmatter.default_review = v; + updated.push(format!( + "default_review={}", + review.as_deref().unwrap_or("null") + )); + } + if let Some(val) = sync { + let v = if val.is_empty() { None } else { Some(val.clone()) }; + doc.frontmatter.default_sync = v; + updated.push(format!( + "default_sync={}", + sync.as_deref().unwrap_or("null") + )); + } + + doc.frontmatter.updated_at = Utc::now(); + save_epic(&epic_path, &doc); + db_upsert_epic(&doc.frontmatter); + + if json_mode { + json_output(json!({ + "id": id, + "default_impl": doc.frontmatter.default_impl, + "default_review": doc.frontmatter.default_review, + "default_sync": doc.frontmatter.default_sync, + "message": format!("Epic {id} backend specs updated: {}", updated.join(", ")), + })); + } else { + println!( + "Epic {id} backend specs updated: {}", + updated.join(", ") + ); + } +} + +fn cmd_set_auto_execute(id: &str, pending: bool, done: bool, json_mode: bool) { + let flow_dir = ensure_flow_exists(); + validate_epic_id(id); + + if !pending && !done { + error_exit("Either --pending or --done must be specified"); + } + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md")); + + // For auto_execute fields, we work with raw frontmatter since Epic struct + // doesn't have these fields. Read, patch, write back. + if !epic_path.exists() { + error_exit(&format!("Epic {id} not found")); + } + let content = fs::read_to_string(&epic_path) + .unwrap_or_else(|e| error_exit(&format!("Failed to read epic: {e}"))); + + let mut doc = frontmatter::parse::(&content) + .unwrap_or_else(|e| error_exit(&format!("Failed to parse epic {id}: {e}"))); + + let action; + if pending { + doc.frontmatter["auto_execute_pending"] = json!(true); + doc.frontmatter["auto_execute_set_at"] = json!(Utc::now().to_rfc3339()); + action = "pending"; + } else { + doc.frontmatter["auto_execute_pending"] = json!(false); + action = "done"; + } + + doc.frontmatter["updated_at"] = json!(Utc::now().to_rfc3339()); + + let serialized = frontmatter::write(&doc) + .unwrap_or_else(|e| error_exit(&format!("Failed to serialize epic: {e}"))); + fs::write(&epic_path, &serialized) + .unwrap_or_else(|e| error_exit(&format!("Failed to write epic: {e}"))); + + if json_mode { + json_output(json!({ + "id": id, + "auto_execute_pending": doc.frontmatter.get("auto_execute_pending"), + "auto_execute_set_at": doc.frontmatter.get("auto_execute_set_at"), + "message": format!("Epic {id} auto_execute set to {action}"), + })); + } else { + println!("Epic {id} auto_execute set to {action}"); + } +} + +// ── Dispatch ──────────────────────────────────────────────────────── + +pub fn dispatch(cmd: &EpicCmd, json: bool) { + match cmd { + EpicCmd::Create { title, branch } => cmd_create(title, branch, json), + EpicCmd::SetPlan { id, file } => cmd_set_plan(id, file, json), + EpicCmd::SetPlanReviewStatus { id, status } => { + cmd_set_plan_review_status(id, status, json) + } + EpicCmd::SetCompletionReviewStatus { id, status } => { + cmd_set_completion_review_status(id, status, json) + } + EpicCmd::SetBranch { id, branch } => cmd_set_branch(id, branch, json), + EpicCmd::SetTitle { id, title } => cmd_set_title(id, title, json), + EpicCmd::Close { + id, + skip_gap_check, + } => cmd_close(id, *skip_gap_check, json), + EpicCmd::Reopen { id } => cmd_reopen(id, json), + EpicCmd::Archive { id, force } => cmd_archive(id, *force, json), + EpicCmd::Clean => cmd_clean(json), + EpicCmd::AddDep { epic, depends_on } => cmd_add_dep(epic, depends_on, json), + EpicCmd::RmDep { epic, depends_on } => cmd_rm_dep(epic, depends_on, json), + EpicCmd::SetBackend { + id, + impl_spec, + review, + sync, + } => cmd_set_backend(id, impl_spec, review, sync, json), + EpicCmd::SetAutoExecute { + id, + pending, + done, + } => cmd_set_auto_execute(id, *pending, *done, json), + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/gap.rs b/flowctl/crates/flowctl-cli/src/commands/gap.rs new file mode 100644 index 00000000..79bf718a --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/gap.rs @@ -0,0 +1,443 @@ +//! Gap registry commands: gap add, list, resolve, check. +//! +//! Gaps track requirement deficiencies in an epic. They are stored in +//! the epic's Markdown frontmatter (via a companion JSON sidecar at +//! `.flow/epics/.gaps.json`). Blocking gaps (required/important) +//! prevent epic closure. + +use std::env; +use std::fs; + +use clap::Subcommand; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +use flowctl_core::id::is_epic_id; +use flowctl_core::types::{EPICS_DIR, FLOW_DIR}; + +// ── Types ────────────────────────────────────────────────────────── + +const GAP_BLOCKING_PRIORITIES: &[&str] = &["required", "important"]; + +#[derive(Subcommand, Debug)] +pub enum GapCmd { + /// Register a requirement gap. + Add { + /// Epic ID. + #[arg(long)] + epic: String, + /// What is missing. + #[arg(long, alias = "title")] + capability: String, + /// Gap priority. + #[arg(long, default_value = "required", value_parser = ["required", "important", "nice-to-have"])] + priority: String, + /// Where gap was found. + #[arg(long, default_value = "manual")] + source: String, + /// Task ID that addresses this gap. + #[arg(long)] + task: Option, + }, + /// List gaps for an epic. + List { + /// Epic ID. + #[arg(long)] + epic: String, + /// Filter by status. + #[arg(long, value_parser = ["open", "resolved"])] + status: Option, + }, + /// Mark a gap as resolved. + Resolve { + /// Epic ID. + #[arg(long)] + epic: String, + /// Capability to resolve. + #[arg(long, alias = "title")] + capability: Option, + /// Gap ID to resolve directly. + #[arg(long)] + id: Option, + /// How the gap was resolved. + #[arg(long)] + evidence: String, + }, + /// Gate check: pass/fail based on unresolved gaps. + Check { + /// Epic ID. + #[arg(long)] + epic: String, + }, +} + +pub fn dispatch(cmd: &GapCmd, json: bool) { + match cmd { + GapCmd::Add { + epic, + capability, + priority, + source, + task, + } => cmd_gap_add(json, epic, capability, priority, source, task.as_deref()), + GapCmd::List { epic, status } => cmd_gap_list(json, epic, status.as_deref()), + GapCmd::Resolve { + epic, + capability, + id, + evidence, + } => cmd_gap_resolve(json, epic, capability.as_deref(), id.as_deref(), evidence), + GapCmd::Check { epic } => cmd_gap_check(json, epic), + } +} + +// ── Helpers ──────────────────────────────────────────────────────── + +fn get_flow_dir() -> std::path::PathBuf { + env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .join(FLOW_DIR) +} + +/// Compute deterministic gap ID from epic + capability (content-hash). +fn gap_id(epic_id: &str, capability: &str) -> String { + use sha2::{Digest, Sha256}; + + let key = format!("{}:{}", epic_id, capability.trim().to_lowercase()); + let hash = Sha256::digest(key.as_bytes()); + let hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect(); + format!("gap-{}", &hex[..8]) +} + +fn now_iso() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +/// Path to the gaps sidecar JSON file for an epic. +fn gaps_path(flow_dir: &std::path::Path, epic_id: &str) -> std::path::PathBuf { + flow_dir.join(EPICS_DIR).join(format!("{}.gaps.json", epic_id)) +} + +/// Load gaps array from sidecar file. Returns empty vec if file doesn't exist. +fn load_gaps(flow_dir: &std::path::Path, epic_id: &str) -> Vec { + let path = gaps_path(flow_dir, epic_id); + if !path.exists() { + return Vec::new(); + } + match fs::read_to_string(&path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => Vec::new(), + } +} + +/// Save gaps array to sidecar file. +fn save_gaps(flow_dir: &std::path::Path, epic_id: &str, gaps: &[serde_json::Value]) { + let path = gaps_path(flow_dir, epic_id); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let content = serde_json::to_string_pretty(gaps).unwrap(); + if let Err(e) = fs::write(&path, &content) { + error_exit(&format!("Failed to write {}: {}", path.display(), e)); + } +} + +/// Verify .flow/ exists, epic ID is valid, and epic file exists. +fn validate_epic(_json: bool, epic_id: &str) -> std::path::PathBuf { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + if !is_epic_id(epic_id) { + error_exit(&format!("Invalid epic ID: {}", epic_id)); + } + // Verify the epic markdown file exists + let epic_md = flow_dir.join(EPICS_DIR).join(format!("{}.md", epic_id)); + if !epic_md.exists() { + error_exit(&format!("Epic not found: {}", epic_id)); + } + flow_dir +} + +// ── Commands ─────────────────────────────────────────────────────── + +fn cmd_gap_add( + json_mode: bool, + epic_id: &str, + capability: &str, + priority: &str, + source: &str, + task: Option<&str>, +) { + let flow_dir = validate_epic(json_mode, epic_id); + let gid = gap_id(epic_id, capability); + + let mut gaps = load_gaps(&flow_dir, epic_id); + + // Check for existing gap (idempotent) + if let Some(existing) = gaps.iter().find(|g| g["id"].as_str() == Some(&gid)) { + if json_mode { + json_output(json!({ + "id": gid, + "created": false, + "gap": existing, + "message": format!("Gap already exists: {}", gid), + })); + } else { + println!( + "Gap already exists: {} \u{2014} {}", + gid, + existing["capability"].as_str().unwrap_or("") + ); + } + return; + } + + let gap = json!({ + "id": gid, + "capability": capability.trim(), + "priority": priority, + "status": "open", + "source": source, + "task": task, + "added_at": now_iso(), + "resolved_at": null, + "evidence": null, + }); + + gaps.push(gap.clone()); + save_gaps(&flow_dir, epic_id, &gaps); + + if json_mode { + json_output(json!({ + "id": gid, + "created": true, + "gap": gap, + "message": format!("Gap {} added to {}", gid, epic_id), + })); + } else { + println!("Gap {} added: [{}] {}", gid, priority, capability.trim()); + } +} + +fn cmd_gap_list(json_mode: bool, epic_id: &str, status_filter: Option<&str>) { + let flow_dir = validate_epic(json_mode, epic_id); + let gaps = load_gaps(&flow_dir, epic_id); + + let filtered: Vec<&serde_json::Value> = if let Some(status) = status_filter { + gaps.iter() + .filter(|g| g["status"].as_str() == Some(status)) + .collect() + } else { + gaps.iter().collect() + }; + + if json_mode { + json_output(json!({ + "epic": epic_id, + "count": filtered.len(), + "gaps": filtered, + })); + } else if filtered.is_empty() { + let suffix = status_filter + .map(|s| format!(" (status={})", s)) + .unwrap_or_default(); + println!("No gaps for {}{}", epic_id, suffix); + } else { + for g in &filtered { + let marker = if g["status"].as_str() == Some("resolved") { + "\u{2713}" + } else { + "\u{2717}" + }; + println!( + " {} {} [{}] {}", + marker, + g["id"].as_str().unwrap_or(""), + g["priority"].as_str().unwrap_or(""), + g["capability"].as_str().unwrap_or(""), + ); + } + } +} + +fn cmd_gap_resolve( + json_mode: bool, + epic_id: &str, + capability: Option<&str>, + gap_id_direct: Option<&str>, + evidence: &str, +) { + let flow_dir = validate_epic(json_mode, epic_id); + let mut gaps = load_gaps(&flow_dir, epic_id); + + // Find the gap by direct ID or by capability content-hash + let gid = if let Some(direct_id) = gap_id_direct { + direct_id.to_string() + } else if let Some(cap) = capability { + gap_id(epic_id, cap) + } else { + error_exit("Either --capability or --id is required"); + }; + + let gap = gaps + .iter_mut() + .find(|g| g["id"].as_str() == Some(&gid)); + + let gap = match gap { + Some(g) => g, + None => { + error_exit(&format!("Gap not found: {}", gid)); + } + }; + + if gap["status"].as_str() == Some("resolved") { + if json_mode { + json_output(json!({ + "id": gid, + "changed": false, + "gap": *gap, + "message": format!("Gap {} already resolved", gid), + })); + } else { + println!("Gap {} already resolved", gid); + } + return; + } + + gap["status"] = json!("resolved"); + gap["resolved_at"] = json!(now_iso()); + gap["evidence"] = json!(evidence); + + save_gaps(&flow_dir, epic_id, &gaps); + + if json_mode { + let resolved_gap = gaps.iter().find(|g| g["id"].as_str() == Some(&gid)).unwrap(); + json_output(json!({ + "id": gid, + "changed": true, + "gap": resolved_gap, + "message": format!("Gap {} resolved", gid), + })); + } else { + println!("Gap {} resolved: {}", gid, evidence); + } +} + +fn cmd_gap_check(json_mode: bool, epic_id: &str) { + let flow_dir = validate_epic(json_mode, epic_id); + let gaps = load_gaps(&flow_dir, epic_id); + + let open_blocking: Vec<&serde_json::Value> = gaps + .iter() + .filter(|g| { + g["status"].as_str() == Some("open") + && g["priority"] + .as_str() + .map(|p| GAP_BLOCKING_PRIORITIES.contains(&p)) + .unwrap_or(false) + }) + .collect(); + + let open_non_blocking: Vec<&serde_json::Value> = gaps + .iter() + .filter(|g| { + g["status"].as_str() == Some("open") + && !g["priority"] + .as_str() + .map(|p| GAP_BLOCKING_PRIORITIES.contains(&p)) + .unwrap_or(false) + }) + .collect(); + + let resolved: Vec<&serde_json::Value> = gaps + .iter() + .filter(|g| g["status"].as_str() == Some("resolved")) + .collect(); + + let gate = if open_blocking.is_empty() { + "pass" + } else { + "fail" + }; + + if json_mode { + json_output(json!({ + "epic": epic_id, + "gate": gate, + "total": gaps.len(), + "open_blocking": open_blocking, + "open_non_blocking": open_non_blocking, + "resolved": resolved, + })); + } else if gate == "pass" { + println!( + "Gap check PASS for {} ({} resolved, {} non-blocking)", + epic_id, + resolved.len(), + open_non_blocking.len() + ); + } else { + println!( + "Gap check FAIL for {} \u{2014} {} blocking gap(s):", + epic_id, + open_blocking.len() + ); + for g in &open_blocking { + println!( + " \u{2717} [{}] {}", + g["priority"].as_str().unwrap_or(""), + g["capability"].as_str().unwrap_or(""), + ); + } + } + + if gate == "fail" { + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_gap_id_deterministic() { + let id1 = gap_id("fn-1-test", "missing auth"); + let id2 = gap_id("fn-1-test", "missing auth"); + assert_eq!(id1, id2); + assert!(id1.starts_with("gap-")); + assert_eq!(id1.len(), 4 + 8); // "gap-" + 8 hex chars + } + + #[test] + fn test_gap_id_case_insensitive() { + let id1 = gap_id("fn-1-test", "Missing Auth"); + let id2 = gap_id("fn-1-test", "missing auth"); + assert_eq!(id1, id2); + } + + #[test] + fn test_gap_id_different_epics() { + let id1 = gap_id("fn-1-test", "missing auth"); + let id2 = gap_id("fn-2-other", "missing auth"); + assert_ne!(id1, id2); + } + + #[test] + fn test_sha256_via_gap_id() { + // Verify gap_id produces Python-parity results via sha2 crate. + // Python: hashlib.sha256(b"fn-1-test:missing auth").hexdigest()[:8] + let gid = gap_id("fn-1-test", "missing auth"); + assert!(gid.starts_with("gap-")); + assert_eq!(gid.len(), 12); // "gap-" + 8 hex chars + } + + #[test] + fn test_blocking_priorities() { + assert!(GAP_BLOCKING_PRIORITIES.contains(&"required")); + assert!(GAP_BLOCKING_PRIORITIES.contains(&"important")); + assert!(!GAP_BLOCKING_PRIORITIES.contains(&"nice-to-have")); + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/memory.rs b/flowctl/crates/flowctl-cli/src/commands/memory.rs new file mode 100644 index 00000000..41bbfff3 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/memory.rs @@ -0,0 +1,1087 @@ +//! Memory commands: init, add, read, list, search, inject, verify, gc. + +use std::collections::HashSet; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{Duration, Utc}; +use clap::Subcommand; +use regex::Regex; +use serde_json::json; +use sha2::{Digest, Sha256}; + +use crate::output::{error_exit, json_output}; +use flowctl_core::types::{CONFIG_FILE, FLOW_DIR, MEMORY_DIR}; + +// ── Constants ────────────────────────────────────────────────────── + +const MEMORY_VALID_TYPES: &[&str] = &["pitfall", "convention", "decision"]; + +const TAG_PATTERNS: &[&str] = &[ + r"\b(typescript|javascript|python|rust|go|java|ruby|swift)\b", + r"\b(react|vue|angular|svelte|nextjs|django|flask|fastapi|express)\b", + r"\b(postgres|mysql|sqlite|redis|mongodb|supabase)\b", + r"\b(docker|kubernetes|ci|cd|github|gitlab)\b", + r"\b(api|auth|oauth|jwt|cors|csrf|xss|sql)\b", + r"\b(test|lint|build|deploy|migration|schema)\b", +]; + +// ── CLI definition ───────────────────────────────────────────────── + +#[derive(Subcommand, Debug)] +pub enum MemoryCmd { + /// Initialize memory (auto-migrates legacy). + Init, + /// Add atomic memory entry. + Add { + /// Type: pitfall, convention, or decision. + #[arg(name = "type")] + entry_type: String, + /// Entry content. + content: String, + }, + /// Read entries (L3: full content). + Read { + /// Filter by type. + #[arg(long = "type")] + entry_type: Option, + }, + /// List entries with ref counts. + List, + /// Search entries by pattern. + Search { + /// Search pattern (regex). + pattern: String, + }, + /// Inject relevant entries (progressive disclosure). + Inject { + /// Filter by type. + #[arg(long = "type")] + entry_type: Option, + /// Filter by tags (comma-separated). + #[arg(long)] + tags: Option, + /// L3: inject full content of all entries. + #[arg(long)] + full: bool, + }, + /// Mark entry as verified (still valid). + Verify { + /// Entry ID to verify. + id: i64, + }, + /// Garbage collect stale entries. + Gc { + /// Remove entries older than N days with 0 refs. + #[arg(long, default_value = "90")] + days: i64, + /// Show what would be removed. + #[arg(long)] + dry_run: bool, + }, +} + +pub fn dispatch(cmd: &MemoryCmd, json: bool) { + match cmd { + MemoryCmd::Init => cmd_memory_init(json), + MemoryCmd::Add { + entry_type, + content, + } => cmd_memory_add(json, entry_type, content), + MemoryCmd::Read { entry_type } => cmd_memory_read(json, entry_type.as_deref()), + MemoryCmd::List => cmd_memory_list(json), + MemoryCmd::Search { pattern } => cmd_memory_search(json, pattern), + MemoryCmd::Inject { + entry_type, + tags, + full, + } => cmd_memory_inject(json, entry_type.as_deref(), tags.as_deref(), *full), + MemoryCmd::Verify { id } => cmd_memory_verify(json, *id), + MemoryCmd::Gc { days, dry_run } => cmd_memory_gc(json, *days, *dry_run), + } +} + +// ── Helpers ──────────────────────────────────────────────────────── + +fn get_flow_dir() -> PathBuf { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(FLOW_DIR) +} + +fn memory_dir() -> PathBuf { + get_flow_dir().join(MEMORY_DIR) +} + +fn memory_entries_dir() -> PathBuf { + memory_dir().join("entries") +} + +fn memory_index_path() -> PathBuf { + memory_dir().join("index.jsonl") +} + +fn memory_stats_path() -> PathBuf { + memory_dir().join("stats.json") +} + +/// Normalize type input: 'pitfalls' -> 'pitfall', etc. +fn normalize_memory_type(raw: &str) -> Option<&'static str> { + let lower = raw.to_lowercase(); + let trimmed = lower.trim_end_matches('s'); + MEMORY_VALID_TYPES + .iter() + .find(|&&t| t == trimmed) + .copied() +} + +/// SHA256 prefix for deduplication (matches Python _content_hash). +fn content_hash(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.trim().as_bytes()); + let result = hasher.finalize(); + hex_encode(&result[..6]) // 12 hex chars = 6 bytes +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +/// Scan existing entries to find next numeric ID. +fn next_entry_id(entries_dir: &Path) -> i64 { + let mut max_id: i64 = 0; + if let Ok(entries) = fs::read_dir(entries_dir) { + let re = Regex::new(r"^(\d+)-").unwrap(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if let Some(caps) = re.captures(&name_str) { + if let Ok(id) = caps[1].parse::() { + max_id = max_id.max(id); + } + } + } + } + max_id + 1 +} + +/// Load index.jsonl entries. +fn load_index(index_path: &Path) -> Vec { + if !index_path.exists() { + return Vec::new(); + } + let content = match fs::read_to_string(index_path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + content + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str(line).ok()) + .collect() +} + +/// Write index.jsonl atomically. +fn save_index(index_path: &Path, entries: &[serde_json::Value]) { + let lines: Vec = entries + .iter() + .map(|e| serde_json::to_string(e).unwrap()) + .collect(); + let content = if lines.is_empty() { + String::new() + } else { + lines.join("\n") + "\n" + }; + if let Some(parent) = index_path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Err(e) = fs::write(index_path, &content) { + error_exit(&format!( + "Failed to write {}: {}", + index_path.display(), + e + )); + } +} + +/// Load stats.json. +fn load_stats(stats_path: &Path) -> serde_json::Value { + if !stats_path.exists() { + return json!({}); + } + match fs::read_to_string(stats_path) { + Ok(content) => serde_json::from_str(&content).unwrap_or(json!({})), + Err(_) => json!({}), + } +} + +/// Write stats.json. +fn save_stats(stats_path: &Path, stats: &serde_json::Value) { + if let Some(parent) = stats_path.parent() { + let _ = fs::create_dir_all(parent); + } + let content = serde_json::to_string_pretty(stats).unwrap(); + if let Err(e) = fs::write(stats_path, &content) { + error_exit(&format!( + "Failed to write {}: {}", + stats_path.display(), + e + )); + } +} + +/// Increment reference counts for injected entries. +fn bump_refs(stats_path: &Path, entry_ids: &[String]) { + if entry_ids.is_empty() { + return; + } + let mut stats = load_stats(stats_path); + let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + for eid in entry_ids { + let entry = stats + .as_object_mut() + .unwrap() + .entry(eid.clone()) + .or_insert_with(|| json!({"refs": 0, "last_ref": ""})); + let refs = entry + .get("refs") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + entry["refs"] = json!(refs + 1); + entry["last_ref"] = json!(now); + } + save_stats(stats_path, &stats); +} + +/// Extract simple keyword tags from content. +fn extract_tags(content: &str) -> Vec { + let mut tags = HashSet::new(); + let lower = content.to_lowercase(); + for pattern in TAG_PATTERNS { + if let Ok(re) = Regex::new(pattern) { + for caps in re.captures_iter(&lower) { + if let Some(m) = caps.get(1) { + tags.insert(m.as_str().to_string()); + } + } + } + } + let mut sorted: Vec = tags.into_iter().collect(); + sorted.sort(); + sorted.truncate(8); + sorted +} + +/// Check memory.enabled config, ensure dirs exist. Returns memory dir or exits. +fn require_memory_enabled(json: bool) -> PathBuf { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + if json { + json_output(json!({"error": ".flow/ does not exist. Run 'flowctl init' first."})); + std::process::exit(1); + } else { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + } + + // Check config + let config_path = flow_dir.join(CONFIG_FILE); + let memory_enabled = if config_path.exists() { + match fs::read_to_string(&config_path) { + Ok(content) => { + let config: serde_json::Value = + serde_json::from_str(&content).unwrap_or(json!({})); + config + .get("memory") + .and_then(|m| m.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } + Err(_) => false, + } + } else { + false + }; + + if !memory_enabled { + if json { + json_output(json!({ + "error": "Memory not enabled. Run: flowctl config set memory.enabled true" + })); + } else { + eprintln!("Error: Memory not enabled."); + eprintln!("Enable with: flowctl config set memory.enabled true"); + } + std::process::exit(1); + } + + let mem_dir = memory_dir(); + let _ = fs::create_dir_all(&mem_dir); + let entries = memory_entries_dir(); + let _ = fs::create_dir_all(&entries); + + mem_dir +} + +// ── Commands ────────────────────────────────────────────────────── + +fn cmd_memory_init(json: bool) { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + if json { + json_output(json!({"error": ".flow/ does not exist. Run 'flowctl init' first."})); + std::process::exit(1); + } else { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + } + + // Check config + let config_path = flow_dir.join(CONFIG_FILE); + let memory_enabled = if config_path.exists() { + match fs::read_to_string(&config_path) { + Ok(content) => { + let config: serde_json::Value = + serde_json::from_str(&content).unwrap_or(json!({})); + config + .get("memory") + .and_then(|m| m.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } + Err(_) => false, + } + } else { + false + }; + + if !memory_enabled { + if json { + json_output(json!({ + "error": "Memory not enabled. Run: flowctl config set memory.enabled true" + })); + } else { + eprintln!("Error: Memory not enabled."); + eprintln!("Enable with: flowctl config set memory.enabled true"); + } + std::process::exit(1); + } + + let mem_dir = memory_dir(); + let _ = fs::create_dir_all(&mem_dir); + let entries_dir = memory_entries_dir(); + let _ = fs::create_dir_all(&entries_dir); + + let mut created = Vec::new(); + + let index_path = memory_index_path(); + if !index_path.exists() { + let _ = fs::write(&index_path, ""); + created.push("index.jsonl".to_string()); + } + + let stats_path = memory_stats_path(); + if !stats_path.exists() { + save_stats(&stats_path, &json!({})); + created.push("stats.json".to_string()); + } + + if json { + json_output(json!({ + "path": mem_dir.to_string_lossy(), + "created": created, + "migrated": 0, + "message": "Memory v2 initialized", + })); + } else { + println!("Memory v2 initialized at {}", mem_dir.display()); + for f in &created { + println!(" Created: {}", f); + } + } +} + +fn cmd_memory_add(json: bool, entry_type: &str, content: &str) { + require_memory_enabled(json); + + let type_name = match normalize_memory_type(entry_type) { + Some(t) => t, + None => { + if json { + json_output(json!({ + "error": format!("Invalid type '{}'. Use: pitfall, convention, or decision", entry_type) + })); + } else { + eprintln!( + "Error: Invalid type '{}'. Use: pitfall, convention, or decision", + entry_type + ); + } + std::process::exit(1); + } + }; + + let content = content.trim(); + if content.is_empty() { + if json { + json_output(json!({"error": "Content cannot be empty"})); + } else { + eprintln!("Error: Content cannot be empty"); + } + std::process::exit(1); + } + + // Dedup check + let chash = content_hash(content); + let index_path = memory_index_path(); + let existing = load_index(&index_path); + for e in &existing { + if e.get("hash").and_then(|v| v.as_str()) == Some(&chash) { + let dup_id = e.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + if json { + json_output(json!({ + "id": dup_id, + "duplicate": true, + "message": "Duplicate entry, skipped", + })); + } else { + println!("Duplicate of entry #{}, skipped", dup_id); + } + return; + } + } + + // Write atomic entry + let entries_dir = memory_entries_dir(); + let entry_id = next_entry_id(&entries_dir); + let entry_filename = format!("{:03}-{}.md", entry_id, type_name); + if let Err(e) = fs::write(entries_dir.join(&entry_filename), content) { + error_exit(&format!("Failed to write entry file: {}", e)); + } + + // Extract tags and summary + let tags = extract_tags(content); + let summary: String = content.lines().next().unwrap_or("").chars().take(120).collect(); + let created = Utc::now().format("%Y-%m-%d").to_string(); + + // Append to index + let idx_entry = json!({ + "id": entry_id, + "type": type_name, + "summary": summary, + "tags": tags, + "hash": chash, + "created": created, + "last_verified": created, + "file": entry_filename, + }); + let mut all_entries = existing; + all_entries.push(idx_entry); + save_index(&index_path, &all_entries); + + if json { + json_output(json!({ + "id": entry_id, + "type": type_name, + "file": entry_filename, + "tags": tags, + })); + } else { + println!("Added {} #{}: {}", type_name, entry_id, summary); + if !tags.is_empty() { + println!(" Tags: {}", tags.join(", ")); + } + } +} + +fn cmd_memory_read(json: bool, entry_type: Option<&str>) { + require_memory_enabled(json); + + let index = load_index(&memory_index_path()); + + let type_filter = entry_type.and_then(normalize_memory_type); + if entry_type.is_some() && type_filter.is_none() { + if json { + json_output(json!({ + "error": format!("Invalid type '{}'. Use: pitfall, convention, or decision", entry_type.unwrap()) + })); + } else { + eprintln!( + "Error: Invalid type '{}'. Use: pitfall, convention, or decision", + entry_type.unwrap() + ); + } + std::process::exit(1); + } + + let entries_dir = memory_entries_dir(); + let mut results = Vec::new(); + + for idx in &index { + if let Some(tf) = type_filter { + if idx.get("type").and_then(|v| v.as_str()) != Some(tf) { + continue; + } + } + let file = idx.get("file").and_then(|v| v.as_str()).unwrap_or(""); + let entry_path = entries_dir.join(file); + let content = fs::read_to_string(&entry_path).unwrap_or_default(); + + results.push(json!({ + "id": idx.get("id"), + "type": idx.get("type"), + "summary": idx.get("summary"), + "tags": idx.get("tags").cloned().unwrap_or(json!([])), + "created": idx.get("created").cloned().unwrap_or(json!("")), + "content": content, + })); + } + + if json { + json_output(json!({ + "entries": results, + "count": results.len(), + })); + } else if results.is_empty() { + let suffix = type_filter + .map(|t| format!(" of type '{}'", t)) + .unwrap_or_default(); + println!("No memory entries{}", suffix); + } else { + for r in &results { + println!( + "--- #{} [{}] {} ---", + r["id"], + r["type"].as_str().unwrap_or(""), + r["created"].as_str().unwrap_or("") + ); + println!("{}", r["content"].as_str().unwrap_or("")); + if let Some(tags) = r["tags"].as_array() { + if !tags.is_empty() { + let tag_strs: Vec<&str> = + tags.iter().filter_map(|t| t.as_str()).collect(); + println!(" Tags: {}", tag_strs.join(", ")); + } + } + println!(); + } + println!("Total: {} entries", results.len()); + } +} + +fn cmd_memory_list(json: bool) { + require_memory_enabled(json); + + let index = load_index(&memory_index_path()); + let stats = load_stats(&memory_stats_path()); + + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + for idx in &index { + let t = idx + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + *counts.entry(t).or_insert(0) += 1; + } + + let total = index.len(); + let total_refs: i64 = stats + .as_object() + .map(|m| { + m.values() + .map(|v| v.get("refs").and_then(|r| r.as_i64()).unwrap_or(0)) + .sum() + }) + .unwrap_or(0); + + // Staleness threshold: 90 days ago + let stale_cutoff = (Utc::now() - Duration::days(90)) + .format("%Y-%m-%d") + .to_string(); + + if json { + let index_data: Vec = index + .iter() + .map(|idx| { + let eid = idx.get("id").and_then(|v| v.as_i64()).unwrap_or(0).to_string(); + let last_verified = idx + .get("last_verified") + .or_else(|| idx.get("created")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let stale = last_verified < stale_cutoff.as_str(); + let refs = stats + .get(&eid) + .and_then(|s| s.get("refs")) + .and_then(|r| r.as_i64()) + .unwrap_or(0); + json!({ + "id": idx.get("id"), + "type": idx.get("type"), + "summary": idx.get("summary"), + "tags": idx.get("tags").cloned().unwrap_or(json!([])), + "created": idx.get("created").cloned().unwrap_or(json!("")), + "last_verified": last_verified, + "stale": stale, + "refs": refs, + }) + }) + .collect(); + + json_output(json!({ + "counts": counts, + "total": total, + "total_refs": total_refs, + "index": index_data, + })); + } else { + let mut stale_count = 0; + println!("Memory: {} entries, {} total references\n", total, total_refs); + for idx in &index { + let eid = idx.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let eid_str = eid.to_string(); + let refs = stats + .get(&eid_str) + .and_then(|s| s.get("refs")) + .and_then(|r| r.as_i64()) + .unwrap_or(0); + let verified = idx + .get("last_verified") + .or_else(|| idx.get("created")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let is_stale = if verified.is_empty() { + true + } else { + verified < stale_cutoff.as_str() + }; + if is_stale { + stale_count += 1; + } + let stale_tag = if is_stale { " [stale]" } else { "" }; + let entry_type = idx.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let summary = idx.get("summary").and_then(|v| v.as_str()).unwrap_or(""); + let summary_trunc: String = summary.chars().take(70).collect(); + println!( + " #{:3} [{:10}] refs={:2} {}{}", + eid, entry_type, refs, summary_trunc, stale_tag + ); + } + println!(); + let mut sorted_counts: Vec<_> = counts.iter().collect(); + sorted_counts.sort_by_key(|(k, _)| (*k).clone()); + for (t, c) in &sorted_counts { + println!(" {}: {}", t, c); + } + println!(" Total: {}", total); + if stale_count > 0 { + println!( + " Stale: {} (not verified in 90+ days — run /flow-code:retro to verify)", + stale_count + ); + } + } +} + +fn cmd_memory_search(json: bool, pattern: &str) { + require_memory_enabled(json); + + let compiled = match Regex::new(&format!("(?i){}", pattern)) { + Ok(re) => re, + Err(e) => { + if json { + json_output(json!({"error": format!("Invalid regex pattern: {}", e)})); + } else { + eprintln!("Error: Invalid regex pattern: {}", e); + } + std::process::exit(1); + } + }; + + let index = load_index(&memory_index_path()); + let entries_dir = memory_entries_dir(); + let mut matches = Vec::new(); + + for idx in &index { + let mut hit = false; + + // Search summary + if let Some(summary) = idx.get("summary").and_then(|v| v.as_str()) { + if compiled.is_match(summary) { + hit = true; + } + } + + // Search tags + if !hit { + if let Some(tags) = idx.get("tags").and_then(|v| v.as_array()) { + for tag in tags { + if let Some(t) = tag.as_str() { + if compiled.is_match(t) { + hit = true; + break; + } + } + } + } + } + + // Search content + let file = idx.get("file").and_then(|v| v.as_str()).unwrap_or(""); + let entry_path = entries_dir.join(file); + let content = if entry_path.exists() { + fs::read_to_string(&entry_path).unwrap_or_default() + } else { + String::new() + }; + + if !hit && compiled.is_match(&content) { + hit = true; + } + + if hit { + matches.push(json!({ + "id": idx.get("id"), + "type": idx.get("type"), + "summary": idx.get("summary"), + "tags": idx.get("tags").cloned().unwrap_or(json!([])), + "content": content, + })); + } + } + + if json { + json_output(json!({ + "pattern": pattern, + "matches": matches, + "count": matches.len(), + })); + } else if matches.is_empty() { + println!("No matches for '{}'", pattern); + } else { + for m in &matches { + println!( + "--- #{} [{}] ---", + m["id"], + m["type"].as_str().unwrap_or("") + ); + println!("{}", m["content"].as_str().unwrap_or("")); + println!(); + } + println!("Found {} matches for '{}'", matches.len(), pattern); + } +} + +fn cmd_memory_inject(json: bool, entry_type: Option<&str>, tags: Option<&str>, full: bool) { + require_memory_enabled(json); + + let index = load_index(&memory_index_path()); + if index.is_empty() { + if json { + json_output(json!({"entries": [], "level": "L1", "count": 0})); + } else { + println!("No memory entries"); + } + return; + } + + let entries_dir = memory_entries_dir(); + + // Determine filters + let type_filter = entry_type.and_then(normalize_memory_type); + let tag_filter: Vec = tags + .map(|t| { + t.split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + + // Filter entries + let filtered: Vec<&serde_json::Value> = index + .iter() + .filter(|idx| { + if let Some(tf) = type_filter { + if idx.get("type").and_then(|v| v.as_str()) != Some(tf) { + return false; + } + } + if !tag_filter.is_empty() { + let entry_tags: Vec = idx + .get("tags") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|t| t.as_str()) + .map(|s| s.to_lowercase()) + .collect() + }) + .unwrap_or_default(); + if !tag_filter.iter().any(|t| entry_tags.contains(t)) { + return false; + } + } + true + }) + .collect(); + + // Determine level + let level = if full && type_filter.is_none() && tag_filter.is_empty() { + "L3" + } else if type_filter.is_some() || !tag_filter.is_empty() { + "L2" + } else { + "L1" + }; + + // Bump reference counts + let ids: Vec = filtered + .iter() + .map(|e| { + e.get("id") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string() + }) + .collect(); + bump_refs(&memory_stats_path(), &ids); + + if level == "L1" { + // Compact index + let compact: Vec = filtered + .iter() + .map(|e| { + json!({ + "id": e.get("id"), + "type": e.get("type"), + "summary": e.get("summary"), + "tags": e.get("tags").cloned().unwrap_or(json!([])), + }) + }) + .collect(); + + if json { + json_output(json!({ + "entries": compact, + "level": "L1", + "count": compact.len(), + })); + } else { + println!("Memory index ({} entries):", filtered.len()); + for e in &filtered { + let tags_arr = e.get("tags").and_then(|v| v.as_array()); + let tags_str = tags_arr + .map(|arr| { + let ts: Vec<&str> = arr + .iter() + .filter_map(|t| t.as_str()) + .take(3) + .collect(); + if ts.is_empty() { + String::new() + } else { + format!(" [{}]", ts.join(",")) + } + }) + .unwrap_or_default(); + let summary = e.get("summary").and_then(|v| v.as_str()).unwrap_or(""); + let summary_trunc: String = summary.chars().take(100).collect(); + println!( + " #{} [{}]{} {}", + e["id"], + e["type"].as_str().unwrap_or(""), + tags_str, + summary_trunc + ); + } + println!( + "\nUse `memory search ` for full content of specific entries." + ); + } + } else { + // Full content for filtered entries + let results: Vec = filtered + .iter() + .map(|idx| { + let file = idx.get("file").and_then(|v| v.as_str()).unwrap_or(""); + let entry_path = entries_dir.join(file); + let content = if entry_path.exists() { + fs::read_to_string(&entry_path).unwrap_or_default() + } else { + String::new() + }; + json!({ + "id": idx.get("id"), + "type": idx.get("type"), + "summary": idx.get("summary"), + "tags": idx.get("tags").cloned().unwrap_or(json!([])), + "content": content, + }) + }) + .collect(); + + if json { + json_output(json!({ + "entries": results, + "level": level, + "count": results.len(), + })); + } else { + for r in &results { + println!( + "--- #{} [{}] ---", + r["id"], + r["type"].as_str().unwrap_or("") + ); + println!("{}", r["content"].as_str().unwrap_or("")); + println!(); + } + } + } +} + +fn cmd_memory_verify(json: bool, entry_id: i64) { + require_memory_enabled(json); + + let today = Utc::now().format("%Y-%m-%d").to_string(); + let index_path = memory_index_path(); + let mut index = load_index(&index_path); + + let mut found = false; + for idx in &mut index { + if idx.get("id").and_then(|v| v.as_i64()) == Some(entry_id) { + idx["last_verified"] = json!(today); + found = true; + break; + } + } + + if !found { + if json { + json_output(json!({"error": format!("Entry #{} not found", entry_id)})); + } else { + eprintln!("Error: Entry #{} not found", entry_id); + } + std::process::exit(1); + } + + save_index(&index_path, &index); + + if json { + json_output(json!({ + "id": entry_id, + "last_verified": today, + "message": format!("Entry #{} verified", entry_id), + })); + } else { + println!("Entry #{} verified as still valid ({})", entry_id, today); + } +} + +fn cmd_memory_gc(json: bool, days: i64, dry_run: bool) { + require_memory_enabled(json); + + let index = load_index(&memory_index_path()); + let mut stats = load_stats(&memory_stats_path()); + let entries_dir = memory_entries_dir(); + + let cutoff_date = (Utc::now() - Duration::days(days)) + .format("%Y-%m-%d") + .to_string(); + + let mut stale = Vec::new(); + let mut keep = Vec::new(); + + for idx in &index { + let eid_str = idx + .get("id") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(); + let refs = stats + .get(&eid_str) + .and_then(|s| s.get("refs")) + .and_then(|r| r.as_i64()) + .unwrap_or(0); + let created = idx + .get("created") + .and_then(|v| v.as_str()) + .unwrap_or("9999-99-99"); + + if refs == 0 && created < cutoff_date.as_str() { + stale.push(idx.clone()); + } else { + keep.push(idx.clone()); + } + } + + if dry_run { + if json { + let stale_info: Vec = stale + .iter() + .map(|s| { + json!({ + "id": s.get("id"), + "type": s.get("type"), + "summary": s.get("summary"), + }) + }) + .collect(); + json_output(json!({ + "dry_run": true, + "stale": stale_info, + "count": stale.len(), + "kept": keep.len(), + })); + } else { + println!( + "Dry run: {} stale entries (0 refs, older than {} days)", + stale.len(), + days + ); + for s in &stale { + let summary = s.get("summary").and_then(|v| v.as_str()).unwrap_or(""); + let summary_trunc: String = summary.chars().take(80).collect(); + println!( + " #{} [{}] {}", + s["id"], + s["type"].as_str().unwrap_or(""), + summary_trunc + ); + } + println!("Would keep: {} entries", keep.len()); + } + return; + } + + // Remove stale entries + let mut removed = 0; + for s in &stale { + let file = s.get("file").and_then(|v| v.as_str()).unwrap_or(""); + let entry_path = entries_dir.join(file); + if entry_path.exists() { + let _ = fs::remove_file(&entry_path); + } + let eid_str = s + .get("id") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(); + if let Some(obj) = stats.as_object_mut() { + obj.remove(&eid_str); + } + removed += 1; + } + + save_index(&memory_index_path(), &keep); + save_stats(&memory_stats_path(), &stats); + + if json { + json_output(json!({"removed": removed, "kept": keep.len()})); + } else { + println!("Removed {} stale entries, kept {}", removed, keep.len()); + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/mod.rs b/flowctl/crates/flowctl-cli/src/commands/mod.rs new file mode 100644 index 00000000..b48432f9 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/mod.rs @@ -0,0 +1,16 @@ +//! Command modules — one file per command group. + +pub mod admin; +pub mod checkpoint; +pub mod codex; +pub mod dep; +pub mod epic; +pub mod gap; +pub mod memory; +pub mod query; +pub mod ralph; +pub mod rp; +pub mod stack; +pub mod stats; +pub mod task; +pub mod workflow; diff --git a/flowctl/crates/flowctl-cli/src/commands/query.rs b/flowctl/crates/flowctl-cli/src/commands/query.rs new file mode 100644 index 00000000..0c1a523a --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/query.rs @@ -0,0 +1,916 @@ +//! Query commands: show, epics, tasks, list, cat, files, lock, unlock, lock-check. +//! +//! Reads from SQLite if available, falls back to scanning Markdown files. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +use flowctl_core::frontmatter; +use flowctl_core::id::{is_epic_id, is_task_id, parse_id}; +use flowctl_core::types::{ + Epic, Task, EPICS_DIR, FLOW_DIR, SPECS_DIR, TASKS_DIR, +}; + +// ── Helpers ───────────────────────────────────────────────────────── + +/// Get the .flow/ directory path. +fn get_flow_dir() -> PathBuf { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(FLOW_DIR) +} + +/// Ensure .flow/ exists, error_exit if not. +fn ensure_flow_exists() -> PathBuf { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + flow_dir +} + +/// Try to open a DB connection. Returns None if DB doesn't exist or can't be opened. +fn try_open_db() -> Option { + let cwd = env::current_dir().ok()?; + flowctl_db::open(&cwd).ok() +} + +/// Serialize an Epic to the JSON format matching Python output. +fn epic_to_json(epic: &Epic) -> serde_json::Value { + let spec_path = format!(".flow/specs/{}.md", epic.id); + json!({ + "id": epic.id, + "title": epic.title, + "status": epic.status.to_string(), + "branch_name": epic.branch_name, + "plan_review_status": epic.plan_review.to_string(), + "plan_reviewed_at": null, + "completion_review_status": epic.completion_review.to_string(), + "completion_reviewed_at": null, + "depends_on_epics": epic.depends_on_epics, + "default_impl": epic.default_impl, + "default_review": epic.default_review, + "default_sync": epic.default_sync, + "spec_path": spec_path, + "created_at": epic.created_at.to_rfc3339(), + "updated_at": epic.updated_at.to_rfc3339(), + }) +} + +/// Serialize a Task to the JSON format matching Python output. +fn task_to_json(task: &Task) -> serde_json::Value { + let spec_path = format!(".flow/tasks/{}.md", task.id); + + // Try to get runtime state from DB + let mut assignee: serde_json::Value = json!(null); + let mut claimed_at: serde_json::Value = json!(null); + let claim_note: serde_json::Value = json!(""); + + if let Some(conn) = try_open_db() { + let runtime_repo = flowctl_db::RuntimeRepo::new(&conn); + if let Ok(Some(state)) = runtime_repo.get(&task.id) { + if let Some(a) = &state.assignee { + assignee = json!(a); + } + if let Some(ca) = &state.claimed_at { + claimed_at = json!(ca.to_rfc3339()); + } + } + } + + json!({ + "id": task.id, + "epic": task.epic, + "title": task.title, + "status": task.status.to_string(), + "priority": task.priority, + "domain": task.domain.to_string(), + "depends_on": task.depends_on, + "files": task.files, + "impl": task.r#impl, + "review": task.review, + "sync": task.sync, + "assignee": assignee, + "claimed_at": claimed_at, + "claim_note": claim_note, + "spec_path": spec_path, + "created_at": task.created_at.to_rfc3339(), + "updated_at": task.updated_at.to_rfc3339(), + }) +} + +/// Task summary for list/show contexts (less detail than full task_to_json). +fn task_summary_json(task: &Task) -> serde_json::Value { + json!({ + "id": task.id, + "title": task.title, + "status": task.status.to_string(), + "priority": task.priority, + "depends_on": task.depends_on, + }) +} + +/// Task summary for tasks command (includes epic, domain). +fn task_list_json(task: &Task) -> serde_json::Value { + json!({ + "id": task.id, + "epic": task.epic, + "title": task.title, + "status": task.status.to_string(), + "priority": task.priority, + "domain": task.domain.to_string(), + "depends_on": task.depends_on, + }) +} + +// ── Markdown scanning fallback ────────────────────────────────────── + +/// Scan .flow/epics/*.md and parse all epics from frontmatter. +fn scan_epics_md(flow_dir: &Path) -> Vec { + let epics_dir = flow_dir.join(EPICS_DIR); + if !epics_dir.is_dir() { + return Vec::new(); + } + + let mut entries: Vec = match fs::read_dir(&epics_dir) { + Ok(entries) => entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md")) + .collect(), + Err(_) => return Vec::new(), + }; + entries.sort(); + + let mut epics = Vec::new(); + for path in entries { + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !is_epic_id(stem) { + continue; + } + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(mut epic) = frontmatter::parse_frontmatter::(&content) { + epic.file_path = Some(format!("epics/{}", path.file_name().unwrap().to_string_lossy())); + epics.push(epic); + } + } + } + + // Sort by epic number + epics.sort_by_key(|e| parse_id(&e.id).map(|p| p.epic).unwrap_or(0)); + epics +} + +/// Scan .flow/tasks/*.md and parse all tasks from frontmatter. +fn scan_tasks_md(flow_dir: &Path, epic_filter: Option<&str>) -> Vec { + let tasks_dir = flow_dir.join(TASKS_DIR); + if !tasks_dir.is_dir() { + return Vec::new(); + } + + let mut entries: Vec = match fs::read_dir(&tasks_dir) { + Ok(entries) => entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md")) + .collect(), + Err(_) => return Vec::new(), + }; + entries.sort(); + + let mut tasks = Vec::new(); + for path in entries { + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !is_task_id(stem) { + continue; + } + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(mut task) = frontmatter::parse_frontmatter::(&content) { + if let Some(filter) = epic_filter { + if task.epic != filter { + continue; + } + } + task.file_path = Some(format!("tasks/{}", path.file_name().unwrap().to_string_lossy())); + tasks.push(task); + } + } + } + + // Sort by (epic_num, task_num) + tasks.sort_by_key(|t| { + let parsed = parse_id(&t.id).ok(); + ( + parsed.as_ref().map(|p| p.epic).unwrap_or(0), + parsed.as_ref().and_then(|p| p.task).unwrap_or(0), + ) + }); + tasks +} + +/// Get a single epic by ID, trying DB first then Markdown. +fn get_epic(flow_dir: &Path, id: &str) -> Option { + // Try DB first + if let Some(conn) = try_open_db() { + let repo = flowctl_db::EpicRepo::new(&conn); + if let Ok(epic) = repo.get(id) { + return Some(epic); + } + } + + // Fall back to Markdown + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{}.md", id)); + if !epic_path.exists() { + return None; + } + let content = fs::read_to_string(&epic_path).ok()?; + let mut epic = frontmatter::parse_frontmatter::(&content).ok()?; + epic.file_path = Some(format!("epics/{}.md", id)); + Some(epic) +} + +/// Get a single task by ID, trying DB first then Markdown. +fn get_task(flow_dir: &Path, id: &str) -> Option { + // Try DB first + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + if let Ok(task) = repo.get(id) { + return Some(task); + } + } + + // Fall back to Markdown + let task_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", id)); + if !task_path.exists() { + return None; + } + let content = fs::read_to_string(&task_path).ok()?; + let mut task = frontmatter::parse_frontmatter::(&content).ok()?; + task.file_path = Some(format!("tasks/{}.md", id)); + Some(task) +} + +/// Get all tasks for an epic, trying DB first then Markdown. +fn get_epic_tasks(flow_dir: &Path, epic_id: &str) -> Vec { + // Try DB first + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + if let Ok(tasks) = repo.list_by_epic(epic_id) { + if !tasks.is_empty() { + return tasks; + } + } + } + + // Fall back to Markdown + scan_tasks_md(flow_dir, Some(epic_id)) +} + +/// Get all epics, trying DB first then Markdown. +fn get_all_epics(flow_dir: &Path) -> Vec { + // Try DB first + if let Some(conn) = try_open_db() { + let repo = flowctl_db::EpicRepo::new(&conn); + if let Ok(epics) = repo.list(None) { + if !epics.is_empty() { + return epics; + } + } + } + + // Fall back to Markdown + scan_epics_md(flow_dir) +} + +/// Get all tasks, optionally filtered, trying DB first then Markdown. +fn get_all_tasks( + flow_dir: &Path, + epic_filter: Option<&str>, + status_filter: Option<&str>, + domain_filter: Option<&str>, +) -> Vec { + // Try DB first + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + match epic_filter { + Some(epic_id) => { + if let Ok(mut tasks) = repo.list_by_epic(epic_id) { + // Apply status/domain filters + if let Some(status) = status_filter { + tasks.retain(|t| t.status.to_string() == status); + } + if let Some(domain) = domain_filter { + tasks.retain(|t| t.domain.to_string() == domain); + } + if !tasks.is_empty() { + return tasks; + } + // If empty, might be a new epic not yet in DB - fall through + } + } + None => { + if let Ok(tasks) = repo.list_all(status_filter, domain_filter) { + if !tasks.is_empty() { + return tasks; + } + } + } + } + } + + // Fall back to Markdown scan + let mut tasks = scan_tasks_md(flow_dir, epic_filter); + + // Apply filters + if let Some(status) = status_filter { + tasks.retain(|t| t.status.to_string() == status); + } + if let Some(domain) = domain_filter { + tasks.retain(|t| t.domain.to_string() == domain); + } + + tasks +} + +// ── Show command ──────────────────────────────────────────────────── + +pub fn cmd_show(json: bool, id: String) { + let flow_dir = ensure_flow_exists(); + + if is_epic_id(&id) { + let epic = match get_epic(&flow_dir, &id) { + Some(e) => e, + None => { + error_exit(&format!("Epic not found: {}", id)); + } + }; + + // Get tasks for this epic + let tasks = get_epic_tasks(&flow_dir, &id); + let task_summaries: Vec = tasks + .iter() + .map(|t| task_summary_json(t)) + .collect(); + + if json { + let mut result = epic_to_json(&epic); + result["tasks"] = json!(task_summaries); + json_output(result); + } else { + println!("Epic: {}", epic.id); + println!("Title: {}", epic.title); + println!("Status: {}", epic.status); + println!("Spec: .flow/specs/{}.md", epic.id); + println!("\nTasks ({}):", tasks.len()); + for t in &tasks { + let deps = if t.depends_on.is_empty() { + String::new() + } else { + format!(" (deps: {})", t.depends_on.join(", ")) + }; + println!(" [{}] {}: {}{}", t.status, t.id, t.title, deps); + } + } + } else if is_task_id(&id) { + let task = match get_task(&flow_dir, &id) { + Some(t) => t, + None => { + error_exit(&format!("Task not found: {}", id)); + } + }; + + if json { + json_output(task_to_json(&task)); + } else { + println!("Task: {}", task.id); + println!("Epic: {}", task.epic); + println!("Title: {}", task.title); + println!("Status: {}", task.status); + if task.domain != flowctl_core::types::Domain::General { + println!("Domain: {}", task.domain); + } + let deps_str = if task.depends_on.is_empty() { + "none".to_string() + } else { + task.depends_on.join(", ") + }; + println!("Depends on: {}", deps_str); + println!("Spec: .flow/tasks/{}.md", task.id); + } + } else { + error_exit(&format!( + "Invalid ID: {}. Expected format: fn-N or fn-N-slug (epic), fn-N.M or fn-N-slug.M (task)", + id + )); + } +} + +// ── Epics command ─────────────────────────────────────────────────── + +pub fn cmd_epics(json: bool) { + let flow_dir = ensure_flow_exists(); + let epics = get_all_epics(&flow_dir); + + let mut epics_out: Vec = Vec::new(); + for epic in &epics { + let tasks = get_epic_tasks(&flow_dir, &epic.id); + let task_count = tasks.len(); + let done_count = tasks + .iter() + .filter(|t| t.status == flowctl_core::state_machine::Status::Done) + .count(); + + epics_out.push(json!({ + "id": epic.id, + "title": epic.title, + "status": epic.status.to_string(), + "tasks": task_count, + "done": done_count, + })); + } + + if json { + json_output(json!({ + "epics": epics_out, + "count": epics_out.len(), + })); + } else if epics_out.is_empty() { + println!("No epics found."); + } else { + println!("Epics ({}):\n", epics_out.len()); + for e in &epics_out { + let tasks = e["tasks"].as_u64().unwrap_or(0); + let done = e["done"].as_u64().unwrap_or(0); + let progress = if tasks > 0 { + format!("{}/{}", done, tasks) + } else { + "0/0".to_string() + }; + println!( + " [{}] {}: {} ({} tasks done)", + e["status"].as_str().unwrap_or(""), + e["id"].as_str().unwrap_or(""), + e["title"].as_str().unwrap_or(""), + progress + ); + } + } +} + +// ── Tasks command ─────────────────────────────────────────────────── + +pub fn cmd_tasks( + json: bool, + epic: Option, + status: Option, + domain: Option, +) { + let flow_dir = ensure_flow_exists(); + + let tasks = get_all_tasks( + &flow_dir, + epic.as_deref(), + status.as_deref(), + domain.as_deref(), + ); + + let tasks_out: Vec = tasks.iter().map(|t| task_list_json(t)).collect(); + + if json { + json_output(json!({ + "tasks": tasks_out, + "count": tasks_out.len(), + })); + } else if tasks_out.is_empty() { + let scope = epic.as_ref().map(|e| format!(" for epic {}", e)).unwrap_or_default(); + let status_filter = status.as_ref().map(|s| format!(" with status '{}'", s)).unwrap_or_default(); + println!("No tasks found{}{}.", scope, status_filter); + } else { + let scope = epic.as_ref().map(|e| format!(" for {}", e)).unwrap_or_default(); + println!("Tasks{} ({}):\n", scope, tasks_out.len()); + for t in &tasks { + let deps = if t.depends_on.is_empty() { + String::new() + } else { + format!(" (deps: {})", t.depends_on.join(", ")) + }; + let domain_tag = if t.domain != flowctl_core::types::Domain::General { + format!(" [{}]", t.domain) + } else { + String::new() + }; + println!( + " [{}] {}: {}{}{}", + t.status, t.id, t.title, domain_tag, deps + ); + } + } +} + +// ── List command ──────────────────────────────────────────────────── + +pub fn cmd_list(json: bool) { + let flow_dir = ensure_flow_exists(); + let epics = get_all_epics(&flow_dir); + let all_tasks = get_all_tasks(&flow_dir, None, None, None); + + // Group tasks by epic + let mut tasks_by_epic: std::collections::HashMap> = + std::collections::HashMap::new(); + for task in &all_tasks { + tasks_by_epic + .entry(task.epic.clone()) + .or_default() + .push(task); + } + + if json { + let epics_out: Vec = epics + .iter() + .map(|e| { + let task_list = tasks_by_epic.get(&e.id).map(|t| t.len()).unwrap_or(0); + let done_count = tasks_by_epic + .get(&e.id) + .map(|tasks| { + tasks + .iter() + .filter(|t| t.status == flowctl_core::state_machine::Status::Done) + .count() + }) + .unwrap_or(0); + json!({ + "id": e.id, + "title": e.title, + "status": e.status.to_string(), + "tasks": task_list, + "done": done_count, + }) + }) + .collect(); + + let tasks_out: Vec = all_tasks + .iter() + .map(|t| { + json!({ + "id": t.id, + "epic": t.epic, + "title": t.title, + "status": t.status.to_string(), + "priority": t.priority, + "depends_on": t.depends_on, + }) + }) + .collect(); + + json_output(json!({ + "epics": epics_out, + "tasks": tasks_out, + "epic_count": epics_out.len(), + "task_count": tasks_out.len(), + })); + } else if epics.is_empty() { + println!("No epics or tasks found."); + } else { + let total_tasks = all_tasks.len(); + let total_done = all_tasks + .iter() + .filter(|t| t.status == flowctl_core::state_machine::Status::Done) + .count(); + println!( + "Flow Status: {} epics, {} tasks ({} done)\n", + epics.len(), + total_tasks, + total_done + ); + + for e in &epics { + let task_list = tasks_by_epic.get(&e.id); + let done_count = task_list + .map(|tasks| { + tasks + .iter() + .filter(|t| t.status == flowctl_core::state_machine::Status::Done) + .count() + }) + .unwrap_or(0); + let task_count = task_list.map(|t| t.len()).unwrap_or(0); + let progress = if task_count > 0 { + format!("{}/{}", done_count, task_count) + } else { + "0/0".to_string() + }; + + println!( + "[{}] {}: {} ({} done)", + e.status, e.id, e.title, progress + ); + + if let Some(tasks) = task_list { + for t in tasks { + let deps = if t.depends_on.is_empty() { + String::new() + } else { + format!(" (deps: {})", t.depends_on.join(", ")) + }; + println!( + " [{}] {}: {}{}", + t.status, t.id, t.title, deps + ); + } + } + println!(); + } + } +} + +// ── Cat command ───────────────────────────────────────────────────── + +pub fn cmd_cat(id: String) { + let flow_dir = ensure_flow_exists(); + + let spec_path = if is_epic_id(&id) { + flow_dir.join(SPECS_DIR).join(format!("{}.md", id)) + } else if is_task_id(&id) { + flow_dir.join(TASKS_DIR).join(format!("{}.md", id)) + } else { + error_exit(&format!( + "Invalid ID: {}. Expected format: fn-N or fn-N-slug (epic), fn-N.M or fn-N-slug.M (task)", + id + )); + }; + + match fs::read_to_string(&spec_path) { + Ok(content) => print!("{}", content), + Err(_) => { + error_exit(&format!( + "Spec not found: {}", + spec_path.display() + )); + } + } +} + +// ── Stub commands (not yet ported) ────────────────────────────────── + +pub fn cmd_files(json_mode: bool, epic: String) { + let flow_dir = ensure_flow_exists(); + + if !is_epic_id(&epic) { + error_exit(&format!("Invalid epic ID: {}", epic)); + } + + let tasks = get_epic_tasks(&flow_dir, &epic); + + // Build ownership map: file -> list of task IDs + let mut ownership: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + + for task in &tasks { + let mut task_files: Vec = task.files.clone(); + + // Fallback: parse **Files:** from spec markdown if no structured files + if task_files.is_empty() { + let spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task.id)); + if let Ok(spec_text) = fs::read_to_string(&spec_path) { + for line in spec_text.lines() { + if let Some(rest) = line.strip_prefix("**Files:**") { + task_files = rest + .split(',') + .map(|f| f.trim().trim_matches('`').to_string()) + .filter(|f| !f.is_empty()) + .collect(); + break; + } + } + } + } + + for fp in task_files { + ownership + .entry(fp) + .or_default() + .push(task.id.clone()); + } + } + + let conflicts: std::collections::BTreeMap<&String, &Vec> = ownership + .iter() + .filter(|(_, tasks)| tasks.len() > 1) + .collect(); + + if json_mode { + json_output(json!({ + "epic": epic, + "ownership": ownership, + "conflicts": conflicts, + "file_count": ownership.len(), + "conflict_count": conflicts.len(), + })); + } else { + println!("File ownership for {}:\n", epic); + if ownership.is_empty() { + println!(" No files declared."); + } else { + for (f, task_ids) in &ownership { + if task_ids.len() == 1 { + println!(" {} \u{2192} {}", f, task_ids[0]); + } else { + println!(" {} \u{2192} CONFLICT: {}", f, task_ids.join(", ")); + } + } + if !conflicts.is_empty() { + println!( + "\n \u{26a0} {} file conflict(s) \u{2014} tasks sharing files cannot run in parallel", + conflicts.len() + ); + } + } + } +} + +// ── Lock commands (Teams mode) ───────────────────────────────────── + +/// Open DB or exit with error. +fn open_db_or_exit() -> rusqlite::Connection { + let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + match flowctl_db::open(&cwd) { + Ok(conn) => conn, + Err(e) => { + error_exit(&format!("Cannot open database: {}", e)); + } + } +} + +pub fn cmd_lock(json: bool, task: String, files: String) { + let _flow_dir = ensure_flow_exists(); + + let file_list: Vec<&str> = files.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + if file_list.is_empty() { + error_exit("No files specified for locking."); + } + + let conn = open_db_or_exit(); + let repo = flowctl_db::FileLockRepo::new(&conn); + + let mut locked = Vec::new(); + let mut already_locked = Vec::new(); + + for file in &file_list { + match repo.acquire(file, &task) { + Ok(()) => locked.push(file.to_string()), + Err(flowctl_db::DbError::Constraint(_)) => { + // Already locked — find out by whom + let owner = repo.check(file).ok().flatten().unwrap_or_else(|| "unknown".to_string()); + if owner == task { + // Re-locking own file is fine, treat as locked + locked.push(file.to_string()); + } else { + already_locked.push(json!({"file": file, "owner": owner})); + } + } + Err(e) => { + error_exit(&format!("Failed to lock {}: {}", file, e)); + } + } + } + + if json { + json_output(json!({ + "locked": locked, + "already_locked": already_locked, + "task": task, + })); + } else { + if !locked.is_empty() { + println!("Locked {} file(s) for task {}", locked.len(), task); + } + for al in &already_locked { + println!( + "Already locked: {} (owner: {})", + al["file"].as_str().unwrap_or(""), + al["owner"].as_str().unwrap_or("") + ); + } + } +} + +pub fn cmd_unlock(json: bool, task: Option, _files: Option, all: bool) { + let _flow_dir = ensure_flow_exists(); + let conn = open_db_or_exit(); + let repo = flowctl_db::FileLockRepo::new(&conn); + + if all { + match repo.release_all() { + Ok(count) => { + if json { + json_output(json!({ + "cleared": count, + "message": format!("Cleared {} file lock(s)", count), + })); + } else { + println!("Cleared {} file lock(s)", count); + } + } + Err(e) => error_exit(&format!("Failed to clear locks: {}", e)), + } + return; + } + + let task_id = match task { + Some(t) => t, + None => { + error_exit("--task is required (or use --all to clear all locks)"); + } + }; + + match repo.release_for_task(&task_id) { + Ok(count) => { + if json { + json_output(json!({ + "task": task_id, + "unlocked": count, + "message": format!("Released {} lock(s) for task {}", count, task_id), + })); + } else { + println!("Released {} lock(s) for task {}", count, task_id); + } + } + Err(e) => error_exit(&format!("Failed to unlock: {}", e)), + } +} + +pub fn cmd_lock_check(json: bool, file: Option) { + let _flow_dir = ensure_flow_exists(); + let conn = open_db_or_exit(); + let repo = flowctl_db::FileLockRepo::new(&conn); + + match file { + Some(f) => { + match repo.check(&f) { + Ok(Some(owner)) => { + if json { + json_output(json!({ + "file": f, + "locked": true, + "owner": owner, + })); + } else { + println!("{}: locked by {}", f, owner); + } + } + Ok(None) => { + if json { + json_output(json!({ + "file": f, + "locked": false, + })); + } else { + println!("{}: not locked", f); + } + } + Err(e) => error_exit(&format!("Failed to check lock: {}", e)), + } + } + None => { + // List all locks — query directly + let mut stmt = conn + .prepare("SELECT file_path, task_id, locked_at FROM file_locks ORDER BY file_path") + .unwrap_or_else(|e| { error_exit(&format!("Query failed: {}", e)); }); + let locks: Vec = stmt + .query_map([], |row| { + Ok(json!({ + "file": row.get::<_, String>(0)?, + "task_id": row.get::<_, String>(1)?, + "locked_at": row.get::<_, String>(2)?, + })) + }) + .unwrap_or_else(|e| { error_exit(&format!("Query failed: {}", e)); }) + .filter_map(|r| r.ok()) + .collect(); + + if json { + json_output(json!({ + "locks": locks, + "count": locks.len(), + })); + } else if locks.is_empty() { + println!("No file locks active."); + } else { + println!("Active file locks ({}):\n", locks.len()); + for l in &locks { + println!( + " {} → {} (since {})", + l["file"].as_str().unwrap_or(""), + l["task_id"].as_str().unwrap_or(""), + l["locked_at"].as_str().unwrap_or("") + ); + } + } + } + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/ralph.rs b/flowctl/crates/flowctl-cli/src/commands/ralph.rs new file mode 100644 index 00000000..b8898d79 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/ralph.rs @@ -0,0 +1,224 @@ +//! Ralph run control commands: pause, resume, stop, status. +//! +//! Ralph runs live in `scripts/ralph/runs//`. Control is via +//! sentinel files (PAUSE, STOP) and progress is tracked in progress.txt. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use clap::Subcommand; +use regex::Regex; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +#[derive(Subcommand, Debug)] +pub enum RalphCmd { + /// Pause a Ralph run. + Pause { + /// Run ID (auto-detect if single). + #[arg(long)] + run: Option, + }, + /// Resume a paused Ralph run. + Resume { + /// Run ID (auto-detect if single). + #[arg(long)] + run: Option, + }, + /// Request a Ralph run to stop. + Stop { + /// Run ID (auto-detect if single). + #[arg(long)] + run: Option, + }, + /// Show Ralph run status. + Status { + /// Run ID (auto-detect if single). + #[arg(long)] + run: Option, + }, +} + +// ── Helpers ───────────────────────────────────────────────────────── + +/// Get repo root via `git rev-parse --show-toplevel`, falling back to cwd. +fn get_repo_root() -> PathBuf { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output(); + match output { + Ok(o) if o.status.success() => { + PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } + _ => env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + } +} + +/// Scan `scripts/ralph/runs/*/progress.txt` for active runs. +/// A run is active if progress.txt exists and does NOT contain both +/// `completion_reason=` and `promise=COMPLETE`. +fn find_active_runs() -> Vec<(String, PathBuf)> { + let runs_dir = get_repo_root().join("scripts").join("ralph").join("runs"); + let mut active = Vec::new(); + + let entries = match fs::read_dir(&runs_dir) { + Ok(e) => e, + Err(_) => return active, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let progress = path.join("progress.txt"); + if !progress.exists() { + continue; + } + let content = fs::read_to_string(&progress).unwrap_or_default(); + if content.contains("completion_reason=") && content.contains("promise=COMPLETE") { + continue; + } + let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + active.push((name, path)); + } + + active +} + +/// Find a single active run. Auto-detect if run_id is None. +fn find_active_run(run_id: Option<&str>) -> (String, PathBuf) { + let runs = find_active_runs(); + + if let Some(rid) = run_id { + for (name, path) in &runs { + if name == rid { + return (name.clone(), path.clone()); + } + } + error_exit(&format!("Run {rid} not found or not active")); + } + + match runs.len() { + 0 => error_exit("No active runs"), + 1 => runs.into_iter().next().unwrap(), + _ => { + let ids: Vec<_> = runs.iter().map(|(n, _)| n.as_str()).collect(); + error_exit(&format!("Multiple active runs, specify --run: {}", ids.join(", "))); + } + } +} + +/// Parse progress.txt for iteration, epic, and task info. +fn parse_progress(run_dir: &Path) -> (Option, Option, Option) { + let progress = run_dir.join("progress.txt"); + let content = match fs::read_to_string(&progress) { + Ok(c) => c, + Err(_) => return (None, None, None), + }; + + let iter_re = Regex::new(r"(?i)iteration[:\s]+(\d+)").unwrap(); + let epic_re = Regex::new(r"(?i)epic[:\s]+(fn-[\w-]+)").unwrap(); + let task_re = Regex::new(r"(?i)task[:\s]+(fn-[\w.-]+\.\d+)").unwrap(); + + let iteration = iter_re.captures(&content).and_then(|c| c[1].parse().ok()); + let epic = epic_re.captures(&content).map(|c| c[1].to_string()); + let task = task_re.captures(&content).map(|c| c[1].to_string()); + + (iteration, epic, task) +} + +// ── Dispatch ──────────────────────────────────────────────────────── + +pub fn dispatch(cmd: &RalphCmd, json: bool) { + match cmd { + RalphCmd::Pause { run } => cmd_pause(json, run.as_deref()), + RalphCmd::Resume { run } => cmd_resume(json, run.as_deref()), + RalphCmd::Stop { run } => cmd_stop(json, run.as_deref()), + RalphCmd::Status { run } => cmd_status(json, run.as_deref()), + } +} + +// ── Commands ──────────────────────────────────────────────────────── + +fn cmd_pause(json_mode: bool, run_id: Option<&str>) { + let (name, run_dir) = find_active_run(run_id); + let pause_file = run_dir.join("PAUSE"); + let _ = fs::write(&pause_file, ""); + + if json_mode { + json_output(json!({"run": name, "action": "paused"})); + } else { + println!("Paused {name}"); + } +} + +fn cmd_resume(json_mode: bool, run_id: Option<&str>) { + let (name, run_dir) = find_active_run(run_id); + let pause_file = run_dir.join("PAUSE"); + let _ = fs::remove_file(&pause_file); + + if json_mode { + json_output(json!({"run": name, "action": "resumed"})); + } else { + println!("Resumed {name}"); + } +} + +fn cmd_stop(json_mode: bool, run_id: Option<&str>) { + let (name, run_dir) = find_active_run(run_id); + let stop_file = run_dir.join("STOP"); + let _ = fs::write(&stop_file, ""); + + if json_mode { + json_output(json!({"run": name, "action": "stop_requested"})); + } else { + println!("Stop requested for {name}"); + } +} + +fn cmd_status(json_mode: bool, run_id: Option<&str>) { + let (name, run_dir) = find_active_run(run_id); + let paused = run_dir.join("PAUSE").exists(); + let stopped = run_dir.join("STOP").exists(); + let (iteration, current_epic, current_task) = parse_progress(&run_dir); + + if json_mode { + json_output(json!({ + "run": name, + "iteration": iteration, + "current_epic": current_epic, + "current_task": current_task, + "paused": paused, + "stopped": stopped, + })); + } else { + let mut state = Vec::new(); + if paused { state.push("PAUSED"); } + if stopped { state.push("STOPPED"); } + let state_str = if state.is_empty() { + " [running]".to_string() + } else { + format!(" [{}]", state.join(", ")) + }; + + let task_info = if let Some(ref t) = current_task { + format!(", working on {t}") + } else if let Some(ref e) = current_epic { + format!(", epic {e}") + } else { + String::new() + }; + + let iter_info = if let Some(i) = iteration { + format!("iteration {i}") + } else { + "starting".to_string() + }; + + println!("{name} ({iter_info}{task_info}){state_str}"); + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/rp.rs b/flowctl/crates/flowctl-cli/src/commands/rp.rs new file mode 100644 index 00000000..8a3f34ba --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/rp.rs @@ -0,0 +1,796 @@ +//! RepoPrompt wrapper commands. +//! +//! Delegates to `rp-cli` for all RP operations. Handles window matching, +//! workspace management, builder invocation, and chat/prompt operations. + +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; + +use clap::Subcommand; +use regex::Regex; +use sha2::{Digest, Sha256}; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +#[derive(Subcommand, Debug)] +pub enum RpCmd { + /// List RepoPrompt windows. + Windows, + /// Pick window by repo root. + PickWindow { + /// Repo root path. + #[arg(long)] + repo_root: String, + }, + /// Ensure workspace and switch window. + EnsureWorkspace { + /// Window id. + #[arg(long)] + window: i64, + /// Repo root path. + #[arg(long)] + repo_root: String, + }, + /// Run builder and return tab. + Builder { + /// Window id. + #[arg(long)] + window: i64, + /// Builder summary. + #[arg(long)] + summary: String, + /// Builder response type. + #[arg(long, value_parser = ["review", "plan", "question", "clarify"])] + response_type: Option, + }, + /// Get current prompt. + PromptGet { + /// Window id. + #[arg(long)] + window: i64, + /// Tab id or name. + #[arg(long)] + tab: String, + }, + /// Set current prompt. + PromptSet { + /// Window id. + #[arg(long)] + window: i64, + /// Tab id or name. + #[arg(long)] + tab: String, + /// Message file. + #[arg(long)] + message_file: String, + }, + /// Get selection. + SelectGet { + /// Window id. + #[arg(long)] + window: i64, + /// Tab id or name. + #[arg(long)] + tab: String, + }, + /// Add files to selection. + SelectAdd { + /// Window id. + #[arg(long)] + window: i64, + /// Tab id or name. + #[arg(long)] + tab: String, + /// Paths to add. + paths: Vec, + }, + /// Send chat via rp-cli. + ChatSend { + /// Window id. + #[arg(long)] + window: i64, + /// Tab id or name. + #[arg(long)] + tab: String, + /// Message file. + #[arg(long)] + message_file: String, + /// Start new chat. + #[arg(long)] + new_chat: bool, + /// Chat name (with --new-chat). + #[arg(long)] + chat_name: Option, + /// Continue specific chat by ID. + #[arg(long)] + chat_id: Option, + /// Chat mode. + #[arg(long, default_value = "chat", value_parser = ["chat", "review", "plan", "edit"])] + mode: String, + /// Override selected paths. + #[arg(long)] + selected_paths: Option>, + }, + /// Export prompt to file. + PromptExport { + /// Window id. + #[arg(long)] + window: i64, + /// Tab id or name. + #[arg(long)] + tab: String, + /// Output file. + #[arg(long)] + out: String, + }, + /// Atomic: pick-window + workspace + builder. + SetupReview { + /// Repo root path. + #[arg(long)] + repo_root: String, + /// Builder summary/instructions. + #[arg(long)] + summary: String, + /// Use builder review mode. + #[arg(long, value_parser = ["review"])] + response_type: Option, + /// Create new RP window if none matches. + #[arg(long)] + create: bool, + }, + /// Prepare JSON for rp-cli chat_send. + PrepChat { + /// (ignored) Epic/task ID for compatibility. + id: Option, + /// File containing message text. + #[arg(long)] + message_file: String, + /// Chat mode. + #[arg(long, default_value = "chat", value_parser = ["chat", "ask"])] + mode: String, + /// Start new chat. + #[arg(long)] + new_chat: bool, + /// Name for new chat. + #[arg(long)] + chat_name: Option, + /// Files to include in context. + #[arg(long)] + selected_paths: Option>, + /// Output file (default: stdout). + #[arg(short, long)] + output: Option, + }, +} + +// ── Helpers ───────────────────────────────────────────────────────── + +/// Find rp-cli in PATH. +fn require_rp_cli() -> String { + which::which("rp-cli") + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| error_exit("rp-cli not found in PATH")) +} + +/// Run rp-cli with args. Returns CompletedProcess-like tuple (stdout, stderr). +/// Exits on error with formatted message. +fn run_rp_cli(args: &[&str], timeout_secs: Option) -> (String, String) { + let rp = require_rp_cli(); + let _timeout = timeout_secs.unwrap_or_else(|| { + env::var("FLOW_RP_TIMEOUT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1200) + }); + + let result = Command::new(&rp) + .args(args) + .output(); + + match result { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if !output.status.success() { + let msg = if !stderr.is_empty() { &stderr } else if !stdout.is_empty() { &stdout } else { "unknown error" }; + error_exit(&format!("rp-cli failed: {}", msg.trim())); + } + (stdout, stderr) + } + Err(e) => { + error_exit(&format!("rp-cli failed: {e}")); + } + } +} + +/// Normalize repo root for window matching. +/// Handles macOS /tmp symlink and git worktree resolution. +fn normalize_repo_root(path: &str) -> Vec { + let real = match fs::canonicalize(path) { + Ok(p) => p.to_string_lossy().to_string(), + Err(_) => path.to_string(), + }; + + let mut roots = vec![real.clone()]; + + // macOS /tmp symlink handling + if real.starts_with("/private/tmp/") { + roots.push(format!("/tmp/{}", &real["/private/tmp/".len()..])); + } else if real.starts_with("/tmp/") { + roots.push(format!("/private/tmp/{}", &real["/tmp/".len()..])); + } + + // Git worktree -> main repo resolution + if let Ok(output) = Command::new("git") + .args(["-C", &real, "rev-parse", "--git-common-dir"]) + .output() + { + if output.status.success() { + let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !common_dir.is_empty() && !common_dir.starts_with('.') { + if let Ok(canon) = fs::canonicalize(&common_dir) { + if let Some(parent) = canon.parent() { + let main_repo = parent.to_string_lossy().to_string(); + if main_repo != real { + roots.push(main_repo.clone()); + if main_repo.starts_with("/private/tmp/") { + roots.push(format!("/tmp/{}", &main_repo["/private/tmp/".len()..])); + } + } + } + } + } + } + } + + // Deduplicate while preserving order + let mut seen = std::collections::HashSet::new(); + roots.retain(|r| seen.insert(r.clone())); + roots +} + +/// Parse windows JSON from rp-cli output. +fn parse_windows(raw: &str) -> Vec { + if let Ok(data) = serde_json::from_str::(raw) { + if let Some(arr) = data.as_array() { + return arr.clone(); + } + if let Some(obj) = data.as_object() { + if let Some(wins) = obj.get("windows").and_then(|v| v.as_array()) { + return wins.clone(); + } + } + } + if raw.contains("single-window mode") { + return vec![json!({"windowID": 1, "rootFolderPaths": []})]; + } + error_exit("windows JSON parse failed"); +} + +/// Extract window ID from a window object. +fn extract_window_id(win: &serde_json::Value) -> Option { + for key in &["windowID", "windowId", "id"] { + if let Some(v) = win.get(key) { + if let Some(n) = v.as_i64() { + return Some(n); + } + if let Some(s) = v.as_str() { + if let Ok(n) = s.parse::() { + return Some(n); + } + } + } + } + None +} + +/// Extract root folder paths from a window object. +fn extract_root_paths(win: &serde_json::Value) -> Vec { + for key in &["rootFolderPaths", "rootFolders", "rootFolderPath"] { + if let Some(v) = win.get(key) { + if let Some(arr) = v.as_array() { + return arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect(); + } + if let Some(s) = v.as_str() { + return vec![s.to_string()]; + } + } + } + Vec::new() +} + +/// Parse builder output for tab ID. +fn parse_builder_tab(output: &str) -> Option { + let re = Regex::new(r"Tab:\s*([A-Za-z0-9-]+)").unwrap(); + re.captures(output).map(|c| c[1].to_string()) +} + +/// Parse chat ID from output. +fn parse_chat_id(output: &str) -> Option { + let re1 = Regex::new(r#"Chat\s*:\s*`([^`]+)`"#).unwrap(); + if let Some(c) = re1.captures(output) { + return Some(c[1].to_string()); + } + let re2 = Regex::new(r#""chat_id"\s*:\s*"([^"]+)""#).unwrap(); + re2.captures(output).map(|c| c[1].to_string()) +} + +/// Build chat payload JSON. +fn build_chat_payload( + message: &str, + mode: &str, + new_chat: bool, + chat_name: Option<&str>, + chat_id: Option<&str>, + selected_paths: Option<&[String]>, +) -> String { + let mut payload = json!({ + "message": message, + "mode": mode, + }); + if new_chat { + payload["new_chat"] = json!(true); + } + if let Some(name) = chat_name { + payload["chat_name"] = json!(name); + } + if let Some(cid) = chat_id { + payload["chat_id"] = json!(cid); + } + if let Some(paths) = selected_paths { + payload["selected_paths"] = json!(paths); + } + serde_json::to_string(&payload).unwrap_or_default() +} + +/// Shell-quote a string (simple: wrap in single quotes, escape inner quotes). +fn shell_quote(s: &str) -> String { + if s.is_empty() { + return "''".to_string(); + } + format!("'{}'", s.replace('\'', "'\\''")) +} + +// ── Dispatch ──────────────────────────────────────────────────────── + +pub fn dispatch(cmd: &RpCmd, json: bool) { + match cmd { + RpCmd::Windows => cmd_windows(json), + RpCmd::PickWindow { repo_root } => cmd_pick_window(json, repo_root), + RpCmd::EnsureWorkspace { window, repo_root } => cmd_ensure_workspace(json, *window, repo_root), + RpCmd::Builder { window, summary, response_type } => cmd_builder(json, *window, summary, response_type.as_deref()), + RpCmd::PromptGet { window, tab } => cmd_prompt_get(*window, tab), + RpCmd::PromptSet { window, tab, message_file } => cmd_prompt_set(*window, tab, message_file), + RpCmd::SelectGet { window, tab } => cmd_select_get(*window, tab), + RpCmd::SelectAdd { window, tab, paths } => cmd_select_add(*window, tab, paths), + RpCmd::ChatSend { window, tab, message_file, new_chat, chat_name, chat_id, mode, selected_paths } => { + cmd_chat_send(json, *window, tab, message_file, *new_chat, chat_name.as_deref(), chat_id.as_deref(), mode, selected_paths.as_ref().map(|v| v.as_slice())); + } + RpCmd::PromptExport { window, tab, out } => cmd_prompt_export(*window, tab, out), + RpCmd::SetupReview { repo_root, summary, response_type, create } => { + cmd_setup_review(json, repo_root, summary, response_type.as_deref(), *create); + } + RpCmd::PrepChat { id: _, message_file, mode, new_chat, chat_name, selected_paths, output } => { + cmd_prep_chat(message_file, mode, *new_chat, chat_name.as_deref(), selected_paths.as_ref().map(|v| v.as_slice()), output.as_deref()); + } + } +} + +// ── Command implementations ───────────────────────────────────────── + +fn cmd_windows(json_mode: bool) { + let (stdout, _) = run_rp_cli(&["--raw-json", "-e", "windows"], None); + if json_mode { + let windows = parse_windows(&stdout); + println!("{}", serde_json::to_string(&windows).unwrap_or_default()); + } else { + print!("{stdout}"); + } +} + +fn cmd_pick_window(json_mode: bool, repo_root: &str) { + let roots = normalize_repo_root(repo_root); + let (stdout, _) = run_rp_cli(&["--raw-json", "-e", "windows"], None); + let windows = parse_windows(&stdout); + + // Single window with no root paths — use it + if windows.len() == 1 && extract_root_paths(&windows[0]).is_empty() { + if let Some(win_id) = extract_window_id(&windows[0]) { + if json_mode { + println!("{}", json!({"window": win_id})); + } else { + println!("{win_id}"); + } + return; + } + } + + // Match by root path + for win in &windows { + if let Some(win_id) = extract_window_id(win) { + for path in extract_root_paths(win) { + if roots.contains(&path) { + if json_mode { + println!("{}", json!({"window": win_id})); + } else { + println!("{win_id}"); + } + return; + } + } + } + } + + error_exit("No window matches repo root"); +} + +fn cmd_ensure_workspace(_json_mode: bool, window: i64, repo_root: &str) { + let real_root = fs::canonicalize(repo_root) + .unwrap_or_else(|_| std::path::PathBuf::from(repo_root)) + .to_string_lossy() + .to_string(); + let ws_name = Path::new(&real_root) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + // List workspaces + let list_payload = serde_json::to_string(&json!({"action": "list"})).unwrap(); + let list_expr = format!("call manage_workspaces {list_payload}"); + let win_str = window.to_string(); + let (stdout, _) = run_rp_cli(&["--raw-json", "-w", &win_str, "-e", &list_expr], None); + + // Check if workspace exists + let ws_exists = if let Ok(data) = serde_json::from_str::(&stdout) { + extract_workspace_names(&data).contains(&ws_name) + } else { + false + }; + + // Create if needed + if !ws_exists { + let create_payload = serde_json::to_string(&json!({ + "action": "create", + "name": ws_name, + "folder_path": real_root, + })).unwrap(); + let create_expr = format!("call manage_workspaces {create_payload}"); + run_rp_cli(&["-w", &win_str, "-e", &create_expr], None); + } + + // Switch to workspace + let switch_payload = serde_json::to_string(&json!({ + "action": "switch", + "workspace": ws_name, + "window_id": window, + })).unwrap(); + let switch_expr = format!("call manage_workspaces {switch_payload}"); + run_rp_cli(&["-w", &win_str, "-e", &switch_expr], None); +} + +/// Extract workspace names from the list response. +fn extract_workspace_names(data: &serde_json::Value) -> Vec { + let list = if let Some(ws) = data.get("workspaces") { + ws + } else if let Some(r) = data.get("result") { + r + } else { + data + }; + + if let Some(arr) = list.as_array() { + arr.iter().filter_map(|item| { + if let Some(s) = item.as_str() { + return Some(s.to_string()); + } + if let Some(obj) = item.as_object() { + for key in &["name", "workspace", "title"] { + if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) { + return Some(v.to_string()); + } + } + } + None + }).collect() + } else { + Vec::new() + } +} + +fn cmd_builder(json_mode: bool, window: i64, summary: &str, response_type: Option<&str>) { + let summary_json = serde_json::to_string(summary).unwrap_or_else(|_| format!("\"{summary}\"")); + let mut builder_expr = format!("builder {summary_json}"); + if let Some(rt) = response_type { + builder_expr.push_str(&format!(" --type {rt}")); + } + + let win_str = window.to_string(); + let mut args: Vec<&str> = Vec::new(); + if response_type.is_some() { + args.extend_from_slice(&["--raw-json"]); + } + args.extend_from_slice(&["-w", &win_str, "-e", &builder_expr]); + + let (stdout, stderr) = run_rp_cli(&args, None); + let output = format!("{stdout}{}", if stderr.is_empty() { String::new() } else { format!("\n{stderr}") }); + + if response_type == Some("review") { + if let Ok(data) = serde_json::from_str::(&stdout) { + let tab = data.get("tab_id").and_then(|v| v.as_str()).unwrap_or(""); + let chat_id = data.get("review").and_then(|v| v.get("chat_id")).and_then(|v| v.as_str()).unwrap_or(""); + let review_response = data.get("review").and_then(|v| v.get("response")).and_then(|v| v.as_str()).unwrap_or(""); + + if json_mode { + json_output(json!({ + "window": window, + "tab": tab, + "chat_id": chat_id, + "review": review_response, + "file_count": data.get("file_count").and_then(|v| v.as_i64()).unwrap_or(0), + "total_tokens": data.get("total_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + })); + } else { + println!("T={tab} CHAT_ID={chat_id}"); + if !review_response.is_empty() { + println!("{review_response}"); + } + } + return; + } + // Fallback to tab parsing + } + + match parse_builder_tab(&output) { + Some(tab) => { + if json_mode { + json_output(json!({"window": window, "tab": tab})); + } else { + println!("{tab}"); + } + } + None => error_exit("builder output missing Tab id"), + } +} + +fn cmd_prompt_get(window: i64, tab: &str) { + let win_str = window.to_string(); + let (stdout, _) = run_rp_cli(&["-w", &win_str, "-t", tab, "-e", "prompt get"], None); + print!("{stdout}"); +} + +fn cmd_prompt_set(window: i64, tab: &str, message_file: &str) { + let message = fs::read_to_string(message_file) + .unwrap_or_else(|e| error_exit(&format!("Failed to read message file: {e}"))); + let payload = serde_json::to_string(&json!({"op": "set", "text": message})).unwrap(); + let expr = format!("call prompt {payload}"); + let win_str = window.to_string(); + let (stdout, _) = run_rp_cli(&["-w", &win_str, "-t", tab, "-e", &expr], None); + print!("{stdout}"); +} + +fn cmd_select_get(window: i64, tab: &str) { + let win_str = window.to_string(); + let (stdout, _) = run_rp_cli(&["-w", &win_str, "-t", tab, "-e", "select get"], None); + print!("{stdout}"); +} + +fn cmd_select_add(window: i64, tab: &str, paths: &[String]) { + if paths.is_empty() { + error_exit("select-add requires at least one path"); + } + let quoted: Vec = paths.iter().map(|p| shell_quote(p)).collect(); + let expr = format!("select add {}", quoted.join(" ")); + let win_str = window.to_string(); + let (stdout, _) = run_rp_cli(&["-w", &win_str, "-t", tab, "-e", &expr], None); + print!("{stdout}"); +} + +fn cmd_chat_send( + json_mode: bool, + window: i64, + tab: &str, + message_file: &str, + new_chat: bool, + chat_name: Option<&str>, + chat_id: Option<&str>, + mode: &str, + selected_paths: Option<&[String]>, +) { + let message = fs::read_to_string(message_file) + .unwrap_or_else(|e| error_exit(&format!("Failed to read message file: {e}"))); + let payload = build_chat_payload( + &message, + mode, + new_chat, + chat_name, + chat_id, + selected_paths, + ); + let expr = format!("call chat_send {payload}"); + let win_str = window.to_string(); + let (stdout, stderr) = run_rp_cli(&["-w", &win_str, "-t", tab, "-e", &expr], None); + let output = format!("{stdout}{}", if stderr.is_empty() { String::new() } else { format!("\n{stderr}") }); + + let cid = parse_chat_id(&output); + if json_mode { + println!("{}", json!({"chat": cid})); + } else { + print!("{stdout}"); + } +} + +fn cmd_prompt_export(window: i64, tab: &str, out: &str) { + let quoted_out = shell_quote(out); + let expr = format!("prompt export {quoted_out}"); + let win_str = window.to_string(); + let (stdout, _) = run_rp_cli(&["-w", &win_str, "-t", tab, "-e", &expr], None); + print!("{stdout}"); +} + +fn cmd_setup_review( + json_mode: bool, + repo_root: &str, + summary: &str, + response_type: Option<&str>, + create: bool, +) { + let real_root = fs::canonicalize(repo_root) + .unwrap_or_else(|_| std::path::PathBuf::from(repo_root)) + .to_string_lossy() + .to_string(); + + // Step 1: pick-window + let roots = normalize_repo_root(&real_root); + let (stdout, _) = run_rp_cli(&["--raw-json", "-e", "windows"], Some(30)); + let windows = parse_windows(&stdout); + + let mut win_id: Option = None; + + // Single window with no root paths — use it + if windows.len() == 1 && extract_root_paths(&windows[0]).is_empty() { + win_id = extract_window_id(&windows[0]); + } + + // Match by root path + if win_id.is_none() { + for win in &windows { + if let Some(wid) = extract_window_id(win) { + for path in extract_root_paths(win) { + if roots.contains(&path) { + win_id = Some(wid); + break; + } + } + if win_id.is_some() { break; } + } + } + } + + if win_id.is_none() { + if create { + let ws_name = Path::new(&real_root) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let create_cmd = format!( + "workspace create {} --new-window --folder-path {}", + shell_quote(&ws_name), + shell_quote(&real_root), + ); + let (stdout, _) = run_rp_cli(&["--raw-json", "-e", &create_cmd], None); + if let Ok(data) = serde_json::from_str::(&stdout) { + win_id = data.get("window_id").and_then(|v| v.as_i64()); + } + if win_id.is_none() { + error_exit("Failed to create RP window"); + } + } else { + error_exit("No RepoPrompt window matches repo root"); + } + } + + let win_id = win_id.unwrap(); + + // Write state file for ralph-guard + let mut hasher = Sha256::new(); + hasher.update(real_root.as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + let state_path = format!("/tmp/.ralph-pick-window-{}", &hash[..16]); + let _ = fs::write(&state_path, format!("{win_id}\n{real_root}\n")); + + // Step 2: builder + let summary_json = serde_json::to_string(summary).unwrap_or_else(|_| format!("\"{summary}\"")); + let mut builder_expr = format!("builder {summary_json}"); + if let Some(rt) = response_type { + builder_expr.push_str(&format!(" --type {rt}")); + } + + let win_str = win_id.to_string(); + let mut args: Vec<&str> = Vec::new(); + args.push("-w"); + args.push(&win_str); + if response_type.is_some() { + args.push("--raw-json"); + } + args.push("-e"); + args.push(&builder_expr); + + let (stdout, stderr) = run_rp_cli(&args, Some(1000)); + let output = format!("{stdout}{}", if stderr.is_empty() { String::new() } else { format!("\n{stderr}") }); + + if response_type == Some("review") { + match serde_json::from_str::(&stdout) { + Ok(data) => { + let tab = data.get("tab_id").and_then(|v| v.as_str()).unwrap_or(""); + let chat_id = data.get("review").and_then(|v| v.get("chat_id")).and_then(|v| v.as_str()).unwrap_or(""); + let review_response = data.get("review").and_then(|v| v.get("response")).and_then(|v| v.as_str()).unwrap_or(""); + + if tab.is_empty() { + error_exit("Builder did not return a tab id"); + } + + if json_mode { + json_output(json!({ + "window": win_id, + "tab": tab, + "chat_id": chat_id, + "review": review_response, + "repo_root": real_root, + "file_count": data.get("file_count").and_then(|v| v.as_i64()).unwrap_or(0), + "total_tokens": data.get("total_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + })); + } else { + println!("W={win_id} T={tab} CHAT_ID={chat_id}"); + if !review_response.is_empty() { + println!("{review_response}"); + } + } + } + Err(_) => error_exit("Failed to parse builder review response"), + } + } else { + match parse_builder_tab(&output) { + Some(tab) => { + if json_mode { + json_output(json!({"window": win_id, "tab": tab, "repo_root": real_root})); + } else { + println!("W={win_id} T={tab}"); + } + } + None => error_exit("Builder did not return a tab id"), + } + } +} + +fn cmd_prep_chat( + message_file: &str, + mode: &str, + new_chat: bool, + chat_name: Option<&str>, + selected_paths: Option<&[String]>, + output_file: Option<&str>, +) { + let message = fs::read_to_string(message_file) + .unwrap_or_else(|e| error_exit(&format!("Failed to read message file: {e}"))); + let json_str = build_chat_payload( + &message, + mode, + new_chat, + chat_name, + None, + selected_paths, + ); + + if let Some(out) = output_file { + fs::write(out, &json_str) + .unwrap_or_else(|e| error_exit(&format!("Failed to write output file: {e}"))); + eprintln!("Wrote {out}"); + } else { + println!("{json_str}"); + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/stack.rs b/flowctl/crates/flowctl-cli/src/commands/stack.rs new file mode 100644 index 00000000..b84507a9 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/stack.rs @@ -0,0 +1,694 @@ +//! Stack commands: detect, set, show. +//! Invariants commands: init, show, check. +//! Guard command: run deterministic lint/test/typecheck. + +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; + +use clap::Subcommand; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +use flowctl_core::types::{CONFIG_FILE, FLOW_DIR}; + +// ── Stack ────────────────────────────────────────────────────────── + +#[derive(Subcommand, Debug)] +pub enum StackCmd { + /// Auto-detect project stack. + Detect { + /// Show detection without saving. + #[arg(long)] + dry_run: bool, + }, + /// Set stack config from JSON file. + Set { + /// JSON file path (or - for stdin). + #[arg(long)] + file: String, + }, + /// Show current stack config. + Show, +} + +pub fn dispatch(cmd: &StackCmd, json: bool) { + match cmd { + StackCmd::Detect { dry_run } => cmd_stack_detect(json, *dry_run), + StackCmd::Set { file } => cmd_stack_set(json, file), + StackCmd::Show => cmd_stack_show(json), + } +} + +// ── Invariants ───────────────────────────────────────────────────── + +#[derive(Subcommand, Debug)] +pub enum InvariantsCmd { + /// Create invariants.md template. + Init { + /// Overwrite existing. + #[arg(long)] + force: bool, + }, + /// Show invariants. + Show, + /// Run all verify commands. + Check, +} + +pub fn dispatch_invariants(cmd: &InvariantsCmd, json: bool) { + match cmd { + InvariantsCmd::Init { force } => cmd_invariants_init(json, *force), + InvariantsCmd::Show => cmd_invariants_show(json), + InvariantsCmd::Check => cmd_invariants_check(json), + } +} + +// ── Helpers ──────────────────────────────────────────────────────── + +fn get_flow_dir() -> std::path::PathBuf { + env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .join(FLOW_DIR) +} + +fn get_repo_root() -> std::path::PathBuf { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let path = String::from_utf8_lossy(&out.stdout).trim().to_string(); + std::path::PathBuf::from(path) + } + _ => env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + } +} + +fn ensure_flow_exists() { + if !get_flow_dir().exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } +} + +/// Load config.json, returning the parsed JSON object. +fn load_config() -> serde_json::Value { + let config_path = get_flow_dir().join(CONFIG_FILE); + if !config_path.exists() { + return json!({}); + } + match fs::read_to_string(&config_path) { + Ok(content) => serde_json::from_str(&content).unwrap_or(json!({})), + Err(_) => json!({}), + } +} + +/// Save config.json. +fn save_config(config: &serde_json::Value) { + let config_path = get_flow_dir().join(CONFIG_FILE); + let content = serde_json::to_string_pretty(config).unwrap(); + if let Err(e) = fs::write(&config_path, &content) { + error_exit(&format!("Failed to write config.json: {}", e)); + } +} + +/// Get a top-level config key. +fn get_config_key(key: &str) -> serde_json::Value { + let config = load_config(); + config.get(key).cloned().unwrap_or(json!({})) +} + +/// Set a top-level config key. +fn set_config_key(key: &str, value: serde_json::Value) { + let mut config = load_config(); + config[key] = value; + save_config(&config); +} + +// ── Stack detection ──────────────────────────────────────────────── + +/// Auto-detect project tech stack from files in the repo. +fn detect_stack() -> serde_json::Value { + let repo = get_repo_root(); + let mut stack = json!({}); + + // --- Backend detection --- + let mut backend = json!({}); + + // Rust detection + let cargo_toml = repo.join("Cargo.toml"); + if cargo_toml.exists() { + backend["language"] = json!("rust"); + backend["test"] = json!("cargo test"); + backend["lint"] = json!("cargo clippy -- -D warnings"); + backend["typecheck"] = json!("cargo check"); + + if let Ok(content) = fs::read_to_string(&cargo_toml) { + if content.contains("actix") { + backend["framework"] = json!("actix"); + } else if content.contains("axum") { + backend["framework"] = json!("axum"); + } else if content.contains("rocket") { + backend["framework"] = json!("rocket"); + } + } + } + + // Python detection + let pyproject = repo.join("pyproject.toml"); + let requirements = repo.join("requirements.txt"); + let setup_py = repo.join("setup.py"); + let manage_py = repo.join("manage.py"); + + let has_python = pyproject.exists() || requirements.exists() || setup_py.exists(); + + if has_python && !cargo_toml.exists() { + let mut py_content = String::new(); + if pyproject.exists() { + py_content += &fs::read_to_string(&pyproject).unwrap_or_default(); + } + if requirements.exists() { + py_content += &fs::read_to_string(&requirements).unwrap_or_default(); + } + + backend["language"] = json!("python"); + + // Framework + let py_lower = py_content.to_lowercase(); + if manage_py.exists() || py_lower.contains("django") { + backend["framework"] = json!("django"); + let mut conventions = Vec::new(); + if py_content.contains("rest_framework") || py_content.contains("djangorestframework") + { + conventions.push("DRF"); + } + if py_lower.contains("celery") { + conventions.push("Celery"); + } + if !conventions.is_empty() { + backend["conventions"] = json!(conventions.join(", ")); + } + } else if py_lower.contains("flask") { + backend["framework"] = json!("flask"); + } else if py_lower.contains("fastapi") { + backend["framework"] = json!("fastapi"); + } + + // Test + if py_content.contains("pytest") { + backend["test"] = json!("pytest"); + } else if manage_py.exists() { + backend["test"] = json!("python manage.py test"); + } + + // Lint + if py_content.contains("ruff") { + backend["lint"] = json!("ruff check"); + } else if py_content.contains("flake8") { + backend["lint"] = json!("flake8"); + } + + // Type check + if py_content.contains("mypy") { + backend["typecheck"] = json!("mypy"); + } else if py_content.contains("pyright") { + backend["typecheck"] = json!("pyright"); + } + } + + // Go detection + let go_mod = repo.join("go.mod"); + if go_mod.exists() && !has_python && !cargo_toml.exists() { + backend["language"] = json!("go"); + backend["test"] = json!("go test ./..."); + backend["lint"] = json!("golangci-lint run"); + if let Ok(go_content) = fs::read_to_string(&go_mod) { + if go_content.contains("gin-gonic") { + backend["framework"] = json!("gin"); + } else if go_content.contains("labstack/echo") { + backend["framework"] = json!("echo"); + } else if go_content.contains("gofiber") { + backend["framework"] = json!("fiber"); + } + } + } + + if backend != json!({}) { + stack["backend"] = backend; + } + + // --- Frontend detection --- + let mut frontend = json!({}); + + // Find package.json (root or common frontend dirs) + let mut pkg_json: Option = None; + let mut pkg_path: Option = None; + let mut best_dep_count: i64 = -1; + + let pkg_candidates: Vec = vec![ + repo.join("package.json"), + repo.join("frontend/package.json"), + repo.join("client/package.json"), + repo.join("web/package.json"), + repo.join("app/package.json"), + ]; + + for p in &pkg_candidates { + if p.exists() { + if let Ok(content) = fs::read_to_string(p) { + if let Ok(parsed) = serde_json::from_str::(&content) { + let dep_count = parsed + .get("dependencies") + .and_then(|d| d.as_object()) + .map(|d| d.len()) + .unwrap_or(0) + + parsed + .get("devDependencies") + .and_then(|d| d.as_object()) + .map(|d| d.len()) + .unwrap_or(0); + if dep_count as i64 > best_dep_count { + best_dep_count = dep_count as i64; + pkg_json = Some(parsed); + pkg_path = Some(p.clone()); + } + } + } + } + } + + if let (Some(pkg), Some(ppath)) = (pkg_json, pkg_path) { + let mut all_deps = serde_json::Map::new(); + if let Some(deps) = pkg.get("dependencies").and_then(|d| d.as_object()) { + all_deps.extend(deps.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + if let Some(deps) = pkg.get("devDependencies").and_then(|d| d.as_object()) { + all_deps.extend(deps.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + let scripts = pkg.get("scripts").and_then(|s| s.as_object()); + + // Language + let pkg_parent = ppath.parent().unwrap_or(Path::new(".")); + if repo.join("tsconfig.json").exists() + || pkg_parent.join("tsconfig.json").exists() + || pkg_parent.join("tsconfig.app.json").exists() + { + frontend["language"] = json!("typescript"); + } else { + frontend["language"] = json!("javascript"); + } + + // Framework + if all_deps.contains_key("react") { + frontend["framework"] = json!("react"); + } else if all_deps.contains_key("vue") { + frontend["framework"] = json!("vue"); + } else if all_deps.contains_key("svelte") { + frontend["framework"] = json!("svelte"); + } else if all_deps.contains_key("@angular/core") { + frontend["framework"] = json!("angular"); + } + + // Meta-framework + if all_deps.contains_key("next") { + frontend["meta_framework"] = json!("nextjs"); + } else if all_deps.contains_key("nuxt") { + frontend["meta_framework"] = json!("nuxt"); + } else if all_deps.contains_key("@remix-run/react") { + frontend["meta_framework"] = json!("remix"); + } + + // Package manager + let pkg_mgr = if pkg_parent.join("pnpm-lock.yaml").exists() { + "pnpm" + } else if pkg_parent.join("yarn.lock").exists() { + "yarn" + } else if pkg_parent.join("bun.lockb").exists() || pkg_parent.join("bun.lock").exists() { + "bun" + } else { + "npm" + }; + + // Prefix for subdirectory projects + let prefix = if pkg_parent != repo.as_path() { + if let Ok(rel) = pkg_parent.strip_prefix(&repo) { + format!("cd {} && ", rel.display()) + } else { + String::new() + } + } else { + String::new() + }; + + // Commands from scripts + if let Some(sc) = scripts { + if sc.contains_key("test") { + frontend["test"] = json!(format!("{}{} test", prefix, pkg_mgr)); + } + if sc.contains_key("lint") { + frontend["lint"] = json!(format!("{}{} run lint", prefix, pkg_mgr)); + } + if sc.contains_key("typecheck") || sc.contains_key("type-check") { + let tc_key = if sc.contains_key("typecheck") { + "typecheck" + } else { + "type-check" + }; + frontend["typecheck"] = json!(format!("{}{} run {}", prefix, pkg_mgr, tc_key)); + } else if frontend.get("language").and_then(|l| l.as_str()) == Some("typescript") { + frontend["typecheck"] = json!(format!("{}npx tsc --noEmit", prefix)); + } + } + + // Tailwind + let has_tailwind = all_deps.contains_key("tailwindcss") + || repo.join("tailwind.config.js").exists() + || repo.join("tailwind.config.ts").exists(); + if has_tailwind { + let existing = frontend + .get("conventions") + .and_then(|c| c.as_str()) + .unwrap_or(""); + if existing.is_empty() { + frontend["conventions"] = json!("Tailwind"); + } else { + frontend["conventions"] = json!(format!("Tailwind, {}", existing)); + } + } + } + + if frontend != json!({}) { + stack["frontend"] = frontend; + } + + // --- Infra detection --- + let mut infra = json!({}); + + if repo.join("Dockerfile").exists() { + infra["runtime"] = json!("docker"); + } + if repo.join("docker-compose.yml").exists() + || repo.join("docker-compose.yaml").exists() + || repo.join("compose.yml").exists() + || repo.join("compose.yaml").exists() + { + infra["compose"] = json!(true); + } + if repo.join("terraform").is_dir() { + infra["iac"] = json!("terraform"); + } else if repo.join("pulumi").is_dir() { + infra["iac"] = json!("pulumi"); + } + + if infra != json!({}) { + stack["infra"] = infra; + } + + stack +} + +// ── Stack commands ───────────────────────────────────────────────── + +fn cmd_stack_detect(json_mode: bool, dry_run: bool) { + ensure_flow_exists(); + + let stack = detect_stack(); + + if stack == json!({}) { + if json_mode { + json_output(json!({"stack": {}, "message": "no stack detected"})); + } else { + println!("No stack detected."); + } + return; + } + + if !dry_run { + set_config_key("stack", stack.clone()); + } + + if json_mode { + let msg = if dry_run { + "stack auto-detected (dry-run)" + } else { + "stack auto-detected" + }; + json_output(json!({"stack": stack, "message": msg})); + } else { + if dry_run { + println!("Detected stack (dry-run, not saved):"); + } else { + println!("Stack detected and saved:"); + } + println!( + "{}", + serde_json::to_string_pretty(&stack).unwrap_or_default() + ); + } +} + +fn cmd_stack_set(json_mode: bool, file: &str) { + ensure_flow_exists(); + + let raw = if file == "-" { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .unwrap_or_else(|e| { + error_exit(&format!("Failed to read stdin: {}", e)); + }); + buf + } else { + fs::read_to_string(file).unwrap_or_else(|e| { + error_exit(&format!("Failed to read {}: {}", file, e)); + }) + }; + + let stack_data: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| { + error_exit(&format!("Invalid JSON: {}", e)); + }); + + if !stack_data.is_object() { + error_exit("Stack config must be a JSON object"); + } + + set_config_key("stack", stack_data.clone()); + + if json_mode { + json_output(json!({"stack": stack_data, "message": "stack config updated"})); + } else { + println!( + "{}", + serde_json::to_string_pretty(&stack_data).unwrap_or_default() + ); + } +} + +fn cmd_stack_show(json_mode: bool) { + ensure_flow_exists(); + + let stack = get_config_key("stack"); + + if json_mode { + json_output(json!({"stack": stack})); + } else if stack == json!({}) { + println!("No stack configured. Use 'flowctl stack set --file ' to set."); + } else { + println!( + "{}", + serde_json::to_string_pretty(&stack).unwrap_or_default() + ); + } +} + +// ── Invariants commands ──────────────────────────────────────────── + +const INVARIANTS_FILE: &str = "invariants.md"; + +fn invariants_path() -> std::path::PathBuf { + get_flow_dir().join(INVARIANTS_FILE) +} + +fn cmd_invariants_init(json_mode: bool, force: bool) { + ensure_flow_exists(); + + let inv_path = invariants_path(); + if inv_path.exists() && !force { + if json_mode { + json_output(json!({ + "created": false, + "message": "invariants.md already exists. Use --force to overwrite.", + })); + } else { + println!("invariants.md already exists. Use --force to overwrite."); + } + return; + } + + let template = r#"# Architecture Invariants + +Rules that must NEVER be violated, regardless of task or feature. +Workers check these during Phase 1. Planners check during Step 1. + + +"#; + + if let Err(e) = fs::write(&inv_path, template) { + error_exit(&format!("Failed to write invariants.md: {}", e)); + } + + if json_mode { + json_output(json!({ + "created": true, + "path": inv_path.to_string_lossy(), + "message": "invariants.md created", + })); + } else { + println!("Created: {}", inv_path.display()); + } +} + +fn cmd_invariants_show(json_mode: bool) { + ensure_flow_exists(); + + let inv_path = invariants_path(); + if !inv_path.exists() { + if json_mode { + json_output(json!({ + "invariants": null, + "message": "no invariants.md \u{2014} create with 'flowctl invariants init'", + })); + } else { + println!("No invariants.md. Create with: flowctl invariants init"); + } + return; + } + + let content = fs::read_to_string(&inv_path).unwrap_or_else(|e| { + error_exit(&format!("Failed to read invariants.md: {}", e)); + }); + + if json_mode { + json_output(json!({ + "invariants": content, + "path": inv_path.to_string_lossy(), + })); + } else { + println!("{}", content); + } +} + +fn cmd_invariants_check(json_mode: bool) { + ensure_flow_exists(); + + let inv_path = invariants_path(); + if !inv_path.exists() { + if json_mode { + json_output(json!({ + "all_passed": true, + "results": [], + "message": "no invariants.md", + })); + } else { + println!("No invariants.md \u{2014} nothing to check."); + } + return; + } + + let content = fs::read_to_string(&inv_path).unwrap_or_else(|e| { + error_exit(&format!("Failed to read invariants.md: {}", e)); + }); + + // Strip HTML comments before parsing + let comment_re = regex::Regex::new(r"(?s)").unwrap(); + let content_clean = comment_re.replace_all(&content, ""); + + let verify_re = regex::Regex::new(r"`([^`]+)`").unwrap(); + let repo_root = get_repo_root(); + + let mut results: Vec = Vec::new(); + let mut all_passed = true; + let mut current_name: Option = None; + + for line in content_clean.lines() { + if let Some(name) = line.strip_prefix("## ") { + current_name = Some(name.trim().to_string()); + } else if line.contains("**Verify:**") { + if let Some(ref name) = current_name { + if let Some(captures) = verify_re.captures(line) { + let cmd = &captures[1]; + + if !json_mode { + println!("\u{25b8} [{}] {}", name, cmd); + } + + let result = Command::new("sh") + .args(["-c", cmd]) + .current_dir(&repo_root) + .stdout(if json_mode { + std::process::Stdio::piped() + } else { + std::process::Stdio::inherit() + }) + .stderr(if json_mode { + std::process::Stdio::piped() + } else { + std::process::Stdio::inherit() + }) + .status(); + + let rc = result.map(|s| s.code().unwrap_or(1)).unwrap_or(1); + let passed = rc == 0; + if !passed { + all_passed = false; + } + + results.push(json!({ + "name": name, + "command": cmd, + "passed": passed, + "exit_code": rc, + })); + + if !json_mode { + let mark = if passed { "\u{2713}" } else { "\u{2717}" }; + println!(" {} exit {}", mark, rc); + } + } + current_name = None; + } + } + } + + if json_mode { + json_output(json!({ + "all_passed": all_passed, + "results": results, + })); + } else { + let total = results.len(); + let passed_count = results.iter().filter(|r| r["passed"] == json!(true)).count(); + if total == 0 { + println!("No verify commands found in invariants.md."); + } else { + let suffix = if all_passed { "" } else { " \u{2014} VIOLATED" }; + println!("\n{}/{} invariants hold{}", passed_count, total, suffix); + } + } + + if !all_passed { + std::process::exit(1); + } +} + diff --git a/flowctl/crates/flowctl-cli/src/commands/stats.rs b/flowctl/crates/flowctl-cli/src/commands/stats.rs new file mode 100644 index 00000000..8194c6dd --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/stats.rs @@ -0,0 +1,359 @@ +//! Stats command: flowctl stats [--epic ] [--weekly] [--tokens] [--bottlenecks] [--dora] [--format json] +//! +//! TTY-aware: table output for terminals, JSON when piped or --json is passed. + +use std::env; +use std::io::IsTerminal; +use std::path::PathBuf; + +use clap::Subcommand; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +/// Open DB or exit with error. +fn open_db_or_exit() -> rusqlite::Connection { + let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + match flowctl_db::open(&cwd) { + Ok(conn) => conn, + Err(e) => { + error_exit(&format!("Cannot open database: {}", e)); + } + } +} + +/// Determine if output should be JSON: explicit --json flag, or stdout is not a terminal. +fn should_json(json_flag: bool) -> bool { + json_flag || !std::io::stdout().is_terminal() +} + +/// Stats subcommands. +#[derive(Subcommand, Debug)] +pub enum StatsCmd { + /// Show overall summary. + Summary, + /// Show per-epic breakdown. + Epic { + /// Specific epic ID (optional, shows all if omitted). + #[arg(long)] + id: Option, + }, + /// Show weekly trends. + Weekly { + /// Number of weeks to show (default: 8). + #[arg(long, default_value = "8")] + weeks: u32, + }, + /// Show token/cost breakdown. + Tokens { + /// Filter by epic ID. + #[arg(long)] + epic: Option, + }, + /// Show bottleneck analysis. + Bottlenecks { + /// Max results (default: 10). + #[arg(long, default_value = "10")] + limit: usize, + }, + /// Show DORA metrics. + Dora, + /// Generate monthly rollups from daily data. + Rollup, + /// Run auto-cleanup (delete old events/rollups). + Cleanup, +} + +pub fn dispatch(cmd: &StatsCmd, json_flag: bool) { + match cmd { + StatsCmd::Summary => cmd_summary(json_flag), + StatsCmd::Epic { id } => cmd_epic(json_flag, id.as_deref()), + StatsCmd::Weekly { weeks } => cmd_weekly(json_flag, *weeks), + StatsCmd::Tokens { epic } => cmd_tokens(json_flag, epic.as_deref()), + StatsCmd::Bottlenecks { limit } => cmd_bottlenecks(json_flag, *limit), + StatsCmd::Dora => cmd_dora(json_flag), + StatsCmd::Rollup => cmd_rollup(json_flag), + StatsCmd::Cleanup => cmd_cleanup(json_flag), + } +} + +fn cmd_summary(json_flag: bool) { + let conn = open_db_or_exit(); + let stats = flowctl_db::StatsQuery::new(&conn); + + let summary = match stats.summary() { + Ok(s) => s, + Err(e) => error_exit(&format!("Failed to query stats: {}", e)), + }; + + if should_json(json_flag) { + json_output(json!({ + "total_epics": summary.total_epics, + "open_epics": summary.open_epics, + "total_tasks": summary.total_tasks, + "done_tasks": summary.done_tasks, + "in_progress_tasks": summary.in_progress_tasks, + "blocked_tasks": summary.blocked_tasks, + "total_events": summary.total_events, + "total_tokens": summary.total_tokens, + "total_cost_usd": summary.total_cost_usd, + })); + } else { + println!("flowctl Stats Summary"); + println!("{}", "=".repeat(40)); + println!("Epics: {} total, {} open", summary.total_epics, summary.open_epics); + println!( + "Tasks: {} total, {} done, {} in progress, {} blocked", + summary.total_tasks, summary.done_tasks, summary.in_progress_tasks, summary.blocked_tasks + ); + println!("Events: {}", summary.total_events); + println!("Tokens: {}", format_tokens(summary.total_tokens)); + println!("Cost: ${:.4}", summary.total_cost_usd); + } +} + +fn cmd_epic(json_flag: bool, epic_id: Option<&str>) { + let conn = open_db_or_exit(); + let stats = flowctl_db::StatsQuery::new(&conn); + + let epics = match stats.per_epic(epic_id) { + Ok(e) => e, + Err(e) => error_exit(&format!("Failed to query epic stats: {}", e)), + }; + + if should_json(json_flag) { + let data: Vec = epics.iter().map(|e| json!({ + "epic_id": e.epic_id, + "title": e.title, + "status": e.status, + "task_count": e.task_count, + "done_count": e.done_count, + "avg_duration_secs": e.avg_duration_secs, + "total_tokens": e.total_tokens, + "total_cost": e.total_cost, + })).collect(); + json_output(json!({ "epics": data, "count": data.len() })); + } else if epics.is_empty() { + println!("No epic stats found."); + } else { + println!("{:<30} {:>6} {:>5}/{:>5} {:>10} {:>10}", "EPIC", "STATUS", "DONE", "TOTAL", "TOKENS", "COST"); + println!("{}", "-".repeat(75)); + for e in &epics { + println!( + "{:<30} {:>6} {:>5}/{:>5} {:>10} {:>10}", + truncate(&e.epic_id, 30), + e.status, + e.done_count, + e.task_count, + format_tokens(e.total_tokens), + format!("${:.4}", e.total_cost), + ); + } + } +} + +fn cmd_weekly(json_flag: bool, weeks: u32) { + let conn = open_db_or_exit(); + let stats = flowctl_db::StatsQuery::new(&conn); + + let trends = match stats.weekly_trends(weeks) { + Ok(t) => t, + Err(e) => error_exit(&format!("Failed to query weekly trends: {}", e)), + }; + + if should_json(json_flag) { + let data: Vec = trends.iter().map(|t| json!({ + "week": t.week, + "tasks_started": t.tasks_started, + "tasks_completed": t.tasks_completed, + "tasks_failed": t.tasks_failed, + })).collect(); + json_output(json!({ "weekly_trends": data })); + } else if trends.is_empty() { + println!("No weekly trend data available."); + } else { + println!("{:<12} {:>8} {:>10} {:>8}", "WEEK", "STARTED", "COMPLETED", "FAILED"); + println!("{}", "-".repeat(42)); + for t in &trends { + println!("{:<12} {:>8} {:>10} {:>8}", t.week, t.tasks_started, t.tasks_completed, t.tasks_failed); + } + } +} + +fn cmd_tokens(json_flag: bool, epic_id: Option<&str>) { + let conn = open_db_or_exit(); + let stats = flowctl_db::StatsQuery::new(&conn); + + let tokens = match stats.token_breakdown(epic_id) { + Ok(t) => t, + Err(e) => error_exit(&format!("Failed to query token usage: {}", e)), + }; + + if should_json(json_flag) { + let data: Vec = tokens.iter().map(|t| json!({ + "epic_id": t.epic_id, + "model": t.model, + "input_tokens": t.input_tokens, + "output_tokens": t.output_tokens, + "cache_read": t.cache_read, + "cache_write": t.cache_write, + "estimated_cost": t.estimated_cost, + })).collect(); + json_output(json!({ "token_usage": data })); + } else if tokens.is_empty() { + println!("No token usage data."); + } else { + println!("{:<25} {:<20} {:>10} {:>10} {:>10}", "EPIC", "MODEL", "INPUT", "OUTPUT", "COST"); + println!("{}", "-".repeat(80)); + for t in &tokens { + println!( + "{:<25} {:<20} {:>10} {:>10} {:>10}", + truncate(&t.epic_id, 25), + truncate(&t.model, 20), + format_tokens(t.input_tokens), + format_tokens(t.output_tokens), + format!("${:.4}", t.estimated_cost), + ); + } + } +} + +fn cmd_bottlenecks(json_flag: bool, limit: usize) { + let conn = open_db_or_exit(); + let stats = flowctl_db::StatsQuery::new(&conn); + + let bottlenecks = match stats.bottlenecks(limit) { + Ok(b) => b, + Err(e) => error_exit(&format!("Failed to query bottlenecks: {}", e)), + }; + + if should_json(json_flag) { + let data: Vec = bottlenecks.iter().map(|b| json!({ + "task_id": b.task_id, + "epic_id": b.epic_id, + "title": b.title, + "duration_secs": b.duration_secs, + "status": b.status, + "blocked_reason": b.blocked_reason, + })).collect(); + json_output(json!({ "bottlenecks": data })); + } else if bottlenecks.is_empty() { + println!("No bottleneck data."); + } else { + println!("{:<25} {:<10} {:>10} {}", "TASK", "STATUS", "DURATION", "TITLE"); + println!("{}", "-".repeat(70)); + for b in &bottlenecks { + let duration = b.duration_secs + .map(|s| format_duration(s)) + .unwrap_or_else(|| "-".to_string()); + let suffix = b.blocked_reason.as_ref() + .map(|r| format!(" [blocked: {}]", truncate(r, 30))) + .unwrap_or_default(); + println!( + "{:<25} {:<10} {:>10} {}{}", + truncate(&b.task_id, 25), + b.status, + duration, + truncate(&b.title, 30), + suffix, + ); + } + } +} + +fn cmd_dora(json_flag: bool) { + let conn = open_db_or_exit(); + let stats = flowctl_db::StatsQuery::new(&conn); + + let dora = match stats.dora_metrics() { + Ok(d) => d, + Err(e) => error_exit(&format!("Failed to compute DORA metrics: {}", e)), + }; + + if should_json(json_flag) { + json_output(json!({ + "lead_time_hours": dora.lead_time_hours, + "throughput_per_week": dora.throughput_per_week, + "change_failure_rate": dora.change_failure_rate, + "time_to_restore_hours": dora.time_to_restore_hours, + })); + } else { + println!("DORA Metrics (last 30 days)"); + println!("{}", "=".repeat(40)); + println!( + "Lead Time: {}", + dora.lead_time_hours + .map(|h| format!("{:.1}h", h)) + .unwrap_or_else(|| "N/A".to_string()) + ); + println!("Throughput: {:.1} tasks/week", dora.throughput_per_week); + println!("Change Failure Rate: {:.1}%", dora.change_failure_rate * 100.0); + println!( + "Time to Restore: {}", + dora.time_to_restore_hours + .map(|h| format!("{:.1}h", h)) + .unwrap_or_else(|| "N/A".to_string()) + ); + } +} + +fn cmd_rollup(json_flag: bool) { + let conn = open_db_or_exit(); + let stats = flowctl_db::StatsQuery::new(&conn); + + match stats.generate_monthly_rollups() { + Ok(count) => { + if should_json(json_flag) { + json_output(json!({ "months_updated": count })); + } else { + println!("Updated {} monthly rollup(s).", count); + } + } + Err(e) => error_exit(&format!("Failed to generate rollups: {}", e)), + } +} + +fn cmd_cleanup(json_flag: bool) { + let conn = open_db_or_exit(); + + match flowctl_db::cleanup(&conn) { + Ok(count) => { + if should_json(json_flag) { + json_output(json!({ "deleted": count })); + } else { + println!("Cleaned up {} old record(s).", count); + } + } + Err(e) => error_exit(&format!("Cleanup failed: {}", e)), + } +} + +// ── Formatting helpers ──────────────────────────────────────────────── + +fn format_tokens(n: i64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}K", n as f64 / 1_000.0) + } else { + n.to_string() + } +} + +fn format_duration(secs: i64) -> String { + if secs >= 3600 { + format!("{:.1}h", secs as f64 / 3600.0) + } else if secs >= 60 { + format!("{}m", secs / 60) + } else { + format!("{}s", secs) + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}...", &s[..max.saturating_sub(3)]) + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/task.rs b/flowctl/crates/flowctl-cli/src/commands/task.rs new file mode 100644 index 00000000..dae9f207 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/task.rs @@ -0,0 +1,1178 @@ +//! Task management commands: create, skip, split, set-spec, set-description, +//! set-acceptance, set-deps, reset, set-backend, show-backend. + +use std::env; +use std::fs; +use std::io::{self, Read as _}; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use clap::Subcommand; +use regex::Regex; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +use flowctl_core::frontmatter; +use flowctl_core::id::{epic_id_from_task, is_epic_id, is_task_id}; +use flowctl_core::state_machine::Status; +use flowctl_core::types::{ + Domain, Epic, Task, EPICS_DIR, FLOW_DIR, TASKS_DIR, +}; + +#[derive(Subcommand, Debug)] +pub enum TaskCmd { + /// Create a new task. + Create { + /// Epic ID. + #[arg(long)] + epic: String, + /// Task title. + #[arg(long)] + title: String, + /// Comma-separated dependency IDs. + #[arg(long)] + deps: Option, + /// Markdown file with acceptance criteria. + #[arg(long)] + acceptance_file: Option, + /// Priority (lower = earlier). + #[arg(long)] + priority: Option, + /// Task domain. + #[arg(long, value_parser = ["frontend", "backend", "architecture", "testing", "docs", "ops", "general"])] + domain: Option, + /// Comma-separated owned file paths. + #[arg(long)] + files: Option, + }, + /// Set task description. + SetDescription { + /// Task ID. + id: String, + /// Markdown file (use '-' for stdin). + #[arg(long)] + file: String, + }, + /// Set task acceptance criteria. + SetAcceptance { + /// Task ID. + id: String, + /// Markdown file (use '-' for stdin). + #[arg(long)] + file: String, + }, + /// Set task spec (full file or sections). + SetSpec { + /// Task ID. + id: String, + /// Full spec file. + #[arg(long)] + file: Option, + /// Description section file. + #[arg(long)] + description: Option, + /// Acceptance section file. + #[arg(long)] + acceptance: Option, + }, + /// Reset task to todo. + Reset { + /// Task ID. + task_id: String, + /// Also reset dependent tasks. + #[arg(long)] + cascade: bool, + }, + /// Skip task (mark as permanently skipped). + Skip { + /// Task ID. + task_id: String, + /// Why the task is being skipped. + #[arg(long)] + reason: Option, + }, + /// Split task into sub-tasks (runtime DAG mutation). + Split { + /// Task ID to split. + task_id: String, + /// Sub-task titles separated by '|'. + #[arg(long)] + titles: String, + /// Chain sub-tasks sequentially. + #[arg(long)] + chain: bool, + }, + /// Set backend specs for impl/review/sync. + SetBackend { + /// Task ID. + id: String, + /// Impl backend spec. + #[arg(long = "impl")] + impl_spec: Option, + /// Review backend spec. + #[arg(long)] + review: Option, + /// Sync backend spec. + #[arg(long)] + sync: Option, + }, + /// Show effective backend specs. + ShowBackend { + /// Task ID. + id: String, + }, + /// Set task dependencies (comma-separated). + SetDeps { + /// Task ID. + task_id: String, + /// Comma-separated dependency IDs. + #[arg(long)] + deps: String, + }, +} + +// ── Helpers ───────────────────────────────────────────────────────── + +/// Get the .flow/ directory path. +fn get_flow_dir() -> PathBuf { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(FLOW_DIR) +} + +/// Ensure .flow/ exists, error_exit if not. +fn ensure_flow_exists() -> PathBuf { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + flow_dir +} + +/// Try to open a DB connection. +fn try_open_db() -> Option { + let cwd = env::current_dir().ok()?; + flowctl_db::open(&cwd).ok() +} + +/// Read file content, or read from stdin if path is "-". +fn read_file_or_stdin(path: &str) -> String { + if path == "-" { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .unwrap_or_else(|e| error_exit(&format!("Failed to read stdin: {e}"))); + buf + } else { + fs::read_to_string(path) + .unwrap_or_else(|e| error_exit(&format!("Failed to read file '{}': {e}", path))) + } +} + +/// Scan .flow/tasks/ to find max task number for an epic. Returns 0 if none exist. +fn scan_max_task_id(flow_dir: &Path, epic_id: &str) -> u32 { + let tasks_dir = flow_dir.join(TASKS_DIR); + if !tasks_dir.exists() { + return 0; + } + + let pattern = format!(r"^{}\.(\d+)\.md$", regex::escape(epic_id)); + let re = Regex::new(&pattern).expect("task ID regex is valid"); + + let mut max_n: u32 = 0; + if let Ok(entries) = fs::read_dir(&tasks_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if let Some(caps) = re.captures(&name_str) { + if let Ok(n) = caps[1].parse::() { + max_n = max_n.max(n); + } + } + } + } + max_n +} + +/// Parse a domain string into a Domain enum. +fn parse_domain(s: &str) -> Domain { + match s { + "frontend" => Domain::Frontend, + "backend" => Domain::Backend, + "architecture" => Domain::Architecture, + "testing" => Domain::Testing, + "docs" => Domain::Docs, + "ops" => Domain::Ops, + _ => Domain::General, + } +} + +/// Create task spec markdown content. +fn create_task_spec(id: &str, title: &str, acceptance: Option<&str>) -> String { + let acceptance_content = acceptance.unwrap_or("- [ ] TBD"); + format!( + "# {} {}\n\n## Description\nTBD\n\n## Acceptance\n{}\n\n## Done summary\nTBD\n\n## Evidence\n- Commits:\n- Tests:\n- PRs:\n", + id, title, acceptance_content + ) +} + +/// Load a task from its Markdown frontmatter file. +fn load_task_md(flow_dir: &Path, task_id: &str) -> Task { + let spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id)); + if !spec_path.exists() { + error_exit(&format!("Task {} not found", task_id)); + } + let content = fs::read_to_string(&spec_path) + .unwrap_or_else(|e| error_exit(&format!("Failed to read task {}: {e}", task_id))); + let doc: frontmatter::Document = frontmatter::parse(&content) + .unwrap_or_else(|e| error_exit(&format!("Failed to parse task {}: {e}", task_id))); + doc.frontmatter +} + +/// Load an epic from its Markdown frontmatter file. +fn load_epic_md(flow_dir: &Path, epic_id: &str) -> Option { + let spec_path = flow_dir.join(EPICS_DIR).join(format!("{}.md", epic_id)); + if !spec_path.exists() { + return None; + } + let content = fs::read_to_string(&spec_path).ok()?; + let doc: frontmatter::Document = frontmatter::parse(&content).ok()?; + Some(doc.frontmatter) +} + +/// Load task's full Markdown document (frontmatter + body). +fn load_task_doc(flow_dir: &Path, task_id: &str) -> frontmatter::Document { + let spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id)); + if !spec_path.exists() { + error_exit(&format!("Task {} not found", task_id)); + } + let content = fs::read_to_string(&spec_path) + .unwrap_or_else(|e| error_exit(&format!("Failed to read task {}: {e}", task_id))); + frontmatter::parse(&content) + .unwrap_or_else(|e| error_exit(&format!("Failed to parse task {}: {e}", task_id))) +} + +/// Write a task document (frontmatter + body) to disk. +fn write_task_doc(flow_dir: &Path, task_id: &str, doc: &frontmatter::Document) { + let spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id)); + let content = frontmatter::write(doc) + .unwrap_or_else(|e| error_exit(&format!("Failed to serialize task {}: {e}", task_id))); + fs::write(&spec_path, content) + .unwrap_or_else(|e| error_exit(&format!("Failed to write task {}: {e}", task_id))); +} + +/// Patch a specific section in a Markdown body. Replaces content under `section` +/// heading (e.g. "## Description") until the next "## " heading. +fn patch_body_section(body: &str, section: &str, new_content: &str) -> String { + // Strip leading section heading from new_content if present + let trimmed_new = { + let lines: Vec<&str> = new_content.trim_start().lines().collect(); + if !lines.is_empty() && lines[0].trim() == section { + lines[1..].join("\n").trim_start().to_string() + } else { + new_content.to_string() + } + }; + + let lines: Vec<&str> = body.lines().collect(); + let mut result = Vec::new(); + let mut in_target = false; + let mut section_found = false; + + for line in &lines { + if line.starts_with("## ") { + if line.trim() == section { + in_target = true; + section_found = true; + result.push(line.to_string()); + result.push(trimmed_new.trim_end().to_string()); + continue; + } else { + in_target = false; + } + } + + if !in_target { + result.push(line.to_string()); + } + } + + if !section_found { + // Auto-append missing section + result.push(String::new()); + result.push(section.to_string()); + result.push(trimmed_new.trim_end().to_string()); + } + + result.join("\n") +} + +/// Find tasks that depend on a given task (recursive BFS within same epic). +fn find_dependents(flow_dir: &Path, task_id: &str) -> Vec { + let tasks_dir = flow_dir.join(TASKS_DIR); + if !tasks_dir.exists() { + return vec![]; + } + + let epic_id = match epic_id_from_task(task_id) { + Ok(id) => id, + Err(_) => return vec![], + }; + + // Load all tasks in the epic + let mut all_tasks: Vec<(String, Vec)> = Vec::new(); + if let Ok(entries) = fs::read_dir(&tasks_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.starts_with(&epic_id) || !name_str.ends_with(".md") { + continue; + } + let tid = name_str.trim_end_matches(".md").to_string(); + if !is_task_id(&tid) { + continue; + } + if let Ok(content) = fs::read_to_string(entry.path()) { + if let Ok(doc) = frontmatter::parse::(&content) { + all_tasks.push((doc.frontmatter.id.clone(), doc.frontmatter.depends_on.clone())); + } + } + } + } + + // BFS + let mut dependents: Vec = Vec::new(); + let mut to_check = vec![task_id.to_string()]; + let mut checked = std::collections::HashSet::new(); + + while let Some(checking) = to_check.pop() { + if !checked.insert(checking.clone()) { + continue; + } + for (tid, deps) in &all_tasks { + if checked.contains(tid) || dependents.contains(tid) { + continue; + } + if deps.contains(&checking) { + dependents.push(tid.clone()); + to_check.push(tid.clone()); + } + } + } + + dependents.sort(); + dependents +} + +/// Clear ## Evidence section in spec body back to template. +fn clear_evidence_in_body(body: &str) -> String { + let re = Regex::new(r"(?s)(## Evidence\s*\n).*?(\n## |\z)").expect("evidence regex valid"); + let replacement = "${1}- Commits:\n- Tests:\n- PRs:\n${2}"; + re.replace(body, replacement).to_string() +} + +// ── Dispatch ──────────────────────────────────────────────────────── + +pub fn dispatch(cmd: &TaskCmd, json: bool) { + match cmd { + TaskCmd::Create { + epic, + title, + deps, + acceptance_file, + priority, + domain, + files, + } => cmd_task_create( + json, + epic, + title, + deps.as_deref(), + acceptance_file.as_deref(), + *priority, + domain.as_deref(), + files.as_deref(), + ), + TaskCmd::SetDescription { id, file } => cmd_task_set_section(json, id, "## Description", file), + TaskCmd::SetAcceptance { id, file } => cmd_task_set_section(json, id, "## Acceptance", file), + TaskCmd::SetSpec { + id, + file, + description, + acceptance, + } => cmd_task_set_spec(json, id, file.as_deref(), description.as_deref(), acceptance.as_deref()), + TaskCmd::Reset { task_id, cascade } => cmd_task_reset(json, task_id, *cascade), + TaskCmd::Skip { task_id, reason } => cmd_task_skip(json, task_id, reason.as_deref()), + TaskCmd::Split { + task_id, + titles, + chain, + } => cmd_task_split(json, task_id, titles, *chain), + TaskCmd::SetBackend { + id, + impl_spec, + review, + sync, + } => cmd_task_set_backend(json, id, impl_spec.as_deref(), review.as_deref(), sync.as_deref()), + TaskCmd::ShowBackend { id } => cmd_task_show_backend(json, id), + TaskCmd::SetDeps { task_id, deps } => cmd_task_set_deps(json, task_id, deps), + } +} + +// ── Command implementations ───────────────────────────────────────── + +fn cmd_task_create( + json_mode: bool, + epic_id: &str, + title: &str, + deps: Option<&str>, + acceptance_file: Option<&str>, + priority: Option, + domain: Option<&str>, + files: Option<&str>, +) { + let flow_dir = ensure_flow_exists(); + + if !is_epic_id(epic_id) { + error_exit(&format!( + "Invalid epic ID: {}. Expected format: fn-N or fn-N-slug (e.g., fn-1, fn-1-add-auth)", + epic_id + )); + } + + // Verify epic exists + let epic_spec = flow_dir.join(EPICS_DIR).join(format!("{}.md", epic_id)); + if !epic_spec.exists() { + error_exit(&format!("Epic {} not found", epic_id)); + } + + // Scan-based ID allocation + let task_num = scan_max_task_id(&flow_dir, epic_id) + 1; + let task_id = format!("{}.{}", epic_id, task_num); + + // Check no collision + let spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id)); + if spec_path.exists() { + error_exit(&format!( + "Refusing to overwrite existing task {}. Check for orphaned files.", + task_id + )); + } + + // Parse dependencies + let dep_list: Vec = match deps { + Some(d) if !d.is_empty() => d + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + _ => vec![], + }; + + // Validate deps + for dep in &dep_list { + if !is_task_id(dep) { + error_exit(&format!( + "Invalid dependency ID: {}. Expected format: fn-N.M or fn-N-slug.M", + dep + )); + } + if let Ok(dep_epic) = epic_id_from_task(dep) { + if dep_epic != epic_id { + error_exit(&format!( + "Dependency {} must be within the same epic ({})", + dep, epic_id + )); + } + } + } + + // Read acceptance from file if provided + let acceptance = acceptance_file.map(|f| read_file_or_stdin(f)); + + // Parse files + let file_list: Vec = match files { + Some(f) if !f.is_empty() => f + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + _ => vec![], + }; + + let domain_enum = domain.map(|d| parse_domain(d)).unwrap_or(Domain::General); + let now = Utc::now(); + + // Create Task struct + let task = Task { + schema_version: 1, + id: task_id.clone(), + epic: epic_id.to_string(), + title: title.to_string(), + status: Status::Todo, + priority: priority.map(|p| p as u32), + domain: domain_enum, + depends_on: dep_list.clone(), + files: file_list, + r#impl: None, + review: None, + sync: None, + file_path: Some(format!("{}/{}/{}.md", FLOW_DIR, TASKS_DIR, task_id)), + created_at: now, + updated_at: now, + }; + + // Create spec markdown body + let body = create_task_spec(&task_id, title, acceptance.as_deref()); + + // Write Markdown file with frontmatter + let doc = frontmatter::Document { + frontmatter: task.clone(), + body, + }; + write_task_doc(&flow_dir, &task_id, &doc); + + // Upsert into SQLite if DB available + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.upsert(&task); + } + + let spec_path_str = format!("{}/{}/{}.md", FLOW_DIR, TASKS_DIR, task_id); + if json_mode { + json_output(json!({ + "id": task_id, + "epic": epic_id, + "title": title, + "depends_on": dep_list, + "spec_path": spec_path_str, + "message": format!("Task {} created", task_id), + })); + } else { + println!("Task {} created: {}", task_id, title); + } +} + +fn cmd_task_set_section(json_mode: bool, task_id: &str, section: &str, file_path: &str) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M", + task_id + )); + } + + let mut doc = load_task_doc(&flow_dir, task_id); + + // Read new content + let new_content = read_file_or_stdin(file_path); + + // Patch body section + doc.body = patch_body_section(&doc.body, section, &new_content); + doc.frontmatter.updated_at = Utc::now(); + + write_task_doc(&flow_dir, task_id, &doc); + + // Update DB + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.upsert(&doc.frontmatter); + } + + if json_mode { + json_output(json!({ + "id": task_id, + "section": section, + "message": format!("Task {} {} updated", task_id, section), + })); + } else { + println!("Task {} {} updated", task_id, section); + } +} + +fn cmd_task_set_spec( + json_mode: bool, + task_id: &str, + file: Option<&str>, + description: Option<&str>, + acceptance: Option<&str>, +) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M", + task_id + )); + } + + if file.is_none() && description.is_none() && acceptance.is_none() { + error_exit("Requires --file, --description, or --acceptance"); + } + + let mut doc = load_task_doc(&flow_dir, task_id); + + if let Some(f) = file { + // Full file replacement mode + let content = read_file_or_stdin(f); + doc.body = content; + doc.frontmatter.updated_at = Utc::now(); + write_task_doc(&flow_dir, task_id, &doc); + + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.upsert(&doc.frontmatter); + } + + if json_mode { + json_output(json!({ + "id": task_id, + "message": format!("Task {} spec replaced", task_id), + })); + } else { + println!("Task {} spec replaced", task_id); + } + return; + } + + // Section patch mode + let mut sections_updated = Vec::new(); + + if let Some(desc_file) = description { + let desc_content = read_file_or_stdin(desc_file); + doc.body = patch_body_section(&doc.body, "## Description", &desc_content); + sections_updated.push("## Description"); + } + + if let Some(acc_file) = acceptance { + let acc_content = read_file_or_stdin(acc_file); + doc.body = patch_body_section(&doc.body, "## Acceptance", &acc_content); + sections_updated.push("## Acceptance"); + } + + doc.frontmatter.updated_at = Utc::now(); + write_task_doc(&flow_dir, task_id, &doc); + + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.upsert(&doc.frontmatter); + } + + if json_mode { + json_output(json!({ + "id": task_id, + "sections": sections_updated, + "message": format!("Task {} updated: {}", task_id, sections_updated.join(", ")), + })); + } else { + println!("Task {} updated: {}", task_id, sections_updated.join(", ")); + } +} + +fn cmd_task_reset(json_mode: bool, task_id: &str, cascade: bool) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M", + task_id + )); + } + + let mut doc = load_task_doc(&flow_dir, task_id); + let current_status = doc.frontmatter.status; + + // Check if epic is closed + if let Ok(eid) = epic_id_from_task(task_id) { + if let Some(epic) = load_epic_md(&flow_dir, &eid) { + if epic.status == flowctl_core::types::EpicStatus::Done { + error_exit(&format!("Cannot reset task in closed epic {}", eid)); + } + } + } + + if current_status == Status::InProgress { + error_exit(&format!( + "Cannot reset in_progress task {}. Complete or block it first.", + task_id + )); + } + + if current_status == Status::Todo { + if json_mode { + json_output(json!({ + "reset": [], + "message": format!("{} already todo", task_id), + })); + } else { + println!("{} already todo", task_id); + } + return; + } + + // Reset the task + doc.frontmatter.status = Status::Todo; + doc.frontmatter.updated_at = Utc::now(); + doc.body = clear_evidence_in_body(&doc.body); + write_task_doc(&flow_dir, task_id, &doc); + + // Update DB + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.update_status(task_id, Status::Todo); + // Clear runtime state by upserting a blank state + let runtime_repo = flowctl_db::RuntimeRepo::new(&conn); + let blank = flowctl_core::types::RuntimeState { + task_id: task_id.to_string(), + ..Default::default() + }; + let _ = runtime_repo.upsert(&blank); + } + + let mut reset_ids = vec![task_id.to_string()]; + + // Handle cascade + if cascade { + let dependents = find_dependents(&flow_dir, task_id); + for dep_id in &dependents { + if let Ok(dep_doc_result) = (|| -> Result, ()> { + let p = flow_dir.join(TASKS_DIR).join(format!("{}.md", dep_id)); + let content = fs::read_to_string(&p).map_err(|_| ())?; + frontmatter::parse::(&content).map_err(|_| ()) + })() { + let mut dep_doc = dep_doc_result; + let dep_status = dep_doc.frontmatter.status; + if dep_status == Status::InProgress || dep_status == Status::Todo { + continue; + } + + dep_doc.frontmatter.status = Status::Todo; + dep_doc.frontmatter.updated_at = Utc::now(); + dep_doc.body = clear_evidence_in_body(&dep_doc.body); + write_task_doc(&flow_dir, dep_id, &dep_doc); + + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.update_status(dep_id, Status::Todo); + let runtime_repo = flowctl_db::RuntimeRepo::new(&conn); + let blank = flowctl_core::types::RuntimeState { + task_id: dep_id.to_string(), + ..Default::default() + }; + let _ = runtime_repo.upsert(&blank); + } + reset_ids.push(dep_id.clone()); + } + } + } + + if json_mode { + json_output(json!({ + "reset": reset_ids, + })); + } else { + println!("Reset: {}", reset_ids.join(", ")); + } +} + +fn cmd_task_skip(json_mode: bool, task_id: &str, reason: Option<&str>) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!("Invalid task ID: {}", task_id)); + } + + let mut doc = load_task_doc(&flow_dir, task_id); + + if doc.frontmatter.status == Status::Done { + error_exit(&format!("Cannot skip already-done task {}", task_id)); + } + + doc.frontmatter.status = Status::Skipped; + doc.frontmatter.updated_at = Utc::now(); + write_task_doc(&flow_dir, task_id, &doc); + + // Update DB + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.update_status(task_id, Status::Skipped); + } + + let reason_str = reason.unwrap_or(""); + if json_mode { + json_output(json!({ + "id": task_id, + "status": "skipped", + "reason": reason_str, + "message": format!("Task {} skipped", task_id), + })); + } else { + let suffix = if !reason_str.is_empty() { + format!(": {}", reason_str) + } else { + String::new() + }; + println!("Task {} skipped{}", task_id, suffix); + } +} + +fn cmd_task_split(json_mode: bool, task_id: &str, titles: &str, chain: bool) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!("Invalid task ID: {}", task_id)); + } + + let doc = load_task_doc(&flow_dir, task_id); + let status = doc.frontmatter.status; + + if status == Status::Done || status == Status::Skipped { + error_exit(&format!( + "Cannot split task {} with status '{}'", + task_id, status + )); + } + + let epic_id = epic_id_from_task(task_id) + .unwrap_or_else(|_| error_exit(&format!("Cannot extract epic from {}", task_id))); + + let title_list: Vec = titles + .split('|') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + if title_list.len() < 2 { + error_exit("Need at least 2 sub-task titles separated by '|'"); + } + + let max_task = scan_max_task_id(&flow_dir, &epic_id); + let original_deps = doc.frontmatter.depends_on.clone(); + let mut created: Vec = Vec::new(); + let now = Utc::now(); + + for (i, sub_title) in title_list.iter().enumerate() { + let sub_num = max_task + 1 + i as u32; + let sub_id = format!("{}.{}", epic_id, sub_num); + + // First sub-task inherits original deps; subsequent depend on previous if chained + let sub_deps = if i == 0 { + original_deps.clone() + } else if chain { + let prev_id = format!("{}.{}", epic_id, max_task + i as u32); + vec![prev_id] + } else { + vec![] + }; + + let sub_task = Task { + schema_version: 1, + id: sub_id.clone(), + epic: epic_id.clone(), + title: sub_title.clone(), + status: Status::Todo, + priority: doc.frontmatter.priority, + domain: doc.frontmatter.domain, + depends_on: sub_deps, + files: vec![], + r#impl: None, + review: None, + sync: None, + file_path: Some(format!("{}/{}/{}.md", FLOW_DIR, TASKS_DIR, sub_id)), + created_at: now, + updated_at: now, + }; + + let body = create_task_spec(&sub_id, sub_title, None); + let sub_doc = frontmatter::Document { + frontmatter: sub_task.clone(), + body, + }; + write_task_doc(&flow_dir, &sub_id, &sub_doc); + + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.upsert(&sub_task); + } + + created.push(sub_id); + } + + // Mark original task as skipped + let mut orig_doc = doc; + orig_doc.frontmatter.status = Status::Skipped; + orig_doc.frontmatter.updated_at = now; + write_task_doc(&flow_dir, task_id, &orig_doc); + + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.update_status(task_id, Status::Skipped); + } + + // Update tasks that depended on original to depend on last sub-task + let last_sub = created.last().unwrap().clone(); + let tasks_dir = flow_dir.join(TASKS_DIR); + if let Ok(entries) = fs::read_dir(&tasks_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.starts_with(&epic_id) || !name_str.ends_with(".md") { + continue; + } + let other_id = name_str.trim_end_matches(".md").to_string(); + if other_id == task_id || created.contains(&other_id) { + continue; + } + if !is_task_id(&other_id) { + continue; + } + if let Ok(content) = fs::read_to_string(entry.path()) { + if let Ok(mut other_doc) = frontmatter::parse::(&content) { + if other_doc.frontmatter.depends_on.contains(&task_id.to_string()) { + other_doc.frontmatter.depends_on = other_doc + .frontmatter + .depends_on + .iter() + .map(|d| { + if d == task_id { + last_sub.clone() + } else { + d.clone() + } + }) + .collect(); + other_doc.frontmatter.updated_at = now; + + // Re-read full doc to preserve body + if let Ok(full_doc) = frontmatter::parse::(&content) { + let updated_doc = frontmatter::Document { + frontmatter: other_doc.frontmatter, + body: full_doc.body, + }; + write_task_doc(&flow_dir, &updated_doc.frontmatter.id, &updated_doc); + } + } + } + } + } + } + + if json_mode { + json_output(json!({ + "original": task_id, + "split_into": created, + "chain": chain, + "message": format!("Task {} split into {} sub-tasks", task_id, created.len()), + })); + } else { + println!("Task {} split into:", task_id); + for sub_id in &created { + println!(" {}", sub_id); + } + println!( + "Original task marked as skipped. Downstream deps updated to {}.", + last_sub + ); + } +} + +fn cmd_task_set_backend( + json_mode: bool, + task_id: &str, + impl_spec: Option<&str>, + review: Option<&str>, + sync: Option<&str>, +) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M", + task_id + )); + } + + if impl_spec.is_none() && review.is_none() && sync.is_none() { + error_exit("At least one of --impl, --review, or --sync must be provided"); + } + + let mut doc = load_task_doc(&flow_dir, task_id); + let mut updated = Vec::new(); + + if let Some(v) = impl_spec { + let val = if v.is_empty() { None } else { Some(v.to_string()) }; + doc.frontmatter.r#impl = val; + updated.push(format!("impl={}", if v.is_empty() { "null" } else { v })); + } + if let Some(v) = review { + let val = if v.is_empty() { None } else { Some(v.to_string()) }; + doc.frontmatter.review = val; + updated.push(format!("review={}", if v.is_empty() { "null" } else { v })); + } + if let Some(v) = sync { + let val = if v.is_empty() { None } else { Some(v.to_string()) }; + doc.frontmatter.sync = val; + updated.push(format!("sync={}", if v.is_empty() { "null" } else { v })); + } + + doc.frontmatter.updated_at = Utc::now(); + write_task_doc(&flow_dir, task_id, &doc); + + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.upsert(&doc.frontmatter); + } + + let msg = format!("Task {} backend specs updated: {}", task_id, updated.join(", ")); + if json_mode { + json_output(json!({ + "id": task_id, + "impl": doc.frontmatter.r#impl, + "review": doc.frontmatter.review, + "sync": doc.frontmatter.sync, + "message": msg, + })); + } else { + println!("{}", msg); + } +} + +fn cmd_task_show_backend(json_mode: bool, task_id: &str) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M", + task_id + )); + } + + let task = load_task_md(&flow_dir, task_id); + let epic_id_str = &task.epic; + let epic = load_epic_md(&flow_dir, epic_id_str); + + // Resolve effective specs with source tracking + let resolve = |task_val: &Option, epic_key: &str| -> (serde_json::Value, serde_json::Value) { + if let Some(v) = task_val { + if !v.is_empty() { + return (json!(v), json!("task")); + } + } + if let Some(ref e) = epic { + let epic_val = match epic_key { + "default_impl" => &e.default_impl, + "default_review" => &e.default_review, + "default_sync" => &e.default_sync, + _ => &None, + }; + if let Some(v) = epic_val { + if !v.is_empty() { + return (json!(v), json!("epic")); + } + } + } + (json!(null), json!(null)) + }; + + let (impl_spec, impl_source) = resolve(&task.r#impl, "default_impl"); + let (review_spec, review_source) = resolve(&task.review, "default_review"); + let (sync_spec, sync_source) = resolve(&task.sync, "default_sync"); + + if json_mode { + json_output(json!({ + "id": task_id, + "epic": epic_id_str, + "impl": {"spec": impl_spec, "source": impl_source}, + "review": {"spec": review_spec, "source": review_source}, + "sync": {"spec": sync_spec, "source": sync_source}, + })); + } else { + let fmt = |spec: &serde_json::Value, source: &serde_json::Value| -> String { + if spec.is_null() { + "null".to_string() + } else { + format!("{} ({})", spec.as_str().unwrap_or("null"), source.as_str().unwrap_or("")) + } + }; + println!("impl: {}", fmt(&impl_spec, &impl_source)); + println!("review: {}", fmt(&review_spec, &review_source)); + println!("sync: {}", fmt(&sync_spec, &sync_source)); + } +} + +fn cmd_task_set_deps(json_mode: bool, task_id: &str, deps: &str) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M", + task_id + )); + } + + let dep_ids: Vec = deps + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + if dep_ids.is_empty() { + error_exit("--deps cannot be empty"); + } + + let task_epic = epic_id_from_task(task_id) + .unwrap_or_else(|_| error_exit(&format!("Invalid task ID: {}", task_id))); + + // Validate all dep IDs + for dep_id in &dep_ids { + if !is_task_id(dep_id) { + error_exit(&format!( + "Invalid dependency ID: {}. Expected format: fn-N.M or fn-N-slug.M", + dep_id + )); + } + if let Ok(dep_epic) = epic_id_from_task(dep_id) { + if dep_epic != task_epic { + error_exit(&format!( + "Dependencies must be within same epic. Task {} is in {}, dependency {} is in {}", + task_id, task_epic, dep_id, dep_epic + )); + } + } + } + + let mut doc = load_task_doc(&flow_dir, task_id); + + let mut added = Vec::new(); + for dep_id in &dep_ids { + if !doc.frontmatter.depends_on.contains(dep_id) { + doc.frontmatter.depends_on.push(dep_id.clone()); + added.push(dep_id.clone()); + } + } + + if !added.is_empty() { + doc.frontmatter.updated_at = Utc::now(); + write_task_doc(&flow_dir, task_id, &doc); + + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + let _ = repo.upsert(&doc.frontmatter); + } + } + + if json_mode { + json_output(json!({ + "task": task_id, + "depends_on": doc.frontmatter.depends_on, + "added": added, + "message": format!("Dependencies set for {}", task_id), + })); + } else if !added.is_empty() { + println!("Added dependencies to {}: {}", task_id, added.join(", ")); + } else { + println!("No new dependencies added (already set)"); + } +} diff --git a/flowctl/crates/flowctl-cli/src/commands/workflow.rs b/flowctl/crates/flowctl-cli/src/commands/workflow.rs new file mode 100644 index 00000000..ff1b5112 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/commands/workflow.rs @@ -0,0 +1,1995 @@ +//! Workflow commands: ready, next, start, done, block, restart, queue, +//! worker-phase next/done. + +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use clap::Subcommand; +use regex::Regex; +use serde_json::json; + +use crate::output::{error_exit, json_output}; + +use flowctl_core::frontmatter; +use flowctl_core::id::{epic_id_from_task, is_epic_id, is_task_id, parse_id}; +use flowctl_core::state_machine::Status; +use flowctl_core::types::{ + Epic, EpicStatus, Evidence, RuntimeState, Task, EPICS_DIR, FLOW_DIR, REVIEWS_DIR, TASKS_DIR, +}; + +/// Worker-phase subcommands. +#[derive(Subcommand, Debug)] +pub enum WorkerPhaseCmd { + /// Return the next uncompleted phase. + Next { + /// Task ID. + #[arg(long)] + task: String, + /// Include TDD phases. + #[arg(long)] + tdd: bool, + /// Include review phase. + #[arg(long, value_parser = ["rp", "codex"])] + review: Option, + }, + /// Mark a phase as completed. + Done { + /// Task ID. + #[arg(long)] + task: String, + /// Phase ID to mark done. + #[arg(long)] + phase: String, + /// Include TDD phases. + #[arg(long)] + tdd: bool, + /// Include review phase. + #[arg(long, value_parser = ["rp", "codex"])] + review: Option, + }, +} + +// ── Helpers ───────────────────────────────────────────────────────── + +/// Get the .flow/ directory path. +fn get_flow_dir() -> PathBuf { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(FLOW_DIR) +} + +/// Ensure .flow/ exists, error_exit if not. +fn ensure_flow_exists() -> PathBuf { + let flow_dir = get_flow_dir(); + if !flow_dir.exists() { + error_exit(".flow/ does not exist. Run 'flowctl init' first."); + } + flow_dir +} + +/// Try to open a DB connection. +fn try_open_db() -> Option { + let cwd = env::current_dir().ok()?; + flowctl_db::open(&cwd).ok() +} + +/// Resolve current actor: FLOW_ACTOR env > git config user.email > git config user.name > $USER > "unknown" +fn resolve_actor() -> String { + if let Ok(actor) = env::var("FLOW_ACTOR") { + let trimmed = actor.trim().to_string(); + if !trimmed.is_empty() { + return trimmed; + } + } + if let Ok(output) = std::process::Command::new("git") + .args(["config", "user.email"]) + .output() + { + if output.status.success() { + let email = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !email.is_empty() { + return email; + } + } + } + if let Ok(output) = std::process::Command::new("git") + .args(["config", "user.name"]) + .output() + { + if output.status.success() { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !name.is_empty() { + return name; + } + } + } + if let Ok(user) = env::var("USER") { + if !user.is_empty() { + return user; + } + } + "unknown".to_string() +} + +/// Load a single epic from Markdown frontmatter. +fn load_epic_md(flow_dir: &Path, epic_id: &str) -> Option { + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{}.md", epic_id)); + if !epic_path.exists() { + return None; + } + let content = fs::read_to_string(&epic_path).ok()?; + frontmatter::parse_frontmatter::(&content).ok() +} + +/// Load a single task from Markdown frontmatter. +fn load_task_md(flow_dir: &Path, task_id: &str) -> Option { + let task_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id)); + if !task_path.exists() { + return None; + } + let content = fs::read_to_string(&task_path).ok()?; + frontmatter::parse_frontmatter::(&content).ok() +} + +/// Load all tasks for an epic, trying DB first then Markdown. +fn load_tasks_for_epic(flow_dir: &Path, epic_id: &str) -> HashMap { + // Try DB first + if let Some(conn) = try_open_db() { + let task_repo = flowctl_db::TaskRepo::new(&conn); + if let Ok(tasks) = task_repo.list_by_epic(epic_id) { + if !tasks.is_empty() { + let mut map = HashMap::new(); + for task in tasks { + map.insert(task.id.clone(), task); + } + return map; + } + } + } + + // Fall back to Markdown scanning + let tasks_dir = flow_dir.join(TASKS_DIR); + if !tasks_dir.is_dir() { + return HashMap::new(); + } + + let mut map = HashMap::new(); + if let Ok(entries) = fs::read_dir(&tasks_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !is_task_id(stem) { + continue; + } + if let Ok(eid) = epic_id_from_task(stem) { + if eid != epic_id { + continue; + } + } else { + continue; + } + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(task) = frontmatter::parse_frontmatter::(&content) { + map.insert(task.id.clone(), task); + } + } + } + } + map +} + +/// Load an epic, trying DB first then Markdown. +fn load_epic(flow_dir: &Path, epic_id: &str) -> Option { + if let Some(conn) = try_open_db() { + let repo = flowctl_db::EpicRepo::new(&conn); + if let Ok(epic) = repo.get(epic_id) { + return Some(epic); + } + } + load_epic_md(flow_dir, epic_id) +} + +/// Load a task, trying DB first then Markdown. +fn load_task(flow_dir: &Path, task_id: &str) -> Option { + if let Some(conn) = try_open_db() { + let repo = flowctl_db::TaskRepo::new(&conn); + if let Ok(task) = repo.get(task_id) { + return Some(task); + } + } + load_task_md(flow_dir, task_id) +} + +/// Get runtime state for a task. +fn get_runtime(task_id: &str) -> Option { + let conn = try_open_db()?; + let repo = flowctl_db::RuntimeRepo::new(&conn); + repo.get(task_id).ok().flatten() +} + +/// Sort key for tasks: (priority, task_num, title). +fn task_sort_key(task: &Task) -> (u32, u32, String) { + let parsed = parse_id(&task.id).ok(); + ( + task.sort_priority(), + parsed.and_then(|p| p.task).unwrap_or(0), + task.title.clone(), + ) +} + +/// Scan all epic .md files in the epics directory, return their IDs sorted. +fn scan_epic_ids(flow_dir: &Path) -> Vec { + let epics_dir = flow_dir.join(EPICS_DIR); + if !epics_dir.is_dir() { + return Vec::new(); + } + + let epic_re = Regex::new( + r"^fn-(\d+)(?:-[a-z0-9][a-z0-9-]*[a-z0-9]|-[a-z0-9]{1,3})?\.md$", + ) + .unwrap(); + + let mut ids = Vec::new(); + if let Ok(entries) = fs::read_dir(&epics_dir) { + for entry in entries.flatten() { + let fname = entry.file_name(); + let name = fname.to_string_lossy(); + if epic_re.is_match(&name) { + let stem = name.trim_end_matches(".md"); + ids.push(stem.to_string()); + } + } + } + ids.sort_by_key(|id| parse_id(id).map(|p| p.epic).unwrap_or(0)); + ids +} + +/// Patch a Markdown section (## heading) with new content. +fn patch_md_section(doc: &str, heading: &str, new_content: &str) -> Option { + let heading_prefix = format!("{}\n", heading); + let pos = doc.find(&heading_prefix)?; + let after_heading = pos + heading_prefix.len(); + + // Find the next ## heading or end of document + let rest = &doc[after_heading..]; + let next_heading = rest.find("\n## ").map(|p| after_heading + p + 1); + + let mut result = String::with_capacity(doc.len()); + result.push_str(&doc[..after_heading]); + result.push_str(new_content.trim_end()); + result.push('\n'); + if let Some(nh) = next_heading { + result.push('\n'); + result.push_str(&doc[nh..]); + } + Some(result) +} + +/// Get a Markdown section content (between ## heading and next ## or EOF). +fn get_md_section(doc: &str, heading: &str) -> String { + let heading_prefix = format!("{}\n", heading); + let Some(pos) = doc.find(&heading_prefix) else { + return String::new(); + }; + let after_heading = pos + heading_prefix.len(); + let rest = &doc[after_heading..]; + let section_end = rest.find("\n## ").unwrap_or(rest.len()); + rest[..section_end].trim().to_string() +} + +/// Find all downstream dependents of a task within the same epic. +fn find_dependents(flow_dir: &Path, task_id: &str) -> Vec { + let epic_id = match epic_id_from_task(task_id) { + Ok(eid) => eid, + Err(_) => return Vec::new(), + }; + + let tasks = load_tasks_for_epic(flow_dir, &epic_id); + let mut dependents = Vec::new(); + let mut visited = std::collections::HashSet::new(); + let mut queue = vec![task_id.to_string()]; + + while let Some(current) = queue.pop() { + for (tid, task) in &tasks { + if visited.contains(tid.as_str()) { + continue; + } + if task.depends_on.contains(¤t) { + visited.insert(tid.clone()); + dependents.push(tid.clone()); + queue.push(tid.clone()); + } + } + } + + dependents.sort(); + dependents +} + +/// Read max_retries from .flow/config.json (defaults to 0 = no retries). +fn get_max_retries() -> u32 { + let config_path = get_flow_dir().join("config.json"); + if let Ok(content) = fs::read_to_string(&config_path) { + if let Ok(config) = serde_json::from_str::(&content) { + if let Some(max) = config.get("max_retries").and_then(|v| v.as_u64()) { + return max as u32; + } + } + } + 0 +} + +/// Propagate upstream_failed to all transitive downstream tasks of `failed_id`. +/// +/// Updates both SQLite and Markdown for each affected task. Returns the list +/// of task IDs that were marked upstream_failed. +fn propagate_upstream_failure(flow_dir: &Path, failed_id: &str) -> Vec { + let epic_id = match epic_id_from_task(failed_id) { + Ok(eid) => eid, + Err(_) => return Vec::new(), + }; + + let tasks = load_tasks_for_epic(flow_dir, &epic_id); + let task_list: Vec = tasks.values().cloned().collect(); + + let dag = match flowctl_core::TaskDag::from_tasks(&task_list) { + Ok(d) => d, + Err(_) => return Vec::new(), + }; + + let downstream = dag.propagate_failure(failed_id); + let mut affected = Vec::new(); + + for tid in &downstream { + let task = match tasks.get(tid) { + Some(t) => t, + None => continue, + }; + + // Only propagate to tasks that aren't already in a terminal or failure state. + if task.status.is_satisfied() || task.status.is_failed() { + continue; + } + + // Update SQLite + if let Some(conn) = try_open_db() { + let task_repo = flowctl_db::TaskRepo::new(&conn); + let _ = task_repo.update_status(tid, Status::UpstreamFailed); + } + + // Update Markdown frontmatter + let task_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", tid)); + if task_path.exists() { + if let Ok(content) = fs::read_to_string(&task_path) { + if let Ok(mut doc) = frontmatter::parse::(&content) { + doc.frontmatter.status = Status::UpstreamFailed; + doc.frontmatter.updated_at = Utc::now(); + if let Ok(new_content) = frontmatter::write(&doc) { + let _ = fs::write(&task_path, new_content); + } + } + } + } + + affected.push(tid.clone()); + } + + affected +} + +/// Handle task failure: check retries, set up_for_retry or failed + propagate. +/// +/// Returns `(final_status, upstream_failed_ids)`. +fn handle_task_failure( + flow_dir: &Path, + task_id: &str, + runtime: &Option, +) -> (Status, Vec) { + let max_retries = get_max_retries(); + let current_retry_count = runtime.as_ref().map(|r| r.retry_count).unwrap_or(0); + + if max_retries > 0 && current_retry_count < max_retries { + // Task has retries remaining — set up_for_retry + let new_retry_count = current_retry_count + 1; + + if let Some(conn) = try_open_db() { + let task_repo = flowctl_db::TaskRepo::new(&conn); + let _ = task_repo.update_status(task_id, Status::UpForRetry); + + let runtime_repo = flowctl_db::RuntimeRepo::new(&conn); + let rt = RuntimeState { + task_id: task_id.to_string(), + assignee: runtime.as_ref().and_then(|r| r.assignee.clone()), + claimed_at: None, + completed_at: None, + duration_secs: None, + blocked_reason: None, + baseline_rev: runtime.as_ref().and_then(|r| r.baseline_rev.clone()), + final_rev: None, + retry_count: new_retry_count, + }; + let _ = runtime_repo.upsert(&rt); + } + + // Update Markdown + let task_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id)); + if task_path.exists() { + if let Ok(content) = fs::read_to_string(&task_path) { + if let Ok(mut doc) = frontmatter::parse::(&content) { + doc.frontmatter.status = Status::UpForRetry; + doc.frontmatter.updated_at = Utc::now(); + if let Ok(new_content) = frontmatter::write(&doc) { + let _ = fs::write(&task_path, new_content); + } + } + } + } + + (Status::UpForRetry, Vec::new()) + } else { + // No retries remaining — mark failed and propagate + if let Some(conn) = try_open_db() { + let task_repo = flowctl_db::TaskRepo::new(&conn); + let _ = task_repo.update_status(task_id, Status::Failed); + } + + // Update Markdown + let task_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id)); + if task_path.exists() { + if let Ok(content) = fs::read_to_string(&task_path) { + if let Ok(mut doc) = frontmatter::parse::(&content) { + doc.frontmatter.status = Status::Failed; + doc.frontmatter.updated_at = Utc::now(); + if let Ok(new_content) = frontmatter::write(&doc) { + let _ = fs::write(&task_path, new_content); + } + } + } + } + + let affected = propagate_upstream_failure(flow_dir, task_id); + (Status::Failed, affected) + } +} + +// ── Commands ──────────────────────────────────────────────────────── + +pub fn cmd_ready(json_mode: bool, epic: String) { + let flow_dir = ensure_flow_exists(); + + if !is_epic_id(&epic) { + error_exit(&format!( + "Invalid epic ID: {}. Expected format: fn-N or fn-N-slug (e.g., fn-1, fn-1-add-auth)", + epic + )); + } + + let epic_path = flow_dir.join(EPICS_DIR).join(format!("{}.md", epic)); + if !epic_path.exists() { + error_exit(&format!("Epic {} not found", epic)); + } + + let current_actor = resolve_actor(); + let tasks = load_tasks_for_epic(&flow_dir, &epic); + + let mut ready = Vec::new(); + let mut in_progress = Vec::new(); + let mut blocked: Vec<(Task, Vec)> = Vec::new(); + + for task in tasks.values() { + match task.status { + Status::InProgress => { + in_progress.push(task.clone()); + continue; + } + Status::Done | Status::Skipped => continue, + Status::Blocked => { + blocked.push((task.clone(), vec!["status=blocked".to_string()])); + continue; + } + Status::Todo => {} + _ => continue, + } + + // Check all deps are done/skipped + let mut deps_done = true; + let mut blocking_deps = Vec::new(); + for dep in &task.depends_on { + match tasks.get(dep) { + Some(dep_task) if dep_task.status.is_satisfied() => {} + _ => { + deps_done = false; + blocking_deps.push(dep.clone()); + } + } + } + + if deps_done { + ready.push(task.clone()); + } else { + blocked.push((task.clone(), blocking_deps)); + } + } + + ready.sort_by_key(|t| task_sort_key(t)); + in_progress.sort_by_key(|t| task_sort_key(t)); + blocked.sort_by_key(|(t, _)| task_sort_key(t)); + + if json_mode { + json_output(json!({ + "epic": epic, + "actor": current_actor, + "ready": ready.iter().map(|t| json!({ + "id": t.id, + "title": t.title, + "depends_on": t.depends_on, + })).collect::>(), + "in_progress": in_progress.iter().map(|t| { + let assignee = get_runtime(&t.id) + .and_then(|rt| rt.assignee) + .unwrap_or_default(); + json!({ + "id": t.id, + "title": t.title, + "assignee": assignee, + }) + }).collect::>(), + "blocked": blocked.iter().map(|(t, deps)| json!({ + "id": t.id, + "title": t.title, + "blocked_by": deps, + })).collect::>(), + })); + } else { + println!("Ready tasks for {} (actor: {}):", epic, current_actor); + if ready.is_empty() { + println!(" (none)"); + } else { + for t in &ready { + println!(" {}: {}", t.id, t.title); + } + } + if !in_progress.is_empty() { + println!("\nIn progress:"); + for t in &in_progress { + let assignee = get_runtime(&t.id) + .and_then(|rt| rt.assignee) + .unwrap_or_else(|| "unknown".to_string()); + let marker = if assignee == current_actor { + " (you)" + } else { + "" + }; + println!(" {}: {} [{}]{}", t.id, t.title, assignee, marker); + } + } + if !blocked.is_empty() { + println!("\nBlocked:"); + for (t, deps) in &blocked { + println!(" {}: {} (by: {})", t.id, t.title, deps.join(", ")); + } + } + } +} + +pub fn cmd_next( + json_mode: bool, + epics_file: Option, + require_plan_review: bool, + require_completion_review: bool, +) { + let flow_dir = ensure_flow_exists(); + let current_actor = resolve_actor(); + + // Resolve epics list + let epic_ids: Vec = if let Some(ref file) = epics_file { + let content = match fs::read_to_string(file) { + Ok(c) => c, + Err(e) => error_exit(&format!("Cannot read epics file: {}", e)), + }; + let data: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => error_exit(&format!("Epics file invalid JSON: {}", e)), + }; + match data.get("epics").and_then(|v| v.as_array()) { + Some(arr) => { + let mut ids = Vec::new(); + for e in arr { + match e.as_str() { + Some(s) if is_epic_id(s) => ids.push(s.to_string()), + _ => error_exit(&format!("Invalid epic ID in epics file: {}", e)), + } + } + ids + } + None => error_exit("Epics file must be JSON with key 'epics' as a list"), + } + } else { + scan_epic_ids(&flow_dir) + }; + + let mut blocked_epics: HashMap> = HashMap::new(); + + for epic_id in &epic_ids { + let epic = match load_epic(&flow_dir, epic_id) { + Some(e) => e, + None => { + if epics_file.is_some() { + error_exit(&format!("Epic {} not found", epic_id)); + } + continue; + } + }; + + if epic.status == EpicStatus::Done { + continue; + } + + // Check epic-level deps + let mut epic_blocked_by = Vec::new(); + for dep in &epic.depends_on_epics { + if dep == epic_id { + continue; + } + match load_epic(&flow_dir, dep) { + Some(dep_epic) if dep_epic.status == EpicStatus::Done => {} + _ => epic_blocked_by.push(dep.clone()), + } + } + if !epic_blocked_by.is_empty() { + blocked_epics.insert(epic_id.clone(), epic_blocked_by); + continue; + } + + // Check plan review gate + if require_plan_review + && epic.plan_review != flowctl_core::types::ReviewStatus::Passed + { + if json_mode { + json_output(json!({ + "status": "plan", + "epic": epic_id, + "task": null, + "reason": "needs_plan_review", + })); + } else { + println!("plan {} needs_plan_review", epic_id); + } + return; + } + + let tasks = load_tasks_for_epic(&flow_dir, epic_id); + + // Resume in_progress tasks owned by current actor + let mut my_in_progress: Vec<&Task> = tasks + .values() + .filter(|t| t.status == Status::InProgress) + .filter(|t| { + get_runtime(&t.id) + .and_then(|rt| rt.assignee) + .map(|a| a == current_actor) + .unwrap_or(false) + }) + .collect(); + my_in_progress.sort_by_key(|t| task_sort_key(t)); + + if let Some(task) = my_in_progress.first() { + if json_mode { + json_output(json!({ + "status": "work", + "epic": epic_id, + "task": task.id, + "reason": "resume_in_progress", + })); + } else { + println!("work {} resume_in_progress", task.id); + } + return; + } + + // Find ready tasks + let mut ready: Vec<&Task> = tasks + .values() + .filter(|t| t.status == Status::Todo) + .filter(|t| { + t.depends_on.iter().all(|dep| { + tasks + .get(dep) + .map(|dt| dt.status.is_satisfied()) + .unwrap_or(false) + }) + }) + .collect(); + ready.sort_by_key(|t| task_sort_key(t)); + + if let Some(task) = ready.first() { + if json_mode { + json_output(json!({ + "status": "work", + "epic": epic_id, + "task": task.id, + "reason": "ready_task", + })); + } else { + println!("work {} ready_task", task.id); + } + return; + } + + // Check completion review + if require_completion_review + && !tasks.is_empty() + && tasks.values().all(|t| t.status == Status::Done) + && epic.completion_review != flowctl_core::types::ReviewStatus::Passed + { + if json_mode { + json_output(json!({ + "status": "completion_review", + "epic": epic_id, + "task": null, + "reason": "needs_completion_review", + })); + } else { + println!("completion_review {} needs_completion_review", epic_id); + } + return; + } + } + + // No work found + if json_mode { + let mut payload = json!({ + "status": "none", + "epic": null, + "task": null, + "reason": "none", + }); + if !blocked_epics.is_empty() { + payload["reason"] = json!("blocked_by_epic_deps"); + payload["blocked_epics"] = json!(blocked_epics); + } + json_output(payload); + } else if !blocked_epics.is_empty() { + println!("none blocked_by_epic_deps"); + for (eid, deps) in &blocked_epics { + println!(" {}: {}", eid, deps.join(", ")); + } + } else { + println!("none"); + } +} + +pub fn cmd_queue(json_mode: bool) { + let flow_dir = ensure_flow_exists(); + let current_actor = resolve_actor(); + + let epic_ids = scan_epic_ids(&flow_dir); + let mut epics_data: Vec = Vec::new(); + + for epic_id in &epic_ids { + let epic = match load_epic(&flow_dir, epic_id) { + Some(e) => e, + None => continue, + }; + + let tasks = load_tasks_for_epic(&flow_dir, epic_id); + + // Count tasks by status + let mut todo = 0u64; + let mut in_progress = 0u64; + let mut done = 0u64; + let mut blocked = 0u64; + let mut ready = 0u64; + + for task in tasks.values() { + match task.status { + Status::Todo => { + todo += 1; + // Check if ready + let deps_done = task.depends_on.iter().all(|dep| { + tasks + .get(dep) + .map(|dt| dt.status.is_satisfied()) + .unwrap_or(false) + }); + if deps_done { + ready += 1; + } + } + Status::InProgress => in_progress += 1, + Status::Done | Status::Skipped => done += 1, + Status::Blocked => blocked += 1, + _ => {} + } + } + + // Check epic-level deps + let mut epic_blocked_by = Vec::new(); + for dep in &epic.depends_on_epics { + if dep == epic_id { + continue; + } + match load_epic(&flow_dir, dep) { + Some(dep_epic) if dep_epic.status == EpicStatus::Done => {} + _ => epic_blocked_by.push(dep.clone()), + } + } + + let total = todo + in_progress + done + blocked; + let progress = if total > 0 { + ((done as f64 / total as f64) * 100.0).round() as u64 + } else { + 0 + }; + + epics_data.push(json!({ + "id": epic.id, + "title": epic.title, + "status": epic.status.to_string(), + "plan_review_status": epic.plan_review.to_string(), + "completion_review_status": epic.completion_review.to_string(), + "depends_on_epics": epic.depends_on_epics, + "blocked_by": epic_blocked_by, + "tasks": { + "todo": todo, + "in_progress": in_progress, + "done": done, + "blocked": blocked, + "ready": ready, + }, + "total_tasks": total, + "progress": progress, + })); + } + + // Sort: open (unblocked) first, then blocked, then done + epics_data.sort_by(|a, b| { + let a_status = if a["status"].as_str() == Some("done") { + 2 + } else if !a["blocked_by"] + .as_array() + .map_or(true, |v| v.is_empty()) + { + 1 + } else { + 0 + }; + let b_status = if b["status"].as_str() == Some("done") { + 2 + } else if !b["blocked_by"] + .as_array() + .map_or(true, |v| v.is_empty()) + { + 1 + } else { + 0 + }; + a_status.cmp(&b_status).then_with(|| { + let a_num = parse_id(a["id"].as_str().unwrap_or("")) + .map(|p| p.epic) + .unwrap_or(0); + let b_num = parse_id(b["id"].as_str().unwrap_or("")) + .map(|p| p.epic) + .unwrap_or(0); + a_num.cmp(&b_num) + }) + }); + + if json_mode { + json_output(json!({ + "actor": current_actor, + "epics": epics_data, + "total": epics_data.len(), + })); + } else { + let open_count = epics_data + .iter() + .filter(|e| e["status"].as_str() != Some("done")) + .count(); + let done_count = epics_data.len() - open_count; + println!("Queue ({} open, {} done):\n", open_count, done_count); + + for e in &epics_data { + let status_icon = if e["status"].as_str() == Some("done") { + "\u{2713}" + } else if !e["blocked_by"] + .as_array() + .map_or(true, |v| v.is_empty()) + { + "\u{2298}" + } else if e["tasks"]["ready"].as_u64().unwrap_or(0) > 0 { + "\u{25b6}" + } else { + "\u{25cb}" + }; + + let tc = &e["tasks"]; + let progress = e["progress"].as_u64().unwrap_or(0); + let bar_len = 20usize; + let total = e["total_tasks"].as_u64().unwrap_or(0); + let done_bars = if total > 0 { + (progress as usize * bar_len / 100).min(bar_len) + } else { + 0 + }; + let bar = format!( + "{}{}", + "\u{2588}".repeat(done_bars), + "\u{2591}".repeat(bar_len - done_bars) + ); + + println!( + " {} {}: {}", + status_icon, + e["id"].as_str().unwrap_or(""), + e["title"].as_str().unwrap_or("") + ); + println!( + " [{}] {}% done={} ready={} todo={} in_progress={} blocked={}", + bar, + progress, + tc["done"].as_u64().unwrap_or(0), + tc["ready"].as_u64().unwrap_or(0), + tc["todo"].as_u64().unwrap_or(0), + tc["in_progress"].as_u64().unwrap_or(0), + tc["blocked"].as_u64().unwrap_or(0) + ); + + if let Some(blocked_by) = e["blocked_by"].as_array() { + if !blocked_by.is_empty() { + let names: Vec<&str> = + blocked_by.iter().filter_map(|v| v.as_str()).collect(); + println!(" \u{2298} blocked by: {}", names.join(", ")); + } + } + + if let Some(deps) = e["depends_on_epics"].as_array() { + let blocked_by = e["blocked_by"].as_array(); + if !deps.is_empty() && blocked_by.map_or(true, |v| v.is_empty()) { + let names: Vec<&str> = deps.iter().filter_map(|v| v.as_str()).collect(); + println!(" \u{2192} deps (resolved): {}", names.join(", ")); + } + } + + println!(); + } + } +} + +pub fn cmd_start(json_mode: bool, id: String, force: bool, _note: Option) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(&id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M (e.g., fn-1.2, fn-1-add-auth.2)", + id + )); + } + + let task = match load_task(&flow_dir, &id) { + Some(t) => t, + None => error_exit(&format!("Task {} not found", id)), + }; + + // Validate dependencies unless --force + if !force { + for dep in &task.depends_on { + let dep_task = match load_task(&flow_dir, dep) { + Some(t) => t, + None => error_exit(&format!( + "Cannot start task {}: dependency {} not found", + id, dep + )), + }; + if !dep_task.status.is_satisfied() { + error_exit(&format!( + "Cannot start task {}: dependency {} is '{}', not 'done'. \ + Complete dependencies first or use --force to override.", + id, dep, dep_task.status + )); + } + } + } + + let current_actor = resolve_actor(); + let existing_rt = get_runtime(&id); + let existing_assignee = existing_rt.as_ref().and_then(|rt| rt.assignee.clone()); + + // Cannot start done task + if task.status == Status::Done { + error_exit(&format!("Cannot start task {}: status is 'done'.", id)); + } + + // Blocked requires --force + if task.status == Status::Blocked && !force { + error_exit(&format!( + "Cannot start task {}: status is 'blocked'. Use --force to override.", + id + )); + } + + // Check if claimed by someone else + if !force { + if let Some(ref assignee) = existing_assignee { + if assignee != ¤t_actor { + error_exit(&format!( + "Cannot start task {}: claimed by '{}'. Use --force to override.", + id, assignee + )); + } + } + } + + // Validate task is in todo status (unless --force or resuming own task) + if !force && task.status != Status::Todo { + let can_resume = task.status == Status::InProgress + && existing_assignee + .as_ref() + .map(|a| a == ¤t_actor) + .unwrap_or(false); + if !can_resume { + error_exit(&format!( + "Cannot start task {}: status is '{}', expected 'todo'. Use --force to override.", + id, task.status + )); + } + } + + // Build runtime state + let now = Utc::now(); + let force_takeover = force + && existing_assignee + .as_ref() + .map(|a| a != ¤t_actor) + .unwrap_or(false); + let new_assignee = if existing_assignee.is_none() || force_takeover { + current_actor.clone() + } else { + existing_assignee.clone().unwrap_or_else(|| current_actor.clone()) + }; + + let claimed_at = if existing_rt + .as_ref() + .and_then(|rt| rt.claimed_at) + .is_some() + && !force_takeover + { + existing_rt.as_ref().unwrap().claimed_at + } else { + Some(now) + }; + + let runtime_state = RuntimeState { + task_id: id.clone(), + assignee: Some(new_assignee), + claimed_at, + completed_at: None, + duration_secs: None, + blocked_reason: None, + baseline_rev: existing_rt.as_ref().and_then(|rt| rt.baseline_rev.clone()), + final_rev: None, + retry_count: existing_rt.as_ref().map(|rt| rt.retry_count).unwrap_or(0), + }; + + // Write SQLite first (authoritative) + if let Some(conn) = try_open_db() { + let task_repo = flowctl_db::TaskRepo::new(&conn); + if let Err(e) = task_repo.update_status(&id, Status::InProgress) { + error_exit(&format!("Failed to update task status: {}", e)); + } + let runtime_repo = flowctl_db::RuntimeRepo::new(&conn); + if let Err(e) = runtime_repo.upsert(&runtime_state) { + error_exit(&format!("Failed to update runtime state: {}", e)); + } + } + + // Update Markdown frontmatter + let task_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", id)); + if task_path.exists() { + if let Ok(content) = fs::read_to_string(&task_path) { + if let Ok(mut doc) = frontmatter::parse::(&content) { + doc.frontmatter.status = Status::InProgress; + doc.frontmatter.updated_at = now; + if let Ok(new_content) = frontmatter::write(&doc) { + let _ = fs::write(&task_path, new_content); + } + } + } + } + + if json_mode { + json_output(json!({ + "id": id, + "status": "in_progress", + "message": format!("Task {} started", id), + })); + } else { + println!("Task {} started", id); + } +} + +pub fn cmd_done( + json_mode: bool, + id: String, + summary_file: Option, + summary: Option, + evidence_json: Option, + evidence: Option, + force: bool, +) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(&id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M (e.g., fn-1.2, fn-1-add-auth.2)", + id + )); + } + + let task = match load_task(&flow_dir, &id) { + Some(t) => t, + None => error_exit(&format!("Task {} not found", id)), + }; + + // Require in_progress status (unless --force) + if !force { + match task.status { + Status::InProgress => {} + Status::Done => error_exit(&format!("Task {} is already done.", id)), + other => error_exit(&format!( + "Task {} is '{}', not 'in_progress'. Use --force to override.", + id, other + )), + } + } + + // Prevent cross-actor completion (unless --force) + let current_actor = resolve_actor(); + let runtime = get_runtime(&id); + if !force { + if let Some(ref rt) = runtime { + if let Some(ref assignee) = rt.assignee { + if assignee != ¤t_actor { + error_exit(&format!( + "Cannot complete task {}: claimed by '{}'. Use --force to override.", + id, assignee + )); + } + } + } + } + + // Get summary + let summary_text = if let Some(ref file) = summary_file { + match fs::read_to_string(file) { + Ok(s) => s, + Err(e) => error_exit(&format!("Cannot read summary file: {}", e)), + } + } else if let Some(ref s) = summary { + s.clone() + } else { + "- Task completed".to_string() + }; + + // Get evidence + let evidence_obj: serde_json::Value = if let Some(ref ev) = evidence_json { + let raw = if ev.trim().starts_with('{') { + ev.clone() + } else { + match fs::read_to_string(ev) { + Ok(s) => s, + Err(e) => error_exit(&format!("Cannot read evidence file: {}", e)), + } + }; + match serde_json::from_str(&raw) { + Ok(v) => v, + Err(e) => error_exit(&format!("Evidence JSON invalid: {}", e)), + } + } else if let Some(ref ev) = evidence { + match serde_json::from_str(ev) { + Ok(v) => v, + Err(e) => error_exit(&format!("Evidence invalid JSON: {}", e)), + } + } else { + json!({"commits": [], "tests": [], "prs": []}) + }; + + if !evidence_obj.is_object() { + error_exit("Evidence JSON must be an object with keys: commits/tests/prs"); + } + + // Calculate duration from claimed_at + let duration_seconds: Option = runtime + .as_ref() + .and_then(|rt| rt.claimed_at) + .map(|start| { + let dur = Utc::now() - start; + dur.num_seconds().max(0) as u64 + }); + + // Validate workspace_changes if present + let ws_changes = evidence_obj.get("workspace_changes"); + let mut ws_warning: Option = None; + if let Some(wc) = ws_changes { + if !wc.is_object() { + ws_warning = Some("workspace_changes must be an object".to_string()); + } else { + let required = [ + "baseline_rev", + "final_rev", + "files_changed", + "insertions", + "deletions", + ]; + let missing: Vec<&str> = required + .iter() + .filter(|k| !wc.as_object().unwrap().contains_key(**k)) + .copied() + .collect(); + if !missing.is_empty() { + ws_warning = Some(format!( + "workspace_changes missing keys: {}", + missing.join(", ") + )); + } + } + } + + // Format evidence as markdown + let to_list = |val: Option<&serde_json::Value>| -> Vec { + match val { + None => Vec::new(), + Some(serde_json::Value::Array(arr)) => arr + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .filter(|s| !s.is_empty()) + .collect(), + Some(serde_json::Value::String(s)) if !s.is_empty() => vec![s.clone()], + _ => Vec::new(), + } + }; + + let commits = to_list(evidence_obj.get("commits")); + let tests = to_list(evidence_obj.get("tests")); + let prs = to_list(evidence_obj.get("prs")); + + let mut evidence_md = Vec::new(); + if commits.is_empty() { + evidence_md.push("- Commits:".to_string()); + } else { + evidence_md.push(format!("- Commits: {}", commits.join(", "))); + } + if tests.is_empty() { + evidence_md.push("- Tests:".to_string()); + } else { + evidence_md.push(format!("- Tests: {}", tests.join(", "))); + } + if prs.is_empty() { + evidence_md.push("- PRs:".to_string()); + } else { + evidence_md.push(format!("- PRs: {}", prs.join(", "))); + } + + if ws_warning.is_none() { + if let Some(wc) = ws_changes { + if wc.is_object() { + let fc = wc.get("files_changed").and_then(|v| v.as_u64()).unwrap_or(0); + let ins = wc.get("insertions").and_then(|v| v.as_u64()).unwrap_or(0); + let del = wc.get("deletions").and_then(|v| v.as_u64()).unwrap_or(0); + let br = wc + .get("baseline_rev") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let fr = wc + .get("final_rev") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + evidence_md.push(format!( + "- Workspace: {} files changed, +{} -{} ({}..{})", + fc, + ins, + del, + &br[..br.len().min(7)], + &fr[..fr.len().min(7)] + )); + } + } + } + + if let Some(dur) = duration_seconds { + let mins = dur / 60; + let secs = dur % 60; + let dur_str = if mins > 0 { + format!("{}m {}s", mins, secs) + } else { + format!("{}s", secs) + }; + evidence_md.push(format!("- Duration: {}", dur_str)); + } + let evidence_content = evidence_md.join("\n"); + + // Write SQLite first (authoritative) + if let Some(conn) = try_open_db() { + let task_repo = flowctl_db::TaskRepo::new(&conn); + let _ = task_repo.update_status(&id, Status::Done); + + let runtime_repo = flowctl_db::RuntimeRepo::new(&conn); + let now = Utc::now(); + let rt = RuntimeState { + task_id: id.clone(), + assignee: runtime.as_ref().and_then(|r| r.assignee.clone()), + claimed_at: runtime.as_ref().and_then(|r| r.claimed_at), + completed_at: Some(now), + duration_secs: duration_seconds, + blocked_reason: None, + baseline_rev: runtime.as_ref().and_then(|r| r.baseline_rev.clone()), + final_rev: runtime.as_ref().and_then(|r| r.final_rev.clone()), + retry_count: runtime.as_ref().map(|r| r.retry_count).unwrap_or(0), + }; + let _ = runtime_repo.upsert(&rt); + + // Store evidence + let ev = Evidence { + commits: commits.clone(), + tests: tests.clone(), + prs: prs.clone(), + ..Evidence::default() + }; + let evidence_repo = flowctl_db::EvidenceRepo::new(&conn); + let _ = evidence_repo.upsert(&id, &ev); + } + + // Update Markdown spec + let task_spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", id)); + if task_spec_path.exists() { + if let Ok(current_spec) = fs::read_to_string(&task_spec_path) { + let mut updated = current_spec; + if let Some(patched) = patch_md_section(&updated, "## Done summary", &summary_text) { + updated = patched; + } + if let Some(patched) = patch_md_section(&updated, "## Evidence", &evidence_content) { + updated = patched; + } + + // Update frontmatter status + if let Ok(mut doc) = frontmatter::parse::(&updated) { + doc.frontmatter.status = Status::Done; + doc.frontmatter.updated_at = Utc::now(); + if let Ok(new_content) = frontmatter::write(&doc) { + let _ = fs::write(&task_spec_path, new_content); + } + } else { + let _ = fs::write(&task_spec_path, updated); + } + } + } + + // Archive review receipt if present + if let Some(receipt) = evidence_obj.get("review_receipt") { + if receipt.is_object() { + let reviews_dir = flow_dir.join(REVIEWS_DIR); + let _ = fs::create_dir_all(&reviews_dir); + let mode = receipt + .get("mode") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let rtype = receipt + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("review"); + let filename = format!("{}-{}-{}.json", rtype, id, mode); + if let Ok(content) = serde_json::to_string_pretty(receipt) { + let _ = fs::write(reviews_dir.join(filename), content); + } + } + } + + if json_mode { + let mut result = json!({ + "id": id, + "status": "done", + "message": format!("Task {} completed", id), + }); + if let Some(dur) = duration_seconds { + result["duration_seconds"] = json!(dur); + } + if let Some(ref warn) = ws_warning { + result["warning"] = json!(warn); + } + json_output(result); + } else { + let dur_str = duration_seconds.map(|dur| { + let mins = dur / 60; + let secs = dur % 60; + if mins > 0 { + format!(" ({}m {}s)", mins, secs) + } else { + format!(" ({}s)", secs) + } + }); + println!("Task {} completed{}", id, dur_str.unwrap_or_default()); + if let Some(warn) = ws_warning { + println!(" warning: {}", warn); + } + } +} + +pub fn cmd_block(json_mode: bool, id: String, reason_file: String) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(&id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M (e.g., fn-1.2, fn-1-add-auth.2)", + id + )); + } + + let task = match load_task(&flow_dir, &id) { + Some(t) => t, + None => error_exit(&format!("Task {} not found", id)), + }; + + if task.status == Status::Done { + error_exit(&format!("Cannot block task {}: status is 'done'.", id)); + } + + let reason = match fs::read_to_string(&reason_file) { + Ok(s) => s.trim().to_string(), + Err(e) => error_exit(&format!("Cannot read reason file: {}", e)), + }; + + if reason.is_empty() { + error_exit("Reason file is empty"); + } + + // Write SQLite first (authoritative) + if let Some(conn) = try_open_db() { + let task_repo = flowctl_db::TaskRepo::new(&conn); + let _ = task_repo.update_status(&id, Status::Blocked); + + let runtime_repo = flowctl_db::RuntimeRepo::new(&conn); + let existing = runtime_repo.get(&id).ok().flatten(); + let rt = RuntimeState { + task_id: id.clone(), + assignee: existing.as_ref().and_then(|r| r.assignee.clone()), + claimed_at: existing.as_ref().and_then(|r| r.claimed_at), + completed_at: None, + duration_secs: None, + blocked_reason: Some(reason.clone()), + baseline_rev: existing.as_ref().and_then(|r| r.baseline_rev.clone()), + final_rev: None, + retry_count: existing.as_ref().map(|r| r.retry_count).unwrap_or(0), + }; + let _ = runtime_repo.upsert(&rt); + } + + // Update Markdown spec + let task_spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", id)); + if task_spec_path.exists() { + if let Ok(current_spec) = fs::read_to_string(&task_spec_path) { + let existing_summary = get_md_section(¤t_spec, "## Done summary"); + let new_summary = if existing_summary.is_empty() + || existing_summary.to_lowercase() == "tbd" + { + format!("Blocked:\n{}", reason) + } else { + format!("{}\n\nBlocked:\n{}", existing_summary, reason) + }; + + let mut updated = current_spec; + if let Some(patched) = patch_md_section(&updated, "## Done summary", &new_summary) { + updated = patched; + } + + // Update frontmatter + if let Ok(mut doc) = frontmatter::parse::(&updated) { + doc.frontmatter.status = Status::Blocked; + doc.frontmatter.updated_at = Utc::now(); + if let Ok(new_content) = frontmatter::write(&doc) { + let _ = fs::write(&task_spec_path, new_content); + } + } else { + let _ = fs::write(&task_spec_path, updated); + } + } + } + + if json_mode { + json_output(json!({ + "id": id, + "status": "blocked", + "message": format!("Task {} blocked", id), + })); + } else { + println!("Task {} blocked", id); + } +} + +pub fn cmd_fail(json_mode: bool, id: String, reason: Option, force: bool) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(&id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M (e.g., fn-1.2, fn-1-add-auth.2)", + id + )); + } + + let task = match load_task(&flow_dir, &id) { + Some(t) => t, + None => error_exit(&format!("Task {} not found", id)), + }; + + if !force && task.status != Status::InProgress { + error_exit(&format!( + "Task {} is '{}', not 'in_progress'. Use --force to override.", + id, task.status + )); + } + + let runtime = get_runtime(&id); + let reason_text = reason.unwrap_or_else(|| "Task failed".to_string()); + + let (final_status, upstream_failed_ids) = handle_task_failure(&flow_dir, &id, &runtime); + + // Update Done summary with failure reason + let task_spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", id)); + if task_spec_path.exists() { + if let Ok(content) = fs::read_to_string(&task_spec_path) { + let mut updated = content; + let summary = format!("Failed:\n{}", reason_text); + if let Some(patched) = patch_md_section(&updated, "## Done summary", &summary) { + updated = patched; + } + // Frontmatter was already updated by handle_task_failure, just write body changes + let _ = fs::write(&task_spec_path, updated); + } + } + + if json_mode { + let mut result = json!({ + "id": id, + "status": final_status.to_string(), + "message": format!("Task {} {}", id, final_status), + "reason": reason_text, + }); + if !upstream_failed_ids.is_empty() { + result["upstream_failed"] = json!(upstream_failed_ids); + } + json_output(result); + } else { + println!("Task {} {}", id, final_status); + if final_status == Status::UpForRetry { + let max = get_max_retries(); + let count = runtime.as_ref().map(|r| r.retry_count).unwrap_or(0) + 1; + println!(" retry {}/{} — will be retried by scheduler", count, max); + } + if !upstream_failed_ids.is_empty() { + println!( + " upstream_failed propagated to {} downstream task(s):", + upstream_failed_ids.len() + ); + for tid in &upstream_failed_ids { + println!(" {}", tid); + } + } + } +} + +pub fn cmd_restart(json_mode: bool, id: String, dry_run: bool, force: bool) { + let flow_dir = ensure_flow_exists(); + + if !is_task_id(&id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M", + id + )); + } + + let task = match load_task(&flow_dir, &id) { + Some(t) => t, + None => error_exit(&format!("Task {} not found", id)), + }; + + // Check epic not closed + if let Ok(epic_id) = epic_id_from_task(&id) { + if let Some(epic) = load_epic(&flow_dir, &epic_id) { + if epic.status == EpicStatus::Done { + error_exit(&format!("Cannot restart task in closed epic {}", epic_id)); + } + } + } + + // Find all downstream dependents + let dependents = find_dependents(&flow_dir, &id); + + // Check for in_progress tasks + let mut in_progress_ids = Vec::new(); + if task.status == Status::InProgress { + in_progress_ids.push(id.clone()); + } + for dep_id in &dependents { + if let Some(dep_task) = load_task(&flow_dir, dep_id) { + if dep_task.status == Status::InProgress { + in_progress_ids.push(dep_id.clone()); + } + } + } + + if !in_progress_ids.is_empty() && !force { + error_exit(&format!( + "Cannot restart: tasks in progress: {}. Use --force to override.", + in_progress_ids.join(", ") + )); + } + + // Build full reset list + let all_ids: Vec = std::iter::once(id.clone()) + .chain(dependents.iter().cloned()) + .collect(); + let mut to_reset = Vec::new(); + let mut skipped = Vec::new(); + + for tid in &all_ids { + let t = match load_task(&flow_dir, tid) { + Some(t) => t, + None => continue, + }; + if t.status == Status::Todo { + skipped.push(tid.clone()); + } else { + to_reset.push(tid.clone()); + } + } + + // Dry-run mode + if dry_run { + if json_mode { + json_output(json!({ + "dry_run": true, + "would_reset": to_reset, + "already_todo": skipped, + "in_progress_overridden": if force { in_progress_ids.clone() } else { Vec::::new() }, + })); + } else { + println!( + "Dry run \u{2014} would restart {} task(s):", + to_reset.len() + ); + for tid in &to_reset { + if let Some(t) = load_task(&flow_dir, tid) { + let marker = if in_progress_ids.contains(tid) { + " (force)" + } else { + "" + }; + println!(" {} {} -> todo{}", tid, t.status, marker); + } + } + if !skipped.is_empty() { + println!("Already todo: {}", skipped.join(", ")); + } + } + return; + } + + // Execute reset + let mut reset_ids = Vec::new(); + for tid in &to_reset { + // Reset in SQLite + if let Some(conn) = try_open_db() { + let task_repo = flowctl_db::TaskRepo::new(&conn); + let _ = task_repo.update_status(tid, Status::Todo); + + // Clear runtime state + let runtime_repo = flowctl_db::RuntimeRepo::new(&conn); + let rt = RuntimeState { + task_id: tid.clone(), + assignee: None, + claimed_at: None, + completed_at: None, + duration_secs: None, + blocked_reason: None, + baseline_rev: None, + final_rev: None, + retry_count: 0, + }; + let _ = runtime_repo.upsert(&rt); + } + + // Update Markdown frontmatter + clear evidence + let task_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", tid)); + if task_path.exists() { + if let Ok(content) = fs::read_to_string(&task_path) { + let mut updated = content; + + // Clear sections + if let Some(patched) = patch_md_section(&updated, "## Done summary", "TBD") { + updated = patched; + } + if let Some(patched) = patch_md_section(&updated, "## Evidence", "TBD") { + updated = patched; + } + + // Update frontmatter status + if let Ok(mut doc) = frontmatter::parse::(&updated) { + doc.frontmatter.status = Status::Todo; + doc.frontmatter.updated_at = Utc::now(); + if let Ok(new_content) = frontmatter::write(&doc) { + updated = new_content; + } + } + + let _ = fs::write(&task_path, updated); + } + } + + reset_ids.push(tid.clone()); + } + + if json_mode { + json_output(json!({ + "reset": reset_ids, + "skipped": skipped, + "cascade_from": id, + })); + } else if reset_ids.is_empty() { + println!( + "Nothing to restart \u{2014} {} and dependents already todo.", + id + ); + } else { + let downstream_count = + reset_ids.len() - if reset_ids.contains(&id) { 1 } else { 0 }; + println!( + "Restarted from {} (cascade: {} downstream):\n", + id, downstream_count + ); + for tid in &reset_ids { + let marker = if *tid == id { " (target)" } else { "" }; + println!(" {} -> todo{}", tid, marker); + } + } +} + +// ── Phase definitions ────────────────────────────────────────────── + +/// Phase definition: (id, title, done_condition). +struct PhaseDef { + id: &'static str, + title: &'static str, + done_condition: &'static str, +} + +const PHASE_DEFS: &[PhaseDef] = &[ + PhaseDef { id: "0", title: "Verify Configuration", done_condition: "OWNED_FILES verified and configuration validated" }, + PhaseDef { id: "1", title: "Re-anchor", done_condition: "Run flowctl show and verify spec was read" }, + PhaseDef { id: "2a", title: "TDD Red-Green", done_condition: "Failing tests written and confirmed to fail" }, + PhaseDef { id: "2", title: "Implement", done_condition: "Feature implemented and code compiles" }, + PhaseDef { id: "2.5", title: "Verify & Fix", done_condition: "flowctl guard passes and diff reviewed" }, + PhaseDef { id: "3", title: "Commit", done_condition: "Changes committed with conventional commit message" }, + PhaseDef { id: "4", title: "Review", done_condition: "SHIP verdict received from reviewer" }, + PhaseDef { id: "5", title: "Complete", done_condition: "flowctl done called and task status is done" }, + PhaseDef { id: "5b", title: "Memory Auto-Save", done_condition: "Non-obvious lessons saved to memory (if any)" }, + PhaseDef { id: "6", title: "Return", done_condition: "Summary returned to main conversation" }, +]; + +/// Canonical ordering of all phases — used to merge sequences. +const CANONICAL_ORDER: &[&str] = &["0", "1", "2a", "2", "2.5", "3", "4", "5", "5b", "6"]; + +/// Default phase sequence (Worktree + Teams, always includes Phase 0). +const PHASE_SEQ_DEFAULT: &[&str] = &["0", "1", "2", "2.5", "3", "5", "5b", "6"]; +const PHASE_SEQ_TDD: &[&str] = &["0", "1", "2a", "2", "2.5", "3", "5", "5b", "6"]; +const PHASE_SEQ_REVIEW: &[&str] = &["0", "1", "2", "2.5", "3", "4", "5", "5b", "6"]; + +fn get_phase_def(phase_id: &str) -> Option<&'static PhaseDef> { + PHASE_DEFS.iter().find(|p| p.id == phase_id) +} + +/// Build the phase sequence based on mode flags. +fn build_phase_sequence(tdd: bool, review: bool) -> Vec<&'static str> { + if !tdd && !review { + return PHASE_SEQ_DEFAULT.to_vec(); + } + + let mut phases = std::collections::HashSet::new(); + for p in PHASE_SEQ_DEFAULT { + phases.insert(*p); + } + if tdd { + for p in PHASE_SEQ_TDD { + phases.insert(*p); + } + } + if review { + for p in PHASE_SEQ_REVIEW { + phases.insert(*p); + } + } + CANONICAL_ORDER.iter().copied().filter(|p| phases.contains(p)).collect() +} + +/// Load completed phases from SQLite. +fn load_completed_phases(task_id: &str) -> Vec { + if let Some(conn) = try_open_db() { + let repo = flowctl_db::PhaseProgressRepo::new(&conn); + repo.get_completed(task_id).unwrap_or_default() + } else { + Vec::new() + } +} + +/// Mark a phase as done in SQLite. +fn save_phase_done(task_id: &str, phase: &str) { + if let Some(conn) = try_open_db() { + let repo = flowctl_db::PhaseProgressRepo::new(&conn); + if let Err(e) = repo.mark_done(task_id, phase) { + eprintln!("Warning: failed to save phase progress: {}", e); + } + } +} + +// ── Worker-phase dispatch ───────────────────────────────────────── + +pub fn dispatch_worker_phase(cmd: &WorkerPhaseCmd, json_mode: bool) { + match cmd { + WorkerPhaseCmd::Next { task, tdd, review } => { + cmd_worker_phase_next(json_mode, task, *tdd, review.as_deref()); + } + WorkerPhaseCmd::Done { task, phase, tdd, review } => { + cmd_worker_phase_done(json_mode, task, phase, *tdd, review.as_deref()); + } + } +} + +fn cmd_worker_phase_next(json_mode: bool, task_id: &str, tdd: bool, review: Option<&str>) { + let _flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M", + task_id + )); + } + + let seq = build_phase_sequence(tdd, review.is_some()); + let completed = load_completed_phases(task_id); + let completed_set: std::collections::HashSet<&str> = + completed.iter().map(|s| s.as_str()).collect(); + + // Find first uncompleted phase + let next_phase = seq.iter().find(|p| !completed_set.contains(**p)).copied(); + + match next_phase { + None => { + if json_mode { + json_output(json!({ + "phase": null, + "all_done": true, + "sequence": seq, + })); + } else { + println!("All phases completed."); + } + } + Some(phase_id) => { + let def = get_phase_def(phase_id); + let title = def.map(|d| d.title).unwrap_or("Unknown"); + let done_condition = def.map(|d| d.done_condition).unwrap_or(""); + + let sorted_completed: Vec<&str> = seq.iter() + .copied() + .filter(|p| completed_set.contains(*p)) + .collect(); + + if json_mode { + json_output(json!({ + "phase": phase_id, + "title": title, + "done_condition": done_condition, + "content": "", + "completed_phases": sorted_completed, + "sequence": seq, + "all_done": false, + })); + } else { + println!("Next phase: {} - {}", phase_id, title); + println!("Done when: {}", done_condition); + if !sorted_completed.is_empty() { + println!("Completed: {}", sorted_completed.join(", ")); + } + } + } + } +} + +fn cmd_worker_phase_done( + json_mode: bool, + task_id: &str, + phase: &str, + tdd: bool, + review: Option<&str>, +) { + let _flow_dir = ensure_flow_exists(); + + if !is_task_id(task_id) { + error_exit(&format!( + "Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M", + task_id + )); + } + + let seq = build_phase_sequence(tdd, review.is_some()); + + // Validate phase exists in sequence + if !seq.contains(&phase) { + error_exit(&format!( + "Phase '{}' is not in the current sequence: {}. \ + Check your mode flags (--tdd, --review).", + phase, + seq.join(", ") + )); + } + + let completed = load_completed_phases(task_id); + let completed_set: std::collections::HashSet<&str> = + completed.iter().map(|s| s.as_str()).collect(); + + // Find expected next phase (first uncompleted) + let expected = seq.iter().find(|p| !completed_set.contains(**p)).copied(); + + match expected { + None => { + error_exit("All phases are already completed. Nothing to mark done."); + } + Some(exp) if exp != phase => { + error_exit(&format!( + "Expected phase {}, got phase {}. Cannot skip phases.", + exp, phase + )); + } + _ => {} + } + + // Mark phase done + save_phase_done(task_id, phase); + + // Reload to get updated state + let updated_completed = load_completed_phases(task_id); + let updated_set: std::collections::HashSet<&str> = + updated_completed.iter().map(|s| s.as_str()).collect(); + let next_phase = seq.iter().find(|p| !updated_set.contains(**p)).copied(); + let all_done = next_phase.is_none(); + + if json_mode { + let mut result = json!({ + "completed_phase": phase, + "completed_phases": updated_completed, + "all_done": all_done, + }); + if let Some(np) = next_phase { + let def = get_phase_def(np); + result["next_phase"] = json!({ + "phase": np, + "title": def.map(|d| d.title).unwrap_or("Unknown"), + "done_condition": def.map(|d| d.done_condition).unwrap_or(""), + }); + } + json_output(result); + } else { + println!("Phase {} marked done.", phase); + if let Some(np) = next_phase { + let def = get_phase_def(np); + let title = def.map(|d| d.title).unwrap_or("Unknown"); + println!("Next: {} - {}", np, title); + } else { + println!("All phases completed."); + } + } +} diff --git a/flowctl/crates/flowctl-cli/src/diagnostics.rs b/flowctl/crates/flowctl-cli/src/diagnostics.rs new file mode 100644 index 00000000..8f236c88 --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/diagnostics.rs @@ -0,0 +1,178 @@ +//! Rich error diagnostics via miette for CLI error reporting. +//! +//! Provides pretty-printed errors with source context for frontmatter +//! parse failures and other structured errors. + +use miette::{Diagnostic, NamedSource, SourceSpan}; +use thiserror::Error; + +/// A rich diagnostic for frontmatter parse errors. +/// +/// Shows the file path, the source content with the error location +/// highlighted, and a helpful suggestion. +#[derive(Debug, Error, Diagnostic)] +#[error("failed to parse frontmatter")] +#[diagnostic( + code(flowctl::frontmatter::parse), +)] +pub struct FrontmatterDiagnostic { + /// The source file content, named by path. + #[source_code] + pub src: NamedSource, + + /// The span where the error occurred (byte offset + length). + #[label("error here")] + pub span: SourceSpan, + + /// The underlying parse error message. + #[help] + pub detail: String, +} + +impl FrontmatterDiagnostic { + /// Create a diagnostic from a file path, its content, and the error message. + /// + /// Attempts to locate the error within the source. If the error mentions + /// a line number, uses that; otherwise highlights the frontmatter region. + pub fn from_parse_error(path: &str, content: &str, error_msg: &str) -> Self { + let (offset, length) = find_error_span(content, error_msg); + + FrontmatterDiagnostic { + src: NamedSource::new(path, content.to_string()), + span: SourceSpan::new(offset.into(), length), + detail: error_msg.to_string(), + } + } +} + +/// Try to find the byte offset + length for an error span. +/// +/// Heuristics: +/// 1. If the error mentions "line N", highlight that line. +/// 2. If the error mentions "no closing ---", highlight end of content. +/// 3. If the error mentions "does not start with ---", highlight the first line. +/// 4. Otherwise, highlight the entire frontmatter region. +fn find_error_span(content: &str, error_msg: &str) -> (usize, usize) { + let lower = error_msg.to_lowercase(); + + // Try to extract a line number from the error message. + if let Some(line_num) = extract_line_number(&lower) { + if let Some((offset, len)) = line_span(content, line_num) { + return (offset, len); + } + } + + // "does not start with ---" -> first line + if lower.contains("does not start with") { + let first_line_end = content.find('\n').unwrap_or(content.len()); + return (0, first_line_end.min(content.len())); + } + + // "no closing ---" -> end of content + if lower.contains("no closing") { + let start = content.len().saturating_sub(20); + return (start, content.len() - start); + } + + // Default: highlight the frontmatter region (between first two ---) + if content.starts_with("---") { + let after_open = content.find('\n').map(|p| p + 1).unwrap_or(3); + let close = content[after_open..].find("\n---"); + let end = close.map(|p| after_open + p + 4).unwrap_or(content.len().min(200)); + return (0, end); + } + + // Fallback: first 80 chars + (0, content.len().min(80)) +} + +/// Extract a line number from an error message (e.g., "at line 5"). +fn extract_line_number(msg: &str) -> Option { + let patterns = ["line ", "at line "]; + for pat in &patterns { + if let Some(pos) = msg.find(pat) { + let after = &msg[pos + pat.len()..]; + let num_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(n) = num_str.parse::() { + return Some(n); + } + } + } + None +} + +/// Get the byte offset and length of a 1-indexed line in content. +fn line_span(content: &str, line_num: usize) -> Option<(usize, usize)> { + if line_num == 0 { + return None; + } + let mut current_line = 1; + let mut line_start = 0; + for (i, ch) in content.char_indices() { + if current_line == line_num { + let line_end = content[i..].find('\n').map(|p| i + p).unwrap_or(content.len()); + return Some((line_start, line_end - line_start)); + } + if ch == '\n' { + current_line += 1; + line_start = i + 1; + } + } + if current_line == line_num { + Some((line_start, content.len() - line_start)) + } else { + None + } +} + +/// Report a frontmatter diagnostic to stderr using miette's handler. +pub fn report_frontmatter_error(path: &str, content: &str, error_msg: &str) { + let diag = FrontmatterDiagnostic::from_parse_error(path, content, error_msg); + eprintln!("{:?}", miette::Report::new(diag)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_line_number() { + assert_eq!(extract_line_number("error at line 5"), Some(5)); + assert_eq!(extract_line_number("line 12 column 3"), Some(12)); + assert_eq!(extract_line_number("no line info"), None); + } + + #[test] + fn test_line_span() { + let content = "line1\nline2\nline3\n"; + assert_eq!(line_span(content, 1), Some((0, 5))); + assert_eq!(line_span(content, 2), Some((6, 5))); + assert_eq!(line_span(content, 3), Some((12, 5))); + } + + #[test] + fn test_find_error_span_first_line() { + let content = "not yaml\nstuff\n"; + let (offset, _len) = find_error_span(content, "does not start with ---"); + assert_eq!(offset, 0); + } + + #[test] + fn test_find_error_span_no_closing() { + let content = "---\nid: test\ntitle: Test\n"; + let (offset, len) = find_error_span(content, "no closing --- delimiter"); + assert!(offset + len == content.len()); + } + + #[test] + fn test_diagnostic_creation() { + let path = "tasks/fn-1-test.1.md"; + let content = "---\nid: test\n: invalid\n---\n"; + let diag = FrontmatterDiagnostic::from_parse_error( + path, + content, + "YAML parse error at line 3", + ); + assert_eq!(diag.detail, "YAML parse error at line 3"); + } +} diff --git a/flowctl/crates/flowctl-cli/src/main.rs b/flowctl/crates/flowctl-cli/src/main.rs new file mode 100644 index 00000000..bca416bc --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/main.rs @@ -0,0 +1,481 @@ +//! flowctl CLI entry point. +//! +//! Clap 4 derive-based CLI matching the full Python flowctl command surface. +//! All commands are registered as stubs that return "not yet implemented". + +mod commands; +mod diagnostics; +mod output; + +use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::{generate, Shell}; + +use commands::{ + admin::{self, ConfigCmd}, + checkpoint::CheckpointCmd, + codex::CodexCmd, + dep::DepCmd, + epic::EpicCmd, + gap::GapCmd, + memory::MemoryCmd, + query, + ralph::RalphCmd, + rp::RpCmd, + stack::{InvariantsCmd, StackCmd}, + stats::StatsCmd, + task::TaskCmd, + workflow::{self, WorkerPhaseCmd}, +}; +use output::OutputOpts; + +/// flowctl - development orchestration engine. +#[derive(Parser, Debug)] +#[command(name = "flowctl", version, about = "Development orchestration engine")] +struct Cli { + #[command(flatten)] + output: OutputOpts, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + // ── Admin / top-level ──────────────────────────────────────────── + /// Initialize .flow/ directory. + Init, + /// Check if .flow/ exists. + Detect, + /// Show .flow state and active runs. + Status { + /// Detect interrupted epics with undone tasks. + #[arg(long)] + interrupted: bool, + }, + /// Run comprehensive state health diagnostics. + Doctor, + /// Validate epic or all. + Validate { + /// Epic ID. + #[arg(long)] + epic: Option, + /// Validate all epics and tasks. + #[arg(long)] + all: bool, + }, + /// Show resolved state directory path. + StatePath { + /// Task ID to show state file path for. + #[arg(long)] + task: Option, + }, + /// Migrate runtime state from definition files to state-dir. + MigrateState { + /// Remove runtime fields from definition files after migration. + #[arg(long)] + clean: bool, + }, + /// Get review backend. + ReviewBackend { + /// Compare review receipts (comma-separated file paths). + #[arg(long)] + compare: Option, + /// Auto-discover review receipts for epic. + #[arg(long)] + epic: Option, + }, + /// Extract structured findings from review output. + ParseFindings { + /// Review output file (or '-' for stdin). + #[arg(long)] + file: String, + /// Epic ID (required with --register). + #[arg(long)] + epic: Option, + /// Auto-register critical/major findings as gaps. + #[arg(long)] + register: bool, + /// Gap source label. + #[arg(long, default_value = "manual")] + source: String, + }, + /// Run test/lint/typecheck guards from stack config. + Guard { + /// Run guards for specific layer. + #[arg(long, default_value = "all")] + layer: String, + }, + /// Output trimmed worker prompt based on mode flags. + WorkerPrompt { + /// Task ID. + #[arg(long)] + task: String, + /// Include TDD Phase 2a. + #[arg(long)] + tdd: bool, + /// Include review Phase 4 (rp or codex). + #[arg(long, value_parser = ["rp", "codex"])] + review: Option, + }, + + // ── Nested command groups ──────────────────────────────────────── + /// Config commands. + Config { + #[command(subcommand)] + cmd: ConfigCmd, + }, + /// Epic commands. + Epic { + #[command(subcommand)] + cmd: EpicCmd, + }, + /// Task commands. + Task { + #[command(subcommand)] + cmd: TaskCmd, + }, + /// Dependency commands. + Dep { + #[command(subcommand)] + cmd: DepCmd, + }, + /// Requirement gap registry. + Gap { + #[command(subcommand)] + cmd: GapCmd, + }, + /// Memory commands (v2: atomic entries). + Memory { + #[command(subcommand)] + cmd: MemoryCmd, + }, + /// Checkpoint commands. + Checkpoint { + #[command(subcommand)] + cmd: CheckpointCmd, + }, + /// Stack profile commands. + Stack { + #[command(subcommand)] + cmd: StackCmd, + }, + /// Architecture invariant registry. + Invariants { + #[command(subcommand)] + cmd: InvariantsCmd, + }, + /// Ralph run control commands. + Ralph { + #[command(subcommand)] + cmd: RalphCmd, + }, + /// RepoPrompt helpers. + Rp { + #[command(subcommand)] + cmd: RpCmd, + }, + /// Codex CLI helpers. + Codex { + #[command(subcommand)] + cmd: CodexCmd, + }, + /// Stats dashboard: summary, trends, tokens, DORA metrics. + Stats { + #[command(subcommand)] + cmd: StatsCmd, + }, + /// Phase-gate sequential execution for workers. + WorkerPhase { + #[command(subcommand)] + cmd: WorkerPhaseCmd, + }, + + // ── Query commands ─────────────────────────────────────────────── + /// Show epic or task. + Show { + /// Epic or task ID. + id: String, + }, + /// List all epics. + Epics, + /// List tasks. + Tasks { + /// Filter by epic ID. + #[arg(long)] + epic: Option, + /// Filter by status. + #[arg(long, value_parser = ["todo", "in_progress", "blocked", "done"])] + status: Option, + /// Filter by domain. + #[arg(long, value_parser = ["frontend", "backend", "architecture", "testing", "docs", "ops", "general"])] + domain: Option, + }, + /// List all epics and tasks. + List, + /// Print spec markdown. + Cat { + /// Epic or task ID. + id: String, + }, + /// Show file ownership map for epic. + Files { + /// Epic ID. + #[arg(long)] + epic: String, + }, + /// Lock files for a task (Teams mode). + Lock { + /// Task ID that owns the files. + #[arg(long)] + task: String, + /// Comma-separated file paths to lock. + #[arg(long)] + files: String, + }, + /// Unlock files for a task (Teams mode). + Unlock { + /// Task ID to unlock files for. + #[arg(long)] + task: Option, + /// Comma-separated file paths. + #[arg(long)] + files: Option, + /// Clear ALL file locks. + #[arg(long)] + all: bool, + }, + /// Check file lock status (Teams mode). + LockCheck { + /// Specific file to check. + #[arg(long)] + file: Option, + }, + + // ── Workflow commands ───────────────────────────────────────────── + /// List ready tasks. + Ready { + /// Epic ID. + #[arg(long)] + epic: String, + }, + /// Select next plan/work unit. + Next { + /// JSON file with ordered epic list. + #[arg(long)] + epics_file: Option, + /// Require plan review before work. + #[arg(long)] + require_plan_review: bool, + /// Require completion review when all tasks done. + #[arg(long)] + require_completion_review: bool, + }, + /// Show multi-epic queue status. + Queue, + /// Start task. + Start { + /// Task ID. + id: String, + /// Skip status/dependency/claim checks. + #[arg(long)] + force: bool, + /// Claim note. + #[arg(long)] + note: Option, + }, + /// Complete task. + Done { + /// Task ID. + id: String, + /// Done summary markdown file. + #[arg(long)] + summary_file: Option, + /// Done summary (inline text). + #[arg(long)] + summary: Option, + /// Evidence JSON file path or inline JSON string. + #[arg(long)] + evidence_json: Option, + /// Evidence JSON (inline string, legacy). + #[arg(long)] + evidence: Option, + /// Skip status checks. + #[arg(long)] + force: bool, + }, + /// Restart task and cascade-reset downstream dependents. + Restart { + /// Task ID. + id: String, + /// Show what would be reset without doing it. + #[arg(long)] + dry_run: bool, + /// Allow restart even if tasks are in_progress. + #[arg(long)] + force: bool, + }, + /// Block task with reason. + Block { + /// Task ID. + id: String, + /// Markdown file with block reason. + #[arg(long)] + reason_file: String, + }, + /// Mark task as failed (triggers upstream_failed propagation to downstream). + Fail { + /// Task ID. + id: String, + /// Reason for failure. + #[arg(long)] + reason: Option, + /// Skip status checks. + #[arg(long)] + force: bool, + }, + + // ── Shell completions ──────────────────────────────────────────── + /// Generate shell completions. + Completions { + /// Shell to generate completions for. + #[arg(value_enum)] + shell: Shell, + }, + + // ── TUI dashboard ─────────────────────────────────────────────── + /// Launch interactive TUI dashboard. + #[cfg(feature = "tui")] + Tui, +} + +fn main() { + miette::set_hook(Box::new(|_| { + Box::new( + miette::MietteHandlerOpts::new() + .terminal_links(true) + .context_lines(3) + .build(), + ) + })) + .ok(); + + let cli = Cli::parse(); + let json = cli.output.json; + + match cli.command { + // Admin / top-level + Commands::Init => admin::cmd_init(json), + Commands::Detect => admin::cmd_detect(json), + Commands::Status { interrupted } => admin::cmd_status(json, interrupted), + Commands::Doctor => admin::cmd_doctor(json), + Commands::Validate { epic, all } => admin::cmd_validate(json, epic, all), + Commands::StatePath { task } => admin::cmd_state_path(json, task), + Commands::MigrateState { clean } => admin::cmd_migrate_state(json, clean), + Commands::ReviewBackend { compare, epic } => admin::cmd_review_backend(json, compare, epic), + Commands::ParseFindings { + file, + epic, + register, + source, + } => admin::cmd_parse_findings(json, file, epic, register, source), + Commands::Guard { layer } => admin::cmd_guard(json, layer), + Commands::WorkerPrompt { task, tdd, review } => { + admin::cmd_worker_prompt(json, task, tdd, review) + } + + // Nested groups + Commands::Config { cmd } => admin::cmd_config(&cmd, json), + Commands::Epic { cmd } => commands::epic::dispatch(&cmd, json), + Commands::Task { cmd } => commands::task::dispatch(&cmd, json), + Commands::Dep { cmd } => commands::dep::dispatch(&cmd, json), + Commands::Gap { cmd } => commands::gap::dispatch(&cmd, json), + Commands::Memory { cmd } => commands::memory::dispatch(&cmd, json), + Commands::Checkpoint { cmd } => commands::checkpoint::dispatch(&cmd, json), + Commands::Stack { cmd } => commands::stack::dispatch(&cmd, json), + Commands::Invariants { cmd } => commands::stack::dispatch_invariants(&cmd, json), + Commands::Ralph { cmd } => commands::ralph::dispatch(&cmd, json), + Commands::Rp { cmd } => commands::rp::dispatch(&cmd, json), + Commands::Codex { cmd } => commands::codex::dispatch(&cmd, json), + Commands::Stats { cmd } => commands::stats::dispatch(&cmd, json), + Commands::WorkerPhase { cmd } => workflow::dispatch_worker_phase(&cmd, json), + + // Query + Commands::Show { id } => query::cmd_show(json, id), + Commands::Epics => query::cmd_epics(json), + Commands::Tasks { + epic, + status, + domain, + } => query::cmd_tasks(json, epic, status, domain), + Commands::List => query::cmd_list(json), + Commands::Cat { id } => query::cmd_cat(id), + Commands::Files { epic } => query::cmd_files(json, epic), + Commands::Lock { task, files } => query::cmd_lock(json, task, files), + Commands::Unlock { task, files, all } => query::cmd_unlock(json, task, files, all), + Commands::LockCheck { file } => query::cmd_lock_check(json, file), + + // Workflow + Commands::Ready { epic } => workflow::cmd_ready(json, epic), + Commands::Next { + epics_file, + require_plan_review, + require_completion_review, + } => workflow::cmd_next( + json, + epics_file, + require_plan_review, + require_completion_review, + ), + Commands::Queue => workflow::cmd_queue(json), + Commands::Start { id, force, note } => workflow::cmd_start(json, id, force, note), + Commands::Done { + id, + summary_file, + summary, + evidence_json, + evidence, + force, + } => workflow::cmd_done( + json, + id, + summary_file, + summary, + evidence_json, + evidence, + force, + ), + Commands::Restart { id, dry_run, force } => workflow::cmd_restart(json, id, dry_run, force), + Commands::Block { id, reason_file } => workflow::cmd_block(json, id, reason_file), + Commands::Fail { id, reason, force } => workflow::cmd_fail(json, id, reason, force), + + // Shell completions + Commands::Completions { shell } => { + let mut cmd = Cli::command(); + generate(shell, &mut cmd, "flowctl", &mut std::io::stdout()); + } + + // TUI dashboard + #[cfg(feature = "tui")] + Commands::Tui => { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + rt.block_on(async { + let mut app = flowctl_tui::App::new(); + if let Err(e) = app.run().await { + eprintln!("TUI error: {e}"); + std::process::exit(1); + } + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_cli() { + // Clap's built-in verification that all derive attributes are valid. + Cli::command().debug_assert(); + } +} diff --git a/flowctl/crates/flowctl-cli/src/output.rs b/flowctl/crates/flowctl-cli/src/output.rs new file mode 100644 index 00000000..5d751ddd --- /dev/null +++ b/flowctl/crates/flowctl-cli/src/output.rs @@ -0,0 +1,53 @@ +//! JSON output helpers for consistent CLI responses. + +use serde_json::{json, Value}; + +/// Shared output options (flattened into every subcommand via clap). +#[derive(clap::Args, Debug, Clone)] +pub struct OutputOpts { + /// Output as JSON. + #[arg(long, global = true)] + pub json: bool, +} + +/// Print a successful JSON response with additional data fields merged in. +pub fn json_output(data: Value) { + let mut obj = match data { + Value::Object(map) => map, + _ => { + let mut m = serde_json::Map::new(); + m.insert("data".to_string(), data); + m + } + }; + obj.insert("success".to_string(), json!(true)); + println!("{}", serde_json::to_string(&Value::Object(obj)).unwrap()); +} + +/// Print an error JSON response and exit with code 1. +#[allow(dead_code)] +pub fn error_exit(message: &str) -> ! { + let out = json!({ + "success": false, + "error": message, + }); + eprintln!("{}", serde_json::to_string(&out).unwrap()); + std::process::exit(1); +} + +/// Print a stub "not yet implemented" response for a command. +pub fn stub(command_name: &str, json_mode: bool) { + if json_mode { + json_output(json!({ + "status": "not_implemented", + "command": command_name, + "message": format!("{} is not yet implemented in Rust flowctl", command_name), + })); + } else { + eprintln!( + "flowctl {}: not yet implemented (Rust port in progress)", + command_name + ); + std::process::exit(1); + } +} diff --git a/flowctl/crates/flowctl-cli/tests/cli_tests.rs b/flowctl/crates/flowctl-cli/tests/cli_tests.rs new file mode 100644 index 00000000..faa832d4 --- /dev/null +++ b/flowctl/crates/flowctl-cli/tests/cli_tests.rs @@ -0,0 +1,11 @@ +//! trycmd integration tests for flowctl CLI. +//! +//! Each `.toml` file under `tests/cmd/` defines a CLI invocation and its +//! expected stdout, stderr, and exit code. trycmd runs the real binary +//! and diffs the output. + +#[test] +fn cli_tests() { + let t = trycmd::TestCases::new(); + t.case("../../tests/cmd/*.toml"); +} diff --git a/flowctl/crates/flowctl-cli/tests/parity_test.rs b/flowctl/crates/flowctl-cli/tests/parity_test.rs new file mode 100644 index 00000000..ffd9527b --- /dev/null +++ b/flowctl/crates/flowctl-cli/tests/parity_test.rs @@ -0,0 +1,627 @@ +//! Integration tests comparing Rust flowctl output against Python flowctl. +//! +//! These tests set up isolated .flow/ directories, run both binaries with +//! identical input, and verify JSON output structure and exit codes match. +//! +//! Requirements: +//! - Python 3 on PATH +//! - FLOWCTL_PYTHON env var pointing to Python flowctl.py +//! (defaults to `../../scripts/flowctl.py` relative to workspace root) +//! - Rust binary built via `cargo build` + +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Locate the Python flowctl.py script. +fn python_flowctl() -> PathBuf { + if let Ok(p) = std::env::var("FLOWCTL_PYTHON") { + return PathBuf::from(p); + } + // Default: relative to the repo root (CARGO_MANIFEST_DIR is crates/flowctl-cli) + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest.join("../../../scripts/flowctl.py") +} + +/// Locate the Rust flowctl binary (cargo-built). +fn rust_flowctl() -> PathBuf { + // cargo test sets this for us + let mut path = PathBuf::from(env!("CARGO_BIN_EXE_flowctl")); + if !path.exists() { + // Fallback: target/debug/flowctl + path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target/debug/flowctl"); + } + path +} + +/// Run Python flowctl: `python3 flowctl.py --json` +fn run_python(work_dir: &Path, args: &[&str]) -> (String, i32) { + let py = python_flowctl(); + let mut cmd_args: Vec<&str> = args.to_vec(); + cmd_args.push("--json"); + + let output = Command::new("python3") + .arg(&py) + .args(&cmd_args) + .current_dir(work_dir) + .output() + .expect("Failed to run python3"); + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = if stdout.trim().is_empty() { + stderr + } else { + stdout + }; + + (combined, output.status.code().unwrap_or(-1)) +} + +/// Run Rust flowctl: `flowctl --json ` +fn run_rust(work_dir: &Path, args: &[&str]) -> (String, i32) { + let bin = rust_flowctl(); + let mut cmd_args: Vec<&str> = vec!["--json"]; + cmd_args.extend_from_slice(args); + + let output = Command::new(&bin) + .args(&cmd_args) + .current_dir(work_dir) + .output() + .expect("Failed to run rust flowctl"); + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = if stdout.trim().is_empty() { + stderr + } else { + stdout + }; + + (combined, output.status.code().unwrap_or(-1)) +} + +/// Parse JSON output, returning None if unparseable. +fn parse_json(output: &str) -> Option { + // Take the first valid JSON object/array from output + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + if let Ok(v) = serde_json::from_str::(trimmed) { + return Some(v); + } + } + } + // Try parsing the whole output + serde_json::from_str(output.trim()).ok() +} + +/// Extract sorted top-level keys from a JSON object. +fn json_keys(val: &Value) -> Vec { + match val { + Value::Object(map) => { + let mut keys: Vec = map.keys().cloned().collect(); + keys.sort(); + keys + } + _ => vec![], + } +} + +/// Assert both outputs parse as JSON with matching top-level keys. +fn assert_keys_match(py_out: &str, rs_out: &str, label: &str) { + let py_json = parse_json(py_out) + .unwrap_or_else(|| panic!("{label}: Python output is not valid JSON:\n{py_out}")); + let rs_json = parse_json(rs_out) + .unwrap_or_else(|| panic!("{label}: Rust output is not valid JSON:\n{rs_out}")); + + let py_keys = json_keys(&py_json); + let rs_keys = json_keys(&rs_json); + + assert_eq!( + py_keys, rs_keys, + "{label}: JSON keys differ.\n Python: {py_keys:?}\n Rust: {rs_keys:?}" + ); +} + +/// Assert Rust keys are a subset of Python keys (for commands where Rust +/// may not yet implement all fields). Also checks that core keys like +/// "success", "id" are present in both. +fn assert_rust_keys_subset(py_out: &str, rs_out: &str, label: &str) { + let py_json = parse_json(py_out) + .unwrap_or_else(|| panic!("{label}: Python output is not valid JSON:\n{py_out}")); + let rs_json = parse_json(rs_out) + .unwrap_or_else(|| panic!("{label}: Rust output is not valid JSON:\n{rs_out}")); + + let py_keys = json_keys(&py_json); + let rs_keys = json_keys(&rs_json); + + // Every Rust key should also exist in Python output + let extra: Vec<&String> = rs_keys.iter().filter(|k| !py_keys.contains(k)).collect(); + assert!( + extra.is_empty(), + "{label}: Rust has keys not in Python: {extra:?}\n Python: {py_keys:?}\n Rust: {rs_keys:?}" + ); + + // Log missing keys for visibility (not a failure) + let missing: Vec<&String> = py_keys.iter().filter(|k| !rs_keys.contains(k)).collect(); + if !missing.is_empty() { + eprintln!(" {label}: Rust missing keys (not yet implemented): {missing:?}"); + } +} + +/// Assert a field equals a specific value in both outputs. +fn assert_field_eq(py_out: &str, rs_out: &str, field: &str, expected: &Value, label: &str) { + let py_json = parse_json(py_out).expect("Python JSON"); + let rs_json = parse_json(rs_out).expect("Rust JSON"); + + assert_eq!( + py_json.get(field), + Some(expected), + "{label}: Python .{field} != {expected}" + ); + assert_eq!( + rs_json.get(field), + Some(expected), + "{label}: Rust .{field} != {expected}" + ); +} + +/// Create a temp directory for testing. +fn temp_dir(prefix: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(prefix) + .tempdir() + .expect("Failed to create temp dir") +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn parity_init() { + let py_dir = temp_dir("py_init_"); + let rs_dir = temp_dir("rs_init_"); + + let (py_out, py_exit) = run_python(py_dir.path(), &["init"]); + let (rs_out, rs_exit) = run_rust(rs_dir.path(), &["init"]); + + assert_eq!(py_exit, 0, "Python init should exit 0"); + assert_eq!(rs_exit, 0, "Rust init should exit 0"); + assert_keys_match(&py_out, &rs_out, "init"); + assert_field_eq(&py_out, &rs_out, "success", &Value::Bool(true), "init"); +} + +#[test] +fn parity_init_idempotent() { + let py_dir = temp_dir("py_reinit_"); + let rs_dir = temp_dir("rs_reinit_"); + + // First init + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + // Second init (idempotent) + let (py_out, py_exit) = run_python(py_dir.path(), &["init"]); + let (rs_out, rs_exit) = run_rust(rs_dir.path(), &["init"]); + + assert_eq!(py_exit, 0); + assert_eq!(rs_exit, 0); + assert_field_eq( + &py_out, + &rs_out, + "success", + &Value::Bool(true), + "reinit", + ); +} + +#[test] +fn parity_status_empty() { + let py_dir = temp_dir("py_status_"); + let rs_dir = temp_dir("rs_status_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (py_out, py_exit) = run_python(py_dir.path(), &["status"]); + let (rs_out, rs_exit) = run_rust(rs_dir.path(), &["status"]); + + assert_eq!(py_exit, 0); + assert_eq!(rs_exit, 0); + assert_keys_match(&py_out, &rs_out, "status"); + + // Verify zero task counts + let py_json = parse_json(&py_out).unwrap(); + let rs_json = parse_json(&rs_out).unwrap(); + assert_eq!(py_json["tasks"]["todo"], 0); + assert_eq!(rs_json["tasks"]["todo"], 0); +} + +#[test] +fn parity_epics_empty() { + let py_dir = temp_dir("py_epics_"); + let rs_dir = temp_dir("rs_epics_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (py_out, py_exit) = run_python(py_dir.path(), &["epics"]); + let (rs_out, rs_exit) = run_rust(rs_dir.path(), &["epics"]); + + assert_eq!(py_exit, 0); + assert_eq!(rs_exit, 0); + assert_keys_match(&py_out, &rs_out, "epics"); + + let py_json = parse_json(&py_out).unwrap(); + let rs_json = parse_json(&rs_out).unwrap(); + assert_eq!(py_json["count"], 0); + assert_eq!(rs_json["count"], 0); +} + +#[test] +fn parity_epic_create() { + let py_dir = temp_dir("py_epicc_"); + let rs_dir = temp_dir("rs_epicc_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (py_out, py_exit) = + run_python(py_dir.path(), &["epic", "create", "--title", "Test Epic"]); + let (rs_out, rs_exit) = + run_rust(rs_dir.path(), &["epic", "create", "--title", "Test Epic"]); + + assert_eq!(py_exit, 0); + assert_eq!(rs_exit, 0); + assert_keys_match(&py_out, &rs_out, "epic create"); + assert_field_eq( + &py_out, + &rs_out, + "success", + &Value::Bool(true), + "epic create", + ); + + // Both should have title = "Test Epic" + let py_json = parse_json(&py_out).unwrap(); + let rs_json = parse_json(&rs_out).unwrap(); + assert_eq!(py_json["title"], "Test Epic"); + assert_eq!(rs_json["title"], "Test Epic"); +} + +#[test] +fn parity_show_epic() { + let py_dir = temp_dir("py_show_"); + let rs_dir = temp_dir("rs_show_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (py_create, _) = + run_python(py_dir.path(), &["epic", "create", "--title", "Show Me"]); + let (rs_create, _) = + run_rust(rs_dir.path(), &["epic", "create", "--title", "Show Me"]); + + let py_id = parse_json(&py_create).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let rs_id = parse_json(&rs_create).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + let (py_out, py_exit) = run_python(py_dir.path(), &["show", &py_id]); + let (rs_out, rs_exit) = run_rust(rs_dir.path(), &["show", &rs_id]); + + assert_eq!(py_exit, 0); + assert_eq!(rs_exit, 0); + // show may have extra keys in Python that Rust hasn't implemented yet + assert_rust_keys_subset(&py_out, &rs_out, "show epic"); +} + +#[test] +fn parity_task_create() { + let py_dir = temp_dir("py_taskc_"); + let rs_dir = temp_dir("rs_taskc_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (py_epic_out, _) = + run_python(py_dir.path(), &["epic", "create", "--title", "Epic"]); + let (rs_epic_out, _) = + run_rust(rs_dir.path(), &["epic", "create", "--title", "Epic"]); + + let py_epic = parse_json(&py_epic_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let rs_epic = parse_json(&rs_epic_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + let (py_out, py_exit) = run_python( + py_dir.path(), + &["task", "create", "--epic", &py_epic, "--title", "Task Alpha"], + ); + let (rs_out, rs_exit) = run_rust( + rs_dir.path(), + &["task", "create", "--epic", &rs_epic, "--title", "Task Alpha"], + ); + + assert_eq!(py_exit, 0); + assert_eq!(rs_exit, 0); + assert_keys_match(&py_out, &rs_out, "task create"); + assert_field_eq( + &py_out, + &rs_out, + "success", + &Value::Bool(true), + "task create", + ); +} + +#[test] +fn parity_tasks_list() { + let py_dir = temp_dir("py_tasks_"); + let rs_dir = temp_dir("rs_tasks_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (py_epic_out, _) = + run_python(py_dir.path(), &["epic", "create", "--title", "Epic"]); + let (rs_epic_out, _) = + run_rust(rs_dir.path(), &["epic", "create", "--title", "Epic"]); + + let py_epic = parse_json(&py_epic_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let rs_epic = parse_json(&rs_epic_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + run_python( + py_dir.path(), + &["task", "create", "--epic", &py_epic, "--title", "T1"], + ); + run_rust( + rs_dir.path(), + &["task", "create", "--epic", &rs_epic, "--title", "T1"], + ); + + let (py_out, py_exit) = run_python(py_dir.path(), &["tasks", "--epic", &py_epic]); + let (rs_out, rs_exit) = run_rust(rs_dir.path(), &["tasks", "--epic", &rs_epic]); + + assert_eq!(py_exit, 0); + assert_eq!(rs_exit, 0); + assert_keys_match(&py_out, &rs_out, "tasks"); +} + +#[test] +fn parity_start() { + let py_dir = temp_dir("py_start_"); + let rs_dir = temp_dir("rs_start_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (py_epic_out, _) = + run_python(py_dir.path(), &["epic", "create", "--title", "E"]); + let (rs_epic_out, _) = + run_rust(rs_dir.path(), &["epic", "create", "--title", "E"]); + + let py_epic = parse_json(&py_epic_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let rs_epic = parse_json(&rs_epic_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + let (py_task_out, _) = run_python( + py_dir.path(), + &["task", "create", "--epic", &py_epic, "--title", "T"], + ); + let (rs_task_out, _) = run_rust( + rs_dir.path(), + &["task", "create", "--epic", &rs_epic, "--title", "T"], + ); + + let py_task = parse_json(&py_task_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let rs_task = parse_json(&rs_task_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + let (py_out, py_exit) = run_python(py_dir.path(), &["start", &py_task]); + let (rs_out, rs_exit) = run_rust(rs_dir.path(), &["start", &rs_task]); + + assert_eq!(py_exit, 0); + assert_eq!(rs_exit, 0); + assert_keys_match(&py_out, &rs_out, "start"); + assert_field_eq(&py_out, &rs_out, "success", &Value::Bool(true), "start"); +} + +#[test] +fn parity_done() { + let py_dir = temp_dir("py_done_"); + let rs_dir = temp_dir("rs_done_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (py_epic_out, _) = + run_python(py_dir.path(), &["epic", "create", "--title", "E"]); + let (rs_epic_out, _) = + run_rust(rs_dir.path(), &["epic", "create", "--title", "E"]); + + let py_epic = parse_json(&py_epic_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let rs_epic = parse_json(&rs_epic_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + let (py_task_out, _) = run_python( + py_dir.path(), + &["task", "create", "--epic", &py_epic, "--title", "T"], + ); + let (rs_task_out, _) = run_rust( + rs_dir.path(), + &["task", "create", "--epic", &rs_epic, "--title", "T"], + ); + + let py_task = parse_json(&py_task_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let rs_task = parse_json(&rs_task_out).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + // Start first + run_python(py_dir.path(), &["start", &py_task]); + run_rust(rs_dir.path(), &["start", &rs_task]); + + // Done with --force --summary + let (py_out, py_exit) = run_python( + py_dir.path(), + &["done", &py_task, "--summary", "Completed", "--force"], + ); + let (rs_out, rs_exit) = run_rust( + rs_dir.path(), + &["done", &rs_task, "--summary", "Completed", "--force"], + ); + + assert_eq!(py_exit, 0); + assert_eq!(rs_exit, 0); + assert_keys_match(&py_out, &rs_out, "done"); + assert_field_eq(&py_out, &rs_out, "success", &Value::Bool(true), "done"); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Edge Cases +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn edge_status_no_flow_dir() { + let py_dir = temp_dir("py_noflow_"); + let rs_dir = temp_dir("rs_noflow_"); + + let (py_out, _py_exit) = run_python(py_dir.path(), &["status"]); + let (rs_out, _rs_exit) = run_rust(rs_dir.path(), &["status"]); + + // Both should indicate flow_exists=false or return an error + let py_json = parse_json(&py_out); + let rs_json = parse_json(&rs_out); + + match (py_json, rs_json) { + (Some(py), Some(rs)) => { + // Both return JSON - check flow_exists + assert_eq!( + py.get("flow_exists"), + rs.get("flow_exists"), + "flow_exists should match" + ); + } + (None, None) => { + // Both return non-JSON error - acceptable + } + _ => { + // One returns JSON, other doesn't - still ok if both indicate error + } + } +} + +#[test] +fn edge_show_invalid_id() { + let py_dir = temp_dir("py_badiid_"); + let rs_dir = temp_dir("rs_badiid_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (_py_out, py_exit) = run_python(py_dir.path(), &["show", "nonexistent-999"]); + let (_rs_out, rs_exit) = run_rust(rs_dir.path(), &["show", "nonexistent-999"]); + + // Both should return non-zero + assert_ne!(py_exit, 0, "Python show invalid ID should fail"); + assert_ne!(rs_exit, 0, "Rust show invalid ID should fail"); +} + +#[test] +fn edge_start_invalid_id() { + let py_dir = temp_dir("py_badst_"); + let rs_dir = temp_dir("rs_badst_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (_py_out, py_exit) = run_python(py_dir.path(), &["start", "bogus-task"]); + let (_rs_out, rs_exit) = run_rust(rs_dir.path(), &["start", "bogus-task"]); + + assert_ne!(py_exit, 0, "Python start invalid ID should fail"); + assert_ne!(rs_exit, 0, "Rust start invalid ID should fail"); +} + +#[test] +fn edge_epic_create_no_title() { + let py_dir = temp_dir("py_notitle_"); + let rs_dir = temp_dir("rs_notitle_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (_py_out, py_exit) = run_python(py_dir.path(), &["epic", "create"]); + let (_rs_out, rs_exit) = run_rust(rs_dir.path(), &["epic", "create"]); + + assert_ne!(py_exit, 0, "Python epic create without title should fail"); + assert_ne!(rs_exit, 0, "Rust epic create without title should fail"); +} + +#[test] +fn edge_task_create_no_epic() { + let py_dir = temp_dir("py_noepic_"); + let rs_dir = temp_dir("rs_noepic_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (_py_out, py_exit) = + run_python(py_dir.path(), &["task", "create", "--title", "Orphan"]); + let (_rs_out, rs_exit) = + run_rust(rs_dir.path(), &["task", "create", "--title", "Orphan"]); + + assert_ne!(py_exit, 0, "Python task create without epic should fail"); + assert_ne!(rs_exit, 0, "Rust task create without epic should fail"); +} + +#[test] +fn edge_done_no_task_id() { + let py_dir = temp_dir("py_nodone_"); + let rs_dir = temp_dir("rs_nodone_"); + + run_python(py_dir.path(), &["init"]); + run_rust(rs_dir.path(), &["init"]); + + let (_py_out, py_exit) = run_python(py_dir.path(), &["done"]); + let (_rs_out, rs_exit) = run_rust(rs_dir.path(), &["done"]); + + assert_ne!(py_exit, 0, "Python done without task ID should fail"); + assert_ne!(rs_exit, 0, "Rust done without task ID should fail"); +} diff --git a/flowctl/crates/flowctl-core/Cargo.toml b/flowctl/crates/flowctl-core/Cargo.toml new file mode 100644 index 00000000..e74ce0b7 --- /dev/null +++ b/flowctl/crates/flowctl-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "flowctl-core" +version = "0.1.0" +description = "Core types, ID parsing, and state machine for flowctl" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +serde-saphyr = "0.0.23" +thiserror = { workspace = true } +chrono = { workspace = true } +regex = { workspace = true } +petgraph = { workspace = true } + +[dev-dependencies] diff --git a/flowctl/crates/flowctl-core/src/dag.rs b/flowctl/crates/flowctl-core/src/dag.rs new file mode 100644 index 00000000..c234c947 --- /dev/null +++ b/flowctl/crates/flowctl-core/src/dag.rs @@ -0,0 +1,973 @@ +//! petgraph-based DAG for task dependency resolution. +//! +//! Uses `StableDiGraph` for stable node indices across runtime mutations +//! (split_task, skip_task). Implements Kahn's algorithm manually for +//! topological sorting — petgraph's `Topo` iterator is ~10x slower +//! (see petgraph#665). + +use std::collections::{HashMap, HashSet, VecDeque}; + +use petgraph::graph::NodeIndex; +use petgraph::stable_graph::StableDiGraph; +use petgraph::Direction; + +use crate::error::CoreError; +use crate::state_machine::Status; +use crate::types::Task; + +/// A directed acyclic graph of task dependencies. +/// +/// Edges point from dependency to dependent: if B depends on A, +/// the edge is A -> B. This means "A must complete before B". +#[derive(Debug)] +pub struct TaskDag { + /// The underlying petgraph graph. Node weight is the task ID string. + graph: StableDiGraph, + /// O(1) lookup from task ID to graph node index. + index: HashMap, +} + +impl TaskDag { + /// Build a DAG from a slice of tasks. + /// + /// Cross-epic dependencies are supported: a task's `depends_on` may + /// reference task IDs from other epics as long as they appear in the + /// input slice. + pub fn from_tasks(tasks: &[Task]) -> Result { + let mut graph = StableDiGraph::new(); + let mut index = HashMap::with_capacity(tasks.len()); + + // Phase 1: add all nodes. + for task in tasks { + if index.contains_key(&task.id) { + return Err(CoreError::DuplicateTask(task.id.clone())); + } + let ni = graph.add_node(task.id.clone()); + index.insert(task.id.clone(), ni); + } + + // Phase 2: add edges (dependency -> dependent). + for task in tasks { + let dependent_ni = index[&task.id]; + for dep_id in &task.depends_on { + let dep_ni = *index.get(dep_id.as_str()).ok_or_else(|| { + CoreError::UnknownDependency { + task: task.id.clone(), + dependency: dep_id.clone(), + } + })?; + // Self-reference check. + if dep_ni == dependent_ni { + return Err(CoreError::CycleDetected(format!( + "self-referencing: {} -> {}", + task.id, dep_id + ))); + } + graph.add_edge(dep_ni, dependent_ni, ()); + } + } + + let dag = TaskDag { graph, index }; + + // Phase 3: cycle detection. + if let Some(cycle) = dag.detect_cycles() { + let cycle_str = cycle.join(" -> "); + return Err(CoreError::CycleDetected(cycle_str)); + } + + Ok(dag) + } + + /// Return task IDs whose dependencies are all satisfied. + /// + /// A task is "ready" when: + /// - Its own status is `Todo` (not started, not blocked, not done) + /// - All of its dependencies have a satisfied status (`Done` or `Skipped`) + pub fn ready_tasks(&self, statuses: &HashMap) -> Vec { + let mut ready = Vec::new(); + for (id, &ni) in &self.index { + let status = statuses.get(id.as_str()).copied().unwrap_or(Status::Todo); + if status != Status::Todo { + continue; + } + let all_deps_satisfied = self + .graph + .neighbors_directed(ni, Direction::Incoming) + .all(|dep_ni| { + let dep_id = &self.graph[dep_ni]; + statuses + .get(dep_id.as_str()) + .copied() + .unwrap_or(Status::Todo) + .is_satisfied() + }); + if all_deps_satisfied { + ready.push(id.clone()); + } + } + ready.sort(); // deterministic ordering + ready + } + + /// Mark a task as complete and return newly-ready downstream task IDs. + /// + /// "Newly ready" means: status is `Todo` and all deps are now satisfied. + /// The caller is responsible for actually updating the status map. + pub fn complete(&self, id: &str, statuses: &HashMap) -> Vec { + let Some(&ni) = self.index.get(id) else { + return vec![]; + }; + let mut newly_ready = Vec::new(); + // Check each downstream dependent. + for dep_ni in self.graph.neighbors_directed(ni, Direction::Outgoing) { + let dep_id = &self.graph[dep_ni]; + let dep_status = statuses + .get(dep_id.as_str()) + .copied() + .unwrap_or(Status::Todo); + if dep_status != Status::Todo { + continue; + } + // Check if ALL of this dependent's deps are satisfied + // (treating the just-completed task as satisfied). + let all_satisfied = self + .graph + .neighbors_directed(dep_ni, Direction::Incoming) + .all(|upstream_ni| { + let upstream_id = &self.graph[upstream_ni]; + if upstream_id == id { + return true; // the one we just completed + } + statuses + .get(upstream_id.as_str()) + .copied() + .unwrap_or(Status::Todo) + .is_satisfied() + }); + if all_satisfied { + newly_ready.push(dep_id.clone()); + } + } + newly_ready.sort(); + newly_ready + } + + /// Propagate failure: return all transitively downstream task IDs. + pub fn propagate_failure(&self, id: &str) -> Vec { + let Some(&ni) = self.index.get(id) else { + return vec![]; + }; + let mut affected = Vec::new(); + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(ni); + visited.insert(ni); + while let Some(current) = queue.pop_front() { + for downstream in self.graph.neighbors_directed(current, Direction::Outgoing) { + if visited.insert(downstream) { + affected.push(self.graph[downstream].clone()); + queue.push_back(downstream); + } + } + } + affected.sort(); + affected + } + + /// Detect cycles using Kahn's algorithm. Returns `None` if the graph is + /// a valid DAG, or `Some(cycle_members)` listing task IDs involved. + pub fn detect_cycles(&self) -> Option> { + let node_count = self.graph.node_count(); + if node_count == 0 { + return None; + } + + // Kahn's algorithm: compute in-degrees, process zero-degree nodes. + let mut in_degree: HashMap = HashMap::with_capacity(node_count); + for ni in self.graph.node_indices() { + in_degree.insert( + ni, + self.graph + .neighbors_directed(ni, Direction::Incoming) + .count(), + ); + } + + let mut queue: VecDeque = in_degree + .iter() + .filter(|(_, °)| deg == 0) + .map(|(&ni, _)| ni) + .collect(); + + let mut processed = 0usize; + while let Some(ni) = queue.pop_front() { + processed += 1; + for downstream in self.graph.neighbors_directed(ni, Direction::Outgoing) { + if let Some(deg) = in_degree.get_mut(&downstream) { + *deg -= 1; + if *deg == 0 { + queue.push_back(downstream); + } + } + } + } + + if processed == node_count { + None + } else { + // Nodes with remaining in-degree > 0 are in cycles. + let mut cycle_members: Vec = in_degree + .iter() + .filter(|(_, °)| deg > 0) + .map(|(&ni, _)| self.graph[ni].clone()) + .collect(); + cycle_members.sort(); + Some(cycle_members) + } + } + + /// Compute the critical path (longest path through the DAG). + /// + /// Each task has unit weight (1). Returns task IDs on the longest path + /// from any source to any sink, in topological order. + pub fn critical_path(&self) -> Vec { + if self.graph.node_count() == 0 { + return vec![]; + } + + // Topological order via Kahn's. + let topo_order = self.topological_sort(); + + // Longest-path DP. + let mut dist: HashMap = HashMap::new(); + let mut pred: HashMap = HashMap::new(); + + for &ni in &topo_order { + dist.insert(ni, 0); + } + + for &ni in &topo_order { + let current_dist = dist[&ni]; + for downstream in self.graph.neighbors_directed(ni, Direction::Outgoing) { + let new_dist = current_dist + 1; + if new_dist > *dist.get(&downstream).unwrap_or(&0) { + dist.insert(downstream, new_dist); + pred.insert(downstream, ni); + } + } + } + + // Find the node with the maximum distance (end of critical path). + let (&end_node, _) = dist.iter().max_by_key(|(_, &d)| d).unwrap(); + + // Trace back. + let mut path = vec![end_node]; + let mut current = end_node; + while let Some(&p) = pred.get(¤t) { + path.push(p); + current = p; + } + path.reverse(); + + path.iter().map(|&ni| self.graph[ni].clone()).collect() + } + + /// Split a task into multiple new tasks, re-wiring dependencies. + /// + /// The original task's incoming edges go to the first new task. + /// The original task's outgoing edges come from the last new task. + /// New tasks are chained sequentially: new[0] -> new[1] -> ... -> new[n-1]. + pub fn split_task(&mut self, id: &str, new_tasks: Vec) -> Result<(), CoreError> { + let &old_ni = self + .index + .get(id) + .ok_or_else(|| CoreError::TaskNotFound(id.to_string()))?; + + if new_tasks.is_empty() { + return Err(CoreError::InvalidId( + "split requires at least one replacement task".to_string(), + )); + } + + // Check for duplicate IDs among new tasks. + for t in &new_tasks { + if self.index.contains_key(&t.id) && t.id != id { + return Err(CoreError::DuplicateTask(t.id.clone())); + } + } + + // Collect incoming/outgoing neighbors before mutation. + let incoming: Vec = self + .graph + .neighbors_directed(old_ni, Direction::Incoming) + .collect(); + let outgoing: Vec = self + .graph + .neighbors_directed(old_ni, Direction::Outgoing) + .collect(); + + // Remove old node. + self.graph.remove_node(old_ni); + self.index.remove(id); + + // Add new nodes. + let mut new_indices = Vec::with_capacity(new_tasks.len()); + for t in &new_tasks { + let ni = self.graph.add_node(t.id.clone()); + self.index.insert(t.id.clone(), ni); + new_indices.push(ni); + } + + // Wire incoming edges to first new node. + for inc in &incoming { + self.graph.add_edge(*inc, new_indices[0], ()); + } + + // Wire last new node to outgoing edges. + let last = *new_indices.last().unwrap(); + for out in &outgoing { + self.graph.add_edge(last, *out, ()); + } + + // Chain new tasks sequentially. + for w in new_indices.windows(2) { + self.graph.add_edge(w[0], w[1], ()); + } + + Ok(()) + } + + /// Skip a task: treat it as satisfied for dependency resolution. + /// Returns the list of downstream task IDs that may now be ready. + /// + /// The caller should set the task's status to `Skipped` in their status map + /// and then call `ready_tasks` or use the returned list directly. + pub fn skip_task(&self, id: &str, statuses: &HashMap) -> Vec { + // Skipped is treated as satisfied, so this is the same as complete. + self.complete(id, statuses) + } + + /// Return all node indices in topological order (Kahn's algorithm). + pub fn topological_sort(&self) -> Vec { + let node_count = self.graph.node_count(); + let mut in_degree: HashMap = HashMap::with_capacity(node_count); + for ni in self.graph.node_indices() { + in_degree.insert( + ni, + self.graph + .neighbors_directed(ni, Direction::Incoming) + .count(), + ); + } + + let mut queue: VecDeque = in_degree + .iter() + .filter(|(_, °)| deg == 0) + .map(|(&ni, _)| ni) + .collect(); + + let mut order = Vec::with_capacity(node_count); + while let Some(ni) = queue.pop_front() { + order.push(ni); + for downstream in self.graph.neighbors_directed(ni, Direction::Outgoing) { + if let Some(deg) = in_degree.get_mut(&downstream) { + *deg -= 1; + if *deg == 0 { + queue.push_back(downstream); + } + } + } + } + order + } + + /// Number of tasks in the DAG. + pub fn len(&self) -> usize { + self.graph.node_count() + } + + /// Whether the DAG is empty. + pub fn is_empty(&self) -> bool { + self.graph.node_count() == 0 + } + + /// Check if a task ID exists in the DAG. + pub fn contains(&self, id: &str) -> bool { + self.index.contains_key(id) + } + + /// Get direct dependencies (upstream) for a task. + pub fn dependencies(&self, id: &str) -> Vec { + let Some(&ni) = self.index.get(id) else { + return vec![]; + }; + let mut deps: Vec = self + .graph + .neighbors_directed(ni, Direction::Incoming) + .map(|dep_ni| self.graph[dep_ni].clone()) + .collect(); + deps.sort(); + deps + } + + /// Return all task IDs in the DAG (sorted for determinism). + pub fn task_ids(&self) -> Vec { + let mut ids: Vec = self.index.keys().cloned().collect(); + ids.sort(); + ids + } + + /// Get direct dependents (downstream) for a task. + pub fn dependents(&self, id: &str) -> Vec { + let Some(&ni) = self.index.get(id) else { + return vec![]; + }; + let mut deps: Vec = self + .graph + .neighbors_directed(ni, Direction::Outgoing) + .map(|dep_ni| self.graph[dep_ni].clone()) + .collect(); + deps.sort(); + deps + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Domain; + use chrono::Utc; + + /// Helper to create a minimal task with the given ID and dependencies. + fn make_task(id: &str, deps: &[&str]) -> Task { + Task { + schema_version: 1, + id: id.to_string(), + epic: "test-epic".to_string(), + title: format!("Task {id}"), + status: Status::Todo, + priority: None, + domain: Domain::General, + depends_on: deps.iter().map(|s| s.to_string()).collect(), + files: vec![], + r#impl: None, + review: None, + sync: None, + file_path: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + fn status_map(entries: &[(&str, Status)]) -> HashMap { + entries + .iter() + .map(|(id, s)| (id.to_string(), *s)) + .collect() + } + + // ── Construction ──────────────────────────────────────────────── + + #[test] + fn test_empty_dag() { + let dag = TaskDag::from_tasks(&[]).unwrap(); + assert!(dag.is_empty()); + assert_eq!(dag.len(), 0); + } + + #[test] + fn test_single_task_no_deps() { + let dag = TaskDag::from_tasks(&[make_task("a", &[])]).unwrap(); + assert_eq!(dag.len(), 1); + assert!(dag.contains("a")); + } + + #[test] + fn test_linear_chain() { + // a -> b -> c + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["b"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + assert_eq!(dag.len(), 3); + assert_eq!(dag.dependencies("b"), vec!["a"]); + assert_eq!(dag.dependents("a"), vec!["b"]); + assert_eq!(dag.dependents("b"), vec!["c"]); + } + + #[test] + fn test_diamond_dag() { + // a + // / \ + // b c + // \ / + // d + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["a"]), + make_task("d", &["b", "c"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + assert_eq!(dag.len(), 4); + assert_eq!(dag.dependencies("d"), vec!["b", "c"]); + } + + #[test] + fn test_duplicate_task_id() { + let tasks = vec![make_task("a", &[]), make_task("a", &[])]; + let err = TaskDag::from_tasks(&tasks).unwrap_err(); + assert!(matches!(err, CoreError::DuplicateTask(_))); + } + + #[test] + fn test_unknown_dependency() { + let tasks = vec![make_task("a", &["nonexistent"])]; + let err = TaskDag::from_tasks(&tasks).unwrap_err(); + assert!(matches!(err, CoreError::UnknownDependency { .. })); + } + + #[test] + fn test_self_reference_detected() { + let tasks = vec![make_task("a", &["a"])]; + let err = TaskDag::from_tasks(&tasks).unwrap_err(); + assert!(matches!(err, CoreError::CycleDetected(_))); + } + + #[test] + fn test_cycle_detected() { + // a -> b -> c -> a + let tasks = vec![ + make_task("a", &["c"]), + make_task("b", &["a"]), + make_task("c", &["b"]), + ]; + let err = TaskDag::from_tasks(&tasks).unwrap_err(); + assert!(matches!(err, CoreError::CycleDetected(_))); + } + + // ── ready_tasks ───────────────────────────────────────────────── + + #[test] + fn test_ready_tasks_all_todo_no_deps() { + let tasks = vec![make_task("a", &[]), make_task("b", &[])]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let statuses = status_map(&[("a", Status::Todo), ("b", Status::Todo)]); + let ready = dag.ready_tasks(&statuses); + assert_eq!(ready, vec!["a", "b"]); + } + + #[test] + fn test_ready_tasks_with_deps() { + // a -> b -> c + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["b"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + + // Initially only a is ready. + let statuses = status_map(&[ + ("a", Status::Todo), + ("b", Status::Todo), + ("c", Status::Todo), + ]); + assert_eq!(dag.ready_tasks(&statuses), vec!["a"]); + + // After a is done, b is ready. + let statuses = status_map(&[ + ("a", Status::Done), + ("b", Status::Todo), + ("c", Status::Todo), + ]); + assert_eq!(dag.ready_tasks(&statuses), vec!["b"]); + } + + #[test] + fn test_ready_tasks_skipped_satisfies_deps() { + let tasks = vec![make_task("a", &[]), make_task("b", &["a"])]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let statuses = status_map(&[("a", Status::Skipped), ("b", Status::Todo)]); + assert_eq!(dag.ready_tasks(&statuses), vec!["b"]); + } + + #[test] + fn test_ready_tasks_excludes_non_todo() { + let tasks = vec![make_task("a", &[]), make_task("b", &[])]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let statuses = status_map(&[("a", Status::InProgress), ("b", Status::Todo)]); + assert_eq!(dag.ready_tasks(&statuses), vec!["b"]); + } + + #[test] + fn test_ready_tasks_diamond() { + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["a"]), + make_task("d", &["b", "c"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + + // a done -> b, c ready. + let statuses = status_map(&[ + ("a", Status::Done), + ("b", Status::Todo), + ("c", Status::Todo), + ("d", Status::Todo), + ]); + assert_eq!(dag.ready_tasks(&statuses), vec!["b", "c"]); + + // Only b done -> d not ready (c still todo). + let statuses = status_map(&[ + ("a", Status::Done), + ("b", Status::Done), + ("c", Status::Todo), + ("d", Status::Todo), + ]); + assert_eq!(dag.ready_tasks(&statuses), vec!["c"]); + + // Both done -> d ready. + let statuses = status_map(&[ + ("a", Status::Done), + ("b", Status::Done), + ("c", Status::Done), + ("d", Status::Todo), + ]); + assert_eq!(dag.ready_tasks(&statuses), vec!["d"]); + } + + // ── complete ──────────────────────────────────────────────────── + + #[test] + fn test_complete_returns_newly_ready() { + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["a"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let statuses = status_map(&[ + ("a", Status::Todo), + ("b", Status::Todo), + ("c", Status::Todo), + ]); + let newly_ready = dag.complete("a", &statuses); + assert_eq!(newly_ready, vec!["b", "c"]); + } + + #[test] + fn test_complete_diamond_partial() { + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["a"]), + make_task("d", &["b", "c"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + + // Complete b with c still todo -> d not ready. + let statuses = status_map(&[ + ("a", Status::Done), + ("b", Status::Todo), + ("c", Status::Todo), + ("d", Status::Todo), + ]); + let newly_ready = dag.complete("b", &statuses); + assert!(!newly_ready.contains(&"d".to_string())); + + // Complete b with c done -> d ready. + let statuses = status_map(&[ + ("a", Status::Done), + ("b", Status::Todo), + ("c", Status::Done), + ("d", Status::Todo), + ]); + let newly_ready = dag.complete("b", &statuses); + assert_eq!(newly_ready, vec!["d"]); + } + + #[test] + fn test_complete_unknown_task() { + let dag = TaskDag::from_tasks(&[make_task("a", &[])]).unwrap(); + let statuses = status_map(&[]); + assert!(dag.complete("nonexistent", &statuses).is_empty()); + } + + // ── propagate_failure ─────────────────────────────────────────── + + #[test] + fn test_propagate_failure_linear() { + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["b"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let affected = dag.propagate_failure("a"); + assert_eq!(affected, vec!["b", "c"]); + } + + #[test] + fn test_propagate_failure_diamond() { + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["a"]), + make_task("d", &["b", "c"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let affected = dag.propagate_failure("a"); + assert_eq!(affected, vec!["b", "c", "d"]); + } + + #[test] + fn test_propagate_failure_leaf() { + let tasks = vec![make_task("a", &[]), make_task("b", &["a"])]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let affected = dag.propagate_failure("b"); + assert!(affected.is_empty()); + } + + #[test] + fn test_propagate_failure_unknown() { + let dag = TaskDag::from_tasks(&[make_task("a", &[])]).unwrap(); + assert!(dag.propagate_failure("nonexistent").is_empty()); + } + + // ── detect_cycles ─────────────────────────────────────────────── + + #[test] + fn test_detect_cycles_none() { + let tasks = vec![make_task("a", &[]), make_task("b", &["a"])]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + assert!(dag.detect_cycles().is_none()); + } + + #[test] + fn test_detect_cycles_empty() { + let dag = TaskDag::from_tasks(&[]).unwrap(); + assert!(dag.detect_cycles().is_none()); + } + + // ── critical_path ─────────────────────────────────────────────── + + #[test] + fn test_critical_path_linear() { + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["b"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let cp = dag.critical_path(); + assert_eq!(cp, vec!["a", "b", "c"]); + } + + #[test] + fn test_critical_path_diamond() { + // a -> b -> d (length 3) + // a -> c -> d (length 3) + // Both paths are equal length, so either is valid. + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["a"]), + make_task("d", &["b", "c"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let cp = dag.critical_path(); + assert_eq!(cp.len(), 3); + assert_eq!(cp[0], "a"); + assert_eq!(cp[2], "d"); + } + + #[test] + fn test_critical_path_single() { + let dag = TaskDag::from_tasks(&[make_task("a", &[])]).unwrap(); + let cp = dag.critical_path(); + assert_eq!(cp, vec!["a"]); + } + + #[test] + fn test_critical_path_empty() { + let dag = TaskDag::from_tasks(&[]).unwrap(); + assert!(dag.critical_path().is_empty()); + } + + #[test] + fn test_critical_path_wide() { + // a -> b -> c -> d (length 4) + // a -> e (length 2) + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["b"]), + make_task("d", &["c"]), + make_task("e", &["a"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let cp = dag.critical_path(); + assert_eq!(cp, vec!["a", "b", "c", "d"]); + } + + // ── split_task ────────────────────────────────────────────────── + + #[test] + fn test_split_task_basic() { + // a -> b -> c => a -> b1 -> b2 -> c + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["b"]), + ]; + let mut dag = TaskDag::from_tasks(&tasks).unwrap(); + dag.split_task("b", vec![make_task("b1", &[]), make_task("b2", &[])]) + .unwrap(); + + assert!(!dag.contains("b")); + assert!(dag.contains("b1")); + assert!(dag.contains("b2")); + assert_eq!(dag.len(), 4); // a, b1, b2, c + + assert_eq!(dag.dependencies("b1"), vec!["a"]); + assert_eq!(dag.dependents("b1"), vec!["b2"]); + assert_eq!(dag.dependencies("b2"), vec!["b1"]); + assert_eq!(dag.dependents("b2"), vec!["c"]); + assert_eq!(dag.dependencies("c"), vec!["b2"]); + } + + #[test] + fn test_split_task_single_replacement() { + let tasks = vec![make_task("a", &[]), make_task("b", &["a"])]; + let mut dag = TaskDag::from_tasks(&tasks).unwrap(); + dag.split_task("b", vec![make_task("b_new", &[])]).unwrap(); + assert!(!dag.contains("b")); + assert!(dag.contains("b_new")); + assert_eq!(dag.dependencies("b_new"), vec!["a"]); + } + + #[test] + fn test_split_task_not_found() { + let mut dag = TaskDag::from_tasks(&[make_task("a", &[])]).unwrap(); + let err = dag + .split_task("nonexistent", vec![make_task("b", &[])]) + .unwrap_err(); + assert!(matches!(err, CoreError::TaskNotFound(_))); + } + + #[test] + fn test_split_task_empty_replacements() { + let mut dag = TaskDag::from_tasks(&[make_task("a", &[])]).unwrap(); + let err = dag.split_task("a", vec![]).unwrap_err(); + assert!(matches!(err, CoreError::InvalidId(_))); + } + + // ── skip_task ─────────────────────────────────────────────────── + + #[test] + fn test_skip_task_unblocks_downstream() { + let tasks = vec![make_task("a", &[]), make_task("b", &["a"])]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let statuses = status_map(&[("a", Status::Todo), ("b", Status::Todo)]); + let newly_ready = dag.skip_task("a", &statuses); + assert_eq!(newly_ready, vec!["b"]); + } + + // ── cross-epic dependencies ───────────────────────────────────── + + #[test] + fn test_cross_epic_deps() { + let mut task_a = make_task("epic1.1", &[]); + task_a.epic = "epic1".to_string(); + let mut task_b = make_task("epic2.1", &["epic1.1"]); + task_b.epic = "epic2".to_string(); + + let dag = TaskDag::from_tasks(&[task_a, task_b]).unwrap(); + assert_eq!(dag.dependencies("epic2.1"), vec!["epic1.1"]); + } + + // ── performance ───────────────────────────────────────────────── + + #[test] + fn test_1000_task_dag_performance() { + use std::time::Instant; + + // Build a 1000-task chain: task-0 -> task-1 -> ... -> task-999. + let tasks: Vec = (0..1000) + .map(|i| { + let deps = if i == 0 { + vec![] + } else { + vec![format!("task-{}", i - 1)] + }; + let mut t = make_task(&format!("task-{i}"), &[]); + t.depends_on = deps; + t + }) + .collect(); + + let start = Instant::now(); + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let elapsed = start.elapsed(); + + assert_eq!(dag.len(), 1000); + assert!( + elapsed.as_millis() < 100, + "1000-task DAG build took {}ms (limit: 100ms)", + elapsed.as_millis() + ); + } + + #[test] + fn test_1000_task_wide_dag() { + // 1 root + 999 tasks all depending on root (star topology). + let mut tasks = vec![make_task("root", &[])]; + for i in 0..999 { + tasks.push(make_task(&format!("leaf-{i}"), &["root"])); + } + + let start = std::time::Instant::now(); + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let elapsed = start.elapsed(); + + assert_eq!(dag.len(), 1000); + assert!(elapsed.as_millis() < 100); + + // All leaves ready after root done. + let mut statuses: HashMap = HashMap::new(); + statuses.insert("root".to_string(), Status::Done); + for i in 0..999 { + statuses.insert(format!("leaf-{i}"), Status::Todo); + } + let ready = dag.ready_tasks(&statuses); + assert_eq!(ready.len(), 999); + } + + // ── topological_sort ──────────────────────────────────────────── + + #[test] + fn test_topological_sort_linear() { + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["b"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let order: Vec = dag + .topological_sort() + .iter() + .map(|&ni| dag.graph[ni].clone()) + .collect(); + + // a must come before b, b before c. + let pos_a = order.iter().position(|x| x == "a").unwrap(); + let pos_b = order.iter().position(|x| x == "b").unwrap(); + let pos_c = order.iter().position(|x| x == "c").unwrap(); + assert!(pos_a < pos_b); + assert!(pos_b < pos_c); + } +} diff --git a/flowctl/crates/flowctl-core/src/error.rs b/flowctl/crates/flowctl-core/src/error.rs new file mode 100644 index 00000000..09e45b62 --- /dev/null +++ b/flowctl/crates/flowctl-core/src/error.rs @@ -0,0 +1,56 @@ +//! Core error types for flowctl-core. +//! +//! Uses `thiserror` for library errors (not `anyhow`, which is for apps). + +use thiserror::Error; + +/// Top-level error type for flowctl-core operations. +#[derive(Debug, Error)] +pub enum CoreError { + /// Invalid ID format. + #[error("invalid ID: {0}")] + InvalidId(String), + + /// Invalid state transition. + #[error("invalid transition from {from} to {to}")] + InvalidTransition { + from: crate::state_machine::Status, + to: crate::state_machine::Status, + }, + + /// Slug generation produced empty result. + #[error("slugify produced empty result for input: {0}")] + EmptySlug(String), + + /// Task not found. + #[error("task not found: {0}")] + TaskNotFound(String), + + /// Epic not found. + #[error("epic not found: {0}")] + EpicNotFound(String), + + /// Serialization error. + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// Frontmatter parse error. + #[error("frontmatter parse error: {0}")] + FrontmatterParse(String), + + /// Frontmatter serialization error. + #[error("frontmatter serialization error: {0}")] + FrontmatterSerialize(String), + + /// Cycle detected in task dependency graph. + #[error("cycle detected in task DAG: {0}")] + CycleDetected(String), + + /// Dependency references a task not in the graph. + #[error("unknown dependency: task {task} depends on {dependency}")] + UnknownDependency { task: String, dependency: String }, + + /// Duplicate task ID in input. + #[error("duplicate task ID: {0}")] + DuplicateTask(String), +} diff --git a/flowctl/crates/flowctl-core/src/frontmatter.rs b/flowctl/crates/flowctl-core/src/frontmatter.rs new file mode 100644 index 00000000..6fa6aee5 --- /dev/null +++ b/flowctl/crates/flowctl-core/src/frontmatter.rs @@ -0,0 +1,347 @@ +//! YAML frontmatter parser and writer for `.flow/` Markdown files. +//! +//! Frontmatter is delimited by `---` on its own line. Only the first pair +//! of `---` markers is treated as frontmatter; subsequent `---` in the +//! Markdown body are left untouched. +//! +//! Uses `serde_saphyr` (not the deprecated `serde_yaml` or RUSTSEC-flagged +//! `serde_yml`). + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::error::CoreError; + +/// A parsed Markdown document with YAML frontmatter and a body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Document { + /// Deserialized frontmatter. + pub frontmatter: T, + /// Markdown body after the closing `---`. + pub body: String, +} + +/// Parse a Markdown string into frontmatter + body. +/// +/// The input must start with `---\n`. The frontmatter ends at the next +/// `---\n` (or `---` at EOF). Everything after that second delimiter is +/// the body. +pub fn parse(input: &str) -> Result, CoreError> { + let trimmed = input.trim_start(); + + if !trimmed.starts_with("---") { + return Err(CoreError::FrontmatterParse( + "document does not start with ---".to_string(), + )); + } + + // Skip the opening "---" line. + let after_open = match trimmed.strip_prefix("---") { + Some(rest) => { + // Consume the newline (or the rest if it's just "---" at EOF). + rest.strip_prefix('\n').unwrap_or(rest) + } + None => unreachable!(), + }; + + // Find the closing "---". + let (yaml_str, body) = find_closing_delimiter(after_open)?; + + let frontmatter: T = serde_saphyr::from_str(yaml_str).map_err(|e| { + CoreError::FrontmatterParse(format!("YAML parse error: {e}")) + })?; + + Ok(Document { + frontmatter, + body: body.to_string(), + }) +} + +/// Serialize frontmatter + body back to a Markdown string. +pub fn write(doc: &Document) -> Result { + let yaml = serde_saphyr::to_string(&doc.frontmatter).map_err(|e| { + CoreError::FrontmatterSerialize(format!("YAML serialize error: {e}")) + })?; + + let mut out = String::with_capacity(yaml.len() + doc.body.len() + 16); + out.push_str("---\n"); + out.push_str(&yaml); + // serde_saphyr::to_string includes a trailing newline; ensure exactly one. + if !out.ends_with('\n') { + out.push('\n'); + } + out.push_str("---\n"); + out.push_str(&doc.body); + Ok(out) +} + +/// Parse only the frontmatter, discarding the body. +pub fn parse_frontmatter(input: &str) -> Result { + parse(input).map(|doc| doc.frontmatter) +} + +/// Find the closing `---` delimiter and split into (yaml, body). +fn find_closing_delimiter(s: &str) -> Result<(&str, &str), CoreError> { + // Search for "\n---\n" or "\n---" at end of string. + let mut search_from = 0; + while search_from < s.len() { + if let Some(pos) = s[search_from..].find("\n---") { + let abs_pos = search_from + pos; + let after_dashes = abs_pos + 4; // skip "\n---" + + // The "---" must be the entire line (not "----" or "--- text"). + if after_dashes >= s.len() { + // "---" at end of string. + return Ok((&s[..abs_pos], "")); + } + + let next_char = s.as_bytes()[after_dashes]; + if next_char == b'\n' { + let body_start = after_dashes + 1; + return Ok((&s[..abs_pos], &s[body_start..])); + } else if next_char == b'\r' { + // Handle \r\n. + let body_start = if after_dashes + 1 < s.len() + && s.as_bytes()[after_dashes + 1] == b'\n' + { + after_dashes + 2 + } else { + after_dashes + 1 + }; + return Ok((&s[..abs_pos], &s[body_start..])); + } + + // Not a clean delimiter (e.g. "----"), keep searching. + search_from = after_dashes; + } else { + break; + } + } + + // Also handle the case where the YAML is empty and closing --- is the first line. + if s.starts_with("---\n") { + return Ok(("", &s[4..])); + } + if s.starts_with("---") && s.len() == 3 { + return Ok(("", "")); + } + + Err(CoreError::FrontmatterParse( + "no closing --- delimiter found".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state_machine::Status; + use crate::types::{Domain, Epic, EpicStatus, ReviewStatus, Task}; + use chrono::Utc; + + #[test] + fn test_parse_epic_frontmatter() { + let input = r#"--- +schema_version: 1 +id: fn-1-add-auth +title: Add Authentication +status: open +plan_review: unknown +created_at: "2026-01-01T00:00:00Z" +updated_at: "2026-01-01T00:00:00Z" +--- +## Description +Add OAuth2 authentication. +"#; + let doc: Document = parse(input).unwrap(); + assert_eq!(doc.frontmatter.id, "fn-1-add-auth"); + assert_eq!(doc.frontmatter.title, "Add Authentication"); + assert_eq!(doc.frontmatter.status, EpicStatus::Open); + assert_eq!(doc.frontmatter.schema_version, 1); + assert!(doc.body.contains("## Description")); + } + + #[test] + fn test_parse_task_frontmatter() { + let input = r#"--- +schema_version: 1 +id: fn-1-add-auth.1 +epic: fn-1-add-auth +title: Design Auth Flow +status: todo +domain: backend +depends_on: + - fn-1-add-auth.0 +files: + - src/auth.rs +created_at: "2026-01-01T00:00:00Z" +updated_at: "2026-01-01T00:00:00Z" +--- +## Description +Design the auth flow. + +## Acceptance +- Flow documented +"#; + let doc: Document = parse(input).unwrap(); + assert_eq!(doc.frontmatter.id, "fn-1-add-auth.1"); + assert_eq!(doc.frontmatter.epic, "fn-1-add-auth"); + assert_eq!(doc.frontmatter.status, Status::Todo); + assert_eq!(doc.frontmatter.domain, Domain::Backend); + assert_eq!(doc.frontmatter.depends_on, vec!["fn-1-add-auth.0"]); + assert_eq!(doc.frontmatter.files, vec!["src/auth.rs"]); + } + + #[test] + fn test_roundtrip_epic() { + let epic = Epic { + schema_version: 1, + id: "fn-2-rewrite".to_string(), + title: "Rust Rewrite".to_string(), + status: EpicStatus::Open, + branch_name: Some("feat/rust-rewrite".to_string()), + plan_review: ReviewStatus::Passed, + completion_review: ReviewStatus::Unknown, + depends_on_epics: vec!["fn-1-base".to_string()], + default_impl: None, + default_review: None, + default_sync: None, + file_path: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let doc = Document { + frontmatter: epic, + body: "# Epic body\n".to_string(), + }; + + let serialized = write(&doc).unwrap(); + let parsed: Document = parse(&serialized).unwrap(); + + assert_eq!(parsed.frontmatter.id, doc.frontmatter.id); + assert_eq!(parsed.frontmatter.title, doc.frontmatter.title); + assert_eq!(parsed.frontmatter.status, doc.frontmatter.status); + assert_eq!(parsed.frontmatter.branch_name, doc.frontmatter.branch_name); + assert_eq!(parsed.frontmatter.plan_review, doc.frontmatter.plan_review); + assert_eq!( + parsed.frontmatter.depends_on_epics, + doc.frontmatter.depends_on_epics + ); + assert_eq!(parsed.body, doc.body); + } + + #[test] + fn test_roundtrip_task() { + let task = Task { + schema_version: 1, + id: "fn-1-test.3".to_string(), + epic: "fn-1-test".to_string(), + title: "Write Tests".to_string(), + status: Status::InProgress, + priority: Some(2), + domain: Domain::Testing, + depends_on: vec!["fn-1-test.1".to_string(), "fn-1-test.2".to_string()], + files: vec!["tests/auth.rs".to_string()], + r#impl: None, + review: None, + sync: None, + file_path: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let doc = Document { + frontmatter: task, + body: "## Description\nTest stuff.\n".to_string(), + }; + + let serialized = write(&doc).unwrap(); + let parsed: Document = parse(&serialized).unwrap(); + + assert_eq!(parsed.frontmatter.id, doc.frontmatter.id); + assert_eq!(parsed.frontmatter.epic, doc.frontmatter.epic); + assert_eq!(parsed.frontmatter.status, doc.frontmatter.status); + assert_eq!(parsed.frontmatter.priority, doc.frontmatter.priority); + assert_eq!(parsed.frontmatter.domain, doc.frontmatter.domain); + assert_eq!(parsed.frontmatter.depends_on, doc.frontmatter.depends_on); + assert_eq!(parsed.frontmatter.files, doc.frontmatter.files); + assert_eq!(parsed.body, doc.body); + } + + #[test] + fn test_body_with_triple_dashes() { + // Only the first --- pair is frontmatter; body can contain ---. + let input = "---\nschema_version: 1\nid: fn-1-test\ntitle: Test\nstatus: open\nplan_review: unknown\ncreated_at: \"2026-01-01T00:00:00Z\"\nupdated_at: \"2026-01-01T00:00:00Z\"\n---\n## Section\n\n---\n\nMore content after horizontal rule.\n"; + let doc: Document = parse(input).unwrap(); + assert!(doc.body.contains("---")); + assert!(doc.body.contains("More content after horizontal rule.")); + } + + #[test] + fn test_missing_frontmatter_delimiter() { + let input = "No frontmatter here.\n"; + let result = parse::(input); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not start with ---"), "Got: {err}"); + } + + #[test] + fn test_no_closing_delimiter() { + let input = "---\nid: test\ntitle: Test\n"; + let result = parse::(input); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("no closing ---"), "Got: {err}"); + } + + #[test] + fn test_invalid_yaml() { + let input = "---\n: : : invalid yaml [[[\n---\n"; + let result = parse::(input); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("YAML parse error"), "Got: {err}"); + } + + #[test] + fn test_missing_required_field() { + // Epic requires id and title. + let input = "---\nschema_version: 1\nstatus: open\n---\n"; + let result = parse::(input); + assert!(result.is_err()); + } + + #[test] + fn test_default_optional_fields() { + // Minimal task with only required fields. + let input = "---\nid: fn-1-t.1\nepic: fn-1-t\ntitle: Minimal\ncreated_at: \"2026-01-01T00:00:00Z\"\nupdated_at: \"2026-01-01T00:00:00Z\"\n---\n"; + let doc: Document = parse(input).unwrap(); + assert_eq!(doc.frontmatter.status, Status::Todo); + assert_eq!(doc.frontmatter.domain, Domain::General); + assert!(doc.frontmatter.depends_on.is_empty()); + assert!(doc.frontmatter.files.is_empty()); + assert_eq!(doc.frontmatter.priority, None); + } + + #[test] + fn test_empty_body() { + let input = "---\nschema_version: 1\nid: fn-1-test\ntitle: Test\nstatus: open\nplan_review: unknown\ncreated_at: \"2026-01-01T00:00:00Z\"\nupdated_at: \"2026-01-01T00:00:00Z\"\n---\n"; + let doc: Document = parse(input).unwrap(); + assert_eq!(doc.body, ""); + } + + #[test] + fn test_parse_frontmatter_only() { + let input = "---\nschema_version: 1\nid: fn-1-test\ntitle: Test\nstatus: open\nplan_review: unknown\ncreated_at: \"2026-01-01T00:00:00Z\"\nupdated_at: \"2026-01-01T00:00:00Z\"\n---\nBody ignored.\n"; + let epic: Epic = parse_frontmatter(input).unwrap(); + assert_eq!(epic.id, "fn-1-test"); + } + + #[test] + fn test_schema_version_defaults_to_1() { + let input = "---\nid: fn-1-test\ntitle: Test\nstatus: open\ncreated_at: \"2026-01-01T00:00:00Z\"\nupdated_at: \"2026-01-01T00:00:00Z\"\n---\n"; + let doc: Document = parse(input).unwrap(); + assert_eq!(doc.frontmatter.schema_version, 1); + } +} diff --git a/flowctl/crates/flowctl-core/src/id.rs b/flowctl/crates/flowctl-core/src/id.rs new file mode 100644 index 00000000..51142340 --- /dev/null +++ b/flowctl/crates/flowctl-core/src/id.rs @@ -0,0 +1,720 @@ +//! ID parsing, generation, and validation utilities. +//! +//! Ported from `scripts/flowctl/core/ids.py`. The regex and slugify logic +//! must produce identical results to the Python implementation. + +use std::sync::LazyLock; + +use regex::Regex; + +/// Compiled regex for parsing flowctl IDs. +/// +/// Pattern supports: +/// - Legacy: `fn-N`, `fn-N.M` +/// - Short suffix: `fn-N-xxx`, `fn-N-xxx.M` (1-3 char random) +/// - Slug suffix: `fn-N-longer-slug`, `fn-N-longer-slug.M` (multi-segment) +/// +/// Ported from `scripts/flowctl/core/ids.py:58-60`. +static ID_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"^fn-(\d+)(?:-[a-z0-9][a-z0-9-]*[a-z0-9]|-[a-z0-9]{1,3})?(?:\.(\d+))?$") + .expect("ID regex is valid") +}); + +/// Regex for slugify: non-word characters (except spaces and hyphens). +static SLUGIFY_NON_WORD: LazyLock = LazyLock::new(|| { + Regex::new(r"[^\w\s-]").expect("slugify non-word regex is valid") +}); + +/// Regex for slugify: collapsing whitespace and hyphens. +static SLUGIFY_COLLAPSE: LazyLock = LazyLock::new(|| { + Regex::new(r"[-\s]+").expect("slugify collapse regex is valid") +}); + +/// Parsed ID components. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedId { + /// Epic number. + pub epic: u32, + /// Task number (None for epic IDs). + pub task: Option, +} + +/// Strong type for epic IDs (e.g. `fn-1-add-auth`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EpicId(pub String); + +impl std::fmt::Display for EpicId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Strong type for task IDs (e.g. `fn-1-add-auth.3`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TaskId(pub String); + +impl std::fmt::Display for TaskId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TaskId { + /// Extract the epic ID portion from a task ID. + /// + /// Preserves suffix: `fn-5-x7k.3` -> `fn-5-x7k`. + /// Ported from `scripts/flowctl/core/ids.py:138-148`. + pub fn epic_id(&self) -> Result { + let parsed = parse_id(&self.0)?; + if parsed.task.is_none() { + return Err(crate::error::CoreError::InvalidId(format!( + "not a task ID: {}", + self.0 + ))); + } + // Split on '.' and take the epic part (preserves suffix). + let epic_part = self + .0 + .rsplit_once('.') + .map(|(epic, _)| epic) + .unwrap_or(&self.0); + Ok(EpicId(epic_part.to_string())) + } +} + +/// Parse an ID string into its components. +/// +/// Returns (epic_num, task_num) where task_num is None for epic IDs. +/// +/// Ported from `scripts/flowctl/core/ids.py:49-66`. Must produce identical +/// results for all test cases. +/// +/// # Examples +/// +/// ``` +/// use flowctl_core::id::parse_id; +/// +/// // Epic IDs +/// let parsed = parse_id("fn-1").unwrap(); +/// assert_eq!(parsed.epic, 1); +/// assert_eq!(parsed.task, None); +/// +/// // Task IDs +/// let parsed = parse_id("fn-1.3").unwrap(); +/// assert_eq!(parsed.epic, 1); +/// assert_eq!(parsed.task, Some(3)); +/// +/// // Slug IDs +/// let parsed = parse_id("fn-2-add-auth").unwrap(); +/// assert_eq!(parsed.epic, 2); +/// assert_eq!(parsed.task, None); +/// +/// // Invalid IDs +/// assert!(parse_id("invalid").is_err()); +/// ``` +pub fn parse_id(id_str: &str) -> Result { + let captures = ID_REGEX + .captures(id_str) + .ok_or_else(|| crate::error::CoreError::InvalidId(id_str.to_string()))?; + + let epic: u32 = captures + .get(1) + .unwrap() + .as_str() + .parse() + .map_err(|_| crate::error::CoreError::InvalidId(id_str.to_string()))?; + + let task: Option = captures + .get(2) + .map(|m| { + m.as_str() + .parse() + .map_err(|_| crate::error::CoreError::InvalidId(id_str.to_string())) + }) + .transpose()?; + + Ok(ParsedId { epic, task }) +} + +/// Check whether a string is a valid epic ID (fn-N or fn-N-slug, no task number). +pub fn is_epic_id(id_str: &str) -> bool { + parse_id(id_str) + .map(|p| p.task.is_none()) + .unwrap_or(false) +} + +/// Check whether a string is a valid task ID (fn-N.M or fn-N-slug.M). +pub fn is_task_id(id_str: &str) -> bool { + parse_id(id_str) + .map(|p| p.task.is_some()) + .unwrap_or(false) +} + +/// Extract the epic ID from a task ID string. +/// +/// Preserves suffix: `fn-5-x7k.3` -> `fn-5-x7k`. +pub fn epic_id_from_task(task_id: &str) -> Result { + let parsed = parse_id(task_id)?; + if parsed.task.is_none() { + return Err(crate::error::CoreError::InvalidId(format!( + "not a task ID: {task_id}" + ))); + } + // Split on '.' and take the epic part (preserves suffix). + let epic_part = task_id + .rsplit_once('.') + .map(|(epic, _)| epic) + .unwrap_or(task_id); + Ok(epic_part.to_string()) +} + +/// Convert text to a URL-safe slug for epic IDs. +/// +/// Uses Django pattern (stdlib only): normalize unicode, strip non-alphanumeric, +/// collapse whitespace/hyphens. Returns None if result is empty. +/// +/// Output contains only `[a-z0-9-]` to match `parse_id()` regex. +/// +/// Ported from `scripts/flowctl/core/ids.py:16-46`. Must produce identical +/// output to the Python version for Unicode input. +/// +/// # Arguments +/// +/// * `text` - Input text to slugify. +/// * `max_length` - Maximum length (default 40). Set to 0 for no limit. +/// +/// # Examples +/// +/// ``` +/// use flowctl_core::id::slugify; +/// +/// assert_eq!(slugify("Hello World", 40), Some("hello-world".to_string())); +/// assert_eq!(slugify("café résumé", 40), Some("cafe-resume".to_string())); +/// assert_eq!(slugify("", 40), None); +/// ``` +pub fn slugify(text: &str, max_length: usize) -> Option { + // Step 1: NFKD normalize + strip to ASCII. + // This matches Python's: unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") + let normalized = unicode_nfkd_to_ascii(text); + + // Step 2: Remove non-word chars (except spaces and hyphens), lowercase. + let lowered = normalized.to_lowercase(); + let cleaned = SLUGIFY_NON_WORD.replace_all(&lowered, ""); + + // Step 3: Convert underscores to spaces. + let no_underscores = cleaned.replace('_', " "); + + // Step 4: Collapse whitespace and hyphens to single hyphen, strip leading/trailing. + let collapsed = SLUGIFY_COLLAPSE.replace_all(&no_underscores, "-"); + let trimmed = collapsed.trim_matches('-'); + + if trimmed.is_empty() { + return None; + } + + let mut result = trimmed.to_string(); + + // Step 5: Truncate at word boundary if too long. + if max_length > 0 && result.len() > max_length { + let truncated = &result[..max_length]; + if let Some(pos) = truncated.rfind('-') { + result = truncated[..pos].trim_end_matches('-').to_string(); + } else { + result = truncated.trim_end_matches('-').to_string(); + } + } + + if result.is_empty() { + None + } else { + Some(result) + } +} + +/// Perform NFKD normalization and strip non-ASCII characters. +/// +/// This replicates Python's behavior: +/// ```python +/// unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") +/// ``` +/// +/// NFKD decomposes characters into base + combining marks, then we keep +/// only ASCII bytes. This turns e.g. 'e' + combining accent into just 'e'. +fn unicode_nfkd_to_ascii(text: &str) -> String { + // Manual NFKD decomposition: iterate over chars, decompose each to + // its NFKD form, keep only ASCII. + // + // For common accented Latin chars, the NFKD decomposition splits them + // into base char + combining diacritical mark. The combining marks are + // non-ASCII (U+0300..U+036F), so they get stripped. + // + // We use Rust's built-in Unicode character database via char methods. + // Since we don't have a full NFKD implementation in std, we do a + // simplified version that handles the common cases matching Python. + let mut result = String::with_capacity(text.len()); + for ch in text.chars() { + // Try to decompose to ASCII-compatible representation. + if ch.is_ascii() { + result.push(ch); + } else { + // For non-ASCII chars, attempt NFKD-style decomposition. + // Common accented characters decompose to base + combining mark. + // We only keep the ASCII base character. + if let Some(ascii_ch) = decompose_to_ascii(ch) { + result.push(ascii_ch); + } + // Non-decomposable non-ASCII characters are dropped (like Python's + // .encode("ascii", "ignore")) + } + } + result +} + +/// Attempt to decompose a Unicode character to its ASCII base. +/// +/// Covers common Latin accented characters that Python's NFKD handles. +/// Returns None if the character has no ASCII decomposition. +fn decompose_to_ascii(ch: char) -> Option { + // Common Latin-1 Supplement and Latin Extended decompositions. + // This table covers the characters most commonly encountered in + // slugification. It matches Python's NFKD + ASCII encode behavior. + match ch { + // A variants + '\u{00C0}'..='\u{00C5}' => Some('A'), + '\u{00E0}'..='\u{00E5}' => Some('a'), + // C variants + '\u{00C7}' => Some('C'), + '\u{00E7}' => Some('c'), + // D variants + '\u{00D0}' => Some('D'), + '\u{00F0}' => Some('d'), + // E variants + '\u{00C8}'..='\u{00CB}' => Some('E'), + '\u{00E8}'..='\u{00EB}' => Some('e'), + // I variants + '\u{00CC}'..='\u{00CF}' => Some('I'), + '\u{00EC}'..='\u{00EF}' => Some('i'), + // N variants + '\u{00D1}' => Some('N'), + '\u{00F1}' => Some('n'), + // O variants + '\u{00D2}'..='\u{00D6}' => Some('O'), + '\u{00D8}' => Some('O'), + '\u{00F2}'..='\u{00F6}' => Some('o'), + '\u{00F8}' => Some('o'), + // U variants + '\u{00D9}'..='\u{00DC}' => Some('U'), + '\u{00F9}'..='\u{00FC}' => Some('u'), + // Y variants + '\u{00DD}' => Some('Y'), + '\u{00FD}' | '\u{00FF}' => Some('y'), + // Ligatures / special + '\u{00C6}' => Some('A'), // Python NFKD: AE doesn't decompose, encode strips it + '\u{00E6}' => Some('a'), // same + '\u{00DF}' => None, // German sharp s - Python NFKD doesn't decompose, stripped + '\u{0152}' => Some('O'), // OE ligature + '\u{0153}' => Some('o'), + // Latin Extended-A (common) + '\u{0100}' | '\u{0102}' | '\u{0104}' => Some('A'), + '\u{0101}' | '\u{0103}' | '\u{0105}' => Some('a'), + '\u{0106}' | '\u{0108}' | '\u{010A}' | '\u{010C}' => Some('C'), + '\u{0107}' | '\u{0109}' | '\u{010B}' | '\u{010D}' => Some('c'), + '\u{010E}' | '\u{0110}' => Some('D'), + '\u{010F}' | '\u{0111}' => Some('d'), + '\u{0112}'..='\u{011A}' if ch as u32 % 2 == 0 => Some('E'), + '\u{0113}'..='\u{011B}' if ch as u32 % 2 == 1 => Some('e'), + '\u{011C}'..='\u{0122}' if ch as u32 % 2 == 0 => Some('G'), + '\u{011D}'..='\u{0123}' if ch as u32 % 2 == 1 => Some('g'), + '\u{0124}' | '\u{0126}' => Some('H'), + '\u{0125}' | '\u{0127}' => Some('h'), + '\u{0128}'..='\u{0130}' if ch as u32 % 2 == 0 => Some('I'), + '\u{0129}'..='\u{0131}' if ch as u32 % 2 == 1 => Some('i'), + '\u{0134}' => Some('J'), + '\u{0135}' => Some('j'), + '\u{0136}' => Some('K'), + '\u{0137}' => Some('k'), + '\u{0139}' | '\u{013B}' | '\u{013D}' | '\u{013F}' | '\u{0141}' => Some('L'), + '\u{013A}' | '\u{013C}' | '\u{013E}' | '\u{0140}' | '\u{0142}' => Some('l'), + '\u{0143}' | '\u{0145}' | '\u{0147}' => Some('N'), + '\u{0144}' | '\u{0146}' | '\u{0148}' => Some('n'), + '\u{014C}'..='\u{0150}' if ch as u32 % 2 == 0 => Some('O'), + '\u{014D}'..='\u{0151}' if ch as u32 % 2 == 1 => Some('o'), + '\u{0154}' | '\u{0156}' | '\u{0158}' => Some('R'), + '\u{0155}' | '\u{0157}' | '\u{0159}' => Some('r'), + '\u{015A}' | '\u{015C}' | '\u{015E}' | '\u{0160}' => Some('S'), + '\u{015B}' | '\u{015D}' | '\u{015F}' | '\u{0161}' => Some('s'), + '\u{0162}' | '\u{0164}' | '\u{0166}' => Some('T'), + '\u{0163}' | '\u{0165}' | '\u{0167}' => Some('t'), + '\u{0168}'..='\u{0172}' if ch as u32 % 2 == 0 => Some('U'), + '\u{0169}'..='\u{0173}' if ch as u32 % 2 == 1 => Some('u'), + '\u{0174}' => Some('W'), + '\u{0175}' => Some('w'), + '\u{0176}' | '\u{0178}' => Some('Y'), + '\u{0177}' => Some('y'), + '\u{0179}' | '\u{017B}' | '\u{017D}' => Some('Z'), + '\u{017A}' | '\u{017C}' | '\u{017E}' => Some('z'), + _ => None, + } +} + +/// Generate a random alphanumeric suffix for epic IDs (a-z0-9). +/// +/// Uses a simple thread-local RNG for suffix generation. +pub fn generate_epic_suffix(length: usize) -> String { + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + + let mut result = String::with_capacity(length); + for i in 0..length { + // Use RandomState for basic randomness (no external dep needed). + let state = RandomState::new(); + let mut hasher = state.build_hasher(); + hasher.write_usize(i); + let idx = (hasher.finish() as usize) % ALPHABET.len(); + result.push(ALPHABET[idx] as char); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── parse_id tests ────────────────────────────────────────────── + + #[test] + fn test_parse_legacy_epic() { + let p = parse_id("fn-1").unwrap(); + assert_eq!(p.epic, 1); + assert_eq!(p.task, None); + } + + #[test] + fn test_parse_legacy_task() { + let p = parse_id("fn-1.3").unwrap(); + assert_eq!(p.epic, 1); + assert_eq!(p.task, Some(3)); + } + + #[test] + fn test_parse_short_suffix_epic() { + let p = parse_id("fn-5-x7k").unwrap(); + assert_eq!(p.epic, 5); + assert_eq!(p.task, None); + } + + #[test] + fn test_parse_short_suffix_task() { + let p = parse_id("fn-5-x7k.3").unwrap(); + assert_eq!(p.epic, 5); + assert_eq!(p.task, Some(3)); + } + + #[test] + fn test_parse_slug_epic() { + let p = parse_id("fn-2-add-auth").unwrap(); + assert_eq!(p.epic, 2); + assert_eq!(p.task, None); + } + + #[test] + fn test_parse_slug_task() { + let p = parse_id("fn-2-add-auth.1").unwrap(); + assert_eq!(p.epic, 2); + assert_eq!(p.task, Some(1)); + } + + #[test] + fn test_parse_long_slug() { + let p = parse_id("fn-10-flowctl-rust-platform-rewrite").unwrap(); + assert_eq!(p.epic, 10); + assert_eq!(p.task, None); + + let p = parse_id("fn-10-flowctl-rust-platform-rewrite.5").unwrap(); + assert_eq!(p.epic, 10); + assert_eq!(p.task, Some(5)); + } + + #[test] + fn test_parse_single_char_suffix() { + let p = parse_id("fn-3-a").unwrap(); + assert_eq!(p.epic, 3); + assert_eq!(p.task, None); + } + + #[test] + fn test_parse_two_char_suffix() { + let p = parse_id("fn-3-ab").unwrap(); + assert_eq!(p.epic, 3); + assert_eq!(p.task, None); + } + + #[test] + fn test_parse_invalid_ids() { + assert!(parse_id("").is_err()); + assert!(parse_id("invalid").is_err()); + assert!(parse_id("fn-").is_err()); + assert!(parse_id("fn-abc").is_err()); + assert!(parse_id("task-1").is_err()); + assert!(parse_id("fn-1-").is_err()); // trailing hyphen + assert!(parse_id("fn-1-ABC").is_err()); // uppercase + assert!(parse_id("fn-1.").is_err()); // trailing dot + assert!(parse_id("fn-1.abc").is_err()); // non-numeric task + } + + #[test] + fn test_parse_large_numbers() { + let p = parse_id("fn-999.99").unwrap(); + assert_eq!(p.epic, 999); + assert_eq!(p.task, Some(99)); + } + + // ── is_epic_id / is_task_id tests ─────────────────────────────── + + #[test] + fn test_is_epic_id() { + assert!(is_epic_id("fn-1")); + assert!(is_epic_id("fn-2-add-auth")); + assert!(!is_epic_id("fn-1.1")); + assert!(!is_epic_id("invalid")); + } + + #[test] + fn test_is_task_id() { + assert!(is_task_id("fn-1.1")); + assert!(is_task_id("fn-2-add-auth.3")); + assert!(!is_task_id("fn-1")); + assert!(!is_task_id("invalid")); + } + + // ── epic_id_from_task tests ───────────────────────────────────── + + #[test] + fn test_epic_id_from_task() { + assert_eq!( + epic_id_from_task("fn-1.3").unwrap(), + "fn-1" + ); + assert_eq!( + epic_id_from_task("fn-5-x7k.3").unwrap(), + "fn-5-x7k" + ); + assert_eq!( + epic_id_from_task("fn-2-add-auth.1").unwrap(), + "fn-2-add-auth" + ); + } + + #[test] + fn test_epic_id_from_task_errors() { + // Epic ID (no task number) should error. + assert!(epic_id_from_task("fn-1").is_err()); + // Invalid ID should error. + assert!(epic_id_from_task("invalid").is_err()); + } + + // ── TaskId tests ──────────────────────────────────────────────── + + #[test] + fn test_task_id_epic_id() { + let tid = TaskId("fn-5-x7k.3".to_string()); + let eid = tid.epic_id().unwrap(); + assert_eq!(eid.0, "fn-5-x7k"); + } + + // ── slugify tests ─────────────────────────────────────────────── + + #[test] + fn test_slugify_basic() { + assert_eq!(slugify("Hello World", 40), Some("hello-world".to_string())); + } + + #[test] + fn test_slugify_accented() { + assert_eq!( + slugify("cafe resume", 40), + Some("cafe-resume".to_string()) + ); + assert_eq!( + slugify("cafe\u{0301} re\u{0301}sume\u{0301}", 40), + Some("cafe-resume".to_string()) + ); + } + + #[test] + fn test_slugify_unicode_accented() { + // "cafe" with combining accent -> "cafe" + // "resume" with combining accent -> "resume" + assert_eq!( + slugify("caf\u{00E9} r\u{00E9}sum\u{00E9}", 40), + Some("cafe-resume".to_string()) + ); + } + + #[test] + fn test_slugify_special_chars() { + assert_eq!( + slugify("Hello, World! (2024)", 40), + Some("hello-world-2024".to_string()) + ); + } + + #[test] + fn test_slugify_underscores() { + assert_eq!( + slugify("hello_world_test", 40), + Some("hello-world-test".to_string()) + ); + } + + #[test] + fn test_slugify_multiple_spaces_hyphens() { + assert_eq!( + slugify("hello --- world", 40), + Some("hello-world".to_string()) + ); + } + + #[test] + fn test_slugify_empty() { + assert_eq!(slugify("", 40), None); + assert_eq!(slugify(" ", 40), None); + assert_eq!(slugify("---", 40), None); + } + + #[test] + fn test_slugify_truncation() { + let long_text = "this is a very long title that should be truncated at a word boundary"; + let result = slugify(long_text, 20).unwrap(); + assert!(result.len() <= 20); + // Should truncate at word boundary (hyphen). + assert!(!result.ends_with('-')); + } + + #[test] + fn test_slugify_no_limit() { + let long_text = "this is a very long title that should not be truncated"; + let result = slugify(long_text, 0).unwrap(); + assert_eq!( + result, + "this-is-a-very-long-title-that-should-not-be-truncated" + ); + } + + #[test] + fn test_slugify_leading_trailing_special() { + assert_eq!( + slugify("---hello---", 40), + Some("hello".to_string()) + ); + assert_eq!( + slugify(" hello ", 40), + Some("hello".to_string()) + ); + } + + // ── generate_epic_suffix tests ────────────────────────────────── + + #[test] + fn test_generate_suffix_length() { + let suffix = generate_epic_suffix(3); + assert_eq!(suffix.len(), 3); + // All chars should be a-z0-9. + assert!(suffix.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())); + } + + #[test] + fn test_generate_suffix_different_lengths() { + for len in [1, 3, 5, 10] { + let suffix = generate_epic_suffix(len); + assert_eq!(suffix.len(), len); + } + } + + // ── Python parity tests ───────────────────────────────────────── + // These test exact output matching with the Python implementation + // at scripts/flowctl/core/ids.py. + + #[test] + fn test_python_parity_parse_id() { + // Exact matches from Python output. + let cases: Vec<(&str, Option, Option)> = vec![ + ("fn-1", Some(1), None), + ("fn-1.3", Some(1), Some(3)), + ("fn-5-x7k", Some(5), None), + ("fn-5-x7k.3", Some(5), Some(3)), + ("fn-2-add-auth", Some(2), None), + ("fn-2-add-auth.1", Some(2), Some(1)), + ("fn-10-flowctl-rust-platform-rewrite", Some(10), None), + ("fn-10-flowctl-rust-platform-rewrite.5", Some(10), Some(5)), + ("fn-3-a", Some(3), None), + ("fn-3-ab", Some(3), None), + ("fn-999.99", Some(999), Some(99)), + // Invalid cases — Python returns (None, None). + ("invalid", None, None), + ("", None, None), + ("fn-abc", None, None), + ("fn-1-", None, None), + ("fn-1.abc", None, None), + ]; + + for (input, expected_epic, expected_task) in cases { + match parse_id(input) { + Ok(parsed) => { + assert_eq!( + Some(parsed.epic), + expected_epic, + "Epic mismatch for {input}" + ); + assert_eq!( + parsed.task, expected_task, + "Task mismatch for {input}" + ); + } + Err(_) => { + assert_eq!( + expected_epic, None, + "Expected valid parse for {input}" + ); + } + } + } + } + + #[test] + fn test_python_parity_slugify() { + // Exact matches from Python output. + let cases: Vec<(&str, usize, Option<&str>)> = vec![ + ("Hello World", 40, Some("hello-world")), + ("caf\u{00E9} r\u{00E9}sum\u{00E9}", 40, Some("cafe-resume")), + ("Hello, World! (2024)", 40, Some("hello-world-2024")), + ("hello_world_test", 40, Some("hello-world-test")), + ("hello --- world", 40, Some("hello-world")), + ("", 40, None), + (" ", 40, None), + ("---", 40, None), + ( + "this is a very long title that should be truncated at a word boundary", + 40, + Some("this-is-a-very-long-title-that-should"), + ), + ("---hello---", 40, Some("hello")), + ( + "this is a very long title that should be truncated at a word boundary", + 20, + Some("this-is-a-very-long"), + ), + ]; + + for (input, max_len, expected) in cases { + let result = slugify(input, max_len); + assert_eq!( + result.as_deref(), + expected, + "slugify({input:?}, {max_len}) mismatch" + ); + } + } +} diff --git a/flowctl/crates/flowctl-core/src/lib.rs b/flowctl/crates/flowctl-core/src/lib.rs new file mode 100644 index 00000000..38937d6c --- /dev/null +++ b/flowctl/crates/flowctl-core/src/lib.rs @@ -0,0 +1,19 @@ +//! flowctl-core: Core types, ID parsing, and state machine for flowctl. +//! +//! This is a leaf crate with zero workspace dependencies. It defines the +//! fundamental data structures, enums, and validation logic used by all +//! other flowctl crates. + +pub mod dag; +pub mod error; +pub mod frontmatter; +pub mod id; +pub mod state_machine; +pub mod types; + +// Re-export commonly used items at crate root. +pub use dag::TaskDag; +pub use error::CoreError; +pub use id::{parse_id, slugify, EpicId, ParsedId, TaskId}; +pub use state_machine::{Status, Transition, TransitionError}; +pub use types::{Epic, Evidence, Phase, Task}; diff --git a/flowctl/crates/flowctl-core/src/state_machine.rs b/flowctl/crates/flowctl-core/src/state_machine.rs new file mode 100644 index 00000000..966655cf --- /dev/null +++ b/flowctl/crates/flowctl-core/src/state_machine.rs @@ -0,0 +1,351 @@ +//! Task state machine with all 8 states and validated transitions. +//! +//! Ported from the design spec's state diagram. The `skipped` state is +//! treated as equivalent to `done` for dependency resolution. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Error type for invalid state transitions. +#[derive(Debug, Error)] +#[error("invalid transition from {from} to {to}")] +pub struct TransitionError { + pub from: Status, + pub to: Status, +} + +/// Task status with all 8 states from the design spec. +/// +/// State diagram: +/// ```text +/// ┌──────────────┐ +/// │ up_for_retry │ +/// └──┬────────────┘ +/// │ retry +/// ┌──────┐ ┌─────────────┐ ┌──▼───────┐ ┌──────┐ +/// │ todo │───>│ in_progress │───>│ failed │ │ done │ +/// └──────┘ └──────┬──────┘ └──────────┘ └──────┘ +/// │ ▲ +/// ├──────────────────────────────┘ +/// │ +/// │ ┌─────────────────┐ +/// │ │ upstream_failed │ +/// │ └─────────────────┘ +/// │ +/// ┌──────▼──────┐ ┌─────────┐ +/// │ blocked │ │ skipped │ +/// └─────────────┘ └──────────┘ +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum Status { + /// Initial state -- task is ready to be started. + #[default] + Todo, + + /// Task is actively being worked on. + InProgress, + + /// Task completed successfully. + Done, + + /// Task is blocked by an external dependency. + Blocked, + + /// Task was deliberately skipped (treated as `done` for dep resolution). + Skipped, + + /// Task failed (terminal failure, not retriable). + Failed, + + /// Task failed but is eligible for retry. + UpForRetry, + + /// A dependency of this task failed; task cannot be executed. + UpstreamFailed, +} + +impl std::fmt::Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Status::Todo => write!(f, "todo"), + Status::InProgress => write!(f, "in_progress"), + Status::Done => write!(f, "done"), + Status::Blocked => write!(f, "blocked"), + Status::Skipped => write!(f, "skipped"), + Status::Failed => write!(f, "failed"), + Status::UpForRetry => write!(f, "up_for_retry"), + Status::UpstreamFailed => write!(f, "upstream_failed"), + } + } +} + +impl Status { + /// All valid status values. + pub const ALL: &[Status] = &[ + Status::Todo, + Status::InProgress, + Status::Done, + Status::Blocked, + Status::Skipped, + Status::Failed, + Status::UpForRetry, + Status::UpstreamFailed, + ]; + + /// Whether this status is considered "satisfied" for dependency resolution. + /// + /// Both `done` and `skipped` satisfy downstream dependencies. + pub fn is_satisfied(&self) -> bool { + matches!(self, Status::Done | Status::Skipped) + } + + /// Whether this status represents a terminal state (no further transitions). + pub fn is_terminal(&self) -> bool { + matches!(self, Status::Done | Status::Skipped) + } + + /// Whether this status indicates the task is in a failure state. + pub fn is_failed(&self) -> bool { + matches!(self, Status::Failed | Status::UpstreamFailed) + } + + /// Whether this status indicates the task is actively running. + pub fn is_active(&self) -> bool { + matches!(self, Status::InProgress) + } + + /// Parse a status string. Case-insensitive, supports both snake_case + /// and the plain form. + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "todo" => Some(Status::Todo), + "in_progress" | "in-progress" | "inprogress" => Some(Status::InProgress), + "done" => Some(Status::Done), + "blocked" => Some(Status::Blocked), + "skipped" => Some(Status::Skipped), + "failed" => Some(Status::Failed), + "up_for_retry" | "up-for-retry" | "upforretry" => Some(Status::UpForRetry), + "upstream_failed" | "upstream-failed" | "upstreamfailed" => { + Some(Status::UpstreamFailed) + } + _ => None, + } + } +} + +/// A validated state transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Transition { + pub from: Status, + pub to: Status, +} + +impl Transition { + /// Attempt to create a validated transition. Returns an error if the + /// transition is not allowed by the state machine rules. + /// + /// Valid transitions (from design spec): + /// - `todo -> in_progress` (flowctl start) + /// - `todo -> skipped` (flowctl task skip) + /// - `in_progress -> done` (flowctl done) + /// - `in_progress -> failed` (guard failure, timeout) + /// - `in_progress -> blocked` (flowctl block) + /// - `failed -> up_for_retry` (auto, if retries remaining) + /// - `up_for_retry -> in_progress` (scheduler auto-retry) + /// - `blocked -> todo` (flowctl restart) + /// - `failed -> todo` (flowctl restart) + /// - `* -> upstream_failed` (dependency entered failed) + pub fn new(from: Status, to: Status) -> Result { + if Self::is_valid(from, to) { + Ok(Transition { from, to }) + } else { + Err(TransitionError { from, to }) + } + } + + /// Check whether a transition is valid without creating one. + pub fn is_valid(from: Status, to: Status) -> bool { + // Any status can transition to upstream_failed (propagation). + if to == Status::UpstreamFailed { + return true; + } + + matches!( + (from, to), + (Status::Todo, Status::InProgress) + | (Status::Todo, Status::Skipped) + | (Status::InProgress, Status::Done) + | (Status::InProgress, Status::Failed) + | (Status::InProgress, Status::Blocked) + | (Status::Failed, Status::UpForRetry) + | (Status::UpForRetry, Status::InProgress) + | (Status::Blocked, Status::Todo) + | (Status::Failed, Status::Todo) + ) + } + + /// Get all valid target states from a given state. + pub fn valid_targets(from: Status) -> Vec { + Status::ALL + .iter() + .copied() + .filter(|&to| Self::is_valid(from, to)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_all_8_states_exist() { + assert_eq!(Status::ALL.len(), 8); + } + + #[test] + fn test_status_display() { + assert_eq!(Status::Todo.to_string(), "todo"); + assert_eq!(Status::InProgress.to_string(), "in_progress"); + assert_eq!(Status::Done.to_string(), "done"); + assert_eq!(Status::Blocked.to_string(), "blocked"); + assert_eq!(Status::Skipped.to_string(), "skipped"); + assert_eq!(Status::Failed.to_string(), "failed"); + assert_eq!(Status::UpForRetry.to_string(), "up_for_retry"); + assert_eq!(Status::UpstreamFailed.to_string(), "upstream_failed"); + } + + #[test] + fn test_status_parse() { + assert_eq!(Status::parse("todo"), Some(Status::Todo)); + assert_eq!(Status::parse("in_progress"), Some(Status::InProgress)); + assert_eq!(Status::parse("in-progress"), Some(Status::InProgress)); + assert_eq!(Status::parse("done"), Some(Status::Done)); + assert_eq!(Status::parse("blocked"), Some(Status::Blocked)); + assert_eq!(Status::parse("skipped"), Some(Status::Skipped)); + assert_eq!(Status::parse("failed"), Some(Status::Failed)); + assert_eq!(Status::parse("up_for_retry"), Some(Status::UpForRetry)); + assert_eq!(Status::parse("upstream_failed"), Some(Status::UpstreamFailed)); + assert_eq!(Status::parse("DONE"), Some(Status::Done)); + assert_eq!(Status::parse("invalid"), None); + } + + #[test] + fn test_status_serde_roundtrip() { + for &status in Status::ALL { + let json = serde_json::to_string(&status).unwrap(); + let deserialized: Status = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, status, "Serde roundtrip failed for {status}"); + } + } + + #[test] + fn test_status_is_satisfied() { + assert!(Status::Done.is_satisfied()); + assert!(Status::Skipped.is_satisfied()); + assert!(!Status::Todo.is_satisfied()); + assert!(!Status::InProgress.is_satisfied()); + assert!(!Status::Blocked.is_satisfied()); + assert!(!Status::Failed.is_satisfied()); + } + + #[test] + fn test_valid_transitions() { + // All explicitly valid transitions from the spec. + let valid = [ + (Status::Todo, Status::InProgress), + (Status::Todo, Status::Skipped), + (Status::InProgress, Status::Done), + (Status::InProgress, Status::Failed), + (Status::InProgress, Status::Blocked), + (Status::Failed, Status::UpForRetry), + (Status::UpForRetry, Status::InProgress), + (Status::Blocked, Status::Todo), + (Status::Failed, Status::Todo), + ]; + + for (from, to) in valid { + assert!( + Transition::is_valid(from, to), + "Expected valid: {from} -> {to}" + ); + assert!( + Transition::new(from, to).is_ok(), + "Expected Ok: {from} -> {to}" + ); + } + } + + #[test] + fn test_upstream_failed_from_any_state() { + // Any state can transition to upstream_failed. + for &from in Status::ALL { + assert!( + Transition::is_valid(from, Status::UpstreamFailed), + "Expected valid: {from} -> upstream_failed" + ); + } + } + + #[test] + fn test_invalid_transitions() { + let invalid = [ + (Status::Todo, Status::Done), // Must go through in_progress + (Status::Todo, Status::Failed), // Must go through in_progress + (Status::Done, Status::InProgress), // Terminal + (Status::Done, Status::Todo), // Terminal + (Status::Skipped, Status::Todo), // Terminal + (Status::Skipped, Status::InProgress), // Terminal + (Status::Blocked, Status::Done), // Must go through todo -> in_progress + (Status::InProgress, Status::Todo), // Can't go back without restart + (Status::InProgress, Status::Skipped), // Can't skip while running + (Status::UpstreamFailed, Status::Todo), // Propagated failure is sticky + ]; + + for (from, to) in invalid { + assert!( + !Transition::is_valid(from, to), + "Expected invalid: {from} -> {to}" + ); + assert!( + Transition::new(from, to).is_err(), + "Expected Err: {from} -> {to}" + ); + } + } + + #[test] + fn test_valid_targets() { + let todo_targets = Transition::valid_targets(Status::Todo); + assert!(todo_targets.contains(&Status::InProgress)); + assert!(todo_targets.contains(&Status::Skipped)); + assert!(todo_targets.contains(&Status::UpstreamFailed)); + assert_eq!(todo_targets.len(), 3); + + let in_progress_targets = Transition::valid_targets(Status::InProgress); + assert!(in_progress_targets.contains(&Status::Done)); + assert!(in_progress_targets.contains(&Status::Failed)); + assert!(in_progress_targets.contains(&Status::Blocked)); + assert!(in_progress_targets.contains(&Status::UpstreamFailed)); + assert_eq!(in_progress_targets.len(), 4); + + let done_targets = Transition::valid_targets(Status::Done); + // Done is terminal -- only upstream_failed + assert_eq!(done_targets, vec![Status::UpstreamFailed]); + } + + #[test] + fn test_default_status_is_todo() { + assert_eq!(Status::default(), Status::Todo); + } + + #[test] + fn test_transition_error_display() { + let err = TransitionError { + from: Status::Todo, + to: Status::Done, + }; + assert_eq!(err.to_string(), "invalid transition from todo to done"); + } +} diff --git a/flowctl/crates/flowctl-core/src/types.rs b/flowctl/crates/flowctl-core/src/types.rs new file mode 100644 index 00000000..abaac6f0 --- /dev/null +++ b/flowctl/crates/flowctl-core/src/types.rs @@ -0,0 +1,545 @@ +//! Core data types for flowctl. +//! +//! Ported from `scripts/flowctl/core/constants.py` and the Markdown +//! frontmatter format defined in the design spec. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::state_machine::Status; + +// ── Constants ──────────────────────────────────────────────────────── + +/// Current schema version for Markdown frontmatter. +pub const SCHEMA_VERSION: u32 = 1; + +/// Supported schema versions for backward compatibility. +pub const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1]; + +/// Directory names within `.flow/`. +pub const FLOW_DIR: &str = ".flow"; +pub const EPICS_DIR: &str = "epics"; +pub const SPECS_DIR: &str = "specs"; +pub const TASKS_DIR: &str = "tasks"; +pub const MEMORY_DIR: &str = "memory"; +pub const REVIEWS_DIR: &str = "reviews"; +pub const CONFIG_FILE: &str = "config.json"; +pub const META_FILE: &str = "meta.json"; +pub const STATE_DIR: &str = ".state"; +pub const ARCHIVE_DIR: &str = ".archive"; + +/// Valid epic statuses. +pub const EPIC_STATUSES: &[&str] = &["open", "done"]; + +/// Required headings in task spec Markdown body. +pub const TASK_SPEC_HEADINGS: &[&str] = &[ + "## Description", + "## Acceptance", + "## Done summary", + "## Evidence", +]; + +// ── Domain ─────────────────────────────────────────────────────────── + +/// Task domain classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Domain { + Frontend, + Backend, + Architecture, + Testing, + Docs, + Ops, + #[default] + General, +} + +impl std::fmt::Display for Domain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Domain::Frontend => write!(f, "frontend"), + Domain::Backend => write!(f, "backend"), + Domain::Architecture => write!(f, "architecture"), + Domain::Testing => write!(f, "testing"), + Domain::Docs => write!(f, "docs"), + Domain::Ops => write!(f, "ops"), + Domain::General => write!(f, "general"), + } + } +} + +// ── Epic ───────────────────────────────────────────────────────────── + +/// Epic status (simpler than task status). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum EpicStatus { + #[default] + Open, + Done, +} + +impl std::fmt::Display for EpicStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EpicStatus::Open => write!(f, "open"), + EpicStatus::Done => write!(f, "done"), + } + } +} + +/// Plan review status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ReviewStatus { + #[default] + Unknown, + Passed, + Failed, +} + +impl std::fmt::Display for ReviewStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReviewStatus::Unknown => write!(f, "unknown"), + ReviewStatus::Passed => write!(f, "passed"), + ReviewStatus::Failed => write!(f, "failed"), + } + } +} + +/// An epic -- a collection of related tasks. +/// +/// Maps to the YAML frontmatter in `epics/fn-N-slug.md`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Epic { + /// Schema version for forward compatibility. + #[serde(default = "default_schema_version")] + pub schema_version: u32, + + /// Unique ID, e.g. `fn-1-add-auth`. + pub id: String, + + /// Human-readable title. + pub title: String, + + /// Current status. + #[serde(default)] + pub status: EpicStatus, + + /// Git branch name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch_name: Option, + + /// Plan review status. + #[serde(default)] + pub plan_review: ReviewStatus, + + /// Completion review status. + #[serde(default)] + pub completion_review: ReviewStatus, + + /// Epic-level dependencies (IDs of other epics). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub depends_on_epics: Vec, + + /// Default implementation backend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_impl: Option, + + /// Default review backend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_review: Option, + + /// Default sync backend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_sync: Option, + + /// File path to the Markdown spec. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_path: Option, + + /// Creation timestamp. + #[serde(default = "Utc::now")] + pub created_at: DateTime, + + /// Last update timestamp. + #[serde(default = "Utc::now")] + pub updated_at: DateTime, +} + +/// A task within an epic. +/// +/// Maps to the YAML frontmatter in `tasks/fn-N-slug.M.md`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Task { + /// Schema version for forward compatibility. + #[serde(default = "default_schema_version")] + pub schema_version: u32, + + /// Unique ID, e.g. `fn-1-add-auth.3`. + pub id: String, + + /// Parent epic ID. + pub epic: String, + + /// Human-readable title. + pub title: String, + + /// Current status. + #[serde(default)] + pub status: Status, + + /// Priority (lower = higher priority, None = 999). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, + + /// Domain classification. + #[serde(default)] + pub domain: Domain, + + /// Task dependencies (IDs of other tasks). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub depends_on: Vec, + + /// Owned files. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub files: Vec, + + /// Implementation backend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r#impl: Option, + + /// Review backend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option, + + /// Sync backend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sync: Option, + + /// File path to the Markdown spec. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_path: Option, + + /// Creation timestamp. + #[serde(default = "Utc::now")] + pub created_at: DateTime, + + /// Last update timestamp. + #[serde(default = "Utc::now")] + pub updated_at: DateTime, +} + +impl Task { + /// Sort priority (None -> 999). + pub fn sort_priority(&self) -> u32 { + self.priority.unwrap_or(999) + } +} + +// ── Phase ──────────────────────────────────────────────────────────── + +/// Worker execution phase. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Phase { + /// Phase ID (e.g. "0", "1", "2a", "2", "2.5"). + pub id: String, + + /// Human-readable title. + pub title: String, + + /// Condition that must be met for the phase to be considered done. + pub done_condition: String, + + /// Current phase status. + #[serde(default)] + pub status: PhaseStatus, + + /// Completion timestamp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, +} + +/// Phase execution status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PhaseStatus { + #[default] + Pending, + Active, + Done, + Skipped, +} + +impl std::fmt::Display for PhaseStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PhaseStatus::Pending => write!(f, "pending"), + PhaseStatus::Active => write!(f, "active"), + PhaseStatus::Done => write!(f, "done"), + PhaseStatus::Skipped => write!(f, "skipped"), + } + } +} + +/// Phase definitions from Python constants.py. +/// Each entry: (id, title, done_condition). +pub const PHASE_DEFS: &[(&str, &str, &str)] = &[ + ("0", "Verify Configuration", "OWNED_FILES verified and configuration validated"), + ("1", "Re-anchor", "Run flowctl show and verify spec was read"), + ("2a", "TDD Red-Green", "Failing tests written and confirmed to fail"), + ("2", "Implement", "Feature implemented and code compiles"), + ("2.5", "Verify & Fix", "flowctl guard passes and diff reviewed"), + ("3", "Commit", "Changes committed with conventional commit message"), + ("4", "Review", "SHIP verdict received from reviewer"), + ("5", "Complete", "flowctl done called and task status is done"), + ("5b", "Memory Auto-Save", "Non-obvious lessons saved to memory (if any)"), + ("6", "Return", "Summary returned to main conversation"), +]; + +/// Phase sequences by mode (from Python constants.py). +pub const PHASE_SEQ_DEFAULT: &[&str] = &["0", "1", "2", "2.5", "3", "5", "5b", "6"]; +pub const PHASE_SEQ_TDD: &[&str] = &["0", "1", "2a", "2", "2.5", "3", "5", "5b", "6"]; +pub const PHASE_SEQ_REVIEW: &[&str] = &["0", "1", "2", "2.5", "3", "4", "5", "5b", "6"]; + +// ── Evidence ───────────────────────────────────────────────────────── + +/// Evidence of task completion, attached when task is marked done. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Evidence { + /// Git commit hashes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub commits: Vec, + + /// Test commands that were run. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tests: Vec, + + /// Pull request URLs. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prs: Vec, + + /// Number of files changed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub files_changed: Option, + + /// Lines inserted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub insertions: Option, + + /// Lines deleted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deletions: Option, + + /// Number of review iterations. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_iterations: Option, + + /// Workspace change tracking. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_changes: Option, +} + +/// Workspace change summary (baseline vs final). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceChanges { + /// Git rev at start of implementation. + pub baseline_rev: String, + + /// Git rev at completion. + pub final_rev: String, + + /// Number of files changed between baseline and final. + pub files_changed: u32, + + /// Total insertions. + pub insertions: u32, + + /// Total deletions. + pub deletions: u32, +} + +// ── Runtime state ──────────────────────────────────────────────────── + +/// Runtime-only fields (not stored in Markdown, only in SQLite). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RuntimeState { + /// Task ID this state belongs to. + pub task_id: String, + + /// Current assignee. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignee: Option, + + /// When the task was claimed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claimed_at: Option>, + + /// When the task was completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, + + /// Duration in seconds from start to completion. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_secs: Option, + + /// Reason for being blocked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocked_reason: Option, + + /// Git rev at start of implementation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub baseline_rev: Option, + + /// Git rev at completion. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub final_rev: Option, + + /// Number of retries attempted so far. + #[serde(default)] + pub retry_count: u32, +} + +/// Runtime fields stored in state-dir (matching Python RUNTIME_FIELDS). +pub const RUNTIME_FIELDS: &[&str] = &[ + "status", + "updated_at", + "claimed_at", + "assignee", + "claim_note", + "evidence", + "blocked_reason", + "phase_progress", +]; + +// ── Helpers ────────────────────────────────────────────────────────── + +fn default_schema_version() -> u32 { + SCHEMA_VERSION +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_task_sort_priority() { + let task = Task { + schema_version: 1, + id: "fn-1-test.1".to_string(), + epic: "fn-1-test".to_string(), + title: "Test".to_string(), + status: Status::Todo, + priority: None, + domain: Domain::General, + depends_on: vec![], + files: vec![], + r#impl: None, + review: None, + sync: None, + file_path: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + assert_eq!(task.sort_priority(), 999); + + let task_with_prio = Task { + priority: Some(1), + ..task + }; + assert_eq!(task_with_prio.sort_priority(), 1); + } + + #[test] + fn test_domain_display() { + assert_eq!(Domain::Frontend.to_string(), "frontend"); + assert_eq!(Domain::Backend.to_string(), "backend"); + assert_eq!(Domain::General.to_string(), "general"); + } + + #[test] + fn test_epic_status_display() { + assert_eq!(EpicStatus::Open.to_string(), "open"); + assert_eq!(EpicStatus::Done.to_string(), "done"); + } + + #[test] + fn test_evidence_default() { + let evidence = Evidence::default(); + assert!(evidence.commits.is_empty()); + assert!(evidence.tests.is_empty()); + assert!(evidence.prs.is_empty()); + assert!(evidence.files_changed.is_none()); + } + + #[test] + fn test_task_serde_roundtrip() { + let task = Task { + schema_version: 1, + id: "fn-1-add-auth.1".to_string(), + epic: "fn-1-add-auth".to_string(), + title: "Design Auth Flow".to_string(), + status: Status::Todo, + priority: Some(1), + domain: Domain::Backend, + depends_on: vec![], + files: vec!["src/auth.ts".to_string()], + r#impl: None, + review: None, + sync: None, + file_path: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let json = serde_json::to_string(&task).unwrap(); + let deserialized: Task = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.id, "fn-1-add-auth.1"); + assert_eq!(deserialized.status, Status::Todo); + assert_eq!(deserialized.domain, Domain::Backend); + assert_eq!(deserialized.priority, Some(1)); + } + + #[test] + fn test_epic_serde_roundtrip() { + let epic = Epic { + schema_version: 1, + id: "fn-1-add-auth".to_string(), + title: "Add Authentication".to_string(), + status: EpicStatus::Open, + branch_name: Some("feat/add-auth".to_string()), + plan_review: ReviewStatus::Unknown, + completion_review: ReviewStatus::Unknown, + depends_on_epics: vec![], + default_impl: None, + default_review: None, + default_sync: None, + file_path: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let json = serde_json::to_string(&epic).unwrap(); + let deserialized: Epic = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.id, "fn-1-add-auth"); + assert_eq!(deserialized.status, EpicStatus::Open); + } + + #[test] + fn test_phase_defs_complete() { + assert_eq!(PHASE_DEFS.len(), 10); + // Verify all phase sequences reference valid phase IDs + let valid_ids: Vec<&str> = PHASE_DEFS.iter().map(|(id, _, _)| *id).collect(); + for seq_id in PHASE_SEQ_DEFAULT { + assert!(valid_ids.contains(seq_id), "Invalid phase in default seq: {seq_id}"); + } + for seq_id in PHASE_SEQ_TDD { + assert!(valid_ids.contains(seq_id), "Invalid phase in TDD seq: {seq_id}"); + } + for seq_id in PHASE_SEQ_REVIEW { + assert!(valid_ids.contains(seq_id), "Invalid phase in review seq: {seq_id}"); + } + } +} diff --git a/flowctl/crates/flowctl-daemon/Cargo.toml b/flowctl/crates/flowctl-daemon/Cargo.toml new file mode 100644 index 00000000..afe64731 --- /dev/null +++ b/flowctl/crates/flowctl-daemon/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "flowctl-daemon" +version = "0.1.0" +description = "Daemon process for flowctl (scheduler, watcher, HTTP API)" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[features] +default = [] +daemon = [ + "dep:tokio", + "dep:tokio-util", + "dep:axum", + "dep:nix", +] + +[dependencies] +flowctl-core = { workspace = true } +flowctl-db = { workspace = true } +flowctl-scheduler = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +notify = { workspace = true } + +# Daemon-only deps (feature-gated) +tokio = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } +axum = { workspace = true, optional = true } +nix = { workspace = true, optional = true } + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true } +tokio-util = { workspace = true } +hyper = "1" +hyper-util = { version = "0.1", features = ["client-legacy", "tokio"] } +http-body-util = "0.1" +bytes = "1" diff --git a/flowctl/crates/flowctl-daemon/src/handlers.rs b/flowctl/crates/flowctl-daemon/src/handlers.rs new file mode 100644 index 00000000..b74aa99d --- /dev/null +++ b/flowctl/crates/flowctl-daemon/src/handlers.rs @@ -0,0 +1,225 @@ +//! HTTP API route handlers for the daemon. +//! +//! Provides REST endpoints for status, epics, tasks, and a WebSocket +//! endpoint for streaming live events to connected TUI clients. + +use std::sync::Arc; + +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use tokio::sync::broadcast; +use tracing::{debug, info, warn}; + +use flowctl_scheduler::TimestampedEvent; + +use crate::lifecycle::DaemonRuntime; + +/// Shared application state for all handlers. +pub type AppState = Arc; + +/// Combined daemon state: runtime + event bus. +pub struct DaemonState { + pub runtime: DaemonRuntime, + pub event_bus: flowctl_scheduler::EventBus, +} + +/// GET /api/v1/health -- simple liveness check. +pub async fn health_handler() -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))) +} + +/// GET /api/v1/metrics -- daemon health metrics. +pub async fn metrics_handler(State(state): State) -> impl IntoResponse { + let metrics = state.runtime.health(); + (StatusCode::OK, Json(metrics)) +} + +/// POST /api/v1/shutdown -- initiate graceful shutdown. +pub async fn shutdown_handler(State(state): State) -> impl IntoResponse { + state.runtime.initiate_shutdown(); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "shutting_down"})), + ) +} + +/// GET /api/v1/status -- combined daemon status overview. +pub async fn status_handler(State(state): State) -> impl IntoResponse { + let health = state.runtime.health(); + let subscribers = state.event_bus.subscriber_count(); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "running", + "uptime_secs": health.uptime_secs, + "pid": health.pid, + "memory_bytes": health.memory_bytes, + "wal_size_bytes": health.wal_size_bytes, + "event_subscribers": subscribers, + })), + ) +} + +/// GET /api/v1/epics -- list epics from the database. +/// +/// Returns a JSON array of epics. On database errors, returns 500. +pub async fn epics_handler(State(state): State) -> impl IntoResponse { + let db_path = state + .runtime + .paths + .state_dir + .parent() + .map(|flow_dir| flow_dir.join("flowctl.db")); + + let Some(db_path) = db_path else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "cannot resolve db path"})), + ); + }; + + match flowctl_db::open(&db_path) { + Ok(conn) => { + let repo = flowctl_db::EpicRepo::new(&conn); + match repo.list(None) { + Ok(epics) => (StatusCode::OK, Json(serde_json::to_value(&epics).unwrap())), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ), + } + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("db open failed: {e}")})), + ), + } +} + +/// GET /api/v1/tasks -- list tasks, optionally filtered by epic_id query param. +pub async fn tasks_handler( + State(state): State, + axum::extract::Query(params): axum::extract::Query, +) -> impl IntoResponse { + let db_path = state + .runtime + .paths + .state_dir + .parent() + .map(|flow_dir| flow_dir.join("flowctl.db")); + + let Some(db_path) = db_path else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "cannot resolve db path"})), + ); + }; + + match flowctl_db::open(&db_path) { + Ok(conn) => { + let repo = flowctl_db::TaskRepo::new(&conn); + let result = if let Some(ref epic_id) = params.epic_id { + repo.list_by_epic(epic_id) + } else { + repo.list_all(None, None) + }; + match result { + Ok(tasks) => (StatusCode::OK, Json(serde_json::to_value(&tasks).unwrap())), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ), + } + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("db open failed: {e}")})), + ), + } +} + +/// Query parameters for the tasks endpoint. +#[derive(Debug, serde::Deserialize)] +pub struct TasksQuery { + pub epic_id: Option, +} + +/// GET /api/v1/events -- WebSocket upgrade for live event streaming. +pub async fn events_ws_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> impl IntoResponse { + let rx = state.event_bus.subscribe(); + let cancel = state.runtime.cancel.clone(); + ws.on_upgrade(move |socket| handle_event_socket(socket, rx, cancel)) +} + +/// Handle a single WebSocket connection: stream events until the client +/// disconnects or the daemon shuts down. +async fn handle_event_socket( + mut socket: WebSocket, + mut rx: broadcast::Receiver, + cancel: tokio_util::sync::CancellationToken, +) { + info!("WebSocket client connected for event streaming"); + + loop { + tokio::select! { + _ = cancel.cancelled() => { + debug!("daemon shutting down, closing WebSocket"); + let _ = socket.send(Message::Close(None)).await; + break; + } + result = rx.recv() => { + match result { + Ok(event) => { + match serde_json::to_string(&event) { + Ok(json) => { + if socket.send(Message::Text(json.into())).await.is_err() { + debug!("WebSocket client disconnected"); + break; + } + } + Err(e) => { + warn!("failed to serialize event: {e}"); + } + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!(skipped = n, "WebSocket client lagged, skipping events"); + } + Err(broadcast::error::RecvError::Closed) => { + debug!("event bus closed, closing WebSocket"); + let _ = socket.send(Message::Close(None)).await; + break; + } + } + } + // Also handle incoming messages (ping/pong, close). + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => { + debug!("WebSocket client disconnected"); + break; + } + Some(Ok(Message::Ping(data))) => { + let _ = socket.send(Message::Pong(data)).await; + } + Some(Ok(_)) => { + // Ignore other messages from client. + } + Some(Err(e)) => { + debug!("WebSocket error: {e}"); + break; + } + } + } + } + } + + info!("WebSocket client disconnected"); +} diff --git a/flowctl/crates/flowctl-daemon/src/lib.rs b/flowctl/crates/flowctl-daemon/src/lib.rs new file mode 100644 index 00000000..fc4bda79 --- /dev/null +++ b/flowctl/crates/flowctl-daemon/src/lib.rs @@ -0,0 +1,13 @@ +//! flowctl-daemon: Background daemon process for flowctl. +//! +//! Provides the DAG scheduler, file watcher, heartbeat watchdog, +//! circuit breaker, and HTTP API over Unix socket. + +pub use flowctl_core; + +#[cfg(feature = "daemon")] +pub mod handlers; +#[cfg(feature = "daemon")] +pub mod lifecycle; +#[cfg(feature = "daemon")] +pub mod server; diff --git a/flowctl/crates/flowctl-daemon/src/lifecycle.rs b/flowctl/crates/flowctl-daemon/src/lifecycle.rs new file mode 100644 index 00000000..a487d69f --- /dev/null +++ b/flowctl/crates/flowctl-daemon/src/lifecycle.rs @@ -0,0 +1,459 @@ +//! Daemon lifecycle: PID lock, stale detection, socket management, graceful shutdown. +//! +//! Follows the Docker-style CLI → Unix socket → Daemon pattern. +//! Feature-gated behind `#[cfg(feature = "daemon")]`. + +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use nix::sys::signal; +use nix::unistd::Pid; +use tokio::io::AsyncWriteExt; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; +use tracing::{info, warn}; + +/// Default drain timeout before force-killing subsystems. +const DRAIN_TIMEOUT: Duration = Duration::from_secs(10); + +/// Paths for daemon state files within `.flow/.state/`. +#[derive(Debug, Clone)] +pub struct DaemonPaths { + /// PID file: `.flow/.state/flowctl.pid` + pub pid_file: PathBuf, + /// Unix socket: `.flow/.state/flowctl.sock` + pub socket_file: PathBuf, + /// State directory: `.flow/.state/` + pub state_dir: PathBuf, +} + +impl DaemonPaths { + /// Create paths rooted at the given `.flow/` directory. + pub fn new(flow_dir: &Path) -> Self { + let state_dir = flow_dir.join(".state"); + Self { + pid_file: state_dir.join("flowctl.pid"), + socket_file: state_dir.join("flowctl.sock"), + state_dir, + } + } + + /// Ensure the state directory exists. + pub fn ensure_state_dir(&self) -> Result<()> { + fs::create_dir_all(&self.state_dir) + .with_context(|| format!("failed to create state dir: {}", self.state_dir.display())) + } +} + +/// Health metrics tracked by the running daemon. +#[derive(Debug, Clone, serde::Serialize)] +pub struct HealthMetrics { + /// Daemon uptime in seconds. + pub uptime_secs: u64, + /// PID of the daemon process. + pub pid: u32, + /// Resident memory in bytes (0 if unavailable). + pub memory_bytes: u64, + /// WAL file size in bytes (0 if no WAL). + pub wal_size_bytes: u64, +} + +/// Runtime handle for the daemon, managing shutdown coordination. +pub struct DaemonRuntime { + /// Token propagated to all subsystems for cooperative cancellation. + pub cancel: CancellationToken, + /// Tracks all spawned subsystem tasks for graceful drain. + pub tracker: TaskTracker, + /// Daemon state paths. + pub paths: DaemonPaths, + /// When the daemon started. + pub started_at: Instant, + /// Sender for shutdown signal (value = true means shutting down). + shutdown_tx: watch::Sender, + /// Receiver cloneable by subsystems. + pub shutdown_rx: watch::Receiver, +} + +impl DaemonRuntime { + /// Create a new daemon runtime. + pub fn new(paths: DaemonPaths) -> Self { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + Self { + cancel: CancellationToken::new(), + tracker: TaskTracker::new(), + paths, + started_at: Instant::now(), + shutdown_tx, + shutdown_rx, + } + } + + /// Get current health metrics. + pub fn health(&self) -> HealthMetrics { + let wal_size = self + .paths + .state_dir + .parent() + .map(|flow_dir| flow_dir.join("flowctl.db-wal")) + .and_then(|p| fs::metadata(p).ok()) + .map(|m| m.len()) + .unwrap_or(0); + + HealthMetrics { + uptime_secs: self.started_at.elapsed().as_secs(), + pid: std::process::id(), + memory_bytes: get_resident_memory(), + wal_size_bytes: wal_size, + } + } + + /// Initiate graceful shutdown: cancel token + notify watchers. + pub fn initiate_shutdown(&self) { + info!("initiating graceful shutdown"); + self.cancel.cancel(); + let _ = self.shutdown_tx.send(true); + } + + /// Wait for all tracked tasks to complete, with drain timeout. + pub async fn drain(&self) -> Result<()> { + self.tracker.close(); + info!("waiting for subsystems to drain (timeout: {}s)", DRAIN_TIMEOUT.as_secs()); + + let result = tokio::time::timeout(DRAIN_TIMEOUT, self.tracker.wait()).await; + + match result { + Ok(()) => { + info!("all subsystems drained cleanly"); + Ok(()) + } + Err(_) => { + warn!("drain timeout exceeded, forcing shutdown"); + // CancellationToken already cancelled — tasks should be aborting. + // TaskTracker will drop remaining tasks when we return. + Ok(()) + } + } + } + + /// Clean up PID and socket files on shutdown. + pub fn cleanup(&self) { + if let Err(e) = fs::remove_file(&self.paths.pid_file) { + if e.kind() != io::ErrorKind::NotFound { + warn!("failed to remove PID file: {}", e); + } + } + if let Err(e) = fs::remove_file(&self.paths.socket_file) { + if e.kind() != io::ErrorKind::NotFound { + warn!("failed to remove socket file: {}", e); + } + } + } +} + +/// Acquire the PID lock file. Returns error if another daemon is running. +/// +/// Handles stale PID detection: if the PID file exists but the process +/// is dead, cleans up and proceeds. +pub fn acquire_pid_lock(paths: &DaemonPaths) -> Result<()> { + paths.ensure_state_dir()?; + + // Check for existing PID file + if paths.pid_file.exists() { + let contents = fs::read_to_string(&paths.pid_file) + .with_context(|| format!("failed to read PID file: {}", paths.pid_file.display()))?; + + if let Ok(pid) = contents.trim().parse::() { + if is_process_alive(pid) { + bail!( + "daemon already running (PID {}). Use `flowctl daemon stop` to stop it.", + pid + ); + } + // Stale PID — process is dead, clean up + warn!("stale PID file found (PID {} is dead), cleaning up", pid); + let _ = fs::remove_file(&paths.pid_file); + } else { + warn!("corrupt PID file, removing"); + let _ = fs::remove_file(&paths.pid_file); + } + } + + // Write our PID + let pid = std::process::id(); + fs::write(&paths.pid_file, pid.to_string()) + .with_context(|| format!("failed to write PID file: {}", paths.pid_file.display()))?; + + info!("acquired PID lock (PID {})", pid); + Ok(()) +} + +/// Clean up orphaned socket file from a previous unclean shutdown. +pub fn cleanup_orphaned_socket(paths: &DaemonPaths) -> Result<()> { + if paths.socket_file.exists() { + warn!( + "removing orphaned socket: {}", + paths.socket_file.display() + ); + fs::remove_file(&paths.socket_file).with_context(|| { + format!( + "failed to remove orphaned socket: {}", + paths.socket_file.display() + ) + })?; + } + Ok(()) +} + +/// Set socket file permissions to 0600 (owner read/write only). +pub fn set_socket_permissions(socket_path: &Path) -> Result<()> { + let perms = fs::Permissions::from_mode(0o600); + fs::set_permissions(socket_path, perms) + .with_context(|| format!("failed to set socket permissions: {}", socket_path.display())) +} + +/// Check if a process with the given PID is alive. +fn is_process_alive(pid: i32) -> bool { + // Signal 0 checks existence without sending a signal + signal::kill(Pid::from_raw(pid), None).is_ok() +} + +/// Read the PID from the PID file, if it exists and is valid. +pub fn read_pid(paths: &DaemonPaths) -> Option { + fs::read_to_string(&paths.pid_file) + .ok() + .and_then(|s| s.trim().parse().ok()) +} + +/// Check if the daemon is currently running (PID file exists and process alive). +pub fn is_daemon_running(paths: &DaemonPaths) -> bool { + read_pid(paths).is_some_and(is_process_alive) +} + +/// Send a stop signal to the running daemon via the socket. +/// +/// Returns `Ok(())` if the stop request was sent successfully, or an error +/// if the daemon is not reachable. +pub async fn send_stop(paths: &DaemonPaths) -> Result<()> { + if !paths.socket_file.exists() { + bail!("daemon socket not found: {}", paths.socket_file.display()); + } + + // Connect to the Unix socket and send a raw HTTP POST /shutdown + let mut stream = tokio::net::UnixStream::connect(&paths.socket_file) + .await + .context("failed to connect to daemon socket — is the daemon running?")?; + + let request = b"POST /api/v1/shutdown HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + stream + .write_all(request) + .await + .context("failed to send shutdown request")?; + + info!("shutdown request sent"); + Ok(()) +} + +/// CLI smart routing: determine how to route a command. +/// +/// Returns `Ok(CliRoute)` indicating whether to use the socket or error out. +pub fn detect_route(paths: &DaemonPaths) -> CliRoute { + let pid = read_pid(paths); + + match pid { + Some(pid) if is_process_alive(pid) => { + if paths.socket_file.exists() { + CliRoute::Socket + } else { + // PID alive but no socket — daemon is starting up or broken + CliRoute::Error(format!( + "daemon PID {} is alive but socket not found. \ + The daemon may be starting up or in a bad state.", + pid + )) + } + } + Some(pid) => { + // PID exists but process dead — stale + CliRoute::Error(format!( + "stale PID file found (PID {} is dead). \ + Run `flowctl daemon start` to start a new daemon.", + pid + )) + } + None => CliRoute::NoDaemon, + } +} + +/// Result of CLI route detection. +#[derive(Debug)] +pub enum CliRoute { + /// Daemon is running and reachable via socket. + Socket, + /// No daemon is running (no PID file). + NoDaemon, + /// Error state: PID exists but daemon unreachable (no fallback). + Error(String), +} + +/// Get resident memory of the current process (macOS/Linux). +fn get_resident_memory() -> u64 { + #[cfg(target_os = "macos")] + { + // macOS: use mach task_info + // Simplified — return 0 if unavailable + 0 + } + #[cfg(target_os = "linux")] + { + // Linux: read /proc/self/statm + fs::read_to_string("/proc/self/statm") + .ok() + .and_then(|s| s.split_whitespace().nth(1)?.parse::().ok()) + .map(|pages| pages * 4096) + .unwrap_or(0) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_paths() -> (TempDir, DaemonPaths) { + let tmp = TempDir::new().unwrap(); + let flow_dir = tmp.path().join(".flow"); + let paths = DaemonPaths::new(&flow_dir); + (tmp, paths) + } + + #[test] + fn acquire_pid_lock_creates_file() { + let (_tmp, paths) = test_paths(); + acquire_pid_lock(&paths).unwrap(); + assert!(paths.pid_file.exists()); + let contents = fs::read_to_string(&paths.pid_file).unwrap(); + let pid: u32 = contents.trim().parse().unwrap(); + assert_eq!(pid, std::process::id()); + } + + #[test] + fn stale_pid_detected_and_cleaned() { + let (_tmp, paths) = test_paths(); + paths.ensure_state_dir().unwrap(); + // Write a PID that definitely doesn't exist (PID 1 is init, use a very high PID) + fs::write(&paths.pid_file, "999999999").unwrap(); + // Should succeed because the PID is dead + acquire_pid_lock(&paths).unwrap(); + let contents = fs::read_to_string(&paths.pid_file).unwrap(); + let pid: u32 = contents.trim().parse().unwrap(); + assert_eq!(pid, std::process::id()); + } + + #[test] + fn live_pid_blocks_acquisition() { + let (_tmp, paths) = test_paths(); + paths.ensure_state_dir().unwrap(); + // Write our own PID — it's alive + fs::write(&paths.pid_file, std::process::id().to_string()).unwrap(); + let result = acquire_pid_lock(&paths); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("already running")); + } + + #[test] + fn corrupt_pid_file_cleaned() { + let (_tmp, paths) = test_paths(); + paths.ensure_state_dir().unwrap(); + fs::write(&paths.pid_file, "not-a-number").unwrap(); + acquire_pid_lock(&paths).unwrap(); + assert!(paths.pid_file.exists()); + } + + #[test] + fn orphaned_socket_cleaned() { + let (_tmp, paths) = test_paths(); + paths.ensure_state_dir().unwrap(); + fs::write(&paths.socket_file, "").unwrap(); + cleanup_orphaned_socket(&paths).unwrap(); + assert!(!paths.socket_file.exists()); + } + + #[test] + fn socket_permissions_set() { + let (_tmp, paths) = test_paths(); + paths.ensure_state_dir().unwrap(); + fs::write(&paths.socket_file, "").unwrap(); + set_socket_permissions(&paths.socket_file).unwrap(); + let meta = fs::metadata(&paths.socket_file).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o600); + } + + #[test] + fn detect_route_no_daemon() { + let (_tmp, paths) = test_paths(); + paths.ensure_state_dir().unwrap(); + assert!(matches!(detect_route(&paths), CliRoute::NoDaemon)); + } + + #[test] + fn detect_route_stale_pid() { + let (_tmp, paths) = test_paths(); + paths.ensure_state_dir().unwrap(); + fs::write(&paths.pid_file, "999999999").unwrap(); + assert!(matches!(detect_route(&paths), CliRoute::Error(_))); + } + + #[test] + fn runtime_health_metrics() { + let (_tmp, paths) = test_paths(); + let runtime = DaemonRuntime::new(paths); + let health = runtime.health(); + assert_eq!(health.pid, std::process::id()); + assert!(health.uptime_secs < 2); + } + + #[test] + fn runtime_cleanup_removes_files() { + let (_tmp, paths) = test_paths(); + paths.ensure_state_dir().unwrap(); + fs::write(&paths.pid_file, "123").unwrap(); + fs::write(&paths.socket_file, "").unwrap(); + let runtime = DaemonRuntime::new(paths.clone()); + runtime.cleanup(); + assert!(!paths.pid_file.exists()); + assert!(!paths.socket_file.exists()); + } + + #[tokio::test] + async fn runtime_drain_completes_with_no_tasks() { + let (_tmp, paths) = test_paths(); + let runtime = DaemonRuntime::new(paths); + runtime.initiate_shutdown(); + runtime.drain().await.unwrap(); + } + + #[tokio::test] + async fn runtime_drain_waits_for_tasks() { + let (_tmp, paths) = test_paths(); + let runtime = DaemonRuntime::new(paths); + let cancel = runtime.cancel.clone(); + + runtime.tracker.spawn(async move { + // Simulate a short-lived task + tokio::time::sleep(Duration::from_millis(50)).await; + }); + + runtime.initiate_shutdown(); + runtime.drain().await.unwrap(); + assert!(cancel.is_cancelled()); + } +} diff --git a/flowctl/crates/flowctl-daemon/src/server.rs b/flowctl/crates/flowctl-daemon/src/server.rs new file mode 100644 index 00000000..7969683e --- /dev/null +++ b/flowctl/crates/flowctl-daemon/src/server.rs @@ -0,0 +1,190 @@ +//! HTTP server on Unix socket for daemon API. +//! +//! Provides health, metrics, status, epics, tasks, shutdown, and a +//! WebSocket endpoint for streaming live events. +//! Feature-gated behind `#[cfg(feature = "daemon")]`. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use axum::routing::{get, post}; +use tokio::net::UnixListener; +use tracing::info; + +use crate::handlers::{ + self, AppState, DaemonState, +}; +use crate::lifecycle::{set_socket_permissions, DaemonRuntime}; + +/// Build the Axum router with all daemon API routes. +fn build_router(state: AppState) -> axum::Router { + axum::Router::new() + .route("/api/v1/health", get(handlers::health_handler)) + .route("/api/v1/metrics", get(handlers::metrics_handler)) + .route("/api/v1/status", get(handlers::status_handler)) + .route("/api/v1/epics", get(handlers::epics_handler)) + .route("/api/v1/tasks", get(handlers::tasks_handler)) + .route("/api/v1/shutdown", post(handlers::shutdown_handler)) + .route("/api/v1/events", get(handlers::events_ws_handler)) + .with_state(state) +} + +/// Start the HTTP server on a Unix socket. +/// +/// Binds to the socket path from `runtime.paths.socket_file`, sets 0600 +/// permissions, and serves until the cancellation token is triggered. +pub async fn serve(runtime: DaemonRuntime, event_bus: flowctl_scheduler::EventBus) -> Result<()> { + let socket_path = runtime.paths.socket_file.clone(); + + let listener = UnixListener::bind(&socket_path) + .with_context(|| format!("failed to bind Unix socket: {}", socket_path.display()))?; + + // Set socket permissions to 0600 (owner only) + set_socket_permissions(&socket_path)?; + + info!("daemon API listening on {}", socket_path.display()); + + let cancel = runtime.cancel.clone(); + + let state: AppState = Arc::new(DaemonState { + runtime, + event_bus, + }); + + let router = build_router(state); + + axum::serve(listener, router) + .with_graceful_shutdown(async move { + cancel.cancelled().await; + info!("HTTP server shutting down"); + }) + .await + .context("HTTP server error")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lifecycle::{DaemonPaths, DaemonRuntime}; + use std::time::Duration; + use tempfile::TempDir; + + fn test_setup() -> (TempDir, DaemonRuntime, flowctl_scheduler::EventBus) { + let tmp = TempDir::new().unwrap(); + let flow_dir = tmp.path().join(".flow"); + let paths = DaemonPaths::new(&flow_dir); + paths.ensure_state_dir().unwrap(); + let runtime = DaemonRuntime::new(paths); + let (event_bus, _critical_rx) = flowctl_scheduler::EventBus::with_default_capacity(); + (tmp, runtime, event_bus) + } + + #[tokio::test] + async fn server_starts_and_responds_to_health() { + let (_tmp, runtime, event_bus) = test_setup(); + let cancel = runtime.cancel.clone(); + let socket_path = runtime.paths.socket_file.clone(); + + let server_handle = tokio::spawn(async move { + serve(runtime, event_bus).await.unwrap(); + }); + + // Give the server a moment to bind + tokio::time::sleep(Duration::from_millis(100)).await; + + // Connect to the socket and check health + let stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); + + // Use hyper to send a request + let (mut sender, conn) = hyper::client::conn::http1::handshake( + hyper_util::rt::TokioIo::new(stream), + ) + .await + .unwrap(); + + tokio::spawn(conn); + + let req = hyper::Request::builder() + .uri("/api/v1/health") + .body(http_body_util::Empty::::new()) + .unwrap(); + + let resp = sender.send_request(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // Shutdown + cancel.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; + } + + #[tokio::test] + async fn shutdown_endpoint_triggers_cancellation() { + let (_tmp, runtime, event_bus) = test_setup(); + let cancel = runtime.cancel.clone(); + let socket_path = runtime.paths.socket_file.clone(); + + tokio::spawn(async move { + serve(runtime, event_bus).await.unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); + + let (mut sender, conn) = hyper::client::conn::http1::handshake( + hyper_util::rt::TokioIo::new(stream), + ) + .await + .unwrap(); + + tokio::spawn(conn); + + let req = hyper::Request::builder() + .method("POST") + .uri("/api/v1/shutdown") + .body(http_body_util::Empty::::new()) + .unwrap(); + + let resp = sender.send_request(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // The cancel token should now be triggered + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(cancel.is_cancelled()); + } + + use axum::http::StatusCode; + + #[tokio::test] + async fn status_endpoint_returns_overview() { + let (_tmp, runtime, event_bus) = test_setup(); + let cancel = runtime.cancel.clone(); + let socket_path = runtime.paths.socket_file.clone(); + + tokio::spawn(async move { + serve(runtime, event_bus).await.unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); + let (mut sender, conn) = hyper::client::conn::http1::handshake( + hyper_util::rt::TokioIo::new(stream), + ) + .await + .unwrap(); + tokio::spawn(conn); + + let req = hyper::Request::builder() + .uri("/api/v1/status") + .body(http_body_util::Empty::::new()) + .unwrap(); + + let resp = sender.send_request(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + cancel.cancel(); + } +} diff --git a/flowctl/crates/flowctl-db/Cargo.toml b/flowctl/crates/flowctl-db/Cargo.toml new file mode 100644 index 00000000..f539a483 --- /dev/null +++ b/flowctl/crates/flowctl-db/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "flowctl-db" +version = "0.1.0" +description = "SQLite storage layer for flowctl" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +flowctl-core = { workspace = true } +rusqlite = { workspace = true } +rusqlite_migration = { workspace = true } +include_dir = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/flowctl/crates/flowctl-db/src/error.rs b/flowctl/crates/flowctl-db/src/error.rs new file mode 100644 index 00000000..43a1d1a1 --- /dev/null +++ b/flowctl/crates/flowctl-db/src/error.rs @@ -0,0 +1,31 @@ +//! Error types for the flowctl-db crate. + +use thiserror::Error; + +/// Top-level error type for database operations. +#[derive(Debug, Error)] +pub enum DbError { + /// SQLite error from rusqlite. + #[error("sqlite error: {0}")] + Sqlite(#[from] rusqlite::Error), + + /// Migration error. + #[error("migration error: {0}")] + Migration(String), + + /// State directory resolution error. + #[error("state directory error: {0}")] + StateDir(String), + + /// Entity not found. + #[error("{entity} not found: {id}")] + NotFound { entity: &'static str, id: String }, + + /// Constraint violation (e.g., duplicate key, FK violation). + #[error("constraint violation: {0}")] + Constraint(String), + + /// Serialization error (JSON payloads in evidence, events). + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), +} diff --git a/flowctl/crates/flowctl-db/src/events.rs b/flowctl/crates/flowctl-db/src/events.rs new file mode 100644 index 00000000..c64a051a --- /dev/null +++ b/flowctl/crates/flowctl-db/src/events.rs @@ -0,0 +1,181 @@ +//! Extended event logging: query events by type/timerange, record token usage. + +use rusqlite::{params, Connection}; + +use crate::error::DbError; +use crate::repo::EventRow; + +/// Extended event queries beyond the basic EventRepo. +pub struct EventLog<'a> { + conn: &'a Connection, +} + +impl<'a> EventLog<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Query events by type, optionally filtered by epic and time range. + pub fn query( + &self, + event_type: Option<&str>, + epic_id: Option<&str>, + since: Option<&str>, + until: Option<&str>, + limit: usize, + ) -> Result, DbError> { + let mut conditions = Vec::new(); + let mut param_values: Vec = Vec::new(); + + if let Some(et) = event_type { + param_values.push(et.to_string()); + conditions.push(format!("event_type = ?{}", param_values.len())); + } + if let Some(eid) = epic_id { + param_values.push(eid.to_string()); + conditions.push(format!("epic_id = ?{}", param_values.len())); + } + if let Some(s) = since { + param_values.push(s.to_string()); + conditions.push(format!("timestamp >= ?{}", param_values.len())); + } + if let Some(u) = until { + param_values.push(u.to_string()); + conditions.push(format!("timestamp <= ?{}", param_values.len())); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + let sql = format!( + "SELECT id, timestamp, epic_id, task_id, event_type, actor, payload, session_id + FROM events {where_clause} ORDER BY id DESC LIMIT ?{}", + param_values.len() + 1 + ); + + let mut stmt = self.conn.prepare(&sql)?; + + // Build params dynamically + let mut all_params: Vec> = param_values + .iter() + .map(|v| Box::new(v.clone()) as Box) + .collect(); + all_params.push(Box::new(limit as i64)); + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = all_params.iter().map(|p| p.as_ref()).collect(); + + let rows = stmt + .query_map(param_refs.as_slice(), |row| { + Ok(EventRow { + id: row.get(0)?, + timestamp: row.get(1)?, + epic_id: row.get(2)?, + task_id: row.get(3)?, + event_type: row.get(4)?, + actor: row.get(5)?, + payload: row.get(6)?, + session_id: row.get(7)?, + }) + })? + .collect::, _>>()?; + + Ok(rows) + } + + /// Record token usage for a task/phase. + pub fn record_tokens( + &self, + epic_id: &str, + task_id: Option<&str>, + phase: Option<&str>, + model: Option<&str>, + input_tokens: i64, + output_tokens: i64, + cache_read: i64, + cache_write: i64, + estimated_cost: Option, + ) -> Result { + self.conn.execute( + "INSERT INTO token_usage (epic_id, task_id, phase, model, input_tokens, output_tokens, cache_read, cache_write, estimated_cost) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![epic_id, task_id, phase, model, input_tokens, output_tokens, cache_read, cache_write, estimated_cost], + )?; + Ok(self.conn.last_insert_rowid()) + } + + /// Count events by type for an epic. + pub fn count_by_type(&self, epic_id: &str) -> Result, DbError> { + let mut stmt = self.conn.prepare( + "SELECT event_type, COUNT(*) FROM events WHERE epic_id = ?1 GROUP BY event_type ORDER BY COUNT(*) DESC", + )?; + let rows = stmt + .query_map(params![epic_id], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?; + Ok(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pool::open_memory; + use crate::repo::EventRepo; + + fn setup() -> Connection { + let conn = open_memory().expect("in-memory db"); + conn.execute( + "INSERT INTO epics (id, title, status, file_path, created_at, updated_at) + VALUES ('fn-1-test', 'Test', 'open', 'e.md', '2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z')", + [], + ).unwrap(); + conn + } + + #[test] + fn test_query_by_type() { + let conn = setup(); + let repo = EventRepo::new(&conn); + repo.insert("fn-1-test", Some("fn-1-test.1"), "task_started", Some("w"), None, None).unwrap(); + repo.insert("fn-1-test", Some("fn-1-test.1"), "task_completed", Some("w"), None, None).unwrap(); + repo.insert("fn-1-test", Some("fn-1-test.2"), "task_started", Some("w"), None, None).unwrap(); + + let log = EventLog::new(&conn); + let started = log.query(Some("task_started"), None, None, None, 100).unwrap(); + assert_eq!(started.len(), 2); + + let completed = log.query(Some("task_completed"), Some("fn-1-test"), None, None, 100).unwrap(); + assert_eq!(completed.len(), 1); + } + + #[test] + fn test_record_tokens() { + let conn = setup(); + let log = EventLog::new(&conn); + let id = log.record_tokens("fn-1-test", Some("fn-1-test.1"), Some("impl"), Some("claude-sonnet-4-20250514"), 1000, 500, 200, 100, Some(0.015)).unwrap(); + assert!(id > 0); + + let total: i64 = conn.query_row( + "SELECT SUM(input_tokens + output_tokens) FROM token_usage WHERE epic_id = 'fn-1-test'", + [], |row| row.get(0), + ).unwrap(); + assert_eq!(total, 1500); + } + + #[test] + fn test_count_by_type() { + let conn = setup(); + let repo = EventRepo::new(&conn); + repo.insert("fn-1-test", None, "task_started", None, None, None).unwrap(); + repo.insert("fn-1-test", None, "task_started", None, None, None).unwrap(); + repo.insert("fn-1-test", None, "task_completed", None, None, None).unwrap(); + + let log = EventLog::new(&conn); + let counts = log.count_by_type("fn-1-test").unwrap(); + assert_eq!(counts.len(), 2); + assert_eq!(counts[0], ("task_started".to_string(), 2)); + assert_eq!(counts[1], ("task_completed".to_string(), 1)); + } +} diff --git a/flowctl/crates/flowctl-db/src/indexer.rs b/flowctl/crates/flowctl-db/src/indexer.rs new file mode 100644 index 00000000..c58241ce --- /dev/null +++ b/flowctl/crates/flowctl-db/src/indexer.rs @@ -0,0 +1,789 @@ +//! Reindex engine: scans `.flow/` Markdown files and rebuilds SQLite index tables. +//! +//! The reindex process: +//! 1. Acquires an exclusive file lock on the database to prevent concurrent reindex +//! 2. Disables triggers during bulk import +//! 3. Clears all indexed tables (epics, tasks, task_deps, epic_deps, file_ownership) +//! 4. Scans `.flow/epics/*.md` and `.flow/tasks/*.md` +//! 5. Parses YAML frontmatter via `flowctl_core::frontmatter::parse()` +//! 6. INSERTs into SQLite index tables +//! 7. Migrates Python runtime state from `flow-state/tasks/*.state.json` +//! 8. Re-enables triggers +//! +//! The operation is idempotent: running twice produces the same result. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use rusqlite::{params, Connection}; +use tracing::{info, warn}; + +use flowctl_core::frontmatter; +use flowctl_core::id::{is_epic_id, is_task_id}; +use flowctl_core::types::{Epic, Task}; + +use crate::error::DbError; +use crate::repo::{EpicRepo, TaskRepo}; + +/// Result of a reindex operation. +#[derive(Debug, Default)] +pub struct ReindexResult { + /// Number of epics indexed. + pub epics_indexed: usize, + /// Number of tasks indexed. + pub tasks_indexed: usize, + /// Number of files skipped (invalid frontmatter, non-task files, etc.). + pub files_skipped: usize, + /// Number of runtime state files migrated. + pub runtime_states_migrated: usize, + /// Warnings collected during indexing. + pub warnings: Vec, +} + +/// Perform a full reindex of `.flow/` Markdown files into SQLite. +/// +/// This is the main entry point for `flowctl reindex`. It acquires an +/// exclusive lock, clears indexed tables, scans files, and rebuilds. +/// +/// # Arguments +/// * `conn` - Open database connection (with migrations already applied) +/// * `flow_dir` - Path to the `.flow/` directory +/// * `state_dir` - Path to the state directory (for runtime state migration) +pub fn reindex( + conn: &Connection, + flow_dir: &Path, + state_dir: Option<&Path>, +) -> Result { + let mut result = ReindexResult::default(); + + // Use a transaction for atomicity. + conn.execute_batch("BEGIN EXCLUSIVE")?; + + let outcome = reindex_inner(conn, flow_dir, state_dir, &mut result); + + match outcome { + Ok(()) => { + conn.execute_batch("COMMIT")?; + info!( + epics = result.epics_indexed, + tasks = result.tasks_indexed, + skipped = result.files_skipped, + runtime = result.runtime_states_migrated, + "reindex complete" + ); + Ok(result) + } + Err(e) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(e) + } + } +} + +/// Inner reindex logic, separated for transaction management. +fn reindex_inner( + conn: &Connection, + flow_dir: &Path, + state_dir: Option<&Path>, + result: &mut ReindexResult, +) -> Result<(), DbError> { + // Step 1: Disable triggers during bulk import. + disable_triggers(conn)?; + + // Step 2: Clear all indexed tables (order matters for FK constraints). + clear_indexed_tables(conn)?; + + // Step 3: Scan and index epics. + let epics_dir = flow_dir.join("epics"); + let indexed_epics = if epics_dir.is_dir() { + index_epics(conn, &epics_dir, result)? + } else { + HashMap::new() + }; + + // Step 4: Scan and index tasks. + let tasks_dir = flow_dir.join("tasks"); + if tasks_dir.is_dir() { + index_tasks(conn, &tasks_dir, &indexed_epics, result)?; + } + + // Step 5: Migrate Python runtime state files if present. + if let Some(sd) = state_dir { + migrate_runtime_state(conn, sd, result)?; + } + + // Step 6: Re-enable triggers. + enable_triggers(conn)?; + + Ok(()) +} + +/// Disable auto-aggregation triggers during bulk import. +fn disable_triggers(conn: &Connection) -> Result<(), DbError> { + // Drop the trigger temporarily. We recreate it after import. + conn.execute_batch( + "DROP TRIGGER IF EXISTS trg_daily_rollup;" + )?; + Ok(()) +} + +/// Re-enable auto-aggregation triggers after bulk import. +fn enable_triggers(conn: &Connection) -> Result<(), DbError> { + conn.execute_batch( + "CREATE TRIGGER IF NOT EXISTS trg_daily_rollup AFTER INSERT ON events + WHEN NEW.event_type IN ('task_completed', 'task_failed', 'task_started') + BEGIN + INSERT INTO daily_rollup (day, epic_id, tasks_completed, tasks_failed, tasks_started) + VALUES (DATE(NEW.timestamp), NEW.epic_id, + CASE WHEN NEW.event_type = 'task_completed' THEN 1 ELSE 0 END, + CASE WHEN NEW.event_type = 'task_failed' THEN 1 ELSE 0 END, + CASE WHEN NEW.event_type = 'task_started' THEN 1 ELSE 0 END) + ON CONFLICT(day, epic_id) DO UPDATE SET + tasks_completed = tasks_completed + + CASE WHEN NEW.event_type = 'task_completed' THEN 1 ELSE 0 END, + tasks_failed = tasks_failed + + CASE WHEN NEW.event_type = 'task_failed' THEN 1 ELSE 0 END, + tasks_started = tasks_started + + CASE WHEN NEW.event_type = 'task_started' THEN 1 ELSE 0 END; + END;" + )?; + Ok(()) +} + +/// Clear all indexed (rebuildable) tables. +fn clear_indexed_tables(conn: &Connection) -> Result<(), DbError> { + conn.execute_batch( + "DELETE FROM file_ownership; + DELETE FROM task_deps; + DELETE FROM epic_deps; + DELETE FROM tasks; + DELETE FROM epics;", + )?; + Ok(()) +} + +/// Scan `.flow/epics/*.md`, parse frontmatter, insert into DB. +/// Returns a map of epic ID -> file path for duplicate detection. +fn index_epics( + conn: &Connection, + epics_dir: &Path, + result: &mut ReindexResult, +) -> Result, DbError> { + let repo = EpicRepo::new(conn); + let mut seen: HashMap = HashMap::new(); + + let entries = read_md_files(epics_dir); + + for path in entries { + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + let msg = format!("failed to read {}: {e}", path.display()); + warn!("{}", msg); + result.warnings.push(msg); + result.files_skipped += 1; + continue; + } + }; + + // Validate filename stem is a valid epic ID. + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !is_epic_id(stem) { + let msg = format!("skipping non-epic file: {}", path.display()); + warn!("{}", msg); + result.warnings.push(msg); + result.files_skipped += 1; + continue; + } + + let mut epic: Epic = match frontmatter::parse_frontmatter(&content) { + Ok(e) => e, + Err(e) => { + let msg = format!("invalid frontmatter in {}: {e}", path.display()); + warn!("{}", msg); + result.warnings.push(msg); + result.files_skipped += 1; + continue; + } + }; + + // Check for duplicate IDs. + if let Some(prev_path) = seen.get(&epic.id) { + return Err(DbError::Constraint(format!( + "duplicate epic ID '{}' in {} and {}", + epic.id, + prev_path.display(), + path.display() + ))); + } + + // Set the file_path to the relative path within .flow/. + epic.file_path = Some(format!("epics/{}", path.file_name().unwrap().to_string_lossy())); + + repo.upsert(&epic)?; + seen.insert(epic.id.clone(), path.clone()); + result.epics_indexed += 1; + } + + Ok(seen) +} + +/// Scan `.flow/tasks/*.md`, parse frontmatter, insert into DB. +fn index_tasks( + conn: &Connection, + tasks_dir: &Path, + indexed_epics: &HashMap, + result: &mut ReindexResult, +) -> Result<(), DbError> { + let task_repo = TaskRepo::new(conn); + let mut seen: HashMap = HashMap::new(); + + let entries = read_md_files(tasks_dir); + + for path in entries { + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + let msg = format!("failed to read {}: {e}", path.display()); + warn!("{}", msg); + result.warnings.push(msg); + result.files_skipped += 1; + continue; + } + }; + + // Validate filename stem is a valid task ID. + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !is_task_id(stem) { + let msg = format!("skipping non-task file: {}", path.display()); + warn!("{}", msg); + result.warnings.push(msg); + result.files_skipped += 1; + continue; + } + + let mut task: Task = match frontmatter::parse_frontmatter(&content) { + Ok(t) => t, + Err(e) => { + let msg = format!("invalid frontmatter in {}: {e}", path.display()); + warn!("{}", msg); + result.warnings.push(msg); + result.files_skipped += 1; + continue; + } + }; + + // Check for duplicate IDs. + if let Some(prev_path) = seen.get(&task.id) { + return Err(DbError::Constraint(format!( + "duplicate task ID '{}' in {} and {}", + task.id, + prev_path.display(), + path.display() + ))); + } + + // Warn about orphan tasks (referencing non-existent epic) but still index them. + if !indexed_epics.contains_key(&task.epic) { + let msg = format!( + "orphan task '{}' references non-existent epic '{}' (indexing anyway)", + task.id, task.epic + ); + warn!("{}", msg); + result.warnings.push(msg); + + // Insert a placeholder epic so FK constraint is satisfied. + insert_placeholder_epic(conn, &task.epic)?; + } + + // Set the file_path to the relative path within .flow/. + task.file_path = Some(format!("tasks/{}", path.file_name().unwrap().to_string_lossy())); + + task_repo.upsert(&task)?; + seen.insert(task.id.clone(), path.clone()); + result.tasks_indexed += 1; + } + + Ok(()) +} + +/// Insert a minimal placeholder epic for orphan task FK satisfaction. +fn insert_placeholder_epic(conn: &Connection, epic_id: &str) -> Result<(), DbError> { + conn.execute( + "INSERT OR IGNORE INTO epics (id, title, status, file_path, created_at, updated_at) + VALUES (?1, ?2, 'open', '', datetime('now'), datetime('now'))", + params![epic_id, format!("[placeholder] {}", epic_id)], + )?; + Ok(()) +} + +/// Migrate Python runtime state files from `flow-state/tasks/*.state.json` into +/// the `runtime_state` table. +/// +/// Each JSON file has the structure matching `RuntimeState` fields. +/// This migration is idempotent (INSERT OR REPLACE). +fn migrate_runtime_state( + conn: &Connection, + state_dir: &Path, + result: &mut ReindexResult, +) -> Result<(), DbError> { + let tasks_state_dir = state_dir.join("tasks"); + if !tasks_state_dir.is_dir() { + return Ok(()); + } + + let entries = match fs::read_dir(&tasks_state_dir) { + Ok(e) => e, + Err(_) => return Ok(()), + }; + + for entry in entries.flatten() { + let path = entry.path(); + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + + // Only process *.state.json files. + if !name.ends_with(".state.json") { + continue; + } + + // Extract task ID from filename: "fn-1-test.1.state.json" -> "fn-1-test.1" + let task_id = name.trim_end_matches(".state.json"); + if !is_task_id(task_id) { + continue; + } + + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + let msg = format!("failed to read runtime state {}: {e}", path.display()); + warn!("{}", msg); + result.warnings.push(msg); + continue; + } + }; + + let state: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + let msg = format!("invalid JSON in {}: {e}", path.display()); + warn!("{}", msg); + result.warnings.push(msg); + continue; + } + }; + + conn.execute( + "INSERT OR REPLACE INTO runtime_state + (task_id, assignee, claimed_at, completed_at, duration_secs, blocked_reason, baseline_rev, final_rev) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + task_id, + state.get("assignee").and_then(|v| v.as_str()), + state.get("claimed_at").and_then(|v| v.as_str()), + state.get("completed_at").and_then(|v| v.as_str()), + state.get("duration_secs").or_else(|| state.get("duration_seconds")).and_then(|v| v.as_i64()), + state.get("blocked_reason").and_then(|v| v.as_str()), + state.get("baseline_rev").and_then(|v| v.as_str()), + state.get("final_rev").and_then(|v| v.as_str()), + ], + )?; + + result.runtime_states_migrated += 1; + } + + Ok(()) +} + +/// Read all `.md` files in a directory, sorted by name for deterministic ordering. +fn read_md_files(dir: &Path) -> Vec { + let mut files: Vec = match fs::read_dir(dir) { + Ok(entries) => entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md")) + .collect(), + Err(_) => Vec::new(), + }; + files.sort(); + files +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pool::open_memory; + use std::io::Write; + use tempfile::TempDir; + + /// Create a temporary .flow/ directory with test fixtures. + fn setup_flow_dir() -> TempDir { + let tmp = TempDir::new().unwrap(); + let flow = tmp.path(); + fs::create_dir_all(flow.join("epics")).unwrap(); + fs::create_dir_all(flow.join("tasks")).unwrap(); + tmp + } + + fn write_file(dir: &Path, name: &str, content: &str) { + let path = dir.join(name); + let mut f = fs::File::create(&path).unwrap(); + f.write_all(content.as_bytes()).unwrap(); + } + + fn epic_md(id: &str, title: &str) -> String { + format!( + r#"--- +schema_version: 1 +id: {id} +title: {title} +status: open +plan_review: unknown +created_at: "2026-01-01T00:00:00Z" +updated_at: "2026-01-01T00:00:00Z" +--- +## Description +Test epic. +"# + ) + } + + fn task_md(id: &str, epic: &str, title: &str, deps: &[&str], files: &[&str]) -> String { + let deps_yaml = if deps.is_empty() { + String::new() + } else { + let items: Vec = deps.iter().map(|d| format!(" - {d}")).collect(); + format!("depends_on:\n{}\n", items.join("\n")) + }; + let files_yaml = if files.is_empty() { + String::new() + } else { + let items: Vec = files.iter().map(|f| format!(" - {f}")).collect(); + format!("files:\n{}\n", items.join("\n")) + }; + format!( + r#"--- +schema_version: 1 +id: {id} +epic: {epic} +title: {title} +status: todo +domain: general +{deps_yaml}{files_yaml}created_at: "2026-01-01T00:00:00Z" +updated_at: "2026-01-01T00:00:00Z" +--- +## Description +Test task. +"# + ) + } + + #[test] + fn test_reindex_basic() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + write_file(&flow.join("epics"), "fn-1-test.md", &epic_md("fn-1-test", "Test Epic")); + write_file( + &flow.join("tasks"), + "fn-1-test.1.md", + &task_md("fn-1-test.1", "fn-1-test", "Task One", &[], &["src/main.rs"]), + ); + write_file( + &flow.join("tasks"), + "fn-1-test.2.md", + &task_md("fn-1-test.2", "fn-1-test", "Task Two", &["fn-1-test.1"], &[]), + ); + + let result = reindex(&conn, flow, None).unwrap(); + assert_eq!(result.epics_indexed, 1); + assert_eq!(result.tasks_indexed, 2); + assert_eq!(result.files_skipped, 0); + + // Verify data in DB. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM epics", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM tasks", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 2); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM task_deps", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM file_ownership", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn test_reindex_idempotent() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + write_file(&flow.join("epics"), "fn-1-test.md", &epic_md("fn-1-test", "Test")); + write_file( + &flow.join("tasks"), + "fn-1-test.1.md", + &task_md("fn-1-test.1", "fn-1-test", "Task", &[], &[]), + ); + + let r1 = reindex(&conn, flow, None).unwrap(); + let r2 = reindex(&conn, flow, None).unwrap(); + + assert_eq!(r1.epics_indexed, r2.epics_indexed); + assert_eq!(r1.tasks_indexed, r2.tasks_indexed); + + // Should still have exactly 1 epic and 1 task. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM epics", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn test_reindex_invalid_frontmatter_skipped() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + write_file(&flow.join("epics"), "fn-1-test.md", &epic_md("fn-1-test", "Good Epic")); + write_file(&flow.join("epics"), "fn-2-bad.md", "not valid frontmatter at all"); + + let result = reindex(&conn, flow, None).unwrap(); + assert_eq!(result.epics_indexed, 1); + assert_eq!(result.files_skipped, 1); + assert!(!result.warnings.is_empty()); + } + + #[test] + fn test_reindex_non_task_files_skipped() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + write_file(&flow.join("epics"), "fn-1-test.md", &epic_md("fn-1-test", "Epic")); + // A .md file with a non-task filename in tasks dir. + write_file(&flow.join("tasks"), "notes.md", "just some notes"); + + let result = reindex(&conn, flow, None).unwrap(); + assert_eq!(result.epics_indexed, 1); + assert_eq!(result.tasks_indexed, 0); + assert_eq!(result.files_skipped, 1); + } + + #[test] + fn test_reindex_orphan_task_warns() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + // No epic file, but a task referencing it. + write_file( + &flow.join("tasks"), + "fn-1-test.1.md", + &task_md("fn-1-test.1", "fn-1-test", "Orphan Task", &[], &[]), + ); + + let result = reindex(&conn, flow, None).unwrap(); + assert_eq!(result.tasks_indexed, 1); + assert!(result.warnings.iter().any(|w| w.contains("orphan"))); + + // Placeholder epic should exist. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM epics WHERE id = 'fn-1-test'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn test_reindex_duplicate_epic_errors() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + // Two files with the same epic ID. + write_file(&flow.join("epics"), "fn-1-test.md", &epic_md("fn-1-test", "First")); + write_file(&flow.join("epics"), "fn-1-test-copy.md", &epic_md("fn-1-test", "Second")); + + let result = reindex(&conn, flow, None); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("duplicate epic ID"), "Got: {err}"); + } + + #[test] + fn test_reindex_duplicate_task_errors() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + write_file(&flow.join("epics"), "fn-1-test.md", &epic_md("fn-1-test", "Epic")); + write_file(&flow.join("epics"), "fn-2-other.md", &epic_md("fn-2-other", "Other")); + // Two files with different valid task-ID filenames but same ID in frontmatter. + write_file( + &flow.join("tasks"), + "fn-1-test.1.md", + &task_md("fn-1-test.1", "fn-1-test", "First", &[], &[]), + ); + write_file( + &flow.join("tasks"), + "fn-2-other.1.md", + &task_md("fn-1-test.1", "fn-2-other", "Second (dup ID)", &[], &[]), + ); + + let result = reindex(&conn, flow, None); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("duplicate task ID"), "Got: {err}"); + } + + #[test] + fn test_reindex_file_ownership() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + write_file(&flow.join("epics"), "fn-1-test.md", &epic_md("fn-1-test", "Epic")); + write_file( + &flow.join("tasks"), + "fn-1-test.1.md", + &task_md("fn-1-test.1", "fn-1-test", "Task", &[], &["src/a.rs", "src/b.rs"]), + ); + + let result = reindex(&conn, flow, None).unwrap(); + assert_eq!(result.tasks_indexed, 1); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM file_ownership", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 2); + } + + #[test] + fn test_reindex_empty_dirs() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + let result = reindex(&conn, flow, None).unwrap(); + assert_eq!(result.epics_indexed, 0); + assert_eq!(result.tasks_indexed, 0); + assert_eq!(result.files_skipped, 0); + } + + #[test] + fn test_reindex_missing_dirs() { + let conn = open_memory().unwrap(); + let tmp = TempDir::new().unwrap(); + // No epics/ or tasks/ subdirectories. + let result = reindex(&conn, tmp.path(), None).unwrap(); + assert_eq!(result.epics_indexed, 0); + assert_eq!(result.tasks_indexed, 0); + } + + #[test] + fn test_reindex_triggers_restored() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + write_file(&flow.join("epics"), "fn-1-test.md", &epic_md("fn-1-test", "Epic")); + reindex(&conn, flow, None).unwrap(); + + // Verify trigger exists after reindex. + let trigger_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name='trg_daily_rollup'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(trigger_count, 1); + } + + #[test] + fn test_migrate_runtime_state() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + let state_dir = TempDir::new().unwrap(); + + // Create epic and task first. + write_file(&flow.join("epics"), "fn-1-test.md", &epic_md("fn-1-test", "Epic")); + write_file( + &flow.join("tasks"), + "fn-1-test.1.md", + &task_md("fn-1-test.1", "fn-1-test", "Task", &[], &[]), + ); + + // Create runtime state file. + let tasks_state = state_dir.path().join("tasks"); + fs::create_dir_all(&tasks_state).unwrap(); + write_file( + &tasks_state, + "fn-1-test.1.state.json", + r#"{"assignee": "worker-1", "claimed_at": "2026-01-01T00:00:00Z", "duration_seconds": 120}"#, + ); + + let result = reindex(&conn, flow, Some(state_dir.path())).unwrap(); + assert_eq!(result.runtime_states_migrated, 1); + + let assignee: String = conn + .query_row( + "SELECT assignee FROM runtime_state WHERE task_id = 'fn-1-test.1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(assignee, "worker-1"); + } + + #[test] + fn test_reindex_epic_deps() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + let flow = tmp.path(); + + write_file(&flow.join("epics"), "fn-1-base.md", &epic_md("fn-1-base", "Base")); + write_file( + &flow.join("epics"), + "fn-2-next.md", + &format!( + r#"--- +schema_version: 1 +id: fn-2-next +title: Next +status: open +plan_review: unknown +depends_on_epics: + - fn-1-base +created_at: "2026-01-01T00:00:00Z" +updated_at: "2026-01-01T00:00:00Z" +--- +## Description +Depends on base. +"# + ), + ); + + let result = reindex(&conn, flow, None).unwrap(); + assert_eq!(result.epics_indexed, 2); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM epic_deps", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } +} diff --git a/flowctl/crates/flowctl-db/src/lib.rs b/flowctl/crates/flowctl-db/src/lib.rs new file mode 100644 index 00000000..fd98b411 --- /dev/null +++ b/flowctl/crates/flowctl-db/src/lib.rs @@ -0,0 +1,36 @@ +//! flowctl-db: SQLite storage layer for flowctl. +//! +//! Provides connection management, repository abstractions, indexing, +//! and schema migrations for the `.flow/.state/flowctl.db` database. +//! +//! # Architecture +//! +//! - **Markdown is canonical, SQLite is cache.** The `flowctl reindex` +//! command can fully rebuild the indexed tables from Markdown frontmatter. +//! Runtime-only data (locks, heartbeats, events, metrics) is not recoverable. +//! +//! - **PRAGMAs are per-connection**, not in migration files. WAL mode, +//! busy_timeout, and foreign_keys are set on every connection open. +//! +//! - **State directory**: resolved via `git rev-parse --git-common-dir` +//! so worktrees share a single database file. + +pub mod error; +pub mod events; +pub mod indexer; +pub mod metrics; +pub mod migration; +pub mod pool; +pub mod repo; +pub mod sync; + +pub use error::DbError; +pub use pool::{cleanup, open, open_memory, resolve_db_path, resolve_state_dir}; +pub use indexer::{reindex, ReindexResult}; +pub use migration::{migrate_runtime_state, needs_reindex, has_legacy_state, MigrationResult}; +pub use repo::{EpicRepo, EvidenceRepo, EventRepo, EventRow, FileLockRepo, PhaseProgressRepo, RuntimeRepo, TaskRepo}; +pub use events::EventLog; +pub use metrics::StatsQuery; +pub use sync::{write_epic, write_task, write_task_with_legacy, check_staleness, refresh_if_stale, retry_pending, SyncStatus}; + +pub use flowctl_core; diff --git a/flowctl/crates/flowctl-db/src/metrics.rs b/flowctl/crates/flowctl-db/src/metrics.rs new file mode 100644 index 00000000..06f96aa0 --- /dev/null +++ b/flowctl/crates/flowctl-db/src/metrics.rs @@ -0,0 +1,494 @@ +//! Stats queries: summary, per-epic, weekly trends, token/cost analysis, +//! bottleneck analysis, DORA metrics, and monthly rollup generation. + +use rusqlite::{params, Connection}; +use serde::Serialize; + +use crate::error::DbError; + +/// Overall summary stats. +#[derive(Debug, Serialize)] +pub struct Summary { + pub total_epics: i64, + pub open_epics: i64, + pub total_tasks: i64, + pub done_tasks: i64, + pub in_progress_tasks: i64, + pub blocked_tasks: i64, + pub total_events: i64, + pub total_tokens: i64, + pub total_cost_usd: f64, +} + +/// Per-epic stats row. +#[derive(Debug, Serialize)] +pub struct EpicStats { + pub epic_id: String, + pub title: String, + pub status: String, + pub task_count: i64, + pub done_count: i64, + pub avg_duration_secs: Option, + pub total_tokens: i64, + pub total_cost: f64, +} + +/// Weekly trend data point. +#[derive(Debug, Serialize)] +pub struct WeeklyTrend { + pub week: String, + pub tasks_started: i64, + pub tasks_completed: i64, + pub tasks_failed: i64, +} + +/// Token usage breakdown. +#[derive(Debug, Serialize)] +pub struct TokenBreakdown { + pub epic_id: String, + pub model: String, + pub input_tokens: i64, + pub output_tokens: i64, + pub cache_read: i64, + pub cache_write: i64, + pub estimated_cost: f64, +} + +/// Bottleneck: tasks that took longest or were blocked. +#[derive(Debug, Serialize)] +pub struct Bottleneck { + pub task_id: String, + pub epic_id: String, + pub title: String, + pub duration_secs: Option, + pub status: String, + pub blocked_reason: Option, +} + +/// DORA metrics. +#[derive(Debug, Serialize)] +pub struct DoraMetrics { + /// Average hours from task creation to completion (last 30 days). + pub lead_time_hours: Option, + /// Tasks completed per week (last 4 weeks average). + pub throughput_per_week: f64, + /// Ratio of failed tasks to total completed (last 30 days). + pub change_failure_rate: f64, + /// Average hours from block to unblock (last 30 days). + pub time_to_restore_hours: Option, +} + +/// Stats query engine. +pub struct StatsQuery<'a> { + conn: &'a Connection, +} + +impl<'a> StatsQuery<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Overall summary across all epics. + pub fn summary(&self) -> Result { + let total_epics: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM epics", [], |row| row.get(0), + )?; + let open_epics: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM epics WHERE status = 'open'", [], |row| row.get(0), + )?; + let total_tasks: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM tasks", [], |row| row.get(0), + )?; + let done_tasks: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM tasks WHERE status = 'done'", [], |row| row.get(0), + )?; + let in_progress_tasks: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM tasks WHERE status = 'in_progress'", [], |row| row.get(0), + )?; + let blocked_tasks: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM tasks WHERE status = 'blocked'", [], |row| row.get(0), + )?; + let total_events: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM events", [], |row| row.get(0), + )?; + let total_tokens: i64 = self.conn.query_row( + "SELECT COALESCE(SUM(input_tokens + output_tokens), 0) FROM token_usage", [], |row| row.get(0), + )?; + let total_cost_usd: f64 = self.conn.query_row( + "SELECT COALESCE(SUM(estimated_cost), 0.0) FROM token_usage", [], |row| row.get(0), + )?; + + Ok(Summary { + total_epics, + open_epics, + total_tasks, + done_tasks, + in_progress_tasks, + blocked_tasks, + total_events, + total_tokens, + total_cost_usd, + }) + } + + /// Per-epic stats. + pub fn per_epic(&self, epic_id: Option<&str>) -> Result, DbError> { + let (sql, filter) = match epic_id { + Some(id) => ( + "SELECT e.id, e.title, e.status, + (SELECT COUNT(*) FROM tasks t WHERE t.epic_id = e.id), + (SELECT COUNT(*) FROM tasks t WHERE t.epic_id = e.id AND t.status = 'done'), + (SELECT AVG(rs.duration_secs) FROM runtime_state rs + JOIN tasks t ON t.id = rs.task_id WHERE t.epic_id = e.id AND rs.duration_secs IS NOT NULL), + COALESCE((SELECT SUM(tu.input_tokens + tu.output_tokens) FROM token_usage tu WHERE tu.epic_id = e.id), 0), + COALESCE((SELECT SUM(tu.estimated_cost) FROM token_usage tu WHERE tu.epic_id = e.id), 0.0) + FROM epics e WHERE e.id = ?1", + Some(id.to_string()), + ), + None => ( + "SELECT e.id, e.title, e.status, + (SELECT COUNT(*) FROM tasks t WHERE t.epic_id = e.id), + (SELECT COUNT(*) FROM tasks t WHERE t.epic_id = e.id AND t.status = 'done'), + (SELECT AVG(rs.duration_secs) FROM runtime_state rs + JOIN tasks t ON t.id = rs.task_id WHERE t.epic_id = e.id AND rs.duration_secs IS NOT NULL), + COALESCE((SELECT SUM(tu.input_tokens + tu.output_tokens) FROM token_usage tu WHERE tu.epic_id = e.id), 0), + COALESCE((SELECT SUM(tu.estimated_cost) FROM token_usage tu WHERE tu.epic_id = e.id), 0.0) + FROM epics e ORDER BY e.created_at", + None, + ), + }; + + let mut stmt = self.conn.prepare(sql)?; + let rows = if let Some(ref id) = filter { + stmt.query_map(params![id], map_epic_stats)? + .collect::, _>>()? + } else { + stmt.query_map([], map_epic_stats)? + .collect::, _>>()? + }; + Ok(rows) + } + + /// Weekly trends from daily_rollup (last N weeks). + pub fn weekly_trends(&self, weeks: u32) -> Result, DbError> { + let mut stmt = self.conn.prepare( + "SELECT strftime('%Y-W%W', day) AS week, + SUM(tasks_started), SUM(tasks_completed), SUM(tasks_failed) + FROM daily_rollup + WHERE day >= strftime('%Y-%m-%d', 'now', ?1) + GROUP BY week ORDER BY week", + )?; + + let offset = format!("-{} days", weeks * 7); + let rows = stmt + .query_map(params![offset], |row| { + Ok(WeeklyTrend { + week: row.get(0)?, + tasks_started: row.get::<_, Option>(1)?.unwrap_or(0), + tasks_completed: row.get::<_, Option>(2)?.unwrap_or(0), + tasks_failed: row.get::<_, Option>(3)?.unwrap_or(0), + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + /// Token/cost breakdown by epic and model. + pub fn token_breakdown(&self, epic_id: Option<&str>) -> Result, DbError> { + let (sql, filter) = match epic_id { + Some(id) => ( + "SELECT epic_id, COALESCE(model, 'unknown'), SUM(input_tokens), SUM(output_tokens), + SUM(cache_read), SUM(cache_write), SUM(estimated_cost) + FROM token_usage WHERE epic_id = ?1 + GROUP BY epic_id, model ORDER BY SUM(estimated_cost) DESC", + Some(id.to_string()), + ), + None => ( + "SELECT epic_id, COALESCE(model, 'unknown'), SUM(input_tokens), SUM(output_tokens), + SUM(cache_read), SUM(cache_write), SUM(estimated_cost) + FROM token_usage + GROUP BY epic_id, model ORDER BY SUM(estimated_cost) DESC", + None, + ), + }; + + let mut stmt = self.conn.prepare(sql)?; + let rows = if let Some(ref id) = filter { + stmt.query_map(params![id], map_token_breakdown)? + .collect::, _>>()? + } else { + stmt.query_map([], map_token_breakdown)? + .collect::, _>>()? + }; + Ok(rows) + } + + /// Bottleneck analysis: longest-running and blocked tasks. + pub fn bottlenecks(&self, limit: usize) -> Result, DbError> { + let mut stmt = self.conn.prepare( + "SELECT t.id, t.epic_id, t.title, rs.duration_secs, t.status, rs.blocked_reason + FROM tasks t + LEFT JOIN runtime_state rs ON rs.task_id = t.id + WHERE t.status IN ('done', 'blocked', 'in_progress') + ORDER BY + CASE WHEN t.status = 'blocked' THEN 0 ELSE 1 END, + rs.duration_secs DESC NULLS LAST + LIMIT ?1", + )?; + + let rows = stmt + .query_map(params![limit as i64], |row| { + Ok(Bottleneck { + task_id: row.get(0)?, + epic_id: row.get(1)?, + title: row.get(2)?, + duration_secs: row.get(3)?, + status: row.get(4)?, + blocked_reason: row.get(5)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + /// DORA-style metrics computed from events and runtime state. + pub fn dora_metrics(&self) -> Result { + // Lead time: avg seconds from task creation to completion (last 30 days) + let lead_time_secs: Option = self.conn.query_row( + "SELECT AVG(rs.duration_secs) + FROM runtime_state rs + WHERE rs.completed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 days') + AND rs.duration_secs IS NOT NULL", + [], + |row| row.get(0), + ).unwrap_or(None); + + // Throughput: tasks completed in last 28 days / 4 + let completed_28d: f64 = self.conn.query_row( + "SELECT CAST(COUNT(*) AS REAL) FROM runtime_state + WHERE completed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-28 days') + AND completed_at IS NOT NULL", + [], + |row| row.get(0), + ).unwrap_or(0.0); + + // Change failure rate: task_failed / (task_completed + task_failed) in last 30 days + let (completed_30d, failed_30d): (f64, f64) = self.conn.query_row( + "SELECT + COALESCE(SUM(CASE WHEN event_type = 'task_completed' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN event_type = 'task_failed' THEN 1 ELSE 0 END), 0) + FROM events + WHERE timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 days') + AND event_type IN ('task_completed', 'task_failed')", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ).unwrap_or((0.0, 0.0)); + + let change_failure_rate = if (completed_30d + failed_30d) > 0.0 { + failed_30d / (completed_30d + failed_30d) + } else { + 0.0 + }; + + // Time to restore: avg hours blocked tasks spent blocked + // Approximated from events: time between task_blocked and task_started (resume) + // Simplified: count blocked tasks with duration in runtime_state + let ttr_secs: Option = self.conn.query_row( + "SELECT AVG(CAST( + (julianday(rs.completed_at) - julianday(rs.claimed_at)) * 86400 AS REAL + )) + FROM runtime_state rs + WHERE rs.blocked_reason IS NOT NULL + AND rs.completed_at IS NOT NULL + AND rs.claimed_at IS NOT NULL", + [], + |row| row.get(0), + ).unwrap_or(None); + + Ok(DoraMetrics { + lead_time_hours: lead_time_secs.map(|s| s / 3600.0), + throughput_per_week: completed_28d / 4.0, + change_failure_rate, + time_to_restore_hours: ttr_secs.map(|s| s / 3600.0), + }) + } + + /// Generate monthly rollup for any months that have daily_rollup data but no monthly entry. + pub fn generate_monthly_rollups(&self) -> Result { + let rows = self.conn.execute( + "INSERT OR REPLACE INTO monthly_rollup (month, epics_completed, tasks_completed, avg_lead_time_h, total_tokens, total_cost_usd) + SELECT + strftime('%Y-%m', day) AS month, + COALESCE((SELECT COUNT(*) FROM epics e WHERE e.status = 'done' + AND strftime('%Y-%m', e.updated_at) = strftime('%Y-%m', dr.day)), 0), + SUM(dr.tasks_completed), + COALESCE((SELECT AVG(rs.duration_secs) / 3600.0 FROM runtime_state rs + WHERE rs.completed_at IS NOT NULL + AND strftime('%Y-%m', rs.completed_at) = strftime('%Y-%m', dr.day)), 0), + COALESCE((SELECT SUM(tu.input_tokens + tu.output_tokens) FROM token_usage tu + WHERE strftime('%Y-%m', tu.timestamp) = strftime('%Y-%m', dr.day)), 0), + COALESCE((SELECT SUM(tu.estimated_cost) FROM token_usage tu + WHERE strftime('%Y-%m', tu.timestamp) = strftime('%Y-%m', dr.day)), 0.0) + FROM daily_rollup dr + GROUP BY strftime('%Y-%m', day)", + [], + )?; + Ok(rows) + } +} + +fn map_epic_stats(row: &rusqlite::Row) -> rusqlite::Result { + Ok(EpicStats { + epic_id: row.get(0)?, + title: row.get(1)?, + status: row.get(2)?, + task_count: row.get(3)?, + done_count: row.get(4)?, + avg_duration_secs: row.get(5)?, + total_tokens: row.get::<_, Option>(6)?.unwrap_or(0), + total_cost: row.get::<_, Option>(7)?.unwrap_or(0.0), + }) +} + +fn map_token_breakdown(row: &rusqlite::Row) -> rusqlite::Result { + Ok(TokenBreakdown { + epic_id: row.get(0)?, + model: row.get(1)?, + input_tokens: row.get::<_, Option>(2)?.unwrap_or(0), + output_tokens: row.get::<_, Option>(3)?.unwrap_or(0), + cache_read: row.get::<_, Option>(4)?.unwrap_or(0), + cache_write: row.get::<_, Option>(5)?.unwrap_or(0), + estimated_cost: row.get::<_, Option>(6)?.unwrap_or(0.0), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pool::open_memory; + use crate::repo::EventRepo; + + fn setup() -> Connection { + let conn = open_memory().expect("in-memory db"); + // Insert test epic + conn.execute( + "INSERT INTO epics (id, title, status, file_path, created_at, updated_at) + VALUES ('fn-1-test', 'Test Epic', 'open', 'e.md', '2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z')", + [], + ).unwrap(); + // Insert test tasks + conn.execute( + "INSERT INTO tasks (id, epic_id, title, status, file_path, created_at, updated_at) + VALUES ('fn-1-test.1', 'fn-1-test', 'Task 1', 'done', 't1.md', '2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO tasks (id, epic_id, title, status, file_path, created_at, updated_at) + VALUES ('fn-1-test.2', 'fn-1-test', 'Task 2', 'in_progress', 't2.md', '2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z')", + [], + ).unwrap(); + conn + } + + #[test] + fn test_summary() { + let conn = setup(); + let stats = StatsQuery::new(&conn); + let s = stats.summary().unwrap(); + assert_eq!(s.total_epics, 1); + assert_eq!(s.total_tasks, 2); + assert_eq!(s.done_tasks, 1); + assert_eq!(s.in_progress_tasks, 1); + } + + #[test] + fn test_per_epic() { + let conn = setup(); + let stats = StatsQuery::new(&conn); + let epics = stats.per_epic(None).unwrap(); + assert_eq!(epics.len(), 1); + assert_eq!(epics[0].task_count, 2); + assert_eq!(epics[0].done_count, 1); + } + + #[test] + fn test_per_epic_filtered() { + let conn = setup(); + let stats = StatsQuery::new(&conn); + let epics = stats.per_epic(Some("fn-1-test")).unwrap(); + assert_eq!(epics.len(), 1); + assert_eq!(epics[0].epic_id, "fn-1-test"); + } + + #[test] + fn test_weekly_trends() { + let conn = setup(); + // Insert events to trigger daily_rollup + let repo = EventRepo::new(&conn); + repo.insert("fn-1-test", Some("fn-1-test.1"), "task_started", None, None, None).unwrap(); + repo.insert("fn-1-test", Some("fn-1-test.1"), "task_completed", None, None, None).unwrap(); + + let stats = StatsQuery::new(&conn); + let trends = stats.weekly_trends(4).unwrap(); + // Should have at least one week with data + assert!(!trends.is_empty()); + assert!(trends[0].tasks_started > 0); + } + + #[test] + fn test_token_breakdown() { + let conn = setup(); + conn.execute( + "INSERT INTO token_usage (epic_id, task_id, model, input_tokens, output_tokens, estimated_cost) + VALUES ('fn-1-test', 'fn-1-test.1', 'claude-sonnet-4-20250514', 1000, 500, 0.01)", + [], + ).unwrap(); + + let stats = StatsQuery::new(&conn); + let tokens = stats.token_breakdown(None).unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].input_tokens, 1000); + assert_eq!(tokens[0].output_tokens, 500); + } + + #[test] + fn test_bottlenecks() { + let conn = setup(); + conn.execute( + "INSERT INTO runtime_state (task_id, duration_secs) VALUES ('fn-1-test.1', 3600)", + [], + ).unwrap(); + + let stats = StatsQuery::new(&conn); + let bottlenecks = stats.bottlenecks(10).unwrap(); + assert!(!bottlenecks.is_empty()); + assert_eq!(bottlenecks[0].task_id, "fn-1-test.1"); + } + + #[test] + fn test_dora_metrics() { + let conn = setup(); + let stats = StatsQuery::new(&conn); + let dora = stats.dora_metrics().unwrap(); + // Fresh DB, no completions in last 30 days + assert_eq!(dora.throughput_per_week, 0.0); + assert_eq!(dora.change_failure_rate, 0.0); + } + + #[test] + fn test_generate_monthly_rollups() { + let conn = setup(); + let repo = EventRepo::new(&conn); + repo.insert("fn-1-test", Some("fn-1-test.1"), "task_completed", None, None, None).unwrap(); + + let stats = StatsQuery::new(&conn); + let count = stats.generate_monthly_rollups().unwrap(); + assert!(count > 0); + + // Verify monthly_rollup has data + let tasks_completed: i64 = conn.query_row( + "SELECT COALESCE(SUM(tasks_completed), 0) FROM monthly_rollup", [], |row| row.get(0), + ).unwrap(); + assert!(tasks_completed > 0); + } +} diff --git a/flowctl/crates/flowctl-db/src/migration.rs b/flowctl/crates/flowctl-db/src/migration.rs new file mode 100644 index 00000000..06b46ef6 --- /dev/null +++ b/flowctl/crates/flowctl-db/src/migration.rs @@ -0,0 +1,365 @@ +//! Runtime state migration from Python's JSON files to SQLite. +//! +//! Reads Python runtime state from `git-common-dir/flow-state/tasks/*.state.json` +//! and inserts into the `runtime_state` table. Also provides auto-detection +//! of missing SQLite databases when `.flow/` JSON files exist. + +use std::fs; +use std::path::Path; + +use rusqlite::{params, Connection}; +use tracing::{info, warn}; + +use flowctl_core::id::is_task_id; + +use crate::error::DbError; + +/// Result of a migration operation. +#[derive(Debug, Default)] +pub struct MigrationResult { + /// Number of runtime state files migrated. + pub states_migrated: usize, + /// Files that could not be migrated. + pub files_skipped: usize, + /// Warnings collected during migration. + pub warnings: Vec, +} + +/// Migrate Python runtime state files into SQLite. +/// +/// Reads `{state_dir}/tasks/*.state.json` files and inserts/replaces +/// rows in the `runtime_state` table. This is idempotent. +/// +/// # Arguments +/// * `conn` - Open database connection +/// * `state_dir` - Path to the state directory (e.g., `git-common-dir/flow-state/`) +pub fn migrate_runtime_state( + conn: &Connection, + state_dir: &Path, +) -> Result { + let mut result = MigrationResult::default(); + + let tasks_state_dir = state_dir.join("tasks"); + if !tasks_state_dir.is_dir() { + return Ok(result); + } + + let entries = match fs::read_dir(&tasks_state_dir) { + Ok(e) => e, + Err(_) => return Ok(result), + }; + + for entry in entries.flatten() { + let path = entry.path(); + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + + // Only process *.state.json files. + if !name.ends_with(".state.json") { + continue; + } + + // Extract task ID: "fn-1-test.1.state.json" -> "fn-1-test.1" + let task_id = name.trim_end_matches(".state.json"); + if !is_task_id(task_id) { + let msg = format!("skipping non-task state file: {name}"); + warn!("{}", msg); + result.warnings.push(msg); + result.files_skipped += 1; + continue; + } + + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + let msg = format!("failed to read {}: {e}", path.display()); + warn!("{}", msg); + result.warnings.push(msg); + result.files_skipped += 1; + continue; + } + }; + + let state: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + let msg = format!("invalid JSON in {}: {e}", path.display()); + warn!("{}", msg); + result.warnings.push(msg); + result.files_skipped += 1; + continue; + } + }; + + conn.execute( + "INSERT OR REPLACE INTO runtime_state + (task_id, assignee, claimed_at, completed_at, duration_secs, blocked_reason, baseline_rev, final_rev) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + task_id, + state.get("assignee").and_then(|v| v.as_str()), + state.get("claimed_at").and_then(|v| v.as_str()), + state.get("completed_at").and_then(|v| v.as_str()), + state + .get("duration_secs") + .or_else(|| state.get("duration_seconds")) + .and_then(|v| v.as_i64()), + state.get("blocked_reason").and_then(|v| v.as_str()), + state.get("baseline_rev").and_then(|v| v.as_str()), + state.get("final_rev").and_then(|v| v.as_str()), + ], + )?; + + result.states_migrated += 1; + } + + info!( + migrated = result.states_migrated, + skipped = result.files_skipped, + "runtime state migration complete" + ); + + Ok(result) +} + +/// Check if a reindex is needed: SQLite DB is missing but `.flow/` has data. +/// +/// Returns `true` if `.flow/epics/` or `.flow/tasks/` contain `.md` files +/// but the SQLite database does not exist at the expected path. +pub fn needs_reindex(flow_dir: &Path, db_path: &Path) -> bool { + if db_path.exists() { + return false; + } + + has_md_files(&flow_dir.join("epics")) || has_md_files(&flow_dir.join("tasks")) +} + +/// Check if a directory contains `.flow/` JSON state files that indicate +/// a Python runtime was in use. +/// +/// Returns `true` if `{state_dir}/tasks/*.state.json` files exist. +pub fn has_legacy_state(state_dir: &Path) -> bool { + let tasks_dir = state_dir.join("tasks"); + if !tasks_dir.is_dir() { + return false; + } + + match fs::read_dir(&tasks_dir) { + Ok(entries) => entries + .flatten() + .any(|e| { + e.path() + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(".state.json")) + .unwrap_or(false) + }), + Err(_) => false, + } +} + +/// Check if a directory contains any `.md` files. +fn has_md_files(dir: &Path) -> bool { + if !dir.is_dir() { + return false; + } + match fs::read_dir(dir) { + Ok(entries) => entries + .flatten() + .any(|e| { + e.path() + .extension() + .and_then(|ext| ext.to_str()) + == Some("md") + }), + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pool::open_memory; + use std::io::Write; + use tempfile::TempDir; + + fn write_file(dir: &Path, name: &str, content: &str) { + let path = dir.join(name); + let mut f = fs::File::create(&path).unwrap(); + f.write_all(content.as_bytes()).unwrap(); + } + + #[test] + fn test_migrate_runtime_state_basic() { + let conn = open_memory().unwrap(); + let state_dir = TempDir::new().unwrap(); + + let tasks_dir = state_dir.path().join("tasks"); + fs::create_dir_all(&tasks_dir).unwrap(); + + write_file( + &tasks_dir, + "fn-1-test.1.state.json", + r#"{ + "assignee": "worker-1", + "claimed_at": "2026-01-01T00:00:00Z", + "duration_seconds": 120, + "baseline_rev": "abc123" + }"#, + ); + + let result = migrate_runtime_state(&conn, state_dir.path()).unwrap(); + assert_eq!(result.states_migrated, 1); + assert_eq!(result.files_skipped, 0); + + // Verify data in DB. + let assignee: String = conn + .query_row( + "SELECT assignee FROM runtime_state WHERE task_id = 'fn-1-test.1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(assignee, "worker-1"); + + let duration: i64 = conn + .query_row( + "SELECT duration_secs FROM runtime_state WHERE task_id = 'fn-1-test.1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(duration, 120); + } + + #[test] + fn test_migrate_runtime_state_invalid_json() { + let conn = open_memory().unwrap(); + let state_dir = TempDir::new().unwrap(); + + let tasks_dir = state_dir.path().join("tasks"); + fs::create_dir_all(&tasks_dir).unwrap(); + + write_file(&tasks_dir, "fn-1-test.1.state.json", "not json"); + + let result = migrate_runtime_state(&conn, state_dir.path()).unwrap(); + assert_eq!(result.states_migrated, 0); + assert_eq!(result.files_skipped, 1); + assert!(!result.warnings.is_empty()); + } + + #[test] + fn test_migrate_runtime_state_idempotent() { + let conn = open_memory().unwrap(); + let state_dir = TempDir::new().unwrap(); + + let tasks_dir = state_dir.path().join("tasks"); + fs::create_dir_all(&tasks_dir).unwrap(); + + write_file( + &tasks_dir, + "fn-1-test.1.state.json", + r#"{"assignee": "worker-1"}"#, + ); + + let r1 = migrate_runtime_state(&conn, state_dir.path()).unwrap(); + let r2 = migrate_runtime_state(&conn, state_dir.path()).unwrap(); + assert_eq!(r1.states_migrated, 1); + assert_eq!(r2.states_migrated, 1); + + // Only one row in DB. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM runtime_state", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn test_migrate_runtime_state_no_state_dir() { + let conn = open_memory().unwrap(); + let tmp = TempDir::new().unwrap(); + + // No tasks/ subdirectory. + let result = migrate_runtime_state(&conn, tmp.path()).unwrap(); + assert_eq!(result.states_migrated, 0); + } + + #[test] + fn test_needs_reindex_no_db_with_md() { + let tmp = TempDir::new().unwrap(); + let flow_dir = tmp.path(); + let db_path = tmp.path().join("nonexistent.db"); + + // Create .flow/epics/ with an MD file. + let epics_dir = flow_dir.join("epics"); + fs::create_dir_all(&epics_dir).unwrap(); + write_file(&epics_dir, "fn-1-test.md", "---\nid: test\n---\n"); + + assert!(needs_reindex(flow_dir, &db_path)); + } + + #[test] + fn test_needs_reindex_db_exists() { + let tmp = TempDir::new().unwrap(); + let flow_dir = tmp.path(); + + // Create a fake DB file. + let db_path = tmp.path().join("flowctl.db"); + write_file(tmp.path(), "flowctl.db", ""); + + // Even with MD files, should return false since DB exists. + let epics_dir = flow_dir.join("epics"); + fs::create_dir_all(&epics_dir).unwrap(); + write_file(&epics_dir, "fn-1-test.md", "---\nid: test\n---\n"); + + assert!(!needs_reindex(flow_dir, &db_path)); + } + + #[test] + fn test_needs_reindex_no_md_files() { + let tmp = TempDir::new().unwrap(); + let flow_dir = tmp.path(); + let db_path = tmp.path().join("nonexistent.db"); + + // Empty directories. + fs::create_dir_all(flow_dir.join("epics")).unwrap(); + fs::create_dir_all(flow_dir.join("tasks")).unwrap(); + + assert!(!needs_reindex(flow_dir, &db_path)); + } + + #[test] + fn test_has_legacy_state() { + let tmp = TempDir::new().unwrap(); + + // No tasks dir. + assert!(!has_legacy_state(tmp.path())); + + // Empty tasks dir. + let tasks_dir = tmp.path().join("tasks"); + fs::create_dir_all(&tasks_dir).unwrap(); + assert!(!has_legacy_state(tmp.path())); + + // With a state file. + write_file(&tasks_dir, "fn-1-test.1.state.json", "{}"); + assert!(has_legacy_state(tmp.path())); + } + + #[test] + fn test_migrate_skips_non_task_ids() { + let conn = open_memory().unwrap(); + let state_dir = TempDir::new().unwrap(); + + let tasks_dir = state_dir.path().join("tasks"); + fs::create_dir_all(&tasks_dir).unwrap(); + + // Not a valid task ID (no dot-number suffix). + write_file(&tasks_dir, "notes.state.json", r#"{"assignee": "x"}"#); + + let result = migrate_runtime_state(&conn, state_dir.path()).unwrap(); + assert_eq!(result.states_migrated, 0); + assert_eq!(result.files_skipped, 1); + } +} diff --git a/flowctl/crates/flowctl-db/src/migrations/01-initial/down.sql b/flowctl/crates/flowctl-db/src/migrations/01-initial/down.sql new file mode 100644 index 00000000..2d3df655 --- /dev/null +++ b/flowctl/crates/flowctl-db/src/migrations/01-initial/down.sql @@ -0,0 +1,15 @@ +DROP TRIGGER IF EXISTS trg_daily_rollup; +DROP TABLE IF EXISTS monthly_rollup; +DROP TABLE IF EXISTS daily_rollup; +DROP TABLE IF EXISTS token_usage; +DROP TABLE IF EXISTS events; +DROP TABLE IF EXISTS evidence; +DROP TABLE IF EXISTS phase_progress; +DROP TABLE IF EXISTS heartbeats; +DROP TABLE IF EXISTS file_locks; +DROP TABLE IF EXISTS runtime_state; +DROP TABLE IF EXISTS file_ownership; +DROP TABLE IF EXISTS epic_deps; +DROP TABLE IF EXISTS task_deps; +DROP TABLE IF EXISTS tasks; +DROP TABLE IF EXISTS epics; diff --git a/flowctl/crates/flowctl-db/src/migrations/01-initial/up.sql b/flowctl/crates/flowctl-db/src/migrations/01-initial/up.sql new file mode 100644 index 00000000..8d3ea03d --- /dev/null +++ b/flowctl/crates/flowctl-db/src/migrations/01-initial/up.sql @@ -0,0 +1,162 @@ +-- flowctl initial schema +-- Indexed from Markdown frontmatter (rebuildable via reindex) + +CREATE TABLE epics ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + branch_name TEXT, + plan_review TEXT DEFAULT 'unknown', + file_path TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + epic_id TEXT NOT NULL REFERENCES epics(id), + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'todo', + priority INTEGER DEFAULT 999, + domain TEXT DEFAULT 'general', + file_path TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE task_deps ( + task_id TEXT NOT NULL, + depends_on TEXT NOT NULL, + PRIMARY KEY (task_id, depends_on) +); + +CREATE TABLE epic_deps ( + epic_id TEXT NOT NULL, + depends_on TEXT NOT NULL, + PRIMARY KEY (epic_id, depends_on) +); + +CREATE TABLE file_ownership ( + file_path TEXT NOT NULL, + task_id TEXT NOT NULL, + PRIMARY KEY (file_path, task_id) +); + +-- Runtime-only data (not in Markdown, not rebuildable) + +CREATE TABLE runtime_state ( + task_id TEXT PRIMARY KEY, + assignee TEXT, + claimed_at TEXT, + completed_at TEXT, + duration_secs INTEGER, + blocked_reason TEXT, + baseline_rev TEXT, + final_rev TEXT +); + +CREATE TABLE file_locks ( + file_path TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + locked_at TEXT NOT NULL +); + +CREATE TABLE heartbeats ( + task_id TEXT PRIMARY KEY, + last_beat TEXT NOT NULL, + worker_pid INTEGER +); + +CREATE TABLE phase_progress ( + task_id TEXT NOT NULL, + phase TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + completed_at TEXT, + PRIMARY KEY (task_id, phase) +); + +CREATE TABLE evidence ( + task_id TEXT PRIMARY KEY, + commits TEXT, + tests TEXT, + files_changed INTEGER, + insertions INTEGER, + deletions INTEGER, + review_iters INTEGER +); + +-- Event log + metrics (append-only, runtime-only) + +CREATE TABLE events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + epic_id TEXT NOT NULL, + task_id TEXT, + event_type TEXT NOT NULL, + actor TEXT, + payload TEXT, + session_id TEXT +); + +CREATE TABLE token_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + epic_id TEXT NOT NULL, + task_id TEXT, + phase TEXT, + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read INTEGER DEFAULT 0, + cache_write INTEGER DEFAULT 0, + estimated_cost REAL +); + +CREATE TABLE daily_rollup ( + day TEXT NOT NULL, + epic_id TEXT, + tasks_started INTEGER DEFAULT 0, + tasks_completed INTEGER DEFAULT 0, + tasks_failed INTEGER DEFAULT 0, + total_duration_s INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + PRIMARY KEY (day, epic_id) +); + +CREATE TABLE monthly_rollup ( + month TEXT PRIMARY KEY, + epics_completed INTEGER DEFAULT 0, + tasks_completed INTEGER DEFAULT 0, + avg_lead_time_h REAL DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + total_cost_usd REAL DEFAULT 0 +); + +-- Indexes + +CREATE INDEX idx_tasks_epic ON tasks(epic_id); +CREATE INDEX idx_tasks_status ON tasks(status); +CREATE INDEX idx_events_entity ON events(epic_id, task_id); +CREATE INDEX idx_events_ts ON events(timestamp); +CREATE INDEX idx_events_type ON events(event_type, timestamp); +CREATE INDEX idx_token_epic ON token_usage(epic_id); + +-- Auto-aggregation trigger: roll up task events into daily_rollup + +CREATE TRIGGER trg_daily_rollup AFTER INSERT ON events +WHEN NEW.event_type IN ('task_completed', 'task_failed', 'task_started') +BEGIN + INSERT INTO daily_rollup (day, epic_id, tasks_completed, tasks_failed, tasks_started) + VALUES (DATE(NEW.timestamp), NEW.epic_id, + CASE WHEN NEW.event_type = 'task_completed' THEN 1 ELSE 0 END, + CASE WHEN NEW.event_type = 'task_failed' THEN 1 ELSE 0 END, + CASE WHEN NEW.event_type = 'task_started' THEN 1 ELSE 0 END) + ON CONFLICT(day, epic_id) DO UPDATE SET + tasks_completed = tasks_completed + + CASE WHEN NEW.event_type = 'task_completed' THEN 1 ELSE 0 END, + tasks_failed = tasks_failed + + CASE WHEN NEW.event_type = 'task_failed' THEN 1 ELSE 0 END, + tasks_started = tasks_started + + CASE WHEN NEW.event_type = 'task_started' THEN 1 ELSE 0 END; +END; diff --git a/flowctl/crates/flowctl-db/src/migrations/02-retry-count/down.sql b/flowctl/crates/flowctl-db/src/migrations/02-retry-count/down.sql new file mode 100644 index 00000000..ce4f9cca --- /dev/null +++ b/flowctl/crates/flowctl-db/src/migrations/02-retry-count/down.sql @@ -0,0 +1,3 @@ +-- SQLite doesn't support DROP COLUMN before 3.35.0; this is best-effort. +-- For older SQLite, a full table rebuild would be needed. +ALTER TABLE runtime_state DROP COLUMN retry_count; diff --git a/flowctl/crates/flowctl-db/src/migrations/02-retry-count/up.sql b/flowctl/crates/flowctl-db/src/migrations/02-retry-count/up.sql new file mode 100644 index 00000000..5bb26033 --- /dev/null +++ b/flowctl/crates/flowctl-db/src/migrations/02-retry-count/up.sql @@ -0,0 +1 @@ +ALTER TABLE runtime_state ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0; diff --git a/flowctl/crates/flowctl-db/src/pool.rs b/flowctl/crates/flowctl-db/src/pool.rs new file mode 100644 index 00000000..9de2f2fe --- /dev/null +++ b/flowctl/crates/flowctl-db/src/pool.rs @@ -0,0 +1,298 @@ +//! Connection management and state directory resolution. +//! +//! Resolves the database path via `git rev-parse --git-common-dir` so that +//! worktrees share a single database. Opens connections with production +//! PRAGMAs (WAL, busy_timeout, etc.) and runs embedded migrations. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use include_dir::{include_dir, Dir}; +use rusqlite::Connection; +use rusqlite_migration::Migrations; + +use crate::error::DbError; + +/// Embedded migration files, compiled into the binary. +static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/migrations"); + +/// Lazily built migrations from the embedded directory. +fn migrations() -> Migrations<'static> { + Migrations::from_directory(&MIGRATIONS_DIR).expect("valid migration directory") +} + +/// Resolve the state directory for the flowctl database. +/// +/// Strategy: `git rev-parse --git-common-dir` + `/flow-state/`. +/// This ensures all worktrees share a single database file. +/// Falls back to `.flow/.state/` in the current directory if not in a git repo. +pub fn resolve_state_dir(working_dir: &Path) -> Result { + let output = Command::new("git") + .args(["rev-parse", "--git-common-dir"]) + .current_dir(working_dir) + .output() + .map_err(|e| DbError::StateDir(format!("failed to run git: {e}")))?; + + if output.status.success() { + let git_common = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let git_common_path = if Path::new(&git_common).is_absolute() { + PathBuf::from(git_common) + } else { + working_dir.join(git_common) + }; + Ok(git_common_path.join("flow-state")) + } else { + // Not a git repo -- use local .flow/.state/ + Ok(working_dir.join(".flow").join(".state")) + } +} + +/// Resolve the full database file path. +pub fn resolve_db_path(working_dir: &Path) -> Result { + let state_dir = resolve_state_dir(working_dir)?; + Ok(state_dir.join("flowctl.db")) +} + +/// Apply production PRAGMAs to a connection. +/// +/// These are set per-connection (not in migration files) because PRAGMAs +/// like journal_mode persist at the database level, while others like +/// busy_timeout are per-connection. +/// +/// Note on macOS: SQLite's default fsync does not use F_FULLFSYNC, which +/// means data can be lost on power failure with certain hardware. For +/// flowctl this is acceptable because the SQLite database is a rebuildable +/// cache -- `flowctl reindex` recovers indexed data from Markdown files. +/// Runtime-only data (events, metrics) is best-effort by design. +fn apply_pragmas(conn: &Connection) -> Result<(), DbError> { + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA busy_timeout = 5000; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + PRAGMA wal_autocheckpoint = 1000;", + ) + .map_err(DbError::Sqlite) +} + +/// Open a database connection with production PRAGMAs and run migrations. +/// +/// Creates the state directory and database file if they don't exist. +pub fn open(working_dir: &Path) -> Result { + let db_path = resolve_db_path(working_dir)?; + + // Ensure the state directory exists. + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + DbError::StateDir(format!("failed to create {}: {e}", parent.display())) + })?; + } + + let mut conn = Connection::open(&db_path).map_err(DbError::Sqlite)?; + apply_pragmas(&conn)?; + + // Run pending migrations. + migrations() + .to_latest(&mut conn) + .map_err(|e| DbError::Migration(e.to_string()))?; + + Ok(conn) +} + +/// Open an in-memory database for testing. Applies PRAGMAs and migrations. +pub fn open_memory() -> Result { + let mut conn = Connection::open_in_memory().map_err(DbError::Sqlite)?; + apply_pragmas(&conn)?; + migrations() + .to_latest(&mut conn) + .map_err(|e| DbError::Migration(e.to_string()))?; + Ok(conn) +} + +/// Run auto-cleanup: delete old events and daily rollups. +/// +/// - events older than 90 days +/// - daily_rollup older than 365 days +pub fn cleanup(conn: &Connection) -> Result { + let events_deleted: usize = conn + .execute( + "DELETE FROM events WHERE timestamp < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 days')", + [], + ) + .map_err(DbError::Sqlite)?; + + let rollups_deleted: usize = conn + .execute( + "DELETE FROM daily_rollup WHERE day < strftime('%Y-%m-%d', 'now', '-365 days')", + [], + ) + .map_err(DbError::Sqlite)?; + + Ok(events_deleted + rollups_deleted) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_open_memory() { + let conn = open_memory().expect("should open in-memory db"); + // Verify tables exist by querying sqlite_master. + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + + assert!(tables.contains(&"epics".to_string())); + assert!(tables.contains(&"tasks".to_string())); + assert!(tables.contains(&"task_deps".to_string())); + assert!(tables.contains(&"epic_deps".to_string())); + assert!(tables.contains(&"file_ownership".to_string())); + assert!(tables.contains(&"runtime_state".to_string())); + assert!(tables.contains(&"file_locks".to_string())); + assert!(tables.contains(&"heartbeats".to_string())); + assert!(tables.contains(&"phase_progress".to_string())); + assert!(tables.contains(&"evidence".to_string())); + assert!(tables.contains(&"events".to_string())); + assert!(tables.contains(&"token_usage".to_string())); + assert!(tables.contains(&"daily_rollup".to_string())); + assert!(tables.contains(&"monthly_rollup".to_string())); + } + + #[test] + fn test_pragmas_applied() { + let conn = open_memory().expect("should open in-memory db"); + + let journal_mode: String = conn + .pragma_query_value(None, "journal_mode", |row| row.get(0)) + .unwrap(); + // In-memory databases use "memory" journal mode regardless of setting. + assert!(journal_mode == "memory" || journal_mode == "wal"); + + let busy_timeout: i64 = conn + .pragma_query_value(None, "busy_timeout", |row| row.get(0)) + .unwrap(); + assert_eq!(busy_timeout, 5000); + + let foreign_keys: i64 = conn + .pragma_query_value(None, "foreign_keys", |row| row.get(0)) + .unwrap(); + assert_eq!(foreign_keys, 1); + } + + #[test] + fn test_trigger_daily_rollup() { + let conn = open_memory().expect("should open in-memory db"); + + // Insert an epic first (FK constraint). + conn.execute( + "INSERT INTO epics (id, title, status, file_path, created_at, updated_at) + VALUES ('fn-1-test', 'Test', 'open', 'epics/fn-1-test.md', '2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z')", + [], + ) + .unwrap(); + + // Insert a task_started event. + conn.execute( + "INSERT INTO events (epic_id, task_id, event_type, actor) + VALUES ('fn-1-test', 'fn-1-test.1', 'task_started', 'worker')", + [], + ) + .unwrap(); + + // Insert a task_completed event. + conn.execute( + "INSERT INTO events (epic_id, task_id, event_type, actor) + VALUES ('fn-1-test', 'fn-1-test.1', 'task_completed', 'worker')", + [], + ) + .unwrap(); + + // Verify daily_rollup was auto-populated. + let (started, completed): (i64, i64) = conn + .query_row( + "SELECT tasks_started, tasks_completed FROM daily_rollup WHERE epic_id = 'fn-1-test'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + + assert_eq!(started, 1); + assert_eq!(completed, 1); + } + + #[test] + fn test_resolve_state_dir_in_git_repo() { + // Create a temp dir with a git repo. + let tmp = std::env::temp_dir().join("flowctl-test-state-dir"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(&tmp) + .output() + .unwrap(); + + let state_dir = resolve_state_dir(&tmp).unwrap(); + assert!(state_dir.to_string_lossy().contains("flow-state")); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_open_file_based() { + let tmp = std::env::temp_dir().join("flowctl-test-open-file"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(&tmp) + .output() + .unwrap(); + + let conn = open(&tmp).expect("should open file-based db"); + + // Verify tables exist. + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table'", + [], + |row| row.get(0), + ) + .unwrap(); + // 14 tables + sqlite_sequence (from AUTOINCREMENT) + assert!(count >= 14, "expected at least 14 tables, got {count}"); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_cleanup_noop_on_fresh_db() { + let conn = open_memory().expect("should open in-memory db"); + let deleted = cleanup(&conn).unwrap(); + assert_eq!(deleted, 0); + } + + #[test] + fn test_idempotent_migrations() { + let mut conn = Connection::open_in_memory().unwrap(); + apply_pragmas(&conn).unwrap(); + + // Run migrations twice -- should be idempotent. + migrations().to_latest(&mut conn).unwrap(); + migrations().to_latest(&mut conn).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table'", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(count >= 14); + } +} diff --git a/flowctl/crates/flowctl-db/src/repo.rs b/flowctl/crates/flowctl-db/src/repo.rs new file mode 100644 index 00000000..ebe7dfcf --- /dev/null +++ b/flowctl/crates/flowctl-db/src/repo.rs @@ -0,0 +1,1172 @@ +//! Repository abstractions for database CRUD operations. +//! +//! Thin wrappers over rusqlite that map between flowctl-core types and +//! SQLite rows. Each repository struct borrows a `&Connection` and +//! provides typed query methods. + +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection}; + +use flowctl_core::types::{Domain, Epic, EpicStatus, Evidence, ReviewStatus, RuntimeState, Task}; +use flowctl_core::state_machine::Status; + +use crate::error::DbError; + +// ── Epic repository ───────────────────────────────────────────────── + +/// Repository for epic CRUD operations. +pub struct EpicRepo<'a> { + conn: &'a Connection, +} + +impl<'a> EpicRepo<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Insert or replace an epic (used by reindex and create). + pub fn upsert(&self, epic: &Epic) -> Result<(), DbError> { + self.conn.execute( + "INSERT INTO epics (id, title, status, branch_name, plan_review, file_path, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + status = excluded.status, + branch_name = excluded.branch_name, + plan_review = excluded.plan_review, + file_path = excluded.file_path, + updated_at = excluded.updated_at", + params![ + epic.id, + epic.title, + epic.status.to_string(), + epic.branch_name, + epic.plan_review.to_string(), + epic.file_path.as_deref().unwrap_or(""), + epic.created_at.to_rfc3339(), + epic.updated_at.to_rfc3339(), + ], + )?; + + // Upsert epic dependencies. + self.conn.execute( + "DELETE FROM epic_deps WHERE epic_id = ?1", + params![epic.id], + )?; + for dep in &epic.depends_on_epics { + self.conn.execute( + "INSERT INTO epic_deps (epic_id, depends_on) VALUES (?1, ?2)", + params![epic.id, dep], + )?; + } + + Ok(()) + } + + /// Get an epic by ID. + pub fn get(&self, id: &str) -> Result { + let mut stmt = self.conn.prepare( + "SELECT id, title, status, branch_name, plan_review, file_path, created_at, updated_at + FROM epics WHERE id = ?1", + )?; + + let epic = stmt + .query_row(params![id], |row| { + Ok(Epic { + schema_version: 1, + id: row.get(0)?, + title: row.get(1)?, + status: parse_epic_status(&row.get::<_, String>(2)?), + branch_name: row.get(3)?, + plan_review: parse_review_status(&row.get::<_, String>(4)?), + completion_review: ReviewStatus::Unknown, + depends_on_epics: Vec::new(), // loaded below + default_impl: None, + default_review: None, + default_sync: None, + file_path: row.get::<_, Option>(5)?, + created_at: parse_datetime(&row.get::<_, String>(6)?), + updated_at: parse_datetime(&row.get::<_, String>(7)?), + }) + }) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => DbError::NotFound { + entity: "epic", + id: id.to_string(), + }, + other => DbError::Sqlite(other), + })?; + + // Load dependencies. + let deps = self.get_deps(&epic.id)?; + Ok(Epic { + depends_on_epics: deps, + ..epic + }) + } + + /// List all epics, optionally filtered by status. + pub fn list(&self, status: Option<&str>) -> Result, DbError> { + let sql = match status { + Some(_) => "SELECT id FROM epics WHERE status = ?1 ORDER BY created_at", + None => "SELECT id FROM epics ORDER BY created_at", + }; + + let mut stmt = self.conn.prepare(sql)?; + let ids: Vec = match status { + Some(s) => stmt + .query_map(params![s], |row| row.get(0))? + .collect::, _>>()?, + None => stmt + .query_map([], |row| row.get(0))? + .collect::, _>>()?, + }; + + ids.iter().map(|id| self.get(id)).collect() + } + + /// Update epic status. + pub fn update_status(&self, id: &str, status: EpicStatus) -> Result<(), DbError> { + let rows = self.conn.execute( + "UPDATE epics SET status = ?1, updated_at = ?2 WHERE id = ?3", + params![status.to_string(), Utc::now().to_rfc3339(), id], + )?; + if rows == 0 { + return Err(DbError::NotFound { + entity: "epic", + id: id.to_string(), + }); + } + Ok(()) + } + + /// Delete an epic and all related data (for reindex). + pub fn delete(&self, id: &str) -> Result<(), DbError> { + self.conn + .execute("DELETE FROM epic_deps WHERE epic_id = ?1", params![id])?; + self.conn + .execute("DELETE FROM epics WHERE id = ?1", params![id])?; + Ok(()) + } + + fn get_deps(&self, epic_id: &str) -> Result, DbError> { + let mut stmt = self + .conn + .prepare("SELECT depends_on FROM epic_deps WHERE epic_id = ?1")?; + let deps = stmt + .query_map(params![epic_id], |row| row.get(0))? + .collect::, _>>()?; + Ok(deps) + } +} + +// ── Task repository ───────────────────────────────────────────────── + +/// Repository for task CRUD operations. +pub struct TaskRepo<'a> { + conn: &'a Connection, +} + +impl<'a> TaskRepo<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Insert or replace a task (used by reindex and create). + pub fn upsert(&self, task: &Task) -> Result<(), DbError> { + self.conn.execute( + "INSERT INTO tasks (id, epic_id, title, status, priority, domain, file_path, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + status = excluded.status, + priority = excluded.priority, + domain = excluded.domain, + file_path = excluded.file_path, + updated_at = excluded.updated_at", + params![ + task.id, + task.epic, + task.title, + task.status.to_string(), + task.sort_priority() as i64, + task.domain.to_string(), + task.file_path.as_deref().unwrap_or(""), + task.created_at.to_rfc3339(), + task.updated_at.to_rfc3339(), + ], + )?; + + // Upsert dependencies. + self.conn.execute( + "DELETE FROM task_deps WHERE task_id = ?1", + params![task.id], + )?; + for dep in &task.depends_on { + self.conn.execute( + "INSERT INTO task_deps (task_id, depends_on) VALUES (?1, ?2)", + params![task.id, dep], + )?; + } + + // Upsert file ownership. + self.conn.execute( + "DELETE FROM file_ownership WHERE task_id = ?1", + params![task.id], + )?; + for file in &task.files { + self.conn.execute( + "INSERT INTO file_ownership (file_path, task_id) VALUES (?1, ?2)", + params![file, task.id], + )?; + } + + Ok(()) + } + + /// Get a task by ID. + pub fn get(&self, id: &str) -> Result { + let mut stmt = self.conn.prepare( + "SELECT id, epic_id, title, status, priority, domain, file_path, created_at, updated_at + FROM tasks WHERE id = ?1", + )?; + + let task = stmt + .query_row(params![id], |row| { + let priority_val: i64 = row.get(4)?; + let priority = if priority_val == 999 { + None + } else { + Some(priority_val as u32) + }; + + Ok(Task { + schema_version: 1, + id: row.get(0)?, + epic: row.get(1)?, + title: row.get(2)?, + status: parse_status(&row.get::<_, String>(3)?), + priority, + domain: parse_domain(&row.get::<_, String>(5)?), + depends_on: Vec::new(), // loaded below + files: Vec::new(), // loaded below + r#impl: None, + review: None, + sync: None, + file_path: row.get::<_, Option>(6)?, + created_at: parse_datetime(&row.get::<_, String>(7)?), + updated_at: parse_datetime(&row.get::<_, String>(8)?), + }) + }) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => DbError::NotFound { + entity: "task", + id: id.to_string(), + }, + other => DbError::Sqlite(other), + })?; + + let deps = self.get_deps(&task.id)?; + let files = self.get_files(&task.id)?; + Ok(Task { + depends_on: deps, + files, + ..task + }) + } + + /// List tasks for an epic. + pub fn list_by_epic(&self, epic_id: &str) -> Result, DbError> { + let mut stmt = self + .conn + .prepare("SELECT id FROM tasks WHERE epic_id = ?1 ORDER BY priority, id")?; + let ids: Vec = stmt + .query_map(params![epic_id], |row| row.get(0))? + .collect::, _>>()?; + + ids.iter().map(|id| self.get(id)).collect() + } + + /// List all tasks, optionally filtered by status and/or domain. + pub fn list_all( + &self, + status: Option<&str>, + domain: Option<&str>, + ) -> Result, DbError> { + let mut conditions = Vec::new(); + let mut param_values: Vec = Vec::new(); + + if let Some(s) = status { + conditions.push(format!("status = ?{}", param_values.len() + 1)); + param_values.push(s.to_string()); + } + if let Some(d) = domain { + conditions.push(format!("domain = ?{}", param_values.len() + 1)); + param_values.push(d.to_string()); + } + + let sql = if conditions.is_empty() { + "SELECT id FROM tasks ORDER BY epic_id, priority, id".to_string() + } else { + format!( + "SELECT id FROM tasks WHERE {} ORDER BY epic_id, priority, id", + conditions.join(" AND ") + ) + }; + + let mut stmt = self.conn.prepare(&sql)?; + let ids: Vec = match param_values.len() { + 0 => stmt + .query_map([], |row| row.get(0))? + .collect::, _>>()?, + 1 => stmt + .query_map(params![param_values[0]], |row| row.get(0))? + .collect::, _>>()?, + 2 => stmt + .query_map(params![param_values[0], param_values[1]], |row| row.get(0))? + .collect::, _>>()?, + _ => unreachable!(), + }; + + ids.iter().map(|id| self.get(id)).collect() + } + + /// List tasks filtered by status. + pub fn list_by_status(&self, status: Status) -> Result, DbError> { + let mut stmt = self + .conn + .prepare("SELECT id FROM tasks WHERE status = ?1 ORDER BY priority, id")?; + let ids: Vec = stmt + .query_map(params![status.to_string()], |row| row.get(0))? + .collect::, _>>()?; + + ids.iter().map(|id| self.get(id)).collect() + } + + /// Update task status. + pub fn update_status(&self, id: &str, status: Status) -> Result<(), DbError> { + let rows = self.conn.execute( + "UPDATE tasks SET status = ?1, updated_at = ?2 WHERE id = ?3", + params![status.to_string(), Utc::now().to_rfc3339(), id], + )?; + if rows == 0 { + return Err(DbError::NotFound { + entity: "task", + id: id.to_string(), + }); + } + Ok(()) + } + + /// Delete a task and all related data (for reindex). + pub fn delete(&self, id: &str) -> Result<(), DbError> { + self.conn + .execute("DELETE FROM task_deps WHERE task_id = ?1", params![id])?; + self.conn + .execute("DELETE FROM file_ownership WHERE task_id = ?1", params![id])?; + self.conn + .execute("DELETE FROM tasks WHERE id = ?1", params![id])?; + Ok(()) + } + + fn get_deps(&self, task_id: &str) -> Result, DbError> { + let mut stmt = self + .conn + .prepare("SELECT depends_on FROM task_deps WHERE task_id = ?1")?; + let deps = stmt + .query_map(params![task_id], |row| row.get(0))? + .collect::, _>>()?; + Ok(deps) + } + + fn get_files(&self, task_id: &str) -> Result, DbError> { + let mut stmt = self + .conn + .prepare("SELECT file_path FROM file_ownership WHERE task_id = ?1")?; + let files = stmt + .query_map(params![task_id], |row| row.get(0))? + .collect::, _>>()?; + Ok(files) + } +} + +// ── Runtime state repository ──────────────────────────────────────── + +/// Repository for runtime state (not in Markdown, SQLite-only). +pub struct RuntimeRepo<'a> { + conn: &'a Connection, +} + +impl<'a> RuntimeRepo<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Upsert runtime state for a task. + pub fn upsert(&self, state: &RuntimeState) -> Result<(), DbError> { + self.conn.execute( + "INSERT INTO runtime_state (task_id, assignee, claimed_at, completed_at, duration_secs, blocked_reason, baseline_rev, final_rev, retry_count) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(task_id) DO UPDATE SET + assignee = excluded.assignee, + claimed_at = excluded.claimed_at, + completed_at = excluded.completed_at, + duration_secs = excluded.duration_secs, + blocked_reason = excluded.blocked_reason, + baseline_rev = excluded.baseline_rev, + final_rev = excluded.final_rev, + retry_count = excluded.retry_count", + params![ + state.task_id, + state.assignee, + state.claimed_at.map(|dt| dt.to_rfc3339()), + state.completed_at.map(|dt| dt.to_rfc3339()), + state.duration_secs.map(|d| d as i64), + state.blocked_reason, + state.baseline_rev, + state.final_rev, + state.retry_count, + ], + )?; + Ok(()) + } + + /// Get runtime state for a task. + pub fn get(&self, task_id: &str) -> Result, DbError> { + let mut stmt = self.conn.prepare( + "SELECT task_id, assignee, claimed_at, completed_at, duration_secs, blocked_reason, baseline_rev, final_rev, retry_count + FROM runtime_state WHERE task_id = ?1", + )?; + + let result = stmt.query_row(params![task_id], |row| { + Ok(RuntimeState { + task_id: row.get(0)?, + assignee: row.get(1)?, + claimed_at: row + .get::<_, Option>(2)? + .map(|s| parse_datetime(&s)), + completed_at: row + .get::<_, Option>(3)? + .map(|s| parse_datetime(&s)), + duration_secs: row.get::<_, Option>(4)?.map(|d| d as u64), + blocked_reason: row.get(5)?, + baseline_rev: row.get(6)?, + final_rev: row.get(7)?, + retry_count: row.get::<_, i32>(8).unwrap_or(0) as u32, + }) + }); + + match result { + Ok(state) => Ok(Some(state)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(DbError::Sqlite(e)), + } + } +} + +// ── Evidence repository ───────────────────────────────────────────── + +/// Repository for task completion evidence. +pub struct EvidenceRepo<'a> { + conn: &'a Connection, +} + +impl<'a> EvidenceRepo<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Upsert evidence for a task. Commits and tests are stored as JSON arrays. + pub fn upsert(&self, task_id: &str, evidence: &Evidence) -> Result<(), DbError> { + let commits_json = + if evidence.commits.is_empty() { None } else { Some(serde_json::to_string(&evidence.commits)?) }; + let tests_json = + if evidence.tests.is_empty() { None } else { Some(serde_json::to_string(&evidence.tests)?) }; + + self.conn.execute( + "INSERT INTO evidence (task_id, commits, tests, files_changed, insertions, deletions, review_iters) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(task_id) DO UPDATE SET + commits = excluded.commits, + tests = excluded.tests, + files_changed = excluded.files_changed, + insertions = excluded.insertions, + deletions = excluded.deletions, + review_iters = excluded.review_iters", + params![ + task_id, + commits_json, + tests_json, + evidence.files_changed.map(|v| v as i64), + evidence.insertions.map(|v| v as i64), + evidence.deletions.map(|v| v as i64), + evidence.review_iterations.map(|v| v as i64), + ], + )?; + Ok(()) + } + + /// Get evidence for a task. + pub fn get(&self, task_id: &str) -> Result, DbError> { + let mut stmt = self.conn.prepare( + "SELECT commits, tests, files_changed, insertions, deletions, review_iters + FROM evidence WHERE task_id = ?1", + )?; + + let result = stmt.query_row(params![task_id], |row| { + let commits_json: Option = row.get(0)?; + let tests_json: Option = row.get(1)?; + + Ok((commits_json, tests_json, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, row.get::<_, Option>(5)?)) + }); + + match result { + Ok((commits_json, tests_json, files_changed, insertions, deletions, review_iters)) => { + let commits: Vec = commits_json + .map(|s| serde_json::from_str(&s)) + .transpose()? + .unwrap_or_default(); + let tests: Vec = tests_json + .map(|s| serde_json::from_str(&s)) + .transpose()? + .unwrap_or_default(); + + Ok(Some(Evidence { + commits, + tests, + prs: Vec::new(), + files_changed: files_changed.map(|v| v as u32), + insertions: insertions.map(|v| v as u32), + deletions: deletions.map(|v| v as u32), + review_iterations: review_iters.map(|v| v as u32), + workspace_changes: None, + })) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(DbError::Sqlite(e)), + } + } +} + +// ── File lock repository ──────────────────────────────────────────── + +/// Repository for runtime file locks (Teams mode). +pub struct FileLockRepo<'a> { + conn: &'a Connection, +} + +impl<'a> FileLockRepo<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Acquire a lock on a file for a task. Returns error if already locked. + pub fn acquire(&self, file_path: &str, task_id: &str) -> Result<(), DbError> { + self.conn + .execute( + "INSERT INTO file_locks (file_path, task_id, locked_at) VALUES (?1, ?2, ?3)", + params![file_path, task_id, Utc::now().to_rfc3339()], + ) + .map_err(|e| match e { + rusqlite::Error::SqliteFailure(err, _) + if err.code == rusqlite::ffi::ErrorCode::ConstraintViolation => + { + DbError::Constraint(format!("file already locked: {file_path}")) + } + other => DbError::Sqlite(other), + })?; + Ok(()) + } + + /// Release locks held by a task. + pub fn release_for_task(&self, task_id: &str) -> Result { + let count = self + .conn + .execute( + "DELETE FROM file_locks WHERE task_id = ?1", + params![task_id], + )?; + Ok(count) + } + + /// Release all locks (between waves). + pub fn release_all(&self) -> Result { + let count = self.conn.execute("DELETE FROM file_locks", [])?; + Ok(count) + } + + /// Check if a file is locked. Returns the locking task_id if so. + pub fn check(&self, file_path: &str) -> Result, DbError> { + let mut stmt = self + .conn + .prepare("SELECT task_id FROM file_locks WHERE file_path = ?1")?; + + match stmt.query_row(params![file_path], |row| row.get(0)) { + Ok(task_id) => Ok(Some(task_id)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(DbError::Sqlite(e)), + } + } +} + +// ── Event repository ──────────────────────────────────────────────── + +/// Repository for the append-only event log. +pub struct EventRepo<'a> { + conn: &'a Connection, +} + +impl<'a> EventRepo<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Record an event. + pub fn insert( + &self, + epic_id: &str, + task_id: Option<&str>, + event_type: &str, + actor: Option<&str>, + payload: Option<&str>, + session_id: Option<&str>, + ) -> Result { + self.conn.execute( + "INSERT INTO events (epic_id, task_id, event_type, actor, payload, session_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![epic_id, task_id, event_type, actor, payload, session_id], + )?; + Ok(self.conn.last_insert_rowid()) + } + + /// Query recent events for an epic. + pub fn list_by_epic(&self, epic_id: &str, limit: usize) -> Result, DbError> { + let mut stmt = self.conn.prepare( + "SELECT id, timestamp, epic_id, task_id, event_type, actor, payload, session_id + FROM events WHERE epic_id = ?1 ORDER BY id DESC LIMIT ?2", + )?; + + let rows = stmt + .query_map(params![epic_id, limit as i64], |row| { + Ok(EventRow { + id: row.get(0)?, + timestamp: row.get(1)?, + epic_id: row.get(2)?, + task_id: row.get(3)?, + event_type: row.get(4)?, + actor: row.get(5)?, + payload: row.get(6)?, + session_id: row.get(7)?, + }) + })? + .collect::, _>>()?; + + Ok(rows) + } +} + +/// A row from the events table. +#[derive(Debug, Clone)] +pub struct EventRow { + pub id: i64, + pub timestamp: String, + pub epic_id: String, + pub task_id: Option, + pub event_type: String, + pub actor: Option, + pub payload: Option, + pub session_id: Option, +} + +// ── Phase progress repository ────────────────────────────────────── + +/// Repository for worker-phase progress tracking. +pub struct PhaseProgressRepo<'a> { + conn: &'a Connection, +} + +impl<'a> PhaseProgressRepo<'a> { + pub fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Get all completed phases for a task. + pub fn get_completed(&self, task_id: &str) -> Result, DbError> { + let mut stmt = self.conn.prepare( + "SELECT phase FROM phase_progress WHERE task_id = ?1 AND status = 'done' ORDER BY rowid", + )?; + let phases = stmt + .query_map(params![task_id], |row| row.get(0))? + .collect::, _>>()?; + Ok(phases) + } + + /// Mark a phase as done. + pub fn mark_done(&self, task_id: &str, phase: &str) -> Result<(), DbError> { + self.conn.execute( + "INSERT INTO phase_progress (task_id, phase, status, completed_at) + VALUES (?1, ?2, 'done', ?3) + ON CONFLICT(task_id, phase) DO UPDATE SET + status = 'done', + completed_at = excluded.completed_at", + params![task_id, phase, Utc::now().to_rfc3339()], + )?; + Ok(()) + } + + /// Reset all phase progress for a task. + pub fn reset(&self, task_id: &str) -> Result { + let count = self + .conn + .execute("DELETE FROM phase_progress WHERE task_id = ?1", params![task_id])?; + Ok(count) + } +} + +// ── Parsing helpers ───────────────────────────────────────────────── + +fn parse_status(s: &str) -> Status { + Status::parse(s).unwrap_or_default() +} + +fn parse_epic_status(s: &str) -> EpicStatus { + match s { + "done" => EpicStatus::Done, + _ => EpicStatus::Open, + } +} + +fn parse_review_status(s: &str) -> ReviewStatus { + match s { + "passed" => ReviewStatus::Passed, + "failed" => ReviewStatus::Failed, + _ => ReviewStatus::Unknown, + } +} + +fn parse_domain(s: &str) -> Domain { + match s { + "frontend" => Domain::Frontend, + "backend" => Domain::Backend, + "architecture" => Domain::Architecture, + "testing" => Domain::Testing, + "docs" => Domain::Docs, + "ops" => Domain::Ops, + _ => Domain::General, + } +} + +fn parse_datetime(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pool::open_memory; + + fn test_conn() -> Connection { + open_memory().expect("in-memory db") + } + + // ── Epic tests ────────────────────────────────────────────────── + + #[test] + fn test_epic_upsert_and_get() { + let conn = test_conn(); + let repo = EpicRepo::new(&conn); + + let epic = Epic { + schema_version: 1, + id: "fn-1-test".to_string(), + title: "Test Epic".to_string(), + status: EpicStatus::Open, + branch_name: Some("feat/test".to_string()), + plan_review: ReviewStatus::Unknown, + completion_review: ReviewStatus::Unknown, + depends_on_epics: vec![], + default_impl: None, + default_review: None, + default_sync: None, + file_path: Some("epics/fn-1-test.md".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + repo.upsert(&epic).unwrap(); + let loaded = repo.get("fn-1-test").unwrap(); + assert_eq!(loaded.id, "fn-1-test"); + assert_eq!(loaded.title, "Test Epic"); + assert_eq!(loaded.status, EpicStatus::Open); + assert_eq!(loaded.branch_name, Some("feat/test".to_string())); + } + + #[test] + fn test_epic_with_deps() { + let conn = test_conn(); + let repo = EpicRepo::new(&conn); + + // Create dependency epic first. + let dep_epic = Epic { + schema_version: 1, + id: "fn-1-dep".to_string(), + title: "Dependency".to_string(), + status: EpicStatus::Open, + branch_name: None, + plan_review: ReviewStatus::Unknown, + completion_review: ReviewStatus::Unknown, + depends_on_epics: vec![], + default_impl: None, + default_review: None, + default_sync: None, + file_path: Some("epics/fn-1-dep.md".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + repo.upsert(&dep_epic).unwrap(); + + let epic = Epic { + depends_on_epics: vec!["fn-1-dep".to_string()], + id: "fn-2-test".to_string(), + title: "With Deps".to_string(), + file_path: Some("epics/fn-2-test.md".to_string()), + ..dep_epic.clone() + }; + repo.upsert(&epic).unwrap(); + + let loaded = repo.get("fn-2-test").unwrap(); + assert_eq!(loaded.depends_on_epics, vec!["fn-1-dep"]); + } + + #[test] + fn test_epic_not_found() { + let conn = test_conn(); + let repo = EpicRepo::new(&conn); + let result = repo.get("nonexistent"); + assert!(matches!(result, Err(DbError::NotFound { .. }))); + } + + #[test] + fn test_epic_list() { + let conn = test_conn(); + let repo = EpicRepo::new(&conn); + + for i in 1..=3 { + let epic = Epic { + schema_version: 1, + id: format!("fn-{i}-test"), + title: format!("Epic {i}"), + status: if i == 3 { EpicStatus::Done } else { EpicStatus::Open }, + branch_name: None, + plan_review: ReviewStatus::Unknown, + completion_review: ReviewStatus::Unknown, + depends_on_epics: vec![], + default_impl: None, + default_review: None, + default_sync: None, + file_path: Some(format!("epics/fn-{i}-test.md")), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + repo.upsert(&epic).unwrap(); + } + + assert_eq!(repo.list(None).unwrap().len(), 3); + assert_eq!(repo.list(Some("open")).unwrap().len(), 2); + assert_eq!(repo.list(Some("done")).unwrap().len(), 1); + } + + // ── Task tests ────────────────────────────────────────────────── + + #[test] + fn test_task_upsert_and_get() { + let conn = test_conn(); + let epic_repo = EpicRepo::new(&conn); + let task_repo = TaskRepo::new(&conn); + + // Create epic first (FK). + let epic = Epic { + schema_version: 1, + id: "fn-1-test".to_string(), + title: "Test".to_string(), + status: EpicStatus::Open, + branch_name: None, + plan_review: ReviewStatus::Unknown, + completion_review: ReviewStatus::Unknown, + depends_on_epics: vec![], + default_impl: None, + default_review: None, + default_sync: None, + file_path: Some("epics/fn-1-test.md".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + epic_repo.upsert(&epic).unwrap(); + + let task = Task { + schema_version: 1, + id: "fn-1-test.1".to_string(), + epic: "fn-1-test".to_string(), + title: "Task 1".to_string(), + status: Status::Todo, + priority: Some(1), + domain: Domain::Backend, + depends_on: vec![], + files: vec!["src/main.rs".to_string()], + r#impl: None, + review: None, + sync: None, + file_path: Some("tasks/fn-1-test.1.md".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + task_repo.upsert(&task).unwrap(); + let loaded = task_repo.get("fn-1-test.1").unwrap(); + assert_eq!(loaded.id, "fn-1-test.1"); + assert_eq!(loaded.title, "Task 1"); + assert_eq!(loaded.status, Status::Todo); + assert_eq!(loaded.priority, Some(1)); + assert_eq!(loaded.domain, Domain::Backend); + assert_eq!(loaded.files, vec!["src/main.rs"]); + } + + #[test] + fn test_task_with_deps() { + let conn = test_conn(); + let epic_repo = EpicRepo::new(&conn); + let task_repo = TaskRepo::new(&conn); + + let epic = Epic { + schema_version: 1, + id: "fn-1-test".to_string(), + title: "Test".to_string(), + status: EpicStatus::Open, + branch_name: None, + plan_review: ReviewStatus::Unknown, + completion_review: ReviewStatus::Unknown, + depends_on_epics: vec![], + default_impl: None, + default_review: None, + default_sync: None, + file_path: Some("epics/fn-1-test.md".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + epic_repo.upsert(&epic).unwrap(); + + // Task 1 (no deps). + let t1 = Task { + schema_version: 1, + id: "fn-1-test.1".to_string(), + epic: "fn-1-test".to_string(), + title: "Task 1".to_string(), + status: Status::Todo, + priority: None, + domain: Domain::General, + depends_on: vec![], + files: vec![], + r#impl: None, + review: None, + sync: None, + file_path: Some("tasks/fn-1-test.1.md".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + task_repo.upsert(&t1).unwrap(); + + // Task 2 depends on Task 1. + let t2 = Task { + id: "fn-1-test.2".to_string(), + title: "Task 2".to_string(), + depends_on: vec!["fn-1-test.1".to_string()], + file_path: Some("tasks/fn-1-test.2.md".to_string()), + ..t1.clone() + }; + task_repo.upsert(&t2).unwrap(); + + let loaded = task_repo.get("fn-1-test.2").unwrap(); + assert_eq!(loaded.depends_on, vec!["fn-1-test.1"]); + } + + #[test] + fn test_task_status_update() { + let conn = test_conn(); + let epic_repo = EpicRepo::new(&conn); + let task_repo = TaskRepo::new(&conn); + + let epic = Epic { + schema_version: 1, + id: "fn-1-test".to_string(), + title: "Test".to_string(), + status: EpicStatus::Open, + branch_name: None, + plan_review: ReviewStatus::Unknown, + completion_review: ReviewStatus::Unknown, + depends_on_epics: vec![], + default_impl: None, + default_review: None, + default_sync: None, + file_path: Some("epics/fn-1-test.md".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + epic_repo.upsert(&epic).unwrap(); + + let task = Task { + schema_version: 1, + id: "fn-1-test.1".to_string(), + epic: "fn-1-test".to_string(), + title: "Task 1".to_string(), + status: Status::Todo, + priority: None, + domain: Domain::General, + depends_on: vec![], + files: vec![], + r#impl: None, + review: None, + sync: None, + file_path: Some("tasks/fn-1-test.1.md".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + task_repo.upsert(&task).unwrap(); + + task_repo + .update_status("fn-1-test.1", Status::InProgress) + .unwrap(); + let loaded = task_repo.get("fn-1-test.1").unwrap(); + assert_eq!(loaded.status, Status::InProgress); + } + + // ── File lock tests ───────────────────────────────────────────── + + #[test] + fn test_file_lock_acquire_release() { + let conn = test_conn(); + let repo = FileLockRepo::new(&conn); + + repo.acquire("src/main.rs", "fn-1.1").unwrap(); + + let locker = repo.check("src/main.rs").unwrap(); + assert_eq!(locker, Some("fn-1.1".to_string())); + + repo.release_for_task("fn-1.1").unwrap(); + let locker = repo.check("src/main.rs").unwrap(); + assert_eq!(locker, None); + } + + #[test] + fn test_file_lock_conflict() { + let conn = test_conn(); + let repo = FileLockRepo::new(&conn); + + repo.acquire("src/main.rs", "fn-1.1").unwrap(); + let result = repo.acquire("src/main.rs", "fn-1.2"); + assert!(matches!(result, Err(DbError::Constraint(_)))); + } + + #[test] + fn test_file_lock_release_all() { + let conn = test_conn(); + let repo = FileLockRepo::new(&conn); + + repo.acquire("src/a.rs", "fn-1.1").unwrap(); + repo.acquire("src/b.rs", "fn-1.2").unwrap(); + + let released = repo.release_all().unwrap(); + assert_eq!(released, 2); + + assert_eq!(repo.check("src/a.rs").unwrap(), None); + assert_eq!(repo.check("src/b.rs").unwrap(), None); + } + + // ── Event tests ───────────────────────────────────────────────── + + #[test] + fn test_event_insert_and_list() { + let conn = test_conn(); + + // Need an epic for the trigger's FK. + conn.execute( + "INSERT INTO epics (id, title, status, file_path, created_at, updated_at) + VALUES ('fn-1-test', 'Test', 'open', 'e.md', '2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z')", + [], + ).unwrap(); + + let repo = EventRepo::new(&conn); + + repo.insert("fn-1-test", Some("fn-1-test.1"), "task_started", Some("worker"), None, None) + .unwrap(); + repo.insert("fn-1-test", Some("fn-1-test.1"), "task_completed", Some("worker"), None, None) + .unwrap(); + + let events = repo.list_by_epic("fn-1-test", 10).unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].event_type, "task_completed"); // DESC order + assert_eq!(events[1].event_type, "task_started"); + } + + // ── Runtime state tests ───────────────────────────────────────── + + #[test] + fn test_runtime_state_upsert_and_get() { + let conn = test_conn(); + let repo = RuntimeRepo::new(&conn); + + let state = RuntimeState { + task_id: "fn-1-test.1".to_string(), + assignee: Some("worker-1".to_string()), + claimed_at: Some(Utc::now()), + completed_at: None, + duration_secs: None, + blocked_reason: None, + baseline_rev: Some("abc123".to_string()), + final_rev: None, + retry_count: 0, + }; + + repo.upsert(&state).unwrap(); + let loaded = repo.get("fn-1-test.1").unwrap().unwrap(); + assert_eq!(loaded.assignee, Some("worker-1".to_string())); + assert_eq!(loaded.baseline_rev, Some("abc123".to_string())); + } + + #[test] + fn test_runtime_state_not_found() { + let conn = test_conn(); + let repo = RuntimeRepo::new(&conn); + let result = repo.get("nonexistent").unwrap(); + assert!(result.is_none()); + } + + // ── Evidence tests ────────────────────────────────────────────── + + #[test] + fn test_evidence_upsert_and_get() { + let conn = test_conn(); + let repo = EvidenceRepo::new(&conn); + + let evidence = Evidence { + commits: vec!["abc123".to_string(), "def456".to_string()], + tests: vec!["cargo test".to_string()], + prs: vec![], + files_changed: Some(5), + insertions: Some(100), + deletions: Some(20), + review_iterations: Some(2), + workspace_changes: None, + }; + + repo.upsert("fn-1-test.1", &evidence).unwrap(); + let loaded = repo.get("fn-1-test.1").unwrap().unwrap(); + assert_eq!(loaded.commits, vec!["abc123", "def456"]); + assert_eq!(loaded.tests, vec!["cargo test"]); + assert_eq!(loaded.files_changed, Some(5)); + assert_eq!(loaded.insertions, Some(100)); + assert_eq!(loaded.deletions, Some(20)); + assert_eq!(loaded.review_iterations, Some(2)); + } +} diff --git a/flowctl/crates/flowctl-db/src/schema.sql b/flowctl/crates/flowctl-db/src/schema.sql new file mode 100644 index 00000000..8667199c --- /dev/null +++ b/flowctl/crates/flowctl-db/src/schema.sql @@ -0,0 +1,166 @@ +-- flowctl SQLite schema reference (canonical version lives in migrations/) +-- This file is for documentation and IDE support only. +-- See migrations/01-initial/up.sql for the migration that creates these tables. + +-- ═══ Indexed from Markdown frontmatter (rebuildable via reindex) ═══ + +CREATE TABLE epics ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + branch_name TEXT, + plan_review TEXT DEFAULT 'unknown', + file_path TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + epic_id TEXT NOT NULL REFERENCES epics(id), + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'todo', + priority INTEGER DEFAULT 999, + domain TEXT DEFAULT 'general', + file_path TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE task_deps ( + task_id TEXT NOT NULL, + depends_on TEXT NOT NULL, + PRIMARY KEY (task_id, depends_on) +); + +CREATE TABLE epic_deps ( + epic_id TEXT NOT NULL, + depends_on TEXT NOT NULL, + PRIMARY KEY (epic_id, depends_on) +); + +CREATE TABLE file_ownership ( + file_path TEXT NOT NULL, + task_id TEXT NOT NULL, + PRIMARY KEY (file_path, task_id) +); + +-- ═══ Runtime-only data (not in Markdown, not rebuildable) ═══ + +CREATE TABLE runtime_state ( + task_id TEXT PRIMARY KEY, + assignee TEXT, + claimed_at TEXT, + completed_at TEXT, + duration_secs INTEGER, + blocked_reason TEXT, + baseline_rev TEXT, + final_rev TEXT, + retry_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE file_locks ( + file_path TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + locked_at TEXT NOT NULL +); + +CREATE TABLE heartbeats ( + task_id TEXT PRIMARY KEY, + last_beat TEXT NOT NULL, + worker_pid INTEGER +); + +CREATE TABLE phase_progress ( + task_id TEXT NOT NULL, + phase TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + completed_at TEXT, + PRIMARY KEY (task_id, phase) +); + +CREATE TABLE evidence ( + task_id TEXT PRIMARY KEY, + commits TEXT, + tests TEXT, + files_changed INTEGER, + insertions INTEGER, + deletions INTEGER, + review_iters INTEGER +); + +-- ═══ Event log + metrics (append-only, runtime-only) ═══ + +CREATE TABLE events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + epic_id TEXT NOT NULL, + task_id TEXT, + event_type TEXT NOT NULL, + actor TEXT, + payload TEXT, + session_id TEXT +); + +CREATE TABLE token_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + epic_id TEXT NOT NULL, + task_id TEXT, + phase TEXT, + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read INTEGER DEFAULT 0, + cache_write INTEGER DEFAULT 0, + estimated_cost REAL +); + +CREATE TABLE daily_rollup ( + day TEXT NOT NULL, + epic_id TEXT, + tasks_started INTEGER DEFAULT 0, + tasks_completed INTEGER DEFAULT 0, + tasks_failed INTEGER DEFAULT 0, + total_duration_s INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + PRIMARY KEY (day, epic_id) +); + +CREATE TABLE monthly_rollup ( + month TEXT PRIMARY KEY, + epics_completed INTEGER DEFAULT 0, + tasks_completed INTEGER DEFAULT 0, + avg_lead_time_h REAL DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + total_cost_usd REAL DEFAULT 0 +); + +-- ═══ Indexes ═══ + +CREATE INDEX idx_tasks_epic ON tasks(epic_id); +CREATE INDEX idx_tasks_status ON tasks(status); +CREATE INDEX idx_events_entity ON events(epic_id, task_id); +CREATE INDEX idx_events_ts ON events(timestamp); +CREATE INDEX idx_events_type ON events(event_type, timestamp); +CREATE INDEX idx_token_epic ON token_usage(epic_id); + +-- ═══ Auto-aggregation triggers ═══ + +CREATE TRIGGER trg_daily_rollup AFTER INSERT ON events +WHEN NEW.event_type IN ('task_completed', 'task_failed', 'task_started') +BEGIN + INSERT INTO daily_rollup (day, epic_id, tasks_completed, tasks_failed, tasks_started) + VALUES (DATE(NEW.timestamp), NEW.epic_id, + CASE WHEN NEW.event_type = 'task_completed' THEN 1 ELSE 0 END, + CASE WHEN NEW.event_type = 'task_failed' THEN 1 ELSE 0 END, + CASE WHEN NEW.event_type = 'task_started' THEN 1 ELSE 0 END) + ON CONFLICT(day, epic_id) DO UPDATE SET + tasks_completed = tasks_completed + + CASE WHEN NEW.event_type = 'task_completed' THEN 1 ELSE 0 END, + tasks_failed = tasks_failed + + CASE WHEN NEW.event_type = 'task_failed' THEN 1 ELSE 0 END, + tasks_started = tasks_started + + CASE WHEN NEW.event_type = 'task_started' THEN 1 ELSE 0 END; +END; diff --git a/flowctl/crates/flowctl-db/src/sync.rs b/flowctl/crates/flowctl-db/src/sync.rs new file mode 100644 index 00000000..a4b9482c --- /dev/null +++ b/flowctl/crates/flowctl-db/src/sync.rs @@ -0,0 +1,662 @@ +//! Bidirectional Markdown-SQLite sync. +//! +//! **Invariant**: SQLite is updated first (in a transaction), then Markdown +//! frontmatter is written. If the Markdown write fails after SQLite commit, +//! the row is marked `pending_sync` and retried on the next operation. +//! +//! Staleness detection compares the Markdown file's mtime against the +//! SQLite `updated_at` timestamp. If Markdown is newer, the frontmatter +//! is re-parsed and SQLite is refreshed. +//! +//! Concurrent modification is guarded by checking mtime before overwrite. + +use std::fs; +use std::path::Path; +use std::time::SystemTime; + +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection}; +use tracing::{error, info, warn}; + +use flowctl_core::frontmatter; +use flowctl_core::types::{Epic, Task}; + +use crate::error::DbError; +use crate::repo::{EpicRepo, TaskRepo}; + +/// Sync status for a row whose Markdown write failed after SQLite commit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncStatus { + /// Both SQLite and Markdown are in sync. + Synced, + /// SQLite was updated but Markdown write failed; needs retry. + PendingSyncMd, + /// Markdown is newer than SQLite; needs re-read. + StaleSqlite, +} + +/// Write an epic to both SQLite (first) and Markdown (second). +/// +/// If the Markdown write fails, the epic is marked `pending_sync` in +/// the `sync_state` table and the error is logged (not propagated). +pub fn write_epic( + conn: &Connection, + flow_dir: &Path, + epic: &Epic, + body: &str, +) -> Result { + // Step 1: Update SQLite in a transaction. + let repo = EpicRepo::new(conn); + repo.upsert(epic)?; + + // Step 2: Write Markdown frontmatter. + let file_path = epic_md_path(flow_dir, &epic.id); + let doc = frontmatter::Document { + frontmatter: epic.clone(), + body: body.to_string(), + }; + + match frontmatter::write(&doc) { + Ok(content) => match write_md_safe(&file_path, &content) { + Ok(()) => { + clear_pending_sync(conn, &epic.id); + Ok(SyncStatus::Synced) + } + Err(e) => { + error!(id = %epic.id, err = %e, "markdown write failed after sqlite commit"); + mark_pending_sync(conn, &epic.id, "epic"); + Ok(SyncStatus::PendingSyncMd) + } + }, + Err(e) => { + error!(id = %epic.id, err = %e, "frontmatter serialize failed"); + mark_pending_sync(conn, &epic.id, "epic"); + Ok(SyncStatus::PendingSyncMd) + } + } +} + +/// Write a task to both SQLite (first) and Markdown (second). +/// +/// Same guarantees as [`write_epic`]. +pub fn write_task( + conn: &Connection, + flow_dir: &Path, + task: &Task, + body: &str, +) -> Result { + // Step 1: Update SQLite. + let repo = TaskRepo::new(conn); + repo.upsert(task)?; + + // Step 2: Write Markdown. + let file_path = task_md_path(flow_dir, &task.id); + let doc = frontmatter::Document { + frontmatter: task.clone(), + body: body.to_string(), + }; + + match frontmatter::write(&doc) { + Ok(content) => match write_md_safe(&file_path, &content) { + Ok(()) => { + clear_pending_sync(conn, &task.id); + Ok(SyncStatus::Synced) + } + Err(e) => { + error!(id = %task.id, err = %e, "markdown write failed after sqlite commit"); + mark_pending_sync(conn, &task.id, "task"); + Ok(SyncStatus::PendingSyncMd) + } + }, + Err(e) => { + error!(id = %task.id, err = %e, "frontmatter serialize failed"); + mark_pending_sync(conn, &task.id, "task"); + Ok(SyncStatus::PendingSyncMd) + } + } +} + +/// Write a task to SQLite and optionally write a legacy JSON state file. +/// +/// When `--legacy-json` is active, also writes a Python-compatible +/// `.state.json` file alongside the Markdown+SQLite sync. +pub fn write_task_with_legacy( + conn: &Connection, + flow_dir: &Path, + state_dir: &Path, + task: &Task, + body: &str, +) -> Result { + let status = write_task(conn, flow_dir, task, body)?; + + // Write legacy JSON for Python compatibility. + if let Err(e) = write_legacy_json(state_dir, task) { + warn!(id = %task.id, err = %e, "legacy JSON write failed (non-fatal)"); + } + + Ok(status) +} + +/// Check staleness: compare Markdown mtime against SQLite `updated_at`. +/// +/// Returns `StaleSqlite` if Markdown was modified after the SQLite row, +/// `PendingSyncMd` if there is a pending sync entry, or `Synced`. +pub fn check_staleness( + conn: &Connection, + flow_dir: &Path, + id: &str, + entity: &str, +) -> SyncStatus { + // Check pending_sync table first. + if is_pending_sync(conn, id) { + return SyncStatus::PendingSyncMd; + } + + let (md_path, db_updated_at) = match entity { + "epic" => { + let path = epic_md_path(flow_dir, id); + let updated = get_db_updated_at(conn, "epics", id); + (path, updated) + } + "task" => { + let path = task_md_path(flow_dir, id); + let updated = get_db_updated_at(conn, "tasks", id); + (path, updated) + } + _ => return SyncStatus::Synced, + }; + + let db_updated = match db_updated_at { + Some(dt) => dt, + None => return SyncStatus::Synced, // not in DB yet + }; + + match file_mtime(&md_path) { + Some(mtime) => { + // Allow 2-second tolerance: file writes naturally have a + // slightly newer mtime than the `updated_at` stored in SQLite, + // because the struct is created before the file is written. + let tolerance = chrono::Duration::seconds(2); + if mtime > db_updated + tolerance { + SyncStatus::StaleSqlite + } else { + SyncStatus::Synced + } + } + None => SyncStatus::Synced, // file doesn't exist + } +} + +/// Refresh SQLite from Markdown if stale. +/// +/// Reads the Markdown file, parses frontmatter, and upserts into SQLite. +pub fn refresh_if_stale( + conn: &Connection, + flow_dir: &Path, + id: &str, + entity: &str, +) -> Result { + let status = check_staleness(conn, flow_dir, id, entity); + + match status { + SyncStatus::StaleSqlite => { + info!(id, entity, "refreshing stale sqlite from markdown"); + match entity { + "epic" => { + let path = epic_md_path(flow_dir, id); + let content = fs::read_to_string(&path).map_err(|e| { + DbError::StateDir(format!("failed to read {}: {e}", path.display())) + })?; + let epic: Epic = flowctl_core::frontmatter::parse_frontmatter(&content) + .map_err(|e| DbError::Migration(format!("parse error: {e}")))?; + EpicRepo::new(conn).upsert(&epic)?; + } + "task" => { + let path = task_md_path(flow_dir, id); + let content = fs::read_to_string(&path).map_err(|e| { + DbError::StateDir(format!("failed to read {}: {e}", path.display())) + })?; + let task: Task = flowctl_core::frontmatter::parse_frontmatter(&content) + .map_err(|e| DbError::Migration(format!("parse error: {e}")))?; + TaskRepo::new(conn).upsert(&task)?; + } + _ => {} + } + Ok(true) + } + SyncStatus::PendingSyncMd => { + // Try to retry the pending Markdown write. + // We'd need the body for this, so just log and skip. + warn!(id, "pending_sync entry exists; re-run write to resolve"); + Ok(false) + } + SyncStatus::Synced => Ok(false), + } +} + +/// Retry all pending sync entries. +/// +/// For each pending entry, re-read from SQLite and re-write the Markdown. +/// Returns the number of entries successfully resolved. +pub fn retry_pending(conn: &Connection, flow_dir: &Path) -> Result { + let pending = list_pending_sync(conn)?; + let mut resolved = 0; + + for (id, entity) in &pending { + let result = match entity.as_str() { + "epic" => { + let repo = EpicRepo::new(conn); + match repo.get(id) { + Ok(epic) => { + let path = epic_md_path(flow_dir, id); + let body = read_body(&path); + let doc = frontmatter::Document { + frontmatter: epic, + body, + }; + match frontmatter::write(&doc) { + Ok(content) => write_md_safe(&path, &content), + Err(e) => Err(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )), + } + } + Err(_) => continue, + } + } + "task" => { + let repo = TaskRepo::new(conn); + match repo.get(id) { + Ok(task) => { + let path = task_md_path(flow_dir, id); + let body = read_body(&path); + let doc = frontmatter::Document { + frontmatter: task, + body, + }; + match frontmatter::write(&doc) { + Ok(content) => write_md_safe(&path, &content), + Err(e) => Err(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )), + } + } + Err(_) => continue, + } + } + _ => continue, + }; + + match result { + Ok(()) => { + clear_pending_sync(conn, id); + resolved += 1; + info!(id, entity, "resolved pending sync"); + } + Err(e) => { + warn!(id, entity, err = %e, "pending sync retry still failing"); + } + } + } + + Ok(resolved) +} + +// ── Internal helpers ─────────────────────────────────────────────── + +/// Construct the Markdown path for an epic. +fn epic_md_path(flow_dir: &Path, id: &str) -> std::path::PathBuf { + flow_dir.join("epics").join(format!("{id}.md")) +} + +/// Construct the Markdown path for a task. +fn task_md_path(flow_dir: &Path, id: &str) -> std::path::PathBuf { + flow_dir.join("tasks").join(format!("{id}.md")) +} + +/// Write Markdown content to a file with concurrent modification check. +/// +/// Reads the file's current mtime before writing. If another process +/// modified the file between our read and write, we detect it. +fn write_md_safe(path: &Path, content: &str) -> Result<(), std::io::Error> { + // Capture pre-write mtime if file exists. + let pre_mtime = file_mtime(path); + + // Ensure parent directory exists. + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + // Check for concurrent modification: if mtime changed since we last + // read, another process may have modified the file. + if let Some(pre) = pre_mtime { + if let Some(current) = file_mtime(path) { + if current != pre { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "concurrent modification detected on {}", + path.display() + ), + )); + } + } + } + + fs::write(path, content) +} + +/// Get file modification time as DateTime. +fn file_mtime(path: &Path) -> Option> { + fs::metadata(path) + .ok() + .and_then(|m| m.modified().ok()) + .map(system_time_to_utc) +} + +/// Convert SystemTime to DateTime. +fn system_time_to_utc(st: SystemTime) -> DateTime { + let duration = st + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + DateTime::from_timestamp(duration.as_secs() as i64, duration.subsec_nanos()) + .unwrap_or_else(|| Utc::now()) +} + +/// Get the `updated_at` value from a SQLite table. +fn get_db_updated_at(conn: &Connection, table: &str, id: &str) -> Option> { + let sql = format!("SELECT updated_at FROM {table} WHERE id = ?1"); + conn.query_row(&sql, params![id], |row| row.get::<_, String>(0)) + .ok() + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)) +} + +/// Read the body (everything after frontmatter) from an existing Markdown file. +fn read_body(path: &Path) -> String { + match fs::read_to_string(path) { + Ok(content) => { + match frontmatter::parse::(&content) { + Ok(doc) => doc.body, + Err(_) => String::new(), + } + } + Err(_) => String::new(), + } +} + +/// Write a Python-compatible `.state.json` file for legacy support. +fn write_legacy_json(state_dir: &Path, task: &Task) -> Result<(), std::io::Error> { + let tasks_dir = state_dir.join("tasks"); + fs::create_dir_all(&tasks_dir)?; + + let json = serde_json::json!({ + "status": task.status.to_string(), + "updated_at": task.updated_at.to_rfc3339(), + }); + + let path = tasks_dir.join(format!("{}.state.json", task.id)); + fs::write(path, serde_json::to_string_pretty(&json).unwrap_or_default()) +} + +// ── Pending sync table helpers ───────────────────────────────────── + +/// Ensure the `sync_state` table exists (created alongside migrations). +fn ensure_sync_table(conn: &Connection) { + let _ = conn.execute_batch( + "CREATE TABLE IF NOT EXISTS sync_state ( + id TEXT PRIMARY KEY, + entity TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending_sync_md', + failed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + )" + ); +} + +/// Mark an entity as pending sync. +fn mark_pending_sync(conn: &Connection, id: &str, entity: &str) { + ensure_sync_table(conn); + let _ = conn.execute( + "INSERT INTO sync_state (id, entity) VALUES (?1, ?2) + ON CONFLICT(id) DO UPDATE SET + failed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')", + params![id, entity], + ); +} + +/// Clear a pending sync entry (after successful Markdown write). +fn clear_pending_sync(conn: &Connection, id: &str) { + ensure_sync_table(conn); + let _ = conn.execute("DELETE FROM sync_state WHERE id = ?1", params![id]); +} + +/// Check if an entity has a pending sync entry. +fn is_pending_sync(conn: &Connection, id: &str) -> bool { + ensure_sync_table(conn); + conn.query_row( + "SELECT COUNT(*) FROM sync_state WHERE id = ?1", + params![id], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0) + > 0 +} + +/// List all pending sync entries. +fn list_pending_sync(conn: &Connection) -> Result, DbError> { + ensure_sync_table(conn); + let mut stmt = conn.prepare("SELECT id, entity FROM sync_state")?; + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?; + Ok(rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pool::open_memory; + use flowctl_core::state_machine::Status; + use flowctl_core::types::{Domain, EpicStatus, ReviewStatus}; + use tempfile::TempDir; + + fn test_epic(id: &str) -> Epic { + Epic { + schema_version: 1, + id: id.to_string(), + title: "Test Epic".to_string(), + status: EpicStatus::Open, + branch_name: None, + plan_review: ReviewStatus::Unknown, + completion_review: ReviewStatus::Unknown, + depends_on_epics: vec![], + default_impl: None, + default_review: None, + default_sync: None, + file_path: Some(format!("epics/{id}.md")), + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + fn test_task(id: &str, epic: &str) -> Task { + Task { + schema_version: 1, + id: id.to_string(), + epic: epic.to_string(), + title: "Test Task".to_string(), + status: Status::Todo, + priority: None, + domain: Domain::General, + depends_on: vec![], + files: vec![], + r#impl: None, + review: None, + sync: None, + file_path: Some(format!("tasks/{id}.md")), + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + fn setup_flow_dir() -> TempDir { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("epics")).unwrap(); + fs::create_dir_all(tmp.path().join("tasks")).unwrap(); + tmp + } + + #[test] + fn test_write_epic_syncs_both() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + + let epic = test_epic("fn-1-test"); + let status = write_epic(&conn, tmp.path(), &epic, "## Description\nTest.\n").unwrap(); + assert_eq!(status, SyncStatus::Synced); + + // Verify SQLite. + let repo = EpicRepo::new(&conn); + let loaded = repo.get("fn-1-test").unwrap(); + assert_eq!(loaded.title, "Test Epic"); + + // Verify Markdown file exists. + let md_path = tmp.path().join("epics/fn-1-test.md"); + assert!(md_path.exists()); + let content = fs::read_to_string(&md_path).unwrap(); + assert!(content.contains("fn-1-test")); + } + + #[test] + fn test_write_task_syncs_both() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + + // Need epic first (FK). + let epic = test_epic("fn-1-test"); + write_epic(&conn, tmp.path(), &epic, "").unwrap(); + + let task = test_task("fn-1-test.1", "fn-1-test"); + let status = write_task(&conn, tmp.path(), &task, "## Description\nDo thing.\n").unwrap(); + assert_eq!(status, SyncStatus::Synced); + + // Verify SQLite. + let repo = TaskRepo::new(&conn); + let loaded = repo.get("fn-1-test.1").unwrap(); + assert_eq!(loaded.title, "Test Task"); + + // Verify Markdown. + let md_path = tmp.path().join("tasks/fn-1-test.1.md"); + assert!(md_path.exists()); + } + + #[test] + fn test_staleness_detection_synced() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + + let epic = test_epic("fn-1-test"); + write_epic(&conn, tmp.path(), &epic, "").unwrap(); + + let status = check_staleness(&conn, tmp.path(), "fn-1-test", "epic"); + assert_eq!(status, SyncStatus::Synced); + } + + #[test] + fn test_staleness_detection_stale() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + + let epic = test_epic("fn-1-test"); + write_epic(&conn, tmp.path(), &epic, "").unwrap(); + + // Manually update the Markdown file to make it "newer". + // We need to set SQLite updated_at to an older time. + conn.execute( + "UPDATE epics SET updated_at = '2020-01-01T00:00:00Z' WHERE id = 'fn-1-test'", + [], + ) + .unwrap(); + + // Touch the file to update its mtime. + let md_path = tmp.path().join("epics/fn-1-test.md"); + let content = fs::read_to_string(&md_path).unwrap(); + fs::write(&md_path, content).unwrap(); + + let status = check_staleness(&conn, tmp.path(), "fn-1-test", "epic"); + assert_eq!(status, SyncStatus::StaleSqlite); + } + + #[test] + fn test_refresh_if_stale() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + + let epic = test_epic("fn-1-test"); + write_epic(&conn, tmp.path(), &epic, "## Body\n").unwrap(); + + // Make SQLite stale by backdating updated_at. + conn.execute( + "UPDATE epics SET updated_at = '2020-01-01T00:00:00Z' WHERE id = 'fn-1-test'", + [], + ) + .unwrap(); + // Touch file. + let md_path = tmp.path().join("epics/fn-1-test.md"); + let content = fs::read_to_string(&md_path).unwrap(); + fs::write(&md_path, content).unwrap(); + + let refreshed = refresh_if_stale(&conn, tmp.path(), "fn-1-test", "epic").unwrap(); + assert!(refreshed); + } + + #[test] + fn test_pending_sync_lifecycle() { + let conn = open_memory().unwrap(); + + assert!(!is_pending_sync(&conn, "fn-1-test")); + + mark_pending_sync(&conn, "fn-1-test", "epic"); + assert!(is_pending_sync(&conn, "fn-1-test")); + + clear_pending_sync(&conn, "fn-1-test"); + assert!(!is_pending_sync(&conn, "fn-1-test")); + } + + #[test] + fn test_legacy_json_write() { + let tmp = TempDir::new().unwrap(); + let task = test_task("fn-1-test.1", "fn-1-test"); + + write_legacy_json(tmp.path(), &task).unwrap(); + + let json_path = tmp.path().join("tasks/fn-1-test.1.state.json"); + assert!(json_path.exists()); + + let content = fs::read_to_string(&json_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed["status"], "todo"); + } + + #[test] + fn test_retry_pending_resolves() { + let conn = open_memory().unwrap(); + let tmp = setup_flow_dir(); + + // Write epic to SQLite only (simulate failed MD write). + let epic = test_epic("fn-1-test"); + EpicRepo::new(&conn).upsert(&epic).unwrap(); + mark_pending_sync(&conn, "fn-1-test", "epic"); + + // Retry should write the MD file. + let resolved = retry_pending(&conn, tmp.path()).unwrap(); + assert_eq!(resolved, 1); + assert!(!is_pending_sync(&conn, "fn-1-test")); + + // MD file should now exist. + let md_path = tmp.path().join("epics/fn-1-test.md"); + assert!(md_path.exists()); + } +} diff --git a/flowctl/crates/flowctl-scheduler/Cargo.toml b/flowctl/crates/flowctl-scheduler/Cargo.toml new file mode 100644 index 00000000..69d5a270 --- /dev/null +++ b/flowctl/crates/flowctl-scheduler/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "flowctl-scheduler" +version = "0.1.0" +description = "DAG scheduler and event bus for flowctl" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[features] +default = [] +daemon = [ + "dep:notify", +] + +[dependencies] +flowctl-core = { workspace = true } +flowctl-db = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +petgraph = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } + +# Daemon-only deps (feature-gated) +notify = { workspace = true, optional = true } + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true } diff --git a/flowctl/crates/flowctl-scheduler/src/circuit_breaker.rs b/flowctl/crates/flowctl-scheduler/src/circuit_breaker.rs new file mode 100644 index 00000000..013b5a8d --- /dev/null +++ b/flowctl/crates/flowctl-scheduler/src/circuit_breaker.rs @@ -0,0 +1,143 @@ +//! Circuit breaker: N consecutive failures triggers scheduler halt. +//! +//! When the breaker opens, the scheduler cancels all in-flight tasks +//! via `CancellationToken` and stops dispatching new work. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + +use tracing::error; + +/// Circuit breaker that opens after N consecutive task failures. +/// +/// Thread-safe via atomics — no mutex needed for the hot path. +#[derive(Debug)] +pub struct CircuitBreaker { + /// Number of consecutive failures before tripping. + threshold: u32, + /// Current consecutive failure count. + consecutive_failures: AtomicU32, + /// Whether the breaker is open (tripped). + open: AtomicBool, +} + +impl CircuitBreaker { + /// Create a new circuit breaker with the given failure threshold. + pub fn new(threshold: u32) -> Self { + Self { + threshold, + consecutive_failures: AtomicU32::new(0), + open: AtomicBool::new(false), + } + } + + /// Record a successful task completion. Resets the failure counter. + pub fn record_success(&self) { + self.consecutive_failures.store(0, Ordering::SeqCst); + } + + /// Record a task failure. If consecutive failures reach the threshold, + /// the breaker opens. + pub fn record_failure(&self) { + let prev = self.consecutive_failures.fetch_add(1, Ordering::SeqCst); + let count = prev + 1; + if count >= self.threshold { + error!( + consecutive_failures = count, + threshold = self.threshold, + "circuit breaker tripped — halting scheduler" + ); + self.open.store(true, Ordering::SeqCst); + } + } + + /// Check whether the circuit breaker is open (tripped). + pub fn is_open(&self) -> bool { + self.open.load(Ordering::SeqCst) + } + + /// Reset the circuit breaker to closed state. + pub fn reset(&self) { + self.consecutive_failures.store(0, Ordering::SeqCst); + self.open.store(false, Ordering::SeqCst); + } + + /// Get the current consecutive failure count. + pub fn failure_count(&self) -> u32 { + self.consecutive_failures.load(Ordering::SeqCst) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initial_state() { + let cb = CircuitBreaker::new(3); + assert!(!cb.is_open()); + assert_eq!(cb.failure_count(), 0); + } + + #[test] + fn test_trips_at_threshold() { + let cb = CircuitBreaker::new(3); + cb.record_failure(); + assert!(!cb.is_open()); + cb.record_failure(); + assert!(!cb.is_open()); + cb.record_failure(); + assert!(cb.is_open()); + } + + #[test] + fn test_success_resets_counter() { + let cb = CircuitBreaker::new(3); + cb.record_failure(); + cb.record_failure(); + assert_eq!(cb.failure_count(), 2); + cb.record_success(); + assert_eq!(cb.failure_count(), 0); + assert!(!cb.is_open()); + } + + #[test] + fn test_success_between_failures_prevents_trip() { + let cb = CircuitBreaker::new(3); + cb.record_failure(); + cb.record_failure(); + cb.record_success(); // reset + cb.record_failure(); + cb.record_failure(); + assert!(!cb.is_open()); + } + + #[test] + fn test_reset() { + let cb = CircuitBreaker::new(2); + cb.record_failure(); + cb.record_failure(); + assert!(cb.is_open()); + cb.reset(); + assert!(!cb.is_open()); + assert_eq!(cb.failure_count(), 0); + } + + #[test] + fn test_threshold_one() { + let cb = CircuitBreaker::new(1); + assert!(!cb.is_open()); + cb.record_failure(); + assert!(cb.is_open()); + } + + #[test] + fn test_stays_open_after_trip() { + let cb = CircuitBreaker::new(2); + cb.record_failure(); + cb.record_failure(); + assert!(cb.is_open()); + // Success doesn't close the breaker — only reset does. + cb.record_success(); + assert!(cb.is_open()); + } +} diff --git a/flowctl/crates/flowctl-scheduler/src/event_bus.rs b/flowctl/crates/flowctl-scheduler/src/event_bus.rs new file mode 100644 index 00000000..ce24d9af --- /dev/null +++ b/flowctl/crates/flowctl-scheduler/src/event_bus.rs @@ -0,0 +1,301 @@ +//! Broadcast event bus for flowctl scheduler events. +//! +//! Dual-channel architecture: +//! - `tokio::sync::broadcast` for non-critical consumers (TUI, WebSocket) +//! - `tokio::sync::mpsc` for critical consumers (SQLite logger) +//! +//! Consumers that fall behind on broadcast will receive `Lagged` and +//! skip missed events (acceptable for live dashboards). + +use std::fmt; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{broadcast, mpsc}; +use tracing::{debug, warn}; + +/// Default broadcast channel capacity. +pub const DEFAULT_CHANNEL_CAPACITY: usize = 1024; + +/// Events emitted by the scheduler and subsystems. +/// +/// Each variant carries enough context for consumers to act without +/// needing to query the database. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", content = "data")] +pub enum FlowEvent { + /// A task's dependencies are satisfied; it is ready for dispatch. + TaskReady { task_id: String, epic_id: String }, + + /// A task has been dispatched to a worker. + TaskStarted { task_id: String, epic_id: String }, + + /// A task completed successfully. + TaskCompleted { task_id: String, epic_id: String }, + + /// A task failed (with optional error message). + TaskFailed { + task_id: String, + epic_id: String, + error: Option, + }, + + /// Watchdog detected a zombie task (no heartbeat within timeout). + TaskZombie { task_id: String, epic_id: String }, + + /// A new wave of tasks has started. + WaveStarted { wave: u32, task_count: usize }, + + /// All tasks in the current wave completed. + WaveCompleted { wave: u32 }, + + /// All tasks in the epic are done. + EpicCompleted { epic_id: String }, + + /// Guard check passed. + GuardPassed { task_id: String }, + + /// Guard check failed. + GuardFailed { + task_id: String, + error: Option, + }, + + /// File lock conflict detected. + LockConflict { + task_id: String, + file: String, + held_by: String, + }, + + /// Circuit breaker opened (too many consecutive failures). + CircuitOpen { consecutive_failures: u32 }, + + /// Daemon started successfully. + DaemonStarted { pid: u32 }, + + /// Daemon is shutting down. + DaemonShutdown, +} + +impl fmt::Display for FlowEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FlowEvent::TaskReady { task_id, .. } => write!(f, "task_ready:{task_id}"), + FlowEvent::TaskStarted { task_id, .. } => write!(f, "task_started:{task_id}"), + FlowEvent::TaskCompleted { task_id, .. } => write!(f, "task_completed:{task_id}"), + FlowEvent::TaskFailed { task_id, error, .. } => { + write!(f, "task_failed:{task_id}")?; + if let Some(e) = error { + write!(f, " ({e})")?; + } + Ok(()) + } + FlowEvent::TaskZombie { task_id, .. } => write!(f, "task_zombie:{task_id}"), + FlowEvent::WaveStarted { wave, task_count } => { + write!(f, "wave_started:{wave} ({task_count} tasks)") + } + FlowEvent::WaveCompleted { wave } => write!(f, "wave_completed:{wave}"), + FlowEvent::EpicCompleted { epic_id } => write!(f, "epic_completed:{epic_id}"), + FlowEvent::GuardPassed { task_id } => write!(f, "guard_passed:{task_id}"), + FlowEvent::GuardFailed { task_id, .. } => write!(f, "guard_failed:{task_id}"), + FlowEvent::LockConflict { task_id, file, .. } => { + write!(f, "lock_conflict:{task_id}:{file}") + } + FlowEvent::CircuitOpen { + consecutive_failures, + } => write!(f, "circuit_open:{consecutive_failures}"), + FlowEvent::DaemonStarted { pid } => write!(f, "daemon_started:{pid}"), + FlowEvent::DaemonShutdown => write!(f, "daemon_shutdown"), + } + } +} + +/// Timestamped wrapper around a FlowEvent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimestampedEvent { + /// When the event was emitted. + pub timestamp: DateTime, + /// The event payload. + pub event: FlowEvent, +} + +/// The event bus: broadcast for non-critical consumers, mpsc for critical ones. +#[derive(Clone)] +pub struct EventBus { + /// Broadcast sender for non-critical consumers (TUI, WebSocket). + broadcast_tx: broadcast::Sender, + /// MPSC sender for critical consumers (SQLite logger). + critical_tx: mpsc::Sender, +} + +impl EventBus { + /// Create a new event bus with the given channel capacity. + /// + /// Returns the bus and the critical receiver (caller must spawn a + /// consumer task that drains it to SQLite). + pub fn new(capacity: usize) -> (Self, mpsc::Receiver) { + let (broadcast_tx, _) = broadcast::channel(capacity); + let (critical_tx, critical_rx) = mpsc::channel(capacity); + + let bus = Self { + broadcast_tx, + critical_tx, + }; + + (bus, critical_rx) + } + + /// Create a new event bus with the default capacity. + pub fn with_default_capacity() -> (Self, mpsc::Receiver) { + Self::new(DEFAULT_CHANNEL_CAPACITY) + } + + /// Emit an event to all consumers. + /// + /// The event is sent to both the broadcast channel (best-effort) and + /// the critical mpsc channel (guaranteed delivery unless full). + pub fn emit(&self, event: FlowEvent) { + let stamped = TimestampedEvent { + timestamp: Utc::now(), + event, + }; + + // Broadcast: best-effort (no receivers = no-op). + let broadcast_count = self.broadcast_tx.send(stamped.clone()).unwrap_or(0); + debug!(broadcast_count, event = %stamped.event, "event emitted"); + + // Critical: warn if the channel is full (should not happen in practice). + if let Err(e) = self.critical_tx.try_send(stamped) { + warn!("critical event channel full, event dropped: {e}"); + } + } + + /// Subscribe to the broadcast channel. Returns a receiver that will + /// get all events emitted after this call. + pub fn subscribe(&self) -> broadcast::Receiver { + self.broadcast_tx.subscribe() + } + + /// Get the current number of broadcast subscribers. + pub fn subscriber_count(&self) -> usize { + self.broadcast_tx.receiver_count() + } +} + +impl fmt::Debug for EventBus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EventBus") + .field("subscribers", &self.broadcast_tx.receiver_count()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_emit_to_broadcast_subscriber() { + let (bus, _critical_rx) = EventBus::with_default_capacity(); + let mut rx = bus.subscribe(); + + bus.emit(FlowEvent::DaemonStarted { pid: 42 }); + + let received = rx.recv().await.unwrap(); + assert_eq!(received.event, FlowEvent::DaemonStarted { pid: 42 }); + } + + #[tokio::test] + async fn test_emit_to_critical_channel() { + let (bus, mut critical_rx) = EventBus::with_default_capacity(); + + bus.emit(FlowEvent::DaemonShutdown); + + let received = critical_rx.recv().await.unwrap(); + assert_eq!(received.event, FlowEvent::DaemonShutdown); + } + + #[tokio::test] + async fn test_multiple_broadcast_subscribers() { + let (bus, _critical_rx) = EventBus::with_default_capacity(); + let mut rx1 = bus.subscribe(); + let mut rx2 = bus.subscribe(); + + bus.emit(FlowEvent::TaskStarted { + task_id: "t1".into(), + epic_id: "e1".into(), + }); + + let e1 = rx1.recv().await.unwrap(); + let e2 = rx2.recv().await.unwrap(); + assert_eq!(e1.event, e2.event); + } + + #[tokio::test] + async fn test_no_subscribers_does_not_panic() { + let (bus, _critical_rx) = EventBus::with_default_capacity(); + // No broadcast subscribers — should not panic. + bus.emit(FlowEvent::DaemonShutdown); + } + + #[tokio::test] + async fn test_subscriber_count() { + let (bus, _critical_rx) = EventBus::with_default_capacity(); + assert_eq!(bus.subscriber_count(), 0); + + let _rx1 = bus.subscribe(); + assert_eq!(bus.subscriber_count(), 1); + + let _rx2 = bus.subscribe(); + assert_eq!(bus.subscriber_count(), 2); + + drop(_rx1); + // Note: broadcast receiver count may not update immediately. + } + + #[test] + fn test_flow_event_display() { + let e = FlowEvent::TaskFailed { + task_id: "t1".into(), + epic_id: "e1".into(), + error: Some("boom".into()), + }; + assert_eq!(e.to_string(), "task_failed:t1 (boom)"); + } + + #[test] + fn test_flow_event_serde_roundtrip() { + let event = FlowEvent::WaveStarted { + wave: 3, + task_count: 5, + }; + let json = serde_json::to_string(&event).unwrap(); + let deserialized: FlowEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, event); + } + + #[test] + fn test_timestamped_event_serde() { + let stamped = TimestampedEvent { + timestamp: Utc::now(), + event: FlowEvent::DaemonStarted { pid: 1234 }, + }; + let json = serde_json::to_string(&stamped).unwrap(); + let deserialized: TimestampedEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.event, FlowEvent::DaemonStarted { pid: 1234 }); + } + + #[test] + fn test_event_bus_is_clone() { + let (bus, _rx) = EventBus::with_default_capacity(); + let _bus2 = bus.clone(); + } + + #[test] + fn test_custom_capacity() { + let (bus, _rx) = EventBus::new(16); + let _sub = bus.subscribe(); + bus.emit(FlowEvent::DaemonShutdown); + } +} diff --git a/flowctl/crates/flowctl-scheduler/src/lib.rs b/flowctl/crates/flowctl-scheduler/src/lib.rs new file mode 100644 index 00000000..3fc43883 --- /dev/null +++ b/flowctl/crates/flowctl-scheduler/src/lib.rs @@ -0,0 +1,19 @@ +//! flowctl-scheduler: DAG scheduler and event bus for flowctl. +//! +//! Implements Kahn's algorithm with bounded parallelism, heartbeat +//! watchdog, circuit breaker, and broadcast event bus. + +pub mod circuit_breaker; +pub mod event_bus; +pub mod scheduler; +pub mod watcher; +pub mod watchdog; + +pub use flowctl_core; + +// Re-export key types at crate root. +pub use circuit_breaker::CircuitBreaker; +pub use event_bus::{EventBus, FlowEvent, TimestampedEvent}; +pub use scheduler::{Scheduler, SchedulerConfig, TaskResult}; +pub use watcher::FlowChange; +pub use watchdog::{HeartbeatTable, ZombieAction}; diff --git a/flowctl/crates/flowctl-scheduler/src/scheduler.rs b/flowctl/crates/flowctl-scheduler/src/scheduler.rs new file mode 100644 index 00000000..313fd698 --- /dev/null +++ b/flowctl/crates/flowctl-scheduler/src/scheduler.rs @@ -0,0 +1,431 @@ +//! DAG-driven task scheduler with bounded parallelism. +//! +//! Uses `TaskDag` from flowctl-core for dependency resolution. Tasks are +//! dispatched as `tokio::spawn` handles, bounded by a `Semaphore` (--jobs N). +//! On completion the DAG is queried for newly-ready tasks and they are +//! dispatched immediately. + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::{mpsc, Semaphore}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use flowctl_core::dag::TaskDag; +use flowctl_core::state_machine::Status; + +use crate::circuit_breaker::CircuitBreaker; + +/// Result of a single task execution. +#[derive(Debug, Clone)] +pub struct TaskResult { + /// Task ID that completed. + pub task_id: String, + /// Whether the task succeeded. + pub success: bool, + /// Optional error message on failure. + pub error: Option, +} + +/// Configuration for the scheduler. +#[derive(Debug, Clone)] +pub struct SchedulerConfig { + /// Maximum concurrent tasks (--jobs N). + pub max_parallel: usize, + /// Maximum retries per task before marking failed. + pub max_retries: u32, +} + +impl Default for SchedulerConfig { + fn default() -> Self { + Self { + max_parallel: 4, + max_retries: 2, + } + } +} + +/// Callback type for executing a single task. The scheduler calls this +/// for each dispatched task; the implementation should do the actual work +/// and return a `TaskResult`. +pub type TaskExecutor = Arc TaskResult + Send + Sync>; + +/// DAG scheduler: discovers ready tasks, dispatches them with bounded +/// parallelism, and feeds completions back to discover the next wave. +pub struct Scheduler { + /// The task dependency graph. + dag: TaskDag, + /// Current status of every task. + statuses: HashMap, + /// Per-task retry counts. + retries: HashMap, + /// Configuration. + config: SchedulerConfig, + /// Cooperative cancellation token shared with all spawned tasks. + cancel: CancellationToken, + /// Circuit breaker for consecutive-failure detection. + circuit_breaker: CircuitBreaker, +} + +impl Scheduler { + /// Create a new scheduler from a DAG, initial statuses, and config. + pub fn new( + dag: TaskDag, + statuses: HashMap, + config: SchedulerConfig, + cancel: CancellationToken, + circuit_breaker: CircuitBreaker, + ) -> Self { + Self { + dag, + statuses, + retries: HashMap::new(), + config, + cancel, + circuit_breaker, + } + } + + /// Run the scheduling loop to completion. + /// + /// Returns the final status map when all tasks are done/failed or the + /// circuit breaker trips. + pub async fn run(&mut self, executor: F) -> HashMap + where + F: Fn(String, CancellationToken) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + let semaphore = Arc::new(Semaphore::new(self.config.max_parallel)); + let (result_tx, mut result_rx) = mpsc::unbounded_channel::(); + + let executor = Arc::new(executor); + let mut in_flight: usize = 0; + + // Initial wave: discover all ready tasks. + let ready = self.dag.ready_tasks(&self.statuses); + for task_id in ready { + if self.cancel.is_cancelled() || self.circuit_breaker.is_open() { + break; + } + self.dispatch_task( + &task_id, + &semaphore, + &result_tx, + &executor, + ); + in_flight += 1; + } + + // Main loop: wait for completions and dispatch newly-ready tasks. + while in_flight > 0 { + let result = tokio::select! { + _ = self.cancel.cancelled() => { + info!("scheduler cancelled, draining in-flight tasks"); + break; + } + res = result_rx.recv() => { + match res { + Some(r) => r, + None => break, + } + } + }; + + in_flight -= 1; + self.handle_result(&result); + + // Check circuit breaker after handling the result. + if self.circuit_breaker.is_open() { + warn!("circuit breaker open, cancelling all in-flight tasks"); + self.cancel.cancel(); + break; + } + + // Discover newly-ready tasks. + let newly_ready = self.dag.complete(&result.task_id, &self.statuses); + for task_id in newly_ready { + if self.cancel.is_cancelled() || self.circuit_breaker.is_open() { + break; + } + // Only dispatch if the task is actually Todo. + if self.statuses.get(&task_id).copied().unwrap_or(Status::Todo) == Status::Todo { + self.dispatch_task( + &task_id, + &semaphore, + &result_tx, + &executor, + ); + in_flight += 1; + } + } + } + + self.statuses.clone() + } + + /// Dispatch a single task as a tokio::spawn with semaphore-bounded parallelism. + fn dispatch_task( + &mut self, + task_id: &str, + semaphore: &Arc, + result_tx: &mpsc::UnboundedSender, + executor: &Arc, + ) where + F: Fn(String, CancellationToken) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + debug!(task_id, "dispatching task"); + self.statuses.insert(task_id.to_string(), Status::InProgress); + + let sem = semaphore.clone(); + let tx = result_tx.clone(); + let exec = executor.clone(); + let id = task_id.to_string(); + let child_token = self.cancel.child_token(); + + tokio::spawn(async move { + // Acquire semaphore permit (bounded parallelism). + let _permit = sem.acquire().await; + if child_token.is_cancelled() { + let _ = tx.send(TaskResult { + task_id: id, + success: false, + error: Some("cancelled".to_string()), + }); + return; + } + + let result = exec(id, child_token).await; + let _ = tx.send(result); + }); + } + + /// Handle a completed task result: update statuses, retries, and + /// propagate failures. + fn handle_result(&mut self, result: &TaskResult) { + if result.success { + info!(task_id = %result.task_id, "task completed successfully"); + self.statuses.insert(result.task_id.clone(), Status::Done); + self.circuit_breaker.record_success(); + } else { + let retries = self.retries.entry(result.task_id.clone()).or_insert(0); + *retries += 1; + + if *retries <= self.config.max_retries { + info!( + task_id = %result.task_id, + attempt = *retries, + max = self.config.max_retries, + "task failed, marking up_for_retry" + ); + self.statuses.insert(result.task_id.clone(), Status::UpForRetry); + // Reset to Todo so it can be re-dispatched. + self.statuses.insert(result.task_id.clone(), Status::Todo); + } else { + warn!( + task_id = %result.task_id, + error = ?result.error, + "task failed permanently" + ); + self.statuses.insert(result.task_id.clone(), Status::Failed); + self.circuit_breaker.record_failure(); + + // Propagate failure to downstream tasks. + let affected = self.dag.propagate_failure(&result.task_id); + for downstream_id in affected { + self.statuses.insert(downstream_id, Status::UpstreamFailed); + } + } + } + } + + /// Get a snapshot of current statuses. + pub fn statuses(&self) -> &HashMap { + &self.statuses + } +} + +#[cfg(test)] +mod tests { + use super::*; + use flowctl_core::types::{Domain, Task}; + use chrono::Utc; + + fn make_task(id: &str, deps: &[&str]) -> Task { + Task { + schema_version: 1, + id: id.to_string(), + epic: "test-epic".to_string(), + title: format!("Task {id}"), + status: Status::Todo, + priority: None, + domain: Domain::General, + depends_on: deps.iter().map(|s| s.to_string()).collect(), + files: vec![], + r#impl: None, + review: None, + sync: None, + file_path: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + fn status_map(entries: &[(&str, Status)]) -> HashMap { + entries.iter().map(|(id, s)| (id.to_string(), *s)).collect() + } + + #[tokio::test] + async fn test_scheduler_all_succeed() { + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + make_task("c", &["a"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let statuses = status_map(&[ + ("a", Status::Todo), + ("b", Status::Todo), + ("c", Status::Todo), + ]); + let cancel = CancellationToken::new(); + let cb = CircuitBreaker::new(5); + + let mut scheduler = Scheduler::new(dag, statuses, SchedulerConfig::default(), cancel, cb); + + let final_statuses = scheduler + .run(|task_id, _cancel| async move { + TaskResult { + task_id, + success: true, + error: None, + } + }) + .await; + + assert_eq!(final_statuses["a"], Status::Done); + assert_eq!(final_statuses["b"], Status::Done); + assert_eq!(final_statuses["c"], Status::Done); + } + + #[tokio::test] + async fn test_scheduler_failure_propagates() { + let tasks = vec![ + make_task("a", &[]), + make_task("b", &["a"]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let statuses = status_map(&[ + ("a", Status::Todo), + ("b", Status::Todo), + ]); + let cancel = CancellationToken::new(); + let cb = CircuitBreaker::new(5); + + let config = SchedulerConfig { + max_parallel: 4, + max_retries: 0, // No retries + }; + let mut scheduler = Scheduler::new(dag, statuses, config, cancel, cb); + + let final_statuses = scheduler + .run(|task_id, _cancel| async move { + TaskResult { + task_id, + success: false, + error: Some("boom".to_string()), + } + }) + .await; + + assert_eq!(final_statuses["a"], Status::Failed); + assert_eq!(final_statuses["b"], Status::UpstreamFailed); + } + + #[tokio::test] + async fn test_scheduler_respects_cancellation() { + let tasks = vec![make_task("a", &[])]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let statuses = status_map(&[("a", Status::Todo)]); + let cancel = CancellationToken::new(); + let cb = CircuitBreaker::new(5); + + // Cancel immediately. + cancel.cancel(); + + let mut scheduler = Scheduler::new( + dag, + statuses, + SchedulerConfig::default(), + cancel, + cb, + ); + + let final_statuses = scheduler + .run(|task_id, _cancel| async move { + TaskResult { + task_id, + success: true, + error: None, + } + }) + .await; + + // Task should not have completed since we cancelled before dispatch. + assert_ne!(final_statuses.get("a").copied(), Some(Status::Done)); + } + + #[tokio::test] + async fn test_scheduler_bounded_parallelism() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let tasks = vec![ + make_task("a", &[]), + make_task("b", &[]), + make_task("c", &[]), + make_task("d", &[]), + ]; + let dag = TaskDag::from_tasks(&tasks).unwrap(); + let statuses = status_map(&[ + ("a", Status::Todo), + ("b", Status::Todo), + ("c", Status::Todo), + ("d", Status::Todo), + ]); + let cancel = CancellationToken::new(); + let cb = CircuitBreaker::new(5); + let config = SchedulerConfig { + max_parallel: 2, + max_retries: 0, + }; + + let peak = Arc::new(AtomicUsize::new(0)); + let current = Arc::new(AtomicUsize::new(0)); + + let peak_clone = peak.clone(); + let current_clone = current.clone(); + + let mut scheduler = Scheduler::new(dag, statuses, config, cancel, cb); + + scheduler + .run(move |task_id, _cancel| { + let p = peak_clone.clone(); + let c = current_clone.clone(); + async move { + let val = c.fetch_add(1, Ordering::SeqCst) + 1; + p.fetch_max(val, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + c.fetch_sub(1, Ordering::SeqCst); + TaskResult { + task_id, + success: true, + error: None, + } + } + }) + .await; + + assert!(peak.load(Ordering::SeqCst) <= 2); + } +} diff --git a/flowctl/crates/flowctl-scheduler/src/watchdog.rs b/flowctl/crates/flowctl-scheduler/src/watchdog.rs new file mode 100644 index 00000000..25dc7560 --- /dev/null +++ b/flowctl/crates/flowctl-scheduler/src/watchdog.rs @@ -0,0 +1,238 @@ +//! Heartbeat-based zombie task detection. +//! +//! Workers emit heartbeats every 10s (write to heartbeats table). +//! The watchdog checks every 15s. If no heartbeat within 60s: +//! - If retries remain → `up_for_retry` +//! - Else → `failed` + propagate downstream + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +/// Default heartbeat interval for workers. +pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10); + +/// How often the watchdog checks for zombies. +pub const WATCHDOG_CHECK_INTERVAL: Duration = Duration::from_secs(15); + +/// Time without a heartbeat before a task is considered zombie. +pub const ZOMBIE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Action the watchdog recommends for a zombie task. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ZombieAction { + /// Task should be retried (retries remaining). + Retry(String), + /// Task has exhausted retries — mark failed. + Fail(String), +} + +/// Heartbeat record for a single task. +#[derive(Debug, Clone)] +struct HeartbeatEntry { + /// When the last heartbeat was received. + last_seen: Instant, + /// How many times this task has been retried. + retry_count: u32, +} + +/// Thread-safe heartbeat table. Workers call `heartbeat()`, the watchdog +/// calls `check_zombies()`. +#[derive(Debug, Clone)] +pub struct HeartbeatTable { + inner: Arc>>, + max_retries: u32, +} + +impl HeartbeatTable { + /// Create a new heartbeat table. + pub fn new(max_retries: u32) -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + max_retries, + } + } + + /// Record a heartbeat for a task. Called by workers every HEARTBEAT_INTERVAL. + pub async fn heartbeat(&self, task_id: &str) { + let mut table = self.inner.lock().await; + let entry = table.entry(task_id.to_string()).or_insert(HeartbeatEntry { + last_seen: Instant::now(), + retry_count: 0, + }); + entry.last_seen = Instant::now(); + debug!(task_id, "heartbeat received"); + } + + /// Register a task as active (called when the scheduler dispatches it). + pub async fn register(&self, task_id: &str) { + let mut table = self.inner.lock().await; + table.insert( + task_id.to_string(), + HeartbeatEntry { + last_seen: Instant::now(), + retry_count: 0, + }, + ); + } + + /// Remove a task from the heartbeat table (called on completion). + pub async fn deregister(&self, task_id: &str) { + let mut table = self.inner.lock().await; + table.remove(task_id); + } + + /// Check for zombie tasks — those with no heartbeat within ZOMBIE_TIMEOUT. + /// Returns recommended actions for each zombie. + pub async fn check_zombies(&self) -> Vec { + let now = Instant::now(); + let mut table = self.inner.lock().await; + let mut actions = Vec::new(); + + let zombie_ids: Vec = table + .iter() + .filter(|(_, entry)| now.duration_since(entry.last_seen) > ZOMBIE_TIMEOUT) + .map(|(id, _)| id.clone()) + .collect(); + + for id in zombie_ids { + if let Some(entry) = table.get_mut(&id) { + if entry.retry_count < self.max_retries { + entry.retry_count += 1; + entry.last_seen = Instant::now(); // Reset timer for retry. + warn!(task_id = %id, retry = entry.retry_count, "zombie detected, scheduling retry"); + actions.push(ZombieAction::Retry(id)); + } else { + warn!(task_id = %id, "zombie detected, max retries exhausted — failing"); + table.remove(&id); + actions.push(ZombieAction::Fail(id)); + } + } + } + + actions + } +} + +/// Run the watchdog loop. Checks the heartbeat table every WATCHDOG_CHECK_INTERVAL +/// and sends zombie actions through the returned channel. +pub async fn run_watchdog( + heartbeats: HeartbeatTable, + cancel: CancellationToken, + action_tx: tokio::sync::mpsc::UnboundedSender, +) { + info!("watchdog started (check interval: {}s, timeout: {}s)", + WATCHDOG_CHECK_INTERVAL.as_secs(), + ZOMBIE_TIMEOUT.as_secs(), + ); + + loop { + tokio::select! { + _ = cancel.cancelled() => { + info!("watchdog shutting down"); + return; + } + _ = tokio::time::sleep(WATCHDOG_CHECK_INTERVAL) => { + let actions = heartbeats.check_zombies().await; + for action in actions { + if action_tx.send(action).is_err() { + // Receiver dropped — scheduler is gone. + return; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_heartbeat_registers_task() { + let table = HeartbeatTable::new(2); + table.register("task-1").await; + // No zombies immediately after registration. + let actions = table.check_zombies().await; + assert!(actions.is_empty()); + } + + #[tokio::test] + async fn test_heartbeat_resets_timer() { + let table = HeartbeatTable::new(2); + table.register("task-1").await; + table.heartbeat("task-1").await; + let actions = table.check_zombies().await; + assert!(actions.is_empty()); + } + + #[tokio::test] + async fn test_deregister_removes_task() { + let table = HeartbeatTable::new(2); + table.register("task-1").await; + table.deregister("task-1").await; + let actions = table.check_zombies().await; + assert!(actions.is_empty()); + } + + #[tokio::test] + async fn test_zombie_detection_retry() { + let table = HeartbeatTable::new(2); + + // Manually insert with old timestamp. + { + let mut inner = table.inner.lock().await; + inner.insert( + "task-1".to_string(), + HeartbeatEntry { + last_seen: Instant::now() - Duration::from_secs(120), + retry_count: 0, + }, + ); + } + + let actions = table.check_zombies().await; + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], ZombieAction::Retry("task-1".to_string())); + } + + #[tokio::test] + async fn test_zombie_detection_fail_after_max_retries() { + let table = HeartbeatTable::new(1); + + // Insert with old timestamp and 1 retry already used. + { + let mut inner = table.inner.lock().await; + inner.insert( + "task-1".to_string(), + HeartbeatEntry { + last_seen: Instant::now() - Duration::from_secs(120), + retry_count: 1, + }, + ); + } + + let actions = table.check_zombies().await; + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], ZombieAction::Fail("task-1".to_string())); + } + + #[tokio::test] + async fn test_watchdog_cancellation() { + let table = HeartbeatTable::new(2); + let cancel = CancellationToken::new(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + cancel.cancel(); // Cancel immediately. + + run_watchdog(table, cancel, tx).await; + + // No actions should have been sent. + assert!(rx.try_recv().is_err()); + } +} diff --git a/flowctl/crates/flowctl-scheduler/src/watcher.rs b/flowctl/crates/flowctl-scheduler/src/watcher.rs new file mode 100644 index 00000000..a2723731 --- /dev/null +++ b/flowctl/crates/flowctl-scheduler/src/watcher.rs @@ -0,0 +1,225 @@ +//! File watcher for `.flow/` directory changes. +//! +//! Uses the `notify` crate to watch for filesystem events. Debounces +//! events with a 500ms window — git checkout can fire many events at once. +//! +//! Feature-gated behind `#[cfg(feature = "daemon")]`. + +#[cfg(feature = "daemon")] +mod inner { + use std::path::PathBuf; + use std::time::Duration; + + use notify::{ + Config, Event, RecommendedWatcher, RecursiveMode, Watcher, + }; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use tracing::{debug, info, warn}; + + /// Debounce window: batch events arriving within this duration. + const DEBOUNCE_WINDOW: Duration = Duration::from_millis(500); + + /// A debounced filesystem change notification. + #[derive(Debug, Clone)] + pub struct FlowChange { + /// Paths that changed (deduplicated within the debounce window). + pub paths: Vec, + } + + /// Start watching the `.flow/` directory for changes. + /// + /// Returns a receiver that emits debounced `FlowChange` notifications. + /// The watcher runs until the `CancellationToken` is cancelled. + pub async fn watch_flow_dir( + flow_dir: PathBuf, + cancel: CancellationToken, + ) -> Result, notify::Error> { + let (change_tx, change_rx) = mpsc::unbounded_channel(); + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + + // Create the filesystem watcher. + let event_tx_clone = event_tx.clone(); + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + match res { + Ok(event) => { + let _ = event_tx_clone.send(event); + } + Err(e) => { + warn!("filesystem watch error: {}", e); + } + } + }, + Config::default(), + )?; + + watcher.watch(&flow_dir, RecursiveMode::Recursive)?; + info!(path = %flow_dir.display(), "watching .flow/ for changes"); + + // Spawn debounce task. + tokio::spawn(async move { + let _watcher = watcher; // Keep watcher alive. + + loop { + // Wait for the first event or cancellation. + let first_event = tokio::select! { + _ = cancel.cancelled() => { + info!("file watcher shutting down"); + return; + } + event = event_rx.recv() => { + match event { + Some(e) => e, + None => return, + } + } + }; + + // Collect paths from the first event. + let mut changed_paths: Vec = first_event.paths; + + // Debounce: collect more events within the window. + let deadline = tokio::time::Instant::now() + DEBOUNCE_WINDOW; + loop { + tokio::select! { + _ = cancel.cancelled() => { + info!("file watcher shutting down during debounce"); + return; + } + _ = tokio::time::sleep_until(deadline) => { + break; // Debounce window elapsed. + } + event = event_rx.recv() => { + match event { + Some(e) => { + changed_paths.extend(e.paths); + } + None => return, + } + } + } + } + + // Deduplicate paths. + changed_paths.sort(); + changed_paths.dedup(); + + debug!( + count = changed_paths.len(), + "debounced filesystem change batch" + ); + + let change = FlowChange { + paths: changed_paths, + }; + + if change_tx.send(change).is_err() { + // Receiver dropped. + return; + } + } + }); + + Ok(change_rx) + } +} + +#[cfg(feature = "daemon")] +pub use inner::*; + +// Stub types when daemon feature is not enabled, so downstream code +// can reference the module without feature gates everywhere. +#[cfg(not(feature = "daemon"))] +mod stub { + /// Placeholder for when the daemon feature is disabled. + #[derive(Debug, Clone)] + pub struct FlowChange { + pub paths: Vec, + } +} + +#[cfg(not(feature = "daemon"))] +pub use stub::*; + +#[cfg(all(test, feature = "daemon"))] +mod tests { + use super::*; + use tempfile::TempDir; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_watch_detects_file_creation() { + let tmp = TempDir::new().unwrap(); + let flow_dir = tmp.path().join(".flow"); + std::fs::create_dir_all(&flow_dir).unwrap(); + + let cancel = CancellationToken::new(); + let mut rx = watch_flow_dir(flow_dir.clone(), cancel.clone()) + .await + .unwrap(); + + // Create a file in the watched directory. + let test_file = flow_dir.join("test.json"); + std::fs::write(&test_file, "{}").unwrap(); + + // Wait for the debounced notification. + let change = tokio::time::timeout( + std::time::Duration::from_secs(3), + rx.recv(), + ) + .await + .expect("timeout waiting for change") + .expect("channel closed"); + + assert!(!change.paths.is_empty()); + + cancel.cancel(); + } + + #[tokio::test] + async fn test_watch_cancellation() { + let tmp = TempDir::new().unwrap(); + let flow_dir = tmp.path().join(".flow"); + std::fs::create_dir_all(&flow_dir).unwrap(); + + let cancel = CancellationToken::new(); + let _rx = watch_flow_dir(flow_dir, cancel.clone()).await.unwrap(); + + cancel.cancel(); + + // Give the watcher task a moment to shut down. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + #[tokio::test] + async fn test_debounce_batches_events() { + let tmp = TempDir::new().unwrap(); + let flow_dir = tmp.path().join(".flow"); + std::fs::create_dir_all(&flow_dir).unwrap(); + + let cancel = CancellationToken::new(); + let mut rx = watch_flow_dir(flow_dir.clone(), cancel.clone()) + .await + .unwrap(); + + // Create multiple files rapidly (should be batched). + for i in 0..5 { + std::fs::write(flow_dir.join(format!("file-{i}.json")), "{}").unwrap(); + } + + // Should receive one debounced batch. + let change = tokio::time::timeout( + std::time::Duration::from_secs(3), + rx.recv(), + ) + .await + .expect("timeout") + .expect("channel closed"); + + // The batch should contain multiple paths (at least some of them). + assert!(!change.paths.is_empty()); + + cancel.cancel(); + } +} diff --git a/flowctl/crates/flowctl-tui/Cargo.toml b/flowctl/crates/flowctl-tui/Cargo.toml new file mode 100644 index 00000000..544edf90 --- /dev/null +++ b/flowctl/crates/flowctl-tui/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "flowctl-tui" +version = "0.1.0" +description = "TUI dashboard for flowctl" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +flowctl-core = { workspace = true } +flowctl-db = { workspace = true } +flowctl-scheduler = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +ratatui = { workspace = true } +crossterm = { workspace = true } +tokio = { workspace = true } +nucleo = { workspace = true } + +[dev-dependencies] +insta = "1" diff --git a/flowctl/crates/flowctl-tui/src/action.rs b/flowctl/crates/flowctl-tui/src/action.rs new file mode 100644 index 00000000..9389f343 --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/action.rs @@ -0,0 +1,37 @@ +//! Actions for inter-component communication via mpsc channels. +//! +//! Follows the Ratatui TEA (The Elm Architecture) pattern where +//! components emit actions instead of mutating state directly. + +use tokio::sync::mpsc; + +use flowctl_scheduler::TimestampedEvent; + +/// Actions that flow between components through the event loop. +#[derive(Debug, Clone)] +pub enum Action { + /// Periodic tick (250ms) for background polling. + Tick, + /// Render the UI (30fps timer). + Render, + /// Switch to a specific tab by index. + SwitchTab(usize), + /// Cycle to the next tab. + NextTab, + /// Cycle to the previous tab. + PrevTab, + /// Quit the application. + Quit, + /// Resize the terminal (cols, rows). + Resize(u16, u16), + /// A key event that wasn't handled at the app level. + Key(crossterm::event::KeyEvent), + /// A live event from the daemon's event bus. + FlowEvent(TimestampedEvent), +} + +/// Convenience type for the action sender half. +pub type ActionSender = mpsc::UnboundedSender; + +/// Convenience type for the action receiver half. +pub type ActionReceiver = mpsc::UnboundedReceiver; diff --git a/flowctl/crates/flowctl-tui/src/app.rs b/flowctl/crates/flowctl-tui/src/app.rs new file mode 100644 index 00000000..78827931 --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/app.rs @@ -0,0 +1,404 @@ +//! Main TUI application with event loop and tab navigation. +//! +//! Architecture: tokio::select! multiplexes three event sources: +//! - Render timer (33ms / ~30fps) +//! - Tick timer (250ms for background polling) +//! - Crossterm keyboard events +//! +//! Daemon auto-detection: on startup, checks whether the flowctl daemon +//! is running. If yes, connects via broadcast channel for live event +//! streaming. If no, falls back to SQLite polling on tick. + +use std::io; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{bail, Result}; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::{execute, cursor}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Tabs}; +use ratatui::Terminal; +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; + +use crate::action::{Action, ActionSender}; +use crate::component::Component; +use crate::tabs::{self, DagTab, LogsTab, StatsTab, Tab, TasksTab}; +use crate::widgets::toast::ToastStack; + +/// How the TUI connects to the daemon for event data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DataSource { + /// No daemon running -- poll SQLite on tick timer. + SqlitePolling, + /// Daemon detected -- receiving live events via broadcast channel. + DaemonEvents, +} + +const MIN_COLS: u16 = 80; +const MIN_ROWS: u16 = 24; +const RENDER_INTERVAL: Duration = Duration::from_millis(33); // ~30fps +const TICK_INTERVAL: Duration = Duration::from_millis(250); + +/// The top-level TUI application. +pub struct App { + active_tab: usize, + tasks_tab: TasksTab, + dag_tab: DagTab, + logs_tab: LogsTab, + stats_tab: StatsTab, + should_quit: bool, + term_cols: u16, + term_rows: u16, + toasts: ToastStack, + data_source: DataSource, +} + +impl App { + pub fn new() -> Self { + Self { + active_tab: 0, + tasks_tab: TasksTab::new(), + dag_tab: DagTab::new(), + logs_tab: LogsTab::new(), + stats_tab: StatsTab::new(), + should_quit: false, + term_cols: 0, + term_rows: 0, + toasts: ToastStack::new(), + data_source: DataSource::SqlitePolling, + } + } + + /// Detect whether the daemon is running by checking the socket file. + /// + /// If a `.flow/.state/flowctl.sock` exists, we assume the daemon is + /// reachable and switch to live event mode. + pub fn detect_daemon(&mut self, flow_dir: Option<&PathBuf>) -> DataSource { + let socket_exists = flow_dir + .map(|dir| dir.join(".state").join("flowctl.sock").exists()) + .unwrap_or(false); + + if socket_exists { + info!("daemon detected, using live event streaming"); + self.data_source = DataSource::DaemonEvents; + } else { + debug!("no daemon detected, using SQLite polling"); + self.data_source = DataSource::SqlitePolling; + } + + self.data_source.clone() + } + + /// Connect to the daemon's event bus via a broadcast receiver. + /// + /// Returns a task handle that forwards events into the action channel. + /// The caller should hold onto this handle for the lifetime of the app. + pub fn connect_event_bus( + &self, + event_bus: &flowctl_scheduler::EventBus, + action_tx: ActionSender, + ) -> tokio::task::JoinHandle<()> { + let mut rx = event_bus.subscribe(); + + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + if action_tx.send(Action::FlowEvent(event)).is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!(skipped = n, "TUI event receiver lagged"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + debug!("event bus closed"); + break; + } + } + } + }) + } + + /// Get the current data source mode. + pub fn data_source(&self) -> &DataSource { + &self.data_source + } + + /// Run the TUI event loop. Takes ownership of the terminal until quit. + pub async fn run(&mut self) -> Result<()> { + // Check minimum terminal size before entering alternate screen. + let (cols, rows) = terminal::size()?; + if cols < MIN_COLS || rows < MIN_ROWS { + bail!( + "Terminal too small: {}x{} (minimum {}x{})", + cols, + rows, + MIN_COLS, + MIN_ROWS, + ); + } + self.term_cols = cols; + self.term_rows = rows; + + // Set up terminal. + terminal::enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, cursor::Hide)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = self.event_loop(&mut terminal).await; + + // Restore terminal (always, even on error). + terminal::disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?; + + result + } + + async fn event_loop( + &mut self, + terminal: &mut Terminal>, + ) -> Result<()> { + let (tx, mut rx) = mpsc::unbounded_channel::(); + + let mut render_interval = tokio::time::interval(RENDER_INTERVAL); + let mut tick_interval = tokio::time::interval(TICK_INTERVAL); + + // Initial render. + terminal.draw(|frame| self.render(frame, frame.area()))?; + + loop { + tokio::select! { + _ = render_interval.tick() => { + terminal.draw(|frame| self.render(frame, frame.area()))?; + } + _ = tick_interval.tick() => { + tx.send(Action::Tick)?; + } + // Crossterm events (keyboard, resize) -- polled with zero timeout + // so we don't block the select. + _ = tokio::task::yield_now() => { + while event::poll(Duration::ZERO)? { + match event::read()? { + Event::Key(key) => { + if !self.handle_key_event(key, &tx)? { + // Forward unhandled keys to active tab. + self.active_component_mut().handle_key_event(key, &tx)?; + } + } + Event::Resize(cols, rows) => { + tx.send(Action::Resize(cols, rows))?; + } + _ => {} + } + } + } + } + + // Drain all pending actions. + while let Ok(action) = rx.try_recv() { + self.update(&action)?; + } + + if self.should_quit { + break; + } + } + + Ok(()) + } + + fn active_component_mut(&mut self) -> &mut dyn Component { + match tabs::Tab::from_index(self.active_tab) { + Tab::Tasks => &mut self.tasks_tab, + Tab::Dag => &mut self.dag_tab, + Tab::Logs => &mut self.logs_tab, + Tab::Stats => &mut self.stats_tab, + } + } + + fn active_component(&self) -> &dyn Component { + match tabs::Tab::from_index(self.active_tab) { + Tab::Tasks => &self.tasks_tab, + Tab::Dag => &self.dag_tab, + Tab::Logs => &self.logs_tab, + Tab::Stats => &self.stats_tab, + } + } + + /// Access the toast stack for pushing notifications. + pub fn toasts_mut(&mut self) -> &mut ToastStack { + &mut self.toasts + } + + /// Render the tab bar, status bar, and keybinding hints. + fn render_chrome(&self, frame: &mut ratatui::Frame, area: Rect) -> Rect { + // Layout: tab bar (3 rows) | content (flex) | status bar (1 row). + let chunks = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + + // Tab bar. + let tab_titles: Vec = Tab::ALL + .iter() + .map(|t| Line::from(format!(" {} ", t.title()))) + .collect(); + + let title = match self.data_source { + DataSource::DaemonEvents => " flowctl [live] ", + DataSource::SqlitePolling => " flowctl ", + }; + + let tabs_widget = Tabs::new(tab_titles) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::White)), + ) + .select(self.active_tab) + .style(Style::default().fg(Color::DarkGray)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .divider(Span::raw("|")); + + frame.render_widget(tabs_widget, chunks[0]); + + // Status bar with keybinding hints. + let mut hints: Vec = vec![ + Span::styled(" 1-4", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(":tab "), + Span::styled("Tab", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(":next "), + Span::styled("q", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(":quit"), + ]; + + // Add tab-specific keybindings. + let tab_bindings = self.active_component().keybindings(); + if !tab_bindings.is_empty() { + hints.push(Span::raw(" | ")); + for (i, (key, desc)) in tab_bindings.iter().enumerate() { + if i > 0 { + hints.push(Span::raw(" ")); + } + hints.push(Span::styled( + *key, + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )); + hints.push(Span::raw(format!(":{desc}"))); + } + } + + let status_bar = ratatui::widgets::Paragraph::new(Line::from(hints)) + .style(Style::default().bg(Color::DarkGray).fg(Color::White)); + frame.render_widget(status_bar, chunks[2]); + + // Return the content area. + chunks[1] + } +} + +impl Component for App { + fn handle_key_event(&mut self, key: event::KeyEvent, tx: &ActionSender) -> Result { + // Global keybindings (Lazygit pattern: app-level first). + match key.code { + KeyCode::Char('q') => { + tx.send(Action::Quit)?; + Ok(true) + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + tx.send(Action::Quit)?; + Ok(true) + } + KeyCode::Char('1') => { + tx.send(Action::SwitchTab(0))?; + Ok(true) + } + KeyCode::Char('2') => { + tx.send(Action::SwitchTab(1))?; + Ok(true) + } + KeyCode::Char('3') => { + tx.send(Action::SwitchTab(2))?; + Ok(true) + } + KeyCode::Char('4') => { + tx.send(Action::SwitchTab(3))?; + Ok(true) + } + KeyCode::Tab => { + tx.send(Action::NextTab)?; + Ok(true) + } + KeyCode::BackTab => { + tx.send(Action::PrevTab)?; + Ok(true) + } + _ => Ok(false), + } + } + + fn update(&mut self, action: &Action) -> Result<()> { + match action { + Action::Quit => { + self.should_quit = true; + } + Action::SwitchTab(idx) => { + self.active_tab = *idx % Tab::ALL.len(); + } + Action::NextTab => { + self.active_tab = (self.active_tab + 1) % Tab::ALL.len(); + } + Action::PrevTab => { + self.active_tab = (self.active_tab + Tab::ALL.len() - 1) % Tab::ALL.len(); + } + Action::Resize(cols, rows) => { + self.term_cols = *cols; + self.term_rows = *rows; + } + Action::Tick => { + self.toasts.gc(); + } + _ => {} + } + + // Forward to active tab. + self.active_component_mut().update(action)?; + Ok(()) + } + + fn render(&self, frame: &mut ratatui::Frame, area: Rect) { + // Check terminal size. + if area.width < MIN_COLS || area.height < MIN_ROWS { + let msg = format!( + "Terminal too small: {}x{} (need {}x{})", + area.width, area.height, MIN_COLS, MIN_ROWS, + ); + let para = ratatui::widgets::Paragraph::new(msg) + .style(Style::default().fg(Color::Red)); + frame.render_widget(para, area); + return; + } + + let content_area = self.render_chrome(frame, area); + self.active_component().render(frame, content_area); + + // Toast overlay (rendered last, on top of everything). + self.toasts.render(frame, area); + } +} diff --git a/flowctl/crates/flowctl-tui/src/component.rs b/flowctl/crates/flowctl-tui/src/component.rs new file mode 100644 index 00000000..f7b170c6 --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/component.rs @@ -0,0 +1,38 @@ +//! Component trait for TUI widgets. +//! +//! Each tab and sub-widget implements this trait. The pattern follows +//! Ratatui's recommended TEA architecture: +//! 1. `handle_key_event` - convert key input into Actions +//! 2. `update` - process Actions and mutate state +//! 3. `render` - draw the component (immutable borrow) + +use anyhow::Result; +use crossterm::event::KeyEvent; +use ratatui::Frame; + +use crate::action::{Action, ActionSender}; + +/// A renderable, interactive TUI component. +pub trait Component { + /// Handle a key event, optionally emitting Actions via the sender. + /// + /// Return `true` if the event was consumed (prevents propagation). + fn handle_key_event(&mut self, key: KeyEvent, tx: &ActionSender) -> Result { + let _ = (key, tx); + Ok(false) + } + + /// Process an action and update internal state. + fn update(&mut self, action: &Action) -> Result<()> { + let _ = action; + Ok(()) + } + + /// Render the component into the given area. + fn render(&self, frame: &mut Frame, area: ratatui::layout::Rect); + + /// Return context-sensitive keybinding hints for the status bar. + fn keybindings(&self) -> Vec<(&str, &str)> { + vec![] + } +} diff --git a/flowctl/crates/flowctl-tui/src/lib.rs b/flowctl/crates/flowctl-tui/src/lib.rs new file mode 100644 index 00000000..25594676 --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/lib.rs @@ -0,0 +1,13 @@ +//! flowctl-tui: Terminal UI dashboard for flowctl. +//! +//! Provides a 4-tab TUI dashboard (Tasks, DAG, Logs, Stats) using +//! ratatui with component architecture and context-sensitive keybindings. + +pub mod action; +pub mod app; +pub mod component; +pub mod tabs; +pub mod widgets; + +pub use app::App; +pub use widgets::toast::{Toast, ToastLevel, ToastStack}; diff --git a/flowctl/crates/flowctl-tui/src/tabs/dag.rs b/flowctl/crates/flowctl-tui/src/tabs/dag.rs new file mode 100644 index 00000000..5ba07e71 --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/tabs/dag.rs @@ -0,0 +1,480 @@ +//! DAG tab - ASCII dependency graph with status-colored nodes. + +use std::collections::{HashMap, HashSet}; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use flowctl_core::dag::TaskDag; +use flowctl_core::state_machine::Status; +use flowctl_core::types::Task; + +use crate::action::{Action, ActionSender}; +use crate::component::Component; + +/// A positioned node in the graph layout. +#[derive(Debug, Clone)] +struct LayoutNode { + id: String, + /// Column (layer/depth from sources). + col: usize, + /// Row within the column. + row: usize, + status: Status, + on_critical_path: bool, +} + +pub struct DagTab { + /// Rendered graph lines (cached). + lines: Vec>, + /// All node IDs in layout order for navigation. + node_ids: Vec, + /// Currently selected node index. + selected: usize, + /// Scroll offset (vertical). + scroll_y: u16, + /// Scroll offset (horizontal). + scroll_x: u16, + /// Whether data has been loaded. + loaded: bool, + /// Status map for coloring. + statuses: HashMap, + /// Critical path node IDs. + critical_path: HashSet, + /// Layout nodes for navigation. + layout_nodes: Vec, +} + +impl DagTab { + pub fn new() -> Self { + Self { + lines: Vec::new(), + node_ids: Vec::new(), + selected: 0, + scroll_y: 0, + scroll_x: 0, + loaded: false, + statuses: HashMap::new(), + critical_path: HashSet::new(), + layout_nodes: Vec::new(), + } + } + + /// Load tasks and rebuild the ASCII graph. + pub fn load_tasks(&mut self, tasks: &[Task]) { + self.loaded = true; + if tasks.is_empty() { + self.lines = Vec::new(); + self.node_ids = Vec::new(); + self.layout_nodes = Vec::new(); + return; + } + + self.statuses = tasks + .iter() + .map(|t| (t.id.clone(), t.status)) + .collect(); + + let dag = match TaskDag::from_tasks(tasks) { + Ok(d) => d, + Err(e) => { + self.lines = vec![Line::from(Span::styled( + format!(" DAG error: {e}"), + Style::default().fg(Color::Red), + ))]; + return; + } + }; + + self.critical_path = dag.critical_path().into_iter().collect(); + self.build_layout(tasks, &dag); + self.render_graph(tasks, &dag); + } + + /// Assign nodes to columns (layers) using topological depth. + fn build_layout(&mut self, _tasks: &[Task], dag: &TaskDag) { + // Compute depth for each node (longest path from any source). + let mut depth: HashMap = HashMap::new(); + let topo_ids = dag.task_ids(); + + // BFS-like layering: depth = max(depth of deps) + 1. + for id in &topo_ids { + let deps = dag.dependencies(id); + let d = if deps.is_empty() { + 0 + } else { + deps.iter() + .filter_map(|dep| depth.get(dep)) + .max() + .copied() + .unwrap_or(0) + + 1 + }; + depth.insert(id.clone(), d); + } + + // Group by depth and assign rows within each column. + let max_depth = depth.values().copied().max().unwrap_or(0); + let mut col_counts = vec![0usize; max_depth + 1]; + let mut nodes = Vec::new(); + + // Sort by depth then ID for deterministic layout. + let mut sorted: Vec<&String> = topo_ids.iter().collect(); + sorted.sort_by_key(|id| (depth.get(id.as_str()).copied().unwrap_or(0), id.as_str())); + + for id in sorted { + let col = depth.get(id.as_str()).copied().unwrap_or(0); + let row = col_counts[col]; + col_counts[col] += 1; + + let status = self.statuses.get(id.as_str()).copied().unwrap_or(Status::Todo); + nodes.push(LayoutNode { + id: id.clone(), + col, + row, + status, + on_critical_path: self.critical_path.contains(id.as_str()), + }); + } + + self.node_ids = nodes.iter().map(|n| n.id.clone()).collect(); + self.layout_nodes = nodes; + if self.selected >= self.node_ids.len() { + self.selected = 0; + } + } + + /// Render the ASCII graph into cached lines. + fn render_graph(&mut self, _tasks: &[Task], dag: &TaskDag) { + // Build a position map: id -> (col, row). + let pos_map: HashMap<&str, (usize, usize)> = self + .layout_nodes + .iter() + .map(|n| (n.id.as_str(), (n.col, n.row))) + .collect(); + + // Compute column widths (max label length + padding). + let max_col = self.layout_nodes.iter().map(|n| n.col).max().unwrap_or(0); + let col_widths: Vec = (0..=max_col) + .map(|c| { + self.layout_nodes + .iter() + .filter(|n| n.col == c) + .map(|n| short_label(&n.id).len() + 6) // "[x] label" + padding + .max() + .unwrap_or(10) + .max(10) + }) + .collect(); + + // Compute x-offsets for each column. + let mut col_x: Vec = Vec::with_capacity(max_col + 1); + let mut x = 2; + for w in &col_widths { + col_x.push(x); + x += w + 4; // gap between columns for arrows + } + + // Compute max rows per column. + let max_rows = self + .layout_nodes + .iter() + .map(|n| n.row) + .max() + .unwrap_or(0) + + 1; + + // Row height = 2 lines per node (node line + spacing). + let total_height = max_rows * 2 + 1; + let total_width = x + 4; + + // Initialize a grid of spans. + let mut grid: Vec> = vec![vec![' '; total_width]; total_height]; + + // Place nodes. + let selected_id = self.node_ids.get(self.selected).cloned().unwrap_or_default(); + + // We build Line<'static> directly instead of using the grid for coloring. + let mut output_lines: Vec> = Vec::new(); + + // First pass: draw edges as characters on the grid. + for node in &self.layout_nodes { + let (nc, nr) = (node.col, node.row); + let node_y = nr * 2; + let dependents = dag.dependents(&node.id); + for dep_id in &dependents { + if let Some(&(dc, dr)) = pos_map.get(dep_id.as_str()) { + let dep_y = dr * 2; + let dep_x_start = col_x[dc]; + let arrow_start_x = col_x[nc] + col_widths[nc] + 1; + + // Horizontal line from source node to midpoint. + if dc > nc { + let mid_x = col_x[dc] - 2; + for cx in arrow_start_x..mid_x { + if cx < total_width && node_y < total_height { + grid[node_y][cx] = '-'; + } + } + // Vertical connector if rows differ. + if dep_y != node_y { + let (y_start, y_end) = if dep_y > node_y { + (node_y, dep_y) + } else { + (dep_y, node_y) + }; + if mid_x < total_width { + for y in y_start..=y_end { + if y < total_height { + grid[y][mid_x] = '|'; + } + } + } + } + // Horizontal line from midpoint to target. + let mid_x = col_x[dc] - 2; + for cx in mid_x..dep_x_start { + if cx < total_width && dep_y < total_height { + grid[dep_y][cx] = '-'; + } + } + // Arrow head. + if dep_x_start > 0 && dep_y < total_height && (dep_x_start - 1) < total_width { + grid[dep_y][dep_x_start - 1] = '>'; + } + } + } + } + } + + // Second pass: build colored lines with nodes overlaid. + for y in 0..total_height { + let mut spans: Vec> = Vec::new(); + + // Check if any node is on this row. + let nodes_on_row: Vec<&LayoutNode> = self + .layout_nodes + .iter() + .filter(|n| n.row * 2 == y) + .collect(); + + if nodes_on_row.is_empty() { + // Just edge characters. + let line_str: String = grid[y].iter().collect(); + let trimmed = line_str.trim_end().to_string(); + if !trimmed.is_empty() { + spans.push(Span::styled(trimmed, Style::default().fg(Color::DarkGray))); + } + } else { + // Build the line char by char, inserting colored nodes. + let mut cursor = 0; + + for node in &nodes_on_row { + let nx = col_x[node.col]; + // Edges before this node. + if cursor < nx { + let edge_part: String = grid[y][cursor..nx].iter().collect(); + spans.push(Span::styled(edge_part, Style::default().fg(Color::DarkGray))); + } + + let label = short_label(&node.id); + let icon = status_icon_short(node.status); + let node_str = format!("{icon} {label}"); + let node_len = node_str.len(); + + let is_selected = node.id == selected_id; + let fg = node_color(node.status); + let mut style = Style::default().fg(fg); + if node.on_critical_path { + style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + } + if is_selected { + style = style.bg(Color::DarkGray).add_modifier(Modifier::REVERSED); + } + + spans.push(Span::styled(node_str, style)); + cursor = nx + node_len; + + // Pad to column width. + let col_end = nx + col_widths[node.col]; + if cursor < col_end { + let pad: String = std::iter::repeat(' ').take(col_end - cursor).collect(); + spans.push(Span::raw(pad)); + cursor = col_end; + } + } + + // Remaining edge characters. + if cursor < total_width { + let rest: String = grid[y][cursor..].iter().collect(); + let trimmed = rest.trim_end().to_string(); + if !trimmed.is_empty() { + spans.push(Span::styled(trimmed, Style::default().fg(Color::DarkGray))); + } + } + } + + output_lines.push(Line::from(spans)); + } + + // Add legend at the bottom. + output_lines.push(Line::from("")); + output_lines.push(Line::from(vec![ + Span::styled(" Legend: ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled("* done ", Style::default().fg(Color::Green)), + Span::styled("~ running ", Style::default().fg(Color::Yellow)), + Span::styled("X failed ", Style::default().fg(Color::Red)), + Span::styled("o todo ", Style::default().fg(Color::DarkGray)), + Span::styled("underline = critical path", Style::default().add_modifier(Modifier::UNDERLINED)), + ])); + + self.lines = output_lines; + } +} + +fn status_icon_short(status: Status) -> &'static str { + match status { + Status::Done => "*", + Status::InProgress => "~", + Status::Failed | Status::UpstreamFailed => "X", + Status::Blocked => "!", + Status::Skipped => "-", + Status::UpForRetry => "?", + Status::Todo => "o", + } +} + +fn node_color(status: Status) -> Color { + match status { + Status::Done => Color::Green, + Status::InProgress => Color::Yellow, + Status::Failed => Color::Red, + Status::UpstreamFailed => Color::LightRed, + Status::Blocked => Color::Magenta, + Status::Skipped => Color::DarkGray, + Status::UpForRetry => Color::LightYellow, + Status::Todo => Color::Gray, + } +} + +/// Shorten a task ID for display (e.g. "fn-2-rewrite.14" -> ".14"). +fn short_label(id: &str) -> String { + if let Some(dot_pos) = id.rfind('.') { + id[dot_pos..].to_string() + } else { + id.to_string() + } +} + +impl Component for DagTab { + fn handle_key_event(&mut self, key: KeyEvent, _tx: &ActionSender) -> Result { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !self.node_ids.is_empty() { + self.selected = (self.selected + 1).min(self.node_ids.len() - 1); + } + // Re-render to update selection highlight. + // (We can't call render_graph here without tasks/dag, + // so we toggle the selected state in the cached lines.) + Ok(true) + } + KeyCode::Char('k') | KeyCode::Up => { + if self.selected > 0 { + self.selected -= 1; + } + Ok(true) + } + KeyCode::Char('h') | KeyCode::Left => { + self.scroll_x = self.scroll_x.saturating_sub(4); + Ok(true) + } + KeyCode::Char('l') | KeyCode::Right => { + self.scroll_x = self.scroll_x.saturating_add(4); + Ok(true) + } + KeyCode::Char('+') | KeyCode::Char('=') => { + // Zoom not implemented yet; scroll faster. + self.scroll_y = self.scroll_y.saturating_sub(3); + Ok(true) + } + KeyCode::Char('-') => { + self.scroll_y = self.scroll_y.saturating_add(3); + Ok(true) + } + _ => Ok(false), + } + } + + fn update(&mut self, _action: &Action) -> Result<()> { + Ok(()) + } + + fn render(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(format!(" DAG ({} nodes) ", self.node_ids.len())) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)); + + if !self.loaded || self.lines.is_empty() { + let empty_msg = vec![ + Line::from(""), + Line::from(Span::styled( + " No dependency graph available", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )), + Line::from(""), + Line::from(Span::styled( + " Task dependencies will be visualized here", + Style::default().fg(Color::DarkGray), + )), + ]; + let paragraph = Paragraph::new(empty_msg).block(block); + frame.render_widget(paragraph, area); + return; + } + + // Selected node info line at top. + let info_line = if let Some(node) = self.layout_nodes.get(self.selected) { + Line::from(vec![ + Span::styled( + format!(" Selected: {} ", node.id), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("[{}]", node.status), + Style::default().fg(node_color(node.status)), + ), + if node.on_critical_path { + Span::styled(" (critical path)", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) + } else { + Span::raw("") + }, + ]) + } else { + Line::from("") + }; + + let mut all_lines = vec![info_line, Line::from("")]; + all_lines.extend(self.lines.clone()); + + let paragraph = Paragraph::new(all_lines) + .block(block) + .scroll((self.scroll_y, self.scroll_x)); + frame.render_widget(paragraph, area); + } + + fn keybindings(&self) -> Vec<(&str, &str)> { + vec![ + ("j/k", "select"), + ("h/l", "pan"), + ("arrows", "navigate"), + ] + } +} diff --git a/flowctl/crates/flowctl-tui/src/tabs/logs.rs b/flowctl/crates/flowctl-tui/src/tabs/logs.rs new file mode 100644 index 00000000..5959bce3 --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/tabs/logs.rs @@ -0,0 +1,529 @@ +//! Logs tab - split-pane log viewer with level filtering and search. + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; +use ratatui::Frame; + +use flowctl_db::EventRow; + +use crate::action::{Action, ActionSender}; +use crate::component::Component; + +/// Log severity levels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LogLevel { + Error, + Warn, + Info, + Debug, +} + +impl LogLevel { + fn from_event_type(event_type: &str) -> Self { + if event_type.contains("failed") || event_type.contains("error") { + LogLevel::Error + } else if event_type.contains("blocked") || event_type.contains("retry") { + LogLevel::Warn + } else if event_type.contains("debug") || event_type.contains("heartbeat") { + LogLevel::Debug + } else { + LogLevel::Info + } + } + + fn label(self) -> &'static str { + match self { + LogLevel::Error => "ERR", + LogLevel::Warn => "WRN", + LogLevel::Info => "INF", + LogLevel::Debug => "DBG", + } + } + + fn color(self) -> Color { + match self { + LogLevel::Error => Color::Red, + LogLevel::Warn => Color::Yellow, + LogLevel::Info => Color::Cyan, + LogLevel::Debug => Color::DarkGray, + } + } +} + +/// A processed log entry ready for display. +#[derive(Debug, Clone)] +struct LogEntry { + id: i64, + timestamp: String, + level: LogLevel, + event_type: String, + epic_id: String, + task_id: Option, + actor: Option, + payload: Option, +} + +impl LogEntry { + fn from_event_row(row: &EventRow) -> Self { + Self { + id: row.id, + timestamp: row.timestamp.clone(), + level: LogLevel::from_event_type(&row.event_type), + event_type: row.event_type.clone(), + epic_id: row.epic_id.clone(), + task_id: row.task_id.clone(), + actor: row.actor.clone(), + payload: row.payload.clone(), + } + } + + fn summary_line(&self) -> String { + let ts = if self.timestamp.len() > 19 { + &self.timestamp[11..19] // HH:MM:SS + } else { + &self.timestamp + }; + let task_part = self.task_id.as_deref().unwrap_or(""); + format!("{} [{}] {} {}", ts, self.level.label(), self.event_type, task_part) + } +} + +/// Focus pane in split view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Pane { + List, + Detail, +} + +pub struct LogsTab { + /// All log entries. + entries: Vec, + /// Indices into `entries` after filtering. + filtered: Vec, + /// List widget state. + list_state: ListState, + /// Level filter toggles. + show_error: bool, + show_warn: bool, + show_info: bool, + show_debug: bool, + /// Search mode active. + search_active: bool, + /// Search query. + search_query: String, + /// Auto-scroll to bottom. + auto_scroll: bool, + /// Active pane. + active_pane: Pane, + /// Whether data has been loaded. + loaded: bool, +} + +impl LogsTab { + pub fn new() -> Self { + Self { + entries: Vec::new(), + filtered: Vec::new(), + list_state: ListState::default(), + show_error: true, + show_warn: true, + show_info: true, + show_debug: false, + search_active: false, + search_query: String::new(), + auto_scroll: true, + active_pane: Pane::List, + loaded: false, + } + } + + /// Load log entries from EventRow data. + pub fn load_events(&mut self, events: Vec) { + self.entries = events.iter().map(LogEntry::from_event_row).collect(); + self.loaded = true; + self.refilter(); + if self.auto_scroll && !self.filtered.is_empty() { + self.list_state.select(Some(self.filtered.len() - 1)); + } else if self.list_state.selected().is_none() && !self.filtered.is_empty() { + self.list_state.select(Some(0)); + } + } + + fn refilter(&mut self) { + let query_lower = self.search_query.to_lowercase(); + self.filtered = (0..self.entries.len()) + .filter(|&i| { + let entry = &self.entries[i]; + // Level filter. + let level_ok = match entry.level { + LogLevel::Error => self.show_error, + LogLevel::Warn => self.show_warn, + LogLevel::Info => self.show_info, + LogLevel::Debug => self.show_debug, + }; + if !level_ok { + return false; + } + // Search filter. + if !query_lower.is_empty() { + let haystack = format!( + "{} {} {} {}", + entry.event_type, + entry.task_id.as_deref().unwrap_or(""), + entry.actor.as_deref().unwrap_or(""), + entry.payload.as_deref().unwrap_or(""), + ) + .to_lowercase(); + if !haystack.contains(&query_lower) { + return false; + } + } + true + }) + .collect(); + + // Clamp selection. + if let Some(sel) = self.list_state.selected() { + if sel >= self.filtered.len() { + self.list_state.select(if self.filtered.is_empty() { + None + } else { + Some(self.filtered.len() - 1) + }); + } + } + } + + fn selected_entry(&self) -> Option<&LogEntry> { + self.list_state + .selected() + .and_then(|i| self.filtered.get(i)) + .map(|&idx| &self.entries[idx]) + } + + fn move_selection(&mut self, delta: isize) { + if self.filtered.is_empty() { + return; + } + self.auto_scroll = false; + let current = self.list_state.selected().unwrap_or(0) as isize; + let next = (current + delta).clamp(0, self.filtered.len() as isize - 1) as usize; + self.list_state.select(Some(next)); + } + + fn filter_status_line(&self) -> Line<'static> { + let toggle = |on: bool, label: &str, color: Color| -> Vec> { + let style = if on { + Style::default().fg(color).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + vec![ + Span::styled(format!("[{}]", if on { "x" } else { " " }), style), + Span::styled(format!("{label} "), style), + ] + }; + + let mut spans = vec![Span::styled(" Filter: ", Style::default().fg(Color::White))]; + spans.extend(toggle(self.show_error, "ERR", Color::Red)); + spans.extend(toggle(self.show_warn, "WRN", Color::Yellow)); + spans.extend(toggle(self.show_info, "INF", Color::Cyan)); + spans.extend(toggle(self.show_debug, "DBG", Color::DarkGray)); + spans.push(Span::styled( + format!(" ({}/{})", self.filtered.len(), self.entries.len()), + Style::default().fg(Color::White), + )); + if self.auto_scroll { + spans.push(Span::styled(" AUTO", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))); + } + Line::from(spans) + } +} + +impl Component for LogsTab { + fn handle_key_event(&mut self, key: KeyEvent, _tx: &ActionSender) -> Result { + // Search mode. + if self.search_active { + match key.code { + KeyCode::Esc => { + self.search_active = false; + self.search_query.clear(); + self.refilter(); + } + KeyCode::Enter => { + self.search_active = false; + } + KeyCode::Backspace => { + self.search_query.pop(); + self.refilter(); + } + KeyCode::Char(c) => { + self.search_query.push(c); + self.refilter(); + } + _ => {} + } + return Ok(true); + } + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + self.move_selection(1); + Ok(true) + } + KeyCode::Char('k') | KeyCode::Up => { + self.move_selection(-1); + Ok(true) + } + KeyCode::Char('G') => { + if !self.filtered.is_empty() { + self.list_state.select(Some(self.filtered.len() - 1)); + self.auto_scroll = true; + } + Ok(true) + } + KeyCode::Char('g') => { + if !self.filtered.is_empty() { + self.list_state.select(Some(0)); + self.auto_scroll = false; + } + Ok(true) + } + KeyCode::Char('/') => { + self.search_active = true; + self.search_query.clear(); + Ok(true) + } + KeyCode::Char('e') => { + self.show_error = !self.show_error; + self.refilter(); + Ok(true) + } + KeyCode::Char('w') => { + self.show_warn = !self.show_warn; + self.refilter(); + Ok(true) + } + KeyCode::Char('i') => { + self.show_info = !self.show_info; + self.refilter(); + Ok(true) + } + KeyCode::Char('d') => { + self.show_debug = !self.show_debug; + self.refilter(); + Ok(true) + } + KeyCode::Char('a') => { + self.auto_scroll = !self.auto_scroll; + if self.auto_scroll && !self.filtered.is_empty() { + self.list_state.select(Some(self.filtered.len() - 1)); + } + Ok(true) + } + KeyCode::Tab => { + self.active_pane = match self.active_pane { + Pane::List => Pane::Detail, + Pane::Detail => Pane::List, + }; + Ok(true) + } + _ => Ok(false), + } + } + + fn update(&mut self, _action: &Action) -> Result<()> { + Ok(()) + } + + fn render(&self, frame: &mut Frame, area: Rect) { + if !self.loaded || self.entries.is_empty() { + let block = Block::default() + .title(" Logs ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)); + + let empty_msg = vec![ + Line::from(""), + Line::from(Span::styled( + " No log events", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )), + Line::from(""), + Line::from(Span::styled( + " Events from flowctl operations will appear here", + Style::default().fg(Color::DarkGray), + )), + ]; + + let paragraph = Paragraph::new(empty_msg).block(block); + frame.render_widget(paragraph, area); + return; + } + + // Layout: filter bar (1) | search bar (0-1) | split pane (rest) + let search_height = if self.search_active { 1 } else { 0 }; + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(search_height), + Constraint::Min(4), + ]) + .split(area); + + // Filter status bar. + let filter_line = self.filter_status_line(); + let filter_bar = Paragraph::new(filter_line) + .style(Style::default().bg(Color::Black)); + frame.render_widget(filter_bar, chunks[0]); + + // Search bar. + if self.search_active { + let search_line = Line::from(vec![ + Span::styled("/", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(&self.search_query), + Span::styled("_", Style::default().fg(Color::Yellow).add_modifier(Modifier::SLOW_BLINK)), + ]); + let search_bar = Paragraph::new(search_line) + .style(Style::default().bg(Color::Black)); + frame.render_widget(search_bar, chunks[1]); + } + + // Split pane: log list (left 60%) | detail (right 40%). + let panes = Layout::horizontal([ + Constraint::Percentage(60), + Constraint::Percentage(40), + ]) + .split(chunks[2]); + + // Log list. + let list_border_color = if self.active_pane == Pane::List { + Color::Yellow + } else { + Color::DarkGray + }; + + let items: Vec = self + .filtered + .iter() + .map(|&idx| { + let entry = &self.entries[idx]; + let line = Line::from(vec![ + Span::styled( + format!("[{}] ", entry.level.label()), + Style::default().fg(entry.level.color()), + ), + Span::styled( + entry.summary_line(), + Style::default().fg(Color::White), + ), + ]); + ListItem::new(line) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .title(format!(" Logs ({}) ", self.filtered.len())) + .borders(Borders::ALL) + .border_style(Style::default().fg(list_border_color)), + ) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = self.list_state.clone(); + frame.render_stateful_widget(list, panes[0], &mut state); + + // Detail pane. + let detail_border_color = if self.active_pane == Pane::Detail { + Color::Yellow + } else { + Color::DarkGray + }; + + let detail_lines = if let Some(entry) = self.selected_entry() { + let mut lines = vec![ + Line::from(vec![ + Span::styled("ID: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", entry.id)), + ]), + Line::from(vec![ + Span::styled("Time: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(entry.timestamp.clone()), + ]), + Line::from(vec![ + Span::styled("Level: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled(entry.level.label().to_string(), Style::default().fg(entry.level.color())), + ]), + Line::from(vec![ + Span::styled("Type: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(entry.event_type.clone()), + ]), + Line::from(vec![ + Span::styled("Epic: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(entry.epic_id.clone()), + ]), + Line::from(vec![ + Span::styled("Task: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(entry.task_id.clone().unwrap_or_else(|| "-".to_string())), + ]), + Line::from(vec![ + Span::styled("Actor: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(entry.actor.clone().unwrap_or_else(|| "-".to_string())), + ]), + ]; + if let Some(payload) = &entry.payload { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Payload:", + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ))); + for pl in payload.lines() { + lines.push(Line::from(Span::raw(format!(" {pl}")))); + } + } + lines + } else { + vec![ + Line::from(""), + Line::from(Span::styled( + " Select a log entry", + Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), + )), + ] + }; + + let detail = Paragraph::new(detail_lines) + .block( + Block::default() + .title(" Detail ") + .borders(Borders::ALL) + .border_style(Style::default().fg(detail_border_color)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(detail, panes[1]); + } + + fn keybindings(&self) -> Vec<(&str, &str)> { + if self.search_active { + return vec![("Esc", "cancel"), ("Enter", "apply")]; + } + vec![ + ("j/k", "navigate"), + ("/", "search"), + ("e/w/i/d", "filter"), + ("a", "auto-scroll"), + ("G/g", "bottom/top"), + ] + } +} diff --git a/flowctl/crates/flowctl-tui/src/tabs/mod.rs b/flowctl/crates/flowctl-tui/src/tabs/mod.rs new file mode 100644 index 00000000..c86f8c32 --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/tabs/mod.rs @@ -0,0 +1,39 @@ +//! Tab components for the TUI dashboard. +//! +//! Each tab is a Component that renders into the main content area. + +mod tasks; +mod dag; +mod logs; +mod stats; + +pub use tasks::TasksTab; +pub use dag::DagTab; +pub use logs::LogsTab; +pub use stats::{StatsData, StatsTab}; + +/// The four dashboard tabs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Tasks = 0, + Dag = 1, + Logs = 2, + Stats = 3, +} + +impl Tab { + pub const ALL: [Tab; 4] = [Tab::Tasks, Tab::Dag, Tab::Logs, Tab::Stats]; + + pub fn title(&self) -> &'static str { + match self { + Tab::Tasks => "Tasks", + Tab::Dag => "DAG", + Tab::Logs => "Logs", + Tab::Stats => "Stats", + } + } + + pub fn from_index(i: usize) -> Tab { + Tab::ALL[i % Tab::ALL.len()] + } +} diff --git a/flowctl/crates/flowctl-tui/src/tabs/stats.rs b/flowctl/crates/flowctl-tui/src/tabs/stats.rs new file mode 100644 index 00000000..2928451a --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/tabs/stats.rs @@ -0,0 +1,351 @@ +//! Stats tab - sparklines, bar charts, gauges for task/epic metrics. + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Bar, BarChart, BarGroup, Block, Borders, Gauge, Paragraph, Sparkline, +}; +use ratatui::Frame; + +use flowctl_db::metrics::{Summary, TokenBreakdown, WeeklyTrend}; + +use crate::action::{Action, ActionSender}; +use crate::component::Component; + +/// Stats data loaded from the database. +#[derive(Debug, Default)] +pub struct StatsData { + pub summary: Option, + pub weekly_trends: Vec, + pub token_breakdown: Vec, +} + +pub struct StatsTab { + data: StatsData, + loaded: bool, +} + +impl StatsTab { + pub fn new() -> Self { + Self { + data: StatsData::default(), + loaded: false, + } + } + + /// Load stats data from pre-queried results. + pub fn load_stats(&mut self, data: StatsData) { + self.data = data; + self.loaded = true; + } + + fn render_summary_gauge(&self, frame: &mut Frame, area: Rect) { + let Some(summary) = &self.data.summary else { + let block = Block::default() + .title(" Progress ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)); + frame.render_widget(block, area); + return; + }; + + let chunks = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(area); + + // Task completion gauge. + let task_ratio = if summary.total_tasks > 0 { + summary.done_tasks as f64 / summary.total_tasks as f64 + } else { + 0.0 + }; + let task_label = format!( + "Tasks: {}/{} done, {} running, {} blocked", + summary.done_tasks, summary.total_tasks, + summary.in_progress_tasks, summary.blocked_tasks, + ); + let task_gauge = Gauge::default() + .block( + Block::default() + .title(" Task Progress ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)), + ) + .gauge_style( + Style::default() + .fg(Color::Green) + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .ratio(task_ratio) + .label(task_label); + frame.render_widget(task_gauge, chunks[0]); + + // Epic completion gauge. + let epic_ratio = if summary.total_epics > 0 { + (summary.total_epics - summary.open_epics) as f64 / summary.total_epics as f64 + } else { + 0.0 + }; + let epic_label = format!( + "Epics: {}/{} done, {} open", + summary.total_epics - summary.open_epics, + summary.total_epics, + summary.open_epics, + ); + let epic_gauge = Gauge::default() + .block( + Block::default() + .title(" Epic Progress ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .gauge_style( + Style::default() + .fg(Color::Cyan) + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .ratio(epic_ratio) + .label(epic_label); + frame.render_widget(epic_gauge, chunks[1]); + + // Summary stats text. + let stats_lines = vec![ + Line::from(vec![ + Span::styled(" Events: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", summary.total_events)), + Span::styled(" Tokens: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(format_tokens(summary.total_tokens)), + Span::styled(" Cost: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(format!("${:.4}", summary.total_cost_usd)), + ]), + ]; + let stats_para = Paragraph::new(stats_lines); + frame.render_widget(stats_para, chunks[2]); + } + + fn render_throughput_sparkline(&self, frame: &mut Frame, area: Rect) { + if self.data.weekly_trends.is_empty() { + let block = Block::default() + .title(" Throughput (weekly) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)); + let empty = Paragraph::new(Span::styled( + " No trend data", + Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), + )) + .block(block); + frame.render_widget(empty, area); + return; + } + + let data: Vec = self + .data + .weekly_trends + .iter() + .map(|w| w.tasks_completed as u64) + .collect(); + + let sparkline = Sparkline::default() + .block( + Block::default() + .title(" Throughput (tasks/week) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)), + ) + .data(&data) + .style(Style::default().fg(Color::Green)); + frame.render_widget(sparkline, area); + } + + fn render_duration_barchart(&self, frame: &mut Frame, area: Rect) { + if self.data.weekly_trends.is_empty() { + let block = Block::default() + .title(" Activity (weekly) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)); + let empty = Paragraph::new(Span::styled( + " No activity data", + Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), + )) + .block(block); + frame.render_widget(empty, area); + return; + } + + let bars: Vec = self + .data + .weekly_trends + .iter() + .map(|w| { + let label = if w.week.len() > 6 { + w.week[5..].to_string() // "W03" from "2025-W03" + } else { + w.week.clone() + }; + Bar::default() + .value(w.tasks_completed as u64) + .label(Line::from(label)) + .style(Style::default().fg(Color::Green)) + }) + .collect(); + + // Also show failed as a second set overlay info. + let barchart = BarChart::default() + .block( + Block::default() + .title(" Completed Tasks (weekly) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)), + ) + .data(BarGroup::default().bars(&bars)) + .bar_width(5) + .bar_gap(1) + .bar_style(Style::default().fg(Color::Green)) + .value_style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)); + + frame.render_widget(barchart, area); + } + + fn render_token_usage(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(" Token Usage ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)); + + if self.data.token_breakdown.is_empty() { + let empty = Paragraph::new(Span::styled( + " No token data", + Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), + )) + .block(block); + frame.render_widget(empty, area); + return; + } + + let mut lines = vec![ + Line::from(vec![ + Span::styled( + format!("{:<15} {:<12} {:>10} {:>10} {:>10}", + "Epic", "Model", "Input", "Output", "Cost"), + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + ]), + ]; + + for tb in &self.data.token_breakdown { + let epic_short = if tb.epic_id.len() > 14 { + format!("{}...", &tb.epic_id[..11]) + } else { + tb.epic_id.clone() + }; + let model_short = if tb.model.len() > 11 { + format!("{}...", &tb.model[..8]) + } else { + tb.model.clone() + }; + lines.push(Line::from(vec![ + Span::raw(format!( + "{:<15} {:<12} {:>10} {:>10} {:>10}", + epic_short, + model_short, + format_tokens(tb.input_tokens), + format_tokens(tb.output_tokens), + format!("${:.4}", tb.estimated_cost), + )), + ])); + } + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); + } +} + +fn format_tokens(n: i64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}K", n as f64 / 1_000.0) + } else { + format!("{n}") + } +} + +impl Component for StatsTab { + fn handle_key_event(&mut self, key: KeyEvent, _tx: &ActionSender) -> Result { + match key.code { + KeyCode::Char('r') => { + // Refresh placeholder -- data loading is external. + Ok(true) + } + _ => Ok(false), + } + } + + fn update(&mut self, _action: &Action) -> Result<()> { + Ok(()) + } + + fn render(&self, frame: &mut Frame, area: Rect) { + if !self.loaded { + let block = Block::default() + .title(" Stats ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)); + + let empty_msg = vec![ + Line::from(""), + Line::from(Span::styled( + " No statistics available", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )), + Line::from(""), + Line::from(Span::styled( + " Completion rates, velocity, and burndown will show here", + Style::default().fg(Color::DarkGray), + )), + ]; + + let paragraph = Paragraph::new(empty_msg).block(block); + frame.render_widget(paragraph, area); + return; + } + + // Layout: top row (gauges + sparkline) | bottom row (barchart + tokens) + let rows = Layout::vertical([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .split(area); + + let top_cols = Layout::horizontal([ + Constraint::Percentage(55), + Constraint::Percentage(45), + ]) + .split(rows[0]); + + let bottom_cols = Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .split(rows[1]); + + self.render_summary_gauge(frame, top_cols[0]); + self.render_throughput_sparkline(frame, top_cols[1]); + self.render_duration_barchart(frame, bottom_cols[0]); + self.render_token_usage(frame, bottom_cols[1]); + } + + fn keybindings(&self) -> Vec<(&str, &str)> { + vec![("r", "refresh")] + } +} diff --git a/flowctl/crates/flowctl-tui/src/tabs/tasks.rs b/flowctl/crates/flowctl-tui/src/tabs/tasks.rs new file mode 100644 index 00000000..4001b8c2 --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/tabs/tasks.rs @@ -0,0 +1,542 @@ +//! Tasks tab - sortable table with fuzzy search and detail popup. + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table, TableState, Wrap, +}; +use ratatui::Frame; + +use flowctl_core::state_machine::Status; +use flowctl_core::types::Task; + +use crate::action::{Action, ActionSender}; +use crate::component::Component; + +/// Column to sort by. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SortColumn { + Status, + Priority, + Domain, + Title, +} + +impl SortColumn { + fn next(self) -> Self { + match self { + SortColumn::Status => SortColumn::Priority, + SortColumn::Priority => SortColumn::Domain, + SortColumn::Domain => SortColumn::Title, + SortColumn::Title => SortColumn::Status, + } + } +} + +pub struct TasksTab { + /// All loaded tasks. + tasks: Vec, + /// Indices into `tasks` after filtering+sorting. + filtered: Vec, + /// Table widget state (selected row). + table_state: TableState, + /// Current sort column. + sort_col: SortColumn, + /// Sort ascending. + sort_asc: bool, + /// Whether search mode is active. + search_active: bool, + /// Search query string. + search_query: String, + /// Whether the detail popup is open. + detail_open: bool, + /// Whether data has been loaded at least once. + loaded: bool, +} + +impl TasksTab { + pub fn new() -> Self { + Self { + tasks: Vec::new(), + filtered: Vec::new(), + table_state: TableState::default(), + sort_col: SortColumn::Status, + sort_asc: true, + search_active: false, + search_query: String::new(), + detail_open: false, + loaded: false, + } + } + + /// Load tasks from the database connection. + pub fn load_tasks(&mut self, tasks: Vec) { + self.tasks = tasks; + self.loaded = true; + self.refilter_and_sort(); + // Preserve selection if possible. + if !self.filtered.is_empty() && self.table_state.selected().is_none() { + self.table_state.select(Some(0)); + } + } + + fn refilter_and_sort(&mut self) { + // Filter by search query (fuzzy: substring match, case-insensitive). + let query_lower = self.search_query.to_lowercase(); + self.filtered = (0..self.tasks.len()) + .filter(|&i| { + if query_lower.is_empty() { + return true; + } + let t = &self.tasks[i]; + let haystack = format!( + "{} {} {} {}", + t.id, t.title, t.status, t.domain + ) + .to_lowercase(); + // Simple fuzzy: all query chars must appear in order. + let mut hay_iter = haystack.chars(); + query_lower.chars().all(|qc| hay_iter.any(|hc| hc == qc)) + }) + .collect(); + + // Sort. + let tasks = &self.tasks; + let col = self.sort_col; + let asc = self.sort_asc; + self.filtered.sort_by(|&a, &b| { + let ta = &tasks[a]; + let tb = &tasks[b]; + let ord = match col { + SortColumn::Status => status_rank(ta.status).cmp(&status_rank(tb.status)), + SortColumn::Priority => ta.sort_priority().cmp(&tb.sort_priority()), + SortColumn::Domain => ta.domain.to_string().cmp(&tb.domain.to_string()), + SortColumn::Title => ta.title.cmp(&tb.title), + }; + if asc { ord } else { ord.reverse() } + }); + + // Clamp selection. + if let Some(sel) = self.table_state.selected() { + if sel >= self.filtered.len() { + self.table_state.select(if self.filtered.is_empty() { + None + } else { + Some(self.filtered.len() - 1) + }); + } + } + } + + fn selected_task(&self) -> Option<&Task> { + self.table_state + .selected() + .and_then(|i| self.filtered.get(i)) + .map(|&idx| &self.tasks[idx]) + } + + fn move_selection(&mut self, delta: isize) { + if self.filtered.is_empty() { + return; + } + let current = self.table_state.selected().unwrap_or(0) as isize; + let next = (current + delta).clamp(0, self.filtered.len() as isize - 1) as usize; + self.table_state.select(Some(next)); + } + + fn progress_stats(&self) -> (usize, usize, usize, usize) { + let total = self.tasks.len(); + let done = self.tasks.iter().filter(|t| t.status == Status::Done).count(); + let in_progress = self.tasks.iter().filter(|t| t.status == Status::InProgress).count(); + let failed = self + .tasks + .iter() + .filter(|t| t.status.is_failed()) + .count(); + (total, done, in_progress, failed) + } +} + +fn status_icon(status: Status) -> &'static str { + match status { + Status::Todo => "[ ]", + Status::InProgress => "[~]", + Status::Done => "[x]", + Status::Blocked => "[!]", + Status::Skipped => "[-]", + Status::Failed => "[X]", + Status::UpForRetry => "[?]", + Status::UpstreamFailed => "[^]", + } +} + +fn status_color(status: Status) -> Color { + match status { + Status::Todo => Color::DarkGray, + Status::InProgress => Color::Yellow, + Status::Done => Color::Green, + Status::Blocked => Color::Magenta, + Status::Skipped => Color::DarkGray, + Status::Failed => Color::Red, + Status::UpForRetry => Color::LightYellow, + Status::UpstreamFailed => Color::LightRed, + } +} + +/// Numeric rank for sorting statuses in a useful order. +fn status_rank(status: Status) -> u8 { + match status { + Status::InProgress => 0, + Status::Failed => 1, + Status::UpForRetry => 2, + Status::Blocked => 3, + Status::UpstreamFailed => 4, + Status::Todo => 5, + Status::Done => 6, + Status::Skipped => 7, + } +} + +impl Component for TasksTab { + fn handle_key_event(&mut self, key: KeyEvent, _tx: &ActionSender) -> Result { + // Search mode captures all input. + if self.search_active { + match key.code { + KeyCode::Esc => { + self.search_active = false; + self.search_query.clear(); + self.refilter_and_sort(); + } + KeyCode::Enter => { + self.search_active = false; + } + KeyCode::Backspace => { + self.search_query.pop(); + self.refilter_and_sort(); + } + KeyCode::Char(c) => { + self.search_query.push(c); + self.refilter_and_sort(); + } + _ => {} + } + return Ok(true); + } + + // Detail popup mode. + if self.detail_open { + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { + self.detail_open = false; + } + _ => {} + } + return Ok(true); + } + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + self.move_selection(1); + Ok(true) + } + KeyCode::Char('k') | KeyCode::Up => { + self.move_selection(-1); + Ok(true) + } + KeyCode::Char('G') => { + if !self.filtered.is_empty() { + self.table_state.select(Some(self.filtered.len() - 1)); + } + Ok(true) + } + KeyCode::Char('g') => { + if !self.filtered.is_empty() { + self.table_state.select(Some(0)); + } + Ok(true) + } + KeyCode::Enter => { + if self.selected_task().is_some() { + self.detail_open = true; + } + Ok(true) + } + KeyCode::Char('/') => { + self.search_active = true; + self.search_query.clear(); + Ok(true) + } + KeyCode::Char('s') => { + // Cycle sort column. + let old_col = self.sort_col; + self.sort_col = self.sort_col.next(); + if self.sort_col == old_col { + self.sort_asc = !self.sort_asc; + } else { + self.sort_asc = true; + } + self.refilter_and_sort(); + Ok(true) + } + KeyCode::Char('S') => { + // Toggle sort direction. + self.sort_asc = !self.sort_asc; + self.refilter_and_sort(); + Ok(true) + } + _ => Ok(false), + } + } + + fn update(&mut self, action: &Action) -> Result<()> { + if let Action::Tick = action { + // Data loading happens externally via load_tasks(); + // Tick is a no-op until we wire up DB polling. + } + Ok(()) + } + + fn render(&self, frame: &mut Frame, area: Rect) { + if !self.loaded || self.tasks.is_empty() { + render_empty(frame, area); + return; + } + + // Layout: progress bar (2 rows) | search bar (1 row if active) | table (rest). + let search_height = if self.search_active { 1 } else { 0 }; + let chunks = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(search_height), + Constraint::Min(4), + ]) + .split(area); + + // Progress bar. + let (total, done, in_progress, failed) = self.progress_stats(); + let ratio = if total > 0 { + done as f64 / total as f64 + } else { + 0.0 + }; + let label = format!( + "{done}/{total} done, {in_progress} running, {failed} failed" + ); + let gauge = Gauge::default() + .block(Block::default().borders(Borders::NONE)) + .gauge_style( + Style::default() + .fg(Color::Green) + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .ratio(ratio) + .label(label); + frame.render_widget(gauge, chunks[0]); + + // Search bar. + if self.search_active { + let search_line = Line::from(vec![ + Span::styled("/", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(&self.search_query), + Span::styled("_", Style::default().fg(Color::Yellow).add_modifier(Modifier::SLOW_BLINK)), + ]); + let search_bar = Paragraph::new(search_line) + .style(Style::default().bg(Color::Black)); + frame.render_widget(search_bar, chunks[1]); + } + + // Table header. + let sort_indicator = |col: SortColumn| -> &str { + if col == self.sort_col { + if self.sort_asc { " ^" } else { " v" } + } else { + "" + } + }; + let header = Row::new(vec![ + Cell::from(format!("Status{}", sort_indicator(SortColumn::Status))), + Cell::from(format!("Pri{}", sort_indicator(SortColumn::Priority))), + Cell::from(format!("Domain{}", sort_indicator(SortColumn::Domain))), + Cell::from("ID"), + Cell::from(format!("Title{}", sort_indicator(SortColumn::Title))), + ]) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = self + .filtered + .iter() + .map(|&idx| { + let t = &self.tasks[idx]; + let sc = status_color(t.status); + Row::new(vec![ + Cell::from(status_icon(t.status)).style(Style::default().fg(sc)), + Cell::from(format!("{}", t.sort_priority())) + .style(Style::default().fg(Color::White)), + Cell::from(t.domain.to_string()) + .style(Style::default().fg(Color::Blue)), + Cell::from(t.id.as_str()) + .style(Style::default().fg(Color::DarkGray)), + Cell::from(t.title.as_str()) + .style(Style::default().fg(Color::White)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(5), + Constraint::Length(4), + Constraint::Length(13), + Constraint::Length(25), + Constraint::Fill(1), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .title(format!( + " Tasks ({}/{}) ", + self.filtered.len(), + self.tasks.len() + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = self.table_state.clone(); + frame.render_stateful_widget(table, chunks[2], &mut state); + + // Detail popup. + if self.detail_open { + if let Some(task) = self.selected_task() { + render_detail_popup(frame, area, task); + } + } + } + + fn keybindings(&self) -> Vec<(&str, &str)> { + if self.search_active { + return vec![("Esc", "cancel"), ("Enter", "apply")]; + } + if self.detail_open { + return vec![("Esc/Enter", "close")]; + } + vec![ + ("j/k", "navigate"), + ("Enter", "details"), + ("/", "search"), + ("s", "sort"), + ("S", "reverse"), + ] + } +} + +fn render_empty(frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(" Tasks ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let empty_msg = vec![ + Line::from(""), + Line::from(Span::styled( + " No tasks loaded", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )), + Line::from(""), + Line::from(Span::styled( + " Run flowctl in a project with .flow/ to see tasks", + Style::default().fg(Color::DarkGray), + )), + ]; + + let paragraph = Paragraph::new(empty_msg).block(block); + frame.render_widget(paragraph, area); +} + +fn render_detail_popup(frame: &mut Frame, area: Rect, task: &Task) { + // Center a popup covering ~60% of the screen. + let popup_width = (area.width * 3 / 5).max(40).min(area.width - 4); + let popup_height = (area.height * 3 / 5).max(12).min(area.height - 4); + let x = area.x + (area.width - popup_width) / 2; + let y = area.y + (area.height - popup_height) / 2; + let popup_area = Rect::new(x, y, popup_width, popup_height); + + frame.render_widget(Clear, popup_area); + + let deps = if task.depends_on.is_empty() { + "none".to_string() + } else { + task.depends_on.join(", ") + }; + let files = if task.files.is_empty() { + "none".to_string() + } else { + task.files.join(", ") + }; + + let lines = vec![ + Line::from(vec![ + Span::styled("ID: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(&task.id), + ]), + Line::from(vec![ + Span::styled("Title: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(&task.title), + ]), + Line::from(vec![ + Span::styled("Epic: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(&task.epic), + ]), + Line::from(vec![ + Span::styled("Status: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled( + format!("{} {}", status_icon(task.status), task.status), + Style::default().fg(status_color(task.status)), + ), + ]), + Line::from(vec![ + Span::styled("Priority: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", task.sort_priority())), + ]), + Line::from(vec![ + Span::styled("Domain: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(task.domain.to_string()), + ]), + Line::from(vec![ + Span::styled("Deps: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(deps), + ]), + Line::from(vec![ + Span::styled("Files: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(files), + ]), + Line::from(vec![ + Span::styled("Created: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(task.created_at.format("%Y-%m-%d %H:%M").to_string()), + ]), + ]; + + let block = Block::default() + .title(" Task Detail ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)); + + let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, popup_area); +} diff --git a/flowctl/crates/flowctl-tui/src/widgets/mod.rs b/flowctl/crates/flowctl-tui/src/widgets/mod.rs new file mode 100644 index 00000000..7c548607 --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/widgets/mod.rs @@ -0,0 +1,5 @@ +//! Reusable TUI widgets. + +pub mod toast; + +pub use toast::{Toast, ToastLevel, ToastStack}; diff --git a/flowctl/crates/flowctl-tui/src/widgets/toast.rs b/flowctl/crates/flowctl-tui/src/widgets/toast.rs new file mode 100644 index 00000000..05d25bee --- /dev/null +++ b/flowctl/crates/flowctl-tui/src/widgets/toast.rs @@ -0,0 +1,156 @@ +//! Toast notification system - bottom-right corner stack with auto-expire. + +use std::time::{Duration, Instant}; + +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::Frame; + +/// Toast severity level. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastLevel { + Success, + Error, + Warning, +} + +impl ToastLevel { + fn color(self) -> Color { + match self { + ToastLevel::Success => Color::Green, + ToastLevel::Error => Color::Red, + ToastLevel::Warning => Color::Yellow, + } + } + + fn icon(self) -> &'static str { + match self { + ToastLevel::Success => "[ok]", + ToastLevel::Error => "[!!]", + ToastLevel::Warning => "[!]", + } + } +} + +/// A single toast notification. +#[derive(Debug, Clone)] +pub struct Toast { + pub level: ToastLevel, + pub message: String, + pub created_at: Instant, + pub ttl: Duration, +} + +impl Toast { + /// Create a new toast with default TTL (4 seconds). + pub fn new(level: ToastLevel, message: impl Into) -> Self { + Self { + level, + message: message.into(), + created_at: Instant::now(), + ttl: Duration::from_secs(4), + } + } + + /// Create a toast with custom TTL. + pub fn with_ttl(level: ToastLevel, message: impl Into, ttl: Duration) -> Self { + Self { + level, + message: message.into(), + created_at: Instant::now(), + ttl, + } + } + + /// Check if the toast has expired. + pub fn is_expired(&self) -> bool { + self.created_at.elapsed() >= self.ttl + } +} + +/// A stack of toast notifications rendered in the bottom-right corner. +#[derive(Debug, Default)] +pub struct ToastStack { + toasts: Vec, +} + +const MAX_TOASTS: usize = 5; +const TOAST_WIDTH: u16 = 40; +const TOAST_HEIGHT: u16 = 3; + +impl ToastStack { + pub fn new() -> Self { + Self { toasts: Vec::new() } + } + + /// Push a new toast onto the stack. + pub fn push(&mut self, toast: Toast) { + self.toasts.push(toast); + // Cap the visible stack. + if self.toasts.len() > MAX_TOASTS { + self.toasts.remove(0); + } + } + + /// Remove expired toasts. Call on every tick. + pub fn gc(&mut self) { + self.toasts.retain(|t| !t.is_expired()); + } + + /// Whether there are any active toasts. + pub fn is_empty(&self) -> bool { + self.toasts.is_empty() + } + + /// Render the toast stack in the bottom-right corner of the given area. + pub fn render(&self, frame: &mut Frame, area: Rect) { + if self.toasts.is_empty() { + return; + } + + let toast_w = TOAST_WIDTH.min(area.width.saturating_sub(2)); + let max_visible = ((area.height.saturating_sub(2)) / TOAST_HEIGHT) as usize; + let visible_toasts: Vec<&Toast> = self + .toasts + .iter() + .rev() + .take(max_visible) + .collect::>() + .into_iter() + .rev() + .collect(); + + for (i, toast) in visible_toasts.iter().enumerate() { + let bottom_offset = (visible_toasts.len() - 1 - i) as u16 * TOAST_HEIGHT; + let y = area.y + area.height.saturating_sub(TOAST_HEIGHT + bottom_offset + 1); + let x = area.x + area.width.saturating_sub(toast_w + 1); + + let toast_area = Rect::new(x, y, toast_w, TOAST_HEIGHT); + + frame.render_widget(Clear, toast_area); + + let color = toast.level.color(); + let icon = toast.level.icon(); + + let line = Line::from(vec![ + Span::styled( + format!("{icon} "), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + Span::styled( + toast.message.clone(), + Style::default().fg(Color::White), + ), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(color)); + + let paragraph = Paragraph::new(line).block(block).wrap(Wrap { trim: true }); + frame.render_widget(paragraph, toast_area); + } + } +} diff --git a/flowctl/crates/flowctl-tui/tests/snapshot_tests.rs b/flowctl/crates/flowctl-tui/tests/snapshot_tests.rs new file mode 100644 index 00000000..8a1f4817 --- /dev/null +++ b/flowctl/crates/flowctl-tui/tests/snapshot_tests.rs @@ -0,0 +1,270 @@ +//! Snapshot tests for TUI components using insta. +//! +//! Each test renders a component into a ratatui Buffer and snapshots +//! the resulting text output. + +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::Terminal; + +use flowctl_core::state_machine::Status; +use flowctl_core::types::{Domain, Task}; +use flowctl_db::EventRow; +use flowctl_db::metrics::{Summary, TokenBreakdown, WeeklyTrend}; + +use flowctl_tui::app::App; +use flowctl_tui::tabs::{LogsTab, StatsTab, TasksTab}; +use flowctl_tui::widgets::toast::{Toast, ToastLevel, ToastStack}; +use flowctl_tui::component::Component; + +fn render_to_string(width: u16, height: u16, render_fn: impl FnOnce(&mut ratatui::Frame, Rect)) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + render_fn(frame, frame.area()); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + buffer_to_string(&buffer) +} + +fn buffer_to_string(buffer: &Buffer) -> String { + let area = buffer.area; + let mut output = String::new(); + for y in area.y..area.y + area.height { + for x in area.x..area.x + area.width { + let cell = &buffer[(x, y)]; + output.push_str(cell.symbol()); + } + output.push('\n'); + } + // Trim trailing whitespace from each line for cleaner snapshots. + output + .lines() + .map(|l| l.trim_end()) + .collect::>() + .join("\n") +} + +fn make_test_tasks() -> Vec { + let now = chrono::Utc::now(); + vec![ + Task { + schema_version: 1, + id: "fn-1-test.1".to_string(), + epic: "fn-1-test".to_string(), + title: "Setup database".to_string(), + status: Status::Done, + priority: Some(1), + domain: Domain::Backend, + depends_on: vec![], + files: vec!["src/db.rs".to_string()], + r#impl: None, + review: None, + sync: None, + file_path: None, + created_at: now, + updated_at: now, + }, + Task { + schema_version: 1, + id: "fn-1-test.2".to_string(), + epic: "fn-1-test".to_string(), + title: "Build API".to_string(), + status: Status::InProgress, + priority: Some(2), + domain: Domain::Backend, + depends_on: vec!["fn-1-test.1".to_string()], + files: vec!["src/api.rs".to_string()], + r#impl: None, + review: None, + sync: None, + file_path: None, + created_at: now, + updated_at: now, + }, + Task { + schema_version: 1, + id: "fn-1-test.3".to_string(), + epic: "fn-1-test".to_string(), + title: "Write tests".to_string(), + status: Status::Todo, + priority: Some(3), + domain: Domain::Testing, + depends_on: vec!["fn-1-test.2".to_string()], + files: vec![], + r#impl: None, + review: None, + sync: None, + file_path: None, + created_at: now, + updated_at: now, + }, + ] +} + +fn make_test_events() -> Vec { + vec![ + EventRow { + id: 1, + timestamp: "2025-01-15T10:30:00.000Z".to_string(), + epic_id: "fn-1-test".to_string(), + task_id: Some("fn-1-test.1".to_string()), + event_type: "task_started".to_string(), + actor: Some("worker-1".to_string()), + payload: None, + session_id: Some("sess-001".to_string()), + }, + EventRow { + id: 2, + timestamp: "2025-01-15T10:35:00.000Z".to_string(), + epic_id: "fn-1-test".to_string(), + task_id: Some("fn-1-test.1".to_string()), + event_type: "task_completed".to_string(), + actor: Some("worker-1".to_string()), + payload: Some("{\"summary\": \"done\"}".to_string()), + session_id: Some("sess-001".to_string()), + }, + EventRow { + id: 3, + timestamp: "2025-01-15T10:36:00.000Z".to_string(), + epic_id: "fn-1-test".to_string(), + task_id: Some("fn-1-test.2".to_string()), + event_type: "task_failed".to_string(), + actor: Some("worker-2".to_string()), + payload: Some("build error".to_string()), + session_id: Some("sess-002".to_string()), + }, + EventRow { + id: 4, + timestamp: "2025-01-15T10:37:00.000Z".to_string(), + epic_id: "fn-1-test".to_string(), + task_id: Some("fn-1-test.2".to_string()), + event_type: "task_blocked".to_string(), + actor: None, + payload: Some("waiting on review".to_string()), + session_id: None, + }, + ] +} + +// ── Tasks Tab ────────────────────────────────────────────────── + +#[test] +fn test_tasks_tab_empty() { + let tab = TasksTab::new(); + let output = render_to_string(80, 20, |frame, area| { + tab.render(frame, area); + }); + insta::assert_snapshot!("tasks_tab_empty", output); +} + +#[test] +fn test_tasks_tab_with_data() { + let mut tab = TasksTab::new(); + tab.load_tasks(make_test_tasks()); + let output = render_to_string(80, 20, |frame, area| { + tab.render(frame, area); + }); + insta::assert_snapshot!("tasks_tab_with_data", output); +} + +// ── Logs Tab ─────────────────────────────────────────────────── + +#[test] +fn test_logs_tab_empty() { + let tab = LogsTab::new(); + let output = render_to_string(80, 20, |frame, area| { + tab.render(frame, area); + }); + insta::assert_snapshot!("logs_tab_empty", output); +} + +#[test] +fn test_logs_tab_with_events() { + let mut tab = LogsTab::new(); + tab.load_events(make_test_events()); + let output = render_to_string(100, 20, |frame, area| { + tab.render(frame, area); + }); + insta::assert_snapshot!("logs_tab_with_events", output); +} + +// ── Stats Tab ────────────────────────────────────────────────── + +#[test] +fn test_stats_tab_empty() { + let tab = StatsTab::new(); + let output = render_to_string(80, 20, |frame, area| { + tab.render(frame, area); + }); + insta::assert_snapshot!("stats_tab_empty", output); +} + +#[test] +fn test_stats_tab_with_data() { + use flowctl_tui::tabs::StatsData; + + let mut tab = StatsTab::new(); + tab.load_stats(StatsData { + summary: Some(Summary { + total_epics: 3, + open_epics: 1, + total_tasks: 15, + done_tasks: 10, + in_progress_tasks: 3, + blocked_tasks: 1, + total_events: 42, + total_tokens: 1_500_000, + total_cost_usd: 2.35, + }), + weekly_trends: vec![ + WeeklyTrend { week: "2025-W01".to_string(), tasks_started: 5, tasks_completed: 3, tasks_failed: 0 }, + WeeklyTrend { week: "2025-W02".to_string(), tasks_started: 8, tasks_completed: 7, tasks_failed: 1 }, + WeeklyTrend { week: "2025-W03".to_string(), tasks_started: 4, tasks_completed: 4, tasks_failed: 0 }, + ], + token_breakdown: vec![ + TokenBreakdown { + epic_id: "fn-1-test".to_string(), + model: "claude-sonnet".to_string(), + input_tokens: 800_000, + output_tokens: 200_000, + cache_read: 100_000, + cache_write: 50_000, + estimated_cost: 1.50, + }, + ], + }); + let output = render_to_string(100, 24, |frame, area| { + tab.render(frame, area); + }); + insta::assert_snapshot!("stats_tab_with_data", output); +} + +// ── Toast ────────────────────────────────────────────────────── + +#[test] +fn test_toast_stack_render() { + let mut stack = ToastStack::new(); + stack.push(Toast::new(ToastLevel::Success, "Task completed!")); + stack.push(Toast::new(ToastLevel::Error, "Build failed")); + stack.push(Toast::new(ToastLevel::Warning, "Low disk space")); + + let output = render_to_string(80, 20, |frame, area| { + stack.render(frame, area); + }); + insta::assert_snapshot!("toast_stack", output); +} + +// ── Full App ─────────────────────────────────────────────────── + +#[test] +fn test_app_default_render() { + let app = App::new(); + let output = render_to_string(100, 30, |frame, area| { + app.render(frame, area); + }); + insta::assert_snapshot!("app_default", output); +} diff --git a/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__app_default.snap b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__app_default.snap new file mode 100644 index 00000000..3b82989a --- /dev/null +++ b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__app_default.snap @@ -0,0 +1,35 @@ +--- +source: crates/flowctl-tui/tests/snapshot_tests.rs +assertion_line: 269 +expression: output +--- +┌ flowctl ─────────────────────────────────────────────────────────────────────────────────────────┐ +│ Tasks | DAG | Logs | Stats │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌ Tasks ───────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ No tasks loaded │ +│ │ +│ Run flowctl in a project with .flow/ to see tasks │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ + 1-4:tab Tab:next q:quit | j/k:navigate Enter:details /:search s:sort S:reverse diff --git a/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__logs_tab_empty.snap b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__logs_tab_empty.snap new file mode 100644 index 00000000..f2b61f34 --- /dev/null +++ b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__logs_tab_empty.snap @@ -0,0 +1,25 @@ +--- +source: crates/flowctl-tui/tests/snapshot_tests.rs +assertion_line: 182 +expression: output +--- +┌ Logs ────────────────────────────────────────────────────────────────────────┐ +│ │ +│ No log events │ +│ │ +│ Events from flowctl operations will appear here │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__logs_tab_with_events.snap b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__logs_tab_with_events.snap new file mode 100644 index 00000000..85753eae --- /dev/null +++ b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__logs_tab_with_events.snap @@ -0,0 +1,25 @@ +--- +source: crates/flowctl-tui/tests/snapshot_tests.rs +assertion_line: 192 +expression: output +--- + Filter: [x]ERR [x]WRN [x]INF [ ]DBG (4/4) AUTO +┌ Logs (4) ────────────────────────────────────────────────┐┌ Detail ──────────────────────────────┐ +│ [INF] 10:30:00 [INF] task_started fn-1-test.1 ││ID: 4 │ +│ [INF] 10:35:00 [INF] task_completed fn-1-test.1 ││Time: 2025-01-15T10:37:00.000Z │ +│ [ERR] 10:36:00 [ERR] task_failed fn-1-test.2 ││Level: WRN │ +│> [WRN] 10:37:00 [WRN] task_blocked fn-1-test.2 ││Type: task_blocked │ +│ ││Epic: fn-1-test │ +│ ││Task: fn-1-test.2 │ +│ ││Actor: - │ +│ ││ │ +│ ││Payload: │ +│ ││ waiting on review │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +└──────────────────────────────────────────────────────────┘└──────────────────────────────────────┘ diff --git a/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__stats_tab_empty.snap b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__stats_tab_empty.snap new file mode 100644 index 00000000..af76763c --- /dev/null +++ b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__stats_tab_empty.snap @@ -0,0 +1,25 @@ +--- +source: crates/flowctl-tui/tests/snapshot_tests.rs +assertion_line: 203 +expression: output +--- +┌ Stats ───────────────────────────────────────────────────────────────────────┐ +│ │ +│ No statistics available │ +│ │ +│ Completion rates, velocity, and burndown will show here │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__stats_tab_with_data.snap b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__stats_tab_with_data.snap new file mode 100644 index 00000000..20d9e83d --- /dev/null +++ b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__stats_tab_with_data.snap @@ -0,0 +1,29 @@ +--- +source: crates/flowctl-tui/tests/snapshot_tests.rs +assertion_line: 243 +expression: output +--- +┌ Task Progress ──────────────────────────────────────┐┌ Throughput (tasks/week) ──────────────────┐ +│███████Tasks: 10/15 done, 3 running, 1 blocked ││ █ │ +└─────────────────────────────────────────────────────┘│ █ │ +┌ Epic Progress ──────────────────────────────────────┐│ █ │ +│███████████████Epics: 2/3 done, 1 open ││ █ │ +└─────────────────────────────────────────────────────┘│ █▅ │ + Events: 42 Tokens: 1.5M Cost: $2.3500 │▂██ │ + │███ │ + │███ │ + │███ │ + │███ │ + └───────────────────────────────────────────┘ +┌ Completed Tasks (weekly) ──────────────────────┐┌ Token Usage ───────────────────────────────────┐ +│ █████ ││Epic Model Input Outp│ +│ █████ ││fn-1-test claude-s... 800.0K 200.│ +│ █████ ││ │ +│ █████ ▁▁▁▁▁ ││ │ +│ █████ █████ ││ │ +│▆▆▆▆▆ █████ █████ ││ │ +│█████ █████ █████ ││ │ +│█████ █████ █████ ││ │ +│██3██ ██7██ ██4██ ││ │ +│ W01 W02 W03 ││ │ +└────────────────────────────────────────────────┘└────────────────────────────────────────────────┘ diff --git a/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__tasks_tab_empty.snap b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__tasks_tab_empty.snap new file mode 100644 index 00000000..3dc73211 --- /dev/null +++ b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__tasks_tab_empty.snap @@ -0,0 +1,25 @@ +--- +source: crates/flowctl-tui/tests/snapshot_tests.rs +assertion_line: 161 +expression: output +--- +┌ Tasks ───────────────────────────────────────────────────────────────────────┐ +│ │ +│ No tasks loaded │ +│ │ +│ Run flowctl in a project with .flow/ to see tasks │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__tasks_tab_with_data.snap b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__tasks_tab_with_data.snap new file mode 100644 index 00000000..4f8ad980 --- /dev/null +++ b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__tasks_tab_with_data.snap @@ -0,0 +1,25 @@ +--- +source: crates/flowctl-tui/tests/snapshot_tests.rs +assertion_line: 171 +expression: output +--- +███████████████████████████ +█████████████████████████1/3 done, 1 running, 0 failed +┌ Tasks (3/3) ─────────────────────────────────────────────────────────────────┐ +│ Statu Pri Domain ID Title │ +│> [~] 2 backend fn-1-test.2 Build API │ +│ [ ] 3 testing fn-1-test.3 Write tests │ +│ [x] 1 backend fn-1-test.1 Setup database │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__toast_stack.snap b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__toast_stack.snap new file mode 100644 index 00000000..1e88964d --- /dev/null +++ b/flowctl/crates/flowctl-tui/tests/snapshots/snapshot_tests__toast_stack.snap @@ -0,0 +1,24 @@ +--- +source: crates/flowctl-tui/tests/snapshot_tests.rs +assertion_line: 258 +expression: output +--- + + + + + + + + + + + ┌──────────────────────────────────────┐ + │[ok] Task completed! │ + └──────────────────────────────────────┘ + ┌──────────────────────────────────────┐ + │[!!] Build failed │ + └──────────────────────────────────────┘ + ┌──────────────────────────────────────┐ + │[!] Low disk space │ + └──────────────────────────────────────┘ diff --git a/flowctl/dist-workspace.toml b/flowctl/dist-workspace.toml new file mode 100644 index 00000000..e8726cb9 --- /dev/null +++ b/flowctl/dist-workspace.toml @@ -0,0 +1,31 @@ +# cargo-dist workspace configuration +# https://opensource.axo.dev/cargo-dist/ + +[workspace] +members = ["./"] + +[dist] +# Target platforms +targets = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", +] + +# CI backend +ci = "github" + +# Archive format per platform +unix-archive = ".tar.gz" +windows-archive = ".zip" + +# Install via shell script +installers = ["shell"] + +# Enable checksums for all artifacts +checksum = "sha256" + +# Build only the flowctl binary +dist = true diff --git a/flowctl/install.sh b/flowctl/install.sh new file mode 100755 index 00000000..2d1c15db --- /dev/null +++ b/flowctl/install.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# flowctl installer — downloads the latest release binary from GitHub. +# Usage: curl -fsSL https://raw.githubusercontent.com/anthropics/flow-code/main/flowctl/install.sh | sh +set -eu + +REPO="anthropics/flow-code" +INSTALL_DIR="${FLOWCTL_INSTALL_DIR:-/usr/local/bin}" + +# ── Platform detection ──────────────────────────────────────────────── + +detect_platform() { + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Linux) os_target="unknown-linux-gnu" ;; + Darwin) os_target="apple-darwin" ;; + *) echo "Error: unsupported OS: $os" >&2; exit 1 ;; + esac + + case "$arch" in + x86_64|amd64) arch_target="x86_64" ;; + aarch64|arm64) arch_target="aarch64" ;; + *) echo "Error: unsupported architecture: $arch" >&2; exit 1 ;; + esac + + echo "${arch_target}-${os_target}" +} + +# ── Version resolution ──────────────────────────────────────────────── + +resolve_version() { + if [ -n "${FLOWCTL_VERSION:-}" ]; then + echo "$FLOWCTL_VERSION" + return + fi + version="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' | head -1 | cut -d'"' -f4)" + if [ -z "$version" ]; then + echo "Error: could not determine latest version" >&2 + exit 1 + fi + echo "$version" +} + +# ── Download & verify ───────────────────────────────────────────────── + +main() { + platform="$(detect_platform)" + version="$(resolve_version)" + archive="flowctl-${version}-${platform}.tar.gz" + base_url="https://github.com/${REPO}/releases/download/${version}" + + echo "Installing flowctl ${version} for ${platform}..." + + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + + echo "Downloading ${archive}..." + curl -fsSL "${base_url}/${archive}" -o "${tmpdir}/${archive}" + curl -fsSL "${base_url}/${archive}.sha256" -o "${tmpdir}/${archive}.sha256" + + echo "Verifying checksum..." + cd "$tmpdir" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "${archive}.sha256" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -c "${archive}.sha256" + else + echo "Warning: no sha256sum or shasum found, skipping checksum verification" >&2 + fi + + echo "Extracting..." + tar xzf "${archive}" + + echo "Installing to ${INSTALL_DIR}..." + if [ -w "$INSTALL_DIR" ]; then + mv flowctl "${INSTALL_DIR}/flowctl" + else + sudo mv flowctl "${INSTALL_DIR}/flowctl" + fi + chmod +x "${INSTALL_DIR}/flowctl" + + echo "Done! flowctl ${version} installed to ${INSTALL_DIR}/flowctl" + echo "Run 'flowctl --help' to get started." +} + +main diff --git a/flowctl/tests/cmd/block_json.toml b/flowctl/tests/cmd/block_json.toml new file mode 100644 index 00000000..daead3c3 --- /dev/null +++ b/flowctl/tests/cmd/block_json.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["--json", "block", "test-task-1", "--reason-file", "/dev/null"] +status.code = 1 +stderr = """ +{"error":"Invalid task ID: test-task-1. Expected format: fn-N.M or fn-N-slug.M (e.g., fn-1.2, fn-1-add-auth.2)","success":false} +""" diff --git a/flowctl/tests/cmd/block_missing_args.toml b/flowctl/tests/cmd/block_missing_args.toml new file mode 100644 index 00000000..388e5fb7 --- /dev/null +++ b/flowctl/tests/cmd/block_missing_args.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["block"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/cat_json.toml b/flowctl/tests/cmd/cat_json.toml new file mode 100644 index 00000000..727bb3d5 --- /dev/null +++ b/flowctl/tests/cmd/cat_json.toml @@ -0,0 +1,7 @@ +# Cat with invalid ID format +bin.name = "flowctl" +args = ["cat", "test-epic-1"] +status.code = 1 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/cat_missing_arg.toml b/flowctl/tests/cmd/cat_missing_arg.toml new file mode 100644 index 00000000..385903e5 --- /dev/null +++ b/flowctl/tests/cmd/cat_missing_arg.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["cat"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/config_get_json.toml b/flowctl/tests/cmd/config_get_json.toml new file mode 100644 index 00000000..4ae1ffcc --- /dev/null +++ b/flowctl/tests/cmd/config_get_json.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["--json", "config", "get", "memory.enabled"] +status.code = 0 +stdout = """ +{"key":"memory.enabled","success":true,"value":true} +""" diff --git a/flowctl/tests/cmd/config_get_missing_key.toml b/flowctl/tests/cmd/config_get_missing_key.toml new file mode 100644 index 00000000..c41bdcc2 --- /dev/null +++ b/flowctl/tests/cmd/config_get_missing_key.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["config", "get"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/dep_add_json.toml b/flowctl/tests/cmd/dep_add_json.toml new file mode 100644 index 00000000..c0082ce4 --- /dev/null +++ b/flowctl/tests/cmd/dep_add_json.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["--json", "dep", "add", "fn-1.1", "fn-1.2"] +status.code = 1 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/dep_add_missing_args.toml b/flowctl/tests/cmd/dep_add_missing_args.toml new file mode 100644 index 00000000..b84726f8 --- /dev/null +++ b/flowctl/tests/cmd/dep_add_missing_args.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["dep", "add"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/done_json.toml b/flowctl/tests/cmd/done_json.toml new file mode 100644 index 00000000..e381a0a1 --- /dev/null +++ b/flowctl/tests/cmd/done_json.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["--json", "done", "test-task-1"] +status.code = 1 +stderr = """ +{"error":"Invalid task ID: test-task-1. Expected format: fn-N.M or fn-N-slug.M (e.g., fn-1.2, fn-1-add-auth.2)","success":false} +""" diff --git a/flowctl/tests/cmd/done_missing_arg.toml b/flowctl/tests/cmd/done_missing_arg.toml new file mode 100644 index 00000000..a9cd474f --- /dev/null +++ b/flowctl/tests/cmd/done_missing_arg.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["done"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/epic_create_json.toml b/flowctl/tests/cmd/epic_create_json.toml new file mode 100644 index 00000000..9aac5577 --- /dev/null +++ b/flowctl/tests/cmd/epic_create_json.toml @@ -0,0 +1,4 @@ +bin.name = "flowctl" +args = ["--json", "epic", "create", "--title", "Test Epic"] +status.code = 0 +stdout = "..." diff --git a/flowctl/tests/cmd/epic_create_missing_title.toml b/flowctl/tests/cmd/epic_create_missing_title.toml new file mode 100644 index 00000000..75c8faa4 --- /dev/null +++ b/flowctl/tests/cmd/epic_create_missing_title.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["epic", "create"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/help.toml b/flowctl/tests/cmd/help.toml new file mode 100644 index 00000000..e3862097 --- /dev/null +++ b/flowctl/tests/cmd/help.toml @@ -0,0 +1,11 @@ +bin.name = "flowctl" +args = ["--help"] +status.code = 0 +stdout = """ +Development orchestration engine + +Usage: flowctl [OPTIONS] + +Commands: +... +""" diff --git a/flowctl/tests/cmd/init_json.toml b/flowctl/tests/cmd/init_json.toml new file mode 100644 index 00000000..10a4566e --- /dev/null +++ b/flowctl/tests/cmd/init_json.toml @@ -0,0 +1,7 @@ +# Init creates or confirms .flow/ directory +bin.name = "flowctl" +args = ["--json", "init"] +status.code = 0 +stdout = """ +... +""" diff --git a/flowctl/tests/cmd/init_plain.toml b/flowctl/tests/cmd/init_plain.toml new file mode 100644 index 00000000..07066172 --- /dev/null +++ b/flowctl/tests/cmd/init_plain.toml @@ -0,0 +1,7 @@ +# Init creates or confirms .flow/ directory +bin.name = "flowctl" +args = ["init"] +status.code = 0 +stdout = """ +... +""" diff --git a/flowctl/tests/cmd/invalid_subcommand.toml b/flowctl/tests/cmd/invalid_subcommand.toml new file mode 100644 index 00000000..41c36d65 --- /dev/null +++ b/flowctl/tests/cmd/invalid_subcommand.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["nonexistent"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/next_json.toml b/flowctl/tests/cmd/next_json.toml new file mode 100644 index 00000000..ed4ba57d --- /dev/null +++ b/flowctl/tests/cmd/next_json.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["--json", "next"] +status.code = 0 +stdout = """ +{"epic":null,"reason":"none","status":"none","success":true,"task":null} +""" diff --git a/flowctl/tests/cmd/no_subcommand.toml b/flowctl/tests/cmd/no_subcommand.toml new file mode 100644 index 00000000..42d17a1d --- /dev/null +++ b/flowctl/tests/cmd/no_subcommand.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = [] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/ready_json.toml b/flowctl/tests/cmd/ready_json.toml new file mode 100644 index 00000000..a5688f67 --- /dev/null +++ b/flowctl/tests/cmd/ready_json.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["--json", "ready", "--epic", "test-epic"] +status.code = 1 +stderr = """ +{"error":"Invalid epic ID: test-epic. Expected format: fn-N or fn-N-slug (e.g., fn-1, fn-1-add-auth)","success":false} +""" diff --git a/flowctl/tests/cmd/ready_missing_epic.toml b/flowctl/tests/cmd/ready_missing_epic.toml new file mode 100644 index 00000000..2573db4f --- /dev/null +++ b/flowctl/tests/cmd/ready_missing_epic.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["ready"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/restart_json.toml b/flowctl/tests/cmd/restart_json.toml new file mode 100644 index 00000000..9b26bd3a --- /dev/null +++ b/flowctl/tests/cmd/restart_json.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["--json", "restart", "test-task-1"] +status.code = 1 +stderr = """ +{"error":"Invalid task ID: test-task-1. Expected format: fn-N.M or fn-N-slug.M","success":false} +""" diff --git a/flowctl/tests/cmd/restart_missing_arg.toml b/flowctl/tests/cmd/restart_missing_arg.toml new file mode 100644 index 00000000..c9ea804e --- /dev/null +++ b/flowctl/tests/cmd/restart_missing_arg.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["restart"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/show_json.toml b/flowctl/tests/cmd/show_json.toml new file mode 100644 index 00000000..122fce18 --- /dev/null +++ b/flowctl/tests/cmd/show_json.toml @@ -0,0 +1,7 @@ +# Show with invalid ID format (not fn-N-slug pattern) +bin.name = "flowctl" +args = ["--json", "show", "test-epic-1"] +status.code = 1 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/show_missing_arg.toml b/flowctl/tests/cmd/show_missing_arg.toml new file mode 100644 index 00000000..09d2bfa3 --- /dev/null +++ b/flowctl/tests/cmd/show_missing_arg.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["show"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/start_json.toml b/flowctl/tests/cmd/start_json.toml new file mode 100644 index 00000000..d3c14589 --- /dev/null +++ b/flowctl/tests/cmd/start_json.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["--json", "start", "test-task-1"] +status.code = 1 +stderr = """ +{"error":"Invalid task ID: test-task-1. Expected format: fn-N.M or fn-N-slug.M (e.g., fn-1.2, fn-1-add-auth.2)","success":false} +""" diff --git a/flowctl/tests/cmd/start_missing_arg.toml b/flowctl/tests/cmd/start_missing_arg.toml new file mode 100644 index 00000000..62c78dbf --- /dev/null +++ b/flowctl/tests/cmd/start_missing_arg.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["start"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/status_json.toml b/flowctl/tests/cmd/status_json.toml new file mode 100644 index 00000000..d7947370 --- /dev/null +++ b/flowctl/tests/cmd/status_json.toml @@ -0,0 +1,7 @@ +# Status command -- handles missing .flow/ gracefully +bin.name = "flowctl" +args = ["--json", "status"] +status.code = 0 +stdout = """ +... +""" diff --git a/flowctl/tests/cmd/status_plain.toml b/flowctl/tests/cmd/status_plain.toml new file mode 100644 index 00000000..5e47eee7 --- /dev/null +++ b/flowctl/tests/cmd/status_plain.toml @@ -0,0 +1,7 @@ +# Status command -- handles missing .flow/ gracefully +bin.name = "flowctl" +args = ["status"] +status.code = 0 +stdout = """ +... +""" diff --git a/flowctl/tests/cmd/task_create_json.toml b/flowctl/tests/cmd/task_create_json.toml new file mode 100644 index 00000000..8f0b062a --- /dev/null +++ b/flowctl/tests/cmd/task_create_json.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["--json", "task", "create", "--epic", "test-epic", "--title", "Test Task"] +status.code = 1 +stderr = """ +{"error":"Invalid epic ID: test-epic. Expected format: fn-N or fn-N-slug (e.g., fn-1, fn-1-add-auth)","success":false} +""" diff --git a/flowctl/tests/cmd/task_create_missing_args.toml b/flowctl/tests/cmd/task_create_missing_args.toml new file mode 100644 index 00000000..f472cb63 --- /dev/null +++ b/flowctl/tests/cmd/task_create_missing_args.toml @@ -0,0 +1,6 @@ +bin.name = "flowctl" +args = ["task", "create"] +status.code = 2 +stderr = """ +... +""" diff --git a/flowctl/tests/cmd/validate_json.toml b/flowctl/tests/cmd/validate_json.toml new file mode 100644 index 00000000..b9ce1daf --- /dev/null +++ b/flowctl/tests/cmd/validate_json.toml @@ -0,0 +1,4 @@ +bin.name = "flowctl" +args = ["--json", "validate", "--all"] +status.code = 0 +stdout = "..." diff --git a/flowctl/tests/cmd/version.toml b/flowctl/tests/cmd/version.toml new file mode 100644 index 00000000..40028032 --- /dev/null +++ b/flowctl/tests/cmd/version.toml @@ -0,0 +1,3 @@ +bin.name = "flowctl" +args = ["--version"] +status.code = 0 diff --git a/flowctl/tests/integration/compare_outputs.sh b/flowctl/tests/integration/compare_outputs.sh new file mode 100755 index 00000000..2fd73032 --- /dev/null +++ b/flowctl/tests/integration/compare_outputs.sh @@ -0,0 +1,565 @@ +#!/usr/bin/env bash +# compare_outputs.sh — Integration tests comparing Rust and Python flowctl output. +# +# Runs both Python ($FLOWCTL) and Rust (cargo-built binary) against identical +# input and compares JSON output structure, key presence, exit codes. +# +# Usage: +# bash flowctl/tests/integration/compare_outputs.sh [--verbose] +# +# Environment: +# FLOWCTL Path to Python flowctl.py (auto-detected if unset) +# RUST_BINARY Path to Rust flowctl binary (auto-detected if unset) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +VERBOSE="${1:-}" +PASS=0 +FAIL=0 +SKIP=0 + +# ── Locate binaries ────────────────────────────────────────────────── +FLOWCTL="${FLOWCTL:-$REPO_ROOT/scripts/flowctl.py}" +if [[ ! -f "$FLOWCTL" ]]; then + echo "FATAL: Python flowctl not found at $FLOWCTL" + exit 1 +fi + +RUST_BINARY="${RUST_BINARY:-$REPO_ROOT/flowctl/target/debug/flowctl}" +if [[ ! -f "$RUST_BINARY" ]]; then + echo "Building Rust binary..." + cargo build --manifest-path "$REPO_ROOT/flowctl/Cargo.toml" 2>/dev/null +fi +if [[ ! -f "$RUST_BINARY" ]]; then + echo "FATAL: Rust binary not found at $RUST_BINARY" + exit 1 +fi + +# ── Helpers ─────────────────────────────────────────────────────────── +TMPDIR_BASE="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_BASE"' EXIT + +log() { echo " $*"; } +log_verbose() { [[ "$VERBOSE" == "--verbose" ]] && echo " $*" || true; } + +# Run Python flowctl (--json goes AFTER subcommand) +run_python() { + local dir="$1"; shift + local cmd="$1"; shift + # Python: flowctl.py [sub-subcommand...] --json [args...] + # We need to insert --json after the command/subcommand tokens + (cd "$dir" && python3 "$FLOWCTL" $cmd --json "$@" 2>&1) +} +run_python_exit() { + local dir="$1"; shift + local cmd="$1"; shift + (cd "$dir" && python3 "$FLOWCTL" $cmd --json "$@" 2>&1; echo "EXIT:$?") | tail -1 | sed 's/EXIT://' +} + +# Run Rust flowctl (--json goes BEFORE subcommand) +run_rust() { + local dir="$1"; shift + (cd "$dir" && "$RUST_BINARY" --json "$@" 2>&1) +} +run_rust_exit() { + local dir="$1"; shift + (cd "$dir" && "$RUST_BINARY" --json "$@" 2>&1; echo "EXIT:$?") | tail -1 | sed 's/EXIT://' +} + +# Compare JSON output: normalize timestamps, ignore key ordering, ignore +# auto-generated IDs (they differ because each binary uses its own .flow/). +# Returns 0 if structurally equivalent, 1 otherwise. +compare_json() { + local py_json="$1" + local rs_json="$2" + local label="$3" + + # Normalize: sort keys, strip timestamps/dates, strip IDs, strip paths + local py_norm rs_norm + py_norm=$(echo "$py_json" | python3 -c " +import sys, json, re +try: + d = json.load(sys.stdin) +except: + print('PARSE_ERROR') + sys.exit(0) +print(json.dumps(d, sort_keys=True)) +" 2>/dev/null || echo "PARSE_ERROR") + + rs_norm=$(echo "$rs_json" | python3 -c " +import sys, json, re +try: + d = json.load(sys.stdin) +except: + print('PARSE_ERROR') + sys.exit(0) +print(json.dumps(d, sort_keys=True)) +" 2>/dev/null || echo "PARSE_ERROR") + + if [[ "$py_norm" == "PARSE_ERROR" ]] || [[ "$rs_norm" == "PARSE_ERROR" ]]; then + log_verbose "JSON parse error for $label" + log_verbose " Python: $py_json" + log_verbose " Rust: $rs_json" + return 1 + fi + + # Compare top-level keys + local py_keys rs_keys + py_keys=$(echo "$py_json" | python3 -c " +import sys, json +d = json.load(sys.stdin) +if isinstance(d, dict): + print(' '.join(sorted(d.keys()))) +else: + print('NOT_DICT') +" 2>/dev/null) + rs_keys=$(echo "$rs_json" | python3 -c " +import sys, json +d = json.load(sys.stdin) +if isinstance(d, dict): + print(' '.join(sorted(d.keys()))) +else: + print('NOT_DICT') +" 2>/dev/null) + + if [[ "$py_keys" != "$rs_keys" ]]; then + log_verbose "Key mismatch for $label" + log_verbose " Python keys: $py_keys" + log_verbose " Rust keys: $rs_keys" + return 1 + fi + + return 0 +} + +# Compare exit codes +compare_exit() { + local py_exit="$1" + local rs_exit="$2" + local label="$3" + + if [[ "$py_exit" != "$rs_exit" ]]; then + log_verbose "Exit code mismatch for $label: Python=$py_exit Rust=$rs_exit" + return 1 + fi + return 0 +} + +# Test runner +test_case() { + local name="$1" + local result="$2" # "pass" or "fail" + + if [[ "$result" == "pass" ]]; then + PASS=$((PASS + 1)) + log "PASS $name" + else + FAIL=$((FAIL + 1)) + log "FAIL $name" + fi +} + +skip_case() { + local name="$1" + local reason="$2" + SKIP=$((SKIP + 1)) + log "SKIP $name ($reason)" +} + +# ── Setup fresh .flow/ dirs ────────────────────────────────────────── +setup_empty_dirs() { + local py_dir="$TMPDIR_BASE/py_$$_$RANDOM" + local rs_dir="$TMPDIR_BASE/rs_$$_$RANDOM" + mkdir -p "$py_dir" "$rs_dir" + echo "$py_dir $rs_dir" +} + +setup_initialized_dirs() { + local dirs + dirs=$(setup_empty_dirs) + local py_dir rs_dir + py_dir=$(echo "$dirs" | cut -d' ' -f1) + rs_dir=$(echo "$dirs" | cut -d' ' -f2) + + run_python "$py_dir" "init" >/dev/null 2>&1 + run_rust "$rs_dir" "init" >/dev/null 2>&1 + + echo "$py_dir $rs_dir" +} + +setup_with_epic() { + local dirs + dirs=$(setup_initialized_dirs) + local py_dir rs_dir + py_dir=$(echo "$dirs" | cut -d' ' -f1) + rs_dir=$(echo "$dirs" | cut -d' ' -f2) + + run_python "$py_dir" "epic create" --title "Test Epic" >/dev/null 2>&1 + run_rust "$rs_dir" "epic" "create" --title "Test Epic" >/dev/null 2>&1 + + echo "$py_dir $rs_dir" +} + +setup_with_task() { + local dirs + dirs=$(setup_with_epic) + local py_dir rs_dir py_epic rs_epic + py_dir=$(echo "$dirs" | cut -d' ' -f1) + rs_dir=$(echo "$dirs" | cut -d' ' -f2) + + # Get epic IDs (they may differ) + py_epic=$(run_python "$py_dir" "epics" | python3 -c "import sys,json; print(json.load(sys.stdin)['epics'][0]['id'])" 2>/dev/null) + rs_epic=$(run_rust "$rs_dir" "epics" | python3 -c "import sys,json; print(json.load(sys.stdin)['epics'][0]['id'])" 2>/dev/null) + + run_python "$py_dir" "task create" --epic "$py_epic" --title "Task One" >/dev/null 2>&1 + run_rust "$rs_dir" "task" "create" --epic "$rs_epic" --title "Task One" >/dev/null 2>&1 + + echo "$py_dir $rs_dir $py_epic $rs_epic" +} + +# ══════════════════════════════════════════════════════════════════════ +echo "=== flowctl Integration Tests: Rust vs Python ===" +echo " Python: $FLOWCTL" +echo " Rust: $RUST_BINARY" +echo "" + +# ── Test 1: init ────────────────────────────────────────────────────── +echo "--- init ---" +dirs=$(setup_empty_dirs) +py_dir=$(echo "$dirs" | cut -d' ' -f1) +rs_dir=$(echo "$dirs" | cut -d' ' -f2) + +py_out=$(run_python "$py_dir" "init") +rs_out=$(run_rust "$rs_dir" "init") +py_exit=$?; rs_exit=$? + +if compare_json "$py_out" "$rs_out" "init"; then + test_case "init: JSON keys match" "pass" +else + test_case "init: JSON keys match" "fail" +fi + +# Check success field +py_success=$(echo "$py_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null) +rs_success=$(echo "$rs_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null) +if [[ "$py_success" == "True" ]] && [[ "$rs_success" == "True" ]]; then + test_case "init: both report success=true" "pass" +else + test_case "init: both report success=true" "fail" +fi + +# ── Test 2: init idempotent (re-init) ──────────────────────────────── +py_out2=$(run_python "$py_dir" "init") +rs_out2=$(run_rust "$rs_dir" "init") +py_success2=$(echo "$py_out2" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null) +rs_success2=$(echo "$rs_out2" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null) +if [[ "$py_success2" == "True" ]] && [[ "$rs_success2" == "True" ]]; then + test_case "init: idempotent re-init succeeds" "pass" +else + test_case "init: idempotent re-init succeeds" "fail" +fi + +# ── Test 3: status (empty .flow/) ──────────────────────────────────── +echo "--- status ---" +dirs=$(setup_initialized_dirs) +py_dir=$(echo "$dirs" | cut -d' ' -f1) +rs_dir=$(echo "$dirs" | cut -d' ' -f2) + +py_out=$(run_python "$py_dir" "status") +rs_out=$(run_rust "$rs_dir" "status") + +if compare_json "$py_out" "$rs_out" "status"; then + test_case "status: JSON keys match" "pass" +else + test_case "status: JSON keys match" "fail" +fi + +# Verify zero counts +py_todo=$(echo "$py_out" | python3 -c "import sys,json; print(json.load(sys.stdin)['tasks']['todo'])" 2>/dev/null) +rs_todo=$(echo "$rs_out" | python3 -c "import sys,json; print(json.load(sys.stdin)['tasks']['todo'])" 2>/dev/null) +if [[ "$py_todo" == "0" ]] && [[ "$rs_todo" == "0" ]]; then + test_case "status: empty .flow/ shows zero tasks" "pass" +else + test_case "status: empty .flow/ shows zero tasks" "fail" +fi + +# ── Test 4: epics (empty) ──────────────────────────────────────────── +echo "--- epics ---" +py_out=$(run_python "$py_dir" "epics") +rs_out=$(run_rust "$rs_dir" "epics") + +if compare_json "$py_out" "$rs_out" "epics-empty"; then + test_case "epics: empty list JSON keys match" "pass" +else + test_case "epics: empty list JSON keys match" "fail" +fi + +py_count=$(echo "$py_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('count',json.load(open('/dev/null')) if False else len(json.load(sys.stdin).get('epics',[]))))" 2>/dev/null || echo "?") +# simpler: +py_count=$(echo "$py_out" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('count', len(d.get('epics',[]))))" 2>/dev/null) +rs_count=$(echo "$rs_out" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('count', len(d.get('epics',[]))))" 2>/dev/null) +if [[ "$py_count" == "0" ]] && [[ "$rs_count" == "0" ]]; then + test_case "epics: both show count=0" "pass" +else + test_case "epics: both show count=0" "fail" +fi + +# ── Test 5: epic create ────────────────────────────────────────────── +echo "--- epic create ---" +py_out=$(run_python "$py_dir" "epic create" --title "Integration Test Epic") +rs_out=$(run_rust "$rs_dir" "epic" "create" --title "Integration Test Epic") + +if compare_json "$py_out" "$rs_out" "epic-create"; then + test_case "epic create: JSON keys match" "pass" +else + test_case "epic create: JSON keys match" "fail" +fi + +py_success=$(echo "$py_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null) +rs_success=$(echo "$rs_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null) +if [[ "$py_success" == "True" ]] && [[ "$rs_success" == "True" ]]; then + test_case "epic create: both succeed" "pass" +else + test_case "epic create: both succeed" "fail" +fi + +# Get epic IDs for subsequent tests +py_epic=$(echo "$py_out" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null) +rs_epic=$(echo "$rs_out" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null) + +# ── Test 6: show (epic) ────────────────────────────────────────────── +echo "--- show ---" +py_out=$(run_python "$py_dir" "show" "$py_epic") +rs_out=$(run_rust "$rs_dir" "show" "$rs_epic") + +# show may have extra keys in Python that Rust hasn't implemented yet +# Check Rust keys are a subset of Python keys +py_keys=$(echo "$py_out" | python3 -c "import sys,json; d=json.load(sys.stdin); print(' '.join(sorted(d.keys())) if isinstance(d,dict) else '')" 2>/dev/null) +rs_keys=$(echo "$rs_out" | python3 -c "import sys,json; d=json.load(sys.stdin); print(' '.join(sorted(d.keys())) if isinstance(d,dict) else '')" 2>/dev/null) +extra=$(python3 -c " +py=set('$py_keys'.split()) +rs=set('$rs_keys'.split()) +extra=rs-py +print(' '.join(sorted(extra)) if extra else '') +") +if [[ -z "$extra" ]]; then + test_case "show epic: Rust keys subset of Python" "pass" + missing=$(python3 -c " +py=set('$py_keys'.split()) +rs=set('$rs_keys'.split()) +m=py-rs +if m: print(' (Rust missing: ' + ', '.join(sorted(m)) + ')') +") + [[ -n "$missing" ]] && log "$missing" +else + test_case "show epic: Rust keys subset of Python" "fail" + log_verbose " Extra Rust keys: $extra" +fi + +# ── Test 7: task create ────────────────────────────────────────────── +echo "--- task create ---" +py_out=$(run_python "$py_dir" "task create" --epic "$py_epic" --title "Test Task Alpha") +rs_out=$(run_rust "$rs_dir" "task" "create" --epic "$rs_epic" --title "Test Task Alpha") + +if compare_json "$py_out" "$rs_out" "task-create"; then + test_case "task create: JSON keys match" "pass" +else + test_case "task create: JSON keys match" "fail" +fi + +py_task=$(echo "$py_out" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null) +rs_task=$(echo "$rs_out" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null) + +# ── Test 8: tasks list ─────────────────────────────────────────────── +echo "--- tasks ---" +py_out=$(run_python "$py_dir" "tasks" --epic "$py_epic") +rs_out=$(run_rust "$rs_dir" "tasks" --epic "$rs_epic") + +if compare_json "$py_out" "$rs_out" "tasks"; then + test_case "tasks: JSON keys match" "pass" +else + test_case "tasks: JSON keys match" "fail" +fi + +# ── Test 9: start ──────────────────────────────────────────────────── +echo "--- start ---" +py_out=$(run_python "$py_dir" "start" "$py_task") +rs_out=$(run_rust "$rs_dir" "start" "$rs_task") + +py_success=$(echo "$py_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null) +rs_success=$(echo "$rs_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null) +if [[ "$py_success" == "True" ]] && [[ "$rs_success" == "True" ]]; then + test_case "start: both succeed" "pass" +else + test_case "start: both succeed" "fail" +fi + +if compare_json "$py_out" "$rs_out" "start"; then + test_case "start: JSON keys match" "pass" +else + test_case "start: JSON keys match" "fail" +fi + +# ── Test 10: done ──────────────────────────────────────────────────── +echo "--- done ---" +py_out=$(run_python "$py_dir" "done" "$py_task" --summary "Completed" --force) +rs_out=$(run_rust "$rs_dir" "done" "$rs_task" --summary "Completed" --force) + +py_success=$(echo "$py_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null) +rs_success=$(echo "$rs_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null) +if [[ "$py_success" == "True" ]] && [[ "$rs_success" == "True" ]]; then + test_case "done: both succeed" "pass" +else + test_case "done: both succeed" "fail" +fi + +if compare_json "$py_out" "$rs_out" "done"; then + test_case "done: JSON keys match" "pass" +else + test_case "done: JSON keys match" "fail" +fi + +# ── Test 11: status after work ─────────────────────────────────────── +echo "--- status after work ---" +py_out=$(run_python "$py_dir" "status") +rs_out=$(run_rust "$rs_dir" "status") + +py_done=$(echo "$py_out" | python3 -c "import sys,json; print(json.load(sys.stdin)['tasks']['done'])" 2>/dev/null) +rs_done=$(echo "$rs_out" | python3 -c "import sys,json; print(json.load(sys.stdin)['tasks']['done'])" 2>/dev/null) +if [[ "$py_done" == "1" ]] && [[ "$rs_done" == "1" ]]; then + test_case "status: both show 1 done task" "pass" +else + test_case "status: both show 1 done task" "fail" +fi + +# ══════════════════════════════════════════════════════════════════════ +# Edge Cases +# ══════════════════════════════════════════════════════════════════════ +echo "" +echo "--- Edge Cases ---" + +# ── Edge 1: status without .flow/ ───────────────────────────────────── +edge_dir_py="$TMPDIR_BASE/edge_py_$$" +edge_dir_rs="$TMPDIR_BASE/edge_rs_$$" +mkdir -p "$edge_dir_py" "$edge_dir_rs" + +py_out=$(run_python "$edge_dir_py" "status" 2>&1 || true) +py_exit=$? +rs_out=$(run_rust "$edge_dir_rs" "status" 2>&1 || true) +rs_exit=$? + +# Both should indicate no .flow/ or fail gracefully +py_exists=$(echo "$py_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('flow_exists',''))" 2>/dev/null || echo "error") +rs_exists=$(echo "$rs_out" | python3 -c "import sys,json; print(json.load(sys.stdin).get('flow_exists',''))" 2>/dev/null || echo "error") +if [[ "$py_exists" == "False" ]] && [[ "$rs_exists" == "False" ]]; then + test_case "edge: status without .flow/ returns flow_exists=false" "pass" +elif [[ "$py_exists" == "error" ]] && [[ "$rs_exists" == "error" ]]; then + # Both error out - also acceptable + test_case "edge: status without .flow/ both error (consistent)" "pass" +else + test_case "edge: status without .flow/ consistent behavior" "fail" + log_verbose " Python flow_exists=$py_exists Rust flow_exists=$rs_exists" +fi + +# ── Edge 2: show with invalid ID ───────────────────────────────────── +dirs=$(setup_initialized_dirs) +py_dir=$(echo "$dirs" | cut -d' ' -f1) +rs_dir=$(echo "$dirs" | cut -d' ' -f2) + +py_out=$(run_python "$py_dir" "show" "nonexistent-id-999" 2>&1; echo "EXIT:$?") +py_exit=$(echo "$py_out" | grep "EXIT:" | sed 's/EXIT://') +rs_out=$(run_rust "$rs_dir" "show" "nonexistent-id-999" 2>&1; echo "EXIT:$?") +rs_exit=$(echo "$rs_out" | grep "EXIT:" | sed 's/EXIT://') + +# Both should return non-zero or error JSON +if [[ "$py_exit" != "0" ]] && [[ "$rs_exit" != "0" ]]; then + test_case "edge: show invalid ID - both return non-zero exit" "pass" +else + # Check if they return error in JSON + py_success=$(echo "$py_out" | head -1 | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null || echo "?") + rs_success=$(echo "$rs_out" | head -1 | python3 -c "import sys,json; print(json.load(sys.stdin).get('success',''))" 2>/dev/null || echo "?") + if [[ "$py_success" == "False" ]] && [[ "$rs_success" == "False" ]]; then + test_case "edge: show invalid ID - both return success=false" "pass" + else + test_case "edge: show invalid ID - consistent error behavior" "fail" + log_verbose " Python exit=$py_exit success=$py_success" + log_verbose " Rust exit=$rs_exit success=$rs_success" + fi +fi + +# ── Edge 3: start with invalid ID ──────────────────────────────────── +py_out=$(run_python "$py_dir" "start" "bogus-task-id" 2>&1; echo "EXIT:$?") +py_exit=$(echo "$py_out" | grep "EXIT:" | sed 's/EXIT://') +rs_out=$(run_rust "$rs_dir" "start" "bogus-task-id" 2>&1; echo "EXIT:$?") +rs_exit=$(echo "$rs_out" | grep "EXIT:" | sed 's/EXIT://') + +if [[ "$py_exit" != "0" ]] && [[ "$rs_exit" != "0" ]]; then + test_case "edge: start invalid ID - both return non-zero" "pass" +elif [[ "$py_exit" == "$rs_exit" ]]; then + test_case "edge: start invalid ID - same exit code ($py_exit)" "pass" +else + test_case "edge: start invalid ID - consistent error" "fail" + log_verbose " Python exit=$py_exit Rust exit=$rs_exit" +fi + +# ── Edge 4: done without required args ──────────────────────────────── +py_exit=0 +(cd "$py_dir" && python3 "$FLOWCTL" done --json >/dev/null 2>&1) || py_exit=$? +rs_exit=0 +(cd "$rs_dir" && "$RUST_BINARY" --json done >/dev/null 2>&1) || rs_exit=$? + +if [[ "$py_exit" != "0" ]] && [[ "$rs_exit" != "0" ]]; then + test_case "edge: done without task ID - both error" "pass" +else + test_case "edge: done without task ID - consistent error" "fail" + log_verbose " Python exit=$py_exit Rust exit=$rs_exit" +fi + +# ── Edge 5: epic create without title ───────────────────────────────── +py_exit=0 +(cd "$py_dir" && python3 "$FLOWCTL" epic create --json >/dev/null 2>&1) || py_exit=$? +rs_exit=0 +(cd "$rs_dir" && "$RUST_BINARY" --json epic create >/dev/null 2>&1) || rs_exit=$? + +if [[ "$py_exit" != "0" ]] && [[ "$rs_exit" != "0" ]]; then + test_case "edge: epic create without title - both error" "pass" +else + test_case "edge: epic create without title - consistent error" "fail" + log_verbose " Python exit=$py_exit Rust exit=$rs_exit" +fi + +# ── Edge 6: task create without epic ────────────────────────────────── +py_exit=0 +(cd "$py_dir" && python3 "$FLOWCTL" task create --json --title "Orphan" >/dev/null 2>&1) || py_exit=$? +rs_exit=0 +(cd "$rs_dir" && "$RUST_BINARY" --json task create --title "Orphan" >/dev/null 2>&1) || rs_exit=$? + +if [[ "$py_exit" != "0" ]] && [[ "$rs_exit" != "0" ]]; then + test_case "edge: task create without epic - both error" "pass" +else + test_case "edge: task create without epic - consistent error" "fail" + log_verbose " Python exit=$py_exit Rust exit=$rs_exit" +fi + +# ══════════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== Results ===" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +echo " SKIP: $SKIP" +TOTAL=$((PASS + FAIL)) +echo " TOTAL: $TOTAL" +echo "" + +if [[ $FAIL -gt 0 ]]; then + echo "FAILED ($FAIL of $TOTAL tests failed)" + exit 1 +else + echo "ALL TESTS PASSED" + exit 0 +fi diff --git a/scripts/flowctl.py b/scripts/flowctl.py index 11a3a211..b7684bab 100755 --- a/scripts/flowctl.py +++ b/scripts/flowctl.py @@ -2,12 +2,45 @@ """ flowctl - CLI for managing .flow/ task tracking system. -Thin shim that delegates to the flowctl package. +Thin shim that delegates to the flowctl package (Python) or the Rust +binary when FLOWCTL_RUST=1 is set. """ import os +import shutil import sys + +def _dispatch_rust(): + """Find and exec the Rust flowctl binary, replacing this process.""" + # 1. Check PATH + rust_bin = shutil.which("flowctl") + + # 2. Check plugin dir bin/flowctl + if not rust_bin: + plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + candidate = os.path.join(plugin_dir, "bin", "flowctl") + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + rust_bin = candidate + + if not rust_bin: + print("Error: Rust flowctl binary not found.", file=sys.stderr) + print("FLOWCTL_RUST=1 is set but no binary is available.", file=sys.stderr) + print("", file=sys.stderr) + print("Install options:", file=sys.stderr) + print(" 1. Build from source: cd flowctl && cargo build --release", file=sys.stderr) + print(" Then copy target/release/flowctl to a directory in your PATH", file=sys.stderr) + print(" 2. Place the binary at: %s" % os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "flowctl" + ), file=sys.stderr) + sys.exit(1) + + os.execvp(rust_bin, [rust_bin] + sys.argv[1:]) + + +if os.environ.get("FLOWCTL_RUST"): + _dispatch_rust() + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: