From 4c2ba68e59bfe7168bb62918522d1cc9a0a87bcf Mon Sep 17 00:00:00 2001 From: Decebal Dobrica <352761+decebal@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:39:32 +0100 Subject: [PATCH] feat(workflow): implement durable cross-host delivery Preserve accepted plans and source across coding hosts through embedded AllSource history, Chronis claims, and Prime context. Bind publication to immutable checkpoints and exact-commit evidence so interruption cannot silently repeat external actions. Allstack-Run: 750de2d1-ec0b-4327-be96-4f0e1ba5c5f5 --- .config/nextest.toml | 3 + .github/workflows/quality.yml | 68 + .gitignore | 2 +- Cargo.lock | 2506 +++++++++++++++++ Cargo.toml | 29 + README.md | 35 +- deny.toml | 13 + docs/README.md | 14 +- .../2026-10-24-cross-host-workflow-design.md | 79 +- ...ck-750de2d1-ec0b-4327-be96-4f0e1ba5c5f5.md | 93 + ...ck-9acd9993-3a70-4a87-b4fe-f62b96eb2cbb.md | 41 + docs/reference/cli.md | 119 + docs/reference/handoff-forward-request.md | 9 + docs/reference/handoff-reverse-request.md | 7 + docs/reference/release-acceptance.md | 14 +- ...-10-24-cross-host-workflow-verification.md | 93 + fixtures/handoff/Cargo.lock | 7 + fixtures/handoff/Cargo.toml | 7 + fixtures/handoff/src/lib.rs | 10 + fixtures/handoff/tests/forward.rs | 15 + fixtures/handoff/tests/reverse.rs | 14 + hosts/claude-code/SKILL.md | 32 + hosts/codex/SKILL.md | 32 + src/application/mod.rs | 287 ++ src/application/ports.rs | 35 + src/domain.rs | 288 ++ src/infrastructure/build_lock.rs | 55 + src/infrastructure/chronis.rs | 122 + src/infrastructure/forge.rs | 184 ++ src/infrastructure/gates.rs | 23 + src/infrastructure/git.rs | 60 + src/infrastructure/hosts.rs | 123 + src/infrastructure/memory.rs | 66 + src/infrastructure/mod.rs | 83 + src/infrastructure/process.rs | 207 ++ src/infrastructure/runtime.rs | 453 +++ src/infrastructure/store.rs | 145 + src/infrastructure/workspace.rs | 212 ++ src/lib.rs | 4 + src/main.rs | 58 + src/presentation.rs | 182 ++ tests/application.rs | 324 +++ tests/architecture.rs | 68 + tests/common/mod.rs | 44 + tests/domain.rs | 72 + tests/process.rs | 100 + tests/store.rs | 91 + workflows/delivery.toml | 10 +- workflows/forward-gates.toml | 20 + workflows/methods/build.md | 7 + workflows/methods/plan.md | 5 + workflows/reverse-gates.toml | 20 + 52 files changed, 6540 insertions(+), 50 deletions(-) create mode 100644 .config/nextest.toml create mode 100644 .github/workflows/quality.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 deny.toml create mode 100644 docs/plans/allstack-750de2d1-ec0b-4327-be96-4f0e1ba5c5f5.md create mode 100644 docs/plans/allstack-9acd9993-3a70-4a87-b4fe-f62b96eb2cbb.md create mode 100644 docs/reference/cli.md create mode 100644 docs/reference/handoff-forward-request.md create mode 100644 docs/reference/handoff-reverse-request.md create mode 100644 docs/reports/2026-10-24-cross-host-workflow-verification.md create mode 100644 fixtures/handoff/Cargo.lock create mode 100644 fixtures/handoff/Cargo.toml create mode 100644 fixtures/handoff/src/lib.rs create mode 100644 fixtures/handoff/tests/forward.rs create mode 100644 fixtures/handoff/tests/reverse.rs create mode 100644 hosts/claude-code/SKILL.md create mode 100644 hosts/codex/SKILL.md create mode 100644 src/application/mod.rs create mode 100644 src/application/ports.rs create mode 100644 src/domain.rs create mode 100644 src/infrastructure/build_lock.rs create mode 100644 src/infrastructure/chronis.rs create mode 100644 src/infrastructure/forge.rs create mode 100644 src/infrastructure/gates.rs create mode 100644 src/infrastructure/git.rs create mode 100644 src/infrastructure/hosts.rs create mode 100644 src/infrastructure/memory.rs create mode 100644 src/infrastructure/mod.rs create mode 100644 src/infrastructure/process.rs create mode 100644 src/infrastructure/runtime.rs create mode 100644 src/infrastructure/store.rs create mode 100644 src/infrastructure/workspace.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/presentation.rs create mode 100644 tests/application.rs create mode 100644 tests/architecture.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/domain.rs create mode 100644 tests/process.rs create mode 100644 tests/store.rs create mode 100644 workflows/forward-gates.toml create mode 100644 workflows/methods/build.md create mode 100644 workflows/methods/plan.md create mode 100644 workflows/reverse-gates.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..0b62c81 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +[profile.default] +slow-timeout = { period = "60s", terminate-after = 2 } +fail-fast = false diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..ae91f64 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,68 @@ +name: Quality + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: quality-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + timeout: timeout + - os: macos-14 + timeout: gtimeout + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + - if: matrix.os == 'macos-14' + run: brew install coreutils + timeout-minutes: 2 + - run: ${{ matrix.timeout }} -k 5 120 rustup toolchain install 1.95 --profile minimal + - uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 + with: + tool: cargo-nextest@0.9.138 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 + - run: ${{ matrix.timeout }} -k 5 270 cargo +1.95 nextest run --locked + - run: ${{ matrix.timeout }} -k 5 60 cargo +1.95 test --manifest-path fixtures/handoff/Cargo.toml --locked --tests + - if: matrix.os == 'macos-14' + run: shasum -a256 target/debug/allstack > target/debug/allstack.sha256 + - if: matrix.os == 'macos-14' + run: git rev-parse HEAD > target/debug/allstack.commit + - if: matrix.os == 'macos-14' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: allstack-macos-14 + path: | + target/debug/allstack + target/debug/allstack.sha256 + target/debug/allstack.commit + if-no-files-found: error + retention-days: 3 + source-and-dependencies: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + - run: timeout -k 5 120 rustup toolchain install 1.95 --profile minimal --component clippy,rustfmt + - uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 + with: + tool: cargo-deny@0.19.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 + - run: timeout -k 5 60 cargo +1.95 fmt --all -- --check + - run: timeout -k 5 90 cargo +1.95 deny check + - run: timeout -k 5 270 cargo +1.95 clippy --all-targets --locked -- -D warnings diff --git a/.gitignore b/.gitignore index 0973618..dd96ef3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,5 @@ /.prime/ /.env /.env.* -/target/ +**/target/ .DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1dbe63d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2506 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "allsource-core" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d95e25744456d2d3c03e300ac05d62bc611ddfaba489984667ba0e3b4faba1" +dependencies = [ + "anyhow", + "arrow", + "async-trait", + "base64 0.22.1", + "bumpalo", + "bytes", + "chrono", + "crc32fast", + "crossbeam", + "dashmap", + "flate2", + "fs4", + "futures", + "hex", + "hmac", + "mimalloc", + "parking_lot", + "parquet", + "prometheus", + "rand", + "serde", + "serde_json", + "sha2", + "simd-json", + "thiserror 2.0.21", + "time", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "allstack" +version = "0.1.0" +dependencies = [ + "allsource-core", + "anyhow", + "clap", + "libc", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "toml", + "uuid", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "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 = "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", +] + +[[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", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrow" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c14b3d39f306bc28fd639d59f06e17a0f377d0021e1b7e9054e4d6fedc98774" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-csv", + "arrow-data", + "arrow-ipc", + "arrow-json", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2961626677665b2195eb59242af4c7befe7b8737ca2050295389362380104e" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e5f6adeffdf587d7a31db5d2266189624b526730cd3627f9ff9fedae97ad584" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "097d193003ce7995d5d087089069ec2a6e0187faf5a6f8c9f38af2645d987182" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635c9c635668ad26adf76cce8fb276c4be7cf06e63bd516de7da514f9680ee53" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.23.1", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-csv" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2ebf8d631e79b02c16cf5ae860561272c26024ec88fce389a56aaddd558e86" +dependencies = [ + "arrow-array", + "arrow-cast", + "arrow-schema", + "chrono", + "csv", + "csv-core", + "regex", +] + +[[package]] +name = "arrow-data" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba2f832eaeca24b8f26143dba750e42ee4ab51cf7d65e701ca9607cfda9f358" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ipc" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcc41681ea80f521df14c36725b74d4c60702c47f0793af2be469c04527e2599" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", +] + +[[package]] +name = "arrow-json" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2f57d7a81969f24ccf80809587b76c09897e6f829d2d65a5976bfb3218851f1" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-ord", + "arrow-schema", + "arrow-select", + "chrono", + "half", + "indexmap", + "itoa", + "lexical-core", + "memchr", + "num-traits", + "ryu", + "serde_core", + "serde_json", + "simdutf8", +] + +[[package]] +name = "arrow-ord" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c900759f3bd8354fd4196bc4403eee846894dc2adf66b4225472006a0bf18c5" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c6425032e28266e3fc4ff680805e57e670d6ea92473043f3e65b7ed6ac79f2" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10fab8d4563491417ba801fab29d205104d20d4bdf37bda6cd1cf425cff598cd" + +[[package]] +name = "arrow-select" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc58569193c2525915f3cc6310edba3792f1200f65d6e9ed330aa33e691493b8" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e0813f3c35c1cfea65e14c20a953440f7783c088b7ad2d0db162ccdeefcec14" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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 = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[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 = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[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 = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "fs4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" +dependencies = [ + "rustix", + "windows-sys", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "halfbrown" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ed2f2edad8a14c8186b847909a41fbb9c3eafa44f88bd891114ed5019da09" +dependencies = [ + "hashbrown 0.16.1", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[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 = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lz4_flex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[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 = "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", + "smallvec", + "windows-link", +] + +[[package]] +name = "parquet" +version = "59.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff322f54b1a0f9288e614ed1f2d329b380af5476420db19f46ffb865e1163d73" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ipc", + "arrow-schema", + "arrow-select", + "base64 0.23.1", + "brotli", + "bytes", + "chrono", + "flate2", + "futures", + "half", + "hashbrown 0.17.1", + "lz4_flex", + "num-bigint", + "num-integer", + "num-traits", + "seq-macro", + "simdutf8", + "snap", + "tokio", + "twox-hash", + "zstd", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "protobuf", + "thiserror 2.0.21", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd-json" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebe141eb4a23ecc4d5de93fa5b139ac65dc07c38037a2fe9a8fb7c9d36bd832" +dependencies = [ + "halfbrown", + "ref-cast", + "serde", + "serde_json", + "simdutf8", + "value-trait", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "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 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-trait" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f4b4a98dfe54bc9ed3641af7ffcb837240269627dbd5cb047d13daa39736cc" +dependencies = [ + "float-cmp", + "halfbrown", + "itoa", + "ryu", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[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 2.0.119", +] + +[[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 2.0.119", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7a24ba4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "allstack" +version = "0.1.0" +edition = "2024" +rust-version = "1.95" +license = "Apache-2.0" +description = "Durable cross-host plan, build, verify, and ship workflows" +repository = "https://github.com/all-source-os/allstack" + +[dependencies] +allsource-core = { version = "=0.26.1", default-features = false, features = ["embedded", "prime"] } +anyhow = "1" +clap = { version = "4", features = ["derive"] } +libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +toml = "1.1" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tempfile = "3" + +[profile.dev] +debug = 0 + +[profile.test] +debug = 0 diff --git a/README.md b/README.md index 63df9cc..d5ea8ac 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ Plan, build, verify, and ship across coding agents, with durable history and evidence tied to the delivered change. -**Status: design scaffold.** This repository specifies the first complete workflow. It does not yet contain an installable CLI, a working coordinator, or validated host integrations. Command examples describe the proposed interface. +**Status: implementation preview.** The Rust CLI implements local run storage, task coordination, host invocation, immutable handoffs, verification, and GitHub publication. Source-level support is distinct from release proof: use the [acceptance scenarios](docs/reference/release-acceptance.md) to audit actual host journeys, interruptions, and delivered artifacts. -Allstack combines [AllSource](https://github.com/all-source-os/all-source) for run history, Chronis for task state, and Prime for recall. Coding agents remain responsible for model execution. The first release targets Codex CLI and Claude Code on one machine, with an explicit handoff between them. +The [verification report](docs/reports/2026-10-24-cross-host-workflow-verification.md) records authenticated host execution, delivered forward-run evidence, reverse handoff observations, and supported limits. Private gate receipts remain readable through `allstack evidence `; source-linked Prime context through `allstack recall `. + +Allstack embeds [AllSource Core](https://github.com/all-source-os/all-source) 0.26.1 for run history and Prime graph storage, and calls Chronis through `cn --toon` for task state. Prime recall is local lexical matching over delivery nodes; vector search and hosted synchronization are outside this preview. Coding agents remain responsible for model execution. The first release targets Codex CLI and Claude Code on one Unix machine, with an explicit handoff between them. ## First complete job @@ -20,23 +22,42 @@ In this workflow, **ship means a verified commit pushed to a feature branch and | Chronis | Tasks, dependencies, claims, and task completion | | Prime | Source-linked decisions, incidents, and relevant prior work | | Allstack | Workflow transitions, handoff contracts, evidence binding, and reconciliation | -| Host adapters | Capability detection and host-specific skill/tool integration | +| Host adapters | Real `codex exec` and `claude --print` invocations with bounded process ownership | | Git and forge | Source content, branches, commits, and pull requests | Workflow methods and repository policies are versioned data. Generic mechanics belong in Rust. Optional browser, research, evaluation, and hosted-service integrations follow after the core workflow is proven. -## Read the design +## Use the preview + +Install from a reviewed checkout with Rust 1.95 or later: + +```sh +cargo install --path . --locked +allstack --help +allstack -C /path/to/target-repository doctor +``` + +The target needs an existing authorized feature branch, a GitHub `origin`, initialized Chronis, indexed documentation, workflow methods, and a frozen verification policy. Both coding hosts need their normal authentication and permissions. Follow the [CLI guide](docs/reference/cli.md) for setup and the complete Codex → Claude Code → Codex sequence. + +Each command holds an exclusive kernel lock on the local store. There is no persistent coordinator daemon or IPC service. Epoch checks fence Allstack-managed transitions; native tools used outside Allstack remain outside that boundary. + +Snapshots require explicit `git add -- ` for new files. They reject symlinks and recognized sensitive filenames, and allow at most 4 MiB per file, 64 MiB total, and 10,000 files. These checks do not detect every possible secret. Keep private stores and process evidence under ignored `.allstack/`. + +## Documentation - [Documentation index](docs/README.md) - [Cross-host workflow design](docs/plans/2026-10-24-cross-host-workflow-design.md) - [Release acceptance scenarios](docs/reference/release-acceptance.md) -- [Proposed workflow data](workflows/delivery.toml) +- [CLI and recovery guide](docs/reference/cli.md) +- [Delivery workflow data](workflows/delivery.toml) +- [Codex host guidance](hosts/codex/SKILL.md) +- [Claude Code host guidance](hosts/claude-code/SKILL.md) -The design distinguishes existing ecosystem primitives from integrations that Allstack still needs to implement. Acceptance checkboxes remain open until real host journeys and failure recovery have been exercised. +Acceptance checkboxes remain open until retained evidence establishes each claim. Source tests and fixture definitions alone cannot establish actual cross-host behavior. ## Relationship to gstack -[gstack](https://github.com/garrytan/gstack) demonstrates opinionated planning, review, QA, and delivery workflows. Allstack uses that workflow shape as a reference and focuses its first release on durable cross-host continuation. No gstack source or skill text is included in this scaffold. Any future reuse must retain the applicable upstream and third-party notices. +[gstack](https://github.com/garrytan/gstack) demonstrates opinionated planning, review, QA, and delivery workflows. Allstack uses that workflow shape as a reference and focuses its first release on durable cross-host continuation. Allstack's workflow text is original. Any future reuse must retain the applicable upstream and third-party notices. ## License diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..aac7f09 --- /dev/null +++ b/deny.toml @@ -0,0 +1,13 @@ +[advisories] +yanked = "deny" + +[licenses] +allow = ["Apache-2.0", "MIT", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-3.0", "Zlib", "CC0-1.0"] + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/docs/README.md b/docs/README.md index 73fa063..cf93a24 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,17 @@ ## Reference +- [Cross-host workflow verification](reports/2026-10-24-cross-host-workflow-verification.md) — authenticated host runs, exact-commit gates, recovery observations, and supported limits. + - [First-release acceptance scenarios](reference/release-acceptance.md) — complete host journeys, failure cases, and required release evidence. -- [Proposed workflow definition](../workflows/delivery.toml) — non-executable workflow data to guide the first implementation. +- [Forward handoff request](reference/handoff-forward-request.md) and [reverse handoff request](reference/handoff-reverse-request.md) — real-host fixture requirements. +- [CLI and recovery guide](reference/cli.md) — prerequisites, frozen verification policy, host handoffs, and publication recovery. +- [Delivery workflow definition](../workflows/delivery.toml) — versioned workflow data and method references. +- [Codex host guidance](../hosts/codex/SKILL.md) — managed Codex execution boundary. +- [Claude Code host guidance](../hosts/claude-code/SKILL.md) — managed Claude Code execution boundary. + +The repository is an implementation preview. Source-level contracts and fixture definitions do not prove real host journeys. Acceptance criteria require retained runtime evidence before release. + +- [Allstack delivery plan 9acd9993-3a70-4a87-b4fe-f62b96eb2cbb](plans/allstack-9acd9993-3a70-4a87-b4fe-f62b96eb2cbb.md) -The repository is a design scaffold. Its acceptance criteria describe work to prove, not features already available. +- [Allstack delivery plan 750de2d1-ec0b-4327-be96-4f0e1ba5c5f5](plans/allstack-750de2d1-ec0b-4327-be96-4f0e1ba5c5f5.md) diff --git a/docs/plans/2026-10-24-cross-host-workflow-design.md b/docs/plans/2026-10-24-cross-host-workflow-design.md index fc29f2f..ef6aa50 100644 --- a/docs/plans/2026-10-24-cross-host-workflow-design.md +++ b/docs/plans/2026-10-24-cross-host-workflow-design.md @@ -2,9 +2,9 @@ Date: 2026-10-24 -Status: proposed implementation design; no runtime implemented +Status: implementation preview; end-to-end release evidence required -Confidence: sand. The component boundaries are understood; durable ordering, authority transfer, and actual host interoperability require proof. +Confidence: sand. The runtime implements the component boundaries and managed workflow. Actual host interoperability, interruption recovery, and installed-artifact behavior require retained proof against the release acceptance scenarios. Publication target: `all-source-os/allstack` @@ -32,44 +32,50 @@ Repository guidance remains authoritative for branch creation, commits, pushes, | Direct gstack fork | Existing catalog and behavior available earlier | Carries upstream structure and maintenance obligations | | Hosted agent platform | Shared access and centralized execution | Adds infrastructure before the local workflow is proven | -Choose a new toolkit. Reuse individual upstream methods only when their license and behavior fit. Keep upstream attribution with every reused portion. This scaffold contains original design prose, not copied gstack skills. +Choose a new toolkit. Reuse individual upstream methods only when their license and behavior fit. Keep upstream attribution with every reused portion. The workflow methods and design prose are original. ## Ownership and topology | Concern | Authority | Derived or cached view | | --- | --- | --- | | Workflow stage, handoff epoch, operation outcome | Allstack domain events in AllSource Core | CLI status and host context bundle | -| Task status, dependencies, task claimant | Chronis task stream | Run stores task reference and observed revision | -| Decisions and incident recall | Prime nodes with source references | Semantic index; never permission or execution state | +| Task status, dependencies, task claimant | Chronis task stream | Run stores task reference; adapter reads back claimant and status | +| Decisions and incident recall | Embedded Prime delivery nodes with source references | Local lexical matching; never permission or execution state | | Files and commits | Git plus retained workspace snapshot | Content manifests and digests | | Remote branch and pull request | Git remote and forge | Publication receipts recording read-back evidence | | Current authority to act | Explicit user instruction and trusted host policy | Scoped operation authorization record | Use existing component APIs. Do not open their WAL or Parquet files directly. Separate namespaces and component-owned stores are acceptable: owning different facts is different from maintaining competing copies of the same fact. -One local Rust coordinator owns the run store and serializes workflow writes. CLI invocations and both host adapters talk to that process over authenticated local IPC. It uses AllSource's engine with acknowledged durable writes, not a parallel JSON or SQLite run database. Startup rehydrates and validates the event stream before accepting mutations. It never obtains authority to execute work merely by finding an old event. +Each CLI invocation is the coordinator for its operation. It acquires an exclusive kernel lock on `.allstack/coordinator.lock` before opening the mutable store, holds it through component updates, and releases it on shutdown. Another command fails as busy instead of competing with an active mutation. The runtime embeds AllSource Core 0.26.1 with WAL synchronization on each event; run events do not live in a parallel JSON or SQLite database. Reads validate the retained revision chain and transition contracts before using a run. -Keep domain types and transitions pure. Application services depend on storage, task, recall, process, and forge ports. Infrastructure implements those ports; CLI, IPC, and MCP remain presentation edges. Composition binds the adapters. Add import-boundary checks with the first executable code so transports cannot bypass application rules. +This preview chooses a command-scoped lock instead of the original persistent-daemon topology. Authenticated local IPC is therefore absent. Epoch checks and the locked coordinator fence managed operations on the sequential same-machine path. An epoch is not a secret or a host identity credential; native commands outside Allstack are not sandboxed by it. A future remote or multi-user service would need an authenticated authority boundary before expanding this claim. -Coordinator startup, readiness, ownership, shutdown, crash recovery, and store locking require executable proof. Two coordinators must not open the same mutable run store. No implicit remote fallback is allowed. `doctor` reports actual store locations, versions, durability mode, capabilities, and sync state without disclosing credentials. +Keep domain types and transitions pure. Application services depend on storage, task, recall, process, and forge ports. Infrastructure implements those ports; the CLI is the presentation edge. Composition binds the adapters. Import-boundary checks keep application services independent of infrastructure and presentation. -## Proposed user interface +Coordinator startup, ownership, shutdown, crash recovery, and store locking require executable proof. Two coordinators must not open the same mutable run store. No implicit remote fallback is allowed. `doctor` reports actual local paths, durability mode, coordinator model, Prime scope, and probed tool versions. Version probes do not prove authentication or successful host execution. -These examples are design targets, not available commands: +## User interface + +The CLI returns JSON. The [CLI guide](../reference/cli.md) specifies prerequisites, authority flags, frozen policy, and recovery. An uninterrupted example uses the current epoch returned by each handoff: ```text allstack doctor -allstack start --workflow delivery --request docs/requests/example.md +allstack start --request docs/requests/example.md --policy workflows/verification.toml --host codex --allow-commit --allow-publish +allstack plan --epoch 1 allstack status -allstack handoff --to claude-code -allstack resume --host codex -allstack verify -allstack ship +allstack handoff --epoch 1 --to claude-code +allstack resume --epoch 2 +allstack build --epoch 2 +allstack handoff --epoch 2 --to codex +allstack resume --epoch 3 +allstack verify --epoch 3 --message "feat(example): implement accepted change" +allstack ship --epoch 3 --title "feat(example): implement accepted change" ``` -Starting a run must bind an existing authorized feature branch or prepare a concrete branch request. No branch is created implicitly. For host execution, adapters render the four workflow methods into each host's supported skill mechanism. Required capabilities are probed rather than inferred from the host's name. MCP is an optional transport for the same application services; the CLI is the first common interface. +Starting a run binds an existing authorized feature branch. No branch is created implicitly. Commit and publish flags may be set only when trusted user instructions already grant that authority. Host adapters invoke real `codex exec` and `claude --print` commands with their ordinary permission modes; no permission-bypass flags are used. Host skill documents explain the managed boundary. MCP is a possible later transport, not a preview dependency. -Every operation returns a machine-readable disposition and a human-readable next action. Unknown state, unavailable authority, missing dependencies, and failed verification have distinct outcomes. A zero-test invocation cannot satisfy a test requirement. +Successful operations return serialized run state; failures return actionable errors and leave durable pending operation state where reconciliation is required. A zero-test invocation cannot satisfy a test requirement. ## Workflow state @@ -80,15 +86,15 @@ stateDiagram-v2 [*] --> planning planning --> building: plan recorded and required authority established building --> verifying: candidate frozen - verifying --> building: defect or candidate change + verifying --> building: checkpoint or handoff invalidates verification verifying --> shipping: exact commit and required gates verified shipping --> shipped: remote commit and PR read back shipped --> [*] ``` -Each stage separately carries an execution disposition: `ready`, `active`, `awaiting_authority`, `blocked`, or `outcome_unknown`. A handoff changes the active host and workspace epoch without resetting the stage. Terminal cancellation records a reason and preserves evidence. +Each run records its active host, workspace epoch, revision, and optional pending operation. A handoff changes host and epoch; handing off a verifying run returns it to building and discards its verification. Pending operations block unrelated execution until reconciled. Richer disposition and terminal-cancellation contracts remain future work. -Changing the workflow version, plan, gate requirements, candidate content, or relevant environment invalidates affected downstream evidence. A new revision records that invalidation; it never overwrites a past result. A host cannot advance a stage by writing a favorable summary. +The workflow definition, plan/build method text, and verification policy are frozen by digest. Candidate content, exact commit, and the selected environment fingerprint bind final evidence. Changed inputs block shipping; checkpoint and handoff clear prior verification. A new revision records transitions without overwriting old results. A host cannot advance a stage by writing a favorable summary. ### Plan @@ -125,9 +131,9 @@ Only after remote commit and PR head match does the run record `shipped`. Comple The first release makes cross-host a sequential transfer of control. It does not pass an active conversation or share a mutable checkout between hosts. 1. Stop admission of new managed actions for the outgoing host. Wait for owned operations to finish, or mark unresolved external operations unknown. -2. Retain an immutable workspace snapshot: base commit, tracked modifications, explicitly included untracked files, file modes, and content digests. Verify the source manifest before and after capture; concurrent changes invalidate the snapshot. Never include ignored files, credentials, tokens, or unrelated host data automatically. Explicitly required inputs need a safe inclusion decision. +2. Retain an immutable workspace snapshot: base commit, tracked modifications, file modes, and content digests. New intended files require explicit `git add -- ` before capture; untracked files block the snapshot. Verify the source manifest before and after capture; concurrent changes invalidate the snapshot. Reject symlinks, path escapes, and recognized sensitive filenames. Enforce limits of 4 MiB per file, 64 MiB total, and 10,000 files. Filename checks do not detect every secret. 3. Store snapshot bytes in a private local content-addressed artifact directory. Append a handoff receipt only after every referenced object is durable and readable. The run event records references and hashes, not source file bodies or raw transcripts. -4. Increment the run's ownership epoch and invalidate the outgoing host's managed-action capability. Issue the incoming host a new scoped capability through local IPC, not through committed files or shared memory. +4. Increment the run's ownership epoch under the store lock. Allstack rejects managed transitions using the old epoch. The incoming context contains the new epoch; there is no bearer capability or IPC identity exchange in this preview. 5. Restore the retained snapshot into a different detached worktree. Verify repository identity, base commit availability, file containment, manifest completeness, modes, setup, and resulting content digests before admitting mutations. 6. Reconcile Chronis ownership through its own API, then read it back. If transfer is incomplete, report it explicitly. Do not steal another active task claim. The run can remain readable while ownership repair blocks execution. 7. Deliver a context bundle: request, plan revision, task reference, stage, evidence references, unresolved operations, repository policy, and active epoch. Prime recall adds source-linked context after authoritative state is loaded. @@ -142,38 +148,43 @@ Use a distinct versioned namespace such as `allstack.workflow.v1.*`. The existin A workflow event binds schema version, run ID, operation ID, sequence, predecessor, workflow digest, workspace/repository scope, host epoch, and event-specific validated fields. Conditional transition checks and idempotent operation recovery happen at the coordinator boundary. Configure durable acknowledgement, validate retained history, and never infer order from timestamps alone. -The append port must expose expected-revision semantics with persisted order across restart. A serial Rust actor prevents competing in-process writers but does not itself prove durable sequence recovery. Verify that behavior through the selected AllSource interface before implementation relies on it. Missing order, partial retained history, and mismatched duplicate operations fail closed. Retention must preserve run IDs and operation tombstones for the supported recovery lifetime. +The append adapter compares the expected run revision under the exclusive store lock, writes through AllSource's embedded API, and reads back the resulting history. Revision order, predecessor hashes, operation identity, and transition validation guard replay. Locking is the local compare/write boundary; the embedded ingest interface is not an external compare-and-swap service. Missing order, partial retained history, and mismatched duplicate operations fail closed. The preview does not configure run-history retention or cross-machine writers. -Chronis and Prime do not participate in a distributed transaction. Record intended component updates as durable workflow operations and reconcile them through their supported APIs. Re-read actual component state after uncertainty. Without a proven deterministic external identity or idempotency mechanism, do not retry an uncertain task/node create automatically. An unavailable Prime index can degrade semantic recall while leaving the authoritative run readable; an unconfirmed task claim blocks managed work. +Chronis and Prime do not participate in a distributed transaction. Record intended component updates as durable workflow operations and reconcile them through their supported APIs. Chronis task recovery searches the exact run-linked task title and refuses duplicate matches; claims are read back with the expected host/epoch actor. Prime delivery records use a run/phase key and source reference. Recall performs local lexical matching over embedded graph nodes, not vector search. Prime is context only; it cannot certify execution or publication. A Prime open failure degrades recall without hiding authoritative run status. Plan indexing remains marked `memory_pending` until `recover` reconciles it; shipping reconciles outcome metadata before terminal completion. An unconfirmed Chronis claim blocks managed work. ## Authority and evidence Recorded events describe observations. A Chronis `approve`, a Prime node, an agent-written hash, or `change.approved` does not independently establish human authority. -An authorization binding identifies the trusted instruction source, allowed operation, target repository/remote, candidate or plan revision, and relevant limits. Persist only the metadata needed to audit and invalidate that scope; credentials and bearer capabilities remain outside event payloads. Rebind when candidate or target changes. Repository and host policy may further restrict operations. +The preview records `allow_commit` and `allow_publish` supplied at run start, together with repository, branch, remote, request, workflow, and policy scope. These flags rely on the trusted caller to obtain user permission; they are not authenticated approval records. Repository and host policy may further restrict operations. A richer externally verifiable authorization binding is future work. Guarantees apply to actions invoked through Allstack's managed boundary. Hooks and host instructions provide additional guardrails with explicit coverage limits. Documentation must not describe them as a sandbox or treat a bypassable check as mandatory enforcement. ## Processes, artifacts, and local operation -A process Allstack spawns must be owned for its whole lifetime by a supervisor that can kill and reap it. Use one deadline across an operation and its attempts. No command may exceed 300 seconds; timeouts kill and reap the owned process tree and preserve diagnostic evidence. Lost acknowledgements of external side effects remain unknown. +A process Allstack spawns must be owned for its whole lifetime by a supervisor that can kill and reap it. The guardian observes a pipe owned by the caller; caller death revokes child lifetime. Managed work has a maximum 290-second execution allowance with cleanup inside the 300-second ceiling. Timeouts terminate and reap the owned process group and preserve diagnostic evidence. Lost acknowledgements of external side effects remain unknown. Process-level death tests are required to establish this behavior. Run only one build or compile gate on the machine at a time. Detect external competing builds and report contention rather than killing other sessions. Local queueing has a bounded deadline. These requirements need actual process-level tests, including supervisor death, rather than only mocked subprocess calls. -Keep runtime stores, credentials, worktrees, and raw evidence out of Git. Private artifacts remain local and are referenced by digest. Public delivery documents contain intentionally selected evidence with project-relative references; they must not expose private absolute paths, account data, source snapshots, or secrets. Export is explicit, schema-versioned, scope-checked, and excludes live capabilities. Cross-machine import is outside this release. +Keep `.allstack/` runtime stores, credentials, worktrees, and raw evidence out of Git. Private artifacts remain local and are referenced by digest. Public delivery documents contain intentionally selected evidence with project-relative references; they must not expose private absolute paths, account data, source snapshots, or secrets. Cross-machine export/import is outside this release. ## Repository shape ```text -workflows/delivery.toml proposed versioned method definition +workflows/delivery.toml versioned workflow and method references +workflows/methods/ frozen plan and build instructions docs/plans/ design and implementation decisions docs/reference/ contracts and release acceptance -crates/ future Rust coordinator, CLI, and adapters -hosts/ future generated host skill packages -fixtures/ future synthetic repositories and failure journeys +src/domain.rs pure run types and transition validation +src/application/ workflow orchestration and ports +src/infrastructure/ AllSource, Chronis, Prime, host, Git, and process adapters +src/presentation.rs CLI commands and composition +hosts/ host skill guidance +tests/ executable source and integration contracts +fixtures/ journey definitions requiring real host execution ``` -Do not add empty executable packages or advertise installation before they work. Publish checksummed binaries and versioned host packages only after the complete supported flow passes. License original code under Apache-2.0 and retain separate notices for any future upstream reuse. +Source installation is available for preview validation. Publish checksummed release binaries and versioned host packages only after the complete supported flow passes. License original code under Apache-2.0 and retain separate notices for any future upstream reuse. ## Implementation sequence @@ -186,7 +197,7 @@ These are dependencies within one complete first release. None is a substitute f ## Evidence and current limits -Source inspection on 24 October 2026 establishes reusable primitives, not installed-version compatibility or a working Allstack integration: +The preview pins AllSource Core 0.26.1 with embedded and Prime features. Source inspection establishes the adapter contracts, not installed-version compatibility or successful authenticated host journeys: - [AllSource embedded engine](https://github.com/all-source-os/all-source/blob/main/apps/core/src/embedded/core.rs) exposes store lifecycle and query/ingest operations. Its public embedded `ingest` signature does not itself establish the expected-revision append contract required here. - [Chronis claim implementation](https://github.com/all-source-os/all-source/blob/main/apps/chronis/src/infrastructure/core_task_repo.rs) reads current task state and appends `workflow.claimed`; [projection](https://github.com/all-source-os/all-source/blob/main/apps/chronis/src/infrastructure/projection.rs) applies the first claim while open. This is not a run fencing or authenticated authority-transfer service. @@ -194,4 +205,4 @@ Source inspection on 24 October 2026 establishes reusable primitives, not instal - [Existing agent-run evidence contract](https://github.com/all-source-os/all-source/blob/main/docs/plans/2026-09-27-agent-run-evidence-contract.md) describes internal capture/read behavior and explicitly leaves public boundaries and execution wrappers open. - [gstack](https://github.com/garrytan/gstack) supplies workflow reference material. Individual future reuse requires applicable notices. -Source URLs track upstream and may change. Implementation must pin compatible component versions and retain tested contract fingerprints. No builds, host journeys, installed-artifact tests, or live publication workflow were executed to establish runtime support in this design scaffold. +Source URLs track upstream and may change. Retain tested contract fingerprints and exact installed versions in release evidence. The implementation currently targets sequential hosts on one Unix machine, a `github.com` remote, and Cargo/nextest test execution summaries. It does not provide cross-machine transport, a host sandbox, authenticated IPC, vector recall, or automatic merge/deployment. Fixture definitions intentionally await actual host journeys. Acceptance claims remain open until retained execution evidence establishes them. diff --git a/docs/plans/allstack-750de2d1-ec0b-4327-be96-4f0e1ba5c5f5.md b/docs/plans/allstack-750de2d1-ec0b-4327-be96-4f0e1ba5c5f5.md new file mode 100644 index 0000000..b3e9cc3 --- /dev/null +++ b/docs/plans/allstack-750de2d1-ec0b-4327-be96-4f0e1ba5c5f5.md @@ -0,0 +1,93 @@ +Write tool disabled — this planning host is restricted to `Read`/`Glob`/`Grep`, as the verification report records. Delivering the plan in this response as instructed. + +--- + +# Plan: implement `normalized_receipt` (reverse host handoff exercise) + +## Context + +Run `750de2d1-ec0b-4327-be96-4f0e1ba5c5f5`, Claude Code epoch 1, Chronis task `t-649659`. This is the reverse leg of the cross-host workflow verification in `docs/reports/2026-10-24-cross-host-workflow-verification.md`. The forward leg delivered the strict `handoff_receipt` contract (commit `112a738`). The reverse leg adds a whitespace-tolerant wrapper, `normalized_receipt`, that normalizes its inputs and then defers every validation decision to the strict, already-tested function. + +The wrapper currently exists as a stub returning `Err("not implemented")`; its two reverse fixture tests fail. The goal is a one-function implementation that makes the four fixture tests pass without weakening `handoff_receipt` and without touching anything else. + +## Contract under implementation + +`fixtures/handoff/src/lib.rs` is a self-contained Cargo package (`handoff-example`, edition 2024, `publish = false`, empty `[workspace]` so it is isolated from the parent Allstack workspace). + +Current source: + +```rust +pub fn handoff_receipt(from: &str, to: &str) -> Result { + match (from, to) { + ("codex", "claude-code") | ("claude-code", "codex") => Ok(format!("{from} -> {to}")), + _ => Err("invalid host transfer"), + } +} + +pub fn normalized_receipt(_from: &str, _to: &str) -> Result { + Err("not implemented") +} +``` + +`handoff_receipt` is the strict contract: it accepts only the two canonical host pairs and rejects everything else (unknown host, same host, empty string). It must remain exactly as above. + +## The change + +Replace the `normalized_receipt` stub body (lines 8–10 of `fixtures/handoff/src/lib.rs`) with a trim-and-delegate implementation: + +```rust +pub fn normalized_receipt(from: &str, to: &str) -> Result { + handoff_receipt(from.trim(), to.trim()) +} +``` + +Rationale: + +- `str::trim()` strips leading and trailing whitespace — spaces, `\t`, `\n` — exactly the normalization the reverse tests feed in (`" claude-code\n"`, `"\tcodex "`). +- Delegation keeps all validation in one place. `normalized_receipt` makes no accept/reject decision of its own, so it cannot widen the set of accepted transfers; the strict behavior is preserved by construction. This is the invariant the request names, guarded by the reverse test `normalization_cannot_hide_unknown_or_same_host_transfers`. +- No lifetime problem: the `Ok` arm returns an owned `String` (from `format!`), the `Err` arm is `&'static str`, so the result borrows nothing from the trimmed arguments. The signature is unchanged from the stub, so no caller or test import changes. +- Rename `_from`/`_to` to `from`/`to` (now used). + +## Caller flow (end to end) + +A caller holding two host identifiers with incidental surrounding whitespace calls `normalized_receipt(from, to)`: + +1. Both arguments trimmed. +2. Trimmed pair handed to `handoff_receipt`. +3. Canonical pair (`codex` ↔ `claude-code`, either direction) → `Ok(" -> ")` built from trimmed values. +4. Any other pair — unknown host, identical host, now-empty string → `Err("invalid host transfer")`. + +All four fixture assertions, worked: + +| Call | Trimmed | Result | +| --- | --- | --- | +| `normalized_receipt(" claude-code\n", "\tcodex ")` | `claude-code`, `codex` | `Ok("claude-code -> codex")` | +| `normalized_receipt(" codex ", " claude-code ")` | `codex`, `claude-code` | `Ok("codex -> claude-code")` | +| `normalized_receipt(" codex ", "\tcodex")` | `codex`, `codex` | `Err` (same host) | +| `normalized_receipt("unknown ", "codex")` | `unknown`, `codex` | `Err` (unknown host) | +| `normalized_receipt(" ", "claude-code")` | ``, `claude-code` | `Err` (empty host) | + +Forward assertions unaffected — `handoff_receipt` untouched. + +## Files + +- **Edit:** `fixtures/handoff/src/lib.rs` — replace the `normalized_receipt` body only. Single source change. +- **Do not touch:** `handoff_receipt` itself; `fixtures/handoff/tests/forward.rs`; `fixtures/handoff/tests/reverse.rs`; `fixtures/handoff/Cargo.toml`; `fixtures/handoff/Cargo.lock`; any gate, workflow-policy, or parent-workspace file. No dependency added — `str::trim` is standard library. + +## Acceptance checks + +1. **Fixture suite green (4 tests).** From `fixtures/handoff/`: `cargo test` — 2 pass in `tests/forward.rs` (`both_supported_directions_have_canonical_receipts`, `unknown_or_unchanged_hosts_are_rejected`), 2 pass in `tests/reverse.rs` (`whitespace_is_normalized_before_validating_hosts`, `normalization_cannot_hide_unknown_or_same_host_transfers`). Zero failures, zero skipped. +2. **Allstack runtime regression suite remains green.** Run the parent repository's runtime regression suite (the forward leg recorded 19 executed / 19 passed / 0 skipped). The fixture package is an isolated workspace not imported by the runtime crate, so this is a no-regression safety check, not a surface this change touches. +3. **Whitespace gate clean.** `git diff --check HEAD -- fixtures/handoff/src/lib.rs` exits 0 — the edit introduces no trailing whitespace. (The report notes trailing whitespace in generated output has stopped commit preparation on this run before.) + +## Unresolved prerequisites + +- **None blocking the edit.** Fully specified by the four test assertions; no ambiguity to resolve with the user. +- **Build-slot contention is an environmental hazard, not a prerequisite.** The report records that competing external Cargo builds previously blocked compilation and that verification correctly waited for the compiler slot to idle rather than terminating a peer. Confirm the compiler slot is free before invoking the fixture `cargo test`; do not kill competing builds to reclaim it. +- **Edition 2024 toolchain.** `Cargo.toml` is `edition = "2024"`; the report cites Rust 1.95 verification toolchain, which satisfies it. + +## Notes for the build host + +- This plan host made no edits (restricted to `Read`/`Glob`/`Grep`). The build host adds editing and permitted Git commands. +- Entire deliverable is the three-line function body above. Do not expand scope: no refactor of `handoff_receipt`, no new helper, no new test, no manifest change. +- Preserve the exact error string `"invalid host transfer"` by not touching `handoff_receipt`; the reverse error-path tests assert only `is_err()`, but the forward contract treats the strict function as frozen. diff --git a/docs/plans/allstack-9acd9993-3a70-4a87-b4fe-f62b96eb2cbb.md b/docs/plans/allstack-9acd9993-3a70-4a87-b4fe-f62b96eb2cbb.md new file mode 100644 index 0000000..fbcec4c --- /dev/null +++ b/docs/plans/allstack-9acd9993-3a70-4a87-b4fe-f62b96eb2cbb.md @@ -0,0 +1,41 @@ +1. **Outcome and complete flow** + + Caller supplies `from` and `to` host strings. `handoff_receipt` returns `Ok("codex -> claude-code")` or `Ok("claude-code -> codex")` for supported transfers. Every other input pair returns `Err`, including empty strings, unknown hosts, unchanged hosts, case variants, and whitespace-padded names. + + Managed flow: coordinator indexes this plan; incoming build host reads indexed plan and request, changes fixture function, and returns candidate for coordinator checkpointing. Coordinator runs frozen verification against resulting candidate. Task ownership, host transitions, commits, and publication remain coordinator-controlled. + +2. **Source context and precise change** + + Requirements confirmed in `docs/reference/handoff-forward-request.md`; workflow context recovered from indexed `docs/plans/2026-10-24-cross-host-workflow-design.md`. + + Change only `fixtures/handoff/src/lib.rs:1`, where `handoff_receipt` currently returns `Err("not implemented")`: + + - Preserve public signature: `(&str, &str) -> Result`. + - Rename `_from` and `_to` parameters to `from` and `to`. + - Match exactly `("codex", "claude-code")` and `("claude-code", "codex")`; return `Ok(format!("{from} -> {to}"))`. + - Return static error such as `"invalid host transfer"` for remaining pairs. Exact error wording has no specified contract. + - Perform no trimming, whitespace normalization, or case conversion. + + Preserve `normalized_receipt` at line 5 byte-for-byte, including its current placeholder. Preserve tests, manifests, lockfiles, gates, workflow policy, and unrelated implementation files. No dependencies or new files required. + +3. **Acceptance checks** + + Coordinator executes existing `workflows/forward-gates.toml` unchanged, sequentially under its configured 240-second command limit: + + | Check | Command | Required evidence | + |---|---|---| + | Runtime regression | `cargo nextest run --locked --offline` | Successful exit, positive executed-test count, `tests run:` marker | + | Forward fixture | `cargo test --manifest-path fixtures/handoff/Cargo.toml --offline --test forward` | Both existing tests execute and pass; `2 passed` marker | + | Candidate whitespace | `git diff --check HEAD^ HEAD` | Successful exit on coordinator-prepared commit | + + Review final diff to confirm only `handoff_receipt` changed. Inspect exhaustive match to verify rejection of whitespace-padded inputs, empty destinations, and `claude-code` self-transfers; existing forward assertions do not cover every required invalid pair. Do not alter assertions. + + Use targeted `--test forward`; reverse tests exercise separate, intentionally preserved placeholder. Root runtime suite and fixture occupy separate Cargo workspaces. + +4. **Unresolved prerequisites and limits** + + No functional ambiguity remains. Later verification requires Rust 1.95 or newer, Cargo, cargo-nextest, offline dependency availability, writable build output, and uncontended compile capacity. Those prerequisites remain unverified. + + Coordinator must supply current authoritative epoch and active worktree after any handoff, retain verification receipts, and reconcile existing commit/publication authority before those stages. Current supplied context: run `9acd9993-3a70-4a87-b4fe-f62b96eb2cbb`, epoch 1, revision 5, task `t-3d5fb1`; detached checkout intentional. + + Planning inspection only. No source changes, compilation, tests, or state transitions performed. diff --git a/docs/reference/cli.md b/docs/reference/cli.md new file mode 100644 index 0000000..d91a1ed --- /dev/null +++ b/docs/reference/cli.md @@ -0,0 +1,119 @@ +# CLI and recovery guide + +Status: implementation preview. This guide describes the source interface. The [release acceptance scenarios](release-acceptance.md) track the evidence required to establish supported end-to-end behavior. + +## Prerequisites + +Use one Unix machine with Git, GitHub CLI, Rust/Cargo, Chronis `cn`, Codex CLI, and Claude Code available on `PATH`. Authenticate both hosts and `gh` through their normal setup. The preview supports `github.com` remotes over HTTPS or SSH; GitHub Enterprise and other forges are outside its current scope. + +Install from the reviewed Allstack checkout with `cargo install --path . --locked`. The Rust package requires Rust 1.95 or later. `allstack --help` and each subcommand's `--help` expose the installed interface. + +Prepare the target repository before starting: + +1. Use an existing feature branch whose creation the user authorized. Confirm its PR has not merged. Allstack does not create branches. +2. Initialize project task storage with `cn init --toon` when `.chronis/` does not exist. Allstack uses this repository's Chronis store through `cn --toon`. +3. Provide `docs/README.md`, a request document, `workflows/delivery.toml`, and the referenced `workflows/methods/plan.md` and `workflows/methods/build.md` documents. Use the versioned delivery definition and methods from this repository as the starting point. +4. Provide the verification policy described below. Commit the prepared inputs through the repository's normal authorized process so the starting tree is clean. Ignore `.allstack/` and machine-local Chronis storage; do not commit either store. +5. Ensure repository setup works in a new detached worktree. Gitignored dependencies and build output are not included in source snapshots. Prepare required local inputs explicitly before invoking managed work. + +Run `allstack -C /path/to/target-repository doctor`. Its JSON reports the actual repository and state directory, local durability mode, coordinator model, Prime scope, and probed tool versions. A successful `--version` probe establishes executable availability; it does not establish authentication, model access, or host behavior. + +## Frozen verification policy + +Create a repository-relative TOML file such as `workflows/verification.toml`: + +```toml +command_seconds = 120 +environment = ["RUSTUP_TOOLCHAIN"] + +[[gates]] +name = "format" +argv = ["cargo", "fmt", "--check"] + +[[gates]] +name = "tests" +argv = ["cargo", "test", "--locked"] +success_marker = "test result: ok." +test = true +compile = true +``` + +Commands are argv arrays, not shell expressions. Every policy needs at least one executed-test gate with a nonempty success marker. Test gates require a positive Cargo or nextest execution count in the retained output; zero tests, `cargo check`, `clippy`, and `--no-run` cannot satisfy that requirement. Other runner formats currently remain inconclusive. + +Set `compile = true` for every gate that can compile. Such gates acquire the machine build lock and check competing compiler processes. Allstack does not terminate another session's work to obtain capacity. + +The request, workflow definition, method text, and policy are retained by digest at run start. Verification and shipping check the current workflow, methods, and policy against the accepted versions. Relevant environment fingerprinting includes the running Allstack executable digest, OS, architecture, selected environment variables, and Git/Rust/Cargo/Chronis versions. This is an explicit fingerprint, not a hermetic environment guarantee. + +The policy permits `command_seconds` in `1..=300`; managed execution clamps work to 290 seconds, leaving cleanup within the 300-second ceiling. An outer guardian also bounds the entire CLI command, so a sequence of individually bounded subprocesses cannot accumulate an unlimited command lifetime. Parent ownership uses a guardian and an owner pipe so caller death revokes owned process lifetime. Host operations and gate commands remain subject to normal host permissions and repository hooks. + +## Codex → Claude Code → Codex + +The following sequence assumes request and policy paths from the preceding setup. Substitute the returned run ID. Read each result before issuing the next command; epochs below describe an uninterrupted first handoff sequence. + +```sh +allstack -C /path/to/target-repository start \ + --request docs/requests/change.md \ + --policy workflows/verification.toml \ + --host codex --allow-commit --allow-publish +allstack -C /path/to/target-repository plan --epoch 1 +allstack -C /path/to/target-repository handoff --epoch 1 --to claude-code +allstack -C /path/to/target-repository resume --epoch 2 +allstack -C /path/to/target-repository build --epoch 2 +allstack -C /path/to/target-repository handoff --epoch 2 --to codex +allstack -C /path/to/target-repository resume --epoch 3 +allstack -C /path/to/target-repository verify --epoch 3 \ + --message "feat(example): implement accepted change" +allstack -C /path/to/target-repository ship --epoch 3 \ + --title "feat(example): implement accepted change" +``` + +Only pass `--allow-commit` and `--allow-publish` when trusted user instructions authorize those actions for this target. These flags record supplied authority; they do not establish that the caller obtained it. Repository rules and host permissions still apply. + +`start` creates and claims a Chronis task, reads back ownership, and restores a run-owned detached worktree. `plan` invokes the active host, writes its plan to `docs/plans/allstack-.md`, and indexes that document in `docs/README.md`. `build` invokes the selected host in the active worktree and retains the resulting checkpoint. Handoff restores another detached worktree, increments the epoch, and reconciles the Chronis claimant. It does not copy a conversation transcript. + +`resume` returns authoritative run state, request, plan, and contextual Prime recall. It does not execute a host or advance a stage. `status ` returns run state; `history ` returns validated event history. Always use the current returned epoch, especially after recovery. + +`evidence ` reads digest-validated gate receipts and run state, including completed runs whose worktrees were removed. `recall ` reads source-linked contextual Prime nodes. These diagnostic commands expose local private data; publish only reviewed excerpts. They do not mutate a run or certify anything beyond their recorded source evidence. + +The reverse sequence starts with `--host claude-code` and hands off to `codex` for implementation. It uses the same transition and verification contracts. Each direction requires its own real-host evidence. + +## New files and checkpoints + +In the active worktree reported by `status`, explicitly stage every new intended file with `git add -- `. Then `checkpoint --epoch ` retains the workspace. Untracked files block snapshot creation; Allstack never silently includes them. Checkpointing or handing off invalidates previous verification. + +Snapshots retain tracked contents and executable modes, including staged new files and tracked deletions. They reject symlinks, path escapes, recognized sensitive filenames, files above 4 MiB, totals above 64 MiB, and manifests above 10,000 files. Filename exclusions are a limited guardrail; review the candidate for secrets independently. + +Old worktrees remain separate after handoff. Native edits there do not change the incoming snapshot. An old epoch cannot drive Allstack-managed mutation. Local users and native host tools remain capable of operating outside Allstack; no filesystem sandbox or authenticated per-host identity is claimed by the epoch number. + +## Interruption and uncertainty + +Inspect `status` and `history` before retrying a failed command. A pending operation means its outcome requires reconciliation. + +| Pending operation | Supported next action | +| --- | --- | +| Task setup | `recover --epoch ` reconciles task identity and ownership | +| Host planning or building | Inspect the active worktree and private process evidence, then `recover`; recovery checkpoints current edits without replaying the uncertain host call | +| Handoff | `recover` completes the recorded transfer and reads back Chronis ownership | +| Plan recorded with `memory_pending` | `recover` retries Prime indexing without invoking the planning host again | +| Commit or verification | Repeat `verify` with the current epoch and authorized commit message; it reconciles the candidate and reruns final gates | +| Publication or later task/memory update | Repeat `ship`; destination state determines remaining work | + +After recovery of an uncertain planning call, planning remains necessary unless an accepted plan was recorded. Inspect preserved edits before deliberately invoking `plan` again. A command that failed after recording its result may already have advanced state; always read the destination state first. + +No retained checkpoint can prove that later abandoned edits were durable. Missing or corrupt objects block recovery. Chronis claimant mismatch blocks managed execution. Prime recall supplies context only; source-linked nodes cannot authorize a transition or certify a successful check. Unavailable Prime storage degrades recall while retaining authoritative run access; pending memory indexing remains explicit until reconciled. + +## Verification and shipping + +`verify` materializes the accepted checkpoint into the original feature checkout and creates or amends the authorized owned commit. It then executes frozen policy gates on that exact SHA and checks for changed content or environment. Do not edit or switch the original checkout during this operation. Branches containing another author's commits or more than one existing feature commit require explicit handling before the run. + +`ship` checks candidate cleanliness, workflow/policy/method digests, environment, branch liveness, remote identity, and verification. It publishes the exact SHA with a branch-specific lease, reads the remote ref, reuses or creates one PR, and reads back its open state and head SHA. A lost response requires destination reconciliation rather than an assumed retry. + +Chronis completion and Prime outcome indexing follow confirmed publication. If either later write fails, rerunning `ship` reconciles remaining metadata without treating missing metadata as proof that publication failed. `publication` fields expose those component outcomes. `shipped` covers the remote commit, open PR, completed task, and indexed outcome; merge and deployment are separate operations. + +Verification records the prepared commit so a changed checkpoint can be committed and verified again. Commit receipts carry the run identity for reconciliation after a lost acknowledgement. After shipping, Allstack removes its detached worktrees only when their content and base still match their last accepted snapshots. Unexpected edits stop cleanup and preserve the workspace for inspection. Repeating `ship` with the current epoch retries cleanup without publishing again. + +## Private evidence and public proof + +`.allstack/` holds AllSource events, Prime graph state, object snapshots, run worktrees, and process receipts. It is local-only and must remain ignored. Read component stores through their APIs or Allstack commands, never by scraping WAL or storage segments. + +Retain tool versions, run IDs, exact commits, actual test counts and outputs, interruption points, remote/PR readbacks, and private artifact digests when validating the release. Publish only deliberately reviewed evidence in indexed repository documents. Fixture definitions and source tests do not establish that authenticated Codex and Claude Code completed the real journey. diff --git a/docs/reference/handoff-forward-request.md b/docs/reference/handoff-forward-request.md new file mode 100644 index 0000000..9d58c14 --- /dev/null +++ b/docs/reference/handoff-forward-request.md @@ -0,0 +1,9 @@ +# Forward host handoff exercise + +Implement `handoff_receipt` in `fixtures/handoff/src/lib.rs`. + +Accept exactly `codex` and `claude-code` as hosts. A transfer must change hosts. For a valid transfer return `FROM -> TO`; reject unknown, empty, or unchanged hosts. Do not normalize whitespace in this function. + +Preserve `normalized_receipt` for the separate reverse-host exercise. Do not alter gate configuration, workflow policy, test assertions, dependencies, or unrelated implementation files. + +Acceptance: the two tests in `fixtures/handoff/tests/forward.rs` pass, and the Allstack runtime regression suite remains green. diff --git a/docs/reference/handoff-reverse-request.md b/docs/reference/handoff-reverse-request.md new file mode 100644 index 0000000..26f0473 --- /dev/null +++ b/docs/reference/handoff-reverse-request.md @@ -0,0 +1,7 @@ +# Reverse host handoff exercise + +Implement `normalized_receipt` in `fixtures/handoff/src/lib.rs` by trimming surrounding whitespace and delegating to the already-tested `handoff_receipt` contract. + +Preserve the strict behavior of `handoff_receipt`. Do not alter gate configuration, workflow policy, test assertions, dependencies, or unrelated implementation files. + +Acceptance: the four forward and reverse fixture tests pass, and the Allstack runtime regression suite remains green. diff --git a/docs/reference/release-acceptance.md b/docs/reference/release-acceptance.md index 390cb67..df25bd8 100644 --- a/docs/reference/release-acceptance.md +++ b/docs/reference/release-acceptance.md @@ -1,6 +1,6 @@ # First-release acceptance scenarios -Status: specification only. Every checkbox remains open until retained evidence establishes it. +Status: implementation-preview release gate. Every checkbox remains open until retained evidence establishes it. Source-level contracts, passing unit tests, and journey definitions do not by themselves establish authenticated host behavior. ## Complete job @@ -23,7 +23,7 @@ Status: specification only. Every checkbox remains open until retained evidence ## Storage and component consistency - [ ] Actual AllSource writes survive process restart with complete, validated sequence and idempotent operation recovery. -- [ ] Competing coordinators cannot mutate one local run store. +- [ ] Competing CLI coordinators cannot mutate one local run store; the per-command kernel lock and stale-epoch checks are exercised across real processes. - [ ] Corrupt, truncated, reordered, duplicated, or partially retained history cannot produce a verified stage. - [ ] Reusing an operation ID with different input fails; lost append acknowledgements recover by reading the store. - [ ] Crash between run state and Chronis/Prime update leaves recoverable pending work rather than contradictory completion claims. @@ -45,7 +45,7 @@ Status: specification only. Every checkbox remains open until retained evidence ## Process and privacy boundary - [ ] Owned commands time out within 300 seconds, then terminate and reap their owned process tree. -- [ ] Supervisor death cannot leak children; process-level evidence checks this invariant. +- [ ] Caller or guardian death cannot leak owned children; process-level evidence checks the owner-pipe and kill/reap invariant. - [ ] One local build runs at a time; external competing builds are observed without terminating another session's work. - [ ] Runtime stores, unredacted transcripts, source snapshots, credentials, and live capabilities stay out of the public repository. - [ ] Host support documentation distinguishes managed-action enforcement, advisory guidance, and unsupported operations. @@ -55,3 +55,11 @@ Status: specification only. Every checkbox remains open until retained evidence Retain the release binary/package digests, host and component versions, workflow digest, synthetic fixture revision, complete run IDs, exact delivered commit SHAs, actual check outputs, interruption points, and reconciled publication receipts. Publish an indexed evidence document containing only material intentionally suitable for public distribution. Passing scaffold validation establishes only that documentation links and example workflow data are consistent. It cannot satisfy any runtime checkbox above. + +## Preview coverage boundary + +The runtime uses AllSource Core 0.26.1 embedded storage, Chronis through `cn --toon`, and an embedded Prime graph with lexical recall. Each CLI command owns an exclusive kernel lock; no persistent daemon or authenticated IPC service exists. Epoch fencing covers managed transitions and publication. Native host tools outside Allstack remain outside that enforcement boundary. + +Release evidence must cover both host directions, not infer one from the other. Fixture placeholders are specifications awaiting actual host execution. A fixture name or manually written lifecycle event cannot satisfy the real-host criteria. + +The initial scope is one Unix machine, sequential detached worktrees, `github.com` publication, and positive Cargo/nextest test execution counts. Snapshots require explicitly staged new files and reject symlinks and recognized sensitive filenames, with caps of 4 MiB per file, 64 MiB total, and 10,000 files. These exclusions are not a complete secret scanner. Merge, deployment, vector retrieval, hosted sync, and cross-machine import/export are outside the preview. diff --git a/docs/reports/2026-10-24-cross-host-workflow-verification.md b/docs/reports/2026-10-24-cross-host-workflow-verification.md new file mode 100644 index 0000000..f80bfa1 --- /dev/null +++ b/docs/reports/2026-10-24-cross-host-workflow-verification.md @@ -0,0 +1,93 @@ +# Cross-host workflow verification + +Date: 2026-10-24. Target: public repository `all-source-os/allstack`, branch `feat/cross-host-workflow`, [implementation PR #2](https://github.com/all-source-os/allstack/pull/2). + +## Observed forward journey + +Run `9acd9993-3a70-4a87-b4fe-f62b96eb2cbb` used authenticated Codex CLI to plan the forward fixture, transferred its accepted snapshot and Chronis claim to Claude Code, and invoked Claude Code to implement `handoff_receipt`. A fresh Codex epoch recovered the source plan and candidate through separate CLI processes. No conversation transcript was transferred. + +| Evidence | Observed result | +| --- | --- | +| Host sequence | Codex epoch 1 → Claude Code epoch 2 → Codex epoch 3 | +| Task | `t-3d5fb1`, read back as `done`, final claimant Codex epoch 3 | +| Delivered commit | `112a738e9d1debbaf34dcc5f06402c313492b8ab` | +| Runtime gate | 19 executed tests, 19 passed, zero skipped | +| Fixture gate | Two executed forward tests, two passed | +| Whitespace gate | `git diff --check HEAD^ HEAD`, exit 0 | +| Publication | Existing PR #2 and remote branch independently matched the verified commit | +| Component completion | Chronis completion and source-linked embedded Prime outcome confirmed | +| Cleanup | All three forward worktrees removed after their snapshots matched accepted history | + +The forward destination already contained that commit from draft implementation publication. Allstack reconciled the destination and completed task/memory bookkeeping without another push or PR creation. The reverse exercise covers publication of a new candidate through the managed publisher. + +Forward verification receipt digests: + +- Runtime tests: `e4ad485d54c2b238519479bafa50e84d6893a006c7bc4cc9d88ba0105e68fffd`. +- Forward fixture: `a56d0f6aa566c51658bf9412d9e20f53165864144a57b5ca69336a7e4c3a5133`. +- Whitespace: `0855960220f15bd59fc22b2ab38462ca0839a480bed8b506d24206a8bbf0f53e`. + +## Reverse exercise and interruption + +Run `750de2d1-ec0b-4327-be96-4f0e1ba5c5f5` starts from the forward-delivered commit, with Claude Code owning epoch 1 and Chronis task `t-649659`. Its request implements whitespace normalization by delegating to the strict transfer function. + +The real planning caller was terminated with SIGTERM. Its host process receipt recorded `owner_lost: true`, no successful exit code, and an elapsed 2,927 ms. Reopening AllSource returned the durable `host_plan` intent. Recovery retained the last checkpoint, cleared uncertainty, and left planning necessary. It did not replay the uncertain model call. No remaining process argv contained this run's identity after interruption. + +A subsequent planning invocation completed a plan but attempted an unavailable shell listing tool. The adapter refused to accept that result. Claude's exposed tool set now limits planning to `Read`, `Glob`, and `Grep`; build adds editing and explicitly permitted Git commands. Repository hooks and native permission modes remain enabled. + +The final reverse host transitions, test receipts, exact delivered SHA, and destination confirmation are available through the run's `history` and `evidence` commands. The PR description records the final SHA after publication; a document cannot contain its own Git commit hash. + +After recovery, the narrowed Claude planning invocation completed without permission denials. Codex epoch 2 received a distinct restored worktree and implemented `normalized_receipt` by trimming inputs and delegating to the strict function. Claude epoch 3 received that resulting snapshot. The frozen reverse policy requires the runtime regression suite, all four forward/reverse fixture tests, and the candidate whitespace gate before delivery. + +## Exercised refusal and isolation paths + +- A second real CLI command was refused while Claude held the coordinator's kernel lock. +- An outgoing Codex epoch 1 checkpoint was refused after ownership advanced to Claude epoch 2. +- Editing the outgoing worktree did not change the incoming candidate. The probe edit was restored before cleanup. +- Changing `RUSTFLAGS` caused managed shipping to fail with `verification stale`, before publication. +- Competing external Cargo builds blocked required compilation. Verification retained its pending state and later succeeded after the compiler slot became idle; no peer process was terminated. +- Trailing whitespace in a generated plan stopped commit preparation. Explicit checkpoint repair retained the known checkout snapshot and enabled a later verification attempt. +- Process tests exercised deadline termination and owner-pipe loss using real children. AllSource tests reopened real embedded storage, rejected a competing writer, rejected stale writes, and detected a forged history gap. Artifact tests rejected corrupted bytes. +- Application fault tests covered a lost task-transfer acknowledgement, task completion failure after publication, Prime indexing outage, stale candidate receipts, and re-verification. These are injected-port tests, distinct from the authenticated host journeys. + +## Executable and component provenance + +| Component | Version or digest | +| --- | --- | +| AllSource Core and embedded Prime | Core `0.26.1`, `embedded` and `prime` features | +| Chronis | `cn 0.9.1` | +| Codex | `codex-cli 0.163.0` | +| Claude Code | `2.1.310` | +| Git | `2.46.1` | +| GitHub CLI | `2.101.0` | +| Verification Rust toolchain | `1.95` | +| Workflow digest | `d38b06fde2ccfb96e1f721579659461edc7b3c1fcdd0461fd43b842f4c09b169` | +| Forward verification policy | `8e53ad4d053288f76a75f78042aa4b9c0353f47ec354ba39a832a8a64e0f2986` | +| Reverse verification policy | `b66b39ff4aaa3ed20bf1efad5def2d23e34c986f0ba93d9a5ec859bdace73004` | +| Repaired adapter executable SHA-256 | `a61bc48cc407306e2e4f586f112a88285b1685f63a14db8a7634d42cb5ef1593` | +| CI bootstrap executable SHA-256 | `baa31b0bca4ca08dabd053fc0be708498d8f71821b18018fd23eb782c333ac7f` | + +[CI run 40114594845](https://github.com/all-source-os/allstack/actions/runs/40114594845) executed 19 tests on both Linux and macOS, with formatting, Clippy, advisory/license/source checks passing. Its macOS executable was built from synthetic merge commit `254761849251daaf3fc8a4793a494bfd56be3cf9`. The merge's tree and the reviewed branch's tree both equal `b783b5bcd8ee3fa9b72eb04db141629292211b8a`; artifact bytes matched the uploaded SHA-256. Exact-commit local gates subsequently passed on the branch commit itself. + +The locked graph contains 258 packages, including Allstack: 248 distinct names and eight names with multiple resolved versions. The embedded Core dependency supplies the Arrow/Parquet stack and those transitive version splits. Direct TOML and SHA-2 dependencies share Core's resolved versions. Defaults on Core remain disabled; vector inference dependencies are excluded. + +## Inspect and reproduce + +Follow the [CLI guide](../reference/cli.md), using the [forward](../reference/handoff-forward-request.md) and [reverse](../reference/handoff-reverse-request.md) requests. In the original local run store: + +```sh +allstack history 9acd9993-3a70-4a87-b4fe-f62b96eb2cbb +allstack evidence 9acd9993-3a70-4a87-b4fe-f62b96eb2cbb +allstack history 750de2d1-ec0b-4327-be96-4f0e1ba5c5f5 +allstack evidence 750de2d1-ec0b-4327-be96-4f0e1ba5c5f5 +allstack recall 'verified commit remote task completion' +``` + +These commands read component state through Allstack and its embedded engines. The private store and unredacted process output are excluded from Git. A fresh clone does not contain the original private store; it can run the versioned fixtures and create its own runs. + +GitHub reported `main.protected = false` during verification. CI results provide review visibility and do not block a direct merge. The managed publisher enforces its frozen verification policy before delivery; the repository enables rebase merges and disables merge commits and squash merges. + +## Boundary of this evidence + +This preview covers sequential hosts on one Unix machine, local embedded storage, lexical Prime recall, and GitHub feature-branch publication. Epoch numbers fence managed transitions; they do not authenticate a local user or sandbox native host tools outside Allstack. Selected environment fingerprints are not hermetic builds. Filename exclusions are not a complete secret scanner. + +The caller-loss test does not establish behavior after SIGKILL of every guardian at once, machine power loss, arbitrary native-plugin daemonization, or cross-machine transfer. Merge and deployment were not performed. Unchecked [release acceptance criteria](../reference/release-acceptance.md) remain unproven rather than being inferred from passing tests. diff --git a/fixtures/handoff/Cargo.lock b/fixtures/handoff/Cargo.lock new file mode 100644 index 0000000..3cb48c6 --- /dev/null +++ b/fixtures/handoff/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "handoff-example" +version = "0.1.0" diff --git a/fixtures/handoff/Cargo.toml b/fixtures/handoff/Cargo.toml new file mode 100644 index 0000000..a615577 --- /dev/null +++ b/fixtures/handoff/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "handoff-example" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] diff --git a/fixtures/handoff/src/lib.rs b/fixtures/handoff/src/lib.rs new file mode 100644 index 0000000..1070b24 --- /dev/null +++ b/fixtures/handoff/src/lib.rs @@ -0,0 +1,10 @@ +pub fn handoff_receipt(from: &str, to: &str) -> Result { + match (from, to) { + ("codex", "claude-code") | ("claude-code", "codex") => Ok(format!("{from} -> {to}")), + _ => Err("invalid host transfer"), + } +} + +pub fn normalized_receipt(from: &str, to: &str) -> Result { + handoff_receipt(from.trim(), to.trim()) +} diff --git a/fixtures/handoff/tests/forward.rs b/fixtures/handoff/tests/forward.rs new file mode 100644 index 0000000..e4e1d1a --- /dev/null +++ b/fixtures/handoff/tests/forward.rs @@ -0,0 +1,15 @@ +use handoff_example::handoff_receipt; + +#[test] +fn both_supported_directions_have_canonical_receipts() { + assert_eq!(handoff_receipt("codex", "claude-code").unwrap(), "codex -> claude-code"); + assert_eq!(handoff_receipt("claude-code", "codex").unwrap(), "claude-code -> codex"); +} + +#[test] +fn unknown_or_unchanged_hosts_are_rejected() { + assert!(handoff_receipt("unknown", "codex").is_err()); + assert!(handoff_receipt("codex", "unknown").is_err()); + assert!(handoff_receipt("codex", "codex").is_err()); + assert!(handoff_receipt("", "claude-code").is_err()); +} diff --git a/fixtures/handoff/tests/reverse.rs b/fixtures/handoff/tests/reverse.rs new file mode 100644 index 0000000..1e05ee6 --- /dev/null +++ b/fixtures/handoff/tests/reverse.rs @@ -0,0 +1,14 @@ +use handoff_example::normalized_receipt; + +#[test] +fn whitespace_is_normalized_before_validating_hosts() { + assert_eq!(normalized_receipt(" claude-code\n", "\tcodex ").unwrap(), "claude-code -> codex"); + assert_eq!(normalized_receipt(" codex ", " claude-code ").unwrap(), "codex -> claude-code"); +} + +#[test] +fn normalization_cannot_hide_unknown_or_same_host_transfers() { + assert!(normalized_receipt(" codex ", "\tcodex").is_err()); + assert!(normalized_receipt("unknown ", "codex").is_err()); + assert!(normalized_receipt(" ", "claude-code").is_err()); +} diff --git a/hosts/claude-code/SKILL.md b/hosts/claude-code/SKILL.md new file mode 100644 index 0000000..4091264 --- /dev/null +++ b/hosts/claude-code/SKILL.md @@ -0,0 +1,32 @@ +--- +name: allstack-delivery +description: Continue a managed Allstack plan, build, verify, and ship workflow through Claude Code and Codex CLI with retained run state and explicit host handoff. +--- + +# Allstack delivery through Claude Code + +Use the installed `allstack` CLI as the managed workflow boundary. Read repository instructions and the [CLI guide](../../docs/reference/cli.md) before starting. Install this skill through the host's ordinary skill mechanism; the adapter does not install skills automatically. + +## Establish context + +Run `allstack -C status ` and `resume --epoch `. Read the returned request, indexed plan, stage, active worktree, pending operation, and contextual Prime recall. Open recalled sources before treating them as requirements. No previous Codex conversation is needed. + +Use the original repository as the CLI's `-C` target. Edit only the active detached worktree returned in run state. The coordinator has already claimed the run's Chronis task and read back the expected claimant; do not claim, release, complete, or replace it independently. + +## Execute the accepted work + +`plan --epoch ` invokes real `claude --print` with planning permissions. `build --epoch ` invokes it with `acceptEdits` and the adapter's explicit tool allowlist. Host authentication and ordinary permission rules remain active. A denied operation must remain visible; never add bypass flags. + +Follow the frozen request and indexed plan. Explicitly stage each intended new file with `git add -- ` before checkpoint capture. Do not commit, push, create branches, alter run state, or modify the verification policy from a managed build. The coordinator owns final verification and publication. + +## Transfer or recover + +Use `handoff --epoch --to codex` for a sequential transfer. Read the returned epoch and active worktree. Do not continue editing the outgoing workspace. Stale epochs fence Allstack-managed transitions; native host tools are outside that boundary. + +After failure, inspect `status`, `history`, active workspace, and private process evidence. Use `recover` for pending host or handoff work only after inspecting its effects. Recovery preserves current edits without replaying an uncertain host invocation. Use the original destination-specific `verify` or `ship` command to reconcile their pending effects. + +## Verify and publish + +Use `verify --epoch --message ` only under recorded commit authority. Verification prepares the original feature checkout and runs frozen gates on the exact SHA. Positive Cargo/nextest execution counts are required; a planning summary or compilation alone cannot satisfy execution proof. + +Use `ship --epoch --title ` only under recorded publication authority. Completion requires actual remote/PR readback and reconciled Chronis/Prime outcomes. Merge and deployment remain separate. Keep private evidence, local stores, credentials, and source snapshots out of public documents. diff --git a/hosts/codex/SKILL.md b/hosts/codex/SKILL.md new file mode 100644 index 0000000..c5200fd --- /dev/null +++ b/hosts/codex/SKILL.md @@ -0,0 +1,32 @@ +--- +name: allstack-delivery +description: Continue a managed Allstack plan, build, verify, and ship workflow through Codex CLI and Claude Code with retained run state and explicit host handoff. +--- + +# Allstack delivery through Codex + +Use the installed `allstack` CLI as the managed workflow boundary. Read the repository's instructions and the [CLI guide](../../docs/reference/cli.md) before starting. Install this skill through the host's ordinary skill mechanism; the adapter does not install skills automatically. + +## Establish context + +Run `allstack -C status ` and `resume --epoch `. Read the returned request, indexed plan, stage, active worktree, pending operation, and contextual Prime recall. Open recalled sources before treating them as requirements. No previous conversation is assumed. + +Use the original repository as the CLI's `-C` target. Edit only the active detached worktree returned in run state. The coordinator has already claimed the run's Chronis task and read back the expected claimant; do not claim, release, complete, or replace it independently. + +## Execute the accepted work + +`plan --epoch ` invokes real `codex exec` in read-only planning mode. The coordinator records and indexes the returned plan. `build --epoch ` invokes Codex with `workspace-write`. Host authentication and permissions remain those of the installed CLI. Never add permission-bypass flags to make a run pass. + +Follow the frozen request and plan. Explicitly stage each intended new file with `git add -- ` before a checkpoint. Do not commit, push, create branches, alter run state, or modify the verification policy from a managed build. Required gates and publication belong to the coordinator. + +## Transfer or recover + +Use `handoff --epoch --to claude-code` for a sequential transfer. Read the returned epoch and worktree; the outgoing workspace becomes stale. A stale epoch cannot advance managed state, although native tools remain outside Allstack's enforcement. + +If a managed command fails, inspect `status`, `history`, active workspace, and private process evidence. Resolve pending host or handoff operations with `recover` only after inspecting their effects. Recovery preserves edits and does not replay an uncertain host call. Use `verify` or `ship` for their destination-specific pending operations. + +## Verify and publish + +Use `verify --epoch --message ` only under recorded user commit authority. It prepares the original feature checkout and executes frozen gates on the exact candidate SHA. Require actual positive Cargo/nextest execution counts; host testimony and source checks cannot prove the complete flow. + +Use `ship --epoch --title ` only under recorded publication authority. Read remote and PR receipts, Chronis completion, and Prime outcome status before reporting completion. Merge and deployment are separate actions. Keep raw transcripts, private stores, credentials, and snapshots out of public documents. diff --git a/src/application/mod.rs b/src/application/mod.rs new file mode 100644 index 0000000..753183e --- /dev/null +++ b/src/application/mod.rs @@ -0,0 +1,287 @@ +pub mod ports; +use crate::domain::{Host, Policy, Run, Stage}; +use anyhow::{Context, Result, ensure}; +use ports::{Platform, Start}; + +pub async fn start(p: &impl Platform, input: Start) -> Result { + let mut run = p.initialize(&input)?; + p.save(&mut run, "started").await?; + run.pending = Some("task_setup".into()); + p.save(&mut run, "intent").await?; + run.task = Some(p.create_task(&run)?); + p.save(&mut run, "task_bound").await?; + p.claim(&run, None)?; + run.pending = None; + p.save(&mut run, "task_claimed").await?; + Ok(run) +} + +pub async fn plan(p: &impl Platform, id: &str, epoch: u64) -> Result { + let mut run = ready(p, id, epoch).await?; + ensure!(run.stage == Stage::Planning, "planning stage required"); + let request = String::from_utf8(p.object(&run.request)?)?; + let recall = p.recall(&request); + let method = + String::from_utf8(p.object(run.methods.get("plan").context("plan method absent")?)?)?; + run.pending = Some("host_plan".into()); + p.save(&mut run, "intent").await?; + let text = p.invoke( + &run, + &format!("{method}\nREQUEST:\n{request}\nRECALL:\n{recall}"), + true, + )?; + ensure!(!text.trim().is_empty(), "host returned no plan"); + ensure!( + text.len() <= 128 * 1024, + "host plan exceeds supported context limit" + ); + run.plan = Some(p.put(text.as_bytes())?); + let source = p.write_plan(&run, &text)?; + run.checkpoint = p.checkpoint(&run)?; + run.stage = Stage::Building; + run.pending = None; + run.memory_pending = true; + p.save(&mut run, "planned").await?; + if p.remember( + &run, + "plan", + &source, + "Indexed accepted delivery plan; open source document before using recalled requirements.", + ) + .await + .is_ok() + { + run.memory_pending = false; + p.save(&mut run, "memory_indexed").await?; + } + Ok(run) +} + +pub async fn build(p: &impl Platform, id: &str, epoch: u64) -> Result { + let mut run = ready(p, id, epoch).await?; + ensure!(run.stage == Stage::Building, "building stage required"); + let plan = String::from_utf8(p.object(run.plan.as_deref().context("plan missing")?)?)?; + let request = String::from_utf8(p.object(&run.request)?)?; + let method = + String::from_utf8(p.object(run.methods.get("build").context("build method absent")?)?)?; + run.pending = Some("host_build".into()); + p.save(&mut run, "intent").await?; + p.invoke( + &run, + &format!("{method}\nREQUEST:\n{request}\nPLAN:\n{plan}"), + false, + )?; + run.checkpoint = p.checkpoint(&run)?; + run.pending = None; + p.save(&mut run, "built").await?; + Ok(run) +} + +pub async fn handoff(p: &impl Platform, id: &str, epoch: u64, host: Host) -> Result { + let mut run = ready(p, id, epoch).await?; + ensure!( + run.host != host && !matches!(run.stage, Stage::Shipping | Stage::Shipped), + "handoff needs another host before shipping" + ); + let previous = run.clone(); + let checkpoint = p.checkpoint(&run)?; + run.pending = Some("handoff".into()); + run.transfer = Some(crate::domain::Transfer { + from_host: run.host, + from_epoch: run.epoch, + to_host: host, + }); + run.checkpoint = checkpoint; + p.save(&mut run, "intent").await?; + let workspace = p.restore(&run, &run.checkpoint, run.epoch + 1)?; + run.epoch += 1; + run.host = host; + run.workspace = workspace; + run.verification = None; + if run.stage == Stage::Verifying { + run.stage = Stage::Building; + } + p.save(&mut run, "ownership_changed").await?; + p.claim(&run, Some(&previous))?; + run.pending = None; + run.transfer = None; + p.save(&mut run, "handoff_completed").await?; + Ok(run) +} + +pub async fn checkpoint(p: &impl Platform, id: &str, epoch: u64) -> Result { + let mut run = p.load(id).await?; + run.check_epoch(epoch).map_err(anyhow::Error::msg)?; + ensure!( + run.pending.is_none() || matches!(run.pending.as_deref(), Some("commit" | "verification")), + "unresolved external action blocks checkpoint repair" + ); + p.claim_readback(&run)?; + if run.pending.as_deref() == Some("commit") { + run.checkout_snapshot = Some(run.checkpoint.clone()); + } + run.checkpoint = p.checkpoint(&run)?; + run.pending = None; + run.verification = None; + if run.stage == Stage::Verifying { + run.stage = Stage::Building; + } + p.save(&mut run, "checkpointed").await?; + Ok(run) +} + +pub async fn recover(p: &impl Platform, id: &str, epoch: u64) -> Result { + let mut run = p.load(id).await?; + run.check_epoch(epoch).map_err(anyhow::Error::msg)?; + match run.pending.as_deref() { + Some("task_setup") => { + run.task = Some(p.create_task(&run)?); + p.save(&mut run, "task_bound").await?; + p.claim(&run, None)?; + } + Some("host_plan" | "host_build") => { + // Recovery preserves inspected edits; it never re-executes an uncertain host call. + run.checkpoint = p.checkpoint(&run)?; + } + Some("handoff") => { + let transfer = run.transfer.clone().context("handoff contract missing")?; + let mut previous = run.clone(); + previous.host = transfer.from_host; + previous.epoch = transfer.from_epoch; + if run.epoch == transfer.from_epoch { + run.workspace = p.restore(&run, &run.checkpoint, run.epoch + 1)?; + run.epoch += 1; + run.host = transfer.to_host; + run.verification = None; + if run.stage == Stage::Verifying { + run.stage = Stage::Building; + } + p.save(&mut run, "ownership_changed").await?; + } + p.claim(&run, Some(&previous))?; + run.transfer = None; + } + None => { + p.claim_readback(&run)?; + if run.memory_pending { + p.remember(&run,"plan",&format!("docs/plans/allstack-{}.md",run.id),"Indexed accepted delivery plan; open source document before using recalled requirements.").await?; + run.memory_pending = false; + p.save(&mut run, "memory_indexed").await?; + } + return Ok(run); + } + Some(other) => { + anyhow::bail!("operation {other} requires its destination-specific resume command") + } + } + run.pending = None; + p.save(&mut run, "reconciled").await?; + Ok(run) +} + +pub async fn verify(p: &impl Platform, id: &str, epoch: u64, message: &str) -> Result { + let mut run = p.load(id).await?; + run.check_epoch(epoch).map_err(anyhow::Error::msg)?; + ensure!( + run.pending.is_none() || matches!(run.pending.as_deref(), Some("commit" | "verification")), + "unresolved action blocks verification" + ); + p.claim_readback(&run)?; + ensure!( + matches!(run.stage, Stage::Building | Stage::Verifying), + "build stage required" + ); + ensure!( + run.allow_commit, + "commit authority absent; start must explicitly authorize commit" + ); + if run.pending.is_none() { + run.checkpoint = p.checkpoint(&run)?; + } + run.stage = Stage::Verifying; + run.verification = None; + run.pending = Some("commit".into()); + p.save(&mut run, "intent").await?; + run.prepared_commit = Some(p.prepare(&run, message)?); + run.checkout_snapshot = Some(run.checkpoint.clone()); + run.pending = Some("verification".into()); + p.save(&mut run, "intent").await?; + let before = p.candidate(&run)?; + let policy: Policy = toml::from_str(&String::from_utf8(p.object(&run.policy)?)?)?; + policy.validate().map_err(anyhow::Error::msg)?; + let logs = p.gates(&run, &policy)?; + let after = p.candidate(&run)?; + ensure!( + before.commit == after.commit + && before.snapshot == after.snapshot + && before.environment == after.environment, + "candidate or environment changed during verification" + ); + run.verification = Some(crate::domain::Verification { logs, ..after }); + run.pending = None; + p.save(&mut run, "verified").await?; + Ok(run) +} + +pub async fn ship(p: &impl Platform, id: &str, epoch: u64, title: &str) -> Result { + let mut run = p.load(id).await?; + ensure!(run.epoch == epoch, "stale host epoch"); + if run.stage == Stage::Shipped { + p.cleanup(&run).await?; + return Ok(run); + } + run.check_epoch(epoch).map_err(anyhow::Error::msg)?; + ensure!( + run.allow_publish && matches!(run.stage, Stage::Verifying | Stage::Shipping), + "publication authority or verified stage missing" + ); + ensure!( + run.pending.is_none() || run.pending.as_deref() == Some("publication"), + "unresolved action blocks publication" + ); + let verified = run.verification.as_ref().context("verification missing")?; + if run.publication.is_none() { + p.claim_readback(&run)?; + } + let actual = p.candidate(&run)?; + ensure!( + actual.commit == verified.commit + && actual.snapshot == verified.snapshot + && actual.environment == verified.environment + && actual.policy == verified.policy + && actual.workflow == verified.workflow, + "verification stale" + ); + run.stage = Stage::Shipping; + run.pending = Some("publication".into()); + p.save(&mut run, "intent").await?; + let mut publication = p.publish(&run, title)?; + run.publication = Some(publication.clone()); + p.save(&mut run, "destination_confirmed").await?; + p.complete_task(&run)?; + publication.task_completed = true; + run.publication = Some(publication.clone()); + p.save(&mut run, "task_completed").await?; + p.remember( + &run, + "shipped", + &publication.pr, + "Exact verified commit and remote PR head read back; task completion confirmed.", + ) + .await?; + publication.memory_indexed = true; + run.publication = Some(publication); + run.stage = Stage::Shipped; + run.pending = None; + p.save(&mut run, "shipped").await?; + p.cleanup(&run).await?; + Ok(run) +} + +async fn ready(p: &impl Platform, id: &str, epoch: u64) -> Result { + let run = p.load(id).await?; + run.check_epoch(epoch).map_err(anyhow::Error::msg)?; + run.require_resolved().map_err(anyhow::Error::msg)?; + p.claim_readback(&run)?; + Ok(run) +} diff --git a/src/application/ports.rs b/src/application/ports.rs new file mode 100644 index 0000000..86a29a0 --- /dev/null +++ b/src/application/ports.rs @@ -0,0 +1,35 @@ +use crate::domain::{Host, Policy, Publication, Run, Verification}; +use anyhow::Result; +use serde_json::Value; + +pub struct Start { + pub request: String, + pub policy: String, + pub host: Host, + pub allow_commit: bool, + pub allow_publish: bool, +} + +#[allow(async_fn_in_trait)] +pub trait Platform { + async fn load(&self, id: &str) -> Result; + async fn save(&self, run: &mut Run, kind: &str) -> Result<()>; + fn initialize(&self, input: &Start) -> Result; + fn object(&self, key: &str) -> Result>; + fn put(&self, bytes: &[u8]) -> Result; + fn checkpoint(&self, run: &Run) -> Result; + fn restore(&self, run: &Run, snapshot: &str, epoch: u64) -> Result; + fn create_task(&self, run: &Run) -> Result; + fn claim(&self, run: &Run, previous: Option<&Run>) -> Result<()>; + fn claim_readback(&self, run: &Run) -> Result<()>; + fn complete_task(&self, run: &Run) -> Result<()>; + fn recall(&self, query: &str) -> Value; + async fn remember(&self, run: &Run, phase: &str, source: &str, summary: &str) -> Result<()>; + fn invoke(&self, run: &Run, prompt: &str, planning: bool) -> Result; + fn write_plan(&self, run: &Run, text: &str) -> Result; + fn prepare(&self, run: &Run, message: &str) -> Result; + fn candidate(&self, run: &Run) -> Result; + fn gates(&self, run: &Run, policy: &Policy) -> Result>; + fn publish(&self, run: &Run, title: &str) -> Result; + async fn cleanup(&self, run: &Run) -> Result<()>; +} diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..fbea199 --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,288 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Host { + Codex, + ClaudeCode, +} + +impl Host { + pub fn name(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::ClaudeCode => "claude-code", + } + } +} + +impl std::str::FromStr for Host { + type Err = String; + fn from_str(value: &str) -> Result { + match value { + "codex" => Ok(Self::Codex), + "claude-code" => Ok(Self::ClaudeCode), + _ => Err("host must be codex or claude-code".into()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Transfer { + pub from_host: Host, + pub from_epoch: u64, + pub to_host: Host, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Stage { + Planning, + Building, + Verifying, + Shipping, + Shipped, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct FileEntry { + pub digest: String, + pub executable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Snapshot { + pub base: String, + pub files: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Gate { + pub name: String, + pub argv: Vec, + pub success_marker: Option, + #[serde(default)] + pub test: bool, + #[serde(default)] + pub compile: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Policy { + pub gates: Vec, + #[serde(default = "default_deadline")] + pub command_seconds: u64, + #[serde(default)] + pub environment: Vec, +} + +fn default_deadline() -> u64 { + 120 +} + +impl Policy { + pub fn validate(&self) -> Result<(), String> { + if self.command_seconds == 0 || self.command_seconds > 300 || self.gates.is_empty() { + return Err("require gates and command_seconds in 1..=300".into()); + } + if !self.gates.iter().any(|g| g.test) { + return Err("at least one executed-test gate is required".into()); + } + let mut names = std::collections::BTreeSet::new(); + for gate in &self.gates { + if gate.argv.is_empty() || !names.insert(&gate.name) { + return Err("gate names must be unique and argv cannot be empty".into()); + } + if gate.test && gate.success_marker.as_deref().is_none_or(str::is_empty) { + return Err("test gates require a nonempty execution success marker".into()); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Verification { + pub commit: String, + pub snapshot: String, + pub policy: String, + pub workflow: String, + pub environment: String, + pub logs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Publication { + pub commit: String, + pub remote: String, + pub branch: String, + pub pr: String, + pub task_completed: bool, + pub memory_indexed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Run { + pub id: String, + pub repository: String, + pub remote: String, + pub branch: String, + pub base: String, + pub trunk: String, + pub workflow: String, + pub methods: BTreeMap, + pub method_paths: BTreeMap, + pub policy: String, + pub policy_path: String, + pub request: String, + pub plan: Option, + pub task: Option, + pub host: Host, + pub epoch: u64, + pub stage: Stage, + pub workspace: String, + pub checkpoint: String, + pub pending: Option, + pub transfer: Option, + pub memory_pending: bool, + pub prepared_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkout_snapshot: Option, + pub verification: Option, + pub publication: Option, + pub allow_commit: bool, + pub allow_publish: bool, + pub revision: u64, +} + +impl Run { + pub fn check_epoch(&self, epoch: u64) -> Result<(), String> { + if self.epoch != epoch { + Err(format!( + "stale host epoch: expected {}, received {epoch}", + self.epoch + )) + } else if self.stage == Stage::Shipped { + Err("run already shipped".into()) + } else { + Ok(()) + } + } + + pub fn require_resolved(&self) -> Result<(), String> { + match &self.pending { + Some(operation) => Err(format!( + "unresolved operation {operation}; reconcile before execution" + )), + None => Ok(()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Event { + pub schema: u32, + pub operation: String, + pub previous: String, + pub kind: String, + pub run: Run, +} + +pub fn validate_transition(previous: Option<&Run>, event: &Event) -> Result<(), String> { + if ![ + "started", + "intent", + "task_bound", + "task_claimed", + "planned", + "built", + "ownership_changed", + "handoff_completed", + "checkpointed", + "reconciled", + "verified", + "destination_confirmed", + "task_completed", + "memory_indexed", + "shipped", + ] + .contains(&event.kind.as_str()) + { + return Err("unknown workflow event kind".into()); + } + if event.schema != 1 || event.operation.is_empty() { + return Err("unsupported event schema or absent operation id".into()); + } + match previous { + None if event.kind == "started" && event.run.revision == 1 && event.run.epoch == 1 => { + Ok(()) + } + Some(old) if old.id == event.run.id && old.revision + 1 == event.run.revision => { + if old.repository != event.run.repository + || old.branch != event.run.branch + || old.workflow != event.run.workflow + || old.methods != event.run.methods + || old.method_paths != event.run.method_paths + || old.policy != event.run.policy + || old.policy_path != event.run.policy_path + || old.remote != event.run.remote + || old.request != event.run.request + || old.base != event.run.base + || old.trunk != event.run.trunk + || old.allow_commit != event.run.allow_commit + || old.allow_publish != event.run.allow_publish + { + return Err("immutable run scope changed".into()); + } + let same = old.stage == event.run.stage; + let allowed = matches!( + (old.stage, event.run.stage), + (Stage::Planning, Stage::Building) + | (Stage::Building, Stage::Verifying) + | (Stage::Verifying, Stage::Building) + | (Stage::Verifying, Stage::Shipping) + | (Stage::Shipping, Stage::Shipped) + ); + if !same && !allowed { + return Err("invalid stage transition".into()); + } + if event.run.epoch < old.epoch || event.run.epoch > old.epoch + 1 { + return Err("invalid ownership epoch".into()); + } + if (event.run.epoch != old.epoch || event.run.host != old.host) + && (event.kind != "ownership_changed" + || event.run.epoch != old.epoch + 1 + || old.transfer.as_ref().is_none_or(|t| { + t.from_host != old.host + || t.from_epoch != old.epoch + || t.to_host != event.run.host + })) + { + return Err("ownership changed without a bound handoff".into()); + } + if old.stage == Stage::Shipped { + return Err("terminal run cannot change".into()); + } + if matches!(event.run.stage, Stage::Shipping | Stage::Shipped) + && event.run.verification.is_none() + { + return Err("publication requires verification".into()); + } + if event.run.stage == Stage::Shipped && event.run.publication.is_none() { + return Err("shipped requires destination receipt".into()); + } + Ok(()) + } + _ => Err("missing, duplicated, or invalid event sequence".into()), + } +} diff --git a/src/infrastructure/build_lock.rs b/src/infrastructure/build_lock.rs new file mode 100644 index 0000000..34b6768 --- /dev/null +++ b/src/infrastructure/build_lock.rs @@ -0,0 +1,55 @@ +use super::process; +use anyhow::{Context, Result, ensure}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{File, OpenOptions}, + os::unix::fs::OpenOptionsExt, + path::Path, +}; + +pub fn acquire(state: &Path, repo: &Path) -> Result { + let path = + std::env::temp_dir().join(format!("allstack-build-{}.lock", unsafe { libc::getuid() })); + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .mode(0o600) + .open(path)?; + lock.try_lock() + .context("another Allstack build owns the machine build slot")?; + let table = process::checked(state, repo, &["ps", "-axo", "pid=,ppid=,comm="])?; + let mut rows = BTreeMap::new(); + for line in table.lines() { + let mut fields = line.split_whitespace(); + if let (Some(pid), Some(ppid), Some(command)) = + (fields.next(), fields.next(), fields.next()) + { + rows.insert( + pid.parse::()?, + (ppid.parse::()?, command.to_string()), + ); + } + } + let mut ancestors = BTreeSet::new(); + let mut current = std::process::id(); + while ancestors.insert(current) { + match rows.get(¤t) { + Some((parent, _)) if *parent != 0 => current = *parent, + _ => break, + } + } + for (pid, (_, command)) in rows { + let name = Path::new(&command) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + ensure!( + ancestors.contains(&pid) + || !["cargo", "rustc", "clang", "cc", "gcc", "ld"].contains(&name), + "external build active ({name}, pid {pid}); wait rather than competing or killing it" + ); + } + Ok(lock) +} diff --git a/src/infrastructure/chronis.rs b/src/infrastructure/chronis.rs new file mode 100644 index 0000000..086266a --- /dev/null +++ b/src/infrastructure/chronis.rs @@ -0,0 +1,122 @@ +use super::process::{self, Spec}; +use crate::domain::Run; +use anyhow::{Context, Result, ensure}; +use std::{collections::BTreeMap, path::Path}; + +pub struct Tasks<'a> { + pub state: &'a Path, + pub repo: &'a Path, +} + +impl Tasks<'_> { + fn call(&self, args: &[&str], actor: Option<&str>) -> Result { + let mut argv = vec!["cn".into()]; + argv.extend(args.iter().map(|s| s.to_string())); + argv.push("--toon".into()); + let mut environment = BTreeMap::new(); + if let Some(actor) = actor { + environment.insert("CN_AGENT_ID".into(), actor.into()); + } + process::run(Spec { + argv, + cwd: self.repo.into(), + seconds: 30, + environment, + input: None, + directory: self.state.join("process"), + })? + .checked() + } + fn actor(run: &Run) -> String { + format!("allstack:{}:{}:{}", run.id, run.host.name(), run.epoch) + } + pub fn create(&self, run: &Run) -> Result { + let title = format!("allstack {}", run.id); + let list = self.call(&["list", "--all"], None)?; + let found: Vec<&str> = list + .lines() + .filter_map(|line| { + let fields: Vec<&str> = line.split('|').collect(); + (fields.len() >= 3 && fields[2] == title).then(|| fields[0]) + }) + .collect(); + ensure!( + found.len() <= 1, + "duplicate Chronis tasks require reconciliation" + ); + if let Some(id) = found.first() { + return Ok(id.to_string()); + } + let result = self.call( + &[ + "task", + "create", + &title, + "-d", + &format!("Allstack run {}; source-linked delivery", run.id), + ], + None, + )?; + result + .lines() + .find_map(|s| s.strip_prefix("created:task:")) + .map(|s| s.split(':').next().unwrap_or(s).to_string()) + .context("uncertain task create; reconcile by exact title before retry") + } + pub fn show(&self, id: &str) -> Result { + self.call(&["show", id], None) + } + pub fn claim(&self, run: &Run, previous: Option<&str>) -> Result<()> { + let id = run.task.as_deref().context("missing Chronis task")?; + let actual = self.show(id)?; + let actor = Self::actor(run); + let owner = actual + .lines() + .find_map(|s| s.strip_prefix("claimed_by:")) + .unwrap_or(""); + if owner == actor && actual.lines().any(|s| s == "status:in-progress") { + return Ok(()); + } + if actual.lines().any(|s| s == "status:in-progress") { + ensure!( + previous == Some(owner), + "Chronis task belongs to another active claimant" + ); + self.call( + &["release", id, "--reason", "explicit Allstack handoff"], + Some(owner), + )?; + } + self.call(&["claim", id], Some(&actor))?; + self.ensure_claim(run) + } + pub fn ensure_claim(&self, run: &Run) -> Result<()> { + let actual = self.show(run.task.as_deref().context("missing task")?)?; + ensure!( + actual + .lines() + .any(|s| s == format!("claimed_by:{}", Self::actor(run))) + && actual.lines().any(|s| s == "status:in-progress"), + "Chronis claim read-back mismatch" + ); + Ok(()) + } + pub fn complete(&self, run: &Run) -> Result<()> { + let id = run.task.as_deref().context("missing task")?; + if !self.show(id)?.lines().any(|s| s == "status:done") { + self.ensure_claim(run)?; + self.call( + &["done", id, "--reason", "remote commit and PR head verified"], + Some(&Self::actor(run)), + )?; + } + ensure!( + self.show(id)?.lines().any(|s| s == "status:done"), + "task completion unconfirmed" + ); + Ok(()) + } + pub fn previous_actor(run: &Run) -> String { + Self::actor(run) + } +} diff --git a/src/infrastructure/forge.rs b/src/infrastructure/forge.rs new file mode 100644 index 0000000..6a58483 --- /dev/null +++ b/src/infrastructure/forge.rs @@ -0,0 +1,184 @@ +use super::{git, process}; +use crate::domain::{Publication, Run}; +use anyhow::{Context, Result, ensure}; +use serde_json::Value; +use std::path::Path; + +pub fn repository(remote: &str) -> Result { + git::safe_remote(remote)?; + let name = remote + .trim_start_matches("https://github.com/") + .trim_start_matches("git@github.com:") + .trim_end_matches(".git"); + ensure!( + name.split('/').count() == 2 + && name.split('/').all(|p| !p.is_empty() + && p.bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-_.".contains(&c))), + "invalid GitHub repository" + ); + Ok(name.into()) +} + +pub fn live(state: &Path, repo: &Path, name: &str, branch: &str) -> Result> { + let raw = process::checked( + state, + repo, + &[ + "gh", + "pr", + "list", + "--repo", + name, + "--head", + branch, + "--state", + "all", + "--json", + "state,url,headRefOid", + ], + )?; + let prs: Vec = serde_json::from_str(&raw)?; + ensure!( + !prs.iter().any(|p| p["state"] == "MERGED"), + "branch PR already merged; dead branch" + ); + ensure!( + prs.iter().filter(|p| p["state"] == "OPEN").count() <= 1, + "multiple open PRs for branch" + ); + Ok(prs) +} + +pub fn remote_tip(state: &Path, repo: &Path, branch: &str) -> Result> { + let raw = git::call( + state, + repo, + &[ + "ls-remote", + "--heads", + "origin", + &format!("refs/heads/{branch}"), + ], + )?; + let rows: Vec<_> = raw.lines().filter(|s| !s.is_empty()).collect(); + ensure!(rows.len() <= 1, "ambiguous remote branch"); + Ok(rows + .first() + .and_then(|s| s.split_whitespace().next()) + .map(String::from)) +} + +pub fn publish(state: &Path, run: &Run, title: &str) -> Result { + let repo = Path::new(&run.repository); + let name = repository(&run.remote)?; + ensure!( + run.branch != run.trunk + && git::call(state, repo, &["branch", "--show-current"])? == run.branch, + "publication must use the authorized live local branch" + ); + let upstream = git::call( + state, + repo, + &[ + "for-each-ref", + "--format=%(upstream:short)", + &format!("refs/heads/{}", run.branch), + ], + )?; + ensure!( + upstream.is_empty() || upstream == format!("origin/{}", run.branch), + "unexpected branch upstream" + ); + ensure!( + git::call(state, repo, &["remote", "get-url", "origin"])? == run.remote, + "remote changed" + ); + git::clean(state, repo)?; + let commit = &run + .verification + .as_ref() + .context("verification missing")? + .commit; + ensure!( + git::call(state, repo, &["rev-parse", "HEAD"])? == *commit, + "head changed after verification" + ); + let prs = live(state, repo, &name, &run.branch)?; + let before = remote_tip(state, repo, &run.branch)?; + if before.as_deref() != Some(commit) { + ensure!( + before.is_none() || before.as_deref() == Some(&run.base), + "remote moved outside this run; publication requires review" + ); + let lease = format!( + "--force-with-lease=refs/heads/{}:{}", + run.branch, + before.unwrap_or_default() + ); + let destination = format!("HEAD:{}", run.branch); + git::call(state, repo, &["push", "-u", &lease, "origin", &destination])?; + } + ensure!( + remote_tip(state, repo, &run.branch)?.as_deref() == Some(commit), + "remote commit unconfirmed; reconcile destination" + ); + let pr = if let Some(open) = prs.iter().find(|p| p["state"] == "OPEN") { + open["url"].as_str().context("PR URL missing")?.to_string() + } else { + let body = format!( + "## Summary\n\n{title}\n\n## Verification\n\nExact commit `{commit}` verified by Allstack run `{}`. Required command receipts remain in the local run store.\n\n## Design\n\nSee the indexed delivery plan under `docs/plans/allstack-{}.md`.\n", + run.id, run.id + ); + let path = state.join(format!("pr-{}.md", run.id)); + super::atomic_write(&path, body.as_bytes())?; + process::checked( + state, + repo, + &[ + "gh", + "pr", + "create", + "--repo", + &name, + "--head", + &run.branch, + "--base", + &run.trunk, + "--title", + title, + "--body-file", + path.to_str().context("invalid body path")?, + ], + )? + .trim() + .to_string() + }; + let raw = process::checked( + state, + repo, + &[ + "gh", + "pr", + "view", + &pr, + "--repo", + &name, + "--json", + "state,headRefOid,url", + ], + )?; + let actual: Value = serde_json::from_str(&raw)?; + ensure!( + actual["state"] == "OPEN" && actual["headRefOid"] == *commit, + "PR head or state does not match verified commit" + ); + Ok(Publication { + commit: commit.clone(), + remote: run.remote.clone(), + branch: run.branch.clone(), + pr, + task_completed: run.publication.as_ref().is_some_and(|p| p.task_completed), + memory_indexed: run.publication.as_ref().is_some_and(|p| p.memory_indexed), + }) +} diff --git a/src/infrastructure/gates.rs b/src/infrastructure/gates.rs new file mode 100644 index 0000000..d92e8eb --- /dev/null +++ b/src/infrastructure/gates.rs @@ -0,0 +1,23 @@ +pub fn executed_tests(output: &str) -> usize { + output + .lines() + .map(|line| { + if let Some(summary) = line.trim().strip_prefix("test result: ok.") { + return summary + .split_whitespace() + .next() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + } + let words: Vec<_> = line.split_whitespace().collect(); + words + .windows(3) + .find_map(|w| { + (w[1] == "tests" && w[2] == "run:") + .then(|| w[0].parse::().ok()) + .flatten() + }) + .unwrap_or(0) + }) + .sum() +} diff --git a/src/infrastructure/git.rs b/src/infrastructure/git.rs new file mode 100644 index 0000000..6b18573 --- /dev/null +++ b/src/infrastructure/git.rs @@ -0,0 +1,60 @@ +use super::process; +use anyhow::{Result, ensure}; +use std::path::{Path, PathBuf}; + +pub fn call(root: &Path, cwd: &Path, args: &[&str]) -> Result { + let mut argv = vec!["git"]; + argv.extend(args); + Ok(process::checked(root, cwd, &argv)? + .trim_end_matches('\n') + .to_string()) +} + +pub fn root(path: &Path) -> Result { + let mut path = path.canonicalize()?; + loop { + if path.join(".git").exists() { + return Ok(path); + } + ensure!(path.pop(), "not a git repository"); + } +} + +pub fn clean(state: &Path, cwd: &Path) -> Result<()> { + ensure!( + call( + state, + cwd, + &["status", "--porcelain", "--untracked-files=all"] + )? + .is_empty(), + "working tree must be clean" + ); + Ok(()) +} + +pub fn trunk(state: &Path, cwd: &Path) -> Result { + let raw = call(state, cwd, &["ls-remote", "--symref", "origin", "HEAD"])?; + let branch = raw.lines().find_map(|line| { + line.strip_prefix("ref: refs/heads/") + .and_then(|s| s.strip_suffix("\tHEAD")) + }); + Ok(branch + .ok_or_else(|| anyhow::anyhow!("remote default branch could not be established"))? + .to_string()) +} + +pub fn safe_remote(remote: &str) -> Result<()> { + ensure!( + !remote.contains('\n') + && !remote.contains('\r') + && !remote.contains('?') + && !remote.contains('#'), + "unsafe remote URL" + ); + ensure!( + remote.starts_with("https://github.com/") || remote.starts_with("git@github.com:"), + "first release supports credential-free github.com remotes" + ); + Ok(()) +} diff --git a/src/infrastructure/hosts.rs b/src/infrastructure/hosts.rs new file mode 100644 index 0000000..896f20c --- /dev/null +++ b/src/infrastructure/hosts.rs @@ -0,0 +1,123 @@ +use super::process::{self, Spec}; +use crate::domain::{Host, Policy, Run}; +use anyhow::{Context, Result, ensure}; +use serde_json::Value; +use std::{collections::BTreeMap, path::Path}; + +pub fn invoke( + state: &Path, + run: &Run, + policy: &Policy, + prompt: &str, + planning: bool, +) -> Result { + let prompt = format!( + "Managed Allstack run {}. Host {}, epoch {}, revision {}. Chronis task {} already claimed and read back by coordinator for this host. Active checkpoint {}. This detached worktree is intentional; original repository branch is {}. Do not change task ownership or run state yourself. Recover source context from the indexed plan; no prior conversation is assumed.\n\n{}", + run.id, + run.host.name(), + run.epoch, + run.revision, + run.task.as_deref().unwrap_or("unbound"), + run.checkpoint, + run.branch, + prompt + ); + let argv: Vec = match run.host { + Host::Codex => vec![ + "codex", + "exec", + "--json", + "--color", + "never", + "--sandbox", + if planning { + "read-only" + } else { + "workspace-write" + }, + "-", + ] + .into_iter() + .map(String::from) + .collect(), + Host::ClaudeCode => vec![ + "claude", + "--print", + "--output-format", + "json", + "--permission-mode", + if planning { "plan" } else { "acceptEdits" }, + "--tools", + if planning { + "Read,Glob,Grep" + } else { + "Read,Glob,Grep,Edit,Write,Bash" + }, + "--allowedTools", + if planning { + "Read,Glob,Grep" + } else { + "Read,Glob,Grep,Edit,Write,Bash(git diff *),Bash(git status *),Bash(git add -- *)" + }, + ] + .into_iter() + .map(String::from) + .collect(), + }; + let outcome = process::run(Spec { + argv, + cwd: (&run.workspace).into(), + seconds: policy.command_seconds.min(290), + environment: BTreeMap::new(), + input: Some(prompt), + directory: state.join("process"), + })?; + ensure!( + outcome.success(), + "host failed, timed out, or lost owner; inspect private process evidence and reconcile: {}", + outcome.stderr + ); + match run.host { + Host::ClaudeCode => { + let value: Value = serde_json::from_str(outcome.stdout.trim()) + .context("invalid Claude result; operation remains unresolved")?; + ensure!( + value["is_error"] == false, + "Claude reported execution error: {}", + value["subtype"] + ); + ensure!( + value + .get("permission_denials") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty), + "Claude tool permission denied; operation remains unresolved" + ); + Ok(value["result"] + .as_str() + .context("Claude final result missing")? + .to_string()) + } + Host::Codex => { + let events: Vec = outcome + .stdout + .lines() + .map(serde_json::from_str) + .collect::>()?; + ensure!( + events.iter().any(|e| e["type"] == "turn.completed") + && !events + .iter() + .any(|e| e["type"] == "turn.failed" || e["type"] == "error"), + "Codex turn did not complete" + ); + events + .iter() + .rev() + .find(|e| e["type"] == "item.completed" && e["item"]["type"] == "agent_message") + .and_then(|e| e["item"]["text"].as_str()) + .map(String::from) + .context("Codex final result missing") + } + } +} diff --git a/src/infrastructure/memory.rs b/src/infrastructure/memory.rs new file mode 100644 index 0000000..3c2d80f --- /dev/null +++ b/src/infrastructure/memory.rs @@ -0,0 +1,66 @@ +use allsource_core::prime::Prime; +use anyhow::{Result, ensure}; +use serde_json::{Value, json}; +use std::path::Path; + +pub struct Memory(Prime); +impl Memory { + pub async fn open(path: &Path) -> Result { + let prime = Prime::open(path).await?; + ensure!( + !prime.is_read_only(), + "Prime memory is owned by another writer" + ); + Ok(Self(prime)) + } + pub fn recall(&self, query: &str) -> Value { + let terms: Vec = query + .split_whitespace() + .filter(|s| s.len() > 3) + .take(30) + .map(str::to_lowercase) + .collect(); + let mut nodes: Vec<_> = self + .0 + .nodes_by_type("delivery") + .into_iter() + .filter_map(|node| { + let text = node.properties.to_string().to_lowercase(); + let score = terms.iter().filter(|t| text.contains(t.as_str())).count(); + (score > 0).then_some((score, node)) + }) + .collect(); + nodes.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); + json!({"retrieval":"lexical-prime-nodes", "authority":"context-only", "nodes": nodes.into_iter().take(5).map(|(_,n)| n).collect::>()}) + } + pub async fn record(&self, run: &str, phase: &str, source: &str, summary: &str) -> Result<()> { + let key = format!("{run}:{phase}"); + if self + .0 + .nodes_by_type("delivery") + .iter() + .any(|n| n.properties["key"] == key) + { + return Ok(()); + } + self.0 + .add_node( + "delivery", + json!({"name":format!("{phase} {run}"),"domain":"allstack","key":key, + "source":source,"summary":summary,"run_id":run}), + ) + .await?; + ensure!( + self.0 + .nodes_by_type("delivery") + .iter() + .any(|n| n.properties["key"] == key), + "Prime write not visible" + ); + Ok(()) + } + pub async fn shutdown(self) -> Result<()> { + self.0.shutdown().await?; + Ok(()) + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 0000000..16da922 --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1,83 @@ +pub mod build_lock; +pub mod chronis; +pub mod forge; +pub mod gates; +pub mod git; +pub mod hosts; +pub mod memory; +pub mod process; +pub mod runtime; +pub mod store; +pub mod workspace; + +use anyhow::{Context, Result, ensure}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Write, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, +}; + +pub fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +pub fn private_dir(path: &Path) -> Result<()> { + fs::create_dir_all(path)?; + ensure!( + !fs::symlink_metadata(path)?.file_type().is_symlink(), + "private directory is a symlink" + ); + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().context("file has no parent")?; + private_dir(parent)?; + let tmp = parent.join(format!(".{}", uuid::Uuid::new_v4())); + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&tmp)?; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&tmp, path)?; + fs::File::open(parent)?.sync_all()?; + Ok(()) +} + +#[derive(Clone)] +pub struct Artifacts(pub PathBuf); + +impl Artifacts { + pub fn put(&self, bytes: &[u8]) -> Result { + let key = digest(bytes); + let target = self.0.join(&key); + if target.exists() { + ensure!(self.get(&key)? == bytes, "artifact collision"); + } else { + atomic_write(&target, bytes)?; + } + Ok(key) + } + pub fn get(&self, key: &str) -> Result> { + ensure!( + key.len() == 64 && key.bytes().all(|c| c.is_ascii_hexdigit()), + "invalid artifact digest" + ); + let path = self.0.join(key); + ensure!( + !fs::symlink_metadata(&path) + .context("missing artifact; recovery blocked")? + .file_type() + .is_symlink(), + "artifact symlink refused" + ); + let bytes = fs::read(path).context("missing artifact; recovery blocked")?; + ensure!(digest(&bytes) == key, "artifact digest mismatch"); + Ok(bytes) + } +} diff --git a/src/infrastructure/process.rs b/src/infrastructure/process.rs new file mode 100644 index 0000000..450ba53 --- /dev/null +++ b/src/infrastructure/process.rs @@ -0,0 +1,207 @@ +use super::{atomic_write, private_dir}; +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs, + io::{BufRead, BufReader, Read, Write}, + os::unix::process::CommandExt, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Spec { + pub argv: Vec, + pub cwd: PathBuf, + pub seconds: u64, + pub environment: BTreeMap, + pub input: Option, + pub directory: PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Outcome { + pub code: Option, + pub timed_out: bool, + pub owner_lost: bool, + pub elapsed_ms: u128, + pub stdout: String, + pub stderr: String, +} + +impl Outcome { + pub fn success(&self) -> bool { + self.code == Some(0) && !self.timed_out && !self.owner_lost + } + pub fn checked(self) -> Result { + ensure!( + self.success(), + "command failed (exit {:?}, timeout {}): {}\n{}", + self.code, + self.timed_out, + self.stderr, + self.stdout + ); + Ok(self.stdout) + } +} + +struct Guardian { + child: Child, + input: Option, +} +impl Drop for Guardian { + fn drop(&mut self) { + self.input.take(); + let until = Instant::now() + Duration::from_secs(3); + while Instant::now() < until { + if self.child.try_wait().ok().flatten().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +pub fn run(mut spec: Spec) -> Result { + ensure!( + !spec.argv.is_empty() && (1..=290).contains(&spec.seconds), + "invalid command or deadline (maximum 290 seconds plus cleanup)" + ); + spec.directory = spec.directory.join(uuid::Uuid::new_v4().to_string()); + private_dir(&spec.directory)?; + let child = Command::new(std::env::current_exe()?) + .arg("__supervise") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0) + .spawn()?; + let mut guardian = Guardian { child, input: None }; + guardian.input = guardian.child.stdin.take(); + let input = guardian.input.as_mut().context("missing supervisor pipe")?; + serde_json::to_writer(&mut *input, &spec)?; + input.write_all(b"\n")?; + input.flush()?; + let end = Instant::now() + Duration::from_secs(spec.seconds + 5); + loop { + if guardian.child.try_wait()?.is_some() { + break; + } + ensure!( + Instant::now() < end, + "supervisor exceeded bounded cleanup deadline" + ); + std::thread::sleep(Duration::from_millis(20)); + } + let outcome = fs::read(spec.directory.join("result.json")) + .context("supervisor exited without receipt; external outcome unknown")?; + Ok(serde_json::from_slice(&outcome)?) +} + +struct OwnedChild(Child); +impl Drop for OwnedChild { + fn drop(&mut self) { + unsafe { + libc::kill(-(self.0.id() as i32), libc::SIGKILL); + } + let _ = self.0.wait(); + } +} + +pub fn supervise() -> Result<()> { + let mut owner = BufReader::new(std::io::stdin()); + let mut line = String::new(); + owner.read_line(&mut line)?; + ensure!(line.len() <= 1024 * 1024, "supervisor input too large"); + let spec: Spec = serde_json::from_str(&line)?; + ensure!( + !spec.argv.is_empty() && (1..=290).contains(&spec.seconds), + "invalid supervisor command" + ); + private_dir(&spec.directory)?; + let output = fs::File::create(spec.directory.join("stdout"))?; + let errors = fs::File::create(spec.directory.join("stderr"))?; + let input = if let Some(text) = &spec.input { + let path = spec.directory.join("input"); + atomic_write(&path, text.as_bytes())?; + Stdio::from(fs::File::open(path)?) + } else { + Stdio::null() + }; + let mut command = Command::new(&spec.argv[0]); + command + .args(&spec.argv[1..]) + .current_dir(&spec.cwd) + .envs(&spec.environment) + .stdout(output) + .stderr(errors) + .stdin(input) + .process_group(0); + let start = Instant::now(); + let mut child = OwnedChild(command.spawn()?); + // The pipe belongs to the caller, so caller death revokes the child's lifetime. + unsafe { + libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, libc::O_NONBLOCK); + } + let mut owner_lost = false; + let mut timed_out = false; + let mut code = None; + loop { + if let Some(status) = child.0.try_wait()? { + code = status.code(); + break; + } + let mut byte = [0]; + match owner.read(&mut byte) { + Ok(0) => owner_lost = true, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => owner_lost = true, + _ => {} + } + timed_out = start.elapsed() >= Duration::from_secs(spec.seconds); + let oversized = ["stdout", "stderr"] + .iter() + .any(|n| fs::metadata(spec.directory.join(n)).is_ok_and(|m| m.len() > 4 * 1024 * 1024)); + if owner_lost || timed_out || oversized { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + drop(child); + let read = |name| -> Result { + let mut bytes = Vec::new(); + fs::File::open(spec.directory.join(name))? + .take(4 * 1024 * 1024) + .read_to_end(&mut bytes)?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) + }; + let result = Outcome { + code, + timed_out, + owner_lost, + elapsed_ms: start.elapsed().as_millis(), + stdout: read("stdout")?, + stderr: read("stderr")?, + }; + atomic_write( + &spec.directory.join("result.json"), + &serde_json::to_vec(&result)?, + ) +} + +pub fn checked(root: &Path, cwd: &Path, argv: &[&str]) -> Result { + run(Spec { + argv: argv.iter().map(|s| s.to_string()).collect(), + cwd: cwd.into(), + seconds: 30, + environment: BTreeMap::new(), + input: None, + directory: root.join("process"), + })? + .checked() +} diff --git a/src/infrastructure/runtime.rs b/src/infrastructure/runtime.rs new file mode 100644 index 0000000..9713cc6 --- /dev/null +++ b/src/infrastructure/runtime.rs @@ -0,0 +1,453 @@ +use super::{ + chronis::Tasks, + digest, forge, git, hosts, + memory::Memory, + process::{self, Spec}, + store::Store, + workspace, +}; +use crate::{ + application::ports::{Platform, Start}, + domain::{Policy, Publication, Run, Stage, Verification}, +}; +use anyhow::{Context, Result, ensure}; +use serde_json::{Value, json}; +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, +}; + +pub struct Runtime { + pub repo: PathBuf, + pub state: PathBuf, + pub store: Store, + pub memory: Option, +} + +impl Runtime { + pub async fn open(repo: &Path) -> Result { + let repo = repo.canonicalize()?; + let state = repo.join(".allstack"); + let store = Store::open(&state).await?; + let memory = Memory::open(&state.join("prime")).await.ok(); + Ok(Self { + repo, + state, + store, + memory, + }) + } + fn tasks(&self) -> Tasks<'_> { + Tasks { + state: &self.state, + repo: &self.repo, + } + } + pub async fn shutdown(self) -> Result<()> { + if let Some(memory) = self.memory { + memory.shutdown().await?; + } + self.store.shutdown().await + } + fn policy(&self, run: &Run) -> Result { + Ok(toml::from_str(&String::from_utf8( + self.object(&run.policy)?, + )?)?) + } + fn snapshot(&self, path: &Path) -> Result { + workspace::capture(&self.state, path, &self.store.artifacts) + } + fn environment(&self, policy: &Policy) -> Result { + let mut data = BTreeMap::new(); + data.insert("os".into(), std::env::consts::OS.into()); + data.insert("arch".into(), std::env::consts::ARCH.into()); + data.insert( + "allstack_binary".into(), + digest(&fs::read(std::env::current_exe()?)?), + ); + for name in &policy.environment { + data.insert(name.clone(), std::env::var(name).unwrap_or_default()); + } + for executable in ["git", "rustc", "cargo", "cn"] { + let output = process::checked(&self.state, &self.repo, &[executable, "--version"])?; + data.insert(executable.into(), output); + } + Ok(digest(&serde_json::to_vec(&data)?)) + } + pub fn doctor(&self) -> Value { + let tools: Vec<_> = ["codex", "claude", "cn", "git", "gh"].iter().map(|name| { + let result = process::checked(&self.state, &self.repo, &[name, "--version"]); + json!({"tool":name,"available":result.is_ok(),"version":result.ok().map(|s| s.lines().next().unwrap_or("").to_string())}) + }).collect(); + json!({"repository":self.repo,"store":self.state,"durability":"fsync-per-event", + "coordinator":"exclusive-kernel-lock-per-command","prime":"embedded graph; lexical recall; local-only","prime_available":self.memory.is_some(), + "host_scope":"sequential-same-machine","tools":tools}) + } +} + +impl Platform for Runtime { + async fn load(&self, id: &str) -> Result { + let run = self.store.load(id).await?; + ensure!( + Path::new(&run.repository) == self.repo, + "run repository does not match coordinator scope" + ); + let expected = self + .state + .join("workspaces") + .join(format!("{}-{}", run.id, run.epoch)); + ensure!( + Path::new(&run.workspace) == expected, + "run workspace does not match active epoch scope" + ); + Ok(run) + } + async fn save(&self, run: &mut Run, kind: &str) -> Result<()> { + self.store.save(run, kind).await + } + fn object(&self, key: &str) -> Result> { + self.store.artifacts.get(key) + } + fn put(&self, bytes: &[u8]) -> Result { + self.store.artifacts.put(bytes) + } + fn initialize(&self, input: &Start) -> Result { + git::clean(&self.state, &self.repo)?; + ensure!( + self.repo.join(".chronis").is_dir(), + "initialize project Chronis with cn init --toon first" + ); + let branch = git::call(&self.state, &self.repo, &["branch", "--show-current"])?; + let trunk = git::trunk(&self.state, &self.repo)?; + ensure!( + !branch.is_empty() && branch != trunk, + "start on an existing authorized feature branch" + ); + let remote = git::call(&self.state, &self.repo, &["remote", "get-url", "origin"])?; + let name = forge::repository(&remote)?; + forge::live(&self.state, &self.repo, &name, &branch)?; + let request = fs::read(workspace::contained(&self.repo, &input.request)?)?; + ensure!(request.len() <= 64 * 1024, "request exceeds 64 KiB"); + let policy_bytes = fs::read(workspace::contained(&self.repo, &input.policy)?)?; + let policy: Policy = toml::from_str(std::str::from_utf8(&policy_bytes)?)?; + policy.validate().map_err(anyhow::Error::msg)?; + let workflow_bytes = fs::read(self.repo.join("workflows/delivery.toml"))?; + let definition: toml::Value = toml::from_str(std::str::from_utf8(&workflow_bytes)?)?; + ensure!( + definition["schema_version"].as_integer() == Some(1) + && definition["id"].as_str() == Some("delivery"), + "unsupported workflow schema or method" + ); + let mut methods = BTreeMap::new(); + let mut method_paths = BTreeMap::new(); + for method in ["plan", "build"] { + let path = definition["methods"][method] + .as_str() + .context("workflow method path missing")?; + methods.insert( + method.into(), + self.put(&fs::read(workspace::contained(&self.repo, path)?)?)?, + ); + method_paths.insert(method.into(), path.to_string()); + } + let base = git::call(&self.state, &self.repo, &["rev-parse", "HEAD"])?; + let id = uuid::Uuid::new_v4().to_string(); + let checkpoint = self.snapshot(&self.repo)?; + let workspace = workspace::restore( + &self.state, + &self.repo, + &self.store.artifacts, + &checkpoint, + &id, + 1, + )?; + Ok(Run { + id, + repository: self.repo.to_string_lossy().into(), + remote, + branch, + base, + trunk, + workflow: self.put(&workflow_bytes)?, + methods, + method_paths, + policy: self.put(&policy_bytes)?, + policy_path: input.policy.clone(), + request: self.put(&request)?, + plan: None, + task: None, + host: input.host, + epoch: 1, + stage: Stage::Planning, + workspace: workspace.to_string_lossy().into(), + checkpoint, + pending: None, + transfer: None, + memory_pending: false, + prepared_commit: None, + checkout_snapshot: None, + verification: None, + publication: None, + allow_commit: input.allow_commit, + allow_publish: input.allow_publish, + revision: 0, + }) + } + fn checkpoint(&self, run: &Run) -> Result { + self.snapshot(Path::new(&run.workspace)) + } + fn restore(&self, run: &Run, snapshot: &str, epoch: u64) -> Result { + Ok(workspace::restore( + &self.state, + &self.repo, + &self.store.artifacts, + snapshot, + &run.id, + epoch, + )? + .to_string_lossy() + .into()) + } + fn create_task(&self, run: &Run) -> Result { + self.tasks().create(run) + } + fn claim(&self, run: &Run, previous: Option<&Run>) -> Result<()> { + self.tasks() + .claim(run, previous.map(Tasks::previous_actor).as_deref()) + } + fn claim_readback(&self, run: &Run) -> Result<()> { + self.tasks().ensure_claim(run) + } + fn complete_task(&self, run: &Run) -> Result<()> { + self.tasks().complete(run) + } + fn recall(&self, query: &str) -> Value { + self.memory + .as_ref() + .map(|m| m.recall(query)) + .unwrap_or_else( + || json!({"retrieval":"unavailable","authority":"context-only","nodes":[]}), + ) + } + async fn remember(&self, run: &Run, phase: &str, source: &str, summary: &str) -> Result<()> { + self.memory + .as_ref() + .context("Prime unavailable; run state remains readable and indexing remains pending")? + .record(&run.id, phase, source, summary) + .await + } + fn invoke(&self, run: &Run, prompt: &str, planning: bool) -> Result { + hosts::invoke(&self.state, run, &self.policy(run)?, prompt, planning) + } + fn write_plan(&self, run: &Run, text: &str) -> Result { + workspace::write_plan(&self.state, Path::new(&run.workspace), &run.id, text) + } + fn prepare(&self, run: &Run, message: &str) -> Result { + let branch = git::call(&self.state, &self.repo, &["branch", "--show-current"])?; + ensure!( + branch == run.branch && branch != run.trunk, + "original checkout branch changed" + ); + forge::live( + &self.state, + &self.repo, + &forge::repository(&run.remote)?, + &run.branch, + )?; + let head = git::call(&self.state, &self.repo, &["rev-parse", "HEAD"])?; + let current = self.snapshot(&self.repo)?; + let target = workspace::read_snapshot(&self.store.artifacts, &run.checkpoint)?; + let observed = workspace::read_snapshot(&self.store.artifacts, ¤t)?; + let identical = observed.files == target.files; + let owned_dirty = run + .checkout_snapshot + .as_ref() + .map(|key| workspace::read_snapshot(&self.store.artifacts, key)) + .transpose()? + .is_some_and(|snapshot| snapshot.files == observed.files); + let marker = format!("Allstack-Run: {}", run.id); + let managed = git::call(&self.state, &self.repo, &["log", "-1", "--format=%B"])? + .lines() + .any(|line| line == marker); + ensure!( + head == run.base + || run.prepared_commit.as_ref() == Some(&head) + || (managed && identical), + "original checkout moved outside the run" + ); + if !identical { + if !owned_dirty { + git::clean(&self.state, &self.repo)?; + } + workspace::materialize( + &self.state, + &self.repo, + &self.store.artifacts, + &run.checkpoint, + )?; + } + if head != run.base && identical && managed { + git::clean(&self.state, &self.repo)?; + return Ok(head); + } + let range = format!("origin/{}..HEAD", run.trunk); + let authors = git::call(&self.state, &self.repo, &["log", "--format=%ae", &range])?; + let owner = git::call(&self.state, &self.repo, &["config", "user.email"])?; + ensure!( + authors.lines().all(|a| a == owner), + "branch contains another author's commits" + ); + let count: usize = + git::call(&self.state, &self.repo, &["rev-list", "--count", &range])?.parse()?; + ensure!( + count <= 1, + "collapse owned branch explicitly before starting run" + ); + let changed = git::call( + &self.state, + &self.repo, + &["diff", "HEAD", "--name-only", "-z"], + )?; + let paths: Vec<&str> = changed.split('\0').filter(|s| !s.is_empty()).collect(); + ensure!(!paths.is_empty(), "candidate has no changes to commit"); + for chunk in paths.chunks(100) { + let mut args = vec!["add", "--"]; + args.extend(chunk); + git::call(&self.state, &self.repo, &args)?; + } + git::call(&self.state, &self.repo, &["diff", "--cached", "--check"])?; + let mut args = vec!["commit"]; + if count == 1 { + args.push("--amend"); + } + args.extend(["-m", message, "-m", &marker]); + git::call(&self.state, &self.repo, &args)?; + git::clean(&self.state, &self.repo)?; + git::call(&self.state, &self.repo, &["rev-parse", "HEAD"]) + } + fn candidate(&self, run: &Run) -> Result { + git::clean(&self.state, &self.repo)?; + ensure!( + git::call(&self.state, &self.repo, &["branch", "--show-current"])? == run.branch, + "candidate branch changed" + ); + let snapshot = self.snapshot(&self.repo)?; + ensure!( + workspace::content_digest(&self.store.artifacts, &snapshot)? + == workspace::content_digest(&self.store.artifacts, &run.checkpoint)?, + "candidate content differs from accepted checkpoint" + ); + let workflow = fs::read(self.repo.join("workflows/delivery.toml"))?; + ensure!( + digest(&workflow) == run.workflow, + "workflow definition changed" + ); + for (method, path) in &run.method_paths { + ensure!( + run.methods.get(method) + == Some(&digest(&fs::read(workspace::contained(&self.repo, path)?)?)), + "workflow method changed from accepted scope" + ); + } + ensure!( + digest(&fs::read(workspace::contained( + &self.repo, + &run.policy_path + )?)?) + == run.policy, + "verification policy file changed from accepted scope" + ); + Ok(Verification { + commit: git::call(&self.state, &self.repo, &["rev-parse", "HEAD"])?, + snapshot: workspace::content_digest(&self.store.artifacts, &snapshot)?, + policy: run.policy.clone(), + workflow: run.workflow.clone(), + environment: self.environment(&self.policy(run)?)?, + logs: vec![], + }) + } + fn gates(&self, _run: &Run, policy: &Policy) -> Result> { + let mut logs = Vec::new(); + for gate in &policy.gates { + let _build = if gate.compile { + Some(super::build_lock::acquire(&self.state, &self.repo)?) + } else { + None + }; + let outcome = process::run(Spec { + argv: gate.argv.clone(), + cwd: self.repo.clone(), + seconds: policy.command_seconds.min(290), + environment: BTreeMap::new(), + input: None, + directory: self.state.join("process"), + })?; + let log = self.put(&serde_json::to_vec(&outcome)?)?; + logs.push(log); + ensure!( + outcome.success(), + "gate {} failed; receipt {}", + gate.name, + logs.last().unwrap() + ); + if let Some(marker) = &gate.success_marker { + ensure!( + outcome.stdout.contains(marker) || outcome.stderr.contains(marker), + "gate {} missing execution marker", + gate.name + ); + } + if gate.test { + let text = format!("{}\n{}", outcome.stdout, outcome.stderr); + ensure!( + super::gates::executed_tests(&text) > 0, + "zero-test or unsupported test-runner output is inconclusive; use Cargo or nextest with retained execution summary" + ); + } + } + Ok(logs) + } + fn publish(&self, run: &Run, title: &str) -> Result { + forge::publish(&self.state, run, title) + } + async fn cleanup(&self, run: &Run) -> Result<()> { + let mut workspaces = BTreeMap::new(); + for event in self.store.history(&run.id).await? { + workspaces.insert(event.run.workspace, event.run.checkpoint); + } + for (path, key) in workspaces { + let path = Path::new(&path); + if !path.exists() { + continue; + } + let parent = self.state.join("workspaces").canonicalize()?; + ensure!( + path.canonicalize()?.parent() == Some(parent.as_path()) + && path + .file_name() + .and_then(|p| p.to_str()) + .is_some_and(|p| p.starts_with(&format!("{}-", run.id))), + "cleanup path is outside owned run workspaces" + ); + let actual = workspace::read_snapshot(&self.store.artifacts, &self.snapshot(path)?)?; + let accepted = workspace::read_snapshot(&self.store.artifacts, &key)?; + ensure!( + actual == accepted, + "workspace has unexpected edits; preserve and inspect before cleanup: {}", + path.display() + ); + git::call( + &self.state, + &self.repo, + &[ + "worktree", + "remove", + "--force", + path.to_str().context("non-UTF8 workspace")?, + ], + )?; + } + Ok(()) + } +} diff --git a/src/infrastructure/store.rs b/src/infrastructure/store.rs new file mode 100644 index 0000000..658896e --- /dev/null +++ b/src/infrastructure/store.rs @@ -0,0 +1,145 @@ +use super::{Artifacts, digest, private_dir}; +use crate::domain::{Event, Run, validate_transition}; +use allsource_core::embedded::{Config, EmbeddedCore, IngestEvent, Query}; +use anyhow::{Context, Result, ensure}; +use std::{ + collections::HashSet, + fs::{File, OpenOptions}, + os::unix::fs::OpenOptionsExt, + path::Path, +}; + +pub struct Store { + core: EmbeddedCore, + _lock: File, + pub artifacts: Artifacts, +} + +impl Store { + pub async fn open(root: &Path) -> Result { + private_dir(root)?; + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .mode(0o600) + .open(root.join("coordinator.lock"))?; + lock.try_lock() + .context("run coordinator busy; another operation owns this store")?; + let core = EmbeddedCore::open( + Config::builder() + .data_dir(root.join("events")) + .wal_sync_on_write(true) + .build()?, + ) + .await?; + ensure!( + core.durability_status().wal_enabled, + "durable WAL is required" + ); + Ok(Self { + core, + _lock: lock, + artifacts: Artifacts(root.join("objects")), + }) + } + + pub async fn history(&self, id: &str) -> Result> { + uuid::Uuid::parse_str(id).context("invalid run id")?; + let entity = format!("allstack-run-{id}"); + let page = self + .core + .query_page(Query::new().entity_id(&entity).limit(1001)) + .await?; + ensure!( + !page.has_more && page.total_count == page.events.len() && page.events.len() <= 1000, + "incomplete or oversized run history" + ); + let mut events = Vec::new(); + let mut ids = HashSet::new(); + for raw in page.events { + ensure!( + raw.entity_id == entity && raw.tenant_id == "default" && ids.insert(raw.id), + "invalid source binding" + ); + let event: Event = serde_json::from_value(raw.payload)?; + ensure!( + event.run.id == id + && raw.event_type == format!("allstack.workflow.v1.{}", event.kind), + "invalid event binding" + ); + events.push(event); + } + events.sort_by_key(|e| e.run.revision); + let mut previous = String::new(); + let mut prior = None; + let mut operations = HashSet::new(); + for event in &events { + ensure!( + event.previous == previous && operations.insert(&event.operation), + "broken event chain or duplicate operation" + ); + validate_transition(prior, event).map_err(anyhow::Error::msg)?; + previous = digest(&serde_json::to_vec(event)?); + prior = Some(&event.run); + } + Ok(events) + } + + pub async fn load(&self, id: &str) -> Result { + self.history(id) + .await? + .pop() + .map(|e| e.run) + .context("run not found") + } + + pub async fn save(&self, run: &mut Run, kind: &str) -> Result<()> { + let events = self.history(&run.id).await?; + let last = events.last(); + ensure!( + last.map_or(0, |e| e.run.revision) == run.revision, + "stale run revision" + ); + let mut next = run.clone(); + next.revision += 1; + let event = Event { + schema: 1, + operation: uuid::Uuid::new_v4().to_string(), + previous: last + .map(|e| serde_json::to_vec(e).map(|b| digest(&b))) + .transpose()? + .unwrap_or_default(), + kind: kind.into(), + run: next, + }; + validate_transition(last.map(|e| &e.run), &event).map_err(anyhow::Error::msg)?; + self.core + .ingest(IngestEvent { + entity_id: &format!("allstack-run-{}", run.id), + event_type: &format!("allstack.workflow.v1.{kind}"), + payload: serde_json::to_value(&event)?, + metadata: None, + tenant_id: None, + }) + .await + .context("append outcome uncertain; inspect run before retry")?; + let actual = self + .history(&run.id) + .await? + .pop() + .context("append missing after acknowledgement")?; + ensure!( + digest(&serde_json::to_vec(&actual)?) == digest(&serde_json::to_vec(&event)?), + "append acknowledgement mismatch" + ); + *run = actual.run; + Ok(()) + } + + pub async fn shutdown(&self) -> Result<()> { + self.core.shutdown().await?; + Ok(()) + } +} diff --git a/src/infrastructure/workspace.rs b/src/infrastructure/workspace.rs new file mode 100644 index 0000000..a089d70 --- /dev/null +++ b/src/infrastructure/workspace.rs @@ -0,0 +1,212 @@ +use super::{Artifacts, digest, git, private_dir}; +use crate::domain::{FileEntry, Snapshot}; +use anyhow::{Context, Result, ensure}; +use std::{ + collections::BTreeMap, + fs, + os::unix::fs::PermissionsExt, + path::{Component, Path, PathBuf}, +}; + +pub fn contained(root: &Path, relative: &str) -> Result { + let path = Path::new(relative); + ensure!( + !path.as_os_str().is_empty() + && path.components().all(|c| matches!(c, Component::Normal(_))), + "unsafe relative path" + ); + ensure!( + !path + .components() + .any(|c| c.as_os_str() == ".git" || c.as_os_str() == ".allstack"), + "reserved workspace path" + ); + let mut current = root.to_path_buf(); + for part in path.components() { + current.push(part); + if let Ok(meta) = fs::symlink_metadata(¤t) { + ensure!( + !meta.file_type().is_symlink(), + "symlink inputs are unsupported: {relative}" + ); + } + } + Ok(current) +} + +fn manifest(state: &Path, cwd: &Path) -> Result, bool)>> { + let tracked = git::call(state, cwd, &["ls-files", "-z"])?; + let others = git::call( + state, + cwd, + &["ls-files", "--others", "--exclude-standard", "-z"], + )?; + ensure!( + others.is_empty(), + "untracked files require explicit git add before checkpoint; never snapshot them implicitly" + ); + let mut result = BTreeMap::new(); + let mut total = 0; + for name in tracked.split('\0').filter(|s| !s.is_empty()) { + let lower = name.to_lowercase(); + ensure!( + !lower.split('/').any(|p| p == ".env" + || p.starts_with(".env.") + || p == "credentials.json" + || p == "id_rsa" + || p.ends_with(".pem")), + "sensitive path requires removal from tracked snapshot: {name}" + ); + let path = contained(cwd, name)?; + if !path.exists() { + continue; + } + let meta = fs::metadata(&path)?; + ensure!( + meta.is_file() && meta.len() <= 4 * 1024 * 1024, + "unsupported file or file exceeds 4 MiB: {name}" + ); + total += meta.len(); + ensure!( + total <= 64 * 1024 * 1024 && result.len() < 10000, + "snapshot exceeds supported bounds" + ); + result.insert( + name.to_string(), + (fs::read(path)?, meta.permissions().mode() & 0o111 != 0), + ); + } + Ok(result) +} + +pub fn capture(state: &Path, cwd: &Path, artifacts: &Artifacts) -> Result { + let base = git::call(state, cwd, &["rev-parse", "HEAD"])?; + let before = manifest(state, cwd)?; + let files = before + .iter() + .map(|(name, (bytes, executable))| { + Ok(( + name.clone(), + FileEntry { + digest: artifacts.put(bytes)?, + executable: *executable, + }, + )) + }) + .collect::>>()?; + ensure!( + manifest(state, cwd)? == before && git::call(state, cwd, &["rev-parse", "HEAD"])? == base, + "workspace changed during checkpoint" + ); + artifacts.put(&serde_json::to_vec(&Snapshot { base, files })?) +} + +pub fn read_snapshot(artifacts: &Artifacts, key: &str) -> Result { + Ok(serde_json::from_slice(&artifacts.get(key)?)?) +} + +pub fn materialize(state: &Path, cwd: &Path, artifacts: &Artifacts, key: &str) -> Result<()> { + let snapshot = read_snapshot(artifacts, key)?; + let mut bytes = BTreeMap::new(); + for (name, entry) in &snapshot.files { + contained(cwd, name)?; + bytes.insert(name, artifacts.get(&entry.digest)?); + } + let tracked = git::call(state, cwd, &["ls-files", "-z"])?; + for name in tracked.split('\0').filter(|s| !s.is_empty()) { + if !snapshot.files.contains_key(name) { + let target = contained(cwd, name)?; + if target.exists() { + fs::remove_file(target)?; + } + } + } + for (name, data) in bytes { + let target = contained(cwd, name)?; + fs::create_dir_all(target.parent().context("missing file parent")?)?; + fs::write(&target, data)?; + fs::set_permissions( + &target, + fs::Permissions::from_mode(if snapshot.files[name].executable { + 0o755 + } else { + 0o644 + }), + )?; + } + // Restored files must enter the index so later snapshots include new source. + let names: Vec<&str> = snapshot.files.keys().map(String::as_str).collect(); + if !names.is_empty() { + let mut args = vec!["add", "--"]; + args.extend(names); + git::call(state, cwd, &args)?; + } + let check = capture(state, cwd, artifacts)?; + let actual = read_snapshot(artifacts, &check)?; + ensure!(actual.files == snapshot.files, "restored snapshot differs"); + Ok(()) +} + +pub fn restore( + state: &Path, + repo: &Path, + artifacts: &Artifacts, + key: &str, + run: &str, + epoch: u64, +) -> Result { + let snapshot = read_snapshot(artifacts, key)?; + let path = state.join("workspaces").join(format!("{run}-{epoch}")); + private_dir(path.parent().context("workspace parent missing")?)?; + if path.exists() { + let actual = read_snapshot(artifacts, &capture(state, &path, artifacts)?)?; + ensure!( + actual.files == snapshot.files && actual.base == snapshot.base, + "interrupted handoff workspace differs; inspect before recovery" + ); + return Ok(path); + } + git::call( + state, + repo, + &[ + "worktree", + "add", + "--detach", + path.to_str().context("non-UTF8 workspace")?, + &snapshot.base, + ], + )?; + materialize(state, &path, artifacts, key)?; + Ok(path) +} + +pub fn content_digest(artifacts: &Artifacts, key: &str) -> Result { + let snapshot = read_snapshot(artifacts, key)?; + Ok(digest(&serde_json::to_vec(&snapshot.files)?)) +} + +pub fn write_plan(state: &Path, cwd: &Path, run: &str, text: &str) -> Result { + ensure!( + cwd.join("docs/README.md").is_file(), + "target needs indexed docs/README.md before planning" + ); + let name = format!("docs/plans/allstack-{run}.md"); + let target = contained(cwd, &name)?; + fs::create_dir_all(target.parent().context("missing plan parent")?)?; + let document = text + .lines() + .map(|line| if line.trim().is_empty() { "" } else { line }) + .collect::>() + .join("\n") + + "\n"; + fs::write(target, document)?; + let index = contained(cwd, "docs/README.md")?; + let mut body = fs::read_to_string(&index)?; + body.push_str(&format!( + "\n- [Allstack delivery plan {run}](plans/allstack-{run}.md)\n" + )); + fs::write(index, body)?; + git::call(state, cwd, &["add", "--", &name, "docs/README.md"])?; + Ok(name) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..de226b8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod application; +pub mod domain; +pub mod infrastructure; +pub mod presentation; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0041429 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,58 @@ +use allstack::infrastructure::{ + git, + process::{self, Spec}, +}; +use clap::Parser; +use std::collections::BTreeMap; + +#[tokio::main] +async fn main() { + let arguments: Vec = std::env::args().collect(); + let result = match arguments.get(1).map(String::as_str) { + Some("__supervise") => process::supervise().map(|_| serde_json::Value::Null), + Some("__operation") => { + let mut child_arguments = arguments.clone(); + child_arguments.remove(1); + allstack::presentation::execute(allstack::presentation::Cli::parse_from( + child_arguments, + )) + .await + } + _ => { + let cli = allstack::presentation::Cli::parse(); + (|| -> anyhow::Result { + let repo = git::root(&cli.repository)?; + let mut argv = vec![ + std::env::current_exe()?.to_string_lossy().into_owned(), + "__operation".into(), + ]; + argv.extend(arguments.into_iter().skip(1)); + let output = process::run(Spec { + argv, + cwd: std::env::current_dir()?, + seconds: 290, + environment: BTreeMap::new(), + input: None, + directory: repo.join(".allstack/process"), + })?; + anyhow::ensure!( + output.success(), + "operation failed or exceeded its deadline: {}", + output.stderr + ); + Ok(serde_json::from_str(&output.stdout)?) + })() + } + }; + match result { + Ok(value) if !value.is_null() => println!( + "{}", + serde_json::to_string_pretty(&value).expect("JSON result") + ), + Ok(_) => {} + Err(error) => { + eprintln!("{}", serde_json::json!({"error":format!("{error:#}")})); + std::process::exit(1); + } + } +} diff --git a/src/presentation.rs b/src/presentation.rs new file mode 100644 index 0000000..da12e13 --- /dev/null +++ b/src/presentation.rs @@ -0,0 +1,182 @@ +use crate::{ + application::{ + self, + ports::{Platform, Start}, + }, + domain::Host, + infrastructure::{git, process, runtime::Runtime}, +}; +use anyhow::{Context, Result, ensure}; +use clap::{Parser, Subcommand}; +use serde_json::{Value, json}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(version, about = "Durable cross-host delivery workflows")] +pub struct Cli { + #[arg(short = 'C', long, global = true, default_value = ".")] + pub repository: PathBuf, + #[command(subcommand)] + pub command: Action, +} + +#[derive(Subcommand)] +pub enum Action { + Doctor, + Start { + #[arg(long)] + request: String, + #[arg(long)] + policy: String, + #[arg(long, default_value = "codex")] + host: Host, + #[arg(long)] + allow_commit: bool, + #[arg(long)] + allow_publish: bool, + }, + Plan { + run: String, + #[arg(long)] + epoch: u64, + }, + Build { + run: String, + #[arg(long)] + epoch: u64, + }, + Handoff { + run: String, + #[arg(long)] + epoch: u64, + #[arg(long)] + to: Host, + }, + Checkpoint { + run: String, + #[arg(long)] + epoch: u64, + }, + Recover { + run: String, + #[arg(long)] + epoch: u64, + }, + Resume { + run: String, + #[arg(long)] + epoch: u64, + }, + Verify { + run: String, + #[arg(long)] + epoch: u64, + #[arg(long)] + message: String, + }, + Ship { + run: String, + #[arg(long)] + epoch: u64, + #[arg(long)] + title: String, + }, + Status { + run: String, + }, + History { + run: String, + }, + Evidence { + run: String, + }, + Recall { + query: String, + }, + #[command(name = "__supervise", hide = true)] + Supervise, +} + +pub async fn execute(cli: Cli) -> Result { + let repo = git::root(&cli.repository)?; + let runtime = Runtime::open(&repo).await?; + let result = dispatch(&runtime, cli.command).await; + let shutdown = runtime.shutdown().await; + let value = result?; + shutdown?; + Ok(value) +} + +async fn dispatch(p: &Runtime, action: Action) -> Result { + let run = match action { + Action::Doctor => return Ok(p.doctor()), + Action::Start { + request, + policy, + host, + allow_commit, + allow_publish, + } => { + application::start( + p, + Start { + request, + policy, + host, + allow_commit, + allow_publish, + }, + ) + .await? + } + Action::Plan { run, epoch } => application::plan(p, &run, epoch).await?, + Action::Build { run, epoch } => application::build(p, &run, epoch).await?, + Action::Handoff { run, epoch, to } => application::handoff(p, &run, epoch, to).await?, + Action::Checkpoint { run, epoch } => application::checkpoint(p, &run, epoch).await?, + Action::Recover { run, epoch } => application::recover(p, &run, epoch).await?, + Action::Verify { + run, + epoch, + message, + } => application::verify(p, &run, epoch, &message).await?, + Action::Ship { run, epoch, title } => application::ship(p, &run, epoch, &title).await?, + Action::Status { run } => p.load(&run).await?, + Action::History { run } => return Ok(serde_json::to_value(p.store.history(&run).await?)?), + Action::Evidence { run } => { + let run = p.load(&run).await?; + let logs = run + .verification + .as_ref() + .map(|v| v.logs.as_slice()) + .unwrap_or_default(); + let receipts = logs.iter().map(|key| { + Ok(json!({"digest":key,"receipt":serde_json::from_slice::(&p.object(key)?)?})) + }).collect::>>()?; + return Ok(json!({"run":run,"gate_receipts":receipts})); + } + Action::Recall { query } => return Ok(p.recall(&query)), + Action::Resume { run, epoch } => { + let run = p.load(&run).await?; + run.check_epoch(epoch).map_err(anyhow::Error::msg)?; + ensure!( + run.pending.is_none(), + "run has unresolved operation; inspect and recover before executing" + ); + p.claim_readback(&run)?; + let request = String::from_utf8(p.object(&run.request)?)?; + let plan = run + .plan + .as_ref() + .map(|key| p.object(key).and_then(|b| Ok(String::from_utf8(b)?))) + .transpose()?; + return Ok( + json!({"run":run,"request":request,"plan":plan,"recall":p.recall(&request)}), + ); + } + Action::Supervise => { + process::supervise().context("supervisor failed")?; + return Ok(Value::Null); + } + }; + Ok(serde_json::to_value(run)?) +} diff --git a/tests/application.rs b/tests/application.rs new file mode 100644 index 0000000..f9ada3d --- /dev/null +++ b/tests/application.rs @@ -0,0 +1,324 @@ +mod common; +use allstack::{ + application::{ + self, + ports::{Platform, Start}, + }, + domain::{Host, Policy, Publication, Run, Verification}, +}; +use anyhow::{Result, ensure}; +use serde_json::{Value, json}; +use std::cell::{Cell, RefCell}; + +struct Fake { + state: RefCell, + calls: RefCell>, + fail_claim: Cell, + fail_completion: Cell, + fail_memory: Cell, + fail_readback: Cell, + published: Cell, + candidate: RefCell, +} + +impl Fake { + fn new() -> Self { + Self { + state: RefCell::new(common::run()), + calls: RefCell::new(vec![]), + fail_claim: Cell::new(false), + fail_completion: Cell::new(false), + fail_memory: Cell::new(false), + fail_readback: Cell::new(false), + published: Cell::new(false), + candidate: RefCell::new("commit-one".into()), + } + } +} + +impl Platform for Fake { + async fn load(&self, _: &str) -> Result { + Ok(self.state.borrow().clone()) + } + async fn save(&self, run: &mut Run, kind: &str) -> Result<()> { + run.revision += 1; + *self.state.borrow_mut() = run.clone(); + self.calls.borrow_mut().push(kind.into()); + Ok(()) + } + fn initialize(&self, _: &Start) -> Result { + Ok(common::run()) + } + fn object(&self, key: &str) -> Result> { + Ok(if key == "policy" { + b"command_seconds=10\n[[gates]]\nname='real tests'\nargv=['cargo','test','--lib']\ntest=true\nsuccess_marker='1 passed'\n".to_vec() + } else { + b"request and plan".to_vec() + }) + } + fn put(&self, _: &[u8]) -> Result { + Ok("plan".into()) + } + fn checkpoint(&self, _: &Run) -> Result { + Ok("snapshot".into()) + } + fn restore(&self, _: &Run, _: &str, epoch: u64) -> Result { + Ok(format!("/workspace-{epoch}")) + } + fn create_task(&self, _: &Run) -> Result { + Ok("task-reference".into()) + } + fn claim(&self, _: &Run, _: Option<&Run>) -> Result<()> { + ensure!( + !self.fail_claim.replace(false), + "simulated lost task acknowledgement" + ); + Ok(()) + } + fn claim_readback(&self, _: &Run) -> Result<()> { + ensure!(!self.fail_readback.get(), "Chronis claimant changed"); + Ok(()) + } + fn complete_task(&self, _: &Run) -> Result<()> { + ensure!( + !self.fail_completion.replace(false), + "task store unavailable" + ); + Ok(()) + } + fn recall(&self, _: &str) -> Value { + json!({"authority":"context-only"}) + } + async fn remember(&self, _: &Run, _: &str, _: &str, _: &str) -> Result<()> { + ensure!(!self.fail_memory.replace(false), "memory unavailable"); + Ok(()) + } + fn invoke(&self, run: &Run, _: &str, planning: bool) -> Result { + self.calls + .borrow_mut() + .push(format!("host:{}:{planning}", run.host.name())); + Ok("plan or completed build".into()) + } + fn write_plan(&self, _: &Run, _: &str) -> Result { + Ok("docs/plans/plan.md".into()) + } + fn prepare(&self, _: &Run, _: &str) -> Result { + Ok(self.candidate.borrow().clone()) + } + fn candidate(&self, _: &Run) -> Result { + Ok(Verification { + commit: self.candidate.borrow().clone(), + snapshot: "snapshot".into(), + policy: "policy".into(), + workflow: "workflow".into(), + environment: "environment".into(), + logs: vec![], + }) + } + fn gates(&self, _: &Run, _: &Policy) -> Result> { + Ok(vec!["executed-receipt".into()]) + } + fn publish(&self, run: &Run, _: &str) -> Result { + if !self.published.replace(true) { + self.calls.borrow_mut().push("push-once".into()); + } + Ok(Publication { + commit: self.candidate.borrow().clone(), + remote: run.remote.clone(), + branch: run.branch.clone(), + pr: "https://github.com/example/project/pull/1".into(), + task_completed: false, + memory_indexed: false, + }) + } + async fn cleanup(&self, _: &Run) -> Result<()> { + self.calls.borrow_mut().push("cleanup".into()); + Ok(()) + } +} + +async fn build_flow(p: &Fake) -> Run { + let run = application::start( + p, + Start { + request: "request".into(), + policy: "policy".into(), + host: Host::Codex, + allow_commit: true, + allow_publish: true, + }, + ) + .await + .unwrap(); + application::plan(p, &run.id, 1).await.unwrap(); + application::handoff(p, &run.id, 1, Host::ClaudeCode) + .await + .unwrap(); + application::build(p, &run.id, 2).await.unwrap(); + application::handoff(p, &run.id, 2, Host::Codex) + .await + .unwrap() +} + +#[tokio::test] +async fn full_flow_preserves_host_sequence_and_reconciles_after_publication() { + let p = Fake::new(); + let run = build_flow(&p).await; + application::verify(&p, &run.id, 3, "feat(example): implement accepted change") + .await + .unwrap(); + p.fail_completion.set(true); + assert!( + application::ship(&p, &run.id, 3, "feat(example): implement accepted change") + .await + .is_err() + ); + let shipped = application::ship(&p, &run.id, 3, "feat(example): implement accepted change") + .await + .unwrap(); + assert_eq!(shipped.stage, allstack::domain::Stage::Shipped); + assert!(shipped.publication.unwrap().memory_indexed); + assert_eq!( + p.calls + .borrow() + .iter() + .filter(|s| *s == "push-once") + .count(), + 1 + ); + assert!(p.calls.borrow().contains(&"host:codex:true".into())); + assert!(p.calls.borrow().contains(&"host:claude-code:false".into())); +} + +#[tokio::test] +async fn failed_task_transfer_can_resume_without_reinvoking_host() { + let p = Fake::new(); + let run = application::start( + &p, + Start { + request: "request".into(), + policy: "policy".into(), + host: Host::Codex, + allow_commit: true, + allow_publish: true, + }, + ) + .await + .unwrap(); + application::plan(&p, &run.id, 1).await.unwrap(); + p.fail_claim.set(true); + assert!( + application::handoff(&p, &run.id, 1, Host::ClaudeCode) + .await + .is_err() + ); + let recovered = application::recover(&p, &run.id, 2).await.unwrap(); + assert_eq!(recovered.host, Host::ClaudeCode); + assert!(recovered.pending.is_none()); + assert!(application::build(&p, &run.id, 1).await.is_err()); + assert_eq!( + p.calls + .borrow() + .iter() + .filter(|s| s.starts_with("host:")) + .count(), + 1 + ); +} + +#[tokio::test] +async fn changed_candidate_cannot_reuse_verification() { + let p = Fake::new(); + let run = build_flow(&p).await; + application::verify(&p, &run.id, 3, "feat(example): accepted change") + .await + .unwrap(); + *p.candidate.borrow_mut() = "different-commit".into(); + assert!( + application::ship(&p, &run.id, 3, "feat(example): accepted change") + .await + .is_err() + ); + assert!(!p.published.get()); +} + +#[tokio::test] +async fn memory_outage_keeps_plan_authoritative_and_retries_indexing() { + let p = Fake::new(); + p.fail_memory.set(true); + let run = application::plan(&p, &common::run().id, 1).await.unwrap(); + assert!(run.memory_pending); + assert!(run.plan.is_some()); + assert_eq!(run.stage, allstack::domain::Stage::Building); + let recovered = application::recover(&p, &run.id, 1).await.unwrap(); + assert!(!recovered.memory_pending); + assert_eq!( + p.calls + .borrow() + .iter() + .filter(|s| s.starts_with("host:")) + .count(), + 1 + ); +} + +#[tokio::test] +async fn reverification_replaces_receipt_and_cleanup_requires_current_epoch() { + let p = Fake::new(); + let run = build_flow(&p).await; + let verified = application::verify(&p, &run.id, 3, "feat(example): accepted change") + .await + .unwrap(); + assert_eq!(verified.prepared_commit.as_deref(), Some("commit-one")); + application::checkpoint(&p, &run.id, 3).await.unwrap(); + *p.candidate.borrow_mut() = "commit-two".into(); + application::verify(&p, &run.id, 3, "feat(example): changed candidate") + .await + .unwrap(); + let shipped = application::ship(&p, &run.id, 3, "feat(example): changed candidate") + .await + .unwrap(); + assert_eq!(shipped.verification.unwrap().commit, "commit-two"); + assert!(application::ship(&p, &run.id, 2, "stale").await.is_err()); + application::ship(&p, &run.id, 3, "idempotent") + .await + .unwrap(); + assert_eq!( + p.calls + .borrow() + .iter() + .filter(|s| s.as_str() == "cleanup") + .count(), + 2 + ); +} + +#[tokio::test] +async fn failed_commit_can_accept_explicit_checkpoint_repair() { + let p = Fake::new(); + let run = build_flow(&p).await; + p.state.borrow_mut().pending = Some("commit".into()); + let repaired = application::checkpoint(&p, &run.id, 3).await.unwrap(); + assert!(repaired.pending.is_none()); + assert_eq!(repaired.checkout_snapshot.as_deref(), Some("snapshot")); + application::verify(&p, &run.id, 3, "feat(example): repair candidate") + .await + .unwrap(); + assert!(p.state.borrow().verification.is_some()); +} + +#[tokio::test] +async fn changed_task_claim_blocks_first_publication() { + let p = Fake::new(); + let run = build_flow(&p).await; + application::verify(&p, &run.id, 3, "feat(example): accepted change") + .await + .unwrap(); + p.fail_readback.set(true); + assert!( + application::ship(&p, &run.id, 3, "feat(example): accepted change") + .await + .is_err() + ); + assert!(!p.published.get()); +} diff --git a/tests/architecture.rs b/tests/architecture.rs new file mode 100644 index 0000000..ea03a0d --- /dev/null +++ b/tests/architecture.rs @@ -0,0 +1,68 @@ +use std::{fs, path::Path}; + +#[test] +fn dependency_direction_and_module_sizes_are_enforced() { + fn walk(path: &Path) { + for entry in fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path); + continue; + } + if path.extension().is_none_or(|e| e != "rs") { + continue; + } + let source = fs::read_to_string(&path).unwrap(); + assert!( + source.lines().count() <= 500, + "module exceeds 500 lines: {}", + path.display() + ); + assert!( + !source.contains("#[cfg(test)]"), + "inline test module: {}", + path.display() + ); + if path.ends_with("domain.rs") { + for forbidden in [ + "infrastructure", + "application", + "presentation", + "clap::", + "std::fs", + ] { + assert!(!source.contains(forbidden), "domain imports {forbidden}"); + } + } + if path.components().any(|c| c.as_os_str() == "application") { + for forbidden in [ + "crate::infrastructure", + "crate::presentation", + "std::fs", + "std::process::Command", + ] { + assert!( + !source.contains(forbidden), + "application crosses boundary: {forbidden}" + ); + } + } + } + } + walk(Path::new(env!("CARGO_MANIFEST_DIR")).join("src").as_path()); +} + +#[test] +fn empty_test_units_do_not_hide_positive_execution_or_count_as_tests() { + let count = allstack::infrastructure::gates::executed_tests; + assert_eq!(count("test result: ok. 0 passed; 0 failed"), 0); + assert_eq!( + count("test result: ok. 0 passed; 0 failed\ntest result: ok. 3 passed; 0 failed"), + 3 + ); + assert_eq!(count("Summary [0.2s] 5 tests run: 5 passed, 0 skipped"), 5); + assert_eq!( + count("No tests found\n1 passed marker without runner summary"), + 0 + ); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..3c5d5c0 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,44 @@ +#![allow(dead_code)] +use allstack::domain::{Host, Run, Stage}; + +pub fn run() -> Run { + Run { + id: uuid::Uuid::new_v4().to_string(), + repository: "/fixture".into(), + remote: "https://github.com/example/project.git".into(), + branch: "feature".into(), + base: "base".into(), + trunk: "main".into(), + workflow: "workflow".into(), + methods: [ + ("plan".into(), "plan-method".into()), + ("build".into(), "build-method".into()), + ] + .into(), + method_paths: [ + ("plan".into(), "workflows/methods/plan.md".into()), + ("build".into(), "workflows/methods/build.md".into()), + ] + .into(), + policy: "policy".into(), + policy_path: "policy.toml".into(), + request: "request".into(), + plan: None, + task: Some("task-reference".into()), + host: Host::Codex, + epoch: 1, + stage: Stage::Planning, + workspace: "/workspace-1".into(), + checkpoint: "snapshot".into(), + pending: None, + transfer: None, + memory_pending: false, + prepared_commit: None, + checkout_snapshot: None, + verification: None, + publication: None, + allow_commit: true, + allow_publish: true, + revision: 0, + } +} diff --git a/tests/domain.rs b/tests/domain.rs new file mode 100644 index 0000000..225b19f --- /dev/null +++ b/tests/domain.rs @@ -0,0 +1,72 @@ +mod common; +use allstack::domain::{Event, Host, Policy, Stage, validate_transition}; + +#[test] +fn stale_host_and_unknown_action_cannot_execute() { + let mut run = common::run(); + run.epoch = 2; + assert!(run.check_epoch(1).unwrap_err().contains("stale host")); + run.pending = Some("publication".into()); + assert!(run.require_resolved().is_err()); +} + +#[test] +fn event_sequence_and_scope_are_immutable() { + let mut run = common::run(); + run.revision = 1; + let event = Event { + schema: 1, + operation: "one".into(), + previous: String::new(), + kind: "started".into(), + run: run.clone(), + }; + validate_transition(None, &event).unwrap(); + let mut next = event.clone(); + next.run.revision = 3; + assert!(validate_transition(Some(&run), &next).is_err()); + next.run.revision = 2; + next.run.remote = "https://github.com/other/project.git".into(); + assert!(validate_transition(Some(&run), &next).is_err()); +} + +#[test] +fn cannot_skip_verification_or_invent_delivery() { + let mut old = common::run(); + old.revision = 1; + old.stage = Stage::Building; + let mut next = old.clone(); + next.revision = 2; + next.stage = Stage::Shipped; + let event = Event { + schema: 1, + operation: "two".into(), + previous: "hash".into(), + kind: "shipped".into(), + run: next, + }; + assert!(validate_transition(Some(&old), &event).is_err()); +} + +#[test] +fn policy_requires_execution_evidence_and_bounded_commands() { + let mut p: Policy = toml::from_str( + "command_seconds=301\n[[gates]]\nname='test'\nargv=['cargo','test']\ntest=true\n", + ) + .unwrap(); + assert!(p.validate().is_err()); + p.command_seconds = 30; + assert!(p.validate().is_err()); + p.gates[0].success_marker = Some("1 passed".into()); + p.validate().unwrap(); + assert_eq!("claude-code".parse::().unwrap(), Host::ClaudeCode); +} + +#[test] +fn snapshot_paths_cannot_escape_or_traverse_symlinks() { + let dir = tempfile::tempdir().unwrap(); + assert!(allstack::infrastructure::workspace::contained(dir.path(), "../outside").is_err()); + assert!(allstack::infrastructure::workspace::contained(dir.path(), ".git/config").is_err()); + std::os::unix::fs::symlink("/tmp", dir.path().join("link")).unwrap(); + assert!(allstack::infrastructure::workspace::contained(dir.path(), "link/secret").is_err()); +} diff --git a/tests/process.rs b/tests/process.rs new file mode 100644 index 0000000..d21d2fd --- /dev/null +++ b/tests/process.rs @@ -0,0 +1,100 @@ +use allstack::infrastructure::process::{Outcome, Spec}; +use std::{ + collections::BTreeMap, + fs, + io::Write, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +struct Owned(Child); +impl Drop for Owned { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn start(dir: &std::path::Path, argv: Vec, seconds: u64) -> Owned { + let mut child = Owned( + Command::new(env!("CARGO_BIN_EXE_allstack")) + .arg("__supervise") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let spec = Spec { + argv, + cwd: dir.into(), + seconds, + environment: BTreeMap::new(), + input: None, + directory: dir.into(), + }; + let pipe = child.0.stdin.as_mut().unwrap(); + serde_json::to_writer(&mut *pipe, &spec).unwrap(); + pipe.write_all(b"\n").unwrap(); + pipe.flush().unwrap(); + child +} + +fn result(child: &mut Owned, dir: &std::path::Path) -> Outcome { + let until = Instant::now() + Duration::from_secs(10); + while child.0.try_wait().unwrap().is_none() { + assert!( + Instant::now() < until, + "supervisor did not terminate within test deadline" + ); + std::thread::sleep(Duration::from_millis(10)); + } + serde_json::from_slice(&fs::read(dir.join("result.json")).unwrap()).unwrap() +} + +#[test] +fn command_result_and_deadline_are_real_process_observations() { + let dir = tempfile::tempdir().unwrap(); + let mut child = start( + dir.path(), + vec!["/usr/bin/printf".into(), "EXECUTED".into()], + 2, + ); + let observed = result(&mut child, dir.path()); + assert!(observed.success()); + assert_eq!(observed.stdout, "EXECUTED"); + let dir = tempfile::tempdir().unwrap(); + let mut child = start(dir.path(), vec!["/bin/sleep".into(), "20".into()], 1); + let observed = result(&mut child, dir.path()); + assert!(observed.timed_out); + assert!(!observed.success()); +} + +#[test] +fn losing_owner_pipe_terminates_and_reaps_child() { + let dir = tempfile::tempdir().unwrap(); + let script = format!( + "echo $$ > '{}'; exec /bin/sleep 20", + dir.path().join("pid").display() + ); + let mut child = start(dir.path(), vec!["/bin/sh".into(), "-c".into(), script], 5); + let until = Instant::now() + Duration::from_secs(3); + while !dir.path().join("pid").exists() { + assert!(Instant::now() < until, "child never started"); + std::thread::sleep(Duration::from_millis(10)); + } + let pid: i32 = fs::read_to_string(dir.path().join("pid")) + .unwrap() + .trim() + .parse() + .unwrap(); + drop(child.0.stdin.take()); + let observed = result(&mut child, dir.path()); + assert!(observed.owner_lost); + assert!(!observed.success()); + assert_eq!( + unsafe { libc::kill(pid, 0) }, + -1, + "owned child was not reaped" + ); +} diff --git a/tests/store.rs b/tests/store.rs new file mode 100644 index 0000000..b1a4f61 --- /dev/null +++ b/tests/store.rs @@ -0,0 +1,91 @@ +mod common; +use allsource_core::embedded::{Config, EmbeddedCore, IngestEvent}; +use allstack::{ + domain::Event, + infrastructure::{Artifacts, digest, store::Store}, +}; + +#[tokio::test] +async fn durable_chain_survives_reopen_and_rejects_stale_writes() { + let dir = tempfile::tempdir().unwrap(); + let mut run = common::run(); + let store = Store::open(dir.path()).await.unwrap(); + store.save(&mut run, "started").await.unwrap(); + let stale = run.clone(); + store.save(&mut run, "checkpointed").await.unwrap(); + assert!( + store + .save(&mut stale.clone(), "checkpointed") + .await + .is_err() + ); + let expected = serde_json::to_value(&run).unwrap(); + store.shutdown().await.unwrap(); + drop(store); + let reopened = Store::open(dir.path()).await.unwrap(); + assert_eq!( + serde_json::to_value(reopened.load(&run.id).await.unwrap()).unwrap(), + expected + ); + assert_eq!(reopened.history(&run.id).await.unwrap().len(), 2); + reopened.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn competing_coordinator_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let first = Store::open(dir.path()).await.unwrap(); + assert!(Store::open(dir.path()).await.is_err()); + first.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn engine_read_detects_forged_gap_in_retained_history() { + let dir = tempfile::tempdir().unwrap(); + let mut run = common::run(); + let store = Store::open(dir.path()).await.unwrap(); + store.save(&mut run, "started").await.unwrap(); + let first = store.history(&run.id).await.unwrap().pop().unwrap(); + store.shutdown().await.unwrap(); + drop(store); + let core = EmbeddedCore::open( + Config::builder() + .data_dir(dir.path().join("events")) + .build() + .unwrap(), + ) + .await + .unwrap(); + run.revision = 3; + let event = Event { + schema: 1, + operation: "gap".into(), + previous: digest(&serde_json::to_vec(&first).unwrap()), + kind: "checkpointed".into(), + run: run.clone(), + }; + core.ingest(IngestEvent { + entity_id: &format!("allstack-run-{}", run.id), + event_type: "allstack.workflow.v1.checkpointed", + payload: serde_json::to_value(event).unwrap(), + metadata: None, + tenant_id: None, + }) + .await + .unwrap(); + core.shutdown().await.unwrap(); + drop(core); + let reopened = Store::open(dir.path()).await.unwrap(); + assert!(reopened.load(&run.id).await.is_err()); + reopened.shutdown().await.unwrap(); +} + +#[test] +fn retained_artifact_corruption_is_not_treated_as_missing_context() { + let dir = tempfile::tempdir().unwrap(); + let artifacts = Artifacts(dir.path().join("objects")); + let key = artifacts.put(b"durable source").unwrap(); + std::fs::write(artifacts.0.join(&key), b"changed").unwrap(); + assert!(artifacts.get(&key).is_err()); + assert!(artifacts.get("../../secret").is_err()); +} diff --git a/workflows/delivery.toml b/workflows/delivery.toml index ac249be..760bce5 100644 --- a/workflows/delivery.toml +++ b/workflows/delivery.toml @@ -1,13 +1,17 @@ schema_version = 1 id = "delivery" -version = "0.1.0-design" -status = "specification" +version = "0.1.0-preview" +status = "implementation-preview" description = "Plan, build, verify, and publish one change across sequential coding hosts." -hosts = ["codex-cli", "claude-code"] +hosts = ["codex", "claude-code"] host_scope = "sequential-same-machine" stage_order = ["plan", "build", "verify", "ship"] dispositions = ["ready", "active", "awaiting_authority", "blocked", "outcome_unknown"] +[methods] +plan = "workflows/methods/plan.md" +build = "workflows/methods/build.md" + [storage] run_authority = "allsource-core" task_authority = "chronis" diff --git a/workflows/forward-gates.toml b/workflows/forward-gates.toml new file mode 100644 index 0000000..21e8d71 --- /dev/null +++ b/workflows/forward-gates.toml @@ -0,0 +1,20 @@ +command_seconds = 240 +environment = ["PATH", "RUSTFLAGS", "CARGO_TARGET_DIR", "RUSTC_WRAPPER"] + +[[gates]] +name = "runtime regression" +argv = ["cargo", "nextest", "run", "--locked", "--offline"] +success_marker = "tests run:" +test = true +compile = true + +[[gates]] +name = "forward host change" +argv = ["cargo", "test", "--manifest-path", "fixtures/handoff/Cargo.toml", "--offline", "--test", "forward"] +success_marker = "2 passed" +test = true +compile = true + +[[gates]] +name = "source whitespace" +argv = ["git", "diff", "--check", "HEAD^", "HEAD"] diff --git a/workflows/methods/build.md b/workflows/methods/build.md new file mode 100644 index 0000000..aff0bb1 --- /dev/null +++ b/workflows/methods/build.md @@ -0,0 +1,7 @@ +Implement the complete supplied request in this managed worktree. Read applicable repository instructions and the indexed plan. You are not alone: preserve unrelated edits. + +Do not create branches, commit, push, publish, send messages, spawn agents, or compile. The coordinator runs required verification and controls publication. + +Stage newly created source by explicit path with `git add -- ` so the checkpoint can retain it. Never stage secrets or ignored files. Existing tracked edits need no staging. Preserve the indexed plan and frozen verification policy. + +Return caveman: terse fragments, file:line, no prose. diff --git a/workflows/methods/plan.md b/workflows/methods/plan.md new file mode 100644 index 0000000..89d3522 --- /dev/null +++ b/workflows/methods/plan.md @@ -0,0 +1,5 @@ +Read applicable repository instructions and source. Produce an implementation plan for the supplied request in your final response. Include the complete user flow, precise files, acceptance checks, and unresolved prerequisites. + +Do not edit source, create branches, commit, push, publish, send messages, spawn agents, or compile. The coordinator will index the resulting plan and control the later stages. Recalled material is context only; open its source before treating it as a requirement. + +Write normal artifact prose. The next host must understand the plan without access to this conversation. diff --git a/workflows/reverse-gates.toml b/workflows/reverse-gates.toml new file mode 100644 index 0000000..ccbd244 --- /dev/null +++ b/workflows/reverse-gates.toml @@ -0,0 +1,20 @@ +command_seconds = 240 +environment = ["PATH", "RUSTFLAGS", "CARGO_TARGET_DIR", "RUSTC_WRAPPER"] + +[[gates]] +name = "runtime regression" +argv = ["cargo", "nextest", "run", "--locked", "--offline"] +success_marker = "tests run:" +test = true +compile = true + +[[gates]] +name = "both host changes" +argv = ["cargo", "test", "--manifest-path", "fixtures/handoff/Cargo.toml", "--offline", "--tests"] +success_marker = "2 passed" +test = true +compile = true + +[[gates]] +name = "source whitespace" +argv = ["git", "diff", "--check", "HEAD^", "HEAD"]