From 5b5e7c6d7b37a1f122bd050f768b0d560d7a4094 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 17:43:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(wave15):=20kei-fork=20=E2=80=94=20managed?= =?UTF-8?q?=20git-worktree=20+=20ledger=20lifecycle=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 45 crates, 726 tests green (up from 713). Closes the ad-hoc `cp files from worktree` workflow that lost data when Claude Code auto-cleaned worktrees mid-session. After this crate ships, orchestrator never touches `git worktree` or manual `cp` again. ## Public API - `create(agent_id, base, kit_root)` → ForkHandle + ledger row - `collect(agent_id, msg, kit_root)` → commit + merge --no-ff + archive - `list(kit_root, status_filter)` → Active/Done/Stale/Merged enumeration - `gc(kit_root, hours)` → prune stale forks (git + branch + ledger fail) - `rescue(agent_id, kit_root, out)` → salvage files live or from archive ## Key design decisions - Worktrees indexed by agent_id (`.claude/forks//`), NOT uuid — grepable, no more "which worktree has my files" confusion. - `.DONE` marker gates collect — agent signals completion explicitly. - Archive path `_archive/forks/YYYY-MM-DD//` preserves history. - `KEI_FORK_SKIP_LEDGER=1` env for hermetic tests. - Constructor Pattern: 10 modules, largest file main.rs 137 LOC. 13 hermetic integration tests via tempfile + git-init kit_roots. Next: wire kei-fork into kei-spawn for the three-role pipeline (Writer → Auditor → Merger with branch-as-sandbox). Co-Authored-By: Claude Opus 4.7 (1M context) --- _primitives/_rust/Cargo.lock | 15 + _primitives/_rust/Cargo.toml | 2 + _primitives/_rust/kei-fork/Cargo.lock | 1178 +++++++++++++++++ _primitives/_rust/kei-fork/Cargo.toml | 27 + _primitives/_rust/kei-fork/src/collect.rs | 118 ++ _primitives/_rust/kei-fork/src/create.rs | 89 ++ _primitives/_rust/kei-fork/src/error.rs | 37 + _primitives/_rust/kei-fork/src/gc.rs | 87 ++ _primitives/_rust/kei-fork/src/git.rs | 96 ++ _primitives/_rust/kei-fork/src/handle.rs | 42 + _primitives/_rust/kei-fork/src/lib.rs | 30 + _primitives/_rust/kei-fork/src/list.rs | 114 ++ _primitives/_rust/kei-fork/src/main.rs | 137 ++ _primitives/_rust/kei-fork/src/meta.rs | 49 + _primitives/_rust/kei-fork/src/rescue.rs | 54 + .../_rust/kei-fork/tests/fork_integration.rs | 234 ++++ 16 files changed, 2309 insertions(+) create mode 100644 _primitives/_rust/kei-fork/Cargo.lock create mode 100644 _primitives/_rust/kei-fork/Cargo.toml create mode 100644 _primitives/_rust/kei-fork/src/collect.rs create mode 100644 _primitives/_rust/kei-fork/src/create.rs create mode 100644 _primitives/_rust/kei-fork/src/error.rs create mode 100644 _primitives/_rust/kei-fork/src/gc.rs create mode 100644 _primitives/_rust/kei-fork/src/git.rs create mode 100644 _primitives/_rust/kei-fork/src/handle.rs create mode 100644 _primitives/_rust/kei-fork/src/lib.rs create mode 100644 _primitives/_rust/kei-fork/src/list.rs create mode 100644 _primitives/_rust/kei-fork/src/main.rs create mode 100644 _primitives/_rust/kei-fork/src/meta.rs create mode 100644 _primitives/_rust/kei-fork/src/rescue.rs create mode 100644 _primitives/_rust/kei-fork/tests/fork_integration.rs diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index e65587b..4c80137 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -2559,6 +2559,21 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "kei-fork" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "kei-agent-runtime", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "toml", +] + [[package]] name = "kei-graph-check" version = "0.1.0" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index ff2a317..31cfd7f 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -67,6 +67,8 @@ members = [ "kei-hibernate", # v0.30 Wave 14 — ed25519 creator attestation "kei-ledger-sign", + # v0.31 Wave 15 — managed git worktree + ledger lifecycle (fork/collect/gc/rescue) + "kei-fork", ] [workspace.package] diff --git a/_primitives/_rust/kei-fork/Cargo.lock b/_primitives/_rust/kei-fork/Cargo.lock new file mode 100644 index 0000000..b57f49d --- /dev/null +++ b/_primitives/_rust/kei-fork/Cargo.lock @@ -0,0 +1,1178 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "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 = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "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 = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[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 = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kei-agent-runtime" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "once_cell", + "rand", + "regex", + "serde", + "serde_json", + "sha2", + "thiserror", + "toml", + "walkdir", +] + +[[package]] +name = "kei-fork" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "kei-agent-runtime", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "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", +] + +[[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", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/_primitives/_rust/kei-fork/Cargo.toml b/_primitives/_rust/kei-fork/Cargo.toml new file mode 100644 index 0000000..82e223c --- /dev/null +++ b/_primitives/_rust/kei-fork/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "kei-fork" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Managed git-worktree + ledger lifecycle for agent spawns (Wave 15 foundation)" + +[[bin]] +name = "kei-fork" +path = "src/main.rs" + +[lib] +name = "kei_fork" +path = "src/lib.rs" + +[dependencies] +kei-agent-runtime = { path = "../kei-agent-runtime" } +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +thiserror = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-fork/src/collect.rs b/_primitives/_rust/kei-fork/src/collect.rs new file mode 100644 index 0000000..ff3a99c --- /dev/null +++ b/_primitives/_rust/kei-fork/src/collect.rs @@ -0,0 +1,118 @@ +//! `collect(agent_id, commit_msg, kit_root)` — merge the fork back. +//! +//! Contract: +//! 1. `.DONE` must exist inside the worktree, else `Error::NotDone` +//! 2. `git add -A && git commit` inside the worktree +//! 3. Capture commit SHA, then `git merge --no-ff fork/` in kit_root +//! 4. Move worktree to `_archive/forks/YYYY-MM-DD//` (preserving +//! the agent's artefacts for post-hoc review / rescue) +//! 5. `git worktree prune && git branch -D fork/` to clean up refs +//! 6. `kei-ledger done ` unless `KEI_FORK_SKIP_LEDGER=1` +//! +//! On SUCCESS: `.claude/forks//` is gone, archive exists, merge +//! commit is on HEAD of kit_root. Return value carries the SHA and +//! count of files added by the agent. + +use crate::error::Error; +use crate::git; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectReport { + pub files_added: usize, + pub commit_sha: String, + pub archive_path: PathBuf, +} + +pub fn collect(agent_id: &str, commit_msg: &str, kit_root: &Path) -> Result { + let worktree_abs = kit_root.join(".claude/forks").join(agent_id); + if !worktree_abs.join(".DONE").exists() { + return Err(Error::NotDone(agent_id.to_string())); + } + let files_added = count_tracked_files(&worktree_abs); + + let branch = format!("fork/{agent_id}"); + git::add_all(&worktree_abs)?; + git::commit(&worktree_abs, commit_msg)?; + let commit_sha = git::rev_parse_head(&worktree_abs)?; + + let merge_msg = format!("Merge {branch}"); + git::merge_no_ff(kit_root, &branch, &merge_msg)?; + + let archive_path = archive_worktree(kit_root, agent_id, &worktree_abs)?; + + // worktree_remove is unnecessary after fs::rename — prune cleans the + // stale worktree metadata and branch -D removes the ref. + let _ = git::worktree_prune(kit_root); + let _ = git::branch_delete(kit_root, &branch); + + ledger_done(agent_id)?; + + Ok(CollectReport { + files_added, + commit_sha, + archive_path, + }) +} + +fn count_tracked_files(worktree_abs: &Path) -> usize { + // Cheap approximation — walk the worktree, skip `.git*` and the + // KEI_FORK meta file. Used for the report only, not for decisions. + fn walk(dir: &Path) -> usize { + let mut n = 0; + let Ok(rd) = fs::read_dir(dir) else { return 0 }; + for e in rd.flatten() { + let p = e.path(); + let name = e.file_name(); + let s = name.to_string_lossy(); + if s.starts_with(".git") { + continue; + } + if p.is_dir() { + n += walk(&p); + } else { + n += 1; + } + } + n + } + walk(worktree_abs) +} + +fn archive_worktree( + kit_root: &Path, + agent_id: &str, + worktree_abs: &Path, +) -> Result { + let date = Utc::now().format("%Y-%m-%d").to_string(); + let archive_dir = kit_root.join("_archive/forks").join(&date); + fs::create_dir_all(&archive_dir)?; + let target = archive_dir.join(agent_id); + if target.exists() { + fs::remove_dir_all(&target)?; + } + fs::rename(worktree_abs, &target)?; + Ok(target) +} + +fn ledger_skipped() -> bool { + std::env::var("KEI_FORK_SKIP_LEDGER").ok().as_deref() == Some("1") +} + +fn ledger_done(agent_id: &str) -> Result<(), Error> { + if ledger_skipped() { + return Ok(()); + } + let status = Command::new("kei-ledger") + .args(["done", agent_id, "--summary", "fork collected"]) + .status(); + match status { + Ok(s) if s.success() => Ok(()), + Ok(s) => Err(Error::Ledger(format!("kei-ledger done exit {s}"))), + Err(e) => Err(Error::Ledger(format!("kei-ledger not runnable: {e}"))), + } +} diff --git a/_primitives/_rust/kei-fork/src/create.rs b/_primitives/_rust/kei-fork/src/create.rs new file mode 100644 index 0000000..b1cc286 --- /dev/null +++ b/_primitives/_rust/kei-fork/src/create.rs @@ -0,0 +1,89 @@ +//! `create(agent_id, base_branch, kit_root)` — spawn a managed fork. +//! +//! Steps: +//! 1. `validate_agent_id` (path-traversal defence) +//! 2. Reject if `.claude/forks//` OR branch `fork/` already exist +//! 3. `git worktree add .claude/forks/ -b fork/ ` +//! 4. Write `.KEI_FORK_META.toml` with agent_id + started_ts + base_branch + ledger_id +//! 5. `kei-ledger fork` unless env `KEI_FORK_SKIP_LEDGER=1` +//! +//! Worktree path is indexed by `agent_id`, not UUID, so `rescue()` / +//! `collect()` can be resolved from a human-readable CLI arg. + +use crate::error::Error; +use crate::git; +use crate::handle::ForkHandle; +use crate::meta::{write_meta, ForkMeta}; +use kei_agent_runtime::validate::validate_agent_id; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn create(agent_id: &str, base_branch: &str, kit_root: &Path) -> Result { + validate_agent_id(agent_id).map_err(|e| Error::Validate(e.reason))?; + let worktree_rel = PathBuf::from(".claude/forks").join(agent_id); + let worktree_abs = kit_root.join(&worktree_rel); + let branch = format!("fork/{agent_id}"); + if worktree_abs.exists() || git::branch_exists(kit_root, &branch) { + return Err(Error::Duplicate(agent_id.to_string())); + } + if let Some(parent) = worktree_abs.parent() { + fs::create_dir_all(parent)?; + } + git::worktree_add(kit_root, &worktree_rel, &branch, base_branch)?; + let started_ts = unix_now(); + let meta = build_meta(agent_id, base_branch, started_ts); + write_meta(&worktree_abs, &meta)?; + ledger_fork(agent_id, &branch, base_branch)?; + Ok(ForkHandle { + agent_id: agent_id.to_string(), + worktree: worktree_abs, + branch, + ledger_id: meta.ledger_id, + started_ts, + }) +} + +fn build_meta(agent_id: &str, base_branch: &str, started_ts: i64) -> ForkMeta { + ForkMeta { + agent_id: agent_id.to_string(), + started_ts, + base_branch: base_branch.to_string(), + ledger_id: agent_id.to_string(), + } +} + +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn ledger_skipped() -> bool { + std::env::var("KEI_FORK_SKIP_LEDGER").ok().as_deref() == Some("1") +} + +fn ledger_fork(agent_id: &str, branch: &str, base: &str) -> Result<(), Error> { + if ledger_skipped() { + return Ok(()); + } + // Best-effort spec_sha placeholder: caller stamps real sha post-commit. + let status = Command::new("kei-ledger") + .args([ + "fork", + agent_id, + branch, + "--parent", + base, + "--spec-sha", + "pending", + ]) + .status(); + match status { + Ok(s) if s.success() => Ok(()), + Ok(s) => Err(Error::Ledger(format!("kei-ledger fork exit {s}"))), + Err(e) => Err(Error::Ledger(format!("kei-ledger not runnable: {e}"))), + } +} diff --git a/_primitives/_rust/kei-fork/src/error.rs b/_primitives/_rust/kei-fork/src/error.rs new file mode 100644 index 0000000..0127495 --- /dev/null +++ b/_primitives/_rust/kei-fork/src/error.rs @@ -0,0 +1,37 @@ +//! Typed error — every kei-fork public op returns `Result<_, Error>`. +//! +//! Categories: +//! - `Validate` — agent-id failed `kei_agent_runtime::validate` +//! - `Duplicate` — worktree/branch for this agent-id already exists +//! - `NotDone` — collect() called before the agent wrote `.DONE` +//! - `Gone` — rescue() could not find the worktree (live or archived) +//! - `Io` / `Git` / `Ledger` / `Meta` — subsystem failures + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid agent-id: {0}")] + Validate(String), + + #[error("fork already exists for agent-id '{0}'")] + Duplicate(String), + + #[error(".DONE marker missing for agent-id '{0}' (agent not finished)")] + NotDone(String), + + #[error("no live or archived worktree found for agent-id '{0}'")] + Gone(String), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("git command failed ({cmd}): {stderr}")] + Git { cmd: String, stderr: String }, + + #[error("ledger command failed: {0}")] + Ledger(String), + + #[error("meta file malformed: {0}")] + Meta(String), +} diff --git a/_primitives/_rust/kei-fork/src/gc.rs b/_primitives/_rust/kei-fork/src/gc.rs new file mode 100644 index 0000000..d11fae0 --- /dev/null +++ b/_primitives/_rust/kei-fork/src/gc.rs @@ -0,0 +1,87 @@ +//! `gc(kit_root, older_than_hours)` — prune stale forks. +//! +//! A fork is STALE when `.DONE` is absent AND `age > older_than_hours`. +//! For each stale fork we: +//! 1. `git worktree remove --force ` +//! 2. `git branch -D fork/` +//! 3. `kei-ledger fail ` unless `KEI_FORK_SKIP_LEDGER=1` +//! +//! Returns the list of agent_ids pruned. Errors on individual forks are +//! swallowed into the report so a single bad fork cannot block cleanup +//! of the rest. + +use crate::error::Error; +use crate::git; +use crate::handle::ForkStatus; +use crate::list::live_with_status; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GcReport { + pub pruned: Vec, + pub skipped: Vec, +} + +pub fn gc(kit_root: &Path, older_than_hours: u32) -> Result { + let mut report = GcReport::default(); + for (worktree_abs, handle, status) in live_with_status(kit_root) { + if !is_prunable(status, handle.started_ts, older_than_hours) { + continue; + } + match prune_one(kit_root, &worktree_abs, &handle.branch, &handle.agent_id) { + Ok(()) => report.pruned.push(handle.agent_id), + Err(_) => report.skipped.push(handle.agent_id), + } + } + Ok(report) +} + +fn is_prunable(status: ForkStatus, started_ts: i64, threshold_h: u32) -> bool { + if status != ForkStatus::Stale && status != ForkStatus::Active { + return false; + } + let age = age_hours(started_ts); + age >= threshold_h +} + +fn age_hours(started_ts: i64) -> u32 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(started_ts); + let delta = (now - started_ts).max(0); + (delta / 3600) as u32 +} + +fn prune_one( + kit_root: &Path, + worktree_abs: &Path, + branch: &str, + agent_id: &str, +) -> Result<(), Error> { + git::worktree_remove_force(kit_root, worktree_abs)?; + let _ = git::branch_delete(kit_root, branch); + let _ = ledger_fail(agent_id); + Ok(()) +} + +fn ledger_skipped() -> bool { + std::env::var("KEI_FORK_SKIP_LEDGER").ok().as_deref() == Some("1") +} + +fn ledger_fail(agent_id: &str) -> Result<(), Error> { + if ledger_skipped() { + return Ok(()); + } + let status = Command::new("kei-ledger") + .args(["fail", agent_id, "--reason", "gc: stale fork"]) + .status(); + match status { + Ok(s) if s.success() => Ok(()), + Ok(s) => Err(Error::Ledger(format!("kei-ledger fail exit {s}"))), + Err(e) => Err(Error::Ledger(format!("kei-ledger not runnable: {e}"))), + } +} diff --git a/_primitives/_rust/kei-fork/src/git.rs b/_primitives/_rust/kei-fork/src/git.rs new file mode 100644 index 0000000..679d849 --- /dev/null +++ b/_primitives/_rust/kei-fork/src/git.rs @@ -0,0 +1,96 @@ +//! Thin `Command::new("git")` wrappers. +//! +//! Every helper runs `git` in `kit_root` (or a specified worktree), +//! captures stdout/stderr, and returns `Error::Git` on non-zero exit. +//! No parsing beyond `trim()` on stdout — callers interpret the string. + +use crate::error::Error; +use std::path::Path; +use std::process::{Command, Output}; + +fn run(cmd_desc: &str, c: &mut Command) -> Result { + let out = c.output().map_err(Error::Io)?; + if !out.status.success() { + return Err(Error::Git { + cmd: cmd_desc.to_string(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }); + } + Ok(out) +} + +pub fn worktree_add( + kit_root: &Path, + worktree_rel: &Path, + new_branch: &str, + base: &str, +) -> Result<(), Error> { + let mut c = Command::new("git"); + c.current_dir(kit_root) + .args(["worktree", "add", worktree_rel.to_str().unwrap_or("."), "-b", new_branch, base]); + run("git worktree add", &mut c)?; + Ok(()) +} + +pub fn add_all(cwd: &Path) -> Result<(), Error> { + let mut c = Command::new("git"); + c.current_dir(cwd).args(["add", "-A"]); + run("git add -A", &mut c)?; + Ok(()) +} + +pub fn commit(cwd: &Path, msg: &str) -> Result<(), Error> { + let mut c = Command::new("git"); + c.current_dir(cwd).args(["commit", "--allow-empty", "-m", msg]); + run("git commit", &mut c)?; + Ok(()) +} + +pub fn rev_parse_head(cwd: &Path) -> Result { + let mut c = Command::new("git"); + c.current_dir(cwd).args(["rev-parse", "HEAD"]); + let out = run("git rev-parse HEAD", &mut c)?; + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +pub fn merge_no_ff(kit_root: &Path, branch: &str, msg: &str) -> Result<(), Error> { + let mut c = Command::new("git"); + c.current_dir(kit_root).args(["merge", "--no-ff", branch, "-m", msg]); + run("git merge --no-ff", &mut c)?; + Ok(()) +} + +pub fn worktree_prune(kit_root: &Path) -> Result<(), Error> { + let mut c = Command::new("git"); + c.current_dir(kit_root).args(["worktree", "prune"]); + run("git worktree prune", &mut c)?; + Ok(()) +} + +pub fn worktree_remove_force(kit_root: &Path, worktree_abs: &Path) -> Result<(), Error> { + let mut c = Command::new("git"); + c.current_dir(kit_root).args([ + "worktree", + "remove", + "--force", + worktree_abs.to_str().unwrap_or("."), + ]); + run("git worktree remove --force", &mut c)?; + Ok(()) +} + +pub fn branch_delete(kit_root: &Path, branch: &str) -> Result<(), Error> { + let mut c = Command::new("git"); + c.current_dir(kit_root).args(["branch", "-D", branch]); + run("git branch -D", &mut c)?; + Ok(()) +} + +/// Check whether `branch` exists. `git show-ref` exits 0 if the ref is +/// present, non-zero otherwise — we treat both as valid data, no error. +pub fn branch_exists(kit_root: &Path, branch: &str) -> bool { + let full = format!("refs/heads/{branch}"); + let mut c = Command::new("git"); + c.current_dir(kit_root).args(["show-ref", "--verify", "--quiet", &full]); + c.status().map(|s| s.success()).unwrap_or(false) +} diff --git a/_primitives/_rust/kei-fork/src/handle.rs b/_primitives/_rust/kei-fork/src/handle.rs new file mode 100644 index 0000000..598d64c --- /dev/null +++ b/_primitives/_rust/kei-fork/src/handle.rs @@ -0,0 +1,42 @@ +//! `ForkHandle` value type + `ForkStatus` enum. +//! +//! `ForkHandle` is the return of `create()` and each row of `list()`. Its +//! fields are derived from `.KEI_FORK_META.toml` plus the worktree path +//! on disk. The handle is `Clone`, `serde::Serialize`, and +//! `serde::Deserialize` so the CLI can emit JSON and downstream callers +//! can round-trip it without touching the TOML file. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ForkHandle { + pub agent_id: String, + pub worktree: PathBuf, + pub branch: String, + pub ledger_id: String, + pub started_ts: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ForkStatus { + Active, + Done, + Stale, + Merged, +} + +impl ForkStatus { + /// Parse CLI `--status` value. Returns `None` for unknown strings so + /// the CLI layer can emit a domain-appropriate error. + pub fn from_cli(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "active" => Some(ForkStatus::Active), + "done" => Some(ForkStatus::Done), + "stale" => Some(ForkStatus::Stale), + "merged" => Some(ForkStatus::Merged), + _ => None, + } + } +} diff --git a/_primitives/_rust/kei-fork/src/lib.rs b/_primitives/_rust/kei-fork/src/lib.rs new file mode 100644 index 0000000..abb927f --- /dev/null +++ b/_primitives/_rust/kei-fork/src/lib.rs @@ -0,0 +1,30 @@ +//! kei-fork — managed git-worktree + ledger lifecycle for agent spawns. +//! +//! Public API: `create`, `collect`, `list`, `gc`, `rescue`. Each op is +//! backed by one module under `src/`, keeping every file ≤200 LOC and +//! every function ≤30 LOC (Constructor Pattern). Shell-out helpers for +//! `git` live in `git.rs`; TOML round-trip for `.KEI_FORK_META.toml` +//! lives in `meta.rs`; the `ForkHandle` value type and the +//! `ForkStatus` enum live in `handle.rs`. +//! +//! Ledger integration is optional at runtime: if env +//! `KEI_FORK_SKIP_LEDGER=1` is set, create/collect/gc skip the +//! `kei-ledger` subprocess call. Tests rely on this for hermeticity. + +pub mod collect; +pub mod create; +pub mod error; +pub mod gc; +pub mod git; +pub mod handle; +pub mod list; +pub mod meta; +pub mod rescue; + +pub use collect::{collect, CollectReport}; +pub use create::create; +pub use error::Error; +pub use gc::{gc, GcReport}; +pub use handle::{ForkHandle, ForkStatus}; +pub use list::list; +pub use rescue::rescue; diff --git a/_primitives/_rust/kei-fork/src/list.rs b/_primitives/_rust/kei-fork/src/list.rs new file mode 100644 index 0000000..442be9c --- /dev/null +++ b/_primitives/_rust/kei-fork/src/list.rs @@ -0,0 +1,114 @@ +//! `list(kit_root, status_filter)` — enumerate known forks. +//! +//! Walks two roots: +//! - `.claude/forks//` — live worktrees (Active, Done, Stale) +//! - `_archive/forks///` — post-collect (Merged) +//! +//! For each discovered directory, reads `.KEI_FORK_META.toml` to build +//! a `ForkHandle`, classifies status, and filters. Returns `Vec` sorted +//! by `started_ts` ascending so oldest forks list first. + +use crate::error::Error; +use crate::handle::{ForkHandle, ForkStatus}; +use crate::meta::read_meta; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const STALE_HOURS_DEFAULT: u32 = 24; + +pub fn list(kit_root: &Path, status: Option) -> Result, Error> { + let mut out = Vec::new(); + collect_live(&kit_root.join(".claude/forks"), &mut out, status); + collect_archive(&kit_root.join("_archive/forks"), &mut out, status); + out.sort_by_key(|h| h.started_ts); + Ok(out) +} + +fn collect_live(root: &Path, out: &mut Vec, filter: Option) { + let Ok(rd) = fs::read_dir(root) else { return }; + for e in rd.flatten() { + let p = e.path(); + if !p.is_dir() { + continue; + } + let Ok(meta) = read_meta(&p) else { continue }; + let status = classify_live(&p, meta.started_ts); + if matches_filter(filter, status) { + out.push(meta.into_handle(p)); + } + } +} + +fn collect_archive(root: &Path, out: &mut Vec, filter: Option) { + let Ok(dates) = fs::read_dir(root) else { return }; + for date_entry in dates.flatten() { + let date_dir = date_entry.path(); + if !date_dir.is_dir() { + continue; + } + scan_date_dir(&date_dir, out, filter); + } +} + +fn scan_date_dir(date_dir: &Path, out: &mut Vec, filter: Option) { + let Ok(rd) = fs::read_dir(date_dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if !p.is_dir() { + continue; + } + let Ok(meta) = read_meta(&p) else { continue }; + let status = ForkStatus::Merged; + if matches_filter(filter, status) { + out.push(meta.into_handle(p)); + } + } +} + +fn classify_live(worktree: &Path, started_ts: i64) -> ForkStatus { + if worktree.join(".DONE").exists() { + return ForkStatus::Done; + } + let age_h = age_hours(started_ts); + if age_h >= STALE_HOURS_DEFAULT { + ForkStatus::Stale + } else { + ForkStatus::Active + } +} + +fn age_hours(started_ts: i64) -> u32 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(started_ts); + let delta = (now - started_ts).max(0); + (delta / 3600) as u32 +} + +fn matches_filter(filter: Option, s: ForkStatus) -> bool { + match filter { + None => true, + Some(want) => want == s, + } +} + +/// Helper reused by `gc` — enumerate live worktrees with their +/// classified status, without filter. +pub(crate) fn live_with_status(kit_root: &Path) -> Vec<(PathBuf, ForkHandle, ForkStatus)> { + let mut out = Vec::new(); + let root = kit_root.join(".claude/forks"); + let Ok(rd) = fs::read_dir(&root) else { return out }; + for e in rd.flatten() { + let p = e.path(); + if !p.is_dir() { + continue; + } + let Ok(meta) = read_meta(&p) else { continue }; + let status = classify_live(&p, meta.started_ts); + let handle = meta.into_handle(p.clone()); + out.push((p, handle, status)); + } + out +} diff --git a/_primitives/_rust/kei-fork/src/main.rs b/_primitives/_rust/kei-fork/src/main.rs new file mode 100644 index 0000000..0de14f7 --- /dev/null +++ b/_primitives/_rust/kei-fork/src/main.rs @@ -0,0 +1,137 @@ +//! kei-fork — CLI dispatcher. +//! +//! Single responsibility: parse args, dispatch to lib ops, print JSON. +//! Default `kit_root = std::env::current_dir()`. + +use clap::{Parser, Subcommand}; +use kei_fork::{collect, create, gc, list, rescue, ForkStatus}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-fork", version, about = "Managed git-worktree + ledger lifecycle")] +struct Cli { + /// Override kit_root (default: current dir). + #[arg(long)] + kit_root: Option, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Spawn a new managed fork. + Create { + #[arg(long)] + agent_id: String, + #[arg(long, default_value = "main")] + base: String, + }, + /// Collect a done fork: commit, merge --no-ff, archive. + Collect { + #[arg(long)] + agent_id: String, + #[arg(long)] + msg: String, + }, + /// List forks, optionally filtered by status. + List { + /// active | done | stale | merged | all + #[arg(long, default_value = "all")] + status: String, + }, + /// Prune stale forks (no .DONE and age ≥ --older-than hours). + Gc { + #[arg(long, default_value_t = 24)] + older_than: u32, + }, + /// Copy a fork's files out of band. + Rescue { + #[arg(long)] + agent_id: String, + #[arg(long)] + out: PathBuf, + }, +} + +fn resolve_kit_root(arg: Option) -> PathBuf { + arg.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) +} + +fn err(msg: &str) -> ExitCode { + eprintln!("kei-fork: {msg}"); + ExitCode::from(1) +} + +fn parse_status_filter(raw: &str) -> Result, String> { + if raw.eq_ignore_ascii_case("all") { + return Ok(None); + } + ForkStatus::from_cli(raw) + .map(Some) + .ok_or_else(|| format!("unknown status '{raw}'")) +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let kit_root = resolve_kit_root(cli.kit_root); + match cli.cmd { + Cmd::Create { agent_id, base } => run_create(&agent_id, &base, &kit_root), + Cmd::Collect { agent_id, msg } => run_collect(&agent_id, &msg, &kit_root), + Cmd::List { status } => run_list(&status, &kit_root), + Cmd::Gc { older_than } => run_gc(older_than, &kit_root), + Cmd::Rescue { agent_id, out } => run_rescue(&agent_id, &kit_root, &out), + } +} + +fn run_create(agent_id: &str, base: &str, kit_root: &std::path::Path) -> ExitCode { + match create(agent_id, base, kit_root) { + Ok(h) => print_json(&h), + Err(e) => err(&e.to_string()), + } +} + +fn run_collect(agent_id: &str, msg: &str, kit_root: &std::path::Path) -> ExitCode { + match collect(agent_id, msg, kit_root) { + Ok(r) => print_json(&r), + Err(e) => err(&e.to_string()), + } +} + +fn run_list(status: &str, kit_root: &std::path::Path) -> ExitCode { + let filter = match parse_status_filter(status) { + Ok(f) => f, + Err(e) => return err(&e), + }; + match list(kit_root, filter) { + Ok(rows) => print_json(&rows), + Err(e) => err(&e.to_string()), + } +} + +fn run_gc(older_than: u32, kit_root: &std::path::Path) -> ExitCode { + match gc(kit_root, older_than) { + Ok(r) => print_json(&r), + Err(e) => err(&e.to_string()), + } +} + +fn run_rescue(agent_id: &str, kit_root: &std::path::Path, out: &std::path::Path) -> ExitCode { + match rescue(agent_id, kit_root, out) { + Ok(n) => { + println!("{n}"); + ExitCode::SUCCESS + } + Err(e) => err(&e.to_string()), + } +} + +fn print_json(v: &T) -> ExitCode { + match serde_json::to_string_pretty(v) { + Ok(s) => { + println!("{s}"); + ExitCode::SUCCESS + } + Err(e) => err(&format!("json encode failed: {e}")), + } +} diff --git a/_primitives/_rust/kei-fork/src/meta.rs b/_primitives/_rust/kei-fork/src/meta.rs new file mode 100644 index 0000000..9695057 --- /dev/null +++ b/_primitives/_rust/kei-fork/src/meta.rs @@ -0,0 +1,49 @@ +//! `.KEI_FORK_META.toml` — on-disk metadata written once by `create()` +//! and read by `list()` / `collect()` / `rescue()` / `gc()`. +//! +//! Layout is stable: `agent_id`, `started_ts`, `base_branch`, `ledger_id`. +//! Never add fields without bumping a schema version. + +use crate::error::Error; +use crate::handle::ForkHandle; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub const META_FILENAME: &str = ".KEI_FORK_META.toml"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ForkMeta { + pub agent_id: String, + pub started_ts: i64, + pub base_branch: String, + pub ledger_id: String, +} + +impl ForkMeta { + pub fn branch(&self) -> String { + format!("fork/{}", self.agent_id) + } + + pub fn into_handle(self, worktree: PathBuf) -> ForkHandle { + let branch = self.branch(); + ForkHandle { + agent_id: self.agent_id, + worktree, + branch, + ledger_id: self.ledger_id, + started_ts: self.started_ts, + } + } +} + +pub fn write_meta(worktree: &Path, meta: &ForkMeta) -> Result<(), Error> { + let body = toml::to_string(meta).map_err(|e| Error::Meta(e.to_string()))?; + fs::write(worktree.join(META_FILENAME), body)?; + Ok(()) +} + +pub fn read_meta(worktree: &Path) -> Result { + let raw = fs::read_to_string(worktree.join(META_FILENAME))?; + toml::from_str(&raw).map_err(|e| Error::Meta(e.to_string())) +} diff --git a/_primitives/_rust/kei-fork/src/rescue.rs b/_primitives/_rust/kei-fork/src/rescue.rs new file mode 100644 index 0000000..4ed21d5 --- /dev/null +++ b/_primitives/_rust/kei-fork/src/rescue.rs @@ -0,0 +1,54 @@ +//! `rescue(agent_id, kit_root, out_dir)` — copy a fork's files out of +//! band. +//! +//! Resolution order: +//! 1. `.claude/forks//` (live) → copy to `out_dir` +//! 2. `_archive/forks///` (archived) → copy to `out_dir` +//! 3. Neither → `Error::Gone` +//! +//! Copy is recursive; the destination may pre-exist (we merge on top). +//! Returns the number of regular files copied. + +use crate::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn rescue(agent_id: &str, kit_root: &Path, out_dir: &Path) -> Result { + let src = locate(agent_id, kit_root).ok_or_else(|| Error::Gone(agent_id.to_string()))?; + fs::create_dir_all(out_dir)?; + Ok(copy_tree(&src, out_dir)?) +} + +fn locate(agent_id: &str, kit_root: &Path) -> Option { + let live = kit_root.join(".claude/forks").join(agent_id); + if live.is_dir() { + return Some(live); + } + let archive_root = kit_root.join("_archive/forks"); + let dates = fs::read_dir(&archive_root).ok()?; + for e in dates.flatten() { + let candidate = e.path().join(agent_id); + if candidate.is_dir() { + return Some(candidate); + } + } + None +} + +fn copy_tree(src: &Path, dst: &Path) -> std::io::Result { + let mut n = 0; + for entry in fs::read_dir(src)? { + let entry = entry?; + let name = entry.file_name(); + let from = entry.path(); + let to = dst.join(&name); + if from.is_dir() { + fs::create_dir_all(&to)?; + n += copy_tree(&from, &to)?; + } else if from.is_file() { + fs::copy(&from, &to)?; + n += 1; + } + } + Ok(n) +} diff --git a/_primitives/_rust/kei-fork/tests/fork_integration.rs b/_primitives/_rust/kei-fork/tests/fork_integration.rs new file mode 100644 index 0000000..43cdd8e --- /dev/null +++ b/_primitives/_rust/kei-fork/tests/fork_integration.rs @@ -0,0 +1,234 @@ +//! Integration tests for kei-fork — hermetic, ledger skipped. +//! +//! Each test spins up a fresh `TempDir`, runs `git init` + initial +//! commit, then drives the public API. `KEI_FORK_SKIP_LEDGER=1` keeps +//! the test tree free of SQLite side-effects. +//! +//! NOTE: `KEI_FORK_SKIP_LEDGER` is process-wide. Tests set it once in +//! `setup_kit()` — do not unset mid-test. + +use kei_fork::{collect, create, gc, list, rescue, ForkStatus}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +fn setup_kit() -> (TempDir, PathBuf) { + std::env::set_var("KEI_FORK_SKIP_LEDGER", "1"); + let td = TempDir::new().expect("tempdir"); + let root = td.path().to_path_buf(); + run_git(&root, &["init", "-q", "-b", "main"]); + run_git(&root, &["config", "user.email", "t@example.com"]); + run_git(&root, &["config", "user.name", "Test"]); + run_git(&root, &["config", "commit.gpgsign", "false"]); + fs::write(root.join("README.md"), "hi").unwrap(); + run_git(&root, &["add", "README.md"]); + run_git(&root, &["commit", "-q", "-m", "init"]); + (td, root) +} + +fn run_git(cwd: &Path, args: &[&str]) { + let out = Command::new("git") + .current_dir(cwd) + .args(args) + .output() + .expect("git runnable"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +fn mark_done(worktree: &Path) { + fs::write(worktree.join(".DONE"), "").unwrap(); + // Add one real artefact so collect has something to commit. + fs::write(worktree.join("hello.txt"), "world").unwrap(); +} + +#[test] +fn create_produces_worktree_and_branch() { + let (_td, root) = setup_kit(); + let h = create("ag-one", "main", &root).expect("create ok"); + assert_eq!(h.agent_id, "ag-one"); + assert_eq!(h.branch, "fork/ag-one"); + assert!(h.worktree.exists()); + assert!(h.worktree.join(".KEI_FORK_META.toml").exists()); + let br = Command::new("git") + .current_dir(&root) + .args(["branch", "--list", "fork/ag-one"]) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&br.stdout).contains("fork/ag-one")); +} + +#[test] +fn create_rejects_invalid_agent_id() { + let (_td, root) = setup_kit(); + let err = create("../evil", "main", &root).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("invalid agent-id"), "got: {msg}"); +} + +#[test] +fn create_rejects_duplicate_agent_id() { + let (_td, root) = setup_kit(); + create("ag-dup", "main", &root).expect("first create"); + let err = create("ag-dup", "main", &root).unwrap_err(); + assert!(err.to_string().contains("already exists")); +} + +#[test] +fn create_writes_meta_toml() { + let (_td, root) = setup_kit(); + let h = create("ag-meta", "main", &root).expect("create ok"); + let raw = fs::read_to_string(h.worktree.join(".KEI_FORK_META.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&raw).unwrap(); + assert_eq!(parsed["agent_id"].as_str(), Some("ag-meta")); + assert_eq!(parsed["base_branch"].as_str(), Some("main")); + assert!(parsed["started_ts"].as_integer().unwrap() > 0); + assert_eq!(parsed["ledger_id"].as_str(), Some("ag-meta")); +} + +#[test] +fn collect_without_done_fails() { + let (_td, root) = setup_kit(); + create("ag-nodone", "main", &root).unwrap(); + let err = collect("ag-nodone", "msg", &root).unwrap_err(); + assert!(err.to_string().contains(".DONE")); +} + +#[test] +fn collect_with_done_produces_merge_commit() { + let (_td, root) = setup_kit(); + let h = create("ag-merge", "main", &root).unwrap(); + mark_done(&h.worktree); + let report = collect("ag-merge", "feat: agent work", &root).expect("collect ok"); + assert_eq!(report.commit_sha.len(), 40); + // HEAD of kit_root must be a merge commit with 2 parents. + let out = Command::new("git") + .current_dir(&root) + .args(["log", "-1", "--pretty=%P"]) + .output() + .unwrap(); + let parents: Vec<&str> = std::str::from_utf8(&out.stdout) + .unwrap() + .trim() + .split_whitespace() + .collect(); + assert_eq!(parents.len(), 2, "expected merge commit"); +} + +#[test] +fn collect_archives_worktree() { + let (_td, root) = setup_kit(); + let h = create("ag-arch", "main", &root).unwrap(); + mark_done(&h.worktree); + let report = collect("ag-arch", "msg", &root).expect("collect ok"); + assert!(report.archive_path.exists()); + assert!(report.archive_path.starts_with(root.join("_archive/forks"))); + assert!(report.archive_path.ends_with("ag-arch")); +} + +#[test] +fn collect_removes_live_worktree() { + let (_td, root) = setup_kit(); + let h = create("ag-gone", "main", &root).unwrap(); + mark_done(&h.worktree); + collect("ag-gone", "msg", &root).expect("collect ok"); + assert!(!h.worktree.exists(), "live worktree should be gone"); +} + +#[test] +fn list_filters_by_status() { + let (_td, root) = setup_kit(); + // Active + create("ag-active", "main", &root).unwrap(); + // Done (mark .DONE but do not collect) + let h_done = create("ag-done", "main", &root).unwrap(); + fs::write(h_done.worktree.join(".DONE"), "").unwrap(); + // Merged (collect one) + let h_merged = create("ag-merged", "main", &root).unwrap(); + mark_done(&h_merged.worktree); + collect("ag-merged", "msg", &root).unwrap(); + + let all = list(&root, None).unwrap(); + assert_eq!(all.len(), 3); + + let active = list(&root, Some(ForkStatus::Active)).unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].agent_id, "ag-active"); + + let done = list(&root, Some(ForkStatus::Done)).unwrap(); + assert_eq!(done.len(), 1); + assert_eq!(done[0].agent_id, "ag-done"); + + let merged = list(&root, Some(ForkStatus::Merged)).unwrap(); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].agent_id, "ag-merged"); +} + +#[test] +fn gc_prunes_stale() { + let (_td, root) = setup_kit(); + let h = create("ag-stale", "main", &root).unwrap(); + // Backdate meta.started_ts by 48h — no .DONE → STALE under 24h threshold. + let raw = fs::read_to_string(h.worktree.join(".KEI_FORK_META.toml")).unwrap(); + let mut parsed: toml::Value = toml::from_str(&raw).unwrap(); + let old_ts = parsed["started_ts"].as_integer().unwrap() - 48 * 3600; + parsed.as_table_mut().unwrap().insert( + "started_ts".to_string(), + toml::Value::Integer(old_ts), + ); + fs::write( + h.worktree.join(".KEI_FORK_META.toml"), + toml::to_string(&parsed).unwrap(), + ) + .unwrap(); + + let report = gc(&root, 24).unwrap(); + assert_eq!(report.pruned, vec!["ag-stale".to_string()]); + assert!(!h.worktree.exists()); +} + +#[test] +fn rescue_copies_live_files() { + let (_td, root) = setup_kit(); + let h = create("ag-rescue-live", "main", &root).unwrap(); + fs::write(h.worktree.join("note.txt"), "payload").unwrap(); + fs::create_dir_all(h.worktree.join("sub")).unwrap(); + fs::write(h.worktree.join("sub/nested.txt"), "deep").unwrap(); + + let out_dir = root.join("rescue-out"); + let n = rescue("ag-rescue-live", &root, &out_dir).unwrap(); + assert!(n >= 3, "expected ≥3 files, got {n}"); + assert_eq!( + fs::read_to_string(out_dir.join("note.txt")).unwrap(), + "payload" + ); + assert_eq!( + fs::read_to_string(out_dir.join("sub/nested.txt")).unwrap(), + "deep" + ); +} + +#[test] +fn rescue_extracts_archived() { + let (_td, root) = setup_kit(); + let h = create("ag-rescue-arch", "main", &root).unwrap(); + mark_done(&h.worktree); + fs::write(h.worktree.join("artefact.md"), "# hi").unwrap(); + collect("ag-rescue-arch", "msg", &root).unwrap(); + + let out_dir = root.join("rescue-out-arch"); + let n = rescue("ag-rescue-arch", &root, &out_dir).unwrap(); + assert!(n >= 1); + assert!(out_dir.join("artefact.md").exists()); +} + +#[test] +fn rescue_missing_agent_errors() { + let (_td, root) = setup_kit(); + let err = rescue("ag-nope", &root, &root.join("x")).unwrap_err(); + assert!(err.to_string().contains("no live or archived")); +}